Files
harbor-net/test/web/responsive-layout-contract.test.js
T
dokril 0a4a6d9443
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s
Update VPN client connection flow
2026-08-07 21:19:33 +03:00

179 lines
11 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('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.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, /const sites = \[\.\.\.CONNECTIVITY_SITES, \.\.\.customServices\]/);
assert.equal((diagnostics.match(/<table className="client-diagnostics-table"/g) || []).length, 2);
assert.match(diagnostics, /Добавить свой сервис/);
assert.match(diagnostics, /client-diagnostics-refresh/);
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
assert.match(rule('.client-diagnostics-feedback'), /min-height:\s*34px/);
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
});
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 > \*/,
);
});