154 lines
12 KiB
JavaScript
154 lines
12 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 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 instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
|
|
const failover = fs.readFileSync(path.join(root, 'src/web/features/failover/FailoverFeature.tsx'), 'utf8');
|
|
const dialog = fs.readFileSync(path.join(root, 'src/web/ui/ConfirmationDialog.tsx'), 'utf8');
|
|
const copyButton = fs.readFileSync(path.join(root, 'src/web/ui/CopyButton.tsx'), 'utf8');
|
|
const styles = readStyleSource(root);
|
|
|
|
test('rule editor add latency stays constant and dirty exits are guarded', () => {
|
|
const rowRule = /\.client-local-rule \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '';
|
|
assert.doesNotMatch(rowRule, /--rule-index|calc\(/);
|
|
assert.match(routing, /addEventListener\('beforeunload', warnBeforeUnload\)/);
|
|
assert.match(routing, /requestClose\(\)/);
|
|
assert.match(routing, /pendingRestart/);
|
|
assert.match(routing, /activeLocalRules/);
|
|
assert.match(routing, /localRulesRevision/);
|
|
assert.match(routing, /Не сохранено/);
|
|
assert.match(routing, /if \(!runtimeActive\) return \['saved', 'Сохранено'\]/);
|
|
assert.match(routing, /Ждёт перезапуска/);
|
|
assert.match(routing, /Перезапустить VPN/);
|
|
assert.match(routing, /const pendingRestart = connected && !bypassed && route\?\.localRulesPendingRestart === true/);
|
|
assert.match(routing, /if \(!connected \|\| !result\.localRulesPendingRestart\) setIsOpen\(false\)/);
|
|
assert.match(routing, /client-deletable-row/);
|
|
assert.match(routing, /className="client-delete-strike"[\s\S]*onAnimationEnd/);
|
|
assert.doesNotMatch(routing, /client-rule-delete-cross/);
|
|
assert.match(routing, /className="client-row-delete"[\s\S]*client-row-delete-lid/);
|
|
assert.match(styles, /\.client-row-delete-lid \{[\s\S]*transition: transform/);
|
|
assert.match(styles, /\.client-row-delete:hover:not\(:disabled\) \.client-row-delete-lid,[\s\S]*translateY\(-2px\) rotate\(-8deg\)/);
|
|
assert.match(styles, /\.client-deletable-row\.is-removing > \.client-delete-strike[\s\S]*client-delete-strike/);
|
|
assert.match(styles, /\.client-delete-strike \{[\s\S]*z-index: 100[\s\S]*background: transparent/);
|
|
assert.match(styles, /\.client-deletable-row\.is-removing > :not\(\.client-delete-strike\)[\s\S]*z-index: 0/);
|
|
assert.match(styles, /clip-path: polygon\(0 24%, 100% 50%, 0 76%\)/);
|
|
assert.doesNotMatch(styles, /\.client-delete-strike::before/);
|
|
assert.doesNotMatch(styles, /client-local-rule > button:last-child/);
|
|
assert.match(styles, /@keyframes client-delete-strike[\s\S]*scaleX\(0\)[\s\S]*scaleX\(1\)/);
|
|
assert.match(styles, /53%[\s\S]*transform-origin: right center[\s\S]*100%[\s\S]*scaleX\(0\)/);
|
|
assert.match(styles, /@keyframes client-row-leave[\s\S]*translateX\(28px\)/);
|
|
assert.match(styles, /@keyframes client-delete-content-dim[\s\S]*opacity: 0\.58[\s\S]*blur\(1\.4px\)[\s\S]*opacity: 0/);
|
|
assert.match(styles, /\.client-local-rule:has\(\.client-rule-type\.is-open\)[\s\S]*z-index: 5/);
|
|
});
|
|
|
|
test('critical confirmations share one accessible blocking popup', () => {
|
|
assert.equal(fs.existsSync(path.join(root, 'src/web/components/ConfirmationPopup.jsx')), false);
|
|
assert.equal(
|
|
(component.match(/<ConfirmationDialog/g) || []).length
|
|
+ (connection.match(/<ConfirmationDialog/g) || []).length
|
|
+ (subscription.match(/<ConfirmationDialog/g) || []).length
|
|
+ (routing.match(/<ConfirmationDialog/g) || []).length
|
|
+ (failover.match(/<ConfirmationDialog/g) || []).length,
|
|
4,
|
|
);
|
|
assert.match(connection, /id="stop-connection"/);
|
|
assert.match(routing, /id="discard-local-rules"/);
|
|
assert.match(subscription, /id="delete-subscription"/);
|
|
assert.match(failover, /id="discard-failover"/);
|
|
assert.doesNotMatch(component, /client-local-rules-discard|client-delete-confirmation/);
|
|
assert.match(dialog, /role="alertdialog"/);
|
|
assert.match(dialog, /aria-modal="true"/);
|
|
assert.match(dialog, /aria-hidden=\{!open\}[\s\S]*inert=\{!open \? true : undefined\}/);
|
|
assert.match(dialog, /querySelectorAll<HTMLElement>\(FOCUSABLE\)/);
|
|
assert.match(dialog, /element\.inert = true/);
|
|
assert.match(dialog, /requestAnimationFrame\(\(\) => cancelRef\.current\?\.focus\(\)\)/);
|
|
assert.match(dialog, /event\.key === 'Escape' && !busyRef\.current/);
|
|
assert.match(dialog, /event\.target === event\.currentTarget && !busy/);
|
|
assert.match(dialog, /background\.forEach\(\(\{ element, inert \}\) => \{ element\.inert = inert; \}\)/);
|
|
assert.match(dialog, /document\.body\.style\.overflow = previousOverflow/);
|
|
assert.match(dialog, /requestAnimationFrame\(\(\) => previousFocus\?\.focus\?\.\(\)\)/);
|
|
assert.match(dialog, /document\.querySelector\('\.app\.client-app'\) \|\| document\.body/);
|
|
assert.match(styles, /\.client-confirmation-popup\.is-open[\s\S]*backdrop-filter: blur\(18px\)/);
|
|
});
|
|
|
|
test('power click and native Enter cannot stop VPN without a separate confirmation', () => {
|
|
assert.match(connection, /className="client-power"[\s\S]*type="button"[\s\S]*onClick=\{toggleConnection\}/);
|
|
assert.match(connection, /if \(action\?\.type === 'stop'\) \{[\s\S]*setConfirmingStop\(true\);[\s\S]*return;[\s\S]*\}/);
|
|
assert.doesNotMatch(connection, /if \(action\?\.type === 'stop'\) return onStop\(\)/);
|
|
assert.match(connection, /id="stop-connection"[\s\S]*cancelLabel="Оставить включённым"[\s\S]*confirmLabel="Отключить VPN"[\s\S]*onConfirm=\{stopConnection\}/);
|
|
});
|
|
|
|
test('copy feedback, drawers and Gateway access actions expose complete semantics', () => {
|
|
assert.match(component, /className="client-live-region" role="status" aria-live="polite" aria-atomic="true"/);
|
|
assert.match(component, /Не удалось скопировать/);
|
|
assert.match(component, /Скопировано/);
|
|
assert.match(copyButton, /client-copy-feedback[\s\S]*feedback\.failed \? 'Ошибка' : 'Скопировано'/);
|
|
assert.doesNotMatch(component, /ГОТОВО/);
|
|
assert.doesNotMatch(component, />Error<|>Copied</);
|
|
assert.match(instructions, /closeLabel="Закрыть инструкции"/);
|
|
assert.match(routing, /closeLabel="Закрыть правила маршрутизации"/);
|
|
assert.match(instructions, /closeRef\.current\?\.focus\(\)/);
|
|
assert.match(routing, /closeRef\.current\?\.focus\(\)/);
|
|
assert.match(connection, /ariaLabel={`Скопировать \$\{label\}: \$\{kind === 'gateway' \? gatewayAddress : proxyUrls\[kind\]\}`}/);
|
|
assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/);
|
|
assert.match(styles, /\.client-drawer-close \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
|
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/);
|
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-rule-handle,[\s\S]*\.client-local-rule-enabled-wrap,[\s\S]*\.client-row-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
|
|
});
|
|
|
|
test('ordered rules use one accessible drag handle and a compact icon route control', () => {
|
|
const handle = /<button\s+className="client-rule-handle"([\s\S]*?)<\/button>/.exec(routing)?.[0] || '';
|
|
assert.match(routing, /className="client-rule-handle"[\s\S]*type="button"[\s\S]*aria-label=\{`Переместить правило, позиция/);
|
|
assert.match(routing, /aria-describedby="client-rule-reorder-instructions"[\s\S]*aria-pressed=\{lifted\}/);
|
|
assert.match(routing, /onPointerDown=\{\(event\) => feature\.startPointerReorder\(event, rule\._key\)\}/);
|
|
assert.doesNotMatch(handle, /onPointerMove|onPointerUp|onPointerCancel|onLostPointerCapture/);
|
|
assert.doesNotMatch(routing, /data-rule-key[^>]*onPointerDown/);
|
|
assert.match(routing, /onClick=\{\(event\) => feature\.handleReorderClick\(event, rule\._key\)\}/);
|
|
assert.match(routing, /<svg viewBox="0 0 16 28" aria-hidden="true">[\s\S]*cx="5" cy="5"[\s\S]*cx="11" cy="23"/);
|
|
assert.match(routing, /client-rule-reorder-instructions[\s\S]*стрелки вверх и вниз[\s\S]*Escape отменяет/);
|
|
assert.match(routing, /client-rule-reorder-live[\s\S]*aria-live="polite"/);
|
|
assert.match(routing, /className="client-rule-outbound client-tooltip-anchor"[\s\S]*className=\{direct \? 'is-direct' : 'is-vpn'\}/);
|
|
assert.match(routing, /Маршрут правила \$\{index \+ 1\}: \$\{label\}\. Переключить на/);
|
|
assert.match(routing, /M4 12h15M14 7l5 5-5 5[\s\S]*M12 3 19 6v5/);
|
|
assert.doesNotMatch(routing, /client-rule-outbound" role="group"/);
|
|
assert.match(routing, /className=\{`client-local-rules-list\$\{draftRules\.length > 1 \? ' has-order-flow' : ''\}`\}[\s\S]*aria-label="Правила применяются сверху вниз"/);
|
|
assert.match(routing, /className="client-rule-status-dot"/);
|
|
assert.match(routing, /role="switch"[\s\S]*aria-checked=\{rule\.enabled\}[\s\S]*client-rule-check/);
|
|
assert.match(routing, /Отключить: правило перестанет участвовать в маршрутизации[\s\S]*Включить: правило снова будет участвовать в маршрутизации/);
|
|
assert.match(routing, /data-route=\{rule\.outbound\}/);
|
|
assert.match(styles, /\.client-local-rule\[data-route='direct'\] \{[\s\S]*--client-rule-route-color/);
|
|
assert.doesNotMatch(styles, /\.client-local-rule::after/);
|
|
assert.match(styles, /\.client-local-rule\.is-active \.client-rule-status-dot \{[\s\S]*background: currentColor/);
|
|
assert.match(styles, /\.client-local-rule\.is-unsaved \.client-rule-status-dot \{[\s\S]*border-radius: 1px[\s\S]*rotate\(45deg\)/);
|
|
assert.match(styles, /\.client-local-rule\.is-pending \.client-rule-status-dot \{[\s\S]*border: 1\.5px dashed/);
|
|
assert.doesNotMatch(routing, />\s*[↑↓]\s*</);
|
|
assert.match(styles, /\.client-rule-handle \{[\s\S]*width: 44px;[\s\S]*height: 44px;[\s\S]*touch-action: none/);
|
|
assert.match(styles, /\.client-row-delete \{[\s\S]*color: oklch\(0\.62 0\.16 28\)/);
|
|
assert.match(styles, /\.client-rule-outbound \{[\s\S]*width: 34px/);
|
|
assert.match(styles, /\.client-rule-outbound svg \{[\s\S]*width: 20px/);
|
|
assert.match(styles, /\.client-local-rules-list\.has-order-flow::before[\s\S]*linear-gradient\(to bottom/);
|
|
assert.match(styles, /\.client-local-rule \+ \.client-local-rule::before[\s\S]*linear-gradient\(90deg/);
|
|
});
|
|
|
|
test('rule values normalize on paste and help animates both directions above the list', () => {
|
|
assert.match(routing, /normalizeRouteRules\(\[\{ \.\.\.rule, value \}\], \{ strict: true \}\)/);
|
|
assert.match(routing, /onPaste=\{\(event\) => \{[\s\S]*event\.preventDefault\(\)[\s\S]*normalizeDraftRuleValue\(rule, event\.clipboardData\.getData\('text'\)\)/);
|
|
assert.match(routing, /onBlur=\{\(\) => feature\.change\(index, 'value', normalizeDraftRuleValue\(rule, rule\.value\)\)\}/);
|
|
assert.equal((routing.match(/Проверяются сверху вниз\. Первое совпадение выбирает маршрут/g) || []).length, 0);
|
|
assert.equal((routing.match(/Правила проверяются сверху вниз\. Первое совпадение выбирает маршрут/g) || []).length, 1);
|
|
assert.match(routing, /const \[helpOpen, setHelpOpen\] = useState\(false\)[\s\S]*aria-expanded=\{helpOpen\}[\s\S]*aria-controls="client-local-rules-help-content"[\s\S]*setHelpOpen\(\(current\) => !current\)[\s\S]*Как работают правила[\s\S]*aria-hidden=\{!helpOpen\}[\s\S]*<form id="client-local-rules-form"/);
|
|
assert.doesNotMatch(routing, /client-local-rules-note/);
|
|
assert.match(styles, /\.client-local-rules-help-reveal \{[\s\S]*grid-template-rows:\s*0fr[\s\S]*opacity:\s*0[\s\S]*420ms cubic-bezier\(0\.16, 1, 0\.3, 1\)/);
|
|
assert.match(styles, /\.client-local-rules-help\.is-open \.client-local-rules-help-reveal \{[\s\S]*grid-template-rows:\s*1fr[\s\S]*opacity:\s*1/);
|
|
assert.match(styles, /\.client-local-rules-help-content \{[\s\S]*overflow:\s*hidden/);
|
|
assert.match(styles, /\.client-local-rules-help-copy \{[\s\S]*background: color-mix[\s\S]*translateY\(-6px\)[\s\S]*transition:\s*transform 420ms/);
|
|
});
|