Refactor VPN proxy components and update related behavior
This commit is contained in:
@@ -48,7 +48,11 @@ test('data invariant: one canonical snapshot owns server selection and never exp
|
||||
});
|
||||
|
||||
assert.equal(snapshot.revision, 9);
|
||||
assert.deepEqual(snapshot.selection, { desiredServerId: selectedServerId, appliedServerId: selectedServerId });
|
||||
assert.equal(snapshot.selection.desiredProfileId, 'profile_primary');
|
||||
assert.equal(snapshot.selection.desiredServerId, selectedServerId);
|
||||
assert.equal(snapshot.selection.appliedProfileId, 'profile_primary');
|
||||
assert.equal(snapshot.selection.appliedServerId, selectedServerId);
|
||||
assert.equal(snapshot.selection.appliedServerSnapshot?.id, selectedServerId);
|
||||
assert.equal(snapshot.servers.find((server) => server.id === selectedServerId)?.host, 'vpn-17.example.test');
|
||||
assert.equal(JSON.stringify(snapshot).includes('private-token'), false);
|
||||
});
|
||||
|
||||
@@ -12,130 +12,113 @@ 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 duplicateA = { id: 'a2', label: 'Alpha', host: 'a2.example', port: 3, protocol: 'vless' };
|
||||
const rules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
let state = structuredClone(overrides.state ?? {
|
||||
const profile = (id, label, desiredServerId = 'a', servers = [serverA, serverB]) => ({
|
||||
id,
|
||||
label,
|
||||
subscriptionUrl: `https://${id}.example/sub`,
|
||||
subscriptionConfig: { outbounds: [] },
|
||||
servers,
|
||||
userInfo: {},
|
||||
fetchedAt: '2026-08-08T10:00:00.000Z',
|
||||
desiredServerId,
|
||||
lastRefreshAttemptAt: null,
|
||||
lastRefreshErrorCode: null,
|
||||
});
|
||||
|
||||
function initialState() {
|
||||
return {
|
||||
revision: 5,
|
||||
servers: [serverA, serverB],
|
||||
selectedServerId: serverA.id,
|
||||
appliedServerId: serverA.id,
|
||||
profiles: [profile('personal', 'Личный'), profile('work', 'Работа', 'b')],
|
||||
desiredProfileId: 'personal',
|
||||
appliedProfileId: 'personal',
|
||||
appliedServerId: 'a',
|
||||
appliedServerSnapshot: serverA,
|
||||
routeRules: rules,
|
||||
appliedRouteRules: rules,
|
||||
routeRulesRevision: 1,
|
||||
connectionDesired: 'running',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
let state = structuredClone(overrides.state ?? initialState());
|
||||
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
||||
let running = overrides.running ?? true;
|
||||
let tail = Promise.resolve();
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
let stateUpdateIndex = 0;
|
||||
let stateUpdates = 0;
|
||||
let startFailure = overrides.failStart || null;
|
||||
const events = [];
|
||||
const failures = {
|
||||
...overrides.failures,
|
||||
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
|
||||
runtimeStarts: [...(overrides.failures?.runtimeStarts || [])],
|
||||
runtimeStops: [...(overrides.failures?.runtimeStops || [])],
|
||||
runtimeRestarts: [...(overrides.failures?.runtimeRestarts || [])],
|
||||
};
|
||||
|
||||
function applyState(mutator) {
|
||||
const failure = failures.stateUpdates[stateUpdateIndex++];
|
||||
if (failure instanceof Error) throw failure;
|
||||
const revision = state.revision + 1;
|
||||
state = { ...structuredClone(mutator(structuredClone(state))), revision };
|
||||
events.push(stateUpdateIndex === 1 ? 'state.desired' : stateUpdateIndex === 2 ? 'state.applied' : 'state.restore');
|
||||
if (failure?.after) throw failure.after;
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
const serialize = (operation) => {
|
||||
const run = async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
active -= 1;
|
||||
}
|
||||
try { return await operation(); } finally { active -= 1; }
|
||||
};
|
||||
const result = tail.then(run, run);
|
||||
tail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
};
|
||||
|
||||
const stopRuntime = async () => {
|
||||
events.push('runtime.stop');
|
||||
const failure = failures.runtimeStops.shift();
|
||||
if (failure) {
|
||||
const updateState = (mutator) => {
|
||||
stateUpdates += 1;
|
||||
const next = structuredClone(mutator(structuredClone(state)));
|
||||
state = { ...next, revision: state.revision + 1 };
|
||||
events.push('state.update');
|
||||
if (overrides.failStateAt === stateUpdates) throw new Error('state failed');
|
||||
return structuredClone(state);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
events.push('runtime.start');
|
||||
if (startFailure) {
|
||||
const failure = startFailure;
|
||||
startFailure = null;
|
||||
running = false;
|
||||
throw failure;
|
||||
}
|
||||
if (overrides.startGate) await overrides.startGate();
|
||||
running = true;
|
||||
};
|
||||
const stop = async () => {
|
||||
events.push('runtime.stop');
|
||||
if (overrides.failStop) throw overrides.failStop;
|
||||
running = false;
|
||||
};
|
||||
|
||||
const restartRuntime = async () => {
|
||||
const restart = async () => {
|
||||
events.push('runtime.restart');
|
||||
const failure = failures.runtimeRestarts.shift();
|
||||
if (failure) {
|
||||
if (failure.code !== 'CONFIG_INVALID') running = false;
|
||||
throw failure;
|
||||
}
|
||||
if (overrides.failRestart) throw overrides.failRestart;
|
||||
running = true;
|
||||
};
|
||||
|
||||
const service = createConnectionService({
|
||||
state: {
|
||||
read: () => structuredClone(state),
|
||||
update: applyState,
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
|
||||
},
|
||||
state: { read: () => structuredClone(state), update: updateState },
|
||||
config: {
|
||||
exists: () => config !== null,
|
||||
build: (_subscription, selectedServerId, routeRules) => ({ selectedServerId, routeRules }),
|
||||
read: () => config,
|
||||
write: (value) => {
|
||||
events.push('config.write');
|
||||
if (failures.configWrite) throw failures.configWrite;
|
||||
config = JSON.stringify(value);
|
||||
if (failures.configWriteAfter) throw failures.configWriteAfter;
|
||||
},
|
||||
restore: (value) => {
|
||||
events.push('config.restore');
|
||||
if (failures.configRestore) throw failures.configRestore;
|
||||
config = value;
|
||||
},
|
||||
remove: () => {
|
||||
events.push('config.remove');
|
||||
if (failures.configRemove) throw failures.configRemove;
|
||||
config = null;
|
||||
if (overrides.failConfigWrite) throw overrides.failConfigWrite;
|
||||
},
|
||||
restore: (value) => { events.push('config.restore'); config = value; },
|
||||
remove: () => { events.push('config.remove'); config = null; },
|
||||
},
|
||||
runtime: {
|
||||
isRunning: async () => {
|
||||
if (failures.runtimeStatus) throw failures.runtimeStatus;
|
||||
if (overrides.failStatus) throw overrides.failStatus;
|
||||
return running;
|
||||
},
|
||||
start: async () => {
|
||||
events.push('runtime.start');
|
||||
const failure = failures.runtimeStarts.shift();
|
||||
if (failure) {
|
||||
running = false;
|
||||
throw failure;
|
||||
}
|
||||
if (overrides.startGate) await overrides.startGate();
|
||||
running = true;
|
||||
},
|
||||
stop: stopRuntime,
|
||||
stopCommand: () => captureRuntimeCommand(stopRuntime),
|
||||
restartCommand: () => captureRuntimeCommand(
|
||||
restartRuntime,
|
||||
{ preMutationErrorCodes: ['CONFIG_INVALID'] },
|
||||
),
|
||||
start,
|
||||
stop,
|
||||
stopCommand: () => captureRuntimeCommand(stop),
|
||||
restartCommand: () => captureRuntimeCommand(restart, { preMutationErrorCodes: ['CONFIG_INVALID'] }),
|
||||
},
|
||||
route: { isGatewayDirect: () => overrides.gatewayDirect === true },
|
||||
serialize,
|
||||
now: () => new Date('2026-08-08T12:00:00.000Z'),
|
||||
});
|
||||
@@ -143,344 +126,195 @@ function createHarness(overrides = {}) {
|
||||
return {
|
||||
service,
|
||||
events,
|
||||
enqueue: serialize,
|
||||
setState: (value) => { state = structuredClone(value); },
|
||||
serialize,
|
||||
snapshot: () => structuredClone({ state, config, running, peak }),
|
||||
};
|
||||
}
|
||||
|
||||
function assertDomainRestored(actual, expected) {
|
||||
const actualDomain = structuredClone(actual);
|
||||
const expectedDomain = structuredClone(expected);
|
||||
delete actualDomain.state.revision;
|
||||
delete expectedDomain.state.revision;
|
||||
delete actualDomain.peak;
|
||||
delete expectedDomain.peak;
|
||||
assert.deepEqual(actualDomain, expectedDomain);
|
||||
assert.ok(actual.state.revision >= expected.state.revision);
|
||||
function domain(value) {
|
||||
const copy = structuredClone(value);
|
||||
delete copy.state.revision;
|
||||
delete copy.peak;
|
||||
return copy;
|
||||
}
|
||||
|
||||
test('apply resolves ID inside the queue and commits desired, config, runtime, then applied state', async () => {
|
||||
const harness = createHarness({ state: {
|
||||
revision: 5,
|
||||
servers: [serverA, serverB, duplicateA],
|
||||
selectedServerId: serverA.id,
|
||||
appliedServerId: serverA.id,
|
||||
routeRules: rules,
|
||||
appliedRouteRules: rules,
|
||||
} });
|
||||
|
||||
assert.deepEqual(await harness.service.apply(' b ', 'Alpha'), { serverId: 'b', selectedTag: 'Beta' });
|
||||
const snapshot = harness.snapshot();
|
||||
assert.equal(snapshot.state.selectedServerId, 'b');
|
||||
assert.equal(snapshot.state.appliedServerId, 'b');
|
||||
assert.equal(snapshot.state.connectionDesired, 'running');
|
||||
assert.equal(snapshot.state.appliedAt, '2026-08-08T12:00:00.000Z');
|
||||
assert.deepEqual(snapshot.state.appliedRouteRules, rules);
|
||||
assert.deepEqual(harness.events, ['state.desired', 'config.write', 'runtime.start', 'state.applied']);
|
||||
|
||||
await assert.rejects(harness.service.apply('', 'Alpha'), (error) => error.code === 'SERVER_NOT_FOUND');
|
||||
await assert.rejects(harness.service.apply('missing', 'Beta'), (error) => error.code === 'SERVER_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('apply accepts one legacy label and rejects missing config before mutation', async () => {
|
||||
const unique = createHarness();
|
||||
assert.deepEqual(await unique.service.apply('', ' Beta '), { serverId: 'b', selectedTag: 'Beta' });
|
||||
|
||||
const missing = createHarness({ missingSubscription: true });
|
||||
const before = missing.snapshot();
|
||||
await assert.rejects(missing.service.apply('b', ''), (error) => error.code === 'CONFIG_INVALID');
|
||||
assertDomainRestored(missing.snapshot(), before);
|
||||
assert.deepEqual(missing.events, []);
|
||||
});
|
||||
|
||||
test('apply restores previous running domain/config/runtime after mutation failures', async () => {
|
||||
const failureCases = [
|
||||
{ failures: { stateUpdates: [new Error('desired')] }, expected: 'desired' },
|
||||
{ failures: { configWriteAfter: new Error('config') }, expected: 'config' },
|
||||
{ failures: { runtimeStarts: [new Error('start')] }, expected: 'start' },
|
||||
{ failures: { stateUpdates: [null, { after: new Error('applied') }] }, expected: 'applied' },
|
||||
];
|
||||
|
||||
for (const { failures, expected } of failureCases) {
|
||||
const harness = createHarness({ failures });
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.apply('b', ''), new RegExp(expected));
|
||||
assertDomainRestored(harness.snapshot(), before);
|
||||
}
|
||||
});
|
||||
|
||||
test('apply compensates a desired state update that persists and then throws', async () => {
|
||||
const failure = new Error('desired persisted then failed');
|
||||
const harness = createHarness({ failures: { stateUpdates: [{ after: failure }] } });
|
||||
const before = harness.snapshot();
|
||||
|
||||
await assert.rejects(harness.service.apply('b', ''), (error) => error === failure);
|
||||
test('apply validates the profile pair and publishes applied identity only after runtime succeeds', async () => {
|
||||
const harness = createHarness();
|
||||
assert.deepEqual(await harness.service.apply('work', 'a', '', 5), {
|
||||
profileId: 'work', serverId: 'a', selectedTag: 'Alpha',
|
||||
});
|
||||
const after = harness.snapshot();
|
||||
assertDomainRestored(after, before);
|
||||
assert.ok(after.state.revision > before.state.revision);
|
||||
assert.equal(after.state.desiredProfileId, 'work');
|
||||
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.appliedServerSnapshot, serverA);
|
||||
assert.deepEqual(harness.events, ['config.write', 'runtime.start', 'state.update']);
|
||||
|
||||
await assert.rejects(harness.service.apply('personal', 'missing'), (error) => error.code === 'SERVER_NOT_FOUND');
|
||||
await assert.rejects(harness.service.apply('missing', 'a'), (error) => error.code === 'PROFILE_NOT_FOUND');
|
||||
await assert.rejects(harness.service.apply('personal', 'a', '', 4), (error) => error.code === 'STATE_CONFLICT');
|
||||
});
|
||||
|
||||
test('apply restores previous stopped state and prior config absence', async () => {
|
||||
const failure = new Error('start');
|
||||
const harness = createHarness({ config: null, running: false, failures: { runtimeStarts: [failure] } });
|
||||
const before = harness.snapshot();
|
||||
|
||||
await assert.rejects(harness.service.apply('b', ''), (error) => error === failure);
|
||||
assertDomainRestored(harness.snapshot(), before);
|
||||
assert.ok(harness.events.includes('config.remove'));
|
||||
assert.ok(harness.events.includes('runtime.stop'));
|
||||
});
|
||||
|
||||
test('apply rollback continues after restore failures and marks runtime rollback failure', async () => {
|
||||
const original = new Error('apply failed');
|
||||
const configFailure = new Error('config restore failed');
|
||||
const nonRuntime = createHarness({
|
||||
failures: { runtimeStarts: [original], configRestore: configFailure },
|
||||
});
|
||||
await assert.rejects(nonRuntime.service.apply('b', ''), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors, [original, configFailure]);
|
||||
return true;
|
||||
});
|
||||
assert.ok(nonRuntime.events.filter((event) => event === 'runtime.start').length >= 2);
|
||||
assert.ok(nonRuntime.events.filter((event) => event.startsWith('state.')).length >= 2);
|
||||
|
||||
const rollbackFailure = new Error('runtime rollback failed');
|
||||
const runtimeBroken = createHarness({ failures: { runtimeStarts: [original, rollbackFailure] } });
|
||||
await assert.rejects(runtimeBroken.service.apply('b', ''), (error) => {
|
||||
assert.equal(error.code, 'PROCESS_START_FAILED');
|
||||
assert.deepEqual(error.cause.errors, [original, rollbackFailure]);
|
||||
return true;
|
||||
});
|
||||
assert.ok(runtimeBroken.events.filter((event) => event.startsWith('state.')).length >= 2);
|
||||
});
|
||||
|
||||
test('queued apply resolves latest state and concurrent valid applies remain serial', async () => {
|
||||
let release;
|
||||
const blocker = new Promise((resolve) => { release = resolve; });
|
||||
const stale = createHarness();
|
||||
const blocked = stale.enqueue(() => blocker);
|
||||
const apply = stale.service.apply('b', '');
|
||||
stale.setState({ ...stale.snapshot().state, servers: [serverA] });
|
||||
release();
|
||||
await blocked;
|
||||
await assert.rejects(apply, (error) => error.code === 'SERVER_NOT_FOUND');
|
||||
|
||||
const serial = createHarness();
|
||||
const [first, second] = await Promise.all([
|
||||
serial.service.apply('a', ''),
|
||||
serial.service.apply('b', ''),
|
||||
]);
|
||||
assert.equal(first.serverId, 'a');
|
||||
assert.equal(second.serverId, 'b');
|
||||
assert.equal(serial.snapshot().state.appliedServerId, 'b');
|
||||
assert.equal(serial.snapshot().peak, 1);
|
||||
});
|
||||
|
||||
test('server apply route preserves defaults, operation kind, response, and single ownership', async () => {
|
||||
const calls = [];
|
||||
const sent = [];
|
||||
const route = createServerApplyRoute({
|
||||
connection: {
|
||||
apply: async (serverId, selectedTag) => {
|
||||
calls.push([serverId, selectedTag]);
|
||||
return { serverId: 'resolved', selectedTag: 'Resolved' };
|
||||
},
|
||||
},
|
||||
readBody: async () => ({}),
|
||||
withOperation: async (kind, operation) => {
|
||||
calls.push(kind);
|
||||
return operation();
|
||||
},
|
||||
sendState: async (_res, extra) => { sent.push(extra); },
|
||||
});
|
||||
const response = {};
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/apply' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/apply' }, response), true);
|
||||
assert.deepEqual(calls, ['apply-server', ['', '']]);
|
||||
assert.deepEqual(sent, [{ serverId: 'resolved', selectedTag: 'Resolved' }]);
|
||||
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createServerApplyRoute\(\{/);
|
||||
assert.doesNotMatch(source, /function applySelectedServer|req\.url === ['"]\/api\/apply['"]/);
|
||||
});
|
||||
|
||||
test('stop always cleans runtime and commits only desired stopped state', async () => {
|
||||
for (const running of [true, false]) {
|
||||
const harness = createHarness({ running });
|
||||
test('failed candidate config or runtime restores the old profile, config and running state', async () => {
|
||||
for (const failure of [
|
||||
{ failConfigWrite: new Error('write failed') },
|
||||
{ failStart: new Error('start failed') },
|
||||
{ failStateAt: 1 },
|
||||
]) {
|
||||
const harness = createHarness(failure);
|
||||
const before = harness.snapshot();
|
||||
await harness.service.stop();
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.running, false);
|
||||
assert.equal(after.state.connectionDesired, 'stopped');
|
||||
assert.equal(after.state.selectedServerId, before.state.selectedServerId);
|
||||
assert.equal(after.state.appliedServerId, before.state.appliedServerId);
|
||||
assert.deepEqual(after.state.appliedRouteRules, before.state.appliedRouteRules);
|
||||
assert.equal(harness.events.filter((event) => event === 'runtime.stop').length, 1);
|
||||
await assert.rejects(harness.service.apply('work', 'b'));
|
||||
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
||||
}
|
||||
});
|
||||
|
||||
test('restart validates config inside the queue and commits applied runtime fields', async () => {
|
||||
const missing = createHarness({ config: null });
|
||||
const beforeMissing = missing.snapshot();
|
||||
await assert.rejects(missing.service.restart(), (error) => error.code === 'CONFIG_INVALID');
|
||||
assertDomainRestored(missing.snapshot(), beforeMissing);
|
||||
assert.deepEqual(missing.events, []);
|
||||
|
||||
for (const running of [true, false]) {
|
||||
const harness = createHarness({ running, state: {
|
||||
revision: 1,
|
||||
servers: [serverA, serverB],
|
||||
selectedServerId: serverB.id,
|
||||
appliedServerId: serverA.id,
|
||||
routeRules: rules,
|
||||
appliedRouteRules: [],
|
||||
appliedAt: 'preserved',
|
||||
connectionDesired: 'stopped',
|
||||
} });
|
||||
await harness.service.restart();
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.running, true);
|
||||
assert.equal(after.state.appliedServerId, serverB.id);
|
||||
assert.equal(after.state.connectionDesired, 'running');
|
||||
assert.deepEqual(after.state.appliedRouteRules, rules);
|
||||
assert.equal(after.state.appliedAt, 'preserved');
|
||||
}
|
||||
test('desired and applied targets stay old while candidate runtime is still starting', async () => {
|
||||
let entered;
|
||||
let release;
|
||||
const started = new Promise((resolve) => { entered = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const harness = createHarness({ startGate: async () => { entered(); await gate; } });
|
||||
const switching = harness.service.apply('work', 'b');
|
||||
await started;
|
||||
assert.equal(harness.snapshot().state.desiredProfileId, 'personal');
|
||||
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
||||
release();
|
||||
await switching;
|
||||
});
|
||||
|
||||
test('stop and restart compensate runtime and pre/post-write state failures', async () => {
|
||||
const statusFailure = new Error('status failed');
|
||||
for (const command of ['stop', 'restart']) {
|
||||
const status = createHarness({ failures: { runtimeStatus: statusFailure } });
|
||||
await status.service[command]();
|
||||
assert.ok(status.events.includes(`runtime.${command}`));
|
||||
}
|
||||
test('activate only changes desired while stopped and transactionally switches while running', async () => {
|
||||
const stopped = createHarness({ running: false });
|
||||
await stopped.service.activate('work');
|
||||
assert.equal(stopped.snapshot().state.desiredProfileId, 'work');
|
||||
assert.equal(stopped.snapshot().state.appliedProfileId, 'personal');
|
||||
assert.equal(stopped.events.includes('config.write'), false);
|
||||
|
||||
for (const stateFailure of [new Error('state before'), { after: new Error('state after') }]) {
|
||||
const stopHarness = createHarness({ failures: { stateUpdates: [stateFailure] } });
|
||||
const stopBefore = stopHarness.snapshot();
|
||||
await assert.rejects(stopHarness.service.stop(), /state/);
|
||||
assertDomainRestored(stopHarness.snapshot(), stopBefore);
|
||||
|
||||
const restartHarness = createHarness({
|
||||
running: false,
|
||||
failures: { stateUpdates: [stateFailure] },
|
||||
});
|
||||
const restartBefore = restartHarness.snapshot();
|
||||
await assert.rejects(restartHarness.service.restart(), /state/);
|
||||
assertDomainRestored(restartHarness.snapshot(), restartBefore);
|
||||
}
|
||||
|
||||
const stopFailure = new Error('stop failed');
|
||||
const stopped = createHarness({ failures: { runtimeStops: [stopFailure] } });
|
||||
const stoppedBefore = stopped.snapshot();
|
||||
await assert.rejects(stopped.service.stop(), (error) => error === stopFailure);
|
||||
assertDomainRestored(stopped.snapshot(), stoppedBefore);
|
||||
|
||||
const restartFailure = new Error('restart failed');
|
||||
const restarted = createHarness({ running: false, failures: { runtimeRestarts: [restartFailure] } });
|
||||
const restartedBefore = restarted.snapshot();
|
||||
await assert.rejects(restarted.service.restart(), (error) => error === restartFailure);
|
||||
assertDomainRestored(restarted.snapshot(), restartedBefore);
|
||||
const running = createHarness();
|
||||
await running.service.activate('work');
|
||||
assert.equal(running.snapshot().state.appliedProfileId, 'work');
|
||||
assert.equal(running.snapshot().state.appliedServerId, 'b');
|
||||
});
|
||||
|
||||
test('restart preserves CONFIG_INVALID when validation fails before runtime mutation', async () => {
|
||||
const invalid = new HarborError('CONFIG_INVALID');
|
||||
const harness = createHarness({ failures: { runtimeRestarts: [invalid] } });
|
||||
const before = harness.snapshot();
|
||||
|
||||
await assert.rejects(harness.service.restart(), (error) => error === invalid);
|
||||
assertDomainRestored(harness.snapshot(), before);
|
||||
assert.equal(harness.events.filter((event) => event === 'runtime.restart').length, 1);
|
||||
test('gateway-direct changes only local desired state and never claims a remote applied target', async () => {
|
||||
const harness = createHarness({ gatewayDirect: true });
|
||||
await harness.service.apply('work', 'b');
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.state.desiredProfileId, 'work');
|
||||
assert.equal(after.state.appliedProfileId, 'personal');
|
||||
assert.equal(harness.events.includes('config.write'), false);
|
||||
assert.equal(harness.events.includes('runtime.start'), false);
|
||||
});
|
||||
|
||||
test('runtime command adapter makes local and remote mutation phase explicit', async () => {
|
||||
const localFailure = new HarborError('CONFIG_INVALID');
|
||||
assert.deepEqual(
|
||||
await captureRuntimeCommand(
|
||||
async () => { throw localFailure; },
|
||||
{ preMutationErrorCodes: ['CONFIG_INVALID'] },
|
||||
),
|
||||
{ ok: false, mutationStarted: false, error: localFailure },
|
||||
);
|
||||
|
||||
const remoteFailure = new HarborError('PROCESS_START_FAILED');
|
||||
assert.deepEqual(
|
||||
await captureRuntimeCommand(async () => { throw remoteFailure; }),
|
||||
{ ok: false, mutationStarted: true, error: remoteFailure },
|
||||
);
|
||||
test('stop clears applied identity but preserves the desired pair', async () => {
|
||||
const harness = createHarness();
|
||||
await harness.service.stop();
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.running, false);
|
||||
assert.equal(after.state.connectionDesired, 'stopped');
|
||||
assert.equal(after.state.desiredProfileId, 'personal');
|
||||
assert.equal(after.state.profiles[0].desiredServerId, 'a');
|
||||
assert.equal(after.state.appliedProfileId, '');
|
||||
assert.equal(after.state.appliedServerId, '');
|
||||
assert.equal(after.state.appliedServerSnapshot, null);
|
||||
});
|
||||
|
||||
test('runtime command rollback continues through state errors and maps runtime restore failure', async () => {
|
||||
const original = new Error('stop state failed');
|
||||
const stateRestore = new Error('state restore failed');
|
||||
const aggregate = createHarness({
|
||||
failures: { stateUpdates: [{ after: original }, stateRestore] },
|
||||
});
|
||||
await assert.rejects(aggregate.service.stop(), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors, [original, stateRestore]);
|
||||
return true;
|
||||
});
|
||||
assert.equal(aggregate.snapshot().running, true);
|
||||
test('restart uses applied pair while running and desired pair while stopped', async () => {
|
||||
const running = createHarness();
|
||||
await running.service.restart();
|
||||
assert.match(running.snapshot().config, /"selectedServerId":"a"/);
|
||||
|
||||
const stopFailure = new Error('stop failed');
|
||||
const runtimeRestore = new Error('start rollback failed');
|
||||
const broken = createHarness({
|
||||
failures: { runtimeStops: [stopFailure], runtimeStarts: [runtimeRestore] },
|
||||
});
|
||||
await assert.rejects(broken.service.stop(), (error) => {
|
||||
assert.equal(error.code, 'PROCESS_START_FAILED');
|
||||
assert.deepEqual(error.cause.errors, [stopFailure, runtimeRestore]);
|
||||
return true;
|
||||
});
|
||||
const stoppedState = initialState();
|
||||
stoppedState.desiredProfileId = 'work';
|
||||
stoppedState.connectionDesired = 'stopped';
|
||||
const stopped = createHarness({ running: false, state: stoppedState });
|
||||
await stopped.service.restart();
|
||||
assert.equal(stopped.snapshot().state.appliedProfileId, 'work');
|
||||
assert.equal(stopped.snapshot().state.appliedServerId, 'b');
|
||||
assert.match(stopped.snapshot().config, /"selectedServerId":"b"/);
|
||||
});
|
||||
|
||||
test('apply, stop, and restart share one deterministic connection queue', async () => {
|
||||
const applyThenStop = createHarness();
|
||||
await Promise.all([
|
||||
applyThenStop.service.apply('b', ''),
|
||||
applyThenStop.service.stop(),
|
||||
]);
|
||||
assert.equal(applyThenStop.snapshot().state.connectionDesired, 'stopped');
|
||||
assert.equal(applyThenStop.snapshot().peak, 1);
|
||||
|
||||
const stopThenRestart = createHarness();
|
||||
await Promise.all([
|
||||
stopThenRestart.service.stop(),
|
||||
stopThenRestart.service.restart(),
|
||||
]);
|
||||
assert.equal(stopThenRestart.snapshot().state.connectionDesired, 'running');
|
||||
assert.equal(stopThenRestart.snapshot().running, true);
|
||||
assert.equal(stopThenRestart.snapshot().peak, 1);
|
||||
test('running restart preserves a pending desired profile while restoring the applied pair', async () => {
|
||||
const state = initialState();
|
||||
state.desiredProfileId = 'work';
|
||||
const harness = createHarness({ state });
|
||||
await harness.service.restart();
|
||||
assert.equal(harness.snapshot().state.desiredProfileId, 'work');
|
||||
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
||||
});
|
||||
|
||||
test('connection runtime route preserves operation kinds, response extras, and single ownership', async () => {
|
||||
test('restart fails closed when runtime status is unknown', async () => {
|
||||
const state = initialState();
|
||||
state.desiredProfileId = 'work';
|
||||
const failure = new Error('status unavailable');
|
||||
const harness = createHarness({ state, failStatus: failure });
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.restart(), (error) => error === failure);
|
||||
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
||||
});
|
||||
|
||||
test('all connection mutations share one deterministic queue', async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const harness = createHarness({ startGate: () => gate });
|
||||
const apply = harness.service.apply('work', 'b');
|
||||
const stop = harness.service.stop();
|
||||
release();
|
||||
await Promise.all([apply, stop]);
|
||||
assert.equal(harness.snapshot().peak, 1);
|
||||
assert.equal(harness.snapshot().state.connectionDesired, 'stopped');
|
||||
});
|
||||
|
||||
test('pair-aware apply and runtime routes preserve one HTTP owner', async () => {
|
||||
const calls = [];
|
||||
const sent = [];
|
||||
const route = createConnectionRuntimeRoute({
|
||||
const applyRoute = createServerApplyRoute({
|
||||
connection: {
|
||||
stop: async () => { calls.push('service.stop'); },
|
||||
restart: async () => { calls.push('service.restart'); },
|
||||
apply: async (...args) => {
|
||||
calls.push(args);
|
||||
return { profileId: 'personal', serverId: 'a', selectedTag: 'Alpha' };
|
||||
},
|
||||
},
|
||||
withOperation: async (kind, operation) => {
|
||||
calls.push(kind);
|
||||
return operation();
|
||||
readBody: async () => ({ profileId: 'personal', serverId: 'a', expectedRevision: 7 }),
|
||||
withOperation: async (kind, operation, options) => {
|
||||
calls.push([kind, options]);
|
||||
return operation(8);
|
||||
},
|
||||
sendState: async (_res, extra) => { sent.push(extra); },
|
||||
});
|
||||
const response = {};
|
||||
assert.equal(await applyRoute.handle({ method: 'POST', url: '/api/apply' }, {}), true);
|
||||
assert.deepEqual(calls, [
|
||||
['apply-server', { expectedRevision: 7, profileId: 'personal', serverId: 'a' }],
|
||||
['personal', 'a', '', 8],
|
||||
]);
|
||||
assert.deepEqual(sent, [{ profileId: 'personal', serverId: 'a', selectedTag: 'Alpha' }]);
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/singbox/stop' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/singbox/stop' }, response), true);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/singbox/restart' }, response), true);
|
||||
assert.deepEqual(calls, ['stop', 'service.stop', 'start', 'service.restart']);
|
||||
assert.deepEqual(sent, [{ singboxRunning: false }, { singboxRunning: true }]);
|
||||
const runtimeCalls = [];
|
||||
const runtimeRoute = createConnectionRuntimeRoute({
|
||||
connection: {
|
||||
stop: async () => { runtimeCalls.push('service.stop'); },
|
||||
restart: async () => { runtimeCalls.push('service.restart'); },
|
||||
},
|
||||
withOperation: async (kind, operation) => { runtimeCalls.push(kind); return operation(); },
|
||||
sendState: async () => {},
|
||||
});
|
||||
await runtimeRoute.handle({ method: 'POST', url: '/api/singbox/stop' }, {});
|
||||
await runtimeRoute.handle({ method: 'POST', url: '/api/singbox/restart' }, {});
|
||||
assert.deepEqual(runtimeCalls, ['stop', 'service.stop', 'start', 'service.restart']);
|
||||
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createConnectionRuntimeRoute\(\{/);
|
||||
assert.doesNotMatch(source, /req\.url === ['"]\/api\/singbox\/(?:stop|restart)['"]/);
|
||||
assert.doesNotMatch(source, /function applySelectedServer|req\.url === ['"]\/api\/apply['"]/);
|
||||
});
|
||||
|
||||
test('runtime command adapter distinguishes failures before and after mutation', async () => {
|
||||
const invalid = new HarborError('CONFIG_INVALID');
|
||||
assert.deepEqual(
|
||||
await captureRuntimeCommand(async () => { throw invalid; }, { preMutationErrorCodes: ['CONFIG_INVALID'] }),
|
||||
{ ok: false, mutationStarted: false, error: invalid },
|
||||
);
|
||||
const failed = new Error('remote failed');
|
||||
assert.deepEqual(
|
||||
await captureRuntimeCommand(async () => { throw failed; }),
|
||||
{ ok: false, mutationStarted: true, error: failed },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -68,12 +68,17 @@ test('connectivity diagnostics endpoint is available in Connect and Gateway thro
|
||||
test('connectivity use case captures applied server before probes and preserves result fields', async () => {
|
||||
const events = [];
|
||||
let state = {
|
||||
desiredProfileId: 'primary',
|
||||
appliedProfileId: 'primary',
|
||||
appliedServerId: 'applied',
|
||||
selectedServerId: 'selected',
|
||||
servers: [
|
||||
{ id: 'applied', label: 'Applied before probe' },
|
||||
{ id: 'selected', label: 'Selected' },
|
||||
],
|
||||
profiles: [{
|
||||
id: 'primary',
|
||||
desiredServerId: 'selected',
|
||||
servers: [
|
||||
{ id: 'applied', label: 'Applied before probe' },
|
||||
{ id: 'selected', label: 'Selected' },
|
||||
],
|
||||
}],
|
||||
};
|
||||
let releaseProbe;
|
||||
const probe = new Promise((resolve) => { releaseProbe = resolve; });
|
||||
@@ -95,7 +100,7 @@ test('connectivity use case captures applied server before probes and preserves
|
||||
},
|
||||
});
|
||||
const resultPromise = useCase.run({ raw: true }, 42);
|
||||
state.servers[0].label = 'Changed during probe';
|
||||
state.profiles[0].servers[0].label = 'Changed during probe';
|
||||
releaseProbe();
|
||||
const result = await resultPromise;
|
||||
|
||||
@@ -114,9 +119,13 @@ test('connectivity use case keeps applied priority, selected fallback and error
|
||||
const result = { vpn: { available: false }, marker: true };
|
||||
const selected = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => ({
|
||||
desiredProfileId: 'primary',
|
||||
appliedServerId: '',
|
||||
selectedServerId: 'selected',
|
||||
servers: [{ id: 'selected', label: 'Selected' }],
|
||||
profiles: [{
|
||||
id: 'primary',
|
||||
desiredServerId: 'selected',
|
||||
servers: [{ id: 'selected', label: 'Selected' }],
|
||||
}],
|
||||
}),
|
||||
runDiagnostics: async () => result,
|
||||
});
|
||||
@@ -127,9 +136,14 @@ test('connectivity use case keeps applied priority, selected fallback and error
|
||||
|
||||
const missingApplied = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => ({
|
||||
desiredProfileId: 'primary',
|
||||
appliedProfileId: 'primary',
|
||||
appliedServerId: 'missing',
|
||||
selectedServerId: 'selected',
|
||||
servers: [{ id: 'selected', label: 'Selected' }],
|
||||
profiles: [{
|
||||
id: 'primary',
|
||||
desiredServerId: 'selected',
|
||||
servers: [{ id: 'selected', label: 'Selected' }],
|
||||
}],
|
||||
}),
|
||||
runDiagnostics: async () => result,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,30 @@ import { createGatewayAutoRoute } from '../../dist/server/http/routes/gatewayAut
|
||||
|
||||
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
|
||||
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
|
||||
const storedProfile = (servers = [server]) => ({
|
||||
id: 'primary',
|
||||
label: 'Основной',
|
||||
subscriptionUrl,
|
||||
subscriptionConfig: { outbounds: [] },
|
||||
servers,
|
||||
userInfo: {},
|
||||
fetchedAt: null,
|
||||
desiredServerId: servers[0]?.id || '',
|
||||
lastRefreshAttemptAt: null,
|
||||
lastRefreshErrorCode: null,
|
||||
});
|
||||
const canonicalState = (profiles = [storedProfile()]) => ({
|
||||
revision: 10,
|
||||
profiles,
|
||||
desiredProfileId: profiles[0]?.id || '',
|
||||
appliedProfileId: profiles[0]?.id || '',
|
||||
appliedServerId: profiles[0]?.desiredServerId || '',
|
||||
appliedServerSnapshot: profiles[0]?.servers[0] || null,
|
||||
routeRules: [],
|
||||
appliedRouteRules: [],
|
||||
routeRulesRevision: 0,
|
||||
gatewayAutoEnabled: true,
|
||||
});
|
||||
const networkA = {
|
||||
gateway: '192.168.50.111',
|
||||
interface: 'en0',
|
||||
@@ -51,17 +75,7 @@ function deferred() {
|
||||
}
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
let state = structuredClone(overrides.state ?? {
|
||||
revision: 10,
|
||||
servers: [server],
|
||||
selectedServerId: server.id,
|
||||
appliedServerId: server.id,
|
||||
routeRules: [],
|
||||
appliedRouteRules: [],
|
||||
routeRulesRevision: 0,
|
||||
subscriptionUrl,
|
||||
gatewayAutoEnabled: true,
|
||||
});
|
||||
let state = structuredClone(overrides.state ?? canonicalState());
|
||||
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
||||
let network = Object.hasOwn(overrides, 'network') ? overrides.network : networkA;
|
||||
let tail = Promise.resolve();
|
||||
@@ -109,11 +123,14 @@ function createHarness(overrides = {}) {
|
||||
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
|
||||
},
|
||||
config: {
|
||||
build: (_subscription, selectedServerId, routeRules, gatewayAuto) => ({
|
||||
selectedServerId,
|
||||
routeRules,
|
||||
clientDirect: gatewayAuto.mode === 'gateway-direct',
|
||||
}),
|
||||
build: (_subscription, selectedServerId, routeRules, gatewayAuto) => {
|
||||
if (failures.configBuild) throw failures.configBuild;
|
||||
return {
|
||||
selectedServerId,
|
||||
routeRules,
|
||||
clientDirect: gatewayAuto.mode === 'gateway-direct',
|
||||
};
|
||||
},
|
||||
read: () => config,
|
||||
write: (value) => {
|
||||
events.push('config.write');
|
||||
@@ -144,6 +161,10 @@ function createHarness(overrides = {}) {
|
||||
events.push('runtime.restore');
|
||||
if (failures.runtimeRestore) throw failures.runtimeRestore;
|
||||
},
|
||||
stopCommand: async () => {
|
||||
events.push('runtime.stop');
|
||||
return overrides.stopCommandResult || { ok: true, mutationStarted: true };
|
||||
},
|
||||
},
|
||||
discovery: {
|
||||
readHostNetwork: () => structuredClone(network),
|
||||
@@ -224,16 +245,8 @@ test('gateway-auto handles no-op and metadata-only discovery without config/runt
|
||||
test('gateway-auto mode changes separate state-only, stopped and running paths', async () => {
|
||||
const stateOnly = createHarness({
|
||||
gatewayAuto: directState(),
|
||||
state: {
|
||||
revision: 1,
|
||||
servers: [],
|
||||
selectedServerId: '',
|
||||
appliedServerId: '',
|
||||
routeRules: [],
|
||||
appliedRouteRules: [],
|
||||
routeRulesRevision: 0,
|
||||
gatewayAutoEnabled: true,
|
||||
},
|
||||
state: { ...canonicalState([]), revision: 1 },
|
||||
running: false,
|
||||
});
|
||||
await stateOnly.service.setEnabled(false);
|
||||
assert.equal(stateOnly.snapshot().gatewayAuto.mode, 'local-vpn');
|
||||
@@ -266,6 +279,38 @@ test('gateway-auto startup writes candidate config before publication without ap
|
||||
]);
|
||||
});
|
||||
|
||||
test('gateway loss safely stops when the applied server disappeared from refreshed config', async () => {
|
||||
const state = canonicalState();
|
||||
state.profiles[0].servers = [];
|
||||
state.profiles[0].desiredServerId = '';
|
||||
const buildError = Object.assign(new Error('server removed'), { code: 'SERVER_NOT_FOUND' });
|
||||
const harness = createHarness({
|
||||
state,
|
||||
gatewayAuto: directState(),
|
||||
network: networkB,
|
||||
failures: { configBuild: buildError },
|
||||
});
|
||||
await assert.rejects(harness.service.refresh(), (error) => error === buildError);
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.gatewayAuto.mode, 'local-vpn');
|
||||
assert.equal(after.state.connectionDesired, 'stopped');
|
||||
assert.equal(after.state.appliedProfileId, '');
|
||||
assert.equal(after.state.appliedServerId, '');
|
||||
assert.equal(harness.events.includes('runtime.stop'), true);
|
||||
});
|
||||
|
||||
test('Gateway discovery preserves a stale applied local runtime when direct config cannot build', async () => {
|
||||
const state = canonicalState();
|
||||
state.profiles[0].servers = [];
|
||||
state.profiles[0].desiredServerId = '';
|
||||
const buildError = Object.assign(new Error('server removed'), { code: 'SERVER_NOT_FOUND' });
|
||||
const harness = createHarness({ state, failures: { configBuild: buildError } });
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.refresh(), (error) => error === buildError);
|
||||
assert.deepEqual(withoutRevision(harness.snapshot()), withoutRevision(before));
|
||||
assert.equal(harness.events.includes('runtime.stop'), false);
|
||||
});
|
||||
|
||||
test('disable and re-enable preserve verified Gateway identity and persisted preference commits', async () => {
|
||||
const harness = createHarness({ gatewayAuto: directState() });
|
||||
await harness.service.setEnabled(false);
|
||||
@@ -382,7 +427,9 @@ test('gateway-auto discards stale subscription and route probe results', async (
|
||||
staleSubscription.service.set(directState());
|
||||
const refresh = staleSubscription.service.refresh();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
staleSubscription.patchState({ subscriptionUrl: `${subscriptionUrl}-new` });
|
||||
const changedState = staleSubscription.snapshot().state;
|
||||
changedState.profiles[0].subscriptionUrl = `${subscriptionUrl}-new`;
|
||||
staleSubscription.setState(changedState);
|
||||
gate.resolve(verifiedA);
|
||||
await refresh;
|
||||
assert.deepEqual(staleSubscription.snapshot().gatewayAuto, createGatewayAutoState());
|
||||
|
||||
@@ -8,17 +8,32 @@ import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesR
|
||||
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true }];
|
||||
const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true }];
|
||||
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
|
||||
const storedProfile = (servers = [server]) => ({
|
||||
id: 'primary',
|
||||
label: 'Основной',
|
||||
subscriptionUrl: 'https://provider.example/sub',
|
||||
subscriptionConfig: { outbounds: [] },
|
||||
servers,
|
||||
userInfo: {},
|
||||
fetchedAt: null,
|
||||
desiredServerId: servers[0]?.id || '',
|
||||
lastRefreshAttemptAt: null,
|
||||
lastRefreshErrorCode: null,
|
||||
});
|
||||
const canonicalState = (profiles = [storedProfile()]) => ({
|
||||
revision: 10,
|
||||
routeRulesRevision: 2,
|
||||
routeRules: oldRules,
|
||||
appliedRouteRules: oldRules,
|
||||
profiles,
|
||||
desiredProfileId: profiles[0]?.id || '',
|
||||
appliedProfileId: profiles[0]?.id || '',
|
||||
appliedServerId: profiles[0]?.desiredServerId || '',
|
||||
appliedServerSnapshot: profiles[0]?.servers[0] || null,
|
||||
});
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
let state = structuredClone(overrides.state ?? {
|
||||
revision: 10,
|
||||
routeRulesRevision: 2,
|
||||
routeRules: oldRules,
|
||||
appliedRouteRules: oldRules,
|
||||
servers: [server],
|
||||
selectedServerId: server.id,
|
||||
appliedServerId: server.id,
|
||||
});
|
||||
let state = structuredClone(overrides.state ?? canonicalState());
|
||||
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
||||
const failures = {
|
||||
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
|
||||
@@ -140,10 +155,10 @@ test('route rules support explicit domain revision and legacy global revision wi
|
||||
|
||||
test('route rules state-only path leaves config and applied rules unchanged', async () => {
|
||||
for (const state of [
|
||||
{ revision: 1, routeRulesRevision: 0, routeRules: oldRules, appliedRouteRules: oldRules, servers: [], selectedServerId: '', appliedServerId: '' },
|
||||
{ revision: 1, routeRulesRevision: 0, routeRules: oldRules, appliedRouteRules: oldRules, servers: [server], selectedServerId: server.id, appliedServerId: server.id },
|
||||
{ ...canonicalState([]), revision: 1, routeRulesRevision: 0 },
|
||||
{ ...canonicalState(), revision: 1, routeRulesRevision: 0 },
|
||||
]) {
|
||||
const harness = createHarness({ state, missingSubscription: Boolean(state.selectedServerId) });
|
||||
const harness = createHarness({ state, missingSubscription: Boolean(state.profiles.length) });
|
||||
await harness.service.update(newRules, 0, undefined);
|
||||
assert.deepEqual(harness.snapshot().state.routeRules, newRules);
|
||||
assert.deepEqual(harness.snapshot().state.appliedRouteRules, oldRules);
|
||||
|
||||
@@ -39,23 +39,29 @@ test('server health selection coerces IDs, deduplicates, ignores unknowns, and k
|
||||
];
|
||||
const checked = [];
|
||||
const service = createServerHealthService({
|
||||
readServers: () => servers,
|
||||
readProfiles: () => [{ id: 'personal', servers }],
|
||||
readDesiredProfileId: () => 'personal',
|
||||
ping: async (host, port) => {
|
||||
checked.push([host, port]);
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual((await service.check(['3', 1, '3', 'missing'])).map(({ id }) => id), ['1', '3']);
|
||||
assert.deepEqual((await service.check('personal', ['3', 1, '3', 'missing'])).map(({ id }) => id), ['1', '3']);
|
||||
assert.deepEqual(checked, [['one.example', 1], ['three.example', 3]]);
|
||||
assert.deepEqual((await service.check(undefined)).map(({ id }) => id), ['1', '2', '3']);
|
||||
assert.deepEqual((await service.check('2')).map(({ id }) => id), ['1', '2', '3']);
|
||||
assert.deepEqual((await service.check([])).map(({ id }) => id), ['1', '2', '3']);
|
||||
assert.deepEqual(await service.check(['missing']), []);
|
||||
assert.deepEqual((await service.check('', undefined)).map(({ id }) => id), ['1', '2', '3']);
|
||||
assert.deepEqual((await service.check('personal', '2')).map(({ id }) => id), ['1', '2', '3']);
|
||||
assert.deepEqual((await service.check('personal', [])).map(({ id }) => id), ['1', '2', '3']);
|
||||
assert.deepEqual(await service.check('personal', ['missing']), []);
|
||||
assert.throws(() => service.check('missing', []), (error) => error.code === 'PROFILE_NOT_FOUND');
|
||||
|
||||
const failure = new Error('ping failed');
|
||||
await assert.rejects(
|
||||
createServerHealthService({ readServers: () => servers, ping: async () => { throw failure; } }).check([]),
|
||||
createServerHealthService({
|
||||
readProfiles: () => [{ id: 'personal', servers }],
|
||||
readDesiredProfileId: () => 'personal',
|
||||
ping: async () => { throw failure; },
|
||||
}).check('personal', []),
|
||||
(error) => error === failure,
|
||||
);
|
||||
});
|
||||
@@ -63,15 +69,18 @@ test('server health selection coerces IDs, deduplicates, ignores unknowns, and k
|
||||
test('server health route is the only endpoint adapter', async () => {
|
||||
const sent = [];
|
||||
const route = createServerHealthRoute({
|
||||
serverHealth: { check: async (ids) => [{ ids }] },
|
||||
serverHealth: { check: async (profileId, ids) => [{ profileId, ids }] },
|
||||
readBody: async () => ({ serverIds: ['chosen'] }),
|
||||
sendState: async (_res, extra) => { sent.push(extra); },
|
||||
});
|
||||
const response = {};
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/servers/ping-all' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/servers/ping-all' }, response), true);
|
||||
assert.deepEqual(sent, [{ results: [{ ids: ['chosen'] }] }]);
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/profiles/personal/servers/ping' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/profiles/personal/servers/ping' }, response), true);
|
||||
assert.deepEqual(sent, [{
|
||||
profileId: 'personal',
|
||||
results: [{ profileId: 'personal', ids: ['chosen'] }],
|
||||
}]);
|
||||
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createServerHealthRoute\(\{/);
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
function listen(server, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(...args, () => {
|
||||
server.off('error', reject);
|
||||
resolve(server.address());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = http.createServer();
|
||||
const address = await listen(server, 0, '127.0.0.1');
|
||||
await close(server);
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function waitForState(port, child, stderr) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (child.exitCode !== null) throw new Error(`Harbor exited early: ${stderr()}`);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/state`);
|
||||
if (response.ok) return response.json();
|
||||
} catch {
|
||||
// The disposable listener is still starting.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(`Harbor did not start: ${stderr()}`);
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null) return;
|
||||
child.kill('SIGTERM');
|
||||
await new Promise((resolve) => child.once('exit', resolve));
|
||||
}
|
||||
|
||||
function fakeSingbox(directory) {
|
||||
const binDirectory = path.join(directory, 'bin');
|
||||
const markerPath = path.join(directory, 'sing-box-runs.log');
|
||||
fs.mkdirSync(binDirectory);
|
||||
const executable = path.join(binDirectory, 'sing-box');
|
||||
fs.writeFileSync(executable, `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
if (process.argv[2] === 'check') process.exit(0);
|
||||
if (process.argv[2] === 'version') {
|
||||
console.log('sing-box version 1.13.18');
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.argv[2] === 'run') {
|
||||
fs.appendFileSync(process.env.HARBOR_TEST_RUN_MARKER, 'run\\n');
|
||||
setInterval(() => {}, 60_000);
|
||||
}
|
||||
`);
|
||||
fs.chmodSync(executable, 0o755);
|
||||
return { binDirectory, markerPath };
|
||||
}
|
||||
|
||||
function profileState(server) {
|
||||
return {
|
||||
schemaVersion: 5,
|
||||
revision: 4,
|
||||
profiles: [{
|
||||
id: 'profile-a',
|
||||
label: 'Основной',
|
||||
subscriptionUrl: 'https://provider.example/subscription-a',
|
||||
subscriptionConfig: null,
|
||||
servers: [server],
|
||||
userInfo: {},
|
||||
fetchedAt: null,
|
||||
desiredServerId: server.id,
|
||||
lastRefreshAttemptAt: null,
|
||||
lastRefreshErrorCode: null,
|
||||
}],
|
||||
desiredProfileId: 'profile-a',
|
||||
appliedProfileId: 'profile-a',
|
||||
appliedServerId: server.id,
|
||||
appliedServerSnapshot: server,
|
||||
routeRules: [],
|
||||
appliedRouteRules: [],
|
||||
routeRulesRevision: 0,
|
||||
connectionDesired: 'running',
|
||||
};
|
||||
}
|
||||
|
||||
function generatedConfig(server, final = server.id) {
|
||||
return {
|
||||
outbounds: [
|
||||
{
|
||||
type: server.protocol,
|
||||
tag: server.id,
|
||||
server: server.host,
|
||||
server_port: server.port,
|
||||
},
|
||||
{ type: 'direct', tag: 'direct' },
|
||||
],
|
||||
route: { final },
|
||||
};
|
||||
}
|
||||
|
||||
async function startClientFixture(t, {
|
||||
state,
|
||||
cacheContents,
|
||||
config,
|
||||
}) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
|
||||
const { binDirectory, markerPath } = fakeSingbox(directory);
|
||||
if (state !== undefined) fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify(state));
|
||||
if (cacheContents !== undefined) {
|
||||
fs.writeFileSync(path.join(directory, 'subscription-cache.json'), cacheContents);
|
||||
}
|
||||
if (config !== undefined) {
|
||||
fs.writeFileSync(path.join(directory, 'sing-box-config.json'), JSON.stringify(config));
|
||||
}
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
APP_COMPONENT: 'control',
|
||||
APP_MODE: 'client',
|
||||
DATA_DIR: directory,
|
||||
PORT: String(port),
|
||||
PATH: `${binDirectory}:${process.env.PATH || ''}`,
|
||||
HARBOR_HOST_NETWORK_STATE: path.join(directory, 'missing-network.json'),
|
||||
HARBOR_TEST_RUN_MARKER: markerPath,
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
t.after(async () => {
|
||||
await stopChild(child);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
return {
|
||||
directory,
|
||||
markerPath,
|
||||
state: await waitForState(port, child, () => stderr),
|
||||
};
|
||||
}
|
||||
|
||||
test('corrupt legacy cache with no canonical subscription fails closed instead of starting stale config', async (t) => {
|
||||
const staleServer = {
|
||||
id: 'stale-server',
|
||||
label: 'Stale server',
|
||||
host: 'stale.example',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
};
|
||||
const fixture = await startClientFixture(t, {
|
||||
state: { schemaVersion: 4, revision: 2, connectionDesired: 'running' },
|
||||
cacheContents: '{broken',
|
||||
config: generatedConfig(staleServer),
|
||||
});
|
||||
|
||||
assert.equal(fixture.state.subscription.status, 'missing');
|
||||
assert.deepEqual(fixture.state.profiles, []);
|
||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
||||
assert.equal(fixture.state.connection.process, 'stopped');
|
||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
||||
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
|
||||
name.startsWith('subscription-cache.json.corrupt-')
|
||||
)));
|
||||
});
|
||||
|
||||
test('legacy cache owned by another URL is backed up without mixing providers and boots stopped', async (t) => {
|
||||
const stateServer = {
|
||||
id: 'server-a',
|
||||
label: 'State server',
|
||||
host: 'state.example',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
};
|
||||
const cacheServer = {
|
||||
id: 'server-b',
|
||||
label: 'Cache server',
|
||||
host: 'cache.example',
|
||||
port: 8443,
|
||||
protocol: 'vless',
|
||||
type: 'vless',
|
||||
tag: 'Cache server',
|
||||
server: 'cache.example',
|
||||
server_port: 8443,
|
||||
};
|
||||
const fixture = await startClientFixture(t, {
|
||||
state: {
|
||||
schemaVersion: 4,
|
||||
revision: 2,
|
||||
subscriptionUrl: 'https://state.example/subscription-a',
|
||||
servers: [stateServer],
|
||||
selectedServerId: stateServer.id,
|
||||
appliedServerId: stateServer.id,
|
||||
connectionDesired: 'running',
|
||||
},
|
||||
cacheContents: JSON.stringify({
|
||||
url: 'https://cache.example/subscription-b',
|
||||
config: { outbounds: [cacheServer] },
|
||||
servers: [cacheServer],
|
||||
}),
|
||||
config: generatedConfig(stateServer),
|
||||
});
|
||||
|
||||
assert.equal(fixture.state.profiles.length, 1);
|
||||
assert.equal(fixture.state.profiles[0].subscription.host, 'state.example/…');
|
||||
assert.deepEqual(fixture.state.profiles[0].servers.map(({ id }) => id), [stateServer.id]);
|
||||
assert.equal(JSON.stringify(fixture.state).includes('cache.example'), false);
|
||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
||||
assert.equal(fixture.state.connection.process, 'stopped');
|
||||
assert.equal(fixture.state.selection.appliedServerId, '');
|
||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'subscription-cache.json')), false);
|
||||
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
|
||||
name.startsWith('subscription-cache.json.backup-v1-')
|
||||
)));
|
||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
||||
});
|
||||
|
||||
test('boot rejects an existing config whose route mode disagrees with the current route', async (t) => {
|
||||
const server = {
|
||||
id: 'server-a',
|
||||
label: 'Server A',
|
||||
host: 'a.example',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
};
|
||||
const fixture = await startClientFixture(t, {
|
||||
state: profileState(server),
|
||||
config: generatedConfig(server, 'direct'),
|
||||
});
|
||||
|
||||
assert.equal(fixture.state.route.mode, 'local-vpn');
|
||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
||||
assert.equal(fixture.state.connection.process, 'stopped');
|
||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
||||
});
|
||||
|
||||
test('boot rejects an existing config owned by a different applied target', async (t) => {
|
||||
const appliedServer = {
|
||||
id: 'server-a',
|
||||
label: 'Server A',
|
||||
host: 'a.example',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
};
|
||||
const otherServer = {
|
||||
id: 'server-b',
|
||||
label: 'Server B',
|
||||
host: 'b.example',
|
||||
port: 8443,
|
||||
protocol: 'vless',
|
||||
};
|
||||
const fixture = await startClientFixture(t, {
|
||||
state: profileState(appliedServer),
|
||||
config: generatedConfig(otherServer),
|
||||
});
|
||||
|
||||
assert.equal(fixture.state.selection.appliedServerId, '');
|
||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
||||
assert.equal(fixture.state.connection.process, 'stopped');
|
||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
||||
});
|
||||
|
||||
test('stopped Gateway boot explicitly stops an already running remote dataplane', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-'));
|
||||
const socketPath = path.join(directory, 'dataplane.sock');
|
||||
const requests = [];
|
||||
let running = true;
|
||||
const dataplane = http.createServer((req, res) => {
|
||||
requests.push(`${req.method} ${req.url}`);
|
||||
if (req.method === 'GET' && req.url === '/status') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
return res.end(JSON.stringify({ running, startedAt: running ? '2026-08-11T12:00:00.000Z' : null }));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/stop') {
|
||||
running = false;
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
return res.end(JSON.stringify({ running: false, startedAt: null }));
|
||||
}
|
||||
res.writeHead(503, { 'content-type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'not used by this fixture' }));
|
||||
});
|
||||
await listen(dataplane, socketPath);
|
||||
fs.writeFileSync(path.join(directory, 'state.json'), JSON.stringify({
|
||||
schemaVersion: 5,
|
||||
revision: 3,
|
||||
profiles: [],
|
||||
desiredProfileId: '',
|
||||
appliedProfileId: '',
|
||||
appliedServerId: '',
|
||||
appliedServerSnapshot: null,
|
||||
routeRules: [],
|
||||
appliedRouteRules: [],
|
||||
routeRulesRevision: 0,
|
||||
connectionDesired: 'stopped',
|
||||
}));
|
||||
fs.writeFileSync(path.join(directory, 'sing-box-config.json'), '{}');
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
APP_COMPONENT: 'control',
|
||||
APP_MODE: 'gateway',
|
||||
DATA_DIR: directory,
|
||||
DATAPLANE_SOCKET: socketPath,
|
||||
PORT: String(port),
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
t.after(async () => {
|
||||
await stopChild(child);
|
||||
await close(dataplane);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const state = await waitForState(port, child, () => stderr);
|
||||
assert.equal(requests.includes('POST /stop'), true);
|
||||
assert.equal(running, false);
|
||||
assert.equal(state.connection.desired, 'stopped');
|
||||
assert.equal(state.connection.process, 'stopped');
|
||||
});
|
||||
@@ -60,7 +60,7 @@ async function waitForState(port, child, stderr) {
|
||||
throw new Error(`Harbor did not start: ${stderr()}`);
|
||||
}
|
||||
|
||||
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
|
||||
test('state v1 projects legacy storage through the canonical profile snapshot', () => {
|
||||
const legacyServer = { tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 };
|
||||
const legacyServerId = createServerId(legacyServer);
|
||||
const stored = normalizeStoredState({
|
||||
@@ -79,9 +79,15 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
|
||||
});
|
||||
|
||||
assert.equal(snapshot.apiVersion, 1);
|
||||
assert.equal(snapshot.profiles.length, 1);
|
||||
assert.equal(snapshot.profiles[0].id, 'profile_primary');
|
||||
assert.equal(snapshot.profiles[0].desiredServerId, legacyServerId);
|
||||
assert.deepEqual(snapshot.selection, {
|
||||
desiredProfileId: 'profile_primary',
|
||||
desiredServerId: legacyServerId,
|
||||
appliedProfileId: 'profile_primary',
|
||||
appliedServerId: legacyServerId,
|
||||
appliedServerSnapshot: snapshot.profiles[0].servers[0],
|
||||
});
|
||||
assert.equal(snapshot.connection.process, 'running');
|
||||
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
|
||||
@@ -145,7 +151,7 @@ test('startup discards a rejected cached subscription and returns to first-run',
|
||||
assert.equal(child.exitCode, null);
|
||||
});
|
||||
|
||||
test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => {
|
||||
test('data invariant: canonical profile API mutates one snapshot and preserves migrated profile data', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
|
||||
const binDir = path.join(dir, 'bin');
|
||||
const config = {
|
||||
@@ -177,12 +183,9 @@ setInterval(() => {}, 60_000);
|
||||
fs.chmodSync(singboxPath, 0o755);
|
||||
|
||||
let providerFetchCount = 0;
|
||||
let delayedPath = '';
|
||||
let invalidNextPath = '';
|
||||
let trafficExhaustedNextPath = '';
|
||||
let delayedRequestStarted = null;
|
||||
let releaseDelayedRequest = null;
|
||||
const subscriptionServer = http.createServer(async (req, res) => {
|
||||
const subscriptionServer = http.createServer((req, res) => {
|
||||
providerFetchCount += 1;
|
||||
if (req.url === '/timeout') return;
|
||||
if (req.url === '/unavailable') {
|
||||
@@ -234,11 +237,6 @@ setInterval(() => {}, 60_000);
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
return res.end('not a subscription');
|
||||
}
|
||||
if (req.url === delayedPath) {
|
||||
delayedRequestStarted?.();
|
||||
await new Promise((resolve) => { releaseDelayedRequest = resolve; });
|
||||
delayedPath = '';
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'subscription-userinfo': 'upload=10; download=20; total=100',
|
||||
@@ -256,6 +254,7 @@ setInterval(() => {}, 60_000);
|
||||
url: subscriptionUrl,
|
||||
config,
|
||||
}));
|
||||
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
|
||||
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
||||
@@ -289,7 +288,13 @@ setInterval(() => {}, 60_000);
|
||||
runtime: { singBox: '1.12.13' },
|
||||
});
|
||||
assertStateSnapshot(initial);
|
||||
assert.equal(initial.profiles.length, 1);
|
||||
assert.equal(initial.profiles[0].id, 'profile_primary');
|
||||
assert.equal(initial.profiles[0].label, 'Основной');
|
||||
assert.equal(initial.selection.desiredProfileId, 'profile_primary');
|
||||
assert.equal(initial.selection.appliedServerId, testServerId);
|
||||
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 },
|
||||
]);
|
||||
@@ -297,6 +302,15 @@ setInterval(() => {}, 60_000);
|
||||
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.profiles.length, 1);
|
||||
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
||||
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
||||
assert.equal(migratedState.profiles[0].subscriptionConfig.outbounds[0].tag, testServerId);
|
||||
assert.equal(Object.hasOwn(migratedState, 'subscriptionUrl'), false);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
||||
assert.ok(fs.readdirSync(dir).some((name) => name.startsWith('subscription-cache.json.backup-v1-')));
|
||||
const stateKeys = Object.keys(initial).sort();
|
||||
assert.deepEqual(stateKeys, [
|
||||
'apiVersion',
|
||||
@@ -309,6 +323,7 @@ setInterval(() => {}, 60_000);
|
||||
'mode',
|
||||
'operation',
|
||||
'port',
|
||||
'profiles',
|
||||
'proxyPort',
|
||||
'revision',
|
||||
'route',
|
||||
@@ -350,11 +365,11 @@ setInterval(() => {}, 60_000);
|
||||
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
|
||||
assert.equal(providerUnavailable.payload.error.retryable, true);
|
||||
|
||||
const preservedSubscription = {
|
||||
state: JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')),
|
||||
cache: fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
|
||||
config: fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
|
||||
};
|
||||
const primaryProfileId = initial.profiles[0].id;
|
||||
const preservedPrimary = structuredClone(
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).profiles[0],
|
||||
);
|
||||
const preservedConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
||||
for (const [pathname, expectedCode] of [
|
||||
['/timeout', 'PROVIDER_UNAVAILABLE'],
|
||||
['/invalid', 'SUBSCRIPTION_INVALID'],
|
||||
@@ -362,46 +377,59 @@ setInterval(() => {}, 60_000);
|
||||
['/traffic', 'SUBSCRIPTION_TRAFFIC_EXHAUSTED'],
|
||||
['/disabled', 'SUBSCRIPTION_DISABLED'],
|
||||
]) {
|
||||
const failedImport = await rawRequest(
|
||||
const failedAdd = await rawRequest(
|
||||
port,
|
||||
'/api/subscription/fetch',
|
||||
'/api/profiles',
|
||||
'POST',
|
||||
{ url: `http://127.0.0.1:${subscriptionPort}${pathname}` },
|
||||
{
|
||||
label: `Новая ${pathname}`,
|
||||
url: `http://127.0.0.1:${subscriptionPort}${pathname}`,
|
||||
expectedRevision: revision,
|
||||
},
|
||||
);
|
||||
assert.equal(failedImport.payload.error.code, expectedCode);
|
||||
assert.equal(failedAdd.payload.error.code, expectedCode);
|
||||
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
||||
assert.equal(storedAfterFailure.subscriptionUrl, preservedSubscription.state.subscriptionUrl);
|
||||
assert.equal(storedAfterFailure.selectedTag, preservedSubscription.state.selectedTag);
|
||||
assert.deepEqual(storedAfterFailure.servers, preservedSubscription.state.servers);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
|
||||
preservedSubscription.cache,
|
||||
);
|
||||
assert.deepEqual(storedAfterFailure.profiles, [preservedPrimary]);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
|
||||
preservedSubscription.config,
|
||||
preservedConfig,
|
||||
);
|
||||
revision = (await request(port, '/api/state')).revision;
|
||||
}
|
||||
|
||||
trafficExhaustedNextPath = '/subscription/test';
|
||||
const exhaustedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
|
||||
const exhaustedRefresh = await rawRequest(
|
||||
port,
|
||||
`/api/profiles/${primaryProfileId}/refresh`,
|
||||
'POST',
|
||||
{ expectedRevision: revision },
|
||||
);
|
||||
assert.equal(exhaustedRefresh.response.status, 400);
|
||||
assert.equal(exhaustedRefresh.payload.error.code, 'SUBSCRIPTION_TRAFFIC_EXHAUSTED');
|
||||
const stateAfterExhaustedRefresh = await request(port, '/api/state');
|
||||
assert.equal(stateAfterExhaustedRefresh.hasSubscription, true);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), preservedSubscription.cache);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedSubscription.config);
|
||||
assert.equal(stateAfterExhaustedRefresh.profiles[0].subscription.status, 'stale');
|
||||
assert.equal(
|
||||
stateAfterExhaustedRefresh.profiles[0].subscription.errorCode,
|
||||
'SUBSCRIPTION_TRAFFIC_EXHAUSTED',
|
||||
);
|
||||
assert.equal(stateAfterExhaustedRefresh.profiles[0].servers[0].id, testServerId);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedConfig);
|
||||
revision = stateAfterExhaustedRefresh.revision;
|
||||
|
||||
const missingServer = await rawRequest(
|
||||
port,
|
||||
'/api/apply',
|
||||
'POST',
|
||||
{ selectedTag: 'missing-server' },
|
||||
{ profileId: primaryProfileId, serverId: 'missing-server', expectedRevision: revision },
|
||||
);
|
||||
assert.equal(missingServer.response.status, 404);
|
||||
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
|
||||
assert.equal((await request(port, '/api/state')).selection.desiredServerId, testServerId);
|
||||
const stateAfterMissingServer = await request(port, '/api/state');
|
||||
assert.equal(stateAfterMissingServer.selection.desiredProfileId, primaryProfileId);
|
||||
assert.equal(stateAfterMissingServer.selection.desiredServerId, testServerId);
|
||||
revision = stateAfterMissingServer.revision;
|
||||
|
||||
async function stateResponse(pathname, method = 'POST', body) {
|
||||
const result = await request(port, pathname, method, body);
|
||||
@@ -417,27 +445,76 @@ setInterval(() => {}, 60_000);
|
||||
return result;
|
||||
}
|
||||
|
||||
const fetchesBeforeImport = providerFetchCount;
|
||||
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
|
||||
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'))).config.outbounds[0].tag,
|
||||
'test-vpn',
|
||||
);
|
||||
const applied = await mutation('/api/apply', 'POST', { serverId: testServerId });
|
||||
assert.deepEqual(applied.state.selection, {
|
||||
desiredServerId: testServerId,
|
||||
appliedServerId: testServerId,
|
||||
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
|
||||
const fetchesBeforeDuplicate = providerFetchCount;
|
||||
const duplicateProfile = await rawRequest(port, '/api/profiles', 'POST', {
|
||||
label: 'основной',
|
||||
url: replacementUrl,
|
||||
expectedRevision: revision,
|
||||
});
|
||||
assert.equal(duplicateProfile.response.status, 409);
|
||||
assert.equal(duplicateProfile.payload.error.code, 'PROFILE_NAME_CONFLICT');
|
||||
assert.equal(providerFetchCount, fetchesBeforeDuplicate);
|
||||
assert.equal((await request(port, '/api/state')).revision, revision);
|
||||
|
||||
const fetchesBeforeAdd = providerFetchCount;
|
||||
const added = await mutation('/api/profiles', 'POST', {
|
||||
label: 'Работа',
|
||||
url: replacementUrl,
|
||||
expectedRevision: revision,
|
||||
});
|
||||
const workProfileId = added.profileId;
|
||||
assert.equal(providerFetchCount, fetchesBeforeAdd + 1);
|
||||
assert.equal(added.state.profiles.length, 2);
|
||||
assert.equal(added.state.selection.desiredProfileId, primaryProfileId);
|
||||
assert.equal(added.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, '');
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'))
|
||||
.profiles.find(({ id }) => id === workProfileId).subscriptionConfig.outbounds[0].tag,
|
||||
testServerId,
|
||||
);
|
||||
|
||||
const renamed = await mutation(`/api/profiles/${workProfileId}`, 'PATCH', {
|
||||
label: 'Офис',
|
||||
expectedRevision: revision,
|
||||
});
|
||||
assert.equal(renamed.state.profiles.find(({ id }) => id === workProfileId).label, 'Офис');
|
||||
const selected = await mutation(`/api/profiles/${workProfileId}/server`, 'PUT', {
|
||||
serverId: testServerId,
|
||||
expectedRevision: revision,
|
||||
});
|
||||
assert.equal(
|
||||
selected.state.profiles.find(({ id }) => id === workProfileId).desiredServerId,
|
||||
testServerId,
|
||||
);
|
||||
assert.equal(selected.state.profiles.find(({ id }) => id === primaryProfileId).desiredServerId, testServerId);
|
||||
|
||||
const applied = await mutation('/api/apply', 'POST', {
|
||||
profileId: workProfileId,
|
||||
serverId: testServerId,
|
||||
expectedRevision: revision,
|
||||
});
|
||||
assert.equal(applied.state.selection.desiredProfileId, workProfileId);
|
||||
assert.equal(applied.state.selection.desiredServerId, testServerId);
|
||||
assert.equal(applied.state.selection.appliedProfileId, workProfileId);
|
||||
assert.equal(applied.state.selection.appliedServerId, testServerId);
|
||||
assert.equal(applied.state.selection.appliedServerSnapshot.id, testServerId);
|
||||
assert.equal(applied.state.connection.process, 'running');
|
||||
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
|
||||
const configBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
||||
invalidNextPath = '/subscription/test';
|
||||
const failedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
|
||||
invalidNextPath = '/subscription/replacement';
|
||||
const failedRefresh = await rawRequest(
|
||||
port,
|
||||
`/api/profiles/${workProfileId}/refresh`,
|
||||
'POST',
|
||||
{ expectedRevision: revision },
|
||||
);
|
||||
assert.equal(failedRefresh.response.status, 400);
|
||||
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).selectedTag, 'test-vpn');
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), cacheBeforeFailedRefresh);
|
||||
const storedAfterFailedRefresh = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
||||
const staleWorkProfile = storedAfterFailedRefresh.profiles.find(({ id }) => id === workProfileId);
|
||||
assert.equal(staleWorkProfile.desiredServerId, testServerId);
|
||||
assert.equal(staleWorkProfile.lastRefreshErrorCode, 'SUBSCRIPTION_INVALID');
|
||||
assert.equal(staleWorkProfile.servers[0].id, testServerId);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), configBeforeFailedRefresh);
|
||||
revision = (await request(port, '/api/state')).revision;
|
||||
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
|
||||
@@ -549,28 +626,28 @@ if (process.argv[2] === 'check') {
|
||||
fs.writeFileSync(singboxPath, workingSingbox);
|
||||
fs.chmodSync(singboxPath, 0o755);
|
||||
|
||||
const delayedRequest = new Promise((resolve) => { delayedRequestStarted = resolve; });
|
||||
delayedPath = '/subscription/test';
|
||||
const staleRefresh = rawRequest(port, '/api/subscription/refresh', 'POST');
|
||||
await delayedRequest;
|
||||
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
|
||||
await mutation('/api/subscription/fetch', 'POST', { url: replacementUrl });
|
||||
releaseDelayedRequest();
|
||||
const staleRefreshResult = await staleRefresh;
|
||||
assert.equal(staleRefreshResult.response.status, 409);
|
||||
assert.equal(staleRefreshResult.payload.error.code, 'STATE_CONFLICT');
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, replacementUrl);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')).url, replacementUrl);
|
||||
revision = (await request(port, '/api/state')).revision;
|
||||
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
|
||||
const forgotten = await mutation('/api/subscription', 'DELETE');
|
||||
assert.equal(forgotten.state.subscription.status, 'missing');
|
||||
assert.equal(forgotten.state.servers.length, 0);
|
||||
assert.deepEqual(forgotten.state.route.localRules, routed.state.route.localRules);
|
||||
assert.deepEqual((await stateResponse('/api/servers/ping-all')).results, []);
|
||||
const inactiveDeleted = await mutation(`/api/profiles/${primaryProfileId}`, 'DELETE', {
|
||||
mode: 'delete',
|
||||
expectedRevision: revision,
|
||||
});
|
||||
assert.deepEqual(inactiveDeleted.state.profiles.map(({ id }) => id), [workProfileId]);
|
||||
assert.equal(inactiveDeleted.state.selection.appliedProfileId, workProfileId);
|
||||
|
||||
const activeDeleted = await mutation(`/api/profiles/${workProfileId}`, 'DELETE', {
|
||||
mode: 'stop-and-delete',
|
||||
expectedRevision: revision,
|
||||
});
|
||||
assert.equal(activeDeleted.state.subscription.status, 'missing');
|
||||
assert.deepEqual(activeDeleted.state.profiles, []);
|
||||
assert.equal(activeDeleted.state.selection.desiredProfileId, '');
|
||||
assert.equal(activeDeleted.state.selection.appliedProfileId, '');
|
||||
assert.equal(activeDeleted.state.servers.length, 0);
|
||||
assert.deepEqual(activeDeleted.state.route.localRules, routed.state.route.localRules);
|
||||
|
||||
const missingConfig = await rawRequest(port, '/api/singbox/restart', 'POST');
|
||||
assert.equal(missingConfig.response.status, 422);
|
||||
assert.equal(missingConfig.payload.error.code, 'CONFIG_INVALID');
|
||||
assert.equal(missingConfig.response.status, 404);
|
||||
assert.equal(missingConfig.payload.error.code, 'PROFILE_NOT_FOUND');
|
||||
assert.equal(missingConfig.payload.error.retryable, false);
|
||||
});
|
||||
|
||||
@@ -63,9 +63,12 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
|
||||
assert.deepEqual(migrated.routeRules, [
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||
]);
|
||||
assert.equal(migrated.appliedTag, 'nl');
|
||||
assert.equal(migrated.selectedServerId, createServerId(legacy.servers[0]));
|
||||
assert.equal(migrated.appliedServerId, migrated.selectedServerId);
|
||||
const primary = migrated.profiles[0];
|
||||
assert.equal(primary.label, 'Основной');
|
||||
assert.equal(primary.desiredServerId, createServerId(legacy.servers[0]));
|
||||
assert.equal(migrated.appliedProfileId, primary.id);
|
||||
assert.equal(migrated.appliedServerId, primary.desiredServerId);
|
||||
assert.equal(Object.hasOwn(migrated, 'selectedServerId'), false);
|
||||
assert.equal(store.migration.fromVersion, 2);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||
@@ -84,9 +87,54 @@ test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
|
||||
|
||||
const migrated = createStateStore(filePath).read();
|
||||
|
||||
assert.equal(migrated.selectedServerId, '');
|
||||
assert.equal(migrated.profiles[0].desiredServerId, '');
|
||||
assert.equal(migrated.appliedServerId, '');
|
||||
assert.equal(migrated.servers.length, 2);
|
||||
assert.equal(migrated.profiles[0].servers.length, 2);
|
||||
});
|
||||
|
||||
test('schema v4 migration never combines canonical state with a different cache owner', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const stateServer = {
|
||||
tag: 'State server',
|
||||
type: 'vless',
|
||||
server: 'state.example',
|
||||
server_port: 443,
|
||||
};
|
||||
const cacheServer = {
|
||||
tag: 'Cache server',
|
||||
type: 'vless',
|
||||
server: 'cache.example',
|
||||
server_port: 8443,
|
||||
};
|
||||
const stateServerId = createServerId(stateServer);
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
schemaVersion: 4,
|
||||
revision: 9,
|
||||
subscriptionUrl: 'https://state.example/subscription-a',
|
||||
servers: [stateServer],
|
||||
selectedServerId: stateServerId,
|
||||
appliedServerId: stateServerId,
|
||||
connectionDesired: 'running',
|
||||
}));
|
||||
|
||||
const migrated = createStateStore(filePath, {
|
||||
legacySubscriptionCache: {
|
||||
url: 'https://cache.example/subscription-b',
|
||||
config: { outbounds: [cacheServer] },
|
||||
servers: [cacheServer],
|
||||
userInfo: { total: 123 },
|
||||
fetchedAt: '2026-08-11T12:00:00.000Z',
|
||||
},
|
||||
}).read();
|
||||
|
||||
assert.equal(migrated.profiles.length, 1);
|
||||
assert.equal(migrated.profiles[0].subscriptionUrl, 'https://state.example/subscription-a');
|
||||
assert.equal(migrated.profiles[0].subscriptionConfig, null);
|
||||
assert.deepEqual(migrated.profiles[0].servers.map(({ id }) => id), [stateServerId]);
|
||||
assert.equal(migrated.profiles[0].desiredServerId, stateServerId);
|
||||
assert.equal(migrated.appliedServerId, stateServerId);
|
||||
assert.equal(migrated.appliedServerSnapshot.id, stateServerId);
|
||||
assert.equal(JSON.stringify(migrated).includes('cache.example'), false);
|
||||
});
|
||||
|
||||
test('data invariant: corrupt JSON preserves original bytes and returns explicit recovery state', (t) => {
|
||||
|
||||
@@ -4,144 +4,111 @@ import test from 'node:test';
|
||||
|
||||
import { createSubscriptionService } from '../../dist/server/features/subscription/index.js';
|
||||
import { createSubscriptionMutationRoute } from '../../dist/server/http/routes/subscriptionMutationRoute.js';
|
||||
import { HarborError } from '../../dist/shared/errors.js';
|
||||
|
||||
const oldServer = {
|
||||
id: 'srv_old',
|
||||
label: 'Old',
|
||||
host: 'old.example',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
};
|
||||
const nextServer = {
|
||||
id: 'srv_next',
|
||||
label: 'Next',
|
||||
host: 'next.example',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
};
|
||||
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 }];
|
||||
|
||||
function parsed(servers = [nextServer]) {
|
||||
const profile = (id, label, server = oldServer) => ({
|
||||
id,
|
||||
label,
|
||||
subscriptionUrl: `https://${id}.example/sub`,
|
||||
subscriptionConfig: { profile: id },
|
||||
servers: [server],
|
||||
userInfo: { total: 100 },
|
||||
fetchedAt: '2026-08-08T10:00:00.000Z',
|
||||
desiredServerId: server.id,
|
||||
lastRefreshAttemptAt: null,
|
||||
lastRefreshErrorCode: null,
|
||||
});
|
||||
|
||||
const parsed = (id, servers = [nextServer]) => ({
|
||||
config: { profile: id, refreshed: true },
|
||||
servers,
|
||||
userInfo: { total: 200 },
|
||||
fetchedAt: '2026-08-08T12:00:00.000Z',
|
||||
});
|
||||
|
||||
function defaultState() {
|
||||
return {
|
||||
config: { normalized: true },
|
||||
sourceConfig: { source: true },
|
||||
servers,
|
||||
userInfo: { total: 100 },
|
||||
fetchedAt: '2026-08-08T10:00:00.000Z',
|
||||
revision: 3,
|
||||
profiles: [profile('personal', 'Личный'), profile('work', 'Работа')],
|
||||
desiredProfileId: 'personal',
|
||||
appliedProfileId: 'personal',
|
||||
appliedServerId: 'shared',
|
||||
appliedServerSnapshot: oldServer,
|
||||
routeRules,
|
||||
appliedRouteRules: routeRules,
|
||||
routeRulesRevision: 1,
|
||||
connectionDesired: 'running',
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
let state = structuredClone(overrides.state ?? {
|
||||
revision: 3,
|
||||
subscriptionUrl: 'https://old.example/sub',
|
||||
servers: [oldServer],
|
||||
selectedServerId: oldServer.id,
|
||||
appliedServerId: oldServer.id,
|
||||
routeRules,
|
||||
gatewayAutoEnabled: false,
|
||||
connectionDesired: 'running',
|
||||
});
|
||||
let cache = structuredClone(overrides.cache ?? { url: 'old', config: { old: true } });
|
||||
let config = overrides.config ?? 'old-config-bytes';
|
||||
let gatewayAuto = structuredClone(overrides.gatewayAuto ?? { mode: 'gateway-direct', gatewayId: 'old' });
|
||||
let state = structuredClone(overrides.state ?? defaultState());
|
||||
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
||||
let gatewayAuto = { mode: 'gateway-direct', gatewayId: 'gateway' };
|
||||
let running = overrides.running ?? true;
|
||||
let serialized = Promise.resolve();
|
||||
let tail = Promise.resolve();
|
||||
let updateCount = 0;
|
||||
const calls = [];
|
||||
const failures = { ...(overrides.failures || {}) };
|
||||
const timers = [];
|
||||
const providerCalls = [];
|
||||
|
||||
const failOnce = (name) => {
|
||||
const failure = failures[name];
|
||||
if (!failure) return;
|
||||
delete failures[name];
|
||||
throw failure;
|
||||
const serialize = (operation) => {
|
||||
const result = tail.then(operation, operation);
|
||||
tail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
};
|
||||
|
||||
const dependencies = {
|
||||
const service = createSubscriptionService({
|
||||
provider: {
|
||||
fetchSubscription: overrides.fetchSubscription || (async () => parsed()),
|
||||
selectRefreshedServer: overrides.selectRefreshedServer || ((current, _before, after) => (
|
||||
fetchSubscription: async (url) => {
|
||||
providerCalls.push(url);
|
||||
if (overrides.fetchSubscription) return overrides.fetchSubscription(url);
|
||||
return parsed(url.includes('work') ? 'work' : 'new');
|
||||
},
|
||||
selectRefreshedServer: (current, _before, after) => (
|
||||
after.some((server) => server.id === current) ? current : ''
|
||||
)),
|
||||
),
|
||||
},
|
||||
state: {
|
||||
read: () => structuredClone(state),
|
||||
update: (mutator) => {
|
||||
failOnce('stateUpdate');
|
||||
const revision = state.revision + 1;
|
||||
state = { ...structuredClone(mutator(structuredClone(state))), revision };
|
||||
updateCount += 1;
|
||||
const next = structuredClone(mutator(structuredClone(state)));
|
||||
state = { ...next, revision: state.revision + 1 };
|
||||
calls.push('state.update');
|
||||
failOnce('stateUpdateAfter');
|
||||
if (overrides.failStateAt === updateCount) throw new Error('state failed');
|
||||
return structuredClone(state);
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
read: () => structuredClone(cache),
|
||||
write: (value) => {
|
||||
failOnce('cacheWrite');
|
||||
cache = structuredClone(value);
|
||||
calls.push('cache.write');
|
||||
},
|
||||
remove: () => {
|
||||
failOnce('cacheRemove');
|
||||
cache = null;
|
||||
calls.push('cache.remove');
|
||||
},
|
||||
},
|
||||
config: {
|
||||
build: (_value, selectedServerId, rules) => ({ selectedServerId, rules }),
|
||||
read: () => config,
|
||||
write: (value) => {
|
||||
failOnce('configWrite');
|
||||
config = JSON.stringify(value);
|
||||
calls.push('config.write');
|
||||
},
|
||||
restore: (value) => {
|
||||
failOnce('configRestore');
|
||||
config = value;
|
||||
calls.push('config.restore');
|
||||
},
|
||||
remove: () => {
|
||||
failOnce('configRemove');
|
||||
config = null;
|
||||
calls.push('config.remove');
|
||||
},
|
||||
write: (value) => { config = JSON.stringify(value); calls.push('config.write'); },
|
||||
restore: (value) => { config = value; calls.push('config.restore'); },
|
||||
remove: () => { config = null; calls.push('config.remove'); },
|
||||
},
|
||||
runtime: {
|
||||
isRunning: async () => running,
|
||||
stop: async () => {
|
||||
calls.push('runtime.stop');
|
||||
failOnce('runtimeStop');
|
||||
if (overrides.failStop) throw overrides.failStop;
|
||||
running = false;
|
||||
},
|
||||
start: async () => {
|
||||
calls.push('runtime.start');
|
||||
const failure = Array.isArray(failures.runtimeStart)
|
||||
? failures.runtimeStart.shift()
|
||||
: failures.runtimeStart;
|
||||
if (failure) {
|
||||
if (!Array.isArray(failures.runtimeStart)) delete failures.runtimeStart;
|
||||
running = false;
|
||||
throw failure;
|
||||
}
|
||||
if (overrides.failStart) throw overrides.failStart;
|
||||
running = true;
|
||||
},
|
||||
},
|
||||
gatewayAuto: {
|
||||
read: () => structuredClone(gatewayAuto),
|
||||
set: (value) => {
|
||||
gatewayAuto = structuredClone(value);
|
||||
calls.push('gateway.set');
|
||||
},
|
||||
set: (value) => { gatewayAuto = structuredClone(value); calls.push('gateway.set'); },
|
||||
createInitial: () => ({ mode: 'local-vpn' }),
|
||||
},
|
||||
serialize: (operation) => {
|
||||
const result = serialized.then(operation, operation);
|
||||
serialized = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
},
|
||||
serialize,
|
||||
scheduler: {
|
||||
setInterval: (callback, intervalMs) => {
|
||||
const timer = {
|
||||
@@ -157,383 +124,241 @@ function createHarness(overrides = {}) {
|
||||
clearInterval: (timer) => { timer.clearCalls += 1; },
|
||||
},
|
||||
onRefreshError: overrides.onRefreshError || (() => {}),
|
||||
};
|
||||
now: () => new Date('2026-08-08T12:30:00.000Z'),
|
||||
});
|
||||
|
||||
return {
|
||||
service: createSubscriptionService(dependencies),
|
||||
service,
|
||||
calls,
|
||||
timers,
|
||||
snapshot: () => structuredClone({ state, cache, config, gatewayAuto, running }),
|
||||
providerCalls,
|
||||
advanceBackgroundRevision: (patch = {}) => {
|
||||
state = { ...state, ...structuredClone(patch), revision: state.revision + 1 };
|
||||
},
|
||||
snapshot: () => structuredClone({ state, config, gatewayAuto, running }),
|
||||
};
|
||||
}
|
||||
|
||||
function assertRestoredSnapshot(actual, expected, label) {
|
||||
const actualRevision = actual.state.revision;
|
||||
const expectedRevision = expected.state.revision;
|
||||
const actualDomain = structuredClone(actual);
|
||||
const expectedDomain = structuredClone(expected);
|
||||
delete actualDomain.state.revision;
|
||||
delete expectedDomain.state.revision;
|
||||
assert.deepEqual(actualDomain, expectedDomain, label);
|
||||
assert.ok(actualRevision >= expectedRevision, `${label}: revision moved backwards`);
|
||||
}
|
||||
test('add fetches before commit, keeps the runtime, and only the first profile becomes desired', async () => {
|
||||
const empty = defaultState();
|
||||
empty.profiles = [];
|
||||
empty.desiredProfileId = '';
|
||||
empty.appliedProfileId = '';
|
||||
empty.appliedServerId = '';
|
||||
empty.appliedServerSnapshot = null;
|
||||
empty.connectionDesired = 'stopped';
|
||||
const harness = createHarness({ state: empty, running: false });
|
||||
|
||||
test('subscription import commits source config, clears selection, and leaves runtime stopped', async () => {
|
||||
const result = await harness.service.addProfile(' Личный ', 'https://new.example/sub', 3);
|
||||
const after = harness.snapshot();
|
||||
assert.equal(result.label, 'Личный');
|
||||
assert.equal(after.state.profiles.length, 1);
|
||||
assert.equal(after.state.desiredProfileId, result.profileId);
|
||||
assert.equal(after.state.profiles[0].subscriptionUrl, 'https://new.example/sub');
|
||||
assert.equal(after.state.profiles[0].desiredServerId, '');
|
||||
assert.equal(after.running, false);
|
||||
assert.equal(after.config, 'old-config');
|
||||
|
||||
await harness.service.addProfile('Работа', 'https://work.example/sub');
|
||||
assert.equal(harness.snapshot().state.desiredProfileId, result.profileId);
|
||||
});
|
||||
|
||||
test('rename and server selection are profile-scoped and reject stale or duplicate writes', async () => {
|
||||
const harness = createHarness();
|
||||
await harness.service.renameProfile('work', 'Офис', 3);
|
||||
assert.equal(harness.snapshot().state.profiles.find(({ id }) => id === 'work').label, 'Офис');
|
||||
const duplicateRevision = harness.snapshot().state.revision;
|
||||
await assert.rejects(harness.service.renameProfile('work', 'личный'), (error) => error.code === 'PROFILE_NAME_CONFLICT');
|
||||
assert.equal(harness.snapshot().state.revision, duplicateRevision);
|
||||
await assert.rejects(harness.service.selectProfileServer('work', 'missing'), (error) => error.code === 'SERVER_NOT_FOUND');
|
||||
|
||||
const result = await harness.service.importSubscription('https://new.example/sub');
|
||||
const snapshot = harness.snapshot();
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.selectedServerId, '');
|
||||
assert.equal(snapshot.state.subscriptionUrl, 'https://new.example/sub');
|
||||
assert.deepEqual(snapshot.state.routeRules, routeRules);
|
||||
assert.equal(snapshot.state.gatewayAutoEnabled, false);
|
||||
assert.equal(snapshot.state.connectionDesired, 'stopped');
|
||||
assert.deepEqual(snapshot.cache.config, { source: true });
|
||||
assert.equal(snapshot.config, null);
|
||||
assert.deepEqual(snapshot.gatewayAuto, { mode: 'local-vpn' });
|
||||
assert.equal(snapshot.running, false);
|
||||
assert.equal(harness.calls.filter((call) => call === 'runtime.stop').length, 1);
|
||||
assert.equal(harness.calls.filter((call) => call === 'runtime.start').length, 0);
|
||||
const state = harness.snapshot().state;
|
||||
state.profiles.find(({ id }) => id === 'work').servers = [oldServer, nextServer];
|
||||
state.profiles.find(({ id }) => id === 'work').desiredServerId = 'shared';
|
||||
const scoped = createHarness({ state });
|
||||
await scoped.service.selectProfileServer('work', 'next', state.revision);
|
||||
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'work').desiredServerId, 'next');
|
||||
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'personal').desiredServerId, 'shared');
|
||||
});
|
||||
|
||||
test('subscription refresh retains selection and restarts only a previously running runtime', async () => {
|
||||
const retained = { ...oldServer, label: 'Renamed' };
|
||||
const harness = createHarness({ fetchSubscription: async () => parsed([retained, nextServer]) });
|
||||
|
||||
const result = await harness.service.refreshSavedSubscription();
|
||||
const snapshot = harness.snapshot();
|
||||
|
||||
assert.equal(result.selectedServerId, oldServer.id);
|
||||
assert.equal(snapshot.state.selectedServerId, oldServer.id);
|
||||
assert.match(snapshot.config, /srv_old/);
|
||||
assert.equal(snapshot.running, true);
|
||||
assert.equal(harness.calls.filter((call) => call === 'runtime.stop').length, 0);
|
||||
assert.equal(harness.calls.filter((call) => call === 'runtime.start').length, 1);
|
||||
test('refreshing an inactive profile does not mutate config, runtime, or the applied pair', async () => {
|
||||
const harness = createHarness({ fetchSubscription: async () => parsed('work', [oldServer, nextServer]) });
|
||||
await harness.service.refreshProfile('work', 3);
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.state.profiles.find(({ id }) => id === 'work').servers.length, 2);
|
||||
assert.equal(after.state.appliedProfileId, 'personal');
|
||||
assert.equal(after.state.appliedServerId, 'shared');
|
||||
assert.equal(after.config, 'old-config');
|
||||
assert.equal(harness.calls.includes('runtime.start'), false);
|
||||
});
|
||||
|
||||
test('subscription import restores every snapshot on pre-commit failures', async () => {
|
||||
for (const failurePoint of ['runtimeStop', 'configRemove', 'cacheWrite', 'stateUpdate', 'stateUpdateAfter']) {
|
||||
const failure = new Error(failurePoint);
|
||||
const harness = createHarness({ failures: { [failurePoint]: failure } });
|
||||
const before = harness.snapshot();
|
||||
|
||||
await assert.rejects(
|
||||
harness.service.importSubscription('https://new.example/sub'),
|
||||
(error) => error === failure,
|
||||
failurePoint,
|
||||
);
|
||||
assertRestoredSnapshot(harness.snapshot(), before, failurePoint);
|
||||
}
|
||||
test('provider failure retains the last successful list and records a scoped stale marker', async () => {
|
||||
const failure = Object.assign(new Error('provider down'), { code: 'PROVIDER_UNAVAILABLE' });
|
||||
const harness = createHarness({ fetchSubscription: async () => { throw failure; } });
|
||||
await assert.rejects(harness.service.refreshProfile('work', 3), (error) => error === failure);
|
||||
const work = harness.snapshot().state.profiles.find(({ id }) => id === 'work');
|
||||
assert.deepEqual(work.servers, [oldServer]);
|
||||
assert.equal(work.lastRefreshErrorCode, 'PROVIDER_UNAVAILABLE');
|
||||
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
||||
});
|
||||
|
||||
test('subscription refresh restores snapshots and reapplies the old config on pre-commit failures', async () => {
|
||||
for (const failurePoint of ['configWrite', 'cacheWrite', 'stateUpdate', 'stateUpdateAfter']) {
|
||||
const failure = new Error(failurePoint);
|
||||
const harness = createHarness({
|
||||
fetchSubscription: async () => parsed([oldServer]),
|
||||
failures: { [failurePoint]: failure },
|
||||
});
|
||||
const before = harness.snapshot();
|
||||
|
||||
await assert.rejects(
|
||||
harness.service.refreshSavedSubscription(),
|
||||
(error) => error === failure,
|
||||
failurePoint,
|
||||
);
|
||||
assertRestoredSnapshot(harness.snapshot(), before, failurePoint);
|
||||
assert.equal(
|
||||
harness.calls.filter((call) => call === 'runtime.start').length,
|
||||
failurePoint === 'stateUpdate' || failurePoint === 'stateUpdateAfter' ? 2 : 0,
|
||||
failurePoint,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('subscription reset restores cache, config, gateway, state, and runtime on removal or commit failure', async () => {
|
||||
for (const failurePoint of ['configRemove', 'cacheRemove', 'stateUpdate', 'stateUpdateAfter']) {
|
||||
const failure = new Error(failurePoint);
|
||||
const harness = createHarness({ failures: { [failurePoint]: failure } });
|
||||
const before = harness.snapshot();
|
||||
|
||||
await assert.rejects(
|
||||
harness.service.resetSavedSubscription(),
|
||||
(error) => error === failure,
|
||||
failurePoint,
|
||||
);
|
||||
assertRestoredSnapshot(harness.snapshot(), before, failurePoint);
|
||||
}
|
||||
|
||||
const noRuntime = createHarness({ failures: { stateUpdate: new Error('commit') } });
|
||||
await assert.rejects(noRuntime.service.resetSavedSubscription({ stopRuntime: false }));
|
||||
assert.equal(noRuntime.calls.includes('runtime.stop'), false);
|
||||
assert.equal(noRuntime.calls.includes('runtime.start'), false);
|
||||
|
||||
const stoppedCleanup = createHarness({ running: false });
|
||||
await stoppedCleanup.service.resetSavedSubscription();
|
||||
assert.equal(stoppedCleanup.calls.filter((call) => call === 'runtime.stop').length, 1);
|
||||
assert.equal(stoppedCleanup.calls.includes('runtime.start'), false);
|
||||
});
|
||||
|
||||
test('subscription rollback attempts remaining restores and aggregates restoration failures', async () => {
|
||||
const commitFailure = new Error('commit failed');
|
||||
const configRestoreFailure = new Error('config restore failed');
|
||||
const configBroken = createHarness({
|
||||
fetchSubscription: async () => parsed([oldServer]),
|
||||
failures: { stateUpdate: commitFailure, configRestore: configRestoreFailure },
|
||||
test('provider mutations keep admission CAS while tolerating background-only revision bumps', async () => {
|
||||
let releaseAdd;
|
||||
let addStarted;
|
||||
const addFetchStarted = new Promise((resolve) => { addStarted = resolve; });
|
||||
const adding = createHarness({
|
||||
fetchSubscription: async () => {
|
||||
addStarted();
|
||||
return new Promise((resolve) => { releaseAdd = resolve; });
|
||||
},
|
||||
});
|
||||
const beforeConfigBroken = configBroken.snapshot();
|
||||
await assert.rejects(configBroken.service.refreshSavedSubscription(), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors, [commitFailure, configRestoreFailure]);
|
||||
return true;
|
||||
});
|
||||
assert.equal(configBroken.snapshot().cache.url, beforeConfigBroken.cache.url);
|
||||
assert.deepEqual(configBroken.snapshot().gatewayAuto, beforeConfigBroken.gatewayAuto);
|
||||
assert.equal(configBroken.snapshot().running, true);
|
||||
assert.equal(configBroken.calls.filter((call) => call === 'runtime.start').length, 2);
|
||||
const pendingAdd = adding.service.addProfile('Резерв', 'https://reserve.example/sub', 3);
|
||||
await addFetchStarted;
|
||||
adding.advanceBackgroundRevision({ gatewayFreshness: 'verified' });
|
||||
releaseAdd(parsed('reserve'));
|
||||
const added = await pendingAdd;
|
||||
assert.equal(adding.snapshot().state.profiles.some(({ id }) => id === added.profileId), true);
|
||||
assert.equal(adding.snapshot().state.gatewayFreshness, 'verified');
|
||||
|
||||
const removeFailure = new Error('remove failed');
|
||||
const cacheRestoreFailure = new Error('cache restore failed');
|
||||
const cacheBroken = createHarness({
|
||||
failures: { configRemove: removeFailure, cacheWrite: cacheRestoreFailure },
|
||||
let rejectRefresh;
|
||||
let refreshStarted;
|
||||
const refreshFetchStarted = new Promise((resolve) => { refreshStarted = resolve; });
|
||||
const refreshing = createHarness({
|
||||
fetchSubscription: async () => {
|
||||
refreshStarted();
|
||||
return new Promise((_resolve, reject) => { rejectRefresh = reject; });
|
||||
},
|
||||
});
|
||||
const beforeCacheBroken = cacheBroken.snapshot();
|
||||
await assert.rejects(cacheBroken.service.importSubscription('https://new.example/sub'), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors, [removeFailure, cacheRestoreFailure]);
|
||||
return true;
|
||||
});
|
||||
assert.equal(cacheBroken.snapshot().config, beforeCacheBroken.config);
|
||||
assert.deepEqual(cacheBroken.snapshot().gatewayAuto, beforeCacheBroken.gatewayAuto);
|
||||
assert.equal(cacheBroken.snapshot().running, true);
|
||||
assert.equal(cacheBroken.calls.filter((call) => call === 'runtime.start').length, 1);
|
||||
});
|
||||
|
||||
test('failed refresh start rolls back, and failed rollback start reports both causes', async () => {
|
||||
const applyFailure = new Error('apply failed');
|
||||
const recovered = createHarness({
|
||||
fetchSubscription: async () => parsed([oldServer]),
|
||||
failures: { runtimeStart: [applyFailure] },
|
||||
});
|
||||
const before = recovered.snapshot();
|
||||
await assert.rejects(
|
||||
recovered.service.refreshSavedSubscription(),
|
||||
(error) => error === applyFailure,
|
||||
const providerFailure = Object.assign(new Error('provider down'), { code: 'PROVIDER_UNAVAILABLE' });
|
||||
const pendingRefresh = refreshing.service.refreshProfile('work', 3);
|
||||
await refreshFetchStarted;
|
||||
refreshing.advanceBackgroundRevision({ gatewayFreshness: 'verified' });
|
||||
rejectRefresh(providerFailure);
|
||||
await assert.rejects(pendingRefresh, (error) => error === providerFailure);
|
||||
const refreshedState = refreshing.snapshot().state;
|
||||
assert.equal(refreshedState.gatewayFreshness, 'verified');
|
||||
assert.equal(
|
||||
refreshedState.profiles.find(({ id }) => id === 'work').lastRefreshErrorCode,
|
||||
'PROVIDER_UNAVAILABLE',
|
||||
);
|
||||
assert.deepEqual(recovered.snapshot(), before);
|
||||
assert.equal(recovered.calls.filter((call) => call === 'runtime.start').length, 2);
|
||||
|
||||
const rollbackFailure = new Error('rollback failed');
|
||||
const broken = createHarness({
|
||||
fetchSubscription: async () => parsed([oldServer]),
|
||||
failures: { runtimeStart: [applyFailure, rollbackFailure] },
|
||||
});
|
||||
await assert.rejects(broken.service.refreshSavedSubscription(), (error) => {
|
||||
assert.equal(error.code, 'PROCESS_START_FAILED');
|
||||
assert.ok(error.cause instanceof AggregateError);
|
||||
assert.deepEqual(error.cause.errors, [applyFailure, rollbackFailure]);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('subscription refresh coalesces provider work and rejects a stale late result', async () => {
|
||||
let releaseOld;
|
||||
const oldResult = new Promise((resolve) => { releaseOld = resolve; });
|
||||
let providerCalls = 0;
|
||||
test('active refresh applies a retained server and keeps last-applied snapshot if it disappears', async () => {
|
||||
const retained = createHarness({ fetchSubscription: async () => parsed('personal', [oldServer, nextServer]) });
|
||||
await retained.service.refreshProfile('personal', 3);
|
||||
assert.ok(retained.calls.includes('config.write'));
|
||||
assert.ok(retained.calls.includes('runtime.start'));
|
||||
assert.equal(retained.snapshot().state.appliedServerId, 'shared');
|
||||
|
||||
const removed = createHarness({ fetchSubscription: async () => parsed('personal', [nextServer]) });
|
||||
await removed.service.refreshProfile('personal', 3);
|
||||
const after = removed.snapshot();
|
||||
assert.equal(after.state.profiles[0].desiredServerId, '');
|
||||
assert.equal(after.state.appliedServerId, 'shared');
|
||||
assert.deepEqual(after.state.appliedServerSnapshot, oldServer);
|
||||
assert.equal(removed.calls.includes('config.write'), false);
|
||||
assert.equal(after.running, true);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.deepEqual(inactive.snapshot().state.profiles.map(({ id }) => id), ['personal']);
|
||||
assert.equal(inactive.snapshot().config, 'old-config');
|
||||
assert.equal(inactive.snapshot().running, true);
|
||||
|
||||
const active = createHarness();
|
||||
await assert.rejects(active.service.deleteProfile('personal', 'delete', 3), (error) => error.code === 'PROFILE_IN_USE');
|
||||
await active.service.deleteProfile('personal', 'stop-and-delete', 3);
|
||||
const after = active.snapshot();
|
||||
assert.deepEqual(after.state.profiles.map(({ id }) => id), ['work']);
|
||||
assert.equal(after.state.appliedProfileId, '');
|
||||
assert.equal(after.state.appliedServerSnapshot, null);
|
||||
assert.equal(after.state.connectionDesired, 'stopped');
|
||||
assert.equal(after.running, false);
|
||||
assert.equal(after.config, null);
|
||||
});
|
||||
|
||||
test('deleting a pending desired profile leaves the running applied route untouched', async () => {
|
||||
const state = defaultState();
|
||||
state.desiredProfileId = 'work';
|
||||
const harness = createHarness({ state });
|
||||
await harness.service.deleteProfile('work', 'delete', 3);
|
||||
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.equal(harness.calls.includes('gateway.set'), false);
|
||||
});
|
||||
|
||||
test('auto refresh iterates profiles once, reports scoped failures, and stops idempotently', async () => {
|
||||
const errors = [];
|
||||
const failure = Object.assign(new Error('down'), { code: 'PROVIDER_UNAVAILABLE' });
|
||||
const harness = createHarness({
|
||||
fetchSubscription: async (url) => {
|
||||
providerCalls += 1;
|
||||
return url.includes('old') ? oldResult : parsed();
|
||||
},
|
||||
});
|
||||
|
||||
const first = harness.service.refreshSavedSubscription();
|
||||
const second = harness.service.refreshSavedSubscription();
|
||||
assert.equal(first, second);
|
||||
assert.equal(providerCalls, 1);
|
||||
|
||||
await harness.service.importSubscription('https://new.example/sub');
|
||||
releaseOld(parsed([oldServer]));
|
||||
await assert.rejects(first, (error) => error.code === 'STATE_CONFLICT');
|
||||
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://new.example/sub');
|
||||
assert.equal(providerCalls, 2);
|
||||
});
|
||||
|
||||
test('subscription generation rejects late success after a newer import of the same URL', async () => {
|
||||
let releaseRefresh;
|
||||
const delayed = new Promise((resolve) => { releaseRefresh = resolve; });
|
||||
let providerCalls = 0;
|
||||
const harness = createHarness({
|
||||
fetchSubscription: async () => {
|
||||
providerCalls += 1;
|
||||
return providerCalls === 1 ? delayed : parsed([nextServer]);
|
||||
},
|
||||
});
|
||||
|
||||
const stale = harness.service.refreshSavedSubscription();
|
||||
await harness.service.importSubscription('https://old.example/sub');
|
||||
releaseRefresh(parsed([oldServer]));
|
||||
|
||||
await assert.rejects(stale, (error) => error.code === 'STATE_CONFLICT');
|
||||
assert.deepEqual(harness.snapshot().state.servers, [nextServer]);
|
||||
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://old.example/sub');
|
||||
});
|
||||
|
||||
test('subscription generation prevents a late terminal refresh from forgetting a newer import', async () => {
|
||||
let rejectRefresh;
|
||||
const delayed = new Promise((_resolve, reject) => { rejectRefresh = reject; });
|
||||
let providerCalls = 0;
|
||||
const harness = createHarness({
|
||||
fetchSubscription: async () => {
|
||||
providerCalls += 1;
|
||||
return providerCalls === 1 ? delayed : parsed([nextServer]);
|
||||
},
|
||||
});
|
||||
|
||||
const stale = harness.service.refreshSavedSubscription();
|
||||
await harness.service.importSubscription('https://old.example/sub');
|
||||
rejectRefresh(new HarborError('SUBSCRIPTION_EXPIRED'));
|
||||
|
||||
await assert.rejects(stale, (error) => error.code === 'STATE_CONFLICT');
|
||||
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://old.example/sub');
|
||||
assert.deepEqual(harness.snapshot().state.servers, [nextServer]);
|
||||
assert.ok(harness.snapshot().cache);
|
||||
});
|
||||
|
||||
test('subscription generation prevents a delayed import from resurrecting a forgotten subscription', async () => {
|
||||
let releaseImport;
|
||||
const delayed = new Promise((resolve) => { releaseImport = resolve; });
|
||||
const harness = createHarness({ fetchSubscription: async () => delayed });
|
||||
|
||||
const staleImport = harness.service.importSubscription('https://new.example/sub');
|
||||
await harness.service.resetSavedSubscription();
|
||||
releaseImport(parsed());
|
||||
|
||||
await assert.rejects(staleImport, (error) => error.code === 'STATE_CONFLICT');
|
||||
assert.equal(Boolean(harness.snapshot().state.subscriptionUrl), false);
|
||||
assert.equal(harness.snapshot().cache, null);
|
||||
assert.equal(harness.snapshot().config, null);
|
||||
});
|
||||
|
||||
test('subscription generation lets only the first completed concurrent import commit', async () => {
|
||||
let releaseFirst;
|
||||
let releaseSecond;
|
||||
const firstResult = new Promise((resolve) => { releaseFirst = resolve; });
|
||||
const secondResult = new Promise((resolve) => { releaseSecond = resolve; });
|
||||
const harness = createHarness({
|
||||
fetchSubscription: async (url) => url.includes('first') ? firstResult : secondResult,
|
||||
});
|
||||
|
||||
const first = harness.service.importSubscription('https://first.example/sub');
|
||||
const second = harness.service.importSubscription('https://second.example/sub');
|
||||
releaseSecond(parsed([nextServer]));
|
||||
await second;
|
||||
releaseFirst(parsed([oldServer]));
|
||||
|
||||
await assert.rejects(first, (error) => error.code === 'STATE_CONFLICT');
|
||||
assert.equal(harness.snapshot().state.subscriptionUrl, 'https://second.example/sub');
|
||||
assert.deepEqual(harness.snapshot().state.servers, [nextServer]);
|
||||
});
|
||||
|
||||
test('only terminal subscription refresh failures forget saved data', async () => {
|
||||
const cases = [
|
||||
['SUBSCRIPTION_EXPIRED', true],
|
||||
['SUBSCRIPTION_DISABLED', true],
|
||||
['SUBSCRIPTION_REJECTED', true],
|
||||
['SUBSCRIPTION_TRAFFIC_EXHAUSTED', false],
|
||||
['SUBSCRIPTION_INVALID', false],
|
||||
['PROVIDER_UNAVAILABLE', false],
|
||||
];
|
||||
|
||||
for (const [code, resets] of cases) {
|
||||
const harness = createHarness({
|
||||
running: false,
|
||||
fetchSubscription: async () => { throw new HarborError(code); },
|
||||
});
|
||||
await assert.rejects(harness.service.refreshSavedSubscription(), (error) => error.code === code);
|
||||
assert.equal(Boolean(harness.snapshot().state.subscriptionUrl), !resets, code);
|
||||
assert.equal(Boolean(harness.snapshot().cache), !resets, code);
|
||||
}
|
||||
});
|
||||
|
||||
test('subscription scheduler is unrefed, skips empty state, reports errors, and stops idempotently', async () => {
|
||||
const errors = [];
|
||||
let providerCalls = 0;
|
||||
const harness = createHarness({
|
||||
state: { revision: 0, servers: [], selectedServerId: '', appliedServerId: '', routeRules: [] },
|
||||
fetchSubscription: async () => {
|
||||
providerCalls += 1;
|
||||
throw new HarborError('PROVIDER_UNAVAILABLE');
|
||||
if (url.includes('work')) throw failure;
|
||||
return parsed('personal', [oldServer]);
|
||||
},
|
||||
onRefreshError: (error) => errors.push(error.code),
|
||||
});
|
||||
|
||||
harness.service.startAutoRefresh(1234);
|
||||
harness.service.startAutoRefresh(1234);
|
||||
assert.equal(harness.timers.length, 1);
|
||||
assert.equal(harness.timers[0].intervalMs, 1234);
|
||||
assert.equal(harness.timers[0].unrefCalls, 1);
|
||||
harness.timers[0].callback();
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(providerCalls, 0);
|
||||
|
||||
const stateful = createHarness({
|
||||
fetchSubscription: async () => {
|
||||
providerCalls += 1;
|
||||
throw new HarborError('PROVIDER_UNAVAILABLE');
|
||||
},
|
||||
onRefreshError: (error) => errors.push(error.code),
|
||||
});
|
||||
stateful.service.startAutoRefresh(10);
|
||||
stateful.timers[0].callback();
|
||||
await new Promise(setImmediate);
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(providerCalls, 1);
|
||||
assert.equal(harness.providerCalls.length, 2);
|
||||
assert.deepEqual(errors, ['PROVIDER_UNAVAILABLE']);
|
||||
|
||||
stateful.service.stopAutoRefresh();
|
||||
stateful.service.stopAutoRefresh();
|
||||
assert.equal(stateful.timers[0].clearCalls, 1);
|
||||
harness.service.stopAutoRefresh();
|
||||
harness.service.stopAutoRefresh();
|
||||
assert.equal(harness.timers[0].clearCalls, 1);
|
||||
});
|
||||
|
||||
test('subscription mutation route preserves methods, operation kinds, and response fields', async () => {
|
||||
const operations = [];
|
||||
test('profile mutation route scopes every target and keeps bounded legacy shims', async () => {
|
||||
const calls = [];
|
||||
const sent = [];
|
||||
const service = {
|
||||
importSubscription: async (url) => ({ success: true, imported: url }),
|
||||
refreshSavedSubscription: async () => ({ success: true, refreshed: true }),
|
||||
resetSavedSubscription: async () => { operations.push('reset'); },
|
||||
preflightAddProfile: () => {},
|
||||
preflightRenameProfile: () => {},
|
||||
addProfile: async (...args) => ({ success: true, args }),
|
||||
renameProfile: async (...args) => ({ success: true, args }),
|
||||
selectProfileServer: async (...args) => ({ success: true, args }),
|
||||
refreshProfile: async (...args) => ({ success: true, args }),
|
||||
deleteProfile: async (...args) => ({ success: true, args }),
|
||||
importSubscription: async (...args) => ({ success: true, args }),
|
||||
refreshSavedSubscription: async (...args) => ({ success: true, args }),
|
||||
resetSavedSubscription: async (...args) => ({ success: true, args }),
|
||||
};
|
||||
const route = createSubscriptionMutationRoute({
|
||||
subscriptionService: service,
|
||||
readBody: async () => ({ url: ' https://new.example/sub ' }),
|
||||
withOperation: async (kind, operation) => {
|
||||
operations.push(kind);
|
||||
return operation();
|
||||
connection: { activate: async (...args) => ({ success: true, args }) },
|
||||
readBody: async () => ({
|
||||
label: 'Работа', url: 'https://work.example/sub', serverId: 'next', mode: 'delete', expectedRevision: 7,
|
||||
}),
|
||||
withOperation: async (kind, operation, options = {}) => {
|
||||
calls.push([kind, options]);
|
||||
return operation(8);
|
||||
},
|
||||
sendState: async (_res, extra = {}) => { sent.push(extra); },
|
||||
});
|
||||
const response = {};
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/profiles'],
|
||||
['PATCH', '/api/profiles/work'],
|
||||
['PUT', '/api/profiles/work/server'],
|
||||
['POST', '/api/profiles/work/activate'],
|
||||
['POST', '/api/profiles/work/refresh'],
|
||||
['DELETE', '/api/profiles/work'],
|
||||
]) assert.equal(await route.handle({ method, url }, response), true);
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/subscription/fetch' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/subscription/fetch' }, response), true);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/subscription/refresh' }, response), true);
|
||||
assert.equal(await route.handle({ method: 'DELETE', url: '/api/subscription' }, response), true);
|
||||
assert.deepEqual(operations, ['subscription-import', 'subscription-refresh', 'subscription-forget', 'reset']);
|
||||
assert.deepEqual(sent, [
|
||||
{ success: true, imported: 'https://new.example/sub' },
|
||||
{ refreshed: true },
|
||||
{},
|
||||
assert.deepEqual(calls.map(([kind]) => kind), [
|
||||
'profile-add', 'profile-rename', 'profile-select-server', 'profile-activate', 'profile-refresh', 'profile-delete',
|
||||
]);
|
||||
});
|
||||
assert.equal(sent.length, 6);
|
||||
assert.deepEqual(calls[2][1], { expectedRevision: 7, profileId: 'work', serverId: 'next' });
|
||||
|
||||
test('composition root contains no displaced subscription mutation owner', () => {
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createSubscriptionMutationRoute\(\{/);
|
||||
assert.match(source, /subscriptionService\.startAutoRefresh\(SUBSCRIPTION_REFRESH_INTERVAL_MS\)/);
|
||||
assert.doesNotMatch(source, /subscriptionRefreshPromise|subscriptionRefreshTimer|TERMINAL_SUBSCRIPTION_CODES/);
|
||||
assert.doesNotMatch(source, /req\.url === ['"]\/api\/subscription\/(?:fetch|refresh)['"]/);
|
||||
assert.doesNotMatch(source, /req\.method === ['"]DELETE['"] && req\.url === ['"]\/api\/subscription['"]/);
|
||||
assert.doesNotMatch(source, /req\.url === ['"]\/api\/subscription\/\(?:fetch\|refresh\)['"]/);
|
||||
});
|
||||
|
||||
@@ -75,8 +75,20 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
||||
}],
|
||||
[() => api.subscription.refresh(), '/api/subscription/refresh', { method: 'POST' }],
|
||||
[() => api.subscription.forget(), '/api/subscription', { method: 'DELETE' }],
|
||||
[() => api.apply('server-1'), '/api/apply', {
|
||||
method: 'POST', body: JSON.stringify({ serverId: 'server-1', selectedTag: 'server-1' }),
|
||||
[() => api.profiles.add('Личный', 'https://sub', 4), '/api/profiles', {
|
||||
method: 'POST', body: JSON.stringify({ label: 'Личный', url: 'https://sub', expectedRevision: 4 }),
|
||||
}],
|
||||
[() => api.profiles.rename('profile-1', 'Работа', 5), '/api/profiles/profile-1', {
|
||||
method: 'PATCH', body: JSON.stringify({ label: 'Работа', expectedRevision: 5 }),
|
||||
}],
|
||||
[() => api.profiles.selectServer('profile-1', 'server-1', 6), '/api/profiles/profile-1/server', {
|
||||
method: 'PUT', body: JSON.stringify({ serverId: 'server-1', expectedRevision: 6 }),
|
||||
}],
|
||||
[() => api.profiles.activate('profile-1', 7), '/api/profiles/profile-1/activate', {
|
||||
method: 'POST', body: JSON.stringify({ expectedRevision: 7 }),
|
||||
}],
|
||||
[() => api.apply('profile-1', 'server-1', 8), '/api/apply', {
|
||||
method: 'POST', body: JSON.stringify({ profileId: 'profile-1', serverId: 'server-1', expectedRevision: 8 }),
|
||||
}],
|
||||
[() => api.gatewayAuto.setEnabled(true), '/api/gateway-auto', {
|
||||
method: 'POST', body: JSON.stringify({ enabled: true }),
|
||||
@@ -101,7 +113,7 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
||||
}],
|
||||
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
|
||||
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
|
||||
[() => api.servers.ping(['one', 'two']), '/api/servers/ping-all', {
|
||||
[() => api.servers.ping('profile-1', ['one', 'two']), '/api/profiles/profile-1/servers/ping', {
|
||||
method: 'POST', body: JSON.stringify({ serverIds: ['one', 'two'] }),
|
||||
}],
|
||||
];
|
||||
|
||||
@@ -20,7 +20,7 @@ test('connection button chooses the only valid client action', () => {
|
||||
type: 'apply',
|
||||
serverId: 'srv_nl',
|
||||
});
|
||||
assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' });
|
||||
assert.equal(connectionAction({ configExists: true }), null);
|
||||
assert.equal(connectionAction({}), null);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ const routing = source('features/routing/RoutingFeature.tsx');
|
||||
const diagnostics = source('features/diagnostics/ConnectivityDiagnosticsPanel.tsx');
|
||||
|
||||
test('App owns one stable mapping from typed transport to component actions', () => {
|
||||
assert.match(app, /const componentActions = \{[\s\S]*validateSubscription: api\.subscription\.validate[\s\S]*listDevices: api\.devices\.list[\s\S]*refreshDevices: api\.devices\.refresh[\s\S]*updateDevice: api\.devices\.update[\s\S]*setDevicePolicy: api\.devices\.setPolicy[\s\S]*pingServers: api\.servers\.ping[\s\S]*runConnectivityDiagnostics: api\.diagnostics\.connectivity[\s\S]*\};/);
|
||||
assert.match(app, /const componentActions = \{[\s\S]*listDevices: api\.devices\.list[\s\S]*refreshDevices: api\.devices\.refresh[\s\S]*updateDevice: api\.devices\.update[\s\S]*setDevicePolicy: api\.devices\.setPolicy[\s\S]*pingServers: api\.servers\.ping[\s\S]*runConnectivityDiagnostics: api\.diagnostics\.connectivity[\s\S]*\};/);
|
||||
assert.doesNotMatch(app, /validateSubscription: api\.subscription\.validate/);
|
||||
assert.equal((app.match(/actions=\{componentActions\}/g) || []).length, 1);
|
||||
assert.doesNotMatch(app, /componentActions\s*=\s*useMemo|componentActions\s*=\s*\([^)]*\)\s*=>/);
|
||||
});
|
||||
@@ -21,8 +22,8 @@ test('App owns one stable mapping from typed transport to component actions', ()
|
||||
test('presentational components use only injected narrow actions', () => {
|
||||
const components = [overview, subscription, devices, servers, routing, diagnostics].join('\n');
|
||||
assert.doesNotMatch(components, /from ['"][^'"]*\/api\/harborClient\.js['"]|\bapi\./);
|
||||
assert.match(overview, /validateSubscription: actions\.validateSubscription/);
|
||||
assert.match(subscription, /await validateSubscription\(normalizedUrl, \{ signal: controller\.signal \}\)/);
|
||||
assert.match(subscription, /isSubscriptionUrlValid\(normalizedUrl\)/);
|
||||
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController/);
|
||||
assert.match(overview, /refreshDevices: actions\.refreshDevices/);
|
||||
assert.match(deviceFeature, /discover \? refreshDevices\(\) : listDevices\(\)/);
|
||||
assert.match(overview, /<ServerPicker[\s\S]*pingServers=\{actions\.pingServers\}/);
|
||||
@@ -31,6 +32,6 @@ test('presentational components use only injected narrow actions', () => {
|
||||
assert.match(overview, /<ConnectivityDiagnosticsPanel[\s\S]*runConnectivityDiagnostics=\{actions\.runConnectivityDiagnostics\}/);
|
||||
assert.match(deviceFeature, /requestDeviceUpdate\(device\.id, patch, snapshot\.revision\)/);
|
||||
assert.match(deviceFeature, /setDevicePolicy\(device\.id, mode, snapshot\.revision\)/);
|
||||
assert.match(servers, /await pingServers\(ids\)/);
|
||||
assert.match(servers, /await pingServers\(profileId, ids\)/);
|
||||
assert.match(diagnostics, /await runConnectivityDiagnostics\(customServices, target\)/);
|
||||
});
|
||||
|
||||
@@ -26,17 +26,28 @@ test('connection feature is the sole always-mounted power panel owner', () => {
|
||||
test('connection feature preserves actions, local preference and opaque neighbor slots', () => {
|
||||
assert.doesNotMatch(panel, /from ['"][^'"]*\/api\/|\bapi\./);
|
||||
assert.doesNotMatch(panel, /setInterval|setTimeout|useState\([^)]*state|useReducer/);
|
||||
assert.match(panel, /connectionAction\(\{ connected, selectedServerId, configExists: configured \}\)/);
|
||||
assert.match(panel, /action\?\.type === 'stop'[\s\S]*action\?\.type === 'apply'[\s\S]*action\?\.type === 'restart'/);
|
||||
assert.match(panel, /connectionAction\(\{ connected, selectedServerId \}\)/);
|
||||
assert.match(panel, /action\?\.type === 'stop'[\s\S]*action\?\.type === 'apply'/);
|
||||
assert.doesNotMatch(panel, /action\?\.type === 'restart'/);
|
||||
assert.match(panel, /if \(!await onStop\(\)\) return;[\s\S]*setConfirmingStop\(false\)/);
|
||||
assert.match(panel, /localStorage\.getItem\(DURATION_MODE_STORAGE_KEY\) === 'words'[\s\S]*localStorage\.setItem\(DURATION_MODE_STORAGE_KEY, nextMode\)/);
|
||||
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=\{isGateway && <div className="client-gateway-route-summary"/);
|
||||
assert.match(page, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
|
||||
assert.match(page, /mainIdentity[\s\S]*appliedProfile[\s\S]*appliedServer/);
|
||||
assert.match(page, /statusSlot=\{<>[\s\S]*InlineError[\s\S]*InlineProgress/);
|
||||
});
|
||||
|
||||
test('gateway-direct is remote-owned even when the local runtime is stopped', () => {
|
||||
assert.match(page, /const mainIdentity = gatewayDirect[\s\S]*Gateway · сервер не определён[\s\S]*: connected/);
|
||||
assert.match(page, /const switchIdentity = gatewayDirect[\s\S]*Данные применённого сервера Gateway недоступны/);
|
||||
assert.match(panel, /const remoteOwned = !isGateway && gatewayDirect/);
|
||||
assert.match(panel, /const powerUnavailable = remoteOwned \|\| \(!connected && !canStart\)/);
|
||||
assert.match(panel, /aria-checked=\{connected \|\| remoteOwned\}[\s\S]*disabled=\{blocked \|\| powerUnavailable\}/);
|
||||
assert.match(panel, /remoteOwned[\s\S]*Подключением управляет Harbor Gateway[\s\S]*Gateway подключён/);
|
||||
});
|
||||
|
||||
test('shared clock, copy feedback and live announcement stay single-owned by the page', () => {
|
||||
const pageBody = page.slice(page.indexOf('export function ClientOverviewPage'));
|
||||
assert.equal((page.match(/setInterval\(\(\) => setNow\(Date\.now\(\)\), 1000\)/g) || []).length, 1);
|
||||
|
||||
@@ -201,8 +201,10 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
|
||||
const connectionPanelStart = overview.indexOf('<ConnectionPanel');
|
||||
|
||||
assert.match(overview, /const appliedServerId = state\?\.selection\?\.appliedServerId \|\| ''/);
|
||||
assert.match(overview, /appliedServer\?\.label \|\| 'VPN-сервер не используется'/);
|
||||
assert.match(overview, /selectedServerId !== appliedServerId[\s\S]*Переключаем на \{desiredServer\.label\}/);
|
||||
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]*`\$\{appliedProfile\.label\} · \$\{appliedServer\.label\}`/);
|
||||
assert.match(overview, /const switchIdentity = gatewayDirect[\s\S]*switchingServer && operationProfile && operationServer[\s\S]*`Переключаем на \$\{operationProfile\.label\} · \$\{operationServer\.label\}`/);
|
||||
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
|
||||
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/);
|
||||
@@ -212,13 +214,13 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
|
||||
assert.match(feature, /feature\.status === 'error' \? feature\.error : null/);
|
||||
assert.match(overview, /if \(!isGateway && \(!connected \|\| !state\?\.connection\?\.startedAt\)\) return undefined/);
|
||||
assert.match(feature, /trafficSourceError[\s\S]*Трафик не обновляется · последние данные/);
|
||||
assert.match(subscription, /client-subscription-drawer\$\{open \? ' is-open' : ''\}/);
|
||||
assert.match(subscription, /const confirmingDeleteRef = useRef\(confirmingDelete\)[\s\S]*if \(confirmingDeleteRef\.current\) return/);
|
||||
assert.match(subscription, /requestDelete: \(\) => setConfirmingDelete\(true\)[\s\S]*open=\{feature\.confirmingDelete\}[\s\S]*onCancel=\{feature\.cancelDelete\}/);
|
||||
assert.match(connection, /aria-label=\{isGateway[\s\S]*Остановить Harbor Connect[\s\S]*Запустить Harbor Connect/);
|
||||
assert.match(connection, /className=\{`client-power-control\$\{isGateway \? ' client-tooltip-anchor' : ''\}`\}[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Сначала добавьте подписку и выберите сервер/);
|
||||
assert.match(subscription, /client-drawer client-subscription-drawer\$\{drawerOpen \? ' is-open' : ''\}/);
|
||||
assert.match(subscription, /const deleteIdRef = useRef\(deleteId\)[\s\S]*if \(deleteIdRef\.current\) return/);
|
||||
assert.match(subscription, /requestDelete: \(profileId: string\) => setDeleteId\(profileId\)[\s\S]*open=\{Boolean\(feature\.deleteProfile\)\}[\s\S]*onCancel=\{feature\.cancelDelete\}[\s\S]*onConfirm=\{feature\.confirmDelete\}/);
|
||||
assert.match(connection, /aria-label=\{remoteOwned[\s\S]*isGateway[\s\S]*Остановить Harbor Connect[\s\S]*Запустить Harbor Connect/);
|
||||
assert.match(connection, /className=\{`client-power-control\$\{powerUnavailable \? ' client-tooltip-anchor' : ''\}`\}[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Подключением управляет Harbor Gateway[\s\S]*Сначала добавьте подписку и выберите сервер/);
|
||||
assert.match(connection, /const powerButton = <button[\s\S]*className="client-power"[\s\S]*client-power-control[\s\S]*\{brandSlot\}[\s\S]*\{powerButton\}/);
|
||||
assert.match(overview, /isGateway && <DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
|
||||
assert.match(overview, /isGateway && hasSubscription && <DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
|
||||
assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ test('diagnostics feature is the sole owner while the conditional panel keeps re
|
||||
assert.equal((page.match(/useDiagnosticsFeature\(\)/g) || []).length, 1);
|
||||
assert.equal((page.match(/<DiagnosticsToggle/g) || []).length, 1);
|
||||
assert.equal((page.match(/<ConnectivityDiagnosticsPanel/g) || []).length, 1);
|
||||
assert.match(page, /const diagnosticsAvailable = isGateway \|\| \(hasSubscription && subscriptionContentReady\)/);
|
||||
assert.match(page, /const diagnosticsAvailable = hasSubscription/);
|
||||
assert.match(page, /\{diagnosticsAvailable && <ConnectivityDiagnosticsPanel[\s\S]*feature=\{diagnosticsFeature\}/);
|
||||
assert.match(page, /if \(!diagnosticsAvailable\) diagnosticsFeature\.close\(\)/);
|
||||
assert.doesNotMatch(page, /diagnosticsOpen|setDiagnosticsOpen|diagnosticsPanelRef|diagnosticsToggleRef|diagnosticsCloseRef|client-diagnostics-toggle/);
|
||||
|
||||
@@ -12,7 +12,12 @@ import { createStateSnapshot } from '../../.test-dist/src/shared/contracts/state
|
||||
const snapshot = (revision, desiredServerId = '', serverIds = ['one', 'two']) => ({
|
||||
apiVersion: 1,
|
||||
revision,
|
||||
selection: { desiredServerId },
|
||||
profiles: [{
|
||||
id: 'primary',
|
||||
desiredServerId,
|
||||
servers: serverIds.map((id) => ({ id })),
|
||||
}],
|
||||
selection: { desiredProfileId: 'primary', desiredServerId },
|
||||
servers: serverIds.map((id) => ({ id })),
|
||||
});
|
||||
|
||||
@@ -87,21 +92,10 @@ test('an equal revision keeps the current snapshot identity', () => {
|
||||
assert.equal(next.snapshot.selection.desiredServerId, 'one');
|
||||
});
|
||||
|
||||
test('pending selection survives polling until canonical state acknowledges it', () => {
|
||||
let state = receive(initialHarborState, snapshot(1, 'one'));
|
||||
state = harborReducer(state, { type: 'select-server', serverId: 'two' });
|
||||
state = receive(state, snapshot(2, 'one'));
|
||||
assert.equal(state.pendingServerId, 'two');
|
||||
|
||||
state = receive(state, snapshot(3, 'two'));
|
||||
assert.equal(state.pendingServerId, '');
|
||||
});
|
||||
|
||||
test('pending selection is cleared when its server disappears', () => {
|
||||
let state = receive(initialHarborState, snapshot(1, 'one'));
|
||||
state = harborReducer(state, { type: 'select-server', serverId: 'two' });
|
||||
|
||||
assert.equal(receive(state, snapshot(2, 'one', ['one'])).pendingServerId, '');
|
||||
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);
|
||||
assert.equal(state.snapshot.profiles[0].desiredServerId, 'two');
|
||||
});
|
||||
|
||||
test('data invariant: initial control outage is retryable without a fabricated snapshot', () => {
|
||||
|
||||
@@ -25,8 +25,8 @@ test('instructions feature is the sole owner behind one public boundary', () =>
|
||||
|
||||
test('unconditional controller and conditional panel preserve lifecycle and reset boundaries', () => {
|
||||
assert.match(page, /const instructionsFeature = useInstructionsFeature\([\s\S]*const diagnosticsAvailable/);
|
||||
assert.match(page, /\{\(isGateway \|\| \(hasSubscription && subscriptionContentReady\)\) && <InstructionsPanel/);
|
||||
assert.match(page, /if \(!hasSubscription\) \{[\s\S]*if \(!isGateway\) \{[\s\S]*instructionsFeature\.close\(\)/);
|
||||
assert.match(page, /\{hasSubscription && <InstructionsPanel/);
|
||||
assert.match(page, /if \(!hasSubscription\) \{[\s\S]*instructionsFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)/);
|
||||
assert.doesNotMatch(page, /if \(!instructionsAvailable\)|instructionsAvailable/);
|
||||
assert.match(feature, /const \[openInstructionId, setOpenInstructionId\] = useState\(''\)/);
|
||||
assert.match(feature, /function InstructionBlock[\s\S]*const \[copyFeedback, setCopyFeedback\] = useState/);
|
||||
|
||||
+10
-22
@@ -1,5 +1,4 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
@@ -21,38 +20,27 @@ test('operation conflicts block domain controls but leave copy and navigation al
|
||||
}
|
||||
}
|
||||
|
||||
const refreshing = { subscriptionRefresh: { status: 'running' } };
|
||||
const refreshing = { profileRefresh: { status: 'running', target: 'primary' } };
|
||||
assert.equal(operationBlocked(refreshing, 'connection'), true);
|
||||
assert.equal(operationBlocked(refreshing, 'serverApply'), true);
|
||||
assert.equal(operationBlocked(refreshing, 'subscriptionDelete'), true);
|
||||
assert.equal(operationBlocked(refreshing, 'copy'), false);
|
||||
assert.equal(operationBlocked(refreshing, 'navigation'), false);
|
||||
assert.equal(operationBlocked(refreshing, 'profileDelete'), true);
|
||||
|
||||
const applying = { serverApply: { status: 'running' } };
|
||||
const applying = { serverApply: { status: 'running', target: 'primary:server' } };
|
||||
assert.equal(operationBlocked(applying, 'connection'), true);
|
||||
assert.equal(operationBlocked(applying, 'subscriptionRefresh'), true);
|
||||
assert.equal(operationBlocked(applying, 'copy'), false);
|
||||
assert.equal(operationBlocked(applying, 'profileRefresh'), true);
|
||||
});
|
||||
|
||||
test('double click shares one in-flight request end to end', async (t) => {
|
||||
test('double click shares one in-flight request end to end', async () => {
|
||||
const request = deferred();
|
||||
let requests = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
requests += 1;
|
||||
setTimeout(() => {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end('{"success":true}');
|
||||
}, 20);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
const registry = createOperationRegistry();
|
||||
const action = () => fetch(`http://127.0.0.1:${server.address().port}/apply`).then((response) => response.json());
|
||||
const first = registry.run('serverApply', action);
|
||||
const second = registry.run('serverApply', action);
|
||||
const action = () => { requests += 1; return request.promise; };
|
||||
const first = registry.run('serverApply', action, 'primary:server');
|
||||
const second = registry.run('serverApply', action, 'primary:server');
|
||||
|
||||
assert.equal(second, first);
|
||||
assert.equal(registry.getSnapshot().serverApply.status, 'running');
|
||||
request.resolve({ success: true });
|
||||
assert.deepEqual(await first, { success: true });
|
||||
assert.equal(requests, 1);
|
||||
assert.deepEqual(registry.getSnapshot(), {});
|
||||
|
||||
@@ -22,7 +22,7 @@ function rule(selector, source = styles) {
|
||||
return new RegExp(`^${escaped}\\s*\\{([\\s\\S]*?)\\n\\}`, 'm').exec(source)?.[1] || '';
|
||||
}
|
||||
|
||||
test('desktop layout keeps the power control on a symmetric center axis', () => {
|
||||
test('desktop layout keeps the power control on a symmetric center axis beside the subscription drawer', () => {
|
||||
const panel = rule('.client-panel');
|
||||
const power = rule('.client-power-section');
|
||||
const form = rule('.client-form');
|
||||
@@ -33,12 +33,11 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
|
||||
assert.match(form, /position:\s*static/);
|
||||
assert.match(form, /overflow:\s*visible/);
|
||||
assert.doesNotMatch(form, /\bleft\s*:|translateX|transition:[^;]*(?:left|width|transform)/);
|
||||
assert.match(styles, /\.client-panel\.has-subscription \.client-form \{[\s\S]*height:\s*var\(--client-work-height\)/);
|
||||
assert.match(rule('.client-form-content'), /align-content:\s*start/);
|
||||
assert.doesNotMatch(styles, /\.client-panel\.has-subscription \.client-form\b/);
|
||||
assert.doesNotMatch(styles, /\.client-form-content\b/);
|
||||
assert.match(styles, /--client-power-top:\s*calc\(\(var\(--client-work-height\) - 96px\) \/ 2\)/);
|
||||
assert.match(styles, /\.client-panel\.has-subscription \{[\s\S]*transform:\s*translateY\(-9vh\)/);
|
||||
assert.match(styles, /\.client-panel\.has-subscription \.client-power-section \{[\s\S]*height:\s*var\(--client-work-height\);[\s\S]*padding-top:\s*var\(--client-power-top\)/);
|
||||
assert.match(styles, /\.client-panel\.has-subscription \.client-form-content \{[\s\S]*padding-top:\s*var\(--client-power-top\)/);
|
||||
assert.match(rule('.client-panel.is-gateway-home'), /min-height:\s*max\(620px, calc\(100dvh - 80px\)\)[\s\S]*grid-template-rows:\s*minmax\(260px, 1fr\) auto[\s\S]*row-gap:\s*36px/);
|
||||
assert.match(rule('.client-gateway-summary'), /grid-column:\s*1 \/ -1[\s\S]*grid-row:\s*2[\s\S]*justify-self:\s*center[\s\S]*width:\s*min\(860px, calc\(100% - 48px\)\)/);
|
||||
assert.match(styles, /\.client-power-section\.is-gateway \.client-power-control,[\s\S]*width:\s*84px;[\s\S]*height:\s*84px/);
|
||||
@@ -49,9 +48,11 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
|
||||
assert.match(rule('.client-gateway-traffic-chart'), /min-height:\s*180px/);
|
||||
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-gateway-traffic-chart,[\s\S]*min-height:\s*130px;[\s\S]*height:\s*130px/);
|
||||
assert.doesNotMatch(rule('.client-gateway-summary'), /background|border|box-shadow/);
|
||||
assert.match(rule('.client-gateway-route-slot'), /min-height:\s*16px[\s\S]*overflow:\s*hidden[\s\S]*text-overflow:\s*ellipsis[\s\S]*white-space:\s*nowrap/);
|
||||
assert.match(rule('.client-applied-operation'), /min-height:\s*15px[\s\S]*overflow:\s*hidden[\s\S]*text-overflow:\s*ellipsis[\s\S]*white-space:\s*nowrap/);
|
||||
assert.match(component, /client-panel\$\{showPower \? '' : ' is-setup'\}[\s\S]*is-gateway-home/);
|
||||
assert.match(component, /const showPower = isGateway \|\| \(hasSubscription && Boolean\(selectedServerId\)\)/);
|
||||
assert.match(component, /const showPower = hasSubscription/);
|
||||
assert.match(component, /\$\{!hasSubscription \? ' is-first-run' : ''\}/);
|
||||
assert.match(component, /\$\{isGateway && hasSubscription \? ' is-gateway-home' : ''\}/);
|
||||
});
|
||||
|
||||
test('page reload plays one stable startup sequence', () => {
|
||||
@@ -66,7 +67,7 @@ test('page reload plays one stable startup sequence', () => {
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-shell\.is-intro \.client-panel,[\s\S]*animation: none/);
|
||||
});
|
||||
|
||||
test('server rows scroll without moving the subscription column or showing a scrollbar', () => {
|
||||
test('server rows scroll inside the subscription drawer without moving the main layout', () => {
|
||||
const scroll = rule('.client-server-scroll');
|
||||
const simpleScroll = rule('.client-server-mode-panel.is-simple .client-server-scroll');
|
||||
const grid = rule('.client-server-grid');
|
||||
@@ -77,7 +78,8 @@ test('server rows scroll without moving the subscription column or showing a scr
|
||||
assert.match(simpleScroll, /max-height:\s*none/);
|
||||
assert.match(simpleScroll, /overflow:\s*visible/);
|
||||
assert.match(grid, /width:\s*min\(100%, 220px\)/);
|
||||
assert.match(rule('.client-form-content'), /gap:\s*24px/);
|
||||
assert.match(rule('.client-drawer'), /overflow-y:\s*auto/);
|
||||
assert.match(subscription, /client-profile-list[\s\S]*feature\.profiles\.map\(\(profile\) => <ProfileGroup/);
|
||||
assert.match(rule('.client-server-toolbar-title'), /grid-column:\s*2/);
|
||||
assert.match(rule('.client-server-mode-toggle'), /grid-row:\s*2/);
|
||||
assert.match(rule('.client-server-check'), /grid-column:\s*3/);
|
||||
@@ -101,7 +103,7 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
|
||||
|
||||
assert.match(rule('.client-duration-toggle'), /width:\s*min\(290px, 100%\)/);
|
||||
assert.match(mobile, /\.client-server-toolbar \{[\s\S]*grid-template-rows:\s*44px 44px/);
|
||||
assert.match(mobile, /\.client-form-content \{[\s\S]*gap:\s*24px/);
|
||||
assert.match(mobile, /\.client-secondary-menu \{[\s\S]*right:\s*8px/);
|
||||
assert.match(mobile, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.35\)/);
|
||||
assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/);
|
||||
assert.match(styles, /\.client-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/);
|
||||
@@ -239,13 +241,14 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
|
||||
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
|
||||
});
|
||||
|
||||
test('subscription validation waits for the provider and keeps diagnostics below errors', () => {
|
||||
assert.match(subscription, /validateSubscription\(normalizedUrl/);
|
||||
assert.match(subscription, /status: 'checking'/);
|
||||
assert.match(subscription, /status: 'valid'/);
|
||||
assert.match(subscription, /message: ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message/);
|
||||
assert.match(subscription, /validationStatus === 'checking' \? '…' : '×'/);
|
||||
assert.match(subscription, /if \(error\?\.context === 'subscription'\) onDismissError\(\)/);
|
||||
test('subscription validation is local and provider errors keep stable detail slots', () => {
|
||||
assert.match(subscription, /const validationStatus = !normalizedUrl[\s\S]*isSubscriptionUrlValid\(normalizedUrl\) \? 'valid' : 'invalid'/);
|
||||
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController|status: 'checking'/);
|
||||
assert.match(subscription, /const message = feature\.duplicateLabel[\s\S]*feature\.validationStatus === 'invalid'[\s\S]*ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message[\s\S]*feature\.addError\?\.message/);
|
||||
assert.match(subscription, /aria-invalid=\{feature\.validationStatus === 'invalid'\}/);
|
||||
assert.match(subscription, /disabled=\{feature\.addBlocked \|\| !feature\.label\.trim\(\) \|\| feature\.duplicateLabel \|\| feature\.validationStatus !== 'valid'\}/);
|
||||
assert.match(subscription, /if \(!await onAdd\(normalizedLabel, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
|
||||
assert.match(subscription, /setUrl: \(value: string\) => \{[\s\S]*onDismissError\(\)/);
|
||||
assert.match(component, /error\.retry[\s\S]*error\.correlationId/);
|
||||
assert.match(rule('.client-inline-error.is-subscription small'), /flex-basis:\s*100%/);
|
||||
assert.match(rule('.client-inline-error.is-subscription small'), /opacity:\s*0\.45/);
|
||||
|
||||
@@ -26,8 +26,8 @@ test('server picker has one public feature owner without legacy shims', () => {
|
||||
assert.match(overview, /import \{ ServerPicker \} from '\.\.\/features\/servers\/index\.js'/);
|
||||
assert.equal((overview.match(/<ServerPicker/g) || []).length, 1);
|
||||
assert.doesNotMatch(picker, /from ['"][^'"]*\/api\/|\bapi\./);
|
||||
assert.match(overview, /const selectedServerId = pendingServerId \|\| state\?\.selection\?\.desiredServerId \|\| ''/);
|
||||
assert.match(overview, /setPendingServerId\(serverId\);[\s\S]*if \(connected && serverId\) onApply\(serverId\)/);
|
||||
assert.match(overview, /const selectedServerId = desiredProfile\?\.desiredServerId \|\| ''/);
|
||||
assert.match(overview, /function selectServer\(profile: ProfileSnapshot, serverId: string\)[\s\S]*onApply\(profile\.id, serverId\)[\s\S]*onSelectProfileServer\(profile\.id, serverId\)/);
|
||||
});
|
||||
|
||||
const fixtures = (count) => Array.from({ length: count }, (_, index) => ({
|
||||
@@ -70,7 +70,7 @@ test('server picker validates unknown ping payloads before publishing results',
|
||||
]) {
|
||||
assert.throws(() => parseServerPingResults(payload), TypeError);
|
||||
}
|
||||
assert.match(picker, /parseServerPingResults\(await pingServers\(ids\)\)/);
|
||||
assert.match(picker, /parseServerPingResults\(await pingServers\(profileId, ids\)\)/);
|
||||
});
|
||||
|
||||
test('server picker checks health only on manual refresh and bounds the result window', () => {
|
||||
@@ -78,7 +78,7 @@ test('server picker checks health only on manual refresh and bounds the result w
|
||||
assert.doesNotMatch(picker, /checkVisible\(\);/);
|
||||
assert.match(picker, /onClick={checkVisible}/);
|
||||
assert.match(picker, /\{ \.\.\.current\[id\], checking: true \}/);
|
||||
assert.match(picker, /900 - \(performance\.now\(\) - startedAt\)/);
|
||||
assert.match(picker, /Math\.max\(900, Math\.ceil\(elapsed \/ 900\) \* 900\)/);
|
||||
assert.match(picker, /\{ \.\.\.current\[id\], checking: false \}/);
|
||||
assert.match(picker, /\.slice\(page \* SERVER_RESULT_WINDOW, \(page \+ 1\) \* SERVER_RESULT_WINDOW\)/);
|
||||
assert.match(picker, /\.slice\(0, 30\)/);
|
||||
|
||||
@@ -36,26 +36,26 @@ const expectedImports = [
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 821,
|
||||
cascadeEdges: 780,
|
||||
customProperties: 31,
|
||||
declarations: 2941,
|
||||
declarations: 2912,
|
||||
important: 0,
|
||||
keyframes: 51,
|
||||
media: 10,
|
||||
rules: 888,
|
||||
variableReferences: 338,
|
||||
keyframes: 49,
|
||||
media: 12,
|
||||
rules: 889,
|
||||
variableReferences: 330,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: '1ffc2a2f986970988643129ff3e32f19c31bb8b1e018a3a474a976e23adc9776',
|
||||
cascadeEdges: '12a578504877454d14c734b8ce608766d061d3f8f809a5abf13513a645d8b6aa',
|
||||
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
|
||||
declarations: '77314876411a21c88472dc277a59ca24192ca7d38de1ce8f367e7bd1007c5323',
|
||||
declarations: '297c4387ebbccec796e80474c4d040eba20941d316a77a29d1e72c31d19c02d6',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
|
||||
keyframes: '0bed7ec3cd3a86ee091cf9b07c081ab2364ac5a17e55e95bc9c4a6430419b019',
|
||||
ruleDeclarationSequences: 'ce3831db5966264c17fa3af3e10fc6a41398cc619d061fa155ac43b47f0947ea',
|
||||
selectors: '828cf6e95b03c890e3a969be20eb05be1f70872d6076e8f9a6a38cf6b415d012',
|
||||
variableReferences: '9b6214da6e8e02b01deb6d5a98fe4c695a1fdaff4f65c936b805d7d5841db04d',
|
||||
witnesses: 'c34721ec2cb99104eae77841ae88ddf77cde278d15c21675a020f3cfdd13a28a',
|
||||
duplicateSelectors: 'bea7ed28b2e90fbaa3ae50aaee2346fc9063996fc9b7c585a3a47693a055b783',
|
||||
keyframes: 'bde9c628dc86cb8a4299d5397eb7a81c5fbc9481136a073130991d3182d0799d',
|
||||
ruleDeclarationSequences: '7386a7be7efa9f3527d167b885fee0f208f236f6947ba3878a0f4acabd31e7a8',
|
||||
selectors: '845bd57c761ad3767b5a908fa004623d61dd4cdb838616ae0320093ffa1718eb',
|
||||
variableReferences: '6755baede1580eefde410ea7237ede30af629fbbabd5bc13fc83012a7e4132b2',
|
||||
witnesses: 'e5869e1f591f6a559ad9aa1942edcbb67b84ebe5ca6d22b4f6108dcba584a9f5',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -110,7 +110,7 @@ test('tokens, shared primitives, and feature styles have one explicit owner', ()
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 716);
|
||||
assert.equal(witnesses.length, 740);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
@@ -127,7 +127,7 @@ test('every live production selector has an expanded DOM witness', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('JSX witness expansion follows cross-file components, ReactNode slots, portals, and imperative classes', () => {
|
||||
test('JSX witness expansion follows cross-file components, render props, ReactNode slots, portals, and imperative classes', () => {
|
||||
const fixtureWitnesses = createStyleWitnesses([
|
||||
{
|
||||
file: '/fixture/Child.tsx',
|
||||
@@ -142,6 +142,20 @@ test('JSX witness expansion follows cross-file components, ReactNode slots, port
|
||||
const slot = fixtureWitnesses.find((witness) => witness.classes.includes('slot'));
|
||||
assert.deepEqual(title?.ancestorClasses, ['child', 'scope']);
|
||||
assert.deepEqual(slot?.ancestorClasses, ['child', 'scope']);
|
||||
const renderPropWitnesses = createStyleWitnesses([
|
||||
{
|
||||
file: '/fixture/List.tsx',
|
||||
source: 'export function List({ renderItem }) { return <section className="list">{renderItem()}</section>; }',
|
||||
},
|
||||
{
|
||||
file: '/fixture/App.tsx',
|
||||
source: 'export function App() { return <main className="scope"><List renderItem={() => <button className="item" />} /></main>; }',
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
renderPropWitnesses.find((witness) => witness.classes.includes('item'))?.ancestorClasses,
|
||||
['list', 'scope'],
|
||||
);
|
||||
const localBindingWitnesses = createStyleWitnesses([{
|
||||
file: '/fixture/App.tsx',
|
||||
source: `export function App() {
|
||||
@@ -293,8 +307,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-DQr1ElW4.css']);
|
||||
assert.deepEqual(assets, ['index-COUqegc1.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 110647);
|
||||
assert.equal(sha256(built), '91bf07a74af3312f9b048fdebd642e811fafc3bb2ec49b55592a35f085c25f41');
|
||||
assert.equal(built.byteLength, 108951);
|
||||
assert.equal(sha256(built), '059653e70cd17fb84953e19b8280dcb9b926760c1781fb19382820434f9d1f01');
|
||||
});
|
||||
|
||||
@@ -235,16 +235,6 @@ function dynamicClassBindings(files) {
|
||||
add(routingSuffix, 'status', [...routingStatusBody.matchAll(/return \['([a-z-]+)'/g)].map((match) => match[1]));
|
||||
}
|
||||
|
||||
const subscriptionSuffix = '/features/subscription/SubscriptionFeature.tsx';
|
||||
const subscription = bySuffix(subscriptionSuffix);
|
||||
if (subscription) {
|
||||
add(subscriptionSuffix, 'validationStatus', literalUnion(
|
||||
subscription,
|
||||
/interface SubscriptionValidation[\s\S]+?status: ([^;]+);/,
|
||||
'SubscriptionValidation.status',
|
||||
));
|
||||
}
|
||||
|
||||
const connectionSuffix = '/features/connection/ConnectionPanel.tsx';
|
||||
const connection = bySuffix(connectionSuffix);
|
||||
if (connection) {
|
||||
@@ -281,7 +271,13 @@ function bindComponentProps(definition, element, callerEnvironment, dynamicBindi
|
||||
const node = attribute.value?.type === 'JSXExpressionContainer'
|
||||
? attribute.value.expression
|
||||
: attribute.value || { type: 'BooleanLiteral', value: true };
|
||||
supplied.set(name, { node, environment: callerEnvironment });
|
||||
supplied.set(name, node.type === 'Identifier' && callerEnvironment.get(node.name)
|
||||
? callerEnvironment.get(node.name)
|
||||
: {
|
||||
callable: ['ArrowFunctionExpression', 'FunctionExpression'].includes(node.type) && producesJsx(node),
|
||||
node,
|
||||
environment: callerEnvironment,
|
||||
});
|
||||
}
|
||||
supplied.set('children', { node: element.children, environment: callerEnvironment });
|
||||
const environment = new Map(dynamicBindings.get(definition.file));
|
||||
|
||||
@@ -21,33 +21,39 @@ test('subscription feature is the sole always-mounted lifecycle and view owner',
|
||||
assert.equal((page.match(/<SubscriptionDeleteDialog/g) || []).length, 1);
|
||||
assert.doesNotMatch(page, /client-subscription-summary|client-usage-bar|id="delete-subscription"/);
|
||||
assert.doesNotMatch(page, /subscriptionValidationAttempt|confirmingDeleteRef|previousHasSubscriptionRef|SUBSCRIPTION_REVEAL_DELAY_MS/);
|
||||
assert.match(feature, /client-subscription-summary/);
|
||||
assert.match(feature, /client-usage-bar/);
|
||||
assert.match(feature, /function ProfileGroup/);
|
||||
assert.match(feature, /client-profile-list/);
|
||||
assert.match(feature, /id="delete-subscription"/);
|
||||
});
|
||||
|
||||
test('App mutation sequencing and the controlled URL draft stay unchanged', () => {
|
||||
assert.match(app, /const \[subscriptionUrl, setSubscriptionUrl\] = useState\(''\)/);
|
||||
assert.match(app, /async function fetchSubscription\(\)[\s\S]*api\.subscription\.fetch\(subscriptionUrl\)[\s\S]*dispatch\(\{ type: 'clear-pending-server' \}\)/);
|
||||
assert.match(app, /async function forgetSubscription\(\)[\s\S]*setSubscriptionUrl\(''\)[\s\S]*dispatch\(\{ type: 'clear-pending-server' \}\)/);
|
||||
assert.match(page, /subscriptionUrl,[\s\S]*setSubscriptionUrl,[\s\S]*validateSubscription: actions\.validateSubscription,[\s\S]*onImport: onFetchSubscription,[\s\S]*onRefresh: onRefreshSubscription,[\s\S]*onForget: onForgetSubscription/);
|
||||
test('App owns profile mutations and always uses the latest canonical revision', () => {
|
||||
assert.match(app, /const revisionRef = useRef\(0\)/);
|
||||
assert.match(app, /const hasAcceptedSnapshotRef = useRef\(false\)/);
|
||||
assert.equal((app.match(/if \(!hasAcceptedSnapshotRef\.current \|\| snapshot\.revision > revisionRef\.current\)/g) || []).length, 2);
|
||||
assert.equal((app.match(/revisionRef\.current = snapshot\.revision/g) || []).length, 2);
|
||||
assert.doesNotMatch(app, /if \(state\) revisionRef\.current = state\.revision/);
|
||||
assert.match(app, /api\.profiles\.add\(label, url, revisionRef\.current\)/);
|
||||
assert.match(app, /api\.profiles\.rename\(profileId, label, revisionRef\.current\)/);
|
||||
assert.match(app, /api\.profiles\.refresh\(profileId, revisionRef\.current\)/);
|
||||
assert.match(app, /api\.profiles\.forget\(profileId, mode, revisionRef\.current\)/);
|
||||
assert.match(page, /profiles,[\s\S]*onAdd: onAddProfile,[\s\S]*onRename: onRenameProfile,[\s\S]*onRefresh: onRefreshProfile,[\s\S]*onForget: onForgetProfile/);
|
||||
assert.doesNotMatch(feature, /from ['"][^'"]*\/api\/|\bapi\./);
|
||||
assert.doesNotMatch(feature, /ServerPicker|InlineError|InlineProgress|ConnectionPanel|DevicesPanel|DiagnosticsPanel/);
|
||||
assert.doesNotMatch(feature, /<ServerPicker|<InlineError|<InlineProgress|<ConnectionPanel|<DevicesPanel|<DiagnosticsPanel/);
|
||||
});
|
||||
|
||||
test('validation, reveal, refresh, usage and drawer timing remain feature-owned', () => {
|
||||
assert.match(feature, /setTimeout\(async \(\) => \{[\s\S]*await validateSubscription\(normalizedUrl, \{ signal: controller\.signal \}\)[\s\S]*\}, 300\)/);
|
||||
assert.match(feature, /normalizeRequestError\(caught\)[\s\S]*requestError\.name === 'AbortError'[\s\S]*controller\.abort\(\)/);
|
||||
assert.match(feature, /currentValidation\?\.error[\s\S]*\|\| localError[\s\S]*error\?\.context === 'subscription'/);
|
||||
assert.match(feature, /retry: requestError\.retryable[\s\S]*setValidationAttempt/);
|
||||
assert.match(feature, /SUBSCRIPTION_REVEAL_DELAY_MS = 1350/);
|
||||
assert.match(feature, /previouslyHadSubscription[\s\S]*prefers-reduced-motion: reduce[\s\S]*setTimeout\(\(\) => setContentReady\(true\), SUBSCRIPTION_REVEAL_DELAY_MS\)/);
|
||||
assert.match(feature, /if \(!hasSubscription\) return undefined;[\s\S]*onRefresh\(\);[\s\S]*\}, \[hasSubscription\]\)/);
|
||||
assert.match(feature, /setTimeout\(\(\) => setEditing\(false\), 5000\)/);
|
||||
assert.match(feature, /requestAnimationFrame\(tick\)[\s\S]*cancelAnimationFrame\(frame\)/);
|
||||
assert.match(feature, /420 \+ Math\.min\(7, Math\.max\(0, serverCount - 1\)\) \* 90/);
|
||||
test('local validation, scoped refresh and drawer focus remain feature-owned', () => {
|
||||
assert.match(feature, /isSubscriptionUrlValid\(normalizedUrl\)/);
|
||||
assert.doesNotMatch(feature, /validateSubscription\(|AbortController|setTimeout\(async/);
|
||||
assert.match(feature, /async function refresh\(profileId: string\)/);
|
||||
assert.match(feature, /Math\.max\(900, Math\.ceil\(elapsed \/ 900\) \* 900\)/);
|
||||
assert.match(feature, /if \(confirmingDeleteRef\.current\) return;[\s\S]*event\.type === 'keydown'[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
||||
assert.match(feature, /previousProfileCountRef\.current === 0 && profiles\.length > 0[\s\S]*setOpen\(true\)[\s\S]*setExpanded/);
|
||||
assert.match(feature, /previousProfileCountRef\.current > 0\) setOpen\(false\)/);
|
||||
assert.match(feature, /if \(!adding \|\| \(profiles\.length > 0 && !open\)\) return undefined/);
|
||||
assert.match(feature, /if \(deleteIdRef\.current\) return;[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
||||
assert.match(feature, /const invoker = addInvokerRef\.current[\s\S]*if \(invoker\) requestAnimationFrame\(\(\) => invoker\.focus\(\)\)/);
|
||||
assert.match(feature, /showAdd: \(\) => \{[\s\S]*addInvokerRef\.current = document\.activeElement instanceof HTMLElement/);
|
||||
assert.match(feature, /function cancelRename\(\)[\s\S]*client-profile-menu-\$\{profileId\}[\s\S]*\.focus\(\)/);
|
||||
assert.match(feature, /id=\{`client-profile-menu-\$\{profile\.id\}`\}/);
|
||||
});
|
||||
|
||||
test('validation rejection parser preserves structured errors and normalizes non-objects', () => {
|
||||
@@ -79,22 +85,27 @@ test('validation rejection parser preserves structured errors and normalizes non
|
||||
});
|
||||
|
||||
test('feature keeps exact slots, truthy closes and subscription DOM order', () => {
|
||||
assert.match(feature, /if \(!await onImport\(\)\) return;[\s\S]*setSubscriptionUrl\(''\)[\s\S]*setEditing\(false\)/);
|
||||
assert.match(feature, /if \(!await onForget\(\)\) return;[\s\S]*setConfirmingDelete\(false\)/);
|
||||
assert.match(feature, /client-subscription-summary[\s\S]*client-subscription-edit[\s\S]*\{statusSlot\}[\s\S]*client-usage[\s\S]*\{serverSlot\}/);
|
||||
assert.match(page, /<SubscriptionPanel[\s\S]*statusSlot=\{<>[\s\S]*InlineError[\s\S]*InlineProgress[\s\S]*serverSlot=\{hasSubscription[\s\S]*<ServerPicker/);
|
||||
assert.match(feature, /if \(!await onAdd\(normalizedLabel, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
|
||||
assert.match(feature, /if \(!await onForget\(deleteProfile\.id, deleteStopsVpn \? 'stop-and-delete' : 'delete'\)\) return;[\s\S]*setDeleteId\(''\)/);
|
||||
assert.match(feature, /client-profiles-operation[\s\S]*client-profiles-current[\s\S]*\{statusSlot\}[\s\S]*client-profile-list/);
|
||||
assert.match(feature, /const drawerOpen = feature\.open && feature\.profiles\.length > 0/);
|
||||
assert.match(feature, /feature\.profiles\.length === 0 && <div className="client-form client-subscription-first-run"/);
|
||||
assert.match(page, /<SubscriptionPanel[\s\S]*statusSlot=\{<>[\s\S]*InlineError[\s\S]*renderServerPicker=[\s\S]*<ServerPicker/);
|
||||
assert.doesNotMatch(page, /<InlineProgress[^>]*context="subscription"/);
|
||||
assert.match(page, /<SubscriptionToggle[\s\S]*subscriptionFeature\.toggle\(\)/);
|
||||
assert.match(page, /subscriptionFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)/);
|
||||
assert.doesNotMatch(feature, /setInterval|copyText|client-live-region/);
|
||||
});
|
||||
|
||||
test('saved subscription stays one clear shared presentation', () => {
|
||||
assert.equal((feature.match(/client-subscription-summary/g) || []).length, 1);
|
||||
assert.match(feature, /client-subscription-heading[\s\S]*client-subscription-status[\s\S]*Сохранена[\s\S]*client-subscription-actions[\s\S]*client-subscription-refresh[\s\S]*client-subscription-delete/);
|
||||
assert.match(feature, /client-subscription-domain-button[\s\S]*client-subscription-label">Подписка[\s\S]*subscriptionDomain\(subscription\?\.host\)/);
|
||||
assert.match(feature, /client-usage-summary[\s\S]*Использовано[\s\S]* из [\s\S]*client-usage-bar[\s\S]*client-usage-details[\s\S]*Действует до[\s\S]*subscriptionDaysLeft/);
|
||||
assert.doesNotMatch(feature, /role="tab"|client-subscription-card|subscriptions\.map\(/);
|
||||
assert.match(styles, /\.client-subscription-heading\s*\{[\s\S]*grid-template-columns:\s*64px minmax\(0, 1fr\) 64px/);
|
||||
assert.match(styles, /\.client-usage\s*\{[\s\S]*min-height:\s*76px/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-usage-summary > strong[\s\S]*transition:\s*none/);
|
||||
test('profiles render as flat accordion groups with scoped controls', () => {
|
||||
assert.match(feature, /feature\.profiles\.map\(\(profile\) => <ProfileGroup/);
|
||||
assert.match(feature, /aria-expanded=\{expanded\}/);
|
||||
assert.match(feature, /profile\.subscription\.status === 'stale'[\s\S]*profile\.subscription\.fetchedAt/);
|
||||
assert.match(feature, /feature\.operations\.profileRefresh\?\.target === profile\.id/);
|
||||
assert.match(feature, /const currentLabel = feature\.gatewayDirect[\s\S]*Gateway · сервер не определён/);
|
||||
assert.match(feature, /const desired = !feature\.connected[\s\S]*&& !feature\.gatewayDirect/);
|
||||
assert.match(styles, /\.client-profile-group/);
|
||||
assert.match(styles, /\.client-profile-body/);
|
||||
assert.match(styles, /\.client-profile-refresh\.is-refreshing/);
|
||||
assert.doesNotMatch(feature, /client-subscription-card|role="tab"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user