Update VPN client and gateway behavior
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-11 08:50:44 +03:00
parent 396c5d1917
commit 79ffff194f
35 changed files with 1036 additions and 590 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ test('shared clock, copy feedback and live announcement stay single-owned by the
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, /const feedback = copyFeedback\?\.\[kind\][\s\S]*<CopyButton[\s\S]*feedback=\{feedback\}/);
assert.match(panel, /localProxyUrls\(proxyPort, gatewayAddress\)/);
assert.match(panel, /onClick=\{\(\) => onCopyProxy\(kind\)\}/);
});
+7 -7
View File
@@ -112,7 +112,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /className=\{`client-device-policy is-\$\{displayPolicy\}/);
assert.match(panel, /displayPolicy === 'direct' \? <svg[\s\S]*M4 12h15M14 7l5 5-5 5[\s\S]*M12 3 19 6v5/);
assert.match(panel, /Полностью обходит sing-box/);
assert.match(panel, /client-drawer client-instructions client-devices/);
assert.match(panel, /<Drawer[\s\S]*className="client-instructions client-devices"/);
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
assert.match(panel, /const \[pinCollapses, setPinCollapses\][\s\S]*animationDone[\s\S]*result: saved \? 'saved' : 'failed'/);
@@ -131,7 +131,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device\.is-deprioritized \.client-device-main \{[\s\S]*grid-column: 1 \/ 4/);
assert.match(styles, /\.client-device-deprioritize-wrap \{[\s\S]*grid-column: 4;[\s\S]*grid-row: 2/);
assert.match(styles, /\.client-device\.is-deprioritized \.client-device-deprioritize-wrap \{[\s\S]*grid-row: 1/);
assert.match(styles, /\.client-device-group-heading \{[\s\S]*text-transform: uppercase/);
assert.match(styles, /\.client-device-group-heading \{[\s\S]*text-transform: var\(--type-label-transform\)/);
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-expand 520ms/);
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-collapse 520ms/);
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-axis \{[\s\S]*top: 0;[\s\S]*height: 58px/);
@@ -156,13 +156,13 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /@keyframes client-device-traffic-detail-in[\s\S]*opacity: 0[\s\S]*opacity: 1/);
assert.match(styles, /@keyframes client-device-traffic-detail-out[\s\S]*opacity: 1[\s\S]*opacity: 0/);
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/);
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: var\(--type-item-title\)/);
assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*client-device-alias-edit-in 360ms/);
assert.match(styles, /@keyframes client-device-alias-edit-in[\s\S]*color: var\(--client-accent\)[\s\S]*filter: blur\(2px\)/);
assert.doesNotMatch(styles, /\.client-device-alias \{/);
assert.match(styles, /\.client-device-ip \{[\s\S]*font: 600 12px\/1\.2/);
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font-size: 8\.5px/);
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font-size: 10px/);
assert.match(styles, /\.client-device-ip \{[\s\S]*font: var\(--type-data\)/);
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font: var\(--type-micro\)/);
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font: var\(--type-control\)/);
assert.match(styles, /\.client-device-name-separator \{[\s\S]*color: var\(--client-muted\)/);
assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/);
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 0 0/);
@@ -214,7 +214,7 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
assert.match(feature, /feature\.status === 'error' \? feature\.error : null/);
assert.match(overview, /if \(!isGateway && \(!connected \|\| !state\?\.connection\?\.startedAt\)\) return undefined/);
assert.match(feature, /trafficSourceError[\s\S]*Трафик не обновляется · последние данные/);
assert.match(subscription, /client-drawer client-subscription-drawer\$\{drawerOpen \? ' is-open' : ''\}/);
assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"[\s\S]*open=\{drawerOpen\}/);
assert.match(subscription, /const deleteIdRef = useRef\(deleteId\)[\s\S]*if \(deleteIdRef\.current\) return/);
assert.match(subscription, /requestDelete: \(profileId: string\) => setDeleteId\(profileId\)[\s\S]*open=\{Boolean\(feature\.deleteProfile\)\}[\s\S]*onCancel=\{feature\.cancelDelete\}[\s\S]*onConfirm=\{feature\.confirmDelete\}/);
assert.match(connection, /aria-label=\{remoteOwned[\s\S]*isGateway[\s\S]*Остановить Harbor Connect[\s\S]*Запустить Harbor Connect/);
@@ -48,5 +48,5 @@ test('private content helpers preserve guide, copy and monitoring contracts', ()
assert.match(prometheus, /\.\.\/\.\.\/\.\.\/\.\.\/monitoring\/grafana\/harbor-gateway\.json/);
assert.match(prometheus, /scrape_interval: 30s[\s\S]*scrape_timeout: 3s[\s\S]*metrics_path: \/metrics/);
assert.match(feature, /target="_blank" rel="noreferrer"/);
assert.match(feature, /client-copy-label">Скопировать/);
assert.match(feature, /<CopyButton[\s\S]*label="Скопировать"/);
});
+1 -1
View File
@@ -23,7 +23,7 @@ test('Gateway info drawer contains copyable Prometheus and Grafana instructions'
assert.match(prometheus, /monitoring\/grafana\/harbor-gateway\.json/);
assert.match(prometheus, /scrape_interval: 30s[\s\S]*metrics_path: \/metrics/);
assert.match(overview, /const controlHost = window\.location\.host/);
assert.match(feature, /client-copy-label">Скопировать/);
assert.match(feature, /<CopyButton[\s\S]*label="Скопировать"/);
assert.doesNotMatch(`${overview}\n${feature}`, /prometheus-toggle|Prometheus<\/span><\/button>/);
assert.match(styles, /\.client-instruction-copy-button \{[\s\S]*width: 104px;[\s\S]*min-width: 104px/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-copy-feedback/);
+8 -8
View File
@@ -131,7 +131,7 @@ test('secondary menus share one right rail and both drawers open from the right'
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
assert.match(component, /<SubscriptionToggle[\s\S]*<InstructionsToggle[\s\S]*<DevicesToggle/);
assert.match(subscription, /aria-controls="client-subscription-drawer"/);
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/);
@@ -166,9 +166,9 @@ test('secondary menus share one right rail and both drawers open from the right'
);
assert.match(rule('.client-instructions'), /width:\s*min\(470px, 100vw\)/);
assert.match(rule('.client-local-rules'), /width:\s*min\(480px, 100vw\)/);
assert.match(instructions, /className={`client-drawer client-instructions/);
assert.match(routing, /className={`client-drawer client-local-rules/);
assert.match(subscription, /client-drawer client-subscription-drawer/);
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\(\)/);
});
@@ -179,7 +179,7 @@ test('connectivity diagnostics render stable compact tables before the first run
assert.match(diagnostics, /className="client-diagnostics-service-table" role="table"/);
assert.match(diagnostics, /\+ Добавить сервис/);
assert.match(diagnostics, /HIDDEN_SERVICES_KEY/);
assert.match(diagnostics, /className="client-local-rule-delete"[\s\S]*Удалить сервис \$\{site\.label\}/);
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/);
@@ -221,9 +221,9 @@ test('responsive motion has a reduced-motion fallback', () => {
assert.match(reducedMotion, /\.client-power-section/);
assert.match(reducedMotion, /\.client-form/);
assert.match(reducedMotion, /\.harbor-brand/);
assert.match(reducedMotion, /\.client-instructions-toggle circle/);
assert.match(reducedMotion, /\.client-instructions-toggle rect/);
assert.match(reducedMotion, /\.client-local-rules-toggle circle/);
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/);
});
+8 -7
View File
@@ -12,6 +12,7 @@ const subscription = fs.readFileSync(path.join(root, 'src/web/features/subscript
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 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', () => {
@@ -31,7 +32,7 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
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-local-rule-delete"/);
assert.match(routing, /className="client-row-delete"/);
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/);
@@ -40,7 +41,7 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
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-local-rule-leave[\s\S]*translateX\(28px\)/);
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/);
});
@@ -84,16 +85,16 @@ 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]*\{feedback\.failed \? 'Ошибка' : 'Скопировано'\}/);
assert.match(copyButton, /client-copy-feedback[\s\S]*feedback\.failed \? 'Ошибка' : 'Скопировано'/);
assert.doesNotMatch(component, /ГОТОВО/);
assert.doesNotMatch(component, />Error<|>Copied</);
assert.match(instructions, /aria-label="Закрыть инструкции"/);
assert.match(routing, /aria-label="Закрыть локальные правила"/);
assert.match(instructions, /closeLabel="Закрыть инструкции"/);
assert.match(routing, /closeLabel="Закрыть локальные правила"/);
assert.match(instructions, /closeRef\.current\?\.focus\(\)/);
assert.match(routing, /closeRef\.current\?\.focus\(\)/);
assert.match(connection, /aria-label={`Скопировать \$\{label\}: \$\{kind === 'gateway' \? gatewayAddress : proxyUrls\[kind\]\}`}/);
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-local-rule-enabled,[\s\S]*\.client-local-rule-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-row-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
});
+1 -1
View File
@@ -103,7 +103,7 @@ test('manual ping uses plain language and keeps results beside server names', ()
assert.match(picker, /is-scalable\$\{simpleServers\.length <= INLINE_SERVER_ROWS \? ' is-short' : ''\}/);
assert.match(styles, /\.client-server-row \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\) 120px minmax\(0, 1fr\);/);
assert.match(styles, /\.client-server-row \.client-server \{[\s\S]*?width: 120px;/);
assert.match(styles, /\.client-server-health \{[\s\S]*?font-size: 9px;[\s\S]*?font-variant-numeric: tabular-nums;/);
assert.match(styles, /\.client-server-health \{[\s\S]*?font: var\(--type-label\);[\s\S]*?font-variant-numeric: var\(--numeric-tabular\);/);
assert.match(styles, /\.client-server-meta \.client-server-health \{[\s\S]*?place-items: end start;[\s\S]*?padding-bottom: 7px;/);
assert.match(styles, /\.client-server-health\.is-checking \.client-server-health-checking \{[\s\S]*?animation: client-spin 900ms linear infinite;/);
assert.match(styles, /\.client-server-check\.is-checking svg \{[\s\S]*?animation: client-spin 900ms linear infinite;/);
+117 -18
View File
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import postcss from 'postcss';
import {
analyzeSelectorList,
@@ -36,26 +37,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 819,
customProperties: 31,
declarations: 2975,
cascadeEdges: 749,
customProperties: 103,
declarations: 3193,
important: 0,
keyframes: 56,
media: 13,
rules: 919,
variableReferences: 328,
rules: 916,
variableReferences: 788,
},
hashes: {
cascadeEdges: '1a3882545e488b80ac5454cc8fa236fd242d15eb8a27ac3371876af866b6c6e8',
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
declarations: '3972e7fd7da24d872a16cfa9dad9e9355861f489708fd9643a8610422e712825',
cascadeEdges: '1f48ed1c4272092b7698d94ea8d0fc1ebfb66454a6a715c9b684515bbff74fb8',
customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4',
declarations: '8507f8575ae0bd512449c411f0a553f6478ab18fdf217534a32617a8abb9aeb2',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: 'c0ec5c96e48cd2ceed32aa5050383bcf29c3c0b8f1b440ed6883435e3e9b964d',
keyframes: '39b6fc9c9ab451d63bb77afd90caf63d2d74a30cb351a8f86503de380d76a936',
ruleDeclarationSequences: '99469fb4eeb3af72a12cc4365f135e988ab95ebcf194572779d62e5185778d20',
selectors: 'b2564f9e6c078233b388e84680181cbd5f21d55e963907990b237b14c5bc250f',
variableReferences: 'c8348faf26b2174f02b9760aaa5812e22e02a72da7902fb5897f0c23e7f4e939',
witnesses: '3c44c91b2cc2a03049b328b0f7a974b1c189c66d2a778dbb386601eed82994e8',
duplicateSelectors: '419962e669cbfdf5c8186318cb26688477fc0199cae98df8e1cd9bf5cddd76c2',
keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac',
ruleDeclarationSequences: 'a1d47956771b1503fad124768e3157fe924c6c76914d219885e2cc2dab4413bb',
selectors: '6dd352f47606c21b5c07817e185753baa8b3168ab92d3cfef4b28f772b989cc7',
variableReferences: '700137c05be5f12722a7ebec90d06be70dde203a754a9312d45c03ff05ff08ec',
witnesses: 'f51f070cc2c7dba0f8d08518cdeee49eaf176aac82e3571cdd8498315ba8db83',
},
};
@@ -98,7 +99,7 @@ test('tokens, shared primitives, and feature styles have one explicit owner', ()
}
for (const keyframe of [
'client-local-rule-enter', 'client-local-rule-leave', 'client-delete-strike',
'client-row-enter', 'client-row-leave', 'client-delete-strike',
'client-delete-content-dim', 'client-spin', 'client-copy-fade',
]) {
const owners = Object.entries(sources)
@@ -108,6 +109,104 @@ test('tokens, shared primitives, and feature styles have one explicit owner', ()
}
});
test('client typography uses the shared semantic scale outside the token owner', () => {
const tokenRoot = postcss.parse(fs.readFileSync(path.join(stylesRoot, 'tokens.css'), 'utf8'));
const tokens = new Map();
tokenRoot.walkDecls((declaration) => tokens.set(declaration.prop, declaration.value));
const roleValues = {
micro: ['0.5rem', 'var(--font-weight-body) var(--font-size-micro)/var(--line-height-micro) var(--font-family-client)', '1.45', 'var(--tracking-normal)', 'var(--text-transform-none)'],
label: ['0.5625rem', 'var(--font-weight-bold) var(--font-size-label)/var(--line-height-label) var(--font-family-client)', '1.2', 'var(--tracking-label)', 'var(--text-transform-label)'],
control: ['0.625rem', 'var(--font-weight-bold) var(--font-size-control)/var(--line-height-control) var(--font-family-client)', '1.2', 'var(--tracking-control)', 'var(--text-transform-none)'],
body: ['0.6875rem', 'var(--font-weight-body) var(--font-size-body)/var(--line-height-body) var(--font-family-client)', '1.7', 'var(--tracking-normal)', 'var(--text-transform-none)'],
data: ['0.75rem', 'var(--font-weight-strong) var(--font-size-data)/var(--line-height-data) var(--font-family-client)', '1.2', 'var(--tracking-normal)', 'var(--text-transform-none)'],
'item-title': ['0.875rem', 'var(--font-weight-bold) var(--font-size-item-title)/var(--line-height-item-title) var(--font-family-client)', '1.2', 'var(--tracking-title)', 'var(--text-transform-none)'],
'section-title': ['1rem', 'var(--font-weight-bold) var(--font-size-section-title)/var(--line-height-section-title) var(--font-family-client)', '1.25', 'var(--tracking-title)', 'var(--text-transform-none)'],
'state-title': ['1.125rem', 'var(--font-weight-bold) var(--font-size-state-title)/var(--line-height-state-title) var(--font-family-client)', '1.3', 'var(--tracking-tight)', 'var(--text-transform-none)'],
'drawer-title': ['1.375rem', 'var(--font-weight-bold) var(--font-size-drawer-title)/var(--line-height-drawer-title) var(--font-family-client)', '1.15', 'var(--tracking-tight)', 'var(--text-transform-none)'],
};
for (const [role, [size, recipe, lineHeight, tracking, transform]] of Object.entries(roleValues)) {
assert.equal(tokens.get(`--font-size-${role}`), size, role);
assert.equal(tokens.get(`--type-${role}`), recipe, role);
assert.equal(tokens.get(`--line-height-${role}`), lineHeight, role);
assert.equal(tokens.get(`--type-${role}-tracking`), tracking, role);
assert.equal(tokens.get(`--type-${role}-transform`), transform, role);
}
assert.equal(tokens.get('--type-tooltip'),
'var(--font-weight-strong) var(--font-size-control)/var(--line-height-tooltip) var(--font-family-client)');
assert.equal(tokens.get('--line-height-tooltip'), '1.35');
const allowed = new Map([
['font', new Set(['inherit', 'var(--type-icon-close)', 'var(--type-tooltip)', ...Object.keys(roleValues).map((role) => `var(--type-${role})`)])],
['font-family', new Set(['var(--font-family-client)'])],
['font-size', new Set([
'var(--font-size-brand)', 'var(--font-size-brand-tooltip)', 'var(--font-size-brand-tooltip-strong)',
'var(--font-size-icon-chevron)', 'var(--font-size-icon-delete)',
])],
['font-weight', new Set(['var(--font-weight-bold)', 'var(--font-weight-brand)', 'var(--font-weight-strong)'])],
['line-height', new Set(['var(--line-height-micro)'])],
['letter-spacing', new Set([
'inherit', 'var(--tracking-normal)', 'var(--tracking-title)',
...Object.keys(roleValues).map((role) => `var(--type-${role}-tracking)`),
'var(--type-tooltip-tracking)',
])],
['text-transform', new Set([
...Object.keys(roleValues).map((role) => `var(--type-${role}-transform)`),
'var(--type-tooltip-transform)',
])],
['font-variant-numeric', new Set(['var(--numeric-tabular)'])],
]);
for (const leaf of styleLeafPaths(root).filter((file) => !file.endsWith('/tokens.css'))) {
const stylesheet = postcss.parse(fs.readFileSync(leaf, 'utf8'), { from: leaf });
stylesheet.walkDecls((declaration) => {
if (allowed.has(declaration.prop)) {
assert.ok(allowed.get(declaration.prop).has(declaration.value),
`${path.relative(root, leaf)}:${declaration.source.start.line} ${declaration.toString()}`);
}
});
stylesheet.walkRules((rule) => {
const font = rule.nodes.find((node) => node.type === 'decl' && node.prop === 'font');
const role = /^var\(--type-(micro|label|control|tooltip|body|data|item-title|section-title|state-title|drawer-title)\)$/.exec(font?.value || '')?.[1];
if (!role) return;
const declarations = new Map(rule.nodes
.filter((node) => node.type === 'decl')
.map((declaration) => [declaration.prop, declaration.value]));
assert.equal(declarations.get('letter-spacing'), `var(--type-${role}-tracking)`, rule.selector);
assert.equal(declarations.get('text-transform'), `var(--type-${role}-transform)`, rule.selector);
});
}
const styles = postcss.parse(readStyleSource(root));
for (const selector of [
'.client-instructions-header h2', '.client-devices-header h2',
'.client-local-rules-header h2', '.client-profiles-header h2',
]) {
const owner = styles.nodes.find((node) => node.type === 'rule' && node.selector === selector);
assert.equal(owner?.nodes.find((node) => node.type === 'decl' && node.prop === 'font')?.value,
'var(--type-drawer-title)', selector);
}
for (const selector of [
'.client-duration', '.client-power-section.is-gateway .client-duration', '.client-proxy-address',
'.client-device-ip', '.client-device-traffic strong',
'.client-device-traffic-point-tooltip', '.client-device-traffic-point-tooltip time',
'.client-device-traffic-point-tooltip strong', '.client-gateway-traffic-total > strong',
'.client-gateway-traffic-speed', '.client-diagnostics-table code',
'.client-server-health', '.harbor-versions', '.harbor-version-code', '.harbor-version-number',
]) {
const owners = [];
styles.walkRules((rule) => {
if (rule.selectors.includes(selector)) owners.push(rule);
});
assert.ok(owners.length > 0, selector);
assert.ok(owners.some((owner) => owner.nodes.some((node) => node.type === 'decl'
&& node.prop === 'font-variant-numeric' && node.value === 'var(--numeric-tabular)')), selector);
for (const owner of owners.filter((rule) => rule.nodes.some((node) => node.type === 'decl' && node.prop === 'font'))) {
assert.equal(owner.nodes.find((node) => node.type === 'decl' && node.prop === 'font-variant-numeric')?.value,
'var(--numeric-tabular)', selector);
}
}
});
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 730);
@@ -306,8 +405,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-kNiIGd5E.css']);
assert.deepEqual(assets, ['index-OzbtuDco.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 112296);
assert.equal(sha256(built), 'cbda70aff44215a69dc7c87ceaa4166ae573131bc53aaea7ddd935950b2c983f');
assert.equal(built.byteLength, 123218);
assert.equal(sha256(built), 'e462ddaffffecd18bbc0f14daebc11e6c0df149e6322e1356433d4052550389b');
});
+30 -20
View File
@@ -260,7 +260,7 @@ function dynamicClassBindings(files) {
}));
}
function bindComponentProps(definition, element, callerEnvironment, dynamicBindings) {
function bindComponentProps(definition, element, callerEnvironment, callerFile, dynamicBindings) {
const supplied = new Map();
for (const attribute of element.openingElement.attributes) {
if (attribute.type === 'JSXSpreadAttribute') {
@@ -277,9 +277,10 @@ function bindComponentProps(definition, element, callerEnvironment, dynamicBindi
callable: ['ArrowFunctionExpression', 'FunctionExpression'].includes(node.type) && producesJsx(node),
node,
environment: callerEnvironment,
file: callerFile,
});
}
supplied.set('children', { node: element.children, environment: callerEnvironment });
supplied.set('children', { node: element.children, environment: callerEnvironment, file: callerFile });
const environment = new Map(dynamicBindings.get(definition.file));
const parameter = definition.node.params?.[0];
if (parameter?.type === 'ObjectPattern') {
@@ -289,7 +290,7 @@ function bindComponentProps(definition, element, callerEnvironment, dynamicBindi
const target = property.value?.type === 'AssignmentPattern' ? property.value.left : property.value;
const localName = target?.name;
const fallback = property.value?.type === 'AssignmentPattern'
? { node: property.value.right, environment: callerEnvironment }
? { node: property.value.right, environment: callerEnvironment, file: definition.file }
: null;
if (sourceName && localName && (supplied.get(sourceName) || fallback)) {
environment.set(localName, supplied.get(sourceName) || fallback);
@@ -309,7 +310,7 @@ function producesJsx(node) {
));
}
function withLocalJsxBindings(node, environment, definitionNodes) {
function withLocalJsxBindings(node, environment, definitionNodes, file) {
const scoped = new Map(environment);
const collect = (value) => {
if (!value || typeof value !== 'object') return;
@@ -323,6 +324,7 @@ function withLocalJsxBindings(node, environment, definitionNodes) {
callable: ['ArrowFunctionExpression', 'FunctionExpression'].includes(value.init?.type),
declaration: value,
environment: scoped,
file,
node: value.init,
});
return;
@@ -336,12 +338,12 @@ function withLocalJsxBindings(node, environment, definitionNodes) {
return scoped;
}
function bindCallParameters(callable, argumentsList, callerEnvironment) {
function bindCallParameters(callable, argumentsList, callerEnvironment, callerFile) {
const environment = new Map(callable.environment);
for (let index = 0; index < callable.node.params.length; index += 1) {
const parameter = callable.node.params[index];
if (parameter?.type === 'Identifier' && argumentsList[index]) {
environment.set(parameter.name, { node: argumentsList[index], environment: callerEnvironment });
environment.set(parameter.name, { node: argumentsList[index], environment: callerEnvironment, file: callerFile });
}
}
return environment;
@@ -361,11 +363,17 @@ export function createStyleWitnesses(sources, { entry = 'App' } = {}) {
const expand = (definition, ancestors, ancestorUnknown, environment, stack) => {
if (stack.includes(definition)) throw new TypeError(`Recursive JSX witness component: ${definition.node.id?.name || definition.file}`);
const nextStack = [...stack, definition];
const entryEnvironment = withLocalJsxBindings(definition.node.body, environment, definitionNodes);
const visit = (node, currentAncestors = ancestors, currentUnknown = ancestorUnknown, currentEnvironment = entryEnvironment) => {
const entryEnvironment = withLocalJsxBindings(definition.node.body, environment, definitionNodes, definition.file);
const visit = (
node,
currentAncestors = ancestors,
currentUnknown = ancestorUnknown,
currentEnvironment = entryEnvironment,
currentFile = definition.file,
) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (const child of node) visit(child, currentAncestors, currentUnknown, currentEnvironment);
for (const child of node) visit(child, currentAncestors, currentUnknown, currentEnvironment, currentFile);
return;
}
if (definitionNodes.has(node)) return;
@@ -378,10 +386,11 @@ export function createStyleWitnesses(sources, { entry = 'App' } = {}) {
if (callable?.callable) {
const callEnvironment = withLocalJsxBindings(
callable.node.body,
bindCallParameters(callable, node.arguments, currentEnvironment),
bindCallParameters(callable, node.arguments, currentEnvironment, currentFile),
definitionNodes,
callable.file || currentFile,
);
visit(callable.node.body, currentAncestors, currentUnknown, callEnvironment);
visit(callable.node.body, currentAncestors, currentUnknown, callEnvironment, callable.file || currentFile);
return;
}
}
@@ -401,16 +410,16 @@ export function createStyleWitnesses(sources, { entry = 'App' } = {}) {
if (!documentBody && !appOrBody) {
throw new TypeError(`Unsupported JSX portal target in ${definition.file}`);
}
visit(node.arguments[0], [], false, currentEnvironment);
visit(node.arguments[0], [], false, currentEnvironment, currentFile);
if (appOrBody) {
visit(node.arguments[0], [{ classes: ['app', 'client-app', 'is-gateway-app'], prefixes: [], unknown: false }], false, currentEnvironment);
visit(node.arguments[0], [{ classes: ['app', 'client-app', 'is-gateway-app'], prefixes: [], unknown: false }], false, currentEnvironment, currentFile);
}
return;
}
if (node.type === 'JSXExpressionContainer' && node.expression?.type === 'Identifier') {
const binding = currentEnvironment.get(node.expression.name);
if (binding) {
visit(binding.node, currentAncestors, currentUnknown, binding.environment);
visit(binding.node, currentAncestors, currentUnknown, binding.environment, binding.file || currentFile);
return;
}
}
@@ -419,7 +428,7 @@ export function createStyleWitnesses(sources, { entry = 'App' } = {}) {
const intrinsic = Boolean(name && /^[a-z]/.test(name));
if (intrinsic) {
const initialInfo = elementClassInfo(node.openingElement, currentEnvironment);
const additions = [...imperativeByFile.get(definition.file).entries()]
const additions = [...imperativeByFile.get(currentFile).entries()]
.filter(([target]) => initialInfo.classes.includes(target))
.flatMap(([, classes]) => classes);
const info = elementClassInfo(node.openingElement, currentEnvironment, additions);
@@ -432,26 +441,26 @@ export function createStyleWitnesses(sources, { entry = 'App' } = {}) {
tag: name,
unknown: info.unknown,
});
visit(node.children, [...currentAncestors, info], currentUnknown, currentEnvironment);
visit(node.children, [...currentAncestors, info], currentUnknown, currentEnvironment, currentFile);
return;
}
if (TRANSPARENT_JSX_COMPONENTS.has(name)) {
visit(node.children, currentAncestors, currentUnknown, currentEnvironment);
visit(node.children, currentAncestors, currentUnknown, currentEnvironment, currentFile);
return;
}
const target = definitions.get(name);
if (!target) throw new TypeError(`Unresolved JSX witness component: ${name} in ${definition.file}`);
const targetEnvironment = bindComponentProps(target, node, currentEnvironment, dynamicByFile);
const targetEnvironment = bindComponentProps(target, node, currentEnvironment, currentFile, dynamicByFile);
expand(target, currentAncestors, currentUnknown, targetEnvironment, nextStack);
return;
}
if (node.type === 'JSXFragment') {
visit(node.children, currentAncestors, currentUnknown, currentEnvironment);
visit(node.children, currentAncestors, currentUnknown, currentEnvironment, currentFile);
return;
}
for (const [key, child] of Object.entries(node)) {
if (['loc', 'start', 'end', 'extra', 'comments', 'tokens', 'errors'].includes(key)) continue;
if (child && typeof child === 'object') visit(child, currentAncestors, currentUnknown, currentEnvironment);
if (child && typeof child === 'object') visit(child, currentAncestors, currentUnknown, currentEnvironment, currentFile);
}
};
visit(definition.node.body, ancestors, ancestorUnknown, entryEnvironment);
@@ -521,6 +530,7 @@ const TRANSPARENT_JSX_COMPONENTS = new Set(['React.Fragment']);
const hash = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
function propertyFamily(property) {
if (property.startsWith('--')) return property;
if (!OBSERVED_PROPERTIES.has(property)) {
throw new TypeError(`Unsupported CSS property in cascade proof: ${property}`);
}
+40
View File
@@ -0,0 +1,40 @@
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 webRoot = path.join(root, 'src/web');
const sourceFiles = fs.readdirSync(webRoot, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.tsx'))
.map((entry) => path.join(entry.parentPath, entry.name));
const sources = sourceFiles.map((file) => ({ file, source: fs.readFileSync(file, 'utf8') }));
const ui = (name) => fs.readFileSync(path.join(webRoot, 'ui', `${name}.tsx`), 'utf8');
test('shared client primitives own repeated markup without feature dependencies', () => {
const tooltip = ui('Tooltip');
const copyButton = ui('CopyButton');
const railAction = ui('RailAction');
const drawer = ui('Drawer');
for (const source of [tooltip, copyButton, railAction, drawer]) {
assert.doesNotMatch(source, /features|api|state\//);
}
assert.match(tooltip, /className="client-tooltip"[\s\S]*role="tooltip"/);
assert.match(copyButton, /client-copy-label[\s\S]*client-copy-feedback/);
assert.match(railAction, /aria-expanded=\{open\}[\s\S]*aria-controls=\{controls\}/);
assert.match(drawer, /<aside[\s\S]*aria-hidden=\{!open\}[\s\S]*inert=\{!open[\s\S]*client-drawer-close/);
});
test('all repeated client primitive consumers use the shared owners', () => {
const production = sources
.filter(({ file }) => !file.includes(`${path.sep}ui${path.sep}`))
.map(({ source }) => source)
.join('\n');
assert.equal((production.match(/<Tooltip\b/g) || []).length, 9);
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
assert.equal((production.match(/<RailAction\b/g) || []).length, 5);
assert.equal((production.match(/<Drawer\b/g) || []).length, 5);
assert.doesNotMatch(production, /className="client-tooltip"|className="client-copy-label"|className="client-drawer-close"/);
});