154 lines
9.0 KiB
JavaScript
154 lines
9.0 KiB
JavaScript
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');
|
||
|
||
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-server-check \{[\s\S]*width:\s*104px/);
|
||
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-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');
|
||
|
||
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, /Локальные правила недоступны: сейчас работают правила 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-instructions'), /inset:\s*0 0 0 auto/);
|
||
assert.match(rule('.client-instructions'), /transform:\s*translateX\(104%\)/);
|
||
assert.match(rule('.client-local-rules'), /inset:\s*0 0 0 auto/);
|
||
assert.match(rule('.client-local-rules'), /transform:\s*translateX\(104%\)/);
|
||
});
|
||
|
||
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 > \*/,
|
||
);
|
||
});
|