Update Harbor client and gateway functionality
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-17 15:23:16 +03:00
parent 0b39211fbd
commit 7c255192b0
41 changed files with 1443 additions and 218 deletions
+3 -2
View File
@@ -93,11 +93,12 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
[() => api.gatewayAuto.setEnabled(true), '/api/gateway-auto', {
method: 'POST', body: JSON.stringify({ enabled: true }),
}],
[() => api.routeRules.update([{ type: 'domain', value: 'example.com' }], 7), '/api/route-rules', {
[() => api.routeRules.update([{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' }], 7), '/api/route-rules/v2', {
method: 'PUT',
body: JSON.stringify({
rules: [{ type: 'domain', value: 'example.com' }],
rules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' }],
expectedRulesRevision: 7,
rulesContractVersion: 2,
}),
}],
[() => api.devices.list(), '/api/devices', {}],
+24
View File
@@ -67,6 +67,30 @@ test('typed Harbor client validates unknown state and isolates wire compatibilit
assert.equal(failed.transport.bootStatus, 'incompatible-api');
});
test('an old rules snapshot remains readable but keeps the mutation capability absent', () => {
const current = createStateSnapshot({
storedState: {
routeRules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'direct' }],
appliedRouteRules: [{ type: 'domain', value: 'example.com', enabled: true, outbound: 'direct' }],
},
runtime: { running: true },
gatewayAuto: null,
appMode: 'client',
configExists: true,
now: new Date('2026-08-17T12:00:00.000Z'),
});
const legacy = structuredClone(current);
delete legacy.route.rulesContractVersion;
for (const rule of [...legacy.route.localRules, ...legacy.route.activeLocalRules]) delete rule.outbound;
const parsed = parseHarborState(legacy);
assert.equal(parsed.route.rulesContractVersion, undefined);
assert.deepEqual(parsed.route.localRules, [
{ type: 'domain', value: 'example.com', enabled: true, outbound: 'direct' },
]);
assert.equal(parseHarborState(current).route.rulesContractVersion, 2);
});
test('data invariant: an older polling promise cannot replace a newer mutation snapshot', async () => {
let state = receive(initialHarborState, snapshot(1, 'one'));
const poll = deferred();
+18 -1
View File
@@ -132,7 +132,8 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
assert.match(component, /<ConnectivityDiagnosticsPanel/);
assert.doesNotMatch(component, /diagnosticsToggleRef|diagnosticsPanelRef|diagnosticsCloseRef/);
assert.match(component, /<ConnectivityDiagnosticsPanel[\s\S]*isGateway=\{isGateway\}/);
assert.match(routing, /Локальные правила недоступны: сейчас работают правила Gateway/);
assert.doesNotMatch(routing, /const disabled = gatewayDirect \|\|/);
assert.match(routing, /Локальный список сейчас обходится Harbor 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/);
@@ -192,6 +193,22 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
assert.doesNotMatch(component, /instructionsFeature\.close\(\)[\s\S]{0,180}devicesFeature\.close\(\)[\s\S]{0,180}subscriptionFeature\.toggle\(\)/);
});
test('routing rules keep two desktop rows and three narrow rows without shrinking drag targets', () => {
assert.match(rule('.client-local-rule'), /grid-template-columns:\s*44px 24px 116px minmax\(0, 1fr\) 28px/);
assert.match(rule('.client-local-rule-meta'), /grid-column:\s*2 \/ -1[\s\S]*grid-row:\s*2/);
const mobile = /@media \(max-width: 560px\) \{([\s\S]*?)\n\}\s*$/.exec(layoutStyles)?.[1] || '';
assert.match(mobile, /grid-template-columns:\s*44px 44px minmax\(0, 1fr\) 44px/);
assert.match(mobile, /\.client-local-rule input \{[\s\S]*grid-row:\s*2/);
assert.match(mobile, /\.client-local-rule-meta \{[\s\S]*grid-row:\s*3/);
assert.match(mobile, /\.client-rule-handle,[\s\S]*width:\s*44px;[\s\S]*height:\s*44px/);
assert.match(mobile, /\.client-rule-type-trigger,[\s\S]*\.client-rule-outbound button \{[\s\S]*height:\s*44px/);
assert.match(mobile, /\.client-rule-type-list button \{[\s\S]*min-height:\s*44px/);
const compact = layoutStyles.slice(layoutStyles.indexOf('@media (max-width: 360px)'));
assert.match(compact, /\.client-local-rule-meta \{[\s\S]*flex-wrap:\s*wrap/);
assert.match(compact, /\.client-rule-outbound \{[\s\S]*width:\s*100%[\s\S]*flex-basis:\s*100%/);
assert.match(compact, /\.client-local-rule-status \{[\s\S]*width:\s*100%[\s\S]*white-space:\s*normal/);
});
test('connectivity diagnostics render stable compact tables before the first run', () => {
assert.match(diagnostics, /CONNECTIVITY_IP_SOURCES\.map/);
assert.match(diagnostics, /CONNECTIVITY_SITES\.filter\(\(\{ id \}\) => !hiddenServiceIds\.includes\(id\)\)/);
+21 -4
View File
@@ -21,16 +21,33 @@ test('routing feature is the sole owner at the four existing composition positio
test('routing controller preserves snapshot drafts, live status and guarded close/save semantics', () => {
assert.match(feature, /const savedRules = route\?\.localRules \|\| \[\]/);
assert.match(feature, /baselineRef\.current = JSON\.stringify\(savedRules\.map/);
assert.match(feature, /setRules\(savedRules\.map\(createLocalRuleDraft\)\)/);
assert.match(feature, /baselineRef\.current = localRulesSignature\(savedRules\)/);
assert.match(feature, /const nextRules = savedRules\.map\(createLocalRuleDraft\)/);
assert.match(feature, /setRevision\(route\?\.localRulesRevision \|\| 0\)/);
assert.match(feature, /if \(dirty\) \{[\s\S]*setConfirmingClose\(true\);[\s\S]*return false/);
assert.match(feature, /const currentRules = cancelReorder\(false\)[\s\S]*localRulesSignature\(currentRules\) !== baselineRef\.current/);
assert.match(feature, /const result = routingSaveState\(await onSave\(values, revision\)\)/);
assert.match(feature, /if \(!result\) return;[\s\S]*baselineRef\.current = JSON\.stringify\(values\);[\s\S]*setRevision\(result\.localRulesRevision\)/);
assert.match(feature, /if \(!result\) return;[\s\S]*baselineRef\.current = localRulesSignature\(values\);[\s\S]*setRevision\(result\.localRulesRevision\)/);
assert.match(feature, /if \(!connected \|\| !result\.localRulesPendingRestart\) setIsOpen\(false\)/);
assert.match(feature, /Number\.isSafeInteger\(localRulesRevision\)[\s\S]*localRulesRevision as number\) < 0[\s\S]*typeof localRulesPendingRestart !== 'boolean'/);
});
test('routing controller owns ordered outbound drafts, capability gating and drag cancellation', () => {
assert.match(feature, /map\(\(\{ type, value, enabled, outbound \}\) => \(\{ type, value, enabled, outbound \}\)\)/);
assert.match(feature, /route\?\.rulesContractVersion === ROUTE_RULES_CONTRACT_VERSION/);
assert.match(feature, /if \(!editable \|\| blocked\) return/);
assert.match(feature, /sameRule\(rule, savedRules\[index\]\)/);
assert.match(feature, /sameRule\(rule, activeRules\[index\]\)/);
assert.match(feature, /beginRuleReorder\(dragRef\.current, ruleKey, 'pointer'\)[\s\S]*setPointerCapture/);
assert.match(feature, /beginRuleReorder\(dragRef\.current, ruleKey, 'keyboard'\)/);
assert.match(feature, /event\.detail === 0\) toggleKeyboardReorder/);
assert.match(feature, /event\.key === 'Tab'[\s\S]*finishReorder\('focus-leave'\)/);
assert.match(feature, /onBlur=\{\(event\) => feature\.handleReorderBlur/);
assert.match(feature, /endRuleReorder\(session, 'unmount'\)\.stopAutoScroll/);
assert.match(feature, /onLostPointerCapture=\{feature\.losePointerReorder\}/);
assert.match(feature, /keyboardEvent\.preventDefault\(\);[\s\S]*cancelReorder\(\)/);
assert.match(feature, /stopAutoScroll\(session\)[\s\S]*Перемещение отменено/);
});
test('routing lifecycle and Page orchestration keep the existing guards and blocking scopes', () => {
assert.match(feature, /keyboardEvent\.key !== 'Escape' \|\| keyboardEvent\.defaultPrevented/);
assert.match(feature, /addEventListener\('beforeunload', warnBeforeUnload\)/);
+18 -3
View File
@@ -27,7 +27,7 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
assert.match(routing, /if \(!runtimeActive\) return \['saved', 'Сохранено'\]/);
assert.match(routing, /Ждёт перезапуска/);
assert.match(routing, /Перезапустить VPN/);
assert.match(routing, /const pendingRestart = connected && route\?\.localRulesPendingRestart === true/);
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/);
@@ -89,12 +89,27 @@ test('copy feedback, drawers and Gateway access actions expose complete semantic
assert.doesNotMatch(component, /ГОТОВО/);
assert.doesNotMatch(component, />Error<|>Copied</);
assert.match(instructions, /closeLabel="Закрыть инструкции"/);
assert.match(routing, /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-local-rule-enabled,[\s\S]*\.client-row-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-rule-handle,[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-row-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
});
test('ordered rules use one accessible drag handle and a fixed two-target control', () => {
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(routing, /data-rule-key[^>]*onPointerDown/);
assert.match(routing, /onClick=\{\(event\) => feature\.handleReorderClick\(event, rule\._key\)\}/);
assert.match(routing, /<svg viewBox="0 0 12 28" aria-hidden="true">[\s\S]*<circle[\s\S]*<circle[\s\S]*<circle/);
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" role="group"[\s\S]*\['vpn', 'VPN'\][\s\S]*\['direct', 'Напрямую'\]/);
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-rule-outbound \{[\s\S]*width: 128px/);
});
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
beginRuleReorder,
crossedRuleIndex,
edgeScrollDelta,
endRuleReorder,
keyboardRuleIndex,
moveRule,
restoreRuleOrder,
RULE_DROP_DURATION_MS,
RULE_REORDER_DURATION_MS,
shouldLiftRule,
} from '../../.test-dist/src/web/features/routing/ruleReorderModel.js';
test('rule drag lifts only after four vertical pixels', () => {
assert.equal(shouldLiftRule(100, 103), false);
assert.equal(shouldLiftRule(100, 104), true);
assert.equal(shouldLiftRule(100, 96), true);
assert.equal(shouldLiftRule(100, 100), false, 'horizontal-only movement leaves Y unchanged');
});
test('center crossing moves one or many slots without changing stable row identity', () => {
const rules = [{ _key: 'a' }, { _key: 'b' }, { _key: 'c' }];
assert.equal(crossedRuleIndex(0, 149, [100, 150, 200]), 0);
assert.equal(crossedRuleIndex(0, 151, [100, 150, 200]), 1);
assert.equal(crossedRuleIndex(0, 201, [100, 150, 200]), 2);
const moved = moveRule(rules, 0, 2);
assert.deepEqual(moved.map(({ _key }) => _key), ['b', 'c', 'a']);
assert.strictEqual(moved[2], rules[0]);
const edited = [{ _key: 'b', value: 2 }, { _key: 'a', value: 10 }, { _key: 'd', value: 4 }];
const restored = restoreRuleOrder(rules, edited, ({ _key }) => _key);
assert.deepEqual(restored.map(({ _key }) => _key), ['a', 'b', 'd']);
assert.equal(restored[0].value, 10, 'cancel restores order without reverting edits');
});
test('one lifecycle owner covers pointer, keyboard, cleanup and reduced motion', () => {
const pointer = beginRuleReorder(null, 'a', 'pointer');
assert.deepEqual(pointer, { key: 'a', input: 'pointer', lifted: false });
assert.equal(beginRuleReorder(pointer, 'b', 'keyboard'), null, 'a second handle cannot replace the session');
const liftedPointer = { ...pointer, lifted: true };
assert.deepEqual(endRuleReorder(liftedPointer, 'drop'), {
restoreOrder: false,
stopAutoScroll: true,
restoreFocus: true,
releasePointerCapture: true,
animateDrop: true,
});
assert.equal(endRuleReorder(liftedPointer, 'drop', true).animateDrop, false);
assert.equal(endRuleReorder(liftedPointer, 'lost-capture').releasePointerCapture, false);
assert.equal(endRuleReorder(liftedPointer, 'cancel').restoreOrder, true);
assert.equal(endRuleReorder(liftedPointer, 'unmount').restoreFocus, false);
const keyboard = beginRuleReorder(null, 'b', 'keyboard');
assert.deepEqual(keyboard, { key: 'b', input: 'keyboard', lifted: true });
const focusLeave = endRuleReorder(keyboard, 'focus-leave');
assert.equal(focusLeave.restoreOrder, true);
assert.equal(focusLeave.stopAutoScroll, true);
assert.equal(focusLeave.restoreFocus, false);
assert.equal(focusLeave.releasePointerCapture, false);
});
test('keyboard movement stops at list boundaries', () => {
assert.equal(keyboardRuleIndex(0, -1, 3), 0);
assert.equal(keyboardRuleIndex(0, 1, 3), 1);
assert.equal(keyboardRuleIndex(2, 1, 3), 2);
});
test('edge scrolling has direction, bounded speed and a zero outside its 32px zone', () => {
assert.equal(edgeScrollDelta(99, 100, 300), 0);
assert.equal(edgeScrollDelta(100, 100, 300), -12);
assert.ok(edgeScrollDelta(131, 100, 300) <= -2);
assert.equal(edgeScrollDelta(132, 100, 300), 0);
assert.equal(edgeScrollDelta(268, 100, 300), 0);
assert.ok(edgeScrollDelta(269, 100, 300) >= 2);
assert.equal(edgeScrollDelta(300, 100, 300), 12);
assert.equal(edgeScrollDelta(301, 100, 300), 0);
assert.equal(RULE_REORDER_DURATION_MS, 220);
assert.equal(RULE_DROP_DURATION_MS, 260);
});
+18 -18
View File
@@ -37,26 +37,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 820,
customProperties: 105,
declarations: 3307,
cascadeEdges: 856,
customProperties: 106,
declarations: 3387,
important: 0,
keyframes: 49,
media: 12,
rules: 941,
variableReferences: 814,
media: 13,
rules: 959,
variableReferences: 829,
},
hashes: {
cascadeEdges: 'a90bd0c489fe893ba65d6b4be6a56fdde8b9c8ba5a9ce606190c6eb95f7a25b1',
customProperties: 'dede9598ee62929fb29ff470897cd475ca34eb5af5dda342b09e306f3eaee887',
declarations: '782a758059d22fe094c2a972f315f5b7273987d5240a5d4c652959fe646f96aa',
cascadeEdges: '0aa9651d9c84e6274ee5f64cd3151304b612aff0a06858c3d86d935996a36216',
customProperties: '06f39794d72566c2b2f8e4a2354866a54dbdceac0a1fdf9ae2619aae9abf2f36',
declarations: '09d0332d509ab6ae1d148a75ef5e931439eab7b29909529c8454382f02778fba',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '0fa66e370695261a2a5c6a189752c07d518d80d68d3e79a3ba4bb34407893c0e',
duplicateSelectors: 'abdba98d777bbb19d76443af200ea2cc9018a11e5ef4ae2885ff2f5dd07ef178',
keyframes: '9e78309512ed82b1e9f87c58aeff505dfcdb694fc30c8f54570d01dce13eb51a',
ruleDeclarationSequences: '04efee8e9ff18e320bdb1c7c6981097a8d5bdf9fab5a461403425053cba4d172',
selectors: '59d230e56506322b783962fbd2b86a1a0a3d08a3eb6ecba57f23ba20733a33c8',
variableReferences: '67fbd84828e97d10d7fe163ebea6504e8c48355e99616ede2498307d836fa616',
witnesses: '2f49f8c981cb3e6dbcdffd6bf3a436c8d66492546c2a1984853f9c9108e8d0ec',
ruleDeclarationSequences: '6e22245aef1e61fe8d56ad064d5e60d02696ec2af5446caacd66311973d5e02c',
selectors: '6487fcc63dbe251f250b79dd1430542e366c598ce93208eebfe51c344dcac38a',
variableReferences: 'f9899b21c75649b687ebd660de6e3781aade3345e1f5bac49b0f6e235f63947d',
witnesses: '87efd05d7a1fd22bc4f421573b41a115a86b2587703266079f9c913e26c323c9',
},
};
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 824);
assert.equal(witnesses.length, 838);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -405,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-CE2JQAR2.css']);
assert.deepEqual(assets, ['index-BoF9kamK.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 128371);
assert.equal(sha256(built), '0254a99e188554e0a2ae34dc9ac60109d86f9f1b6c2c96d2f8dcadb6641afe6c');
assert.equal(built.byteLength, 131216);
assert.equal(sha256(built), '1cab733ed1ae385a808f2afe5666ecc2252b48808d69a20c85e148c5eb60b96f');
});
+2 -2
View File
@@ -479,7 +479,7 @@ export function readStyleWitnesses(root) {
const OBSERVED_PROPERTIES = new Set(`
--client-accent --client-accent-soft --client-bg --client-border --client-control
--client-delete-strike-y --client-device-chart-height --client-device-copy-color
--client-delete-strike-y --client-device-chart-height --client-device-copy-color --client-rule-drag-y
--client-muted --client-panel --client-power-top --client-text --client-work-height
--harbor-connect --harbor-gateway --harbor-word -webkit-backdrop-filter
-webkit-text-fill-color align-content align-items align-self animation
@@ -497,7 +497,7 @@ padding-bottom padding-inline padding-left padding-right padding-top place-conte
place-items pointer-events position right row-gap scrollbar-width stroke stroke-dasharray
stroke-dashoffset stroke-linecap stroke-linejoin stroke-width table-layout text-align
text-decoration text-overflow text-shadow text-transform text-underline-offset top
transform transform-box transform-origin transition transition-delay user-select
touch-action transform transform-box transform-origin transition transition-delay user-select
vector-effect vertical-align visibility white-space width will-change z-index
`.trim().split(/\s+/));