Update Harbor client and gateway integration workflows
Build and Deploy Gateway / build-and-push (push) Failing after 14s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-19 18:16:10 +03:00
parent 416b2b294a
commit daec12e013
63 changed files with 5016 additions and 108 deletions
@@ -0,0 +1,27 @@
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 page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
const feature = fs.readFileSync(path.join(root, 'src/web/features/activity-journal/ActivityJournalFeature.tsx'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'src/web/styles/features/activity-journal.css'), 'utf8');
test('journal is the last Gateway-only drawer and refreshes whenever it opens', () => {
assert.match(page, /<RoutingToggle[\s\S]*\{isGateway && <ActivityJournalToggle/);
assert.match(page, /DRAWER_ORDER = \[[^\]]*'journal'\]/);
assert.match(feature, /if \(feature\.isOpen\) void load\(null, status !== 'idle'\)/);
assert.match(feature, /assertActivityJournalPage\(await loadPage\(cursor\)\)/);
});
test('journal presents 30-day grouped history with refresh, pagination and stable states', () => {
assert.match(feature, /Важные события хранятся 30 дней/);
assert.match(feature, /Сегодня[\s\S]*Вчера/);
assert.match(feature, /Обновить журнал/);
assert.match(feature, /M5 8V4m0 4h4/);
assert.match(feature, /Показать ещё/);
assert.match(feature, /Журнал временно недоступен[\s\S]*Повторить/);
assert.match(styles, /\.client-journal-skeleton/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)/);
});
+1 -1
View File
@@ -34,7 +34,7 @@ test('connection feature preserves actions, local preference and opaque neighbor
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=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
assert.match(page, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity \|\| failoverIdentity\} \/>\}/);
assert.match(page, /mainIdentity[\s\S]*appliedProfile[\s\S]*appliedServer/);
assert.match(page, /statusSlot=\{<>[\s\S]*InlineError[\s\S]*InlineProgress/);
});
+1 -1
View File
@@ -233,7 +233,7 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
assert.match(overview, /const appliedServer = appliedProfile\?\.servers\.find[\s\S]*state\.selection\.appliedServerSnapshot/);
assert.match(overview, /const mainIdentity = gatewayDirect[\s\S]*: connected[\s\S]*appliedProfile && appliedServer[\s\S]*`\$\{subscriptionDomain\(appliedProfile\.subscription\.host\)\} · \$\{appliedServer\.label\}`/);
assert.match(overview, /const switchIdentity = gatewayDirect[\s\S]*switchingServer && operationProfile && operationServer[\s\S]*`Переключаем на \$\{subscriptionDomain\(operationProfile\.subscription\.host\)\} · \$\{operationServer\.label\}`/);
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity \|\| failoverIdentity\} \/>\}/);
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
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/);
@@ -10,6 +10,7 @@ const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewP
const feature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8');
const panel = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8');
const model = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/connectivityResult.ts'), 'utf8');
const customServiceAction = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/customServiceAction.ts'), 'utf8');
const boundary = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/index.ts'), 'utf8');
const ip = { source: 'cloudflare', address: '198.51.100.10', extra: true };
@@ -82,7 +83,7 @@ test('unknown target and legacy-full results pass one identity-preserving parser
assert.doesNotMatch(model, /\sas\s(?:ConnectivityResult|Record<string, unknown>)/);
});
test('serial probes and editor stay panel-owned while backend state owns the service set', () => {
test('serial probes stay panel-owned while custom service validation is shared by both editors', () => {
assert.match(panel, /const targets = onlyTarget \? \[onlyTarget\] : \[[\s\S]*CONNECTIVITY_NETWORK_SOURCE\.id[\s\S]*CONNECTIVITY_IP_SOURCES\.map[\s\S]*sites\.map/);
assert.match(panel, /for \(const target of targets\) \{[\s\S]*setActiveTarget\(target\)[\s\S]*await runConnectivityDiagnostics\(target\)[\s\S]*if \(legacyFullResult\) break/);
assert.match(panel, /CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services'/);
@@ -92,7 +93,8 @@ test('serial probes and editor stay panel-owned while backend state owns the ser
assert.doesNotMatch(panel, /localStorage\.setItem/);
assert.doesNotMatch(panel, /useState\(read(?:Custom|Hidden)Services\)/);
assert.match(panel, /slice\(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES\)/);
assert.match(panel, /parsed\.protocol !== 'https:'/);
assert.match(panel, /saveCustomDiagnosticService/);
assert.match(customServiceAction, /parsed\.protocol !== 'https:'/);
assert.match(panel, /document\.startViewTransition\(update\)/);
assert.match(panel, /retryable: Boolean\(Reflect\.get\(value, 'retryable'\)\)/);
assert.match(panel, /requestDetails\(error\)[\s\S]*requestError\.retryable/);
@@ -0,0 +1,41 @@
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 page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
const feature = fs.readFileSync(path.join(root, 'src/web/features/failover/FailoverFeature.tsx'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'src/web/styles/features/failover.css'), 'utf8');
test('reserve is a Gateway-only drawer immediately after subscriptions', () => {
assert.match(page, /<SubscriptionToggle[\s\S]*\{isGateway && <FailoverToggle[\s\S]*<InstructionsToggle/);
assert.match(page, /\{isGateway && hasSubscription && <FailoverPanel/);
assert.match(page, /DRAWER_ORDER = \['subscription', 'failover'/);
assert.match(feature, /label="Резерв"/);
assert.match(feature, /Уже открытые соединения Harbor не закрывает/);
});
test('failover settings expose an off switch, channel targets, checks and per-service timeouts', () => {
assert.match(feature, /Использовать резерв[\s\S]*Полностью пассивен/);
assert.match(feature, /\['primary', 'reserve'\][\s\S]*Подписка[\s\S]*Сервер/);
assert.match(feature, /Что проверять/);
assert.match(feature, /min="2"[\s\S]*max="30"[\s\S]*Таймаут проверки:/);
assert.match(feature, /Проверять каждые, сек[\s\S]*Сбой должен длиться, сек[\s\S]*Восстановление, сек/);
assert.match(feature, /Не переключать во время работы[\s\S]*Тишина перед переключением[\s\S]*Активный трафик, КБ\/с/);
assert.match(feature, /Защита от повторных сбоев[\s\S]*Окно повторных сбоев[\s\S]*Карантин основного/);
assert.match(feature, /Добавить HTTPS-сервис/);
assert.match(feature, /Проверить оба канала/);
assert.match(feature, /beforeunload/);
});
test('runtime status keeps fixed geometry and explains active traffic without motion dependence', () => {
assert.match(feature, /Ждём завершения активной работы/);
assert.match(feature, /Активный трафик ·[\s\S]*transmittingConnections/);
assert.match(feature, /activity\?\.blockers\.slice\(0, 2\)\.map/);
assert.match(feature, /Ещё \{activity\.blockers\.length - 2\}/);
assert.match(feature, /Нестабилен ·[\s\S]*Следующее решение не раньше чем через/);
assert.match(feature, /switchRole && snapshot\.enabled && snapshot\.activation === 'active'/);
assert.match(styles, /\.client-failover-runtime \{[^}]*min-height:\s*132px/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*transition:\s*none/);
});
+19
View File
@@ -56,6 +56,10 @@ test('typed Harbor client validates unknown state and isolates wire compatibilit
gatewayAvailable: true,
});
assert.equal(Object.hasOwn(parsed, 'proxyPort'), false);
assert.throws(() => parseHarborState({
...snapshot,
failover: { ...snapshot.failover, policy: { ...snapshot.failover.policy, intervalMs: 'fast' } },
}), { code: 'INCOMPATIBLE_API' });
let incompatible;
try {
parseHarborState({ ...snapshot, revision: -1 });
@@ -116,6 +120,21 @@ test('an equal revision keeps the current snapshot identity', () => {
assert.equal(next.snapshot.selection.desiredServerId, 'one');
});
test('equal revision accepts only newer failover observations and retires old epochs', () => {
const value = (epoch, sequence, status) => ({
...snapshot(4, 'one'),
failover: { observationEpoch: epoch, observationSequence: sequence, status },
});
let state = receive(initialHarborState, value('epoch-a', 1, 'primary'));
state = receive(state, value('epoch-a', 2, 'waiting-for-idle'));
assert.equal(state.snapshot.failover.status, 'waiting-for-idle');
state = receive(state, value('epoch-b', 1, 'reserve'));
assert.equal(state.snapshot.failover.status, 'reserve');
state = receive(state, value('epoch-a', 99, 'primary'));
assert.equal(state.snapshot.failover.status, 'reserve');
assert.deepEqual(state.failoverTransport.retiredEpochs, ['epoch-a']);
});
test('selection has no client-side shadow and follows canonical profile snapshots', () => {
const state = receive(initialHarborState, snapshot(2, 'two'));
assert.equal(Object.hasOwn(state, 'pendingServerId'), false);
+2 -2
View File
@@ -166,13 +166,13 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
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, /const DRAWER_ORDER = \['subscription', 'instructions', 'devices', 'diagnostics', 'routing'\]/);
assert.match(component, /const DRAWER_ORDER = \['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'\]/);
assert.match(component, /function switchDrawer\(target: DrawerKey\)[\s\S]*from\.inert = true[\s\S]*translateY\(\$\{direction \* 100\}%\)[\s\S]*translateY\(\$\{-direction \* 100\}%\)/);
assert.match(component, /const \[drawerSwitchTarget, setDrawerSwitchTarget\] = useState<DrawerKey \| null>\(null\)/);
assert.match(component, /const activeRailDrawer = drawerSwitchTarget && drawerControls\[drawerSwitchTarget\]\.isOpen[\s\S]*drawerControls\[drawer\]\.isOpen/);
assert.match(component, /setDrawerSwitchTarget\(target\);[\s\S]*flushSync\(\(\) => toControl\.show\(\)\)/);
assert.match(component, /fromControl\.close\(\);[\s\S]*setDrawerSwitchTarget\(null\)/);
assert.match(component, /<SubscriptionToggle[\s\S]*open=\{activeRailDrawer === 'subscription'\}[\s\S]*<RoutingToggle[\s\S]*open=\{activeRailDrawer === 'routing'\}/);
assert.match(component, /<SubscriptionToggle[\s\S]*open=\{activeRailDrawer === 'subscription'\}[\s\S]*<FailoverToggle[\s\S]*<RoutingToggle[\s\S]*open=\{activeRailDrawer === 'routing'\}[\s\S]*<ActivityJournalToggle/);
assert.match(component, /const cancel = \(\) => \{[\s\S]*from\.inert = false;[\s\S]*from\.removeAttribute\('aria-hidden'\)/);
assert.match(component, /DRAWER_SWITCH_MS = 620[\s\S]*prefers-reduced-motion: reduce[\s\S]*flushSync/);
assert.match(component, /current === 'routing' && routingFeature\.dirty[\s\S]*routingFeature\.requestClose\(\)/);
+18 -16
View File
@@ -31,36 +31,38 @@ const expectedImports = [
'./features/servers.css',
'./primitives.css',
'./features/diagnostics.css',
'./features/failover.css',
'./features/activity-journal.css',
'./layout.css',
'./themes.css',
];
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 954,
cascadeEdges: 984,
customProperties: 106,
declarations: 3487,
declarations: 3805,
important: 0,
keyframes: 48,
media: 13,
rules: 971,
variableReferences: 837,
media: 17,
rules: 1045,
variableReferences: 977,
},
hashes: {
cascadeEdges: 'd53f6a2236717d6bc27d486fc19f0f80a33169fa7f68a6df2a19536fd30be3bb',
cascadeEdges: '423d29f846b45c3522fdc101b5aa1399264f2abe5953b9687b380d5fb865bd07',
customProperties: 'fd6f16069f9fe3526f09d4d574431ddae67150d8e7a8a40e04c9fd2d179ec7ac',
declarations: 'e1401e80ed2ebc5d3601d4d61a3b44c2adbf91db451907ef95a0998170ac5de5',
declarations: '52b5bcd795dbea19950afee0259c62cb0fb1aaca094d553cfaaae046a5f747f9',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
keyframes: 'a0d69c5b3e5f235d3a76ed04bdd64f67a73fafc9c59fe2635ffda603709c0ad9',
ruleDeclarationSequences: '89f5f3b302f3a10df0ba90dfbbc63a7ccbc1d8a0c784be26a475321f44dfa8e8',
selectors: 'dc1a2687ec872d1922a39e8258977949a9b775168764a59119ae6ea93a9f88a1',
variableReferences: '843a5f71b0fb74691ed623dbc7ebb7ddddc34e5736e1f419f17cf54e22d43073',
witnesses: '81676a3fdb6f3bbd63e86b275b6cb6d40e930651b9d5cbde208408f3163ab2b4',
ruleDeclarationSequences: 'bd8d10966c15df58eb76d67fc5a21acaeec524a64805797d5fb33af3b942292e',
selectors: '76d2d373aa9ecfd5cac3f95a47ed0fa2c44a659ac98f02f6bfd2f4505de23226',
variableReferences: 'bea2b082cf2366041936c2da22ac3e0ab83b3e5cead1326a0f65a015daedbb3b',
witnesses: '605555d8bae9b8d64b1c960c398b14cf910e624d547b72e4fcfced85eec30991',
},
};
test('public stylesheet exposes exactly twelve flat semantic owners', () => {
test('public stylesheet exposes exactly fourteen flat semantic owners', () => {
const imports = Array.from(index.matchAll(/^@import ['"](\.\/[^'"]+\.css)['"];$/gm), ([, file]) => file);
assert.deepEqual(imports, expectedImports);
assert.equal(index, `${expectedImports.map((file) => `@import '${file}';`).join('\n')}\n`);
@@ -209,7 +211,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, 884);
assert.equal(witnesses.length, 1021);
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 +407,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-CdbGat4L.css']);
assert.deepEqual(assets, ['index-DaioW5qf.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 133670);
assert.equal(sha256(built), '1aaa222692046eb166e5bfb8a195e53cf0ef61692a82ae53ca0119b4c8f878a2');
assert.equal(built.byteLength, 144728);
assert.equal(sha256(built), '7d175321821b38be6a947a76457e310cf519e2af4b9ee95fbb0097933ca70a28');
});
+2 -2
View File
@@ -34,7 +34,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
assert.equal((production.match(/<Tooltip\b/g) || []).length, 14);
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.equal((production.match(/<RailAction\b/g) || []).length, 7);
assert.equal((production.match(/<Drawer\b/g) || []).length, 7);
assert.doesNotMatch(production, /className="client-tooltip"|className="client-copy-label"|className="client-drawer-close"/);
});