Update VPN proxy client behavior
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-09 11:55:02 +03:00
parent 71ede44be0
commit 90433d7cd8
27 changed files with 770 additions and 254 deletions
+68
View File
@@ -93,6 +93,74 @@ test('copy uses the synchronous native path available on gateway HTTP', async ()
assert.equal(textarea.removed, true);
});
test('copy prefers the Clipboard API when it is available', async () => {
const writes = [];
let legacyCalls = 0;
await copyText('socks5://127.0.0.1:8082', {
clipboard: { writeText: async (text) => writes.push(text) },
documentRef: { execCommand: () => { legacyCalls += 1; } },
});
assert.deepEqual(writes, ['socks5://127.0.0.1:8082']);
assert.equal(legacyCalls, 0);
});
test('copy falls back after the Clipboard API rejects', async () => {
const textarea = {
style: {},
setAttribute() {},
select() {},
remove() { this.removed = true; },
};
const documentRef = {
body: { append() {} },
createElement: () => textarea,
execCommand: () => true,
};
await copyText('192.168.50.111', {
clipboard: { writeText: async () => { throw new Error('denied'); } },
documentRef,
});
assert.equal(textarea.removed, true);
});
test('copy rejects and removes the temporary textarea when the fallback fails', async () => {
const textarea = {
style: {},
setAttribute() {},
select() {},
remove() { this.removed = true; },
};
const documentRef = {
body: { append() {} },
createElement: () => textarea,
execCommand: () => false,
};
await assert.rejects(copyText('192.168.50.111', { documentRef }), /Copy failed/);
assert.equal(textarea.removed, true);
});
test('copy removes the temporary textarea when the fallback throws', async () => {
const textarea = {
style: {},
setAttribute() {},
select() {},
remove() { this.removed = true; },
};
const documentRef = {
body: { append() {} },
createElement: () => textarea,
execCommand: () => { throw new Error('legacy failed'); },
};
await assert.rejects(copyText('192.168.50.111', { documentRef }), /legacy failed/);
assert.equal(textarea.removed, true);
});
test('subscription usage combines traffic and caps progress', () => {
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
upload: 30,
+5 -2
View File
@@ -15,7 +15,7 @@ test('connection feature is the sole always-mounted power panel owner', () => {
assert.equal((page.match(/<ConnectionPanel/g) || []).length, 1);
assert.match(page, /<main[\s\S]*<ConnectionPanel[\s\S]*<GatewayTrafficSummary/);
assert.doesNotMatch(page, /client-power-section|const powerButton|function toggleConnection|confirmingStop|id="stop-connection"|DURATION_MODE_STORAGE_KEY/);
assert.match(panel, /\{visible && <section className="client-power-section"/);
assert.match(panel, /\{visible && <section className=\{`client-power-section\$\{isGateway \? ' is-gateway' : ''\}`\}/);
assert.match(panel, /<ConfirmationDialog[\s\S]*open=\{confirmingStop\}/);
assert.ok(panel.indexOf('<ConfirmationDialog') > panel.indexOf('{visible && <section'), 'dialog remains mounted outside the visible section');
});
@@ -27,7 +27,7 @@ test('connection feature preserves actions, local preference and opaque neighbor
assert.match(panel, /action\?\.type === 'stop'[\s\S]*action\?\.type === 'apply'[\s\S]*action\?\.type === 'restart'/);
assert.match(panel, /if \(!await onStop\(\)\) return;[\s\S]*setConfirmingStop\(false\)/);
assert.match(panel, /localStorage\.getItem\(DURATION_MODE_STORAGE_KEY\) === 'words'[\s\S]*localStorage\.setItem\(DURATION_MODE_STORAGE_KEY, nextMode\)/);
assert.match(panel, /\{routingSlot\}[\s\S]*client-state-copy[\s\S]*\{serverSlot\}[\s\S]*client-state-detail[\s\S]*client-proxies[\s\S]*\{statusSlot\}/);
assert.match(panel, /client-state-detail[\s\S]*\{routingSlot\}[\s\S]*client-state-copy[\s\S]*\{serverSlot\}[\s\S]*client-proxies[\s\S]*\{statusSlot\}/);
assert.match(page, /routingSlot=\{<RoutingPendingStatus[\s\S]*blocked=\{connectionBlocked\}[\s\S]*onRestart=\{onRestart\}/);
assert.match(routing, /client-route-rules-pending[\s\S]*Перезапустить VPN/);
assert.match(page, /serverSlot=\{isGateway && <div className="client-gateway-route-summary"/);
@@ -41,6 +41,9 @@ test('shared clock, copy feedback and live announcement stay single-owned by the
assert.doesNotMatch(panel, /const \[copyFeedback, setCopyFeedback\]/);
assert.equal((pageBody.match(/className="client-live-region"/g) || []).length, 1);
assert.match(page, /onCopyProxy=\{copyProxy\}/);
assert.match(page, /copyTimersRef = useRef<Partial<Record<CopyKind[\s\S]*copyTimersRef\.current\[kind\]/);
assert.match(page, /copyAttemptsRef = useRef<Partial<Record<CopyKind[\s\S]*copyAttemptsRef\.current\[kind\] = attempt[\s\S]*await copyText\(value\)[\s\S]*copyAttemptsRef\.current\[kind\] !== attempt[\s\S]*const pendingTimer = copyTimersRef\.current\[kind\][\s\S]*setCopyFeedback/);
assert.match(panel, /const feedback = copyFeedback\?\.\[kind\][\s\S]*key=\{feedback\.cycle\}/);
assert.match(panel, /localProxyUrls\(proxyPort, gatewayAddress\)/);
assert.match(panel, /onClick=\{\(\) => onCopyProxy\(kind\)\}/);
});
+20 -8
View File
@@ -10,6 +10,7 @@ import {
sortDevicesByTraffic,
stabilizeDevicesByTraffic,
trafficAxisMid,
trafficBytesPerSecond,
trafficScaleRatio,
} from '../../.test-dist/src/web/utils/format.js';
import { readStyleSource } from './style-source.js';
@@ -63,7 +64,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/);
assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/);
assert.match(panel, /COPY_FEEDBACK_MS = 800/);
assert.match(panel, /IP скопирован/);
assert.match(panel, /IP \$\{device\.ip\} скопирован/);
assert.doesNotMatch(panel, /client-device-name-feedback|client-device-name-primary/);
assert.doesNotMatch(panel, /client-device-title/);
assert.match(panel, /online \? 'В сети' : <TextMorph from="Не в сети" to=\{seen\.relative\} \/>/);
@@ -95,11 +96,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(chart, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
assert.match(chart, /onPointerMove=\{trackPointer\}/);
assert.match(chart, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
assert.match(chart, /client-device-traffic-grid[\s\S]*path className="is-gateway"[\s\S]*path className="is-proxy"/);
assert.match(chart, /client-device-traffic-cursor[\s\S]*className="is-point is-gateway"[\s\S]*className="is-point is-proxy"/);
assert.match(chart, /client-device-traffic-lines[\s\S]*series === 'speed' \? 'is-download' : 'is-gateway'[\s\S]*series === 'speed' \? 'is-upload' : 'is-proxy'/);
assert.match(chart, /client-device-traffic-cursor[\s\S]*`is-point \$\{series === 'speed' \? 'is-download' : 'is-gateway'\}`[\s\S]*`is-point \$\{series === 'speed' \? 'is-upload' : 'is-proxy'\}`/);
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
assert.match(chart, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/);
assert.match(chart, /<time dateTime=\{samples\[0\]\.observedAt\}>[\s\S]*<span>15 с<\/span>/);
assert.match(chart, /<time dateTime=\{visibleSamples\[0\]\.observedAt\}>[\s\S]*series === 'speed' \? '↓ \/ ↑' : '15 с'/);
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
@@ -136,6 +137,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/);
assert.match(styles, /@keyframes client-device-traffic-shift[\s\S]*translateX\(calc\(100% \/ var\(--sample-count\)\)\)[\s\S]*translateX\(0\)/);
assert.match(styles, /@keyframes client-device-traffic-line-draw[\s\S]*stroke-dashoffset: 0/);
assert.match(styles, /\.client-device-traffic-lines \.is-upload \{[\s\S]*stroke-dasharray: 5 4/);
assert.match(styles, /\.client-device-traffic-lines path\.is-new\.is-upload \{[\s\S]*stroke-dashoffset: 0;[\s\S]*animation: none/);
assert.match(styles, /@keyframes client-device-chart-expand[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(1\)/);
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: 700 14px\/1\.2/);
@@ -165,13 +168,20 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/);
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*\.client-device-traffic-lines path\.is-new \{[\s\S]*stroke-dashoffset: 0;[\s\S]*animation: none/);
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
});
test('device copy feedback stays keyed per device', () => {
assert.match(panel, /copyTimers = useRef\(new Map[\s\S]*copyTimers\.current\.get\(device\.id\)[\s\S]*copyTimers\.current\.set\(device\.id/);
assert.match(panel, /copyAttempts = useRef\(new Map[\s\S]*copyAttempts\.current\.set\(device\.id, attempt\)[\s\S]*await copyText\(device\.ip\)[\s\S]*copyAttempts\.current\.get\(device\.id\) !== attempt/);
assert.match(panel, /setCopyFeedback\(\(current\) => \(\{ \.\.\.current, \[device\.id\]: feedback \}\)\)/);
assert.match(panel, /const feedback = copyFeedback\[device\.id\]/);
});
test('Gateway Home reuses the canonical device snapshot for applied route and global traffic', () => {
const powerStart = connection.indexOf('<section className="client-power-section"');
const powerStart = connection.indexOf('<section className={`client-power-section');
const trafficStart = overview.indexOf('<GatewayTrafficSummary');
const connectionPanelStart = overview.indexOf('<ConnectionPanel');
@@ -179,8 +189,8 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
assert.match(overview, /appliedServer\?\.label \|\| 'VPN-сервер не используется'/);
assert.match(overview, /selectedServerId !== appliedServerId[\s\S]*Переключаем на \{desiredServer\.label\}/);
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
assert.match(feature, /samples=\{globalTraffic\?\.history \|\| \[\]\}[\s\S]*capacity=\{feature\.snapshot\?\.trafficHistoryCapacity \|\| 120\}[\s\S]*routeLabel="Gateway"/);
assert.match(connection, /<section className="client-power-section"[\s\S]*client-connection-title[\s\S]*\{serverSlot\}[\s\S]*client-state-detail[\s\S]*client-proxies/);
assert.match(feature, /const history = globalTraffic\?\.history \|\| \[\][\s\S]*samples=\{history\}[\s\S]*routeLabel="Gateway"[\s\S]*series="speed"/);
assert.match(connection, /<section className=\{`client-power-section[\s\S]*client-state-detail[\s\S]*client-connection-title[\s\S]*\{serverSlot\}[\s\S]*client-proxies/);
assert.ok(powerStart >= 0 && connectionPanelStart >= 0 && trafficStart > connectionPanelStart, 'traffic summary follows the connection panel');
assert.equal((feature.match(/className="client-gateway-summary"/g) || []).length, 1);
assert.doesNotMatch(feature, /<TrafficChart[\s\S]{0,240}scale=/);
@@ -225,6 +235,8 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
assert.equal(Math.round(trafficScaleRatio('9', '99', 'log') * 100), 50);
assert.equal(trafficAxisMid('100', 'linear'), 50n);
assert.equal(trafficAxisMid('99', 'log'), 9n);
assert.equal(trafficBytesPerSecond('15000', '2026-08-07T13:00:00.000Z', '2026-08-07T13:00:15.000Z'), 1000n);
assert.equal(trafficBytesPerSecond('15000', 'invalid', '2026-08-07T13:00:15.000Z'), 0n);
const devices = [
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },
@@ -30,7 +30,9 @@ test('unconditional controller and conditional panel preserve lifecycle and rese
assert.doesNotMatch(page, /if \(!instructionsAvailable\)|instructionsAvailable/);
assert.match(feature, /const \[openInstructionId, setOpenInstructionId\] = useState\(''\)/);
assert.match(feature, /function InstructionBlock[\s\S]*const \[copyFeedback, setCopyFeedback\] = useState/);
assert.match(feature, /clearTimeout\(copyTimer\.current\)[\s\S]*setTimeout\(\(\) => setCopyFeedback\(null\), 800\)/);
assert.match(feature, /copyTimers = useRef\(new Map[\s\S]*copyTimers\.current\.get\(action\.id\)[\s\S]*copyTimers\.current\.set\(action\.id/);
assert.match(feature, /copyAttempts = useRef\(new Map[\s\S]*copyAttempts\.current\.set\(action\.id, attempt\)[\s\S]*await copyText\(action\.text\)[\s\S]*copyAttempts\.current\.get\(action\.id\) !== attempt/);
assert.match(feature, /setCopyFeedback\(\(current\) => \(\{ \.\.\.current, \[action\.id\]: feedback \}\)\)/);
assert.match(feature, /requestAnimationFrame\(\(\) => closeRef\.current\?\.focus\(\)\)[\s\S]*panelRef\.current\?\.contains[\s\S]*toggleRef\.current\?\.focus\(\)/);
assert.match(feature, /addEventListener\('pointerdown', closeOutside\)/);
assert.match(feature, /flushSync[\s\S]*prefers-reduced-motion: reduce[\s\S]*document\.startViewTransition\(update\)/);
+5 -2
View File
@@ -39,8 +39,11 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
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\)/);
assert.match(rule('.client-panel.is-gateway-home'), /grid-template-rows:\s*minmax\(0, 1fr\) auto[\s\S]*row-gap:\s*28px/);
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\(480px, calc\(100% - 24px\)\)/);
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*68px;[\s\S]*height:\s*68px/);
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-gateway-route-slot'), /min-height:\s*16px[\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/);
+1 -1
View File
@@ -84,7 +84,7 @@ test('copy feedback, drawers and Gateway access actions expose complete semantic
assert.match(component, /className="client-live-region" role="status" aria-live="polite" aria-atomic="true"/);
assert.match(component, /Не удалось скопировать/);
assert.match(component, /Скопировано/);
assert.match(connection, /client-copy-feedback[^\n]*\{copyFeedback\.failed \? 'Ошибка' : 'Скопировано'\}/);
assert.match(connection, /client-copy-feedback[^\n]*\{feedback\.failed \? 'Ошибка' : 'Скопировано'\}/);
assert.doesNotMatch(component, /ГОТОВО/);
assert.doesNotMatch(component, />Error<|>Copied</);
assert.match(instructions, /aria-label="Закрыть инструкции"/);
+15 -15
View File
@@ -36,26 +36,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 742,
cascadeEdges: 780,
customProperties: 31,
declarations: 2793,
declarations: 2885,
important: 0,
keyframes: 48,
media: 8,
rules: 824,
variableReferences: 323,
media: 10,
rules: 861,
variableReferences: 339,
},
hashes: {
cascadeEdges: '9e85cea58c179358dac8767ed987433e3e1f4974324999dba1803a49cbd6f4f6',
cascadeEdges: '7d47b1c26e06748e4532ce9ba7229e9a9bab4058b9c615129ce668aa7e906714',
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
declarations: '2e733c57dc0d89369e46a0eb44c1d67d09d1d40c28f7c36813c5fec607539be7',
declarations: 'ea140d4e00166daba7dc92fabb2ef63afd2ed3cb9d6c0dadeaf346bbf1eddc32',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
keyframes: '853c54c05759d9db27bea891254913e1651b1f601059ad9e3e2baa2c55ef1b2b',
ruleDeclarationSequences: '4a741c5ee09f9cd694e058609b4362cf796c97009d83886c5c8b4b4fa4d73b52',
selectors: '2da3202f84a79a8cf68e0cf262d36aa4b7756268133135469c086317dcbb82ff',
variableReferences: 'e8dc6951d717dbd6a9aa51a495798a56568b00ce0bbd7fb6edc7c758a81ddb6e',
witnesses: '68723a5909eb1a0972a70cde2be75e63e90abda61babe98db713414bda6a0c23',
ruleDeclarationSequences: '1a231e3068345d08f1670b64e5b2db91abbdb7083f164d3e96f073d4ae561bdb',
selectors: '824d578e36c3ae25a39bc686494635da353ea45996633eed01f93132dfb4dabd',
variableReferences: 'da4298e47c6249c3cfa12168d3670b0ec9c92c0fca7033ba968ea70598753a65',
witnesses: '6d498098d34d0b9fe399e457ece265456d99b4351a46a534e1b4fe5e4620e90f',
},
};
@@ -110,7 +110,7 @@ test('tokens, shared primitives, and feature styles have one explicit owner', ()
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 686);
assert.equal(witnesses.length, 707);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -293,8 +293,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-jheYW1hW.css']);
assert.deepEqual(assets, ['index-ZmHjygJv.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 103299);
assert.equal(sha256(built), '3acfedf526a1d6e867e825692b1dbdf55896d481a3ec19d97b513dd7704ae291');
assert.equal(built.byteLength, 107497);
assert.equal(sha256(built), '40412db73b9c6cd2f2f49f4a2b6cdf2130253a12f4d0d0e4a37277017933421c');
});
@@ -9,6 +9,7 @@ const root = path.resolve(import.meta.dirname, '../..');
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
const app = fs.readFileSync(path.join(root, 'src/web/App.tsx'), 'utf8');
const feature = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'src/web/styles/features/subscription.css'), 'utf8');
const boundary = fs.readFileSync(path.join(root, 'src/web/features/subscription/index.ts'), 'utf8');
test('subscription feature is the sole always-mounted lifecycle and view owner', () => {
@@ -86,3 +87,14 @@ test('feature keeps exact slots, truthy closes and subscription DOM order', () =
assert.match(page, /subscriptionFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)/);
assert.doesNotMatch(feature, /setInterval|copyText|client-live-region/);
});
test('saved subscription stays one clear shared presentation', () => {
assert.equal((feature.match(/client-subscription-summary/g) || []).length, 1);
assert.match(feature, /client-subscription-heading[\s\S]*client-subscription-status[\s\S]*Сохранена[\s\S]*client-subscription-actions[\s\S]*client-subscription-refresh[\s\S]*client-subscription-delete/);
assert.match(feature, /client-subscription-domain-button[\s\S]*client-subscription-label">Подписка[\s\S]*subscriptionDomain\(subscription\?\.host\)/);
assert.match(feature, /client-usage-summary[\s\S]*Использовано[\s\S]* из [\s\S]*client-usage-bar[\s\S]*client-usage-details[\s\S]*Действует до[\s\S]*subscriptionDaysLeft/);
assert.doesNotMatch(feature, /role="tab"|client-subscription-card|subscriptions\.map\(/);
assert.match(styles, /\.client-subscription-heading\s*\{[\s\S]*grid-template-columns:\s*64px minmax\(0, 1fr\) 64px/);
assert.match(styles, /\.client-usage\s*\{[\s\S]*min-height:\s*76px/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-usage-summary > strong[\s\S]*transition:\s*none/);
});