Update Harbor client and gateway integration workflows
This commit is contained in:
@@ -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\)/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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\(\)/);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user