Files
harbor-net/test/web/responsive-layout-contract.test.js
T
dokril 021cdb28d0
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 7s
Preserve drawer during startup animation
2026-08-11 09:02:19 +03:00

267 lines
19 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { readStyleSource } from './style-source.js';
const root = path.resolve(import.meta.dirname, '../..');
const styles = readStyleSource(root);
const layoutStyles = fs.readFileSync(path.join(root, 'src/web/styles/layout.css'), 'utf8');
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
const connection = fs.readFileSync(path.join(root, 'src/web/features/connection/ConnectionPanel.tsx'), 'utf8');
const subscription = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
const routing = fs.readFileSync(path.join(root, 'src/web/features/routing/RoutingFeature.tsx'), 'utf8');
const devices = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8');
const diagnosticsFeature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8');
const diagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8');
const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), '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 beside the subscription drawer', () => {
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.doesNotMatch(styles, /\.client-panel\.has-subscription \.client-form\b/);
assert.doesNotMatch(styles, /\.client-form-content\b/);
assert.match(styles, /--client-power-top:\s*calc\(\(var\(--client-work-height\) - 96px\) \/ 2\)/);
const subscribedPanel = /\.client-panel\.has-subscription\s*\{([^}]*)\}/.exec(layoutStyles)?.[1] || '';
assert.match(subscribedPanel, /top:\s*-9vh/);
assert.doesNotMatch(subscribedPanel, /\b(?:transform|filter|perspective|contain|will-change)\s*:/);
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(rule('.client-panel.is-gateway-home'), /min-height:\s*max\(620px, calc\(100dvh - 80px\)\)[\s\S]*grid-template-rows:\s*minmax\(260px, 1fr\) auto[\s\S]*row-gap:\s*36px/);
assert.match(rule('.client-gateway-summary'), /grid-column:\s*1 \/ -1[\s\S]*grid-row:\s*2[\s\S]*justify-self:\s*center[\s\S]*width:\s*min\(860px, calc\(100% - 48px\)\)/);
assert.match(styles, /\.client-power-section\.is-gateway \.client-power-control,[\s\S]*width:\s*84px;[\s\S]*height:\s*84px/);
assert.match(rule('.client-power-control'), /position:\s*relative/);
assert.match(rule('.client-power-control > .harbor-brand'), /position:\s*absolute[\s\S]*bottom:\s*calc\(100% \+ 44px\)[\s\S]*left:\s*50%[\s\S]*transform:\s*translateX\(-50%\) scale\(3\)[\s\S]*transform-origin:\s*center bottom/);
assert.match(rule('.client-power-control > .client-tooltip'), /top:\s*calc\(100% \+ 12px\)[\s\S]*left:\s*50%[\s\S]*translate\(-50%, -2px\)/);
assert.doesNotMatch(styles, /\.harbor-brand\.is-connected[^}]*transform|translateY\(calc\(-50% - 4px\)\)/);
assert.match(rule('.client-gateway-traffic-chart'), /min-height:\s*180px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-gateway-traffic-chart,[\s\S]*min-height:\s*130px;[\s\S]*height:\s*130px/);
assert.doesNotMatch(rule('.client-gateway-summary'), /background|border|box-shadow/);
assert.match(rule('.client-applied-operation'), /min-height:\s*15px[\s\S]*overflow:\s*hidden[\s\S]*text-overflow:\s*ellipsis[\s\S]*white-space:\s*nowrap/);
assert.match(component, /client-panel\$\{showPower \? '' : ' is-setup'\}[\s\S]*is-gateway-home/);
assert.match(component, /const showPower = hasSubscription/);
assert.match(component, /\$\{!hasSubscription \? ' is-first-run' : ''\}/);
assert.match(component, /\$\{isGateway && hasSubscription \? ' is-gateway-home' : ''\}/);
});
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 > :not\(\.client-drawer\),[\s\S]*\.client-secondary-menu,[\s\S]*\.harbor-versions \{[\s\S]*harbor-startup-content 720ms 120ms/);
assert.doesNotMatch(styles, /\.client-shell\.is-intro \.client-panel\s*,/);
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 > :not\(\.client-drawer\),[\s\S]*animation: none/);
});
test('server rows scroll inside the subscription drawer without moving the main layout', () => {
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*352px/);
assert.match(simpleScroll, /overflow-y:\s*auto/);
assert.match(simpleScroll, /scrollbar-width:\s*thin/);
assert.match(grid, /width:\s*min\(100%, 220px\)/);
assert.match(rule('.client-drawer'), /overflow-y:\s*auto/);
assert.match(subscription, /client-profile-list[\s\S]*feature\.profiles\.map\(\(profile\) => <ProfileGroup/);
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(layoutStyles)?.[1] || '';
const mobile = /@media \(max-width: 560px\) \{([\s\S]*?)\n\}\s*$/.exec(layoutStyles)?.[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, /\.client-power-section \{[\s\S]*order:\s*1/);
assert.match(responsive, /\.client-gateway-summary \{[\s\S]*grid-row:\s*auto;[\s\S]*order:\s*2/);
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-power-control > \.harbor-brand \{[\s\S]*scale\(1\.55\)/);
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-secondary-menu \{[\s\S]*right:\s*8px/);
assert.match(mobile, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.35\)/);
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-subscription-drawer\s*\{[\s\S]*width:\s*min\(480px, 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, /<SubscriptionToggle[\s\S]*<InstructionsToggle[\s\S]*<DevicesToggle/);
assert.match(subscription, /controls="client-subscription-drawer"/);
assert.match(component, /<InstructionsToggle[\s\S]*<RoutingToggle/);
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
assert.match(diagnosticsFeature, /client-diagnostics-toggle/);
assert.match(component, /<ConnectivityDiagnosticsPanel/);
assert.doesNotMatch(component, /diagnosticsToggleRef|diagnosticsPanelRef|diagnosticsCloseRef/);
assert.match(component, /<ConnectivityDiagnosticsPanel[\s\S]*isGateway=\{isGateway\}/);
assert.match(routing, /Локальные правила недоступны: сейчас работают правила 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(instructions, /client-rail-info-ring/);
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
assert.match(diagnosticsFeature, /client-rail-diagnostics-base[\s\S]*client-rail-diagnostics-pulse/);
assert.match(devices, /client-rail-device-primary[\s\S]*client-rail-device-secondary[\s\S]*client-rail-device-link/);
assert.match(routing, /client-rail-rule-knob is-top[\s\S]*client-rail-rule-knob is-bottom/);
assert.match(styles, /client-rail-info-refill[\s\S]*client-rail-device-left[\s\S]*client-rail-device-right[\s\S]*client-rail-diagnostics-pulse[\s\S]*client-rail-rule-top[\s\S]*client-rail-rule-bottom/);
assert.match(styles, /\.client-rail-diagnostics-pulse \{[\s\S]*stroke-dasharray: 0\.16 0\.84/);
assert.match(styles, /@keyframes client-rail-diagnostics-pulse[\s\S]*stroke-dashoffset: 1[\s\S]*stroke-dashoffset: 0/);
assert.match(styles, /@keyframes client-rail-device-left[\s\S]*translate\(-0\.6px, -2\.25px\)[\s\S]*@keyframes client-rail-device-right[\s\S]*translate\(0\.6px, -2\.25px\)/);
assert.match(styles, /@keyframes client-rail-rule-top[\s\S]*translateX\(-3px\)[\s\S]*@keyframes client-rail-rule-bottom[\s\S]*translateX\(3px\)/);
assert.match(styles, /@keyframes client-rail-info-refill[\s\S]*stroke-dashoffset: 1[\s\S]*stroke-dashoffset: 0/);
assert.doesNotMatch(styles, /client-secondary-icon-motion|\.client-local-rules-toggle\.is-open svg\s*\{[^}]*rotate/);
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(instructions, /<Drawer[\s\S]*className="client-instructions"/);
assert.match(routing, /<Drawer[\s\S]*className="client-local-rules"/);
assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"/);
assert.match(component, /subscriptionFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)[\s\S]*instructionsFeature\.toggle\(\)/);
});
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-row-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, /\{Boolean\(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, /runConnectivityDiagnostics\(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(connection, /client-duration-word-row is-calendar/);
assert.match(connection, /client-duration-word-row is-clock/);
assert.match(rule('.client-duration-toggle > .client-tooltip'), /right:\s*calc\(100% \+ 12px\)/);
assert.match(connection, /\['gateway', 'GATEWAY'\]/);
assert.match(connection, /\['socks5', 'SOCKS5'\]/);
assert.match(connection, /\['http', 'HTTP'\]/);
assert.doesNotMatch(`${component}\n${connection}`, /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, /\.client-rail-action circle/);
assert.match(reducedMotion, /\.client-rail-action rect/);
assert.match(reducedMotion, /\.client-drawer/);
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 is local and provider errors keep stable detail slots', () => {
assert.match(subscription, /const validationStatus = !normalizedUrl[\s\S]*isSubscriptionUrlValid\(normalizedUrl\) \? 'valid' : 'invalid'/);
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController|status: 'checking'/);
assert.match(subscription, /const message = feature\.validationStatus === 'invalid'[\s\S]*ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message[\s\S]*feature\.addError\?\.message/);
assert.match(subscription, /aria-invalid=\{feature\.validationStatus === 'invalid'\}/);
assert.match(subscription, /disabled=\{feature\.addBlocked \|\| feature\.validationStatus !== 'valid'\}/);
assert.match(subscription, /const host = new URL\(normalizedUrl\)\.hostname;[\s\S]*if \(!await onAdd\(label, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
assert.doesNotMatch(subscription, />Название<|duplicateLabel/);
assert.match(subscription, /setUrl: \(value: string\) => \{[\s\S]*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 > \*/,
);
});