Update Harbor client and gateway functionality
This commit is contained in:
@@ -12,7 +12,7 @@ import { HarborError } from '../../dist/shared/errors.js';
|
||||
|
||||
const serverA = { id: 'a', label: 'Alpha', host: 'a.example', port: 1, protocol: 'vless' };
|
||||
const serverB = { id: 'b', label: 'Beta', host: 'b.example', port: 2, protocol: 'vless' };
|
||||
const rules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
|
||||
const rules = [{ type: 'domain_suffix', value: 'example', enabled: true, outbound: 'direct' }];
|
||||
|
||||
const profile = (id, label, desiredServerId = 'a', servers = [serverA, serverB]) => ({
|
||||
id,
|
||||
@@ -148,6 +148,7 @@ test('apply validates the profile pair and publishes applied identity only after
|
||||
assert.equal(after.state.profiles.find(({ id }) => id === 'work').desiredServerId, 'a');
|
||||
assert.equal(after.state.appliedProfileId, 'work');
|
||||
assert.equal(after.state.appliedServerId, 'a');
|
||||
assert.deepEqual(after.state.appliedRouteRules, rules);
|
||||
assert.deepEqual(after.state.appliedServerSnapshot, serverA);
|
||||
assert.deepEqual(harness.events, ['config.write', 'runtime.start', 'state.update']);
|
||||
|
||||
@@ -197,11 +198,14 @@ test('activate only changes desired while stopped and transactionally switches w
|
||||
});
|
||||
|
||||
test('gateway-direct changes only local desired state and never claims a remote applied target', async () => {
|
||||
const harness = createHarness({ gatewayDirect: true });
|
||||
const state = initialState();
|
||||
state.appliedRouteRules = [];
|
||||
const harness = createHarness({ gatewayDirect: true, state });
|
||||
await harness.service.apply('work', 'b');
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.state.desiredProfileId, 'work');
|
||||
assert.equal(after.state.appliedProfileId, 'personal');
|
||||
assert.deepEqual(after.state.appliedRouteRules, []);
|
||||
assert.equal(harness.events.includes('config.write'), false);
|
||||
assert.equal(harness.events.includes('runtime.start'), false);
|
||||
});
|
||||
@@ -232,6 +236,15 @@ test('restart uses applied pair while running and desired pair while stopped', a
|
||||
assert.equal(stopped.snapshot().state.appliedProfileId, 'work');
|
||||
assert.equal(stopped.snapshot().state.appliedServerId, 'b');
|
||||
assert.match(stopped.snapshot().config, /"selectedServerId":"b"/);
|
||||
assert.deepEqual(stopped.snapshot().state.appliedRouteRules, rules);
|
||||
});
|
||||
|
||||
test('restart in gateway-direct keeps user rules omitted from applied truth', async () => {
|
||||
const state = initialState();
|
||||
state.appliedRouteRules = [];
|
||||
const harness = createHarness({ gatewayDirect: true, state });
|
||||
await harness.service.restart();
|
||||
assert.deepEqual(harness.snapshot().state.appliedRouteRules, []);
|
||||
});
|
||||
|
||||
test('running restart preserves a pending desired profile while restoring the applied pair', async () => {
|
||||
|
||||
@@ -27,6 +27,10 @@ const storedProfile = (servers = [server]) => ({
|
||||
lastRefreshAttemptAt: null,
|
||||
lastRefreshErrorCode: null,
|
||||
});
|
||||
const routeRules = [
|
||||
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
||||
];
|
||||
const canonicalState = (profiles = [storedProfile()]) => ({
|
||||
revision: 10,
|
||||
profiles,
|
||||
@@ -34,8 +38,8 @@ const canonicalState = (profiles = [storedProfile()]) => ({
|
||||
appliedProfileId: profiles[0]?.id || '',
|
||||
appliedServerId: profiles[0]?.desiredServerId || '',
|
||||
appliedServerSnapshot: profiles[0]?.servers[0] || null,
|
||||
routeRules: [],
|
||||
appliedRouteRules: [],
|
||||
routeRules,
|
||||
appliedRouteRules: routeRules,
|
||||
routeRulesRevision: 0,
|
||||
gatewayAutoEnabled: true,
|
||||
});
|
||||
@@ -277,6 +281,18 @@ test('gateway-auto startup writes candidate config before publication without ap
|
||||
'state.update',
|
||||
'route:gateway-direct',
|
||||
]);
|
||||
assert.deepEqual(harness.snapshot().state.appliedRouteRules, routeRules);
|
||||
});
|
||||
|
||||
test('gateway-auto running transitions publish the rules actually present in each mode', async () => {
|
||||
const harness = createHarness({ running: true });
|
||||
await harness.service.refresh();
|
||||
assert.equal(harness.snapshot().gatewayAuto.mode, 'gateway-direct');
|
||||
assert.deepEqual(harness.snapshot().state.appliedRouteRules, []);
|
||||
|
||||
await harness.service.setEnabled(false);
|
||||
assert.equal(harness.snapshot().gatewayAuto.mode, 'local-vpn');
|
||||
assert.deepEqual(harness.snapshot().state.appliedRouteRules, routeRules);
|
||||
});
|
||||
|
||||
test('gateway loss safely stops when the applied server disappeared from refreshed config', async () => {
|
||||
|
||||
@@ -136,10 +136,17 @@ test('verified Gateway stays active through transient discovery failures', () =>
|
||||
});
|
||||
|
||||
test('canonical route distinguishes fresh, stale, lost, disabled and local states', () => {
|
||||
const desiredRules = [
|
||||
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
||||
];
|
||||
const appliedRules = [...desiredRules].reverse();
|
||||
const storedState = {
|
||||
revision: 1,
|
||||
subscriptionUrl,
|
||||
gatewayAutoEnabled: true,
|
||||
routeRules: desiredRules,
|
||||
appliedRouteRules: appliedRules,
|
||||
};
|
||||
const snapshot = (gatewayAuto, stored = storedState) => createStateSnapshot({
|
||||
storedState: stored,
|
||||
@@ -167,10 +174,17 @@ test('canonical route distinguishes fresh, stale, lost, disabled and local state
|
||||
assert.equal(found.lastVerifiedAt, '2026-07-13T11:59:59.000Z');
|
||||
assert.equal(found.autoEnabled, true);
|
||||
assert.equal(found.fallbackPreference, 'local-vpn');
|
||||
assert.equal(found.rulesContractVersion, 2);
|
||||
assert.deepEqual(found.localRules, desiredRules);
|
||||
assert.deepEqual(found.activeLocalRules, []);
|
||||
assert.equal(found.localRulesPendingRestart, false);
|
||||
assert.equal(snapshot({ ...fresh, failures: 1 }).reason, 'gateway-stale');
|
||||
assert.equal(snapshot({ ...fresh, mode: 'local-vpn', gatewayId: '', lastError: 'lost' }).reason, 'gateway-lost');
|
||||
assert.equal(snapshot(fresh, { ...storedState, gatewayAutoEnabled: false }).reason, 'disabled');
|
||||
assert.equal(snapshot(createGatewayAutoState()).reason, 'local');
|
||||
const local = snapshot(createGatewayAutoState());
|
||||
assert.equal(local.reason, 'local');
|
||||
assert.deepEqual(local.activeLocalRules, appliedRules);
|
||||
assert.equal(local.localRulesPendingRestart, true);
|
||||
});
|
||||
|
||||
test('client can ignore and restore a verified Gateway without losing discovery', () => {
|
||||
|
||||
@@ -5,8 +5,8 @@ import test from 'node:test';
|
||||
import { createRouteRulesService } from '../../dist/server/features/routing/index.js';
|
||||
import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesRoute.js';
|
||||
|
||||
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true }];
|
||||
const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true }];
|
||||
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true, outbound: 'direct' }];
|
||||
const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'vpn' }];
|
||||
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
|
||||
const storedProfile = (servers = [server]) => ({
|
||||
id: 'primary',
|
||||
@@ -78,7 +78,7 @@ function createHarness(overrides = {}) {
|
||||
write: (value) => {
|
||||
events.push('config.write');
|
||||
if (failures.configWrite) throw failures.configWrite;
|
||||
config = JSON.stringify(value);
|
||||
config = JSON.stringify(value, null, 2);
|
||||
if (failures.configWriteAfter) throw failures.configWriteAfter;
|
||||
},
|
||||
restore: (value) => {
|
||||
@@ -103,6 +103,7 @@ function createHarness(overrides = {}) {
|
||||
if (failures.runtimeRestore) throw failures.runtimeRestore;
|
||||
},
|
||||
},
|
||||
route: { isGatewayDirect: () => overrides.gatewayDirect === true },
|
||||
serialize,
|
||||
runOperation: async (operation) => {
|
||||
events.push('operation');
|
||||
@@ -130,27 +131,28 @@ function assertDomainRestored(actual, expected) {
|
||||
|
||||
test('route rules validate strictly, conflict before no-op, and preserve no-op revisions', async () => {
|
||||
const harness = createHarness();
|
||||
assert.throws(() => harness.service.update('bad', 2, undefined), (error) => error.code === 'REQUEST_INVALID');
|
||||
assert.throws(() => harness.service.update(newRules, -1, undefined), (error) => error.code === 'REQUEST_INVALID');
|
||||
await assert.rejects(harness.service.update(oldRules, 1, undefined), (error) => error.code === 'STATE_CONFLICT');
|
||||
assert.throws(() => harness.service.update(newRules, 2, undefined), (error) => error.code === 'REQUEST_INVALID');
|
||||
assert.throws(() => harness.service.update('bad', 2, 2), (error) => error.code === 'REQUEST_INVALID');
|
||||
assert.throws(() => harness.service.update(newRules, -1, 2), (error) => error.code === 'REQUEST_INVALID');
|
||||
assert.throws(
|
||||
() => harness.service.update([{ type: 'domain', value: 'example.com', enabled: true }], 2, 2),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
await assert.rejects(harness.service.update(oldRules, 1, 2), (error) => error.code === 'STATE_CONFLICT');
|
||||
const before = harness.snapshot();
|
||||
await harness.service.update(oldRules, 2, undefined);
|
||||
await harness.service.update(oldRules, 2, 2);
|
||||
assert.deepEqual(harness.snapshot(), before);
|
||||
assert.deepEqual(harness.events, []);
|
||||
});
|
||||
|
||||
test('route rules support explicit domain revision and legacy global revision with normalization', async () => {
|
||||
test('route rules use the explicit domain revision and normalize without losing target', async () => {
|
||||
const explicit = createHarness();
|
||||
await explicit.service.update([
|
||||
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'new.example', enabled: true },
|
||||
], 2, undefined);
|
||||
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'new.example', enabled: true, outbound: 'direct' },
|
||||
], 2, 2);
|
||||
assert.deepEqual(explicit.snapshot().state.routeRules, newRules);
|
||||
assert.equal(explicit.snapshot().state.routeRulesRevision, 3);
|
||||
|
||||
const legacy = createHarness();
|
||||
await legacy.service.update(newRules, undefined, 10);
|
||||
assert.deepEqual(legacy.snapshot().state.routeRules, newRules);
|
||||
});
|
||||
|
||||
test('route rules state-only path leaves config and applied rules unchanged', async () => {
|
||||
@@ -159,7 +161,7 @@ test('route rules state-only path leaves config and applied rules unchanged', as
|
||||
{ ...canonicalState(), revision: 1, routeRulesRevision: 0 },
|
||||
]) {
|
||||
const harness = createHarness({ state, missingSubscription: Boolean(state.profiles.length) });
|
||||
await harness.service.update(newRules, 0, undefined);
|
||||
await harness.service.update(newRules, 0, 2);
|
||||
assert.deepEqual(harness.snapshot().state.routeRules, newRules);
|
||||
assert.deepEqual(harness.snapshot().state.appliedRouteRules, oldRules);
|
||||
assert.equal(harness.snapshot().config, 'old-config');
|
||||
@@ -169,16 +171,35 @@ test('route rules state-only path leaves config and applied rules unchanged', as
|
||||
|
||||
test('route rules running apply updates active rules while stopped leaves them pending', async () => {
|
||||
const running = createHarness({ running: true });
|
||||
await running.service.update(newRules, 2, undefined);
|
||||
await running.service.update(newRules, 2, 2);
|
||||
assert.deepEqual(running.snapshot().state.appliedRouteRules, newRules);
|
||||
assert.deepEqual(running.events, ['operation', 'config.write', 'runtime.apply', 'state.update']);
|
||||
|
||||
const stopped = createHarness({ running: false });
|
||||
await stopped.service.update(newRules, 2, undefined);
|
||||
await stopped.service.update(newRules, 2, 2);
|
||||
assert.deepEqual(stopped.snapshot().state.appliedRouteRules, oldRules);
|
||||
assert.equal(stopped.events.includes('runtime.apply'), false);
|
||||
});
|
||||
|
||||
test('route rules in gateway-direct update desired order without touching config or runtime', async () => {
|
||||
const state = { ...canonicalState(), appliedRouteRules: [] };
|
||||
const harness = createHarness({ state, gatewayDirect: true, running: true });
|
||||
await harness.service.update(newRules, 2, 2);
|
||||
assert.deepEqual(harness.snapshot().state.routeRules, newRules);
|
||||
assert.deepEqual(harness.snapshot().state.appliedRouteRules, []);
|
||||
assert.equal(harness.snapshot().state.routeRulesRevision, 3);
|
||||
assert.equal(harness.snapshot().config, 'old-config');
|
||||
assert.deepEqual(harness.events, ['operation', 'state.update']);
|
||||
});
|
||||
|
||||
test('route rules skip runtime apply when the generated config bytes are unchanged', async () => {
|
||||
const config = JSON.stringify({ selectedServerId: 'server', routeRules: newRules }, null, 2);
|
||||
const harness = createHarness({ running: true, config });
|
||||
await harness.service.update(newRules, 2, 2);
|
||||
assert.deepEqual(harness.snapshot().state.appliedRouteRules, newRules);
|
||||
assert.deepEqual(harness.events, ['operation', 'state.update']);
|
||||
});
|
||||
|
||||
test('route rules rollback restores config/domain and honors runtime mutation phase', async () => {
|
||||
for (const failures of [
|
||||
{ configWriteAfter: new Error('config') },
|
||||
@@ -186,18 +207,18 @@ test('route rules rollback restores config/domain and honors runtime mutation ph
|
||||
]) {
|
||||
const harness = createHarness({ failures });
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.update(newRules, 2, undefined));
|
||||
await assert.rejects(harness.service.update(newRules, 2, 2));
|
||||
assertDomainRestored(harness.snapshot(), before);
|
||||
}
|
||||
|
||||
const preMutation = new Error('invalid config');
|
||||
const local = createHarness({ commandResult: { ok: false, mutationStarted: false, error: preMutation } });
|
||||
await assert.rejects(local.service.update(newRules, 2, undefined), (error) => error === preMutation);
|
||||
await assert.rejects(local.service.update(newRules, 2, 2), (error) => error === preMutation);
|
||||
assert.equal(local.events.includes('runtime.restore'), false);
|
||||
|
||||
const postMutation = new Error('remote failed');
|
||||
const remote = createHarness({ commandResult: { ok: false, mutationStarted: true, error: postMutation } });
|
||||
await assert.rejects(remote.service.update(newRules, 2, undefined), (error) => error === postMutation);
|
||||
await assert.rejects(remote.service.update(newRules, 2, 2), (error) => error === postMutation);
|
||||
assert.equal(remote.events.includes('runtime.restore'), true);
|
||||
});
|
||||
|
||||
@@ -207,7 +228,7 @@ test('route rules rollback continues and classifies runtime restore failure', as
|
||||
const aggregate = createHarness({
|
||||
failures: { stateUpdates: [original], configRestore },
|
||||
});
|
||||
await assert.rejects(aggregate.service.update(newRules, 2, undefined), (error) => {
|
||||
await assert.rejects(aggregate.service.update(newRules, 2, 2), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors, [original, configRestore]);
|
||||
return true;
|
||||
@@ -217,7 +238,7 @@ test('route rules rollback continues and classifies runtime restore failure', as
|
||||
const broken = createHarness({
|
||||
failures: { stateUpdates: [original], runtimeRestore },
|
||||
});
|
||||
await assert.rejects(broken.service.update(newRules, 2, undefined), (error) => {
|
||||
await assert.rejects(broken.service.update(newRules, 2, 2), (error) => {
|
||||
assert.equal(error.code, 'PROCESS_START_FAILED');
|
||||
assert.deepEqual(error.cause.errors, [original, runtimeRestore]);
|
||||
return true;
|
||||
@@ -229,13 +250,13 @@ test('route rules route preserves one adapter and state-only response', async ()
|
||||
const calls = [];
|
||||
const route = createRouteRulesRoute({
|
||||
routeRules: { update: async (...args) => { calls.push(args); } },
|
||||
readBody: async () => ({ rules: newRules, expectedRulesRevision: 3, expectedRevision: 99 }),
|
||||
readBody: async () => ({ rules: newRules, expectedRulesRevision: 3, rulesContractVersion: 2 }),
|
||||
sendState: async () => { calls.push('sent'); },
|
||||
});
|
||||
const response = {};
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/route-rules' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'PUT', url: '/api/route-rules' }, response), true);
|
||||
assert.deepEqual(calls, [[newRules, 3, 99], 'sent']);
|
||||
assert.equal(await route.handle({ method: 'PUT', url: '/api/route-rules/v2' }, response), true);
|
||||
assert.deepEqual(calls, [[newRules, 3, 2], 'sent']);
|
||||
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createRouteRulesRoute\(\{/);
|
||||
|
||||
@@ -21,12 +21,12 @@ const subscriptionConfig = {
|
||||
}],
|
||||
};
|
||||
|
||||
test('client exposes one local proxy and routes local exceptions before the selected VPN', () => {
|
||||
test('client exposes one local proxy and preserves mixed user-rule order before fallback', () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
||||
routeRules: [
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||
{ type: 'domain', value: 'example.com', enabled: true },
|
||||
{ type: 'domain_keyword', value: 'cdn', enabled: false },
|
||||
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
||||
{ type: 'domain_keyword', value: 'cdn', enabled: false, outbound: 'vpn' },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -44,8 +44,8 @@ test('client exposes one local proxy and routes local exceptions before the sele
|
||||
timeout: '1s',
|
||||
},
|
||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
||||
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||
{ domain: ['example.com'], outbound: 'direct' },
|
||||
{ domain: ['api.example.com'], outbound: 'test-vpn' },
|
||||
{ domain_suffix: ['example.com'], outbound: 'direct' },
|
||||
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||
]);
|
||||
assert.equal(config.route.final, 'test-vpn');
|
||||
@@ -55,7 +55,10 @@ test('client exposes one local proxy and routes local exceptions before the sele
|
||||
test('client keeps its local proxy but routes directly when Harbor Gateway is ahead', () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
||||
clientDirect: true,
|
||||
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
|
||||
routeRules: [
|
||||
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(config.route.rules, [
|
||||
@@ -66,9 +69,9 @@ test('client keeps its local proxy but routes directly when Harbor Gateway is ah
|
||||
timeout: '1s',
|
||||
},
|
||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
||||
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||
{ inbound: ['mixed-in'], outbound: 'direct' },
|
||||
]);
|
||||
assert.equal(config.route.final, 'direct');
|
||||
assert.deepEqual(config.outbounds.map((outbound) => outbound.tag), ['test-vpn', 'direct']);
|
||||
assert.equal(config.route.rules.some((rule) => Object.keys(rule).some((key) => key.startsWith('domain'))), false);
|
||||
});
|
||||
|
||||
@@ -21,9 +21,13 @@ const subscriptionConfig = {
|
||||
}],
|
||||
};
|
||||
|
||||
test('gateway routes .ru domains directly and other traffic through the selected VPN', () => {
|
||||
test('gateway preserves mixed user-rule order and dynamic VPN target', () => {
|
||||
const config = buildGatewayConfig(subscriptionConfig, 'test-vpn', {
|
||||
routeRules: [{ type: 'domain_suffix', value: 'ru', enabled: true }],
|
||||
routeRules: [
|
||||
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
||||
{ type: 'domain_keyword', value: 'disabled', enabled: false, outbound: 'vpn' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(config.route.rule_set, []);
|
||||
@@ -47,7 +51,8 @@ test('gateway routes .ru domains directly and other traffic through the selected
|
||||
timeout: '1s',
|
||||
},
|
||||
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
|
||||
{ domain_suffix: ['ru'], outbound: 'direct' },
|
||||
{ domain: ['api.example.com'], outbound: 'test-vpn' },
|
||||
{ domain_suffix: ['example.com'], outbound: 'direct' },
|
||||
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
|
||||
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||
]);
|
||||
|
||||
@@ -6,6 +6,9 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildGatewayPresence } from '../../dist/server/gatewayPresence.js';
|
||||
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
function listen(server, ...args) {
|
||||
@@ -72,7 +75,7 @@ if (process.argv[2] === 'run') {
|
||||
|
||||
function profileState(server) {
|
||||
return {
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 6,
|
||||
revision: 4,
|
||||
profiles: [{
|
||||
id: 'profile-a',
|
||||
@@ -116,6 +119,8 @@ async function startClientFixture(t, {
|
||||
state,
|
||||
cacheContents,
|
||||
config,
|
||||
hostNetwork,
|
||||
gatewayPresencePort,
|
||||
}) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
|
||||
const { binDirectory, markerPath } = fakeSingbox(directory);
|
||||
@@ -126,6 +131,8 @@ async function startClientFixture(t, {
|
||||
if (config !== undefined) {
|
||||
fs.writeFileSync(path.join(directory, 'sing-box-config.json'), JSON.stringify(config));
|
||||
}
|
||||
const hostNetworkPath = path.join(directory, 'host-network.json');
|
||||
if (hostNetwork !== undefined) fs.writeFileSync(hostNetworkPath, JSON.stringify(hostNetwork));
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
||||
cwd: root,
|
||||
@@ -136,7 +143,10 @@ async function startClientFixture(t, {
|
||||
DATA_DIR: directory,
|
||||
PORT: String(port),
|
||||
PATH: `${binDirectory}:${process.env.PATH || ''}`,
|
||||
HARBOR_HOST_NETWORK_STATE: path.join(directory, 'missing-network.json'),
|
||||
HARBOR_HOST_NETWORK_STATE: hostNetwork === undefined
|
||||
? path.join(directory, 'missing-network.json')
|
||||
: hostNetworkPath,
|
||||
...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}),
|
||||
HARBOR_TEST_RUN_MARKER: markerPath,
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
@@ -154,6 +164,82 @@ async function startClientFixture(t, {
|
||||
};
|
||||
}
|
||||
|
||||
function bootStateWithRules() {
|
||||
const normalized = normalizeSubscriptionConfig({
|
||||
outbounds: [{
|
||||
type: 'vless',
|
||||
tag: 'Boot VPN',
|
||||
server: 'boot.example',
|
||||
server_port: 443,
|
||||
uuid: '00000000-0000-4000-8000-000000000000',
|
||||
}],
|
||||
});
|
||||
const server = normalized.servers[0];
|
||||
const state = profileState(server);
|
||||
state.profiles[0].subscriptionUrl = 'https://provider.example/0123456789abcdef0123456789abcdef';
|
||||
state.profiles[0].subscriptionConfig = normalized.config;
|
||||
state.routeRules = [
|
||||
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
||||
];
|
||||
state.appliedRouteRules = [];
|
||||
return state;
|
||||
}
|
||||
|
||||
test('local-vpn boot promotes the exact desired ordered rules to applied truth', async (t) => {
|
||||
const state = bootStateWithRules();
|
||||
const fixture = await startClientFixture(t, { state });
|
||||
const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8'));
|
||||
|
||||
assert.equal(fixture.state.route.mode, 'local-vpn');
|
||||
assert.deepEqual(fixture.state.route.activeLocalRules, state.routeRules);
|
||||
assert.equal(fixture.state.route.localRulesPendingRestart, false);
|
||||
assert.deepEqual(config.route.rules.slice(2, 4), [
|
||||
{ domain: ['api.example.com'], outbound: state.profiles[0].servers[0].id },
|
||||
{ domain_suffix: ['example.com'], outbound: 'direct' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('gateway-direct boot keeps the local proxy and diagnostics but omits every user rule', async (t) => {
|
||||
const state = bootStateWithRules();
|
||||
const gateway = http.createServer((req, res) => {
|
||||
const nonce = new URL(req.url, 'http://127.0.0.1').searchParams.get('nonce');
|
||||
const payload = buildGatewayPresence({
|
||||
appMode: 'gateway',
|
||||
subscriptionUrl: state.profiles[0].subscriptionUrl,
|
||||
gatewayId: 'gateway-test',
|
||||
nonce,
|
||||
});
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
});
|
||||
const address = await listen(gateway, 0, '127.0.0.1');
|
||||
t.after(() => close(gateway));
|
||||
const fixture = await startClientFixture(t, {
|
||||
state,
|
||||
gatewayPresencePort: address.port,
|
||||
hostNetwork: {
|
||||
gateway: '127.0.0.1',
|
||||
interface: 'lo0',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
observedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8'));
|
||||
const stored = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'state.json'), 'utf8'));
|
||||
|
||||
assert.equal(fixture.state.route.mode, 'gateway-direct');
|
||||
assert.deepEqual(fixture.state.route.activeLocalRules, []);
|
||||
assert.equal(fixture.state.route.localRulesPendingRestart, false);
|
||||
assert.deepEqual(stored.appliedRouteRules, []);
|
||||
assert.deepEqual(config.inbounds.map(({ tag }) => tag), ['mixed-in', 'diagnostics-vpn-in']);
|
||||
assert.equal(config.route.rules.some((rule) => Object.keys(rule).some((key) => key.startsWith('domain'))), false);
|
||||
assert.deepEqual(config.route.rules[1], {
|
||||
inbound: ['diagnostics-vpn-in'],
|
||||
outbound: state.profiles[0].servers[0].id,
|
||||
});
|
||||
});
|
||||
|
||||
test('corrupt legacy cache with no canonical subscription fails closed instead of starting stale config', async (t) => {
|
||||
const staleServer = {
|
||||
id: 'stale-server',
|
||||
@@ -298,7 +384,7 @@ test('stopped Gateway boot explicitly stops an already running remote dataplane'
|
||||
});
|
||||
await listen(dataplane, socketPath);
|
||||
fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify({
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 6,
|
||||
revision: 3,
|
||||
profiles: [],
|
||||
desiredProfileId: '',
|
||||
|
||||
@@ -90,6 +90,7 @@ test('state v1 projects legacy storage through the canonical profile snapshot',
|
||||
appliedServerSnapshot: snapshot.profiles[0].servers[0],
|
||||
});
|
||||
assert.equal(snapshot.connection.process, 'running');
|
||||
assert.equal(snapshot.route.rulesContractVersion, 2);
|
||||
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
|
||||
assert.throws(
|
||||
() => assertStateSnapshot({ ...snapshot, revision: -1 }),
|
||||
@@ -107,7 +108,7 @@ test('startup discards a rejected cached subscription and returns to first-run',
|
||||
server: '0.0.0.0',
|
||||
server_port: 1,
|
||||
};
|
||||
const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true }];
|
||||
const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' }];
|
||||
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
|
||||
subscriptionUrl,
|
||||
selectedTag: rejectedServer.tag,
|
||||
@@ -296,14 +297,15 @@ setInterval(() => {}, 60_000);
|
||||
assert.equal(initial.selection.appliedProfileId, 'profile_primary');
|
||||
assert.equal(initial.selection.appliedServerSnapshot.id, testServerId);
|
||||
assert.deepEqual(initial.route.localRules, [
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' },
|
||||
]);
|
||||
assert.equal(initial.route.rulesContractVersion, 2);
|
||||
assert.deepEqual(initial.route.activeLocalRules, initial.route.localRules);
|
||||
assert.equal(initial.route.localRulesRevision, 0);
|
||||
assert.equal(initial.route.localRulesPendingRestart, false);
|
||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
||||
assert.equal(migratedState.schemaVersion, 5);
|
||||
assert.equal(migratedState.schemaVersion, 6);
|
||||
assert.equal(migratedState.profiles.length, 1);
|
||||
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
||||
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
||||
@@ -521,19 +523,20 @@ setInterval(() => {}, 60_000);
|
||||
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
||||
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
||||
|
||||
let routed = await mutation('/api/route-rules', 'PUT', {
|
||||
let routed = await mutation('/api/route-rules/v2', 'PUT', {
|
||||
expectedRulesRevision: rulesRevision,
|
||||
rulesContractVersion: 2,
|
||||
rules: [
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
||||
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true },
|
||||
{ type: 'domain_suffix', value: '*.Example.org', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
|
||||
{ type: 'domain', value: 'https://Example.com/private?q=1', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: '*.Example.org', enabled: true, outbound: 'direct' },
|
||||
],
|
||||
});
|
||||
rulesRevision = routed.state.route.localRulesRevision;
|
||||
assert.deepEqual(routed.state.route.localRules, [
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
||||
{ type: 'domain', value: 'example.com', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'example.org', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
|
||||
{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' },
|
||||
]);
|
||||
assert.equal(routed.state.route.localRulesPendingRestart, false);
|
||||
assert.deepEqual(routed.state.route.activeLocalRules, routed.state.route.localRules);
|
||||
@@ -545,16 +548,17 @@ setInterval(() => {}, 60_000);
|
||||
timeout: '1s',
|
||||
},
|
||||
{ inbound: ['diagnostics-vpn-in'], outbound: testServerId },
|
||||
{ domain: ['example.com'], outbound: 'direct' },
|
||||
{ domain: ['example.com'], outbound: testServerId },
|
||||
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
||||
]);
|
||||
|
||||
await mutation('/api/singbox/stop');
|
||||
routed = await mutation('/api/route-rules', 'PUT', {
|
||||
routed = await mutation('/api/route-rules/v2', 'PUT', {
|
||||
expectedRulesRevision: rulesRevision,
|
||||
rulesContractVersion: 2,
|
||||
rules: [
|
||||
...routed.state.route.localRules,
|
||||
{ type: 'domain_keyword', value: 'media', enabled: true },
|
||||
{ type: 'domain_keyword', value: 'media', enabled: true, outbound: 'vpn' },
|
||||
],
|
||||
});
|
||||
rulesRevision = routed.state.route.localRulesRevision;
|
||||
@@ -565,27 +569,48 @@ setInterval(() => {}, 60_000);
|
||||
assert.equal(restartedRules.state.route.localRulesPendingRestart, false);
|
||||
assert.deepEqual(restartedRules.state.route.activeLocalRules, routed.state.route.localRules);
|
||||
|
||||
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||
const invalidRules = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
|
||||
expectedRulesRevision: rulesRevision,
|
||||
rules: [{ type: 'domain_regex', value: '.*' }],
|
||||
rulesContractVersion: 2,
|
||||
rules: [{ type: 'domain_regex', value: '.*', enabled: true, outbound: 'direct' }],
|
||||
});
|
||||
assert.equal(invalidRules.response.status, 400);
|
||||
assert.equal(invalidRules.payload.error.code, 'REQUEST_INVALID');
|
||||
assert.equal((await request(port, '/api/state')).revision, revision);
|
||||
|
||||
const staleRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||
const staleRules = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
|
||||
expectedRulesRevision: 0,
|
||||
rulesContractVersion: 2,
|
||||
rules: [],
|
||||
});
|
||||
assert.equal(staleRules.response.status, 409);
|
||||
assert.equal(staleRules.payload.error.code, 'STATE_CONFLICT');
|
||||
assert.deepEqual((await request(port, '/api/state')).route.localRules, routed.state.route.localRules);
|
||||
|
||||
const skewConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
||||
const legacyNoop = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||
expectedRevision: revision,
|
||||
rules: routed.state.route.localRules,
|
||||
});
|
||||
assert.equal(legacyNoop.response.status, 200);
|
||||
assert.equal(legacyNoop.response.status, 400);
|
||||
assert.equal(legacyNoop.payload.error.code, 'REQUEST_INVALID');
|
||||
assert.equal((await request(port, '/api/state')).revision, revision);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), skewConfig);
|
||||
|
||||
for (const rules of [
|
||||
[{ type: 'domain', value: 'missing.example', enabled: true }],
|
||||
[{ type: 'domain', value: 'unknown.example', enabled: true, outbound: 'block' }],
|
||||
]) {
|
||||
const rejected = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
|
||||
expectedRulesRevision: rulesRevision,
|
||||
rulesContractVersion: 2,
|
||||
rules,
|
||||
});
|
||||
assert.equal(rejected.response.status, 400);
|
||||
assert.equal(rejected.payload.error.code, 'REQUEST_INVALID');
|
||||
assert.equal((await request(port, '/api/state')).revision, revision);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), skewConfig);
|
||||
}
|
||||
|
||||
const workingConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
||||
fs.writeFileSync(singboxPath, `#!/usr/bin/env node
|
||||
@@ -599,9 +624,10 @@ process.on('SIGTERM', () => process.exit(0));
|
||||
setInterval(() => {}, 60_000);
|
||||
`);
|
||||
fs.chmodSync(singboxPath, 0o755);
|
||||
const failedRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||
const failedRules = await rawRequest(port, '/api/route-rules/v2', 'PUT', {
|
||||
expectedRulesRevision: rulesRevision,
|
||||
rules: [{ type: 'domain', value: 'broken.example' }],
|
||||
rulesContractVersion: 2,
|
||||
rules: [{ type: 'domain', value: 'broken.example', enabled: true, outbound: 'vpn' }],
|
||||
});
|
||||
assert.equal(failedRules.response.status, 422);
|
||||
assert.equal(failedRules.payload.error.code, 'CONFIG_INVALID');
|
||||
|
||||
@@ -61,7 +61,7 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
|
||||
|
||||
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||
assert.deepEqual(migrated.routeRules, [
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' },
|
||||
]);
|
||||
const primary = migrated.profiles[0];
|
||||
assert.equal(primary.label, 'Основной');
|
||||
@@ -74,6 +74,61 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
test('schema v5 migrates saved and applied rules to v6 with an exact backup', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const legacy = {
|
||||
schemaVersion: 5,
|
||||
revision: 11,
|
||||
routeRulesRevision: 7,
|
||||
routeRules: [
|
||||
{ type: 'domain', value: 'api.example.com', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'example.com', enabled: false },
|
||||
],
|
||||
appliedRouteRules: [
|
||||
{ type: 'domain_suffix', value: 'example.com', enabled: false },
|
||||
{ type: 'domain', value: 'api.example.com', enabled: true },
|
||||
],
|
||||
};
|
||||
const bytes = JSON.stringify(legacy);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
|
||||
const store = createStateStore(filePath, {
|
||||
now: () => new Date('2026-08-17T12:00:00.000Z'),
|
||||
});
|
||||
const migrated = store.read();
|
||||
|
||||
assert.equal(migrated.schemaVersion, 6);
|
||||
assert.equal(migrated.routeRulesRevision, 7);
|
||||
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
||||
assert.deepEqual(migrated.appliedRouteRules.map(({ type, outbound }) => [type, outbound]), [
|
||||
['domain_suffix', 'direct'],
|
||||
['domain', 'direct'],
|
||||
]);
|
||||
assert.equal(store.migration.fromVersion, 5);
|
||||
assert.equal(store.migration.toVersion, 6);
|
||||
assert.match(store.migration.backupPath, /\.backup-v5-/);
|
||||
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
|
||||
});
|
||||
|
||||
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
|
||||
for (const [name, rule] of [
|
||||
['missing', { type: 'domain', value: 'example.com', enabled: true }],
|
||||
['unknown', { type: 'domain', value: 'example.com', enabled: true, outbound: 'block' }],
|
||||
]) {
|
||||
const filePath = `${fixture(t)}-${name}`;
|
||||
const bytes = JSON.stringify({
|
||||
schemaVersion: 6,
|
||||
routeRules: [rule],
|
||||
appliedRouteRules: [],
|
||||
});
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
const store = createStateStore(filePath);
|
||||
assert.throws(() => store.read(), /outbound/);
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), bytes);
|
||||
assert.equal(store.migration, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
|
||||
const filePath = fixture(t);
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createSubscriptionMutationRoute } from '../../dist/server/http/routes/s
|
||||
|
||||
const oldServer = { id: 'shared', label: 'Old', host: 'old.example', port: 443, protocol: 'vless' };
|
||||
const nextServer = { id: 'next', label: 'Next', host: 'next.example', port: 443, protocol: 'vless' };
|
||||
const routeRules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
|
||||
const routeRules = [{ type: 'domain_suffix', value: 'example', enabled: true, outbound: 'direct' }];
|
||||
|
||||
const profile = (id, label, server = oldServer) => ({
|
||||
id,
|
||||
@@ -47,7 +47,7 @@ function defaultState() {
|
||||
function createHarness(overrides = {}) {
|
||||
let state = structuredClone(overrides.state ?? defaultState());
|
||||
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
||||
let gatewayAuto = { mode: 'gateway-direct', gatewayId: 'gateway' };
|
||||
let gatewayAuto = structuredClone(overrides.gatewayAuto ?? { mode: 'local-vpn', gatewayId: '' });
|
||||
let running = overrides.running ?? true;
|
||||
let tail = Promise.resolve();
|
||||
let updateCount = 0;
|
||||
@@ -267,6 +267,25 @@ test('active refresh applies a retained server and keeps last-applied snapshot i
|
||||
assert.equal(after.running, true);
|
||||
});
|
||||
|
||||
test('active refresh publishes the route rules used by the rebuilt config', async () => {
|
||||
const nextRules = [{ type: 'domain', value: 'vpn.example', enabled: true, outbound: 'vpn' }];
|
||||
const state = defaultState();
|
||||
state.routeRules = nextRules;
|
||||
state.appliedRouteRules = routeRules;
|
||||
const local = createHarness({ state, fetchSubscription: async () => parsed('personal', [oldServer]) });
|
||||
await local.service.refreshProfile('personal', state.revision);
|
||||
assert.deepEqual(local.snapshot().state.appliedRouteRules, nextRules);
|
||||
assert.deepEqual(JSON.parse(local.snapshot().config).rules, nextRules);
|
||||
|
||||
const bypassed = createHarness({
|
||||
state,
|
||||
gatewayAuto: { mode: 'gateway-direct', gatewayId: 'gateway' },
|
||||
fetchSubscription: async () => parsed('personal', [oldServer]),
|
||||
});
|
||||
await bypassed.service.refreshProfile('personal', state.revision);
|
||||
assert.deepEqual(bypassed.snapshot().state.appliedRouteRules, []);
|
||||
});
|
||||
|
||||
test('inactive delete is state-only; applied delete requires one stop-and-delete transaction', async () => {
|
||||
const inactive = createHarness();
|
||||
await inactive.service.deleteProfile('work', 'delete', 3);
|
||||
@@ -294,7 +313,7 @@ test('deleting a pending desired profile leaves the running applied route untouc
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.config, 'old-config');
|
||||
assert.equal(after.running, true);
|
||||
assert.deepEqual(after.gatewayAuto, { mode: 'gateway-direct', gatewayId: 'gateway' });
|
||||
assert.deepEqual(after.gatewayAuto, { mode: 'local-vpn', gatewayId: '' });
|
||||
assert.equal(harness.calls.includes('gateway.set'), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@ import { canAppendRouteRule, normalizeRouteRules } from '../../dist/shared/routi
|
||||
|
||||
test('local route rules normalize URLs, suffixes and duplicates', () => {
|
||||
assert.deepEqual(normalizeRouteRules([
|
||||
{ type: 'domain', value: 'https://Example.com/news?id=1' },
|
||||
{ type: 'domain_suffix', value: '*.Example.org' },
|
||||
{ type: 'domain_keyword', value: ' CDN ' },
|
||||
{ type: 'domain', value: 'example.com' },
|
||||
{ type: 'domain_suffix', value: '.ru', enabled: false },
|
||||
{ type: 'domain', value: 'https://Example.com/news?id=1', outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: '*.Example.org', outbound: 'direct' },
|
||||
{ type: 'domain_keyword', value: ' CDN ', outbound: 'vpn' },
|
||||
{ type: 'domain', value: 'example.com', outbound: 'direct' },
|
||||
{ type: 'domain_suffix', value: '.ru', enabled: false, outbound: 'direct' },
|
||||
], { strict: true }), [
|
||||
{ type: 'domain', value: 'example.com', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'example.org', enabled: true },
|
||||
{ type: 'domain_keyword', value: 'cdn', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
||||
{ type: 'domain', value: 'example.com', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'example.org', enabled: true, outbound: 'direct' },
|
||||
{ type: 'domain_keyword', value: 'cdn', enabled: true, outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: false, outbound: 'direct' },
|
||||
]);
|
||||
assert.equal(canAppendRouteRule([{ value: 'filled' }]), true);
|
||||
assert.equal(canAppendRouteRule([{ value: '' }]), false);
|
||||
@@ -22,11 +22,30 @@ test('local route rules normalize URLs, suffixes and duplicates', () => {
|
||||
|
||||
test('invalid local route rules fail at the strict boundary', () => {
|
||||
assert.throws(
|
||||
() => normalizeRouteRules([{ type: 'domain_regex', value: '.*' }], { strict: true }),
|
||||
() => normalizeRouteRules([{ type: 'domain_regex', value: '.*', outbound: 'direct' }], { strict: true }),
|
||||
/Invalid domain rule type/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRouteRules([{ type: 'domain_keyword', value: 'bad/path' }], { strict: true }),
|
||||
() => normalizeRouteRules([{ type: 'domain_keyword', value: 'bad/path', outbound: 'direct' }], { strict: true }),
|
||||
/Invalid domain rule value/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRouteRules([{ type: 'domain', value: 'example.com' }], { strict: true }),
|
||||
/outbound is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRouteRules([{ type: 'domain', value: 'example.com', outbound: 'block' }], { strict: true }),
|
||||
/Invalid route rule outbound/,
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy rules default to direct while order and first duplicate stay canonical', () => {
|
||||
assert.deepEqual(normalizeRouteRules([
|
||||
{ type: 'domain_suffix', value: 'example.com' },
|
||||
{ type: 'domain', value: 'api.example.com', outbound: 'vpn' },
|
||||
{ type: 'domain_suffix', value: 'example.com', outbound: 'vpn' },
|
||||
]), [
|
||||
{ type: 'domain_suffix', value: 'example.com', enabled: true, outbound: 'direct' },
|
||||
{ type: 'domain', value: 'api.example.com', enabled: true, outbound: 'vpn' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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', {}],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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\)/);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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+/));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user