Files
harbor-net/test/web/responsive-layout-contract.test.js
T
dokril ca0322f93a
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
Refine connectivity diagnostics service management
2026-08-07 22:27:19 +03:00

212 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '../..');
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
const diagnostics = fs.readFileSync(path.join(root, 'src/web/components/ConnectivityDiagnosticsPanel.jsx'), 'utf8');
function rule(selector, source = styles) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped}\\s*\\{([\\s\\S]*?)\\n\\}`, 'm').exec(source)?.[1] || '';
}
test('desktop layout keeps the power control on a symmetric center axis', () => {
const panel = rule('.client-panel');
const power = rule('.client-power-section');
const form = rule('.client-form');
assert.match(panel, /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(240px, 280px\) minmax\(0, 1fr\)/);
assert.match(power, /grid-column:\s*2/);
assert.match(form, /grid-column:\s*3/);
assert.match(form, /position:\s*static/);
assert.match(form, /overflow:\s*visible/);
assert.doesNotMatch(form, /\bleft\s*:|translateX|transition:[^;]*(?:left|width|transform)/);
assert.match(styles, /\.client-panel\.has-subscription \.client-form \{[\s\S]*height:\s*var\(--client-work-height\)/);
assert.match(rule('.client-form-content'), /align-content:\s*start/);
assert.match(styles, /--client-power-top:\s*calc\(\(var\(--client-work-height\) - 96px\) \/ 2\)/);
assert.match(styles, /\.client-panel\.has-subscription \{[\s\S]*transform:\s*translateY\(-9vh\)/);
assert.match(styles, /\.client-panel\.has-subscription \.client-power-section \{[\s\S]*height:\s*var\(--client-work-height\);[\s\S]*padding-top:\s*var\(--client-power-top\)/);
assert.match(styles, /\.client-panel\.has-subscription \.client-form-content \{[\s\S]*padding-top:\s*var\(--client-power-top\)/);
});
test('page reload plays one stable startup sequence', () => {
assert.match(component, /const \[showIntro, setShowIntro\] = useState\(true\)/);
assert.match(component, /\$\{showIntro \? ' is-intro' : ''\}/);
assert.doesNotMatch(component, /showIntro && !hasSubscription/);
assert.match(styles, /\.client-shell\.is-intro \.harbor-brand-content \{[\s\S]*harbor-startup-brand 760ms/);
assert.match(styles, /@keyframes harbor-startup-brand[\s\S]*opacity: 0\.35;[\s\S]*filter: blur\(5px\)/);
assert.doesNotMatch(/@keyframes harbor-startup-brand \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '', /translate|scale/);
assert.match(styles, /\.client-shell\.is-intro \.client-panel,[\s\S]*\.client-secondary-menu,[\s\S]*\.harbor-versions \{[\s\S]*harbor-startup-content 720ms 120ms/);
assert.match(styles, /@keyframes harbor-startup-content[\s\S]*opacity: 0;[\s\S]*filter: blur\(6px\)/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-shell\.is-intro \.client-panel,[\s\S]*animation: none/);
});
test('server rows scroll without moving the subscription column or showing a scrollbar', () => {
const scroll = rule('.client-server-scroll');
const simpleScroll = rule('.client-server-mode-panel.is-simple .client-server-scroll');
const grid = rule('.client-server-grid');
assert.match(scroll, /overflow-y:\s*auto/);
assert.match(scroll, /scrollbar-width:\s*none/);
assert.match(styles, /\.client-server-scroll::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/);
assert.match(simpleScroll, /max-height:\s*none/);
assert.match(simpleScroll, /overflow:\s*visible/);
assert.match(grid, /width:\s*min\(100%, 220px\)/);
assert.match(rule('.client-form-content'), /gap:\s*24px/);
assert.match(rule('.client-server-toolbar-title'), /grid-column:\s*2/);
assert.match(rule('.client-server-mode-toggle'), /grid-row:\s*2/);
assert.match(rule('.client-server-check'), /grid-column:\s*3/);
assert.doesNotMatch(rule('.client-servers.is-scalable .client-server-grid'), /width:/);
});
test('tablet and mobile regions use normal flow with viewport-safe widths', () => {
const responsive = /@media \(max-width: 920px\) \{([\s\S]*?)\n\}\n\n@media \(max-width: 560px\)/.exec(styles)?.[1] || '';
const mobile = /@media \(max-width: 560px\) \{([\s\S]*?)\n\}\n\n@media \(prefers-reduced-motion/.exec(styles)?.[1] || '';
assert.match(responsive, /grid-template-columns:\s*minmax\(0, 1fr\)/);
assert.match(responsive, /\.client-power-section,[\s\S]*\.client-form,[\s\S]*grid-column:\s*1/);
assert.match(responsive, /width:\s*min\(100%, 380px\)/);
assert.match(responsive, /max-height:\s*none/);
assert.match(responsive, /overflow-y:\s*visible/);
assert.match(responsive, /\.harbor-brand,[\s\S]*position:\s*absolute/);
assert.match(responsive, /\.client-inline-error\.is-connection,[\s\S]*position:\s*static/);
assert.match(rule('.client-duration-toggle'), /width:\s*min\(290px, 100%\)/);
assert.match(mobile, /\.client-server-toolbar \{[\s\S]*grid-template-rows:\s*44px 44px/);
assert.match(mobile, /\.client-form-content \{[\s\S]*gap:\s*24px/);
assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/);
assert.match(styles, /\.client-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/);
assert.match(styles, /\.client-local-rules\s*\{[\s\S]*width:\s*min\(480px, 100vw\)/);
assert.match(styles, /\.client-instructions\.client-diagnostics\s*\{[\s\S]*width:\s*min\(580px, 100vw\)/);
assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/);
for (const viewport of [320, 390, 768]) {
const pagePadding = viewport <= 560 ? 28 : 48;
const panelPadding = 12;
const available = viewport - pagePadding - panelPadding;
assert.ok(Math.min(380, available) <= viewport, `form fits ${viewport}px viewport`);
assert.ok(Math.min(290, available) <= viewport, `duration control fits ${viewport}px viewport`);
}
const desktopOuterColumn = (1100 - 280) / 2;
assert.ok(Math.min(360, desktopOuterColumn) <= desktopOuterColumn, 'details fit the 1440px desktop grid');
});
test('secondary menus share one right rail and both drawers open from the right', () => {
const disabledRulesLabel = rule('.client-local-rules-toggle:disabled span');
const zIndex = (selector) => Number(/z-index:\s*(\d+)/.exec(rule(selector))?.[1]);
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
assert.match(component, /client-diagnostics-toggle/);
assert.match(component, /<ConnectivityDiagnosticsPanel/);
assert.doesNotMatch(component, /\{isGateway && <button[\s\S]{0,120}diagnosticsToggleRef/);
assert.match(component, /<ConnectivityDiagnosticsPanel[\s\S]*isGateway=\{isGateway\}/);
assert.match(component, /Локальные правила недоступны: сейчас работают правила Gateway/);
assert.match(rule('.client-secondary-menu'), /right:\s*max\(14px, env\(safe-area-inset-right\)\)/);
assert.match(rule('.client-secondary-menu'), /display:\s*grid/);
assert.match(disabledRulesLabel, /opacity:\s*0/);
assert.match(disabledRulesLabel, /filter:\s*blur\(5px\)/);
assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/);
assert.match(rule('.client-drawer'), /inset:\s*0 0 0 auto/);
assert.match(rule('.client-drawer'), /transform:\s*translateX\(104%\)/);
assert.match(rule('.client-drawer'), /z-index:\s*50/);
assert.match(rule('.client-drawer'), /box-shadow:/);
assert.deepEqual(
['.client-confirmation-popup', '.client-secondary-menu', '.client-drawer', '.harbor-versions'].map(zIndex),
[100, 60, 50, 40],
);
assert.match(rule('.client-instructions'), /width:\s*min\(470px, 100vw\)/);
assert.match(rule('.client-local-rules'), /width:\s*min\(480px, 100vw\)/);
assert.match(component, /className={`client-drawer client-instructions/);
assert.match(component, /className={`client-drawer client-local-rules/);
});
test('connectivity diagnostics render stable compact tables before the first run', () => {
assert.match(diagnostics, /CONNECTIVITY_IP_SOURCES\.map/);
assert.match(diagnostics, /CONNECTIVITY_SITES\.filter\(\(\{ id \}\) => !hiddenServiceIds\.includes\(id\)\)/);
assert.equal((diagnostics.match(/<table className="client-diagnostics-table"/g) || []).length, 1);
assert.match(diagnostics, /className="client-diagnostics-service-table" role="table"/);
assert.match(diagnostics, /\+ Добавить сервис/);
assert.match(diagnostics, /HIDDEN_SERVICES_KEY/);
assert.match(diagnostics, /className="client-local-rule-delete"[\s\S]*Удалить сервис \$\{site\.label\}/);
assert.match(diagnostics, /client-deletable-row[\s\S]*className="client-delete-strike"[\s\S]*onAnimationEnd/);
assert.match(diagnostics, /document\.startViewTransition\(update\)/);
assert.match(diagnostics, /client-diagnostics-refresh/);
assert.match(diagnostics, /isGateway \? 'Gateway' : 'Connect'/);
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
assert.match(diagnostics, /\{error && <div className="client-diagnostics-feedback"/);
assert.doesNotMatch(diagnostics, /SUMMARY_COPY|client-diagnostics-summary|client-diagnostics-time|checkedAt|Прямой маршрут/);
assert.doesNotMatch(diagnostics, /PathDetails|client-diagnostics-details|Технические детали/);
assert.doesNotMatch(rule('.client-diagnostics-feedback'), /min-height:/);
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
assert.match(diagnostics, /for \(const target of targets\)/);
assert.match(diagnostics, /api\.diagnostics\.connectivity\(customServices, target\)/);
assert.match(diagnostics, /const target = `ip:\$\{source\.id\}`;[\s\S]*activeTarget === target/);
assert.match(diagnostics, /activeTarget === `site:\$\{site\.id\}`/);
assert.match(diagnostics, /data-diagnostic-target=\{target\}/);
assert.match(diagnostics, /client-diagnostics-active-marker/);
assert.match(styles, /\.client-diagnostics-active-marker\.is-moving \{[\s\S]*transform 680ms/);
assert.match(diagnostics, />\.{3}<\/span>/);
assert.doesNotMatch(styles, /client-diagnostics-row-pulse/);
assert.doesNotMatch(rule('.client-diagnostics-status.is-running'), /animation:|transform:|opacity:/);
});
test('duration and Gateway access keep stable geometry without tabs', () => {
assert.match(rule('.client-state-detail'), /min-height:\s*44px/);
assert.match(rule('.client-duration-toggle'), /min-height:\s*44px/);
assert.match(component, /client-duration-word-row is-calendar/);
assert.match(component, /client-duration-word-row is-clock/);
assert.match(rule('.client-duration-toggle > .client-tooltip'), /right:\s*calc\(100% \+ 12px\)/);
assert.match(component, /\['gateway', 'GATEWAY'\]/);
assert.match(component, /\['socks5', 'SOCKS5'\]/);
assert.match(component, /\['http', 'HTTP'\]/);
assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/);
assert.match(rule('.client-proxies.is-gateway'), /width:\s*270px/);
});
test('responsive motion has a reduced-motion fallback', () => {
const reducedMotion = /@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\n\}/.exec(styles)?.[1] || '';
assert.match(reducedMotion, /\.client-power-section/);
assert.match(reducedMotion, /\.client-form/);
assert.match(reducedMotion, /\.harbor-brand/);
assert.match(reducedMotion, /transition:\s*none/);
assert.match(reducedMotion, /animation:\s*none/);
});
test('tooltips stay opaque, above adjacent content, and do not stick after pointer clicks', () => {
const tooltip = rule('.client-tooltip');
assert.match(tooltip, /z-index:\s*1000/);
assert.match(tooltip, /background:\s*oklch\(0\.14 0\.012 145\)/);
assert.doesNotMatch(tooltip, /transparent|backdrop-filter/);
assert.match(styles, /\.client-power-section:has\(\.client-tooltip-anchor:hover\)[\s\S]*z-index:\s*90/);
assert.match(styles, /\.client-tooltip-anchor:focus-visible > \.client-tooltip/);
assert.match(styles, /\.client-tooltip-anchor:has\(> :focus-visible\) > \.client-tooltip/);
assert.doesNotMatch(styles, /\.client-tooltip-anchor:focus-within > \.client-tooltip/);
assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
});
test('subscription validation waits for the provider and keeps diagnostics below errors', () => {
assert.match(component, /api\.subscription\.validate\(normalizedSubscriptionUrl/);
assert.match(component, /status: 'checking'/);
assert.match(component, /status: 'valid'/);
assert.match(component, /message: ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message/);
assert.match(component, /subscriptionValidationStatus === 'checking' \? '…' : '×'/);
assert.match(component, /if \(error\?\.context === 'subscription'\) onDismissError\(\)/);
assert.match(component, /error\.retry[\s\S]*error\.correlationId/);
assert.match(rule('.client-inline-error.is-subscription small'), /flex-basis:\s*100%/);
assert.match(rule('.client-inline-error.is-subscription small'), /opacity:\s*0\.45/);
assert.match(styles, /@keyframes client-subscription-error-in[\s\S]*filter:\s*blur\(7px\)/);
assert.match(styles, /@keyframes client-subscription-code-in[\s\S]*transform:\s*translateY\(-2px\)/);
assert.match(
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\n\}/.exec(styles)?.[1] || '',
/\.client-inline-error\.is-subscription > \*/,
);
});