Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
checkImportBoundaries,
|
||||
importBoundaryViolation,
|
||||
} from '../../scripts/check-import-boundaries.mjs';
|
||||
|
||||
test('architecture rejects reverse ownership imports', () => {
|
||||
assert.equal(
|
||||
importBoundaryViolation('src/shared/contracts/state.ts', '../../server/main.ts'),
|
||||
'shared cannot import server or web',
|
||||
);
|
||||
assert.equal(
|
||||
importBoundaryViolation('src/server/http/routes/state.ts', '../../infrastructure/filesystem/store.ts'),
|
||||
'server/http cannot import infrastructure directly',
|
||||
);
|
||||
assert.equal(
|
||||
importBoundaryViolation('src/web/ui/Button.tsx', '../api/harborClient.ts'),
|
||||
'web/ui cannot import api or features',
|
||||
);
|
||||
assert.equal(
|
||||
importBoundaryViolation('src/web/ui/Button.tsx', '../api.js'),
|
||||
'web/ui cannot import api or features',
|
||||
);
|
||||
});
|
||||
|
||||
test('architecture requires cross-feature imports to use the public index', () => {
|
||||
assert.equal(
|
||||
importBoundaryViolation(
|
||||
'src/web/features/connection/controller.ts',
|
||||
'../servers/private/selectors.ts',
|
||||
),
|
||||
'cross-feature imports must use the feature index',
|
||||
);
|
||||
assert.equal(
|
||||
importBoundaryViolation('src/web/features/connection/controller.ts', '../servers/index.ts'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('strict TypeScript source tree passes the active boundaries', () => {
|
||||
const result = checkImportBoundaries();
|
||||
assert.equal(result.violations.length, 0, JSON.stringify(result.violations, null, 2));
|
||||
assert.ok(result.filesChecked > 0);
|
||||
});
|
||||
|
||||
test('state views cannot read flat v0 wire aliases or a parallel state transport', () => {
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(root, 'src/web/App.tsx'), 'utf8');
|
||||
const client = fs.readFileSync(path.join(root, 'src/web/api/harborClient.ts'), 'utf8');
|
||||
const server = fs.readFileSync(path.join(root, 'src/server/index.ts'), 'utf8');
|
||||
const consumers = [
|
||||
'src/web/App.tsx',
|
||||
'src/web/components/ClientOverviewPage.tsx',
|
||||
'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx',
|
||||
'src/web/features/devices/DevicesPanel.tsx',
|
||||
'src/web/features/servers/ServerPicker.tsx',
|
||||
'src/web/features/connection/ConnectionPanel.tsx',
|
||||
'src/web/features/subscription/SubscriptionFeature.tsx',
|
||||
'src/web/features/routing/RoutingFeature.tsx',
|
||||
'src/web/features/instructions/InstructionsFeature.tsx',
|
||||
].map((file) => fs.readFileSync(path.join(root, file), 'utf8')).join('\n');
|
||||
const componentSources = fs.readdirSync(path.join(root, 'src/web/components'))
|
||||
.filter((file) => /\.[jt]sx?$/.test(file))
|
||||
.map((file) => fs.readFileSync(path.join(root, 'src/web/components', file), 'utf8'))
|
||||
.concat([
|
||||
fs.readFileSync(path.join(root, 'src/web/features/connection/ConnectionPanel.tsx'), 'utf8'),
|
||||
fs.readFileSync(path.join(root, 'src/web/features/servers/ServerPicker.tsx'), 'utf8'),
|
||||
fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8'),
|
||||
fs.readFileSync(path.join(root, 'src/web/features/routing/RoutingFeature.tsx'), 'utf8'),
|
||||
fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8'),
|
||||
fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesPanel.tsx'), 'utf8'),
|
||||
fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8'),
|
||||
fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8'),
|
||||
fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8'),
|
||||
])
|
||||
.join('\n');
|
||||
const flatRead = /state\??\.(?:hasSubscription|subscriptionHost|singboxRunning|singboxStartedAt|configExists|proxyPort|userInfo|gatewayAuto)\b/;
|
||||
|
||||
assert.doesNotMatch(page, flatRead);
|
||||
assert.doesNotMatch(app, flatRead);
|
||||
assert.doesNotMatch(client, /\bstate:\s*\(\)\s*=>\s*request\(['"]\/api\/state/);
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/api.js')), false);
|
||||
assert.doesNotMatch(consumers, /from ['"][^'"]*\/api\.js['"]/);
|
||||
assert.doesNotMatch(componentSources, /from ['"][^'"]*\/api(?:\/|\.js)/);
|
||||
assert.match(client, /export class HarborApiError/);
|
||||
assert.match(client, /export async function request/);
|
||||
assert.match(client, /export const api =/);
|
||||
assert.match(client, /export function parseHarborState/);
|
||||
assert.doesNotMatch(server, /function\s+publicState\b/);
|
||||
assert.doesNotMatch(server, /req\.url\s*===\s*['"]\/api\/state['"]/);
|
||||
assert.match(app, /const displayState = previewReady \? \{[\s\S]*mode: 'client' as const/);
|
||||
});
|
||||
|
||||
test('comments, strings, and property names are not treated as imports', (t) => {
|
||||
const repository = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-boundaries-'));
|
||||
t.after(() => fs.rmSync(repository, { recursive: true, force: true }));
|
||||
const file = path.join(repository, 'src/web/ui/example.ts');
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, [
|
||||
'// import value from "../../server/private.ts";',
|
||||
'const example = \'import value from "../../server/private.ts";\';',
|
||||
'const metadata = { import: "../../server/private.ts" };',
|
||||
'const loader = { import() {} };',
|
||||
'loader.import("../../server/private.ts");',
|
||||
'export { example };',
|
||||
'',
|
||||
].join('\n'));
|
||||
assert.equal(checkImportBoundaries(repository).violations.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
function filesUnder(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesUnder(absolute) : [absolute];
|
||||
});
|
||||
}
|
||||
|
||||
test('runtime source tree has one strict TypeScript path without compiler escape hatches', () => {
|
||||
const sourceFiles = filesUnder(path.join(root, 'src'));
|
||||
const legacy = sourceFiles.filter((file) => /\.(?:js|jsx)$/.test(file));
|
||||
assert.deepEqual(legacy, []);
|
||||
|
||||
const configs = fs.readdirSync(root)
|
||||
.filter((file) => /^tsconfig(?:\.[^.]+)?\.json$/.test(file))
|
||||
.map((file) => fs.readFileSync(path.join(root, file), 'utf8'))
|
||||
.join('\n');
|
||||
assert.doesNotMatch(configs, /"(?:allowJs|checkJs|noCheck)"/);
|
||||
assert.match(fs.readFileSync(path.join(root, 'tsconfig.base.json'), 'utf8'), /"strict": true/);
|
||||
|
||||
const source = sourceFiles
|
||||
.filter((file) => /\.tsx?$/.test(file))
|
||||
.map((file) => fs.readFileSync(file, 'utf8'))
|
||||
.join('\n');
|
||||
assert.doesNotMatch(source, /@ts-(?:nocheck|ignore|expect-error)/);
|
||||
});
|
||||
|
||||
test('Node tests execute emitted JavaScript rather than source TypeScript', () => {
|
||||
const tests = filesUnder(path.join(root, 'test'))
|
||||
.filter((file) => file.endsWith('.js') && path.basename(file) !== 'typescript-cutover.test.js')
|
||||
.map((file) => fs.readFileSync(file, 'utf8'))
|
||||
.join('\n');
|
||||
const sourceTypeScriptImport = /(?:from\s*|import\s*\()['"][^'"]*src\/[^'"]+\.tsx?['"]/;
|
||||
assert.doesNotMatch(tests, sourceTypeScriptImport);
|
||||
assert.doesNotMatch(tests, /stripTypeScriptTypes|data:text\/javascript/);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
test('test emit removes stale compiled artifacts before TypeScript runs', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-test-emit-'));
|
||||
const output = path.join(directory, '.test-dist');
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
fs.mkdirSync(output);
|
||||
fs.writeFileSync(path.join(output, 'removed-module.js'), 'stale');
|
||||
|
||||
execFileSync(process.execPath, [path.join(root, 'scripts/clean-test-dist.mjs')], { cwd: directory });
|
||||
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseSubscriptionBody } from '../src/server/subscription.js';
|
||||
import { createStateSnapshot, normalizeStoredState } from '../src/shared/contracts/state.js';
|
||||
import { parseSubscriptionBody } from '../dist/server/subscription.js';
|
||||
import { createStateSnapshot, normalizeStoredState } from '../dist/shared/contracts/state.js';
|
||||
|
||||
const outbound = (index) => ({
|
||||
type: 'vless',
|
||||
@@ -52,3 +52,14 @@ test('data invariant: one canonical snapshot owns server selection and never exp
|
||||
assert.equal(snapshot.servers.find((server) => server.id === selectedServerId)?.host, 'vpn-17.example.test');
|
||||
assert.equal(JSON.stringify(snapshot).includes('private-token'), false);
|
||||
});
|
||||
|
||||
test('persisted numeric IDs and legacy tags keep their previous string normalization', () => {
|
||||
const stored = normalizeStoredState({
|
||||
servers: [{ id: '42', label: '7', host: 'vpn.example', port: 443, protocol: 'vless' }],
|
||||
selectedServerId: 42,
|
||||
appliedTag: 7,
|
||||
});
|
||||
|
||||
assert.equal(stored.selectedServerId, '42');
|
||||
assert.equal(stored.appliedServerId, '42');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { classifyRuntimeImpact } from '../../scripts/runtime-impact.mjs';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
test('runtime impact keeps affected components separate from restart scope', () => {
|
||||
assert.deepEqual(classifyRuntimeImpact(['README.md', 'workpack/STATUS.md', 'test/example.test.js']), {
|
||||
affectedComponents: [],
|
||||
restartScope: 'none',
|
||||
});
|
||||
assert.deepEqual(classifyRuntimeImpact(['scripts/clean-test-dist.mjs']), {
|
||||
affectedComponents: [],
|
||||
restartScope: 'none',
|
||||
});
|
||||
assert.deepEqual(classifyRuntimeImpact(['src/web/App.tsx']), {
|
||||
affectedComponents: ['control'],
|
||||
restartScope: 'control',
|
||||
});
|
||||
assert.deepEqual(classifyRuntimeImpact(['src/server/services/domainTrafficService.ts']), {
|
||||
affectedComponents: ['dataplane'],
|
||||
restartScope: 'both',
|
||||
});
|
||||
assert.deepEqual(classifyRuntimeImpact(['package-lock.json']), {
|
||||
affectedComponents: ['control', 'dataplane'],
|
||||
restartScope: 'both',
|
||||
});
|
||||
});
|
||||
|
||||
test('every displaced regex path keeps dataplane restart coverage in JS and TS', () => {
|
||||
const legacyPaths = [
|
||||
'src/server/config',
|
||||
'src/server/dataplane',
|
||||
'src/server/gatewayRouting',
|
||||
'src/server/singbox',
|
||||
'src/server/singboxRuntime',
|
||||
'src/server/version',
|
||||
'src/server/adapters/neighbors',
|
||||
'src/server/services/connectivityDiagnosticsService',
|
||||
'src/server/services/deviceTrafficService',
|
||||
'src/server/services/devicePolicyService',
|
||||
'src/shared/connectivityDiagnostics',
|
||||
'src/shared/errors',
|
||||
];
|
||||
for (const file of legacyPaths) {
|
||||
assert.equal(classifyRuntimeImpact([`${file}.js`]).restartScope, 'both', `${file}.js`);
|
||||
assert.equal(classifyRuntimeImpact([`${file}.ts`]).restartScope, 'both', `${file}.ts`);
|
||||
}
|
||||
for (const file of ['Dockerfile', 'entrypoint.sh', 'package.json', 'package-lock.json',
|
||||
'scripts/build-runtime-base.sh', '.gitea/workflows/gateway-build.yml']) {
|
||||
assert.equal(classifyRuntimeImpact([file]).restartScope, 'both', file);
|
||||
}
|
||||
});
|
||||
|
||||
test('classifier covers current dataplane reachability without promoting its client adapter', () => {
|
||||
for (const file of ['src/server/main.js', 'src/server/main.ts']) {
|
||||
assert.deepEqual(classifyRuntimeImpact([file]), {
|
||||
affectedComponents: ['control', 'dataplane'],
|
||||
restartScope: 'both',
|
||||
}, file);
|
||||
}
|
||||
assert.deepEqual(classifyRuntimeImpact(['src/server/services/domainTrafficService.js']), {
|
||||
affectedComponents: ['dataplane'],
|
||||
restartScope: 'both',
|
||||
});
|
||||
assert.deepEqual(classifyRuntimeImpact(['src/server/services/deviceInventoryService.js']), {
|
||||
affectedComponents: ['control', 'dataplane'],
|
||||
restartScope: 'both',
|
||||
});
|
||||
assert.deepEqual(classifyRuntimeImpact(['src/server/dataplaneClient.ts']), {
|
||||
affectedComponents: ['control'],
|
||||
restartScope: 'control',
|
||||
});
|
||||
});
|
||||
|
||||
test('version bumps do not promote a control-only change to a dataplane restart', () => {
|
||||
assert.deepEqual(classifyRuntimeImpact(['src/web/App.tsx', 'src/shared/versions.ts']), {
|
||||
affectedComponents: ['control'],
|
||||
restartScope: 'control',
|
||||
});
|
||||
});
|
||||
|
||||
test('release-critical inputs restart both and unknown paths fail closed', () => {
|
||||
for (const file of [
|
||||
'.dockerignore',
|
||||
'.gitea/workflows/another.yml',
|
||||
'docker-compose.gateway.yml',
|
||||
'scripts/deploy-gateway.sh',
|
||||
]) {
|
||||
assert.deepEqual(classifyRuntimeImpact([file]), {
|
||||
affectedComponents: ['control', 'dataplane'],
|
||||
restartScope: 'both',
|
||||
}, file);
|
||||
}
|
||||
assert.throws(() => classifyRuntimeImpact(['src/new-owner.ts']), /Unclassified path/);
|
||||
assert.throws(() => classifyRuntimeImpact(['scripts/new-runtime.sh']), /Unclassified path/);
|
||||
});
|
||||
|
||||
test('every tracked repository path has an explicit ownership class', () => {
|
||||
const tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' })
|
||||
.trim()
|
||||
.split('\n');
|
||||
assert.doesNotThrow(() => classifyRuntimeImpact(tracked));
|
||||
});
|
||||
|
||||
test('workflow CLI reads changed paths from stdin', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/runtime-impact.mjs', '--stdin'],
|
||||
{ cwd: root, input: 'src/web/App.tsx\nsrc/server/dataplane.ts\n', encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout.trim(), 'affected-components=control+dataplane\nrestart-scope=both');
|
||||
});
|
||||
|
||||
test('push range keeps a dataplane change from an earlier commit', (t) => {
|
||||
const repository = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-impact-'));
|
||||
t.after(() => fs.rmSync(repository, { recursive: true, force: true }));
|
||||
git(repository, 'init', '-q');
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'base\n');
|
||||
git(repository, 'add', '.');
|
||||
git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'base');
|
||||
const before = git(repository, 'rev-parse', 'HEAD');
|
||||
|
||||
const dataplane = path.join(repository, 'src/server/services/domainTrafficService.ts');
|
||||
fs.mkdirSync(path.dirname(dataplane), { recursive: true });
|
||||
fs.writeFileSync(dataplane, 'export {};\n');
|
||||
git(repository, 'add', '.');
|
||||
git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'dataplane');
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'tip docs change\n');
|
||||
git(repository, 'add', '.');
|
||||
git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'tip');
|
||||
|
||||
const changed = git(repository, 'diff', '--name-only', before, 'HEAD').split('\n');
|
||||
assert.deepEqual(classifyRuntimeImpact(changed), {
|
||||
affectedComponents: ['dataplane'],
|
||||
restartScope: 'both',
|
||||
});
|
||||
});
|
||||
|
||||
test('rename range keeps ownership of the displaced dataplane path', (t) => {
|
||||
const repository = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-impact-rename-'));
|
||||
t.after(() => fs.rmSync(repository, { recursive: true, force: true }));
|
||||
git(repository, 'init', '-q');
|
||||
const oldPath = path.join(repository, 'src/server/services/domainTrafficService.ts');
|
||||
fs.mkdirSync(path.dirname(oldPath), { recursive: true });
|
||||
fs.writeFileSync(oldPath, 'export function dataplaneOwned() { return true; }\n');
|
||||
git(repository, 'add', '.');
|
||||
git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'base');
|
||||
const before = git(repository, 'rev-parse', 'HEAD');
|
||||
|
||||
fs.renameSync(oldPath, path.join(path.dirname(oldPath), 'newService.ts'));
|
||||
git(repository, 'add', '-A');
|
||||
git(repository, '-c', 'user.name=Harbor', '-c', 'user.email=harbor@example.test', 'commit', '-qm', 'rename');
|
||||
const changed = git(repository, 'diff', '--no-renames', '--name-only', before, 'HEAD').split('\n');
|
||||
|
||||
assert.deepEqual(classifyRuntimeImpact(changed), {
|
||||
affectedComponents: ['control', 'dataplane'],
|
||||
restartScope: 'both',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
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) {
|
||||
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = http.createServer();
|
||||
const port = await listen(server);
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
function socketRequest(socketPath, pathname) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({ socketPath, path: pathname }, (response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve({ status: response.statusCode, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function httpText(url) {
|
||||
const response = await fetch(url);
|
||||
return { status: response.status, type: response.headers.get('content-type'), body: await response.text() };
|
||||
}
|
||||
|
||||
async function waitFor(probe, child, stderr) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (child.exitCode !== null) throw new Error(`Compiled Harbor exited early: ${stderr()}`);
|
||||
try {
|
||||
const result = await probe();
|
||||
if (result) return result;
|
||||
} catch {
|
||||
// The disposable listener is still starting.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(`Compiled Harbor did not become ready: ${stderr()}`);
|
||||
}
|
||||
|
||||
async function stop(child) {
|
||||
child.kill('SIGTERM');
|
||||
if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve));
|
||||
assert.equal(child.exitCode, 0);
|
||||
}
|
||||
|
||||
test('compiled dispatcher starts and stops control and dataplane contracts', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-compiled-'));
|
||||
const controlData = path.join(directory, 'control');
|
||||
const dataplaneData = path.join(directory, 'dataplane');
|
||||
const socketPath = path.join(directory, 'dataplane.sock');
|
||||
const port = await freePort();
|
||||
fs.mkdirSync(controlData);
|
||||
fs.mkdirSync(dataplaneData);
|
||||
const children = [];
|
||||
t.after(async () => {
|
||||
for (const child of children) {
|
||||
if (child.exitCode === null) await stop(child);
|
||||
}
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const start = (env) => {
|
||||
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
children.push(child);
|
||||
return { child, stderr: () => stderr };
|
||||
};
|
||||
|
||||
const control = start({
|
||||
APP_COMPONENT: 'control',
|
||||
APP_MODE: 'client',
|
||||
DATA_DIR: controlData,
|
||||
DIST_DIR: '',
|
||||
HARBOR_HOST_NETWORK_STATE: path.join(directory, 'missing-network.json'),
|
||||
PORT: String(port),
|
||||
SING_BOX_CACHE: path.join(controlData, 'cache.db'),
|
||||
});
|
||||
const state = await waitFor(async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/state`);
|
||||
return response.ok ? response.json() : null;
|
||||
}, control.child, control.stderr);
|
||||
assert.equal(state.apiVersion, 1);
|
||||
assert.equal(state.mode, 'client');
|
||||
const page = await httpText(`http://127.0.0.1:${port}/`);
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(page.type, /^text\/html/);
|
||||
assert.match(page.body, /<div id="root"><\/div>/);
|
||||
await stop(control.child);
|
||||
|
||||
const dataplane = start({
|
||||
APP_COMPONENT: 'dataplane',
|
||||
APP_MODE: 'gateway',
|
||||
DATA_DIR: dataplaneData,
|
||||
DATAPLANE_SOCKET: socketPath,
|
||||
SING_BOX_CACHE: path.join(dataplaneData, 'cache.db'),
|
||||
SING_BOX_CONFIG: path.join(dataplaneData, 'missing-config.json'),
|
||||
});
|
||||
const status = await waitFor(async () => {
|
||||
const response = await socketRequest(socketPath, '/status');
|
||||
return response.status === 200 ? response.body : null;
|
||||
}, dataplane.child, dataplane.stderr);
|
||||
assert.equal(status.ready, true);
|
||||
await stop(dataplane.child);
|
||||
});
|
||||
|
||||
test('production paths use only the compiled dispatcher', () => {
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const packageJson = JSON.parse(read('package.json'));
|
||||
const main = read('src/server/main.ts');
|
||||
const gatewayEntrypoint = read('entrypoint.sh');
|
||||
const clientEntrypoint = read('entrypoint.client.sh');
|
||||
const workflow = read('.gitea/workflows/gateway-build.yml');
|
||||
const legacyBuild = read('scripts/build-on-107-deploy-111.sh');
|
||||
const dockerignore = read('.dockerignore');
|
||||
|
||||
assert.equal(packageJson.scripts['build:production'], 'npm run build && npm run build:server');
|
||||
assert.equal(packageJson.scripts.prestart, 'npm run build:production');
|
||||
assert.equal(packageJson.scripts.start, 'node dist/server/main.js');
|
||||
assert.match(main, /APP_COMPONENT === 'dataplane'/);
|
||||
assert.match(main, /process\.env\.DIST_DIR \|\|= path\.resolve\('dist'\)/);
|
||||
assert.match(main, /import\('\.\/dataplane\.js'\)/);
|
||||
assert.match(main, /import\('\.\/index\.js'\)/);
|
||||
for (const entrypoint of [gatewayEntrypoint, clientEntrypoint]) {
|
||||
assert.match(entrypoint, /node \/app\/dist\/server\/main\.js/);
|
||||
assert.doesNotMatch(entrypoint, /\/app\/src/);
|
||||
}
|
||||
assert.match(workflow, /npm run build:production/);
|
||||
assert.match(workflow, /NODE_BUILD_IMAGE: node:20\.19-alpine/);
|
||||
assert.match(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"[\s\S]*validating inside \$\{\{ env\.NODE_BUILD_IMAGE \}\}/);
|
||||
assert.match(legacyBuild, /npm run build:production && docker build/);
|
||||
assert.match(dockerignore, /^dist$/m);
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
captureRuntimeCommand,
|
||||
createConnectionService,
|
||||
} from '../../dist/server/features/connection/index.js';
|
||||
import { createConnectionRuntimeRoute } from '../../dist/server/http/routes/connectionRuntimeRoute.js';
|
||||
import { createServerApplyRoute } from '../../dist/server/http/routes/serverApplyRoute.js';
|
||||
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 ?? {
|
||||
revision: 5,
|
||||
servers: [serverA, serverB],
|
||||
selectedServerId: serverA.id,
|
||||
appliedServerId: serverA.id,
|
||||
routeRules: rules,
|
||||
appliedRouteRules: rules,
|
||||
connectionDesired: 'running',
|
||||
});
|
||||
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;
|
||||
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;
|
||||
}
|
||||
};
|
||||
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) {
|
||||
running = false;
|
||||
throw failure;
|
||||
}
|
||||
running = false;
|
||||
};
|
||||
|
||||
const restartRuntime = async () => {
|
||||
events.push('runtime.restart');
|
||||
const failure = failures.runtimeRestarts.shift();
|
||||
if (failure) {
|
||||
if (failure.code !== 'CONFIG_INVALID') running = false;
|
||||
throw failure;
|
||||
}
|
||||
running = true;
|
||||
};
|
||||
|
||||
const service = createConnectionService({
|
||||
state: {
|
||||
read: () => structuredClone(state),
|
||||
update: applyState,
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
|
||||
},
|
||||
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;
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
isRunning: async () => {
|
||||
if (failures.runtimeStatus) throw failures.runtimeStatus;
|
||||
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'] },
|
||||
),
|
||||
},
|
||||
serialize,
|
||||
now: () => new Date('2026-08-08T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
return {
|
||||
service,
|
||||
events,
|
||||
enqueue: serialize,
|
||||
setState: (value) => { state = structuredClone(value); },
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
const after = harness.snapshot();
|
||||
assertDomainRestored(after, before);
|
||||
assert.ok(after.state.revision > before.state.revision);
|
||||
});
|
||||
|
||||
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 });
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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('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}`));
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
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('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);
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
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('connection runtime route preserves operation kinds, response extras, and single ownership', async () => {
|
||||
const calls = [];
|
||||
const sent = [];
|
||||
const route = createConnectionRuntimeRoute({
|
||||
connection: {
|
||||
stop: async () => { calls.push('service.stop'); },
|
||||
restart: async () => { calls.push('service.restart'); },
|
||||
},
|
||||
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/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 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)['"]/);
|
||||
});
|
||||
@@ -5,9 +5,11 @@ import test from 'node:test';
|
||||
import {
|
||||
createConnectivityDiagnosticsService,
|
||||
CURL_META_MARKER,
|
||||
} from '../../src/server/services/connectivityDiagnosticsService.js';
|
||||
} from '../../dist/server/services/connectivityDiagnosticsService.js';
|
||||
import { createConnectivityDiagnosticsUseCase } from '../../dist/server/features/diagnostics/index.js';
|
||||
import { createConnectivityDiagnosticsRoute } from '../../dist/server/http/routes/connectivityDiagnosticsRoute.js';
|
||||
|
||||
const server = fs.readFileSync(path.resolve(import.meta.dirname, '../../src/server/index.js'), 'utf8');
|
||||
const server = fs.readFileSync(path.resolve(import.meta.dirname, '../../src/server/index.ts'), 'utf8');
|
||||
|
||||
function response(body = '', overrides = {}) {
|
||||
return {
|
||||
@@ -52,10 +54,154 @@ test('connectivity diagnostics force separate direct and VPN paths', async () =>
|
||||
assert.ok(calls.some((args) => args.includes('--proxy') && args.includes('http://127.0.0.1:18080')));
|
||||
});
|
||||
|
||||
test('connectivity diagnostics endpoint is available in Connect and Gateway', () => {
|
||||
test('connectivity diagnostics endpoint is available in Connect and Gateway through one owner', () => {
|
||||
assert.match(server, /const localConnectivityDiagnostics = !remoteDataplane/);
|
||||
assert.doesNotMatch(server, /settings\.appMode !== 'gateway'[\s\S]{0,120}ENDPOINT_NOT_FOUND/);
|
||||
assert.match(server, /runConnectivityDiagnostics\(services, target\)/);
|
||||
assert.match(server, /createConnectivityDiagnosticsUseCase\(\{/);
|
||||
assert.match(server, /createConnectivityDiagnosticsRoute\(\{/);
|
||||
assert.match(server, /connectivityDiagnosticsRoute\.handle\(req, res\)/);
|
||||
assert.doesNotMatch(server, /['"]\/api\/diagnostics\/connectivity['"]/);
|
||||
assert.doesNotMatch(server, /appliedServerId\s*\|\|\s*state\.selectedServerId/);
|
||||
});
|
||||
|
||||
test('connectivity use case captures applied server before probes and preserves result fields', async () => {
|
||||
const events = [];
|
||||
let state = {
|
||||
appliedServerId: 'applied',
|
||||
selectedServerId: 'selected',
|
||||
servers: [
|
||||
{ id: 'applied', label: 'Applied before probe' },
|
||||
{ id: 'selected', label: 'Selected' },
|
||||
],
|
||||
};
|
||||
let releaseProbe;
|
||||
const probe = new Promise((resolve) => { releaseProbe = resolve; });
|
||||
const sourceResult = {
|
||||
checkedAt: 'now',
|
||||
direct: { available: true },
|
||||
vpn: { available: true, server: { id: 'stale', label: 'Stale' }, detail: 7 },
|
||||
assessment: { summary: 'available' },
|
||||
};
|
||||
const useCase = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => {
|
||||
events.push('state');
|
||||
return state;
|
||||
},
|
||||
runDiagnostics: async (services, target) => {
|
||||
events.push(['probe', services, target]);
|
||||
await probe;
|
||||
return sourceResult;
|
||||
},
|
||||
});
|
||||
const resultPromise = useCase.run({ raw: true }, 42);
|
||||
state.servers[0].label = 'Changed during probe';
|
||||
releaseProbe();
|
||||
const result = await resultPromise;
|
||||
|
||||
assert.deepEqual(events, ['state', ['probe', { raw: true }, 42]]);
|
||||
assert.deepEqual(result, {
|
||||
...sourceResult,
|
||||
vpn: {
|
||||
...sourceResult.vpn,
|
||||
server: { id: 'applied', label: 'Applied before probe' },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(sourceResult.vpn.server, { id: 'stale', label: 'Stale' });
|
||||
});
|
||||
|
||||
test('connectivity use case keeps applied priority, selected fallback and error identity', async () => {
|
||||
const result = { vpn: { available: false }, marker: true };
|
||||
const selected = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => ({
|
||||
appliedServerId: '',
|
||||
selectedServerId: 'selected',
|
||||
servers: [{ id: 'selected', label: 'Selected' }],
|
||||
}),
|
||||
runDiagnostics: async () => result,
|
||||
});
|
||||
assert.deepEqual((await selected.run(null, null)).vpn.server, {
|
||||
id: 'selected',
|
||||
label: 'Selected',
|
||||
});
|
||||
|
||||
const missingApplied = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => ({
|
||||
appliedServerId: 'missing',
|
||||
selectedServerId: 'selected',
|
||||
servers: [{ id: 'selected', label: 'Selected' }],
|
||||
}),
|
||||
runDiagnostics: async () => result,
|
||||
});
|
||||
assert.equal((await missingApplied.run([], null)).vpn.server, null);
|
||||
|
||||
const stateError = new Error('state failed');
|
||||
let probes = 0;
|
||||
const brokenState = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => { throw stateError; },
|
||||
runDiagnostics: async () => { probes += 1; return result; },
|
||||
});
|
||||
await assert.rejects(brokenState.run([], null), (error) => error === stateError);
|
||||
assert.equal(probes, 0);
|
||||
|
||||
const probeError = new Error('probe failed');
|
||||
const brokenProbe = createConnectivityDiagnosticsUseCase({
|
||||
readState: () => ({ servers: [] }),
|
||||
runDiagnostics: async () => { throw probeError; },
|
||||
});
|
||||
await assert.rejects(brokenProbe.run([], null), (error) => error === probeError);
|
||||
});
|
||||
|
||||
function routeResponse() {
|
||||
return {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(payload) {
|
||||
this.payload = JSON.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('connectivity route preserves exact URL, defaults and raw response', async () => {
|
||||
const calls = [];
|
||||
let body = {};
|
||||
let bodyReads = 0;
|
||||
const route = createConnectivityDiagnosticsRoute({
|
||||
diagnostics: {
|
||||
run: async (...args) => {
|
||||
calls.push(args);
|
||||
return { checkedAt: 'now', vpn: { server: null } };
|
||||
},
|
||||
},
|
||||
readBody: async () => {
|
||||
bodyReads += 1;
|
||||
return body;
|
||||
},
|
||||
});
|
||||
const res = routeResponse();
|
||||
assert.equal(await route.handle({
|
||||
method: 'POST',
|
||||
url: '/api/diagnostics/connectivity',
|
||||
}, res), true);
|
||||
assert.deepEqual(calls, [[[], null]]);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.deepEqual(res.payload, { checkedAt: 'now', vpn: { server: null } });
|
||||
|
||||
body = { services: null, target: 17 };
|
||||
await route.handle({ method: 'POST', url: '/api/diagnostics/connectivity' }, routeResponse());
|
||||
assert.deepEqual(calls.at(-1), [null, 17]);
|
||||
|
||||
for (const [method, url] of [
|
||||
['GET', '/api/diagnostics/connectivity'],
|
||||
['POST', '/api/diagnostics/connectivity?target=all'],
|
||||
['POST', '/api/diagnostics/other'],
|
||||
]) {
|
||||
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
||||
}
|
||||
assert.equal(bodyReads, 2);
|
||||
});
|
||||
|
||||
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createDataplaneClient } from '../../src/server/dataplaneClient.js';
|
||||
import { createDataplaneClient } from '../../dist/server/dataplaneClient.js';
|
||||
|
||||
test('control uses the dataplane socket protocol', async () => {
|
||||
const requests = [];
|
||||
|
||||
@@ -7,6 +7,7 @@ const root = path.resolve(import.meta.dirname, '../..');
|
||||
const compose = fs.readFileSync(path.join(root, 'docker-compose.gateway.yml'), 'utf8');
|
||||
const deploy = fs.readFileSync(path.join(root, 'scripts/deploy-gateway.sh'), 'utf8');
|
||||
const workflow = fs.readFileSync(path.join(root, '.gitea/workflows/gateway-build.yml'), 'utf8');
|
||||
const clientDockerfile = fs.readFileSync(path.join(root, 'Dockerfile.client'), 'utf8');
|
||||
const dockerfiles = ['Dockerfile', 'Dockerfile.client']
|
||||
.map((file) => fs.readFileSync(path.join(root, file), 'utf8'));
|
||||
|
||||
@@ -16,14 +17,24 @@ test('gateway deploy updates control without recreating dataplane', () => {
|
||||
assert.match(compose, /DATAPLANE_SOCKET: \/run\/vpn-proxy\/dataplane\.sock/);
|
||||
assert.match(deploy, /up -d --no-deps --wait[^\n]+vpn-proxy-control/);
|
||||
assert.match(workflow, /UPDATE_DATAPLANE="\$\{UPDATE_DATAPLANE\}"/);
|
||||
assert.match(workflow, /src\/server\/\(config\|dataplane\|gatewayRouting\|singbox\|singboxRuntime\|version\)/);
|
||||
assert.match(workflow, /src\/server\/\(adapters\/neighbors\|services\/\(connectivityDiagnosticsService\|deviceTrafficService\|devicePolicyService\)\)/);
|
||||
assert.match(workflow, /src\/shared\/\(connectivityDiagnostics\|errors\)/);
|
||||
assert.doesNotMatch(workflow, /dataplaneClient/);
|
||||
assert.match(workflow, /node scripts\/runtime-impact\.mjs --stdin/);
|
||||
assert.match(workflow, /Affected components: \$\{AFFECTED_COMPONENTS\}/);
|
||||
assert.match(workflow, /Restart scope: \$\{RESTART_SCOPE\}/);
|
||||
assert.match(workflow, /git diff --no-renames --name-only "\$BEFORE_SHA"/);
|
||||
assert.match(workflow, /git diff-tree --no-renames/);
|
||||
assert.match(workflow, /git cat-file -e "\$\{BEFORE_SHA\}\^\{commit\}"/);
|
||||
assert.match(workflow, /npm test[\s\S]*Image build and push skipped: no Gateway runtime impact\.[\s\S]*exit 0[\s\S]*docker login/);
|
||||
assert.match(workflow, /Image build and push skipped: no Gateway runtime impact\.[\s\S]*exit 0[\s\S]*\.\/scripts\/build-runtime-base\.sh/);
|
||||
assert.match(workflow, /Deploy skipped: no Gateway runtime impact\.[\s\S]*exit 0[\s\S]*bash scripts\/deploy-gateway\.sh/);
|
||||
assert.doesNotMatch(workflow, /grep -Eq/);
|
||||
});
|
||||
|
||||
test('runtime images include shared server modules', () => {
|
||||
test('runtime images contain only compiled application modules', () => {
|
||||
for (const dockerfile of dockerfiles) {
|
||||
assert.match(dockerfile, /COPY src\/shared \/app\/src\/shared/);
|
||||
assert.match(dockerfile, /RUN npm run build:production/);
|
||||
assert.match(dockerfile, /COPY --from=build \/src\/dist \/app\/dist/);
|
||||
assert.doesNotMatch(dockerfile, /\/app\/src/);
|
||||
}
|
||||
assert.match(clientDockerfile, /COPY index\.html vite\.config\.ts/);
|
||||
assert.doesNotMatch(clientDockerfile, /vite\.config\.js/);
|
||||
});
|
||||
|
||||
@@ -3,15 +3,16 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { parseNeighborSnapshot, readNeighborSnapshot } from '../../src/server/adapters/neighbors.js';
|
||||
import { parseNeighborSnapshot, readNeighborSnapshot } from '../../dist/server/adapters/neighbors.js';
|
||||
import {
|
||||
createDeviceInventoryService,
|
||||
createVendorLookup,
|
||||
DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
deviceId,
|
||||
migrateDeviceInventoryState,
|
||||
} from '../../src/server/services/deviceInventoryService.js';
|
||||
import { fingerprintDirectDevices } from '../../src/server/services/devicePolicyService.js';
|
||||
import { createJsonStore } from '../../src/server/services/stateStore.js';
|
||||
} from '../../dist/server/services/deviceInventoryService.js';
|
||||
import { fingerprintDirectDevices } from '../../dist/server/services/devicePolicyService.js';
|
||||
import { createJsonStore } from '../../dist/server/services/stateStore.js';
|
||||
|
||||
test('container neighbors are hidden while a real 172 LAN device remains valid', () => {
|
||||
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||
@@ -47,6 +48,73 @@ test('container neighbors are hidden while a real 172 LAN device remains valid',
|
||||
assert.deepEqual(migrated.devices.map(({ ip }) => ip), ['172.20.0.7']);
|
||||
});
|
||||
|
||||
test('malformed persisted devices and remote observations cannot enter canonical inventory', async (t) => {
|
||||
const observedAt = '2026-08-08T12:00:00.000Z';
|
||||
const mac = '00:11:22:33:44:55';
|
||||
const persisted = {
|
||||
id: 'legacy-device-id',
|
||||
alias: 42,
|
||||
pinned: 'yes',
|
||||
hostname: 42,
|
||||
manufacturer: [],
|
||||
mac,
|
||||
ip: '192.168.50.7',
|
||||
interface: 'eth0',
|
||||
firstSeenAt: observedAt,
|
||||
lastSeenAt: observedAt,
|
||||
source: null,
|
||||
confidence: 'untrusted',
|
||||
};
|
||||
const migrated = migrateDeviceInventoryState({
|
||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
devices: [
|
||||
persisted,
|
||||
{ ...persisted, mac: 'invalid' },
|
||||
{ ...persisted, mac: '00:11:22:33:44:66', ip: 'not-an-ip' },
|
||||
{ ...persisted, mac: '00:11:22:33:44:77', lastSeenAt: 'not-a-date' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(migrated.devices, [{
|
||||
...persisted,
|
||||
id: deviceId(mac),
|
||||
alias: '',
|
||||
pinned: false,
|
||||
hostname: null,
|
||||
manufacturer: null,
|
||||
source: 'neighbor',
|
||||
confidence: 'high',
|
||||
}]);
|
||||
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-guard-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
});
|
||||
store.remove();
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({
|
||||
observedAt,
|
||||
error: null,
|
||||
observations: [
|
||||
{ ip: '192.168.50.8', mac: '00:11:22:33:44:88', interface: 'eth0', observedAt, active: true },
|
||||
{ ip: 'bad', mac: '00:11:22:33:44:99', interface: 'eth0', observedAt, active: true },
|
||||
{ ip: '192.168.50.9', mac: 'invalid', interface: 'eth0', observedAt, active: true },
|
||||
{ ip: '192.168.50.10', mac: '00:11:22:33:44:aa', interface: 'docker0', observedAt, active: true },
|
||||
{ ip: '192.168.50.11', mac: '00:11:22:33:44:bb', interface: 'eth0', observedAt, active: 'yes' },
|
||||
{ ip: '192.168.50.12', mac: '00:11:22:33:44:cc', interface: 'eth0', observedAt: 'bad', active: true },
|
||||
],
|
||||
}),
|
||||
now: () => new Date(observedAt),
|
||||
});
|
||||
const snapshot = await service.refresh();
|
||||
assert.deepEqual(snapshot.devices.map(({ mac: deviceMac, ip }) => [deviceMac, ip]), [
|
||||
['00:11:22:33:44:88', '192.168.50.8'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('device inventory discovers, merges, persists metadata and expires anonymous devices', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
createDevicePolicyService,
|
||||
fingerprintDirectDevices,
|
||||
normalizeDirectDevices,
|
||||
} from '../../src/server/services/devicePolicyService.js';
|
||||
} from '../../dist/server/services/devicePolicyService.js';
|
||||
|
||||
const directDevice = {
|
||||
id: 'dev_0011223344556677',
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createDeviceInventoryRoute } from '../../dist/server/http/routes/deviceInventoryRoute.js';
|
||||
|
||||
const deviceId = 'dev_0123456789abcdef';
|
||||
|
||||
function response() {
|
||||
return {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(payload) {
|
||||
this.payload = JSON.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness({ inventory = {}, body = {} } = {}) {
|
||||
const calls = [];
|
||||
let bodyReads = 0;
|
||||
const deviceInventory = inventory === null ? null : {
|
||||
snapshot: () => {
|
||||
calls.push(['snapshot']);
|
||||
return inventory.snapshot ?? { revision: 1, devices: [] };
|
||||
},
|
||||
refresh: async () => {
|
||||
calls.push(['refresh']);
|
||||
return inventory.refresh ?? { revision: 2, devices: [] };
|
||||
},
|
||||
update: (...args) => {
|
||||
calls.push(['update', ...args]);
|
||||
return inventory.update ?? { revision: 3 };
|
||||
},
|
||||
setPolicy: async (...args) => {
|
||||
calls.push(['setPolicy', ...args]);
|
||||
return inventory.setPolicy ?? { revision: 4 };
|
||||
},
|
||||
};
|
||||
const route = createDeviceInventoryRoute({
|
||||
deviceInventory,
|
||||
readBody: async () => {
|
||||
bodyReads += 1;
|
||||
return body;
|
||||
},
|
||||
});
|
||||
return { route, calls, bodyReads: () => bodyReads };
|
||||
}
|
||||
|
||||
test('device route forwards list and refresh query paths as raw JSON responses', async () => {
|
||||
const harness = createHarness({
|
||||
inventory: {
|
||||
snapshot: { revision: 11, devices: [{ id: deviceId }] },
|
||||
refresh: { revision: 12, devices: [] },
|
||||
},
|
||||
});
|
||||
const listResponse = response();
|
||||
assert.equal(await harness.route.handle({
|
||||
method: 'GET',
|
||||
url: '/api/devices?source=ui',
|
||||
}, listResponse), true);
|
||||
assert.equal(listResponse.status, 200);
|
||||
assert.equal(listResponse.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.deepEqual(listResponse.payload, { revision: 11, devices: [{ id: deviceId }] });
|
||||
|
||||
const refreshResponse = response();
|
||||
assert.equal(await harness.route.handle({
|
||||
method: 'POST',
|
||||
url: '/api/devices/refresh?source=ui',
|
||||
}, refreshResponse), true);
|
||||
assert.deepEqual(refreshResponse.payload, { revision: 12, devices: [] });
|
||||
assert.deepEqual(harness.calls, [['snapshot'], ['refresh']]);
|
||||
});
|
||||
|
||||
test('device route forwards metadata patch and policy arguments without coercion', async () => {
|
||||
const patch = { expectedRevision: 7, alias: 'Desk', pinned: false, extra: 0 };
|
||||
const metadata = createHarness({ body: patch });
|
||||
const metadataResponse = response();
|
||||
assert.equal(await metadata.route.handle({
|
||||
method: 'PUT',
|
||||
url: `/api/devices/${deviceId}?source=ui`,
|
||||
}, metadataResponse), true);
|
||||
assert.deepEqual(metadata.calls, [[
|
||||
'update',
|
||||
deviceId,
|
||||
{ alias: 'Desk', pinned: false, extra: 0 },
|
||||
7,
|
||||
]]);
|
||||
assert.deepEqual(metadataResponse.payload, { revision: 3 });
|
||||
|
||||
const policy = createHarness({ body: { mode: 42, expectedRevision: '8', ignored: true } });
|
||||
const policyResponse = response();
|
||||
assert.equal(await policy.route.handle({
|
||||
method: 'PUT',
|
||||
url: `/api/devices/${deviceId}/policy?source=ui`,
|
||||
}, policyResponse), true);
|
||||
assert.deepEqual(policy.calls, [['setPolicy', deviceId, 42, '8']]);
|
||||
assert.deepEqual(policyResponse.payload, { revision: 4 });
|
||||
});
|
||||
|
||||
test('device route preserves endpoint gating and strict lowercase IDs', async () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/devices'],
|
||||
['GET', '/api/devices/refresh'],
|
||||
['GET', `/api/devices/${deviceId}`],
|
||||
['POST', `/api/devices/${deviceId}/policy`],
|
||||
]) {
|
||||
const harness = createHarness();
|
||||
await assert.rejects(
|
||||
harness.route.handle({ method, url }, response()),
|
||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||
);
|
||||
assert.deepEqual(harness.calls, []);
|
||||
assert.equal(harness.bodyReads(), 0);
|
||||
}
|
||||
|
||||
for (const url of [
|
||||
'/api/other',
|
||||
'/api/devices/dev_0123456789ABCDEF',
|
||||
'/api/devices/dev_short',
|
||||
]) {
|
||||
const harness = createHarness();
|
||||
assert.equal(await harness.route.handle({ method: 'PUT', url }, response()), false);
|
||||
assert.deepEqual(harness.calls, []);
|
||||
assert.equal(harness.bodyReads(), 0);
|
||||
}
|
||||
|
||||
for (const [method, url] of [
|
||||
['GET', '/api/devices'],
|
||||
['POST', '/api/devices/refresh'],
|
||||
['PUT', `/api/devices/${deviceId}`],
|
||||
['PUT', `/api/devices/${deviceId}/policy`],
|
||||
]) {
|
||||
const client = createHarness({ inventory: null });
|
||||
await assert.rejects(
|
||||
client.route.handle({ method, url }, response()),
|
||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||
);
|
||||
assert.equal(client.bodyReads(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('device route propagates synchronous and asynchronous service errors unchanged', async () => {
|
||||
const syncError = new Error('snapshot failed');
|
||||
const syncRoute = createDeviceInventoryRoute({
|
||||
deviceInventory: {
|
||||
snapshot: () => { throw syncError; },
|
||||
refresh: async () => ({}),
|
||||
update: () => ({}),
|
||||
setPolicy: async () => ({}),
|
||||
},
|
||||
readBody: async () => ({}),
|
||||
});
|
||||
await assert.rejects(
|
||||
syncRoute.handle({ method: 'GET', url: '/api/devices' }, response()),
|
||||
(error) => error === syncError,
|
||||
);
|
||||
|
||||
const asyncError = new Error('refresh failed');
|
||||
const asyncRoute = createDeviceInventoryRoute({
|
||||
deviceInventory: {
|
||||
snapshot: () => ({}),
|
||||
refresh: async () => { throw asyncError; },
|
||||
update: () => ({}),
|
||||
setPolicy: async () => ({}),
|
||||
},
|
||||
readBody: async () => ({}),
|
||||
});
|
||||
await assert.rejects(
|
||||
asyncRoute.handle({ method: 'POST', url: '/api/devices/refresh' }, response()),
|
||||
(error) => error === asyncError,
|
||||
);
|
||||
});
|
||||
|
||||
test('device route is the only HTTP owner while lifecycle stays in composition', () => {
|
||||
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
const route = readFileSync(
|
||||
new URL('../../src/server/http/routes/deviceInventoryRoute.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(index, /createDeviceInventoryRoute\(\{/);
|
||||
assert.match(index, /deviceInventoryRoute\.handle\(req, res\)/);
|
||||
assert.doesNotMatch(index, /\/api\/devices/);
|
||||
assert.doesNotMatch(index, /deviceInventory\.(?:snapshot|update|setPolicy)\(/);
|
||||
assert.match(index, /deviceInventory\.reconcilePolicies\(\)/);
|
||||
assert.match(index, /deviceInventory\.refresh\(\)/);
|
||||
assert.match(route, /DEVICE_PATH/);
|
||||
assert.match(route, /DEVICE_POLICY_PATH/);
|
||||
});
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
createDeviceTrafficService,
|
||||
parseTrafficCounters,
|
||||
selectTrafficDevices,
|
||||
} from '../../src/server/services/deviceTrafficService.js';
|
||||
} from '../../dist/server/services/deviceTrafficService.js';
|
||||
|
||||
const uploadChain = 'VPN_PROXY_TRAFFIC_UP';
|
||||
const downloadChain = 'VPN_PROXY_TRAFFIC_DOWN';
|
||||
const dataplaneSource = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '../../src/server/dataplane.js'),
|
||||
path.resolve(import.meta.dirname, '../../src/server/dataplane.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const observation = (ip, mac = '00:11:22:33:44:55', deviceInterface = 'eth0') => ({
|
||||
|
||||
@@ -3,8 +3,8 @@ import test from 'node:test';
|
||||
import {
|
||||
classifyDomain,
|
||||
createDomainTrafficService,
|
||||
} from '../../src/server/services/domainTrafficService.js';
|
||||
import { deviceId } from '../../src/server/services/deviceInventoryService.js';
|
||||
} from '../../dist/server/services/domainTrafficService.js';
|
||||
import { deviceId } from '../../dist/server/services/deviceInventoryService.js';
|
||||
|
||||
const mac = '00:11:22:33:44:55';
|
||||
const id = deviceId(mac);
|
||||
|
||||
@@ -31,7 +31,8 @@ test('gateway keeps direct forwarding active while TProxy interception is switch
|
||||
|
||||
test('control bypasses host routing while dataplane owns it', () => {
|
||||
assert.match(entrypoint, /APP_COMPONENT.*control/);
|
||||
assert.match(entrypoint, /exec node \/app\/src\/server\/index\.js/);
|
||||
assert.match(entrypoint, /APP_COMPONENT.*dataplane/);
|
||||
assert.match(entrypoint, /node \/app\/src\/server\/dataplane\.js/);
|
||||
assert.match(entrypoint, /exec node \/app\/dist\/server\/main\.js/);
|
||||
assert.match(entrypoint, /fi[\s\S]*setup_gateway_forwarding[\s\S]*node \/app\/dist\/server\/main\.js/);
|
||||
assert.match(entrypoint, /node \/app\/dist\/server\/main\.js/);
|
||||
assert.doesNotMatch(entrypoint, /\/app\/src/);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ERROR_DEFINITIONS,
|
||||
HarborError,
|
||||
normalizeHarborError,
|
||||
} from '../../src/shared/errors.js';
|
||||
} from '../../dist/shared/errors.js';
|
||||
|
||||
test('every Harbor error code has stable Russian copy and retry policy', () => {
|
||||
for (const [code, definition] of Object.entries(ERROR_DEFINITIONS)) {
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createGatewayAutoService,
|
||||
} from '../../dist/server/features/routing/index.js';
|
||||
import {
|
||||
applyGatewayPreference,
|
||||
createGatewayAutoState,
|
||||
nextGatewayAutoState,
|
||||
sameGatewayRoute,
|
||||
} from '../../dist/server/gatewayPresence.js';
|
||||
import { createGatewayAutoRoute } from '../../dist/server/http/routes/gatewayAutoRoute.js';
|
||||
|
||||
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
|
||||
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
|
||||
const networkA = {
|
||||
gateway: '192.168.50.111',
|
||||
interface: 'en0',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
observedAt: Date.now(),
|
||||
};
|
||||
const networkB = {
|
||||
gateway: '192.168.60.111',
|
||||
interface: 'en1',
|
||||
mac: '11:22:33:44:55:66',
|
||||
observedAt: Date.now(),
|
||||
};
|
||||
const verifiedA = {
|
||||
gatewayId: 'gateway-a',
|
||||
uiOrigin: 'http://192.168.50.111:3456',
|
||||
verifiedAt: '2026-08-08T12:00:00.000Z',
|
||||
};
|
||||
|
||||
function directState(network = networkA, verified = verifiedA) {
|
||||
return nextGatewayAutoState(createGatewayAutoState(), {
|
||||
network,
|
||||
verifiedGateway: verified,
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
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 config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
||||
let network = Object.hasOwn(overrides, 'network') ? overrides.network : networkA;
|
||||
let tail = Promise.resolve();
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
let stateUpdateIndex = 0;
|
||||
let timerCallback = null;
|
||||
let timerUnrefs = 0;
|
||||
let timerClears = 0;
|
||||
const events = [];
|
||||
const warnings = [];
|
||||
const failures = {
|
||||
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
|
||||
...overrides.failures,
|
||||
};
|
||||
|
||||
const serialize = (operation) => {
|
||||
const run = async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
try { return await operation(); } finally { active -= 1; }
|
||||
};
|
||||
const result = tail.then(run, run);
|
||||
tail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
};
|
||||
|
||||
const service = createGatewayAutoService({
|
||||
appMode: overrides.appMode || 'client',
|
||||
state: {
|
||||
read: () => structuredClone(state),
|
||||
update: (mutator) => {
|
||||
const failure = failures.stateUpdates[stateUpdateIndex++];
|
||||
if (failure instanceof Error) throw failure;
|
||||
state = {
|
||||
...structuredClone(mutator(structuredClone(state))),
|
||||
revision: state.revision + 1,
|
||||
};
|
||||
events.push('state.update');
|
||||
if (failure?.after) throw failure.after;
|
||||
return structuredClone(state);
|
||||
},
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
|
||||
},
|
||||
config: {
|
||||
build: (_subscription, selectedServerId, routeRules, gatewayAuto) => ({
|
||||
selectedServerId,
|
||||
routeRules,
|
||||
clientDirect: gatewayAuto.mode === 'gateway-direct',
|
||||
}),
|
||||
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;
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
isRunning: () => overrides.running ?? true,
|
||||
applyCommand: async () => {
|
||||
events.push('runtime.apply');
|
||||
return typeof overrides.commandResult === 'function'
|
||||
? overrides.commandResult()
|
||||
: overrides.commandResult || { ok: true, mutationStarted: true };
|
||||
},
|
||||
restoreRunning: async () => {
|
||||
events.push('runtime.restore');
|
||||
if (failures.runtimeRestore) throw failures.runtimeRestore;
|
||||
},
|
||||
},
|
||||
discovery: {
|
||||
readHostNetwork: () => structuredClone(network),
|
||||
probeGateway: async (input) => {
|
||||
events.push('discovery.probe');
|
||||
if (overrides.probe) return overrides.probe(input);
|
||||
return verifiedA;
|
||||
},
|
||||
},
|
||||
transition: {
|
||||
createInitial: createGatewayAutoState,
|
||||
applyPreference: applyGatewayPreference,
|
||||
next: nextGatewayAutoState,
|
||||
sameRoute: sameGatewayRoute,
|
||||
},
|
||||
serialize,
|
||||
scheduler: {
|
||||
setInterval: (callback, intervalMs) => {
|
||||
events.push(`timer.set:${intervalMs}`);
|
||||
timerCallback = callback;
|
||||
return { unref: () => { timerUnrefs += 1; } };
|
||||
},
|
||||
clearInterval: () => {
|
||||
events.push('timer.clear');
|
||||
timerClears += 1;
|
||||
timerCallback = null;
|
||||
},
|
||||
},
|
||||
onRouteChange: (gatewayAuto) => events.push(`route:${gatewayAuto.mode}`),
|
||||
onDiscoveryWarning: (reason) => warnings.push(reason),
|
||||
onTimerError: (error) => warnings.push(error.message),
|
||||
});
|
||||
if (overrides.gatewayAuto) service.set(structuredClone(overrides.gatewayAuto));
|
||||
|
||||
return {
|
||||
service,
|
||||
events,
|
||||
warnings,
|
||||
enqueue: serialize,
|
||||
setNetwork: (value) => { network = value; },
|
||||
setState: (value) => { state = structuredClone(value); },
|
||||
patchState: (value) => { state = { ...state, ...structuredClone(value) }; },
|
||||
fireTimer: () => timerCallback?.(),
|
||||
snapshot: () => structuredClone({
|
||||
state,
|
||||
gatewayAuto: service.read(),
|
||||
config,
|
||||
peak,
|
||||
timerUnrefs,
|
||||
timerClears,
|
||||
timerActive: Boolean(timerCallback),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function withoutRevision(value) {
|
||||
const copy = structuredClone(value);
|
||||
delete copy.state.revision;
|
||||
delete copy.peak;
|
||||
return copy;
|
||||
}
|
||||
|
||||
test('gateway-auto handles no-op and metadata-only discovery without config/runtime mutation', async () => {
|
||||
const noOp = createHarness({ appMode: 'gateway' });
|
||||
const before = noOp.snapshot();
|
||||
await noOp.service.refresh();
|
||||
assert.deepEqual(noOp.snapshot(), before);
|
||||
assert.deepEqual(noOp.events, []);
|
||||
|
||||
const metadata = createHarness({ probe: async () => { throw new Error('offline'); } });
|
||||
await metadata.service.refresh();
|
||||
assert.equal(metadata.snapshot().gatewayAuto.mode, 'local-vpn');
|
||||
assert.equal(metadata.snapshot().gatewayAuto.failures, 1);
|
||||
assert.deepEqual(metadata.events, ['discovery.probe', 'state.update']);
|
||||
assert.deepEqual(metadata.warnings, ['offline']);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
await stateOnly.service.setEnabled(false);
|
||||
assert.equal(stateOnly.snapshot().gatewayAuto.mode, 'local-vpn');
|
||||
assert.deepEqual(stateOnly.events, ['state.update', 'state.update', 'route:local-vpn']);
|
||||
|
||||
const stopped = createHarness({ gatewayAuto: directState(), running: false });
|
||||
await stopped.service.setEnabled(false);
|
||||
assert.equal(stopped.events.includes('config.write'), true);
|
||||
assert.equal(stopped.events.includes('runtime.apply'), false);
|
||||
|
||||
const running = createHarness({ gatewayAuto: directState(), running: true });
|
||||
await running.service.setEnabled(false);
|
||||
assert.deepEqual(running.events.slice(0, 4), [
|
||||
'config.write',
|
||||
'runtime.apply',
|
||||
'state.update',
|
||||
'state.update',
|
||||
]);
|
||||
});
|
||||
|
||||
test('gateway-auto startup writes candidate config before publication without applying runtime', async () => {
|
||||
const harness = createHarness();
|
||||
await harness.service.refresh({ reconfigure: false });
|
||||
assert.equal(harness.snapshot().gatewayAuto.mode, 'gateway-direct');
|
||||
assert.deepEqual(harness.events, [
|
||||
'discovery.probe',
|
||||
'config.write',
|
||||
'state.update',
|
||||
'route:gateway-direct',
|
||||
]);
|
||||
});
|
||||
|
||||
test('disable and re-enable preserve verified Gateway identity and persisted preference commits', async () => {
|
||||
const harness = createHarness({ gatewayAuto: directState() });
|
||||
await harness.service.setEnabled(false);
|
||||
const disabled = harness.snapshot();
|
||||
assert.equal(disabled.gatewayAuto.mode, 'local-vpn');
|
||||
assert.equal(disabled.gatewayAuto.gatewayId, verifiedA.gatewayId);
|
||||
assert.equal(disabled.state.gatewayAutoEnabled, false);
|
||||
await harness.service.setEnabled(true);
|
||||
const enabled = harness.snapshot();
|
||||
assert.equal(enabled.gatewayAuto.mode, 'gateway-direct');
|
||||
assert.equal(enabled.gatewayAuto.gatewayId, verifiedA.gatewayId);
|
||||
assert.equal(enabled.state.gatewayAutoEnabled, true);
|
||||
|
||||
const revision = enabled.state.revision;
|
||||
await harness.service.setEnabled(true);
|
||||
assert.equal(harness.snapshot().state.revision, revision + 1);
|
||||
});
|
||||
|
||||
test('gateway-auto rollback restores config, transient owner, runtime and domain state', async () => {
|
||||
const original = new Error('state failed');
|
||||
const harness = createHarness({
|
||||
gatewayAuto: directState(),
|
||||
failures: { stateUpdates: [{ after: original }] },
|
||||
});
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.setEnabled(false), (error) => error === original);
|
||||
const after = harness.snapshot();
|
||||
assert.deepEqual(withoutRevision(after), withoutRevision(before));
|
||||
assert.ok(after.state.revision >= before.state.revision);
|
||||
assert.deepEqual(harness.events, [
|
||||
'config.write',
|
||||
'runtime.apply',
|
||||
'state.update',
|
||||
'config.restore',
|
||||
'runtime.restore',
|
||||
'state.update',
|
||||
]);
|
||||
});
|
||||
|
||||
test('gateway-auto rollback honors explicit runtime mutation phase and write-after-failure', async () => {
|
||||
const configFailure = new Error('config write after');
|
||||
const config = createHarness({
|
||||
gatewayAuto: directState(),
|
||||
failures: { configWriteAfter: configFailure },
|
||||
});
|
||||
await assert.rejects(config.service.setEnabled(false), (error) => error === configFailure);
|
||||
assert.equal(config.events.includes('config.restore'), true);
|
||||
assert.equal(config.events.includes('runtime.apply'), false);
|
||||
|
||||
const preMutationError = new Error('invalid config');
|
||||
const local = createHarness({
|
||||
gatewayAuto: directState(),
|
||||
commandResult: { ok: false, mutationStarted: false, error: preMutationError },
|
||||
});
|
||||
await assert.rejects(local.service.setEnabled(false), (error) => error === preMutationError);
|
||||
assert.equal(local.events.includes('runtime.restore'), false);
|
||||
|
||||
const postMutationError = new Error('remote failed');
|
||||
const remote = createHarness({
|
||||
gatewayAuto: directState(),
|
||||
commandResult: { ok: false, mutationStarted: true, error: postMutationError },
|
||||
});
|
||||
await assert.rejects(remote.service.setEnabled(false), (error) => error === postMutationError);
|
||||
assert.equal(remote.events.includes('runtime.restore'), true);
|
||||
});
|
||||
|
||||
test('gateway-auto rollback continues after restore failures and classifies runtime restore', async () => {
|
||||
const original = new Error('state failed');
|
||||
const configRestore = new Error('config restore failed');
|
||||
const aggregate = createHarness({
|
||||
gatewayAuto: directState(),
|
||||
failures: { stateUpdates: [original], configRestore },
|
||||
});
|
||||
await assert.rejects(aggregate.service.setEnabled(false), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.equal(error.message, 'Gateway auto rollback failed');
|
||||
assert.deepEqual(error.errors, [original, configRestore]);
|
||||
return true;
|
||||
});
|
||||
assert.equal(aggregate.events.includes('runtime.restore'), true);
|
||||
assert.ok(aggregate.events.filter((event) => event === 'state.update').length >= 1);
|
||||
|
||||
const runtimeRestore = new Error('runtime restore failed');
|
||||
const brokenRuntime = createHarness({
|
||||
gatewayAuto: directState(),
|
||||
failures: { stateUpdates: [original], runtimeRestore },
|
||||
});
|
||||
await assert.rejects(brokenRuntime.service.setEnabled(false), (error) => {
|
||||
assert.equal(error.code, 'PROCESS_START_FAILED');
|
||||
assert.deepEqual(error.cause.errors, [original, runtimeRestore]);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('gateway-auto refresh coalesces while queued and never overlaps the shared serializer', async () => {
|
||||
const blocker = deferred();
|
||||
const probe = deferred();
|
||||
const harness = createHarness({ probe: () => probe.promise });
|
||||
const queued = harness.enqueue(() => blocker.promise);
|
||||
const first = harness.service.refresh();
|
||||
const second = harness.service.refresh();
|
||||
assert.strictEqual(first, second);
|
||||
blocker.resolve();
|
||||
await queued;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
probe.resolve(verifiedA);
|
||||
await first;
|
||||
assert.equal(harness.snapshot().peak, 1);
|
||||
});
|
||||
|
||||
test('gateway-auto discards stale subscription and route probe results', async () => {
|
||||
const gate = deferred();
|
||||
const staleSubscription = createHarness({ probe: () => gate.promise });
|
||||
staleSubscription.service.set(directState());
|
||||
const refresh = staleSubscription.service.refresh();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
staleSubscription.patchState({ subscriptionUrl: `${subscriptionUrl}-new` });
|
||||
gate.resolve(verifiedA);
|
||||
await refresh;
|
||||
assert.deepEqual(staleSubscription.snapshot().gatewayAuto, createGatewayAutoState());
|
||||
|
||||
const routeGate = deferred();
|
||||
const staleRoute = createHarness({ probe: () => routeGate.promise });
|
||||
const routeRefresh = staleRoute.service.refresh();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
staleRoute.setNetwork(networkB);
|
||||
routeGate.resolve(verifiedA);
|
||||
await routeRefresh;
|
||||
assert.deepEqual(staleRoute.snapshot().gatewayAuto, createGatewayAutoState());
|
||||
});
|
||||
|
||||
test('gateway-auto demotes a changed route before probing and retains verified identity on transient failure', async () => {
|
||||
let routeHarness;
|
||||
routeHarness = createHarness({
|
||||
gatewayAuto: directState(networkA),
|
||||
network: networkB,
|
||||
probe: async () => {
|
||||
assert.equal(routeHarness.service.read().mode, 'local-vpn');
|
||||
assert.equal(routeHarness.service.read().gatewayId, '');
|
||||
return { ...verifiedA, gatewayId: 'gateway-b', uiOrigin: 'http://192.168.60.111:3456' };
|
||||
},
|
||||
});
|
||||
await routeHarness.service.refresh();
|
||||
assert.equal(routeHarness.snapshot().gatewayAuto.gatewayId, 'gateway-b');
|
||||
assert.deepEqual(
|
||||
routeHarness.events.filter((event) => event.startsWith('route:')),
|
||||
['route:local-vpn', 'route:gateway-direct'],
|
||||
);
|
||||
|
||||
const transient = createHarness({
|
||||
gatewayAuto: directState(),
|
||||
probe: async () => { throw new Error('timeout'); },
|
||||
});
|
||||
await transient.service.refresh();
|
||||
const state = transient.snapshot().gatewayAuto;
|
||||
assert.equal(state.mode, 'gateway-direct');
|
||||
assert.equal(state.gatewayId, verifiedA.gatewayId);
|
||||
assert.equal(state.failures, 1);
|
||||
assert.equal(state.lastError, 'timeout');
|
||||
|
||||
const structural = createHarness({
|
||||
probe: async () => { throw { message: 'cross-realm failure' }; },
|
||||
});
|
||||
await structural.service.refresh();
|
||||
assert.equal(structural.snapshot().gatewayAuto.lastError, 'cross-realm failure');
|
||||
assert.deepEqual(structural.warnings, ['cross-realm failure']);
|
||||
});
|
||||
|
||||
test('gateway-auto discovery timer is single, unrefed, reports errors and stops idempotently', async () => {
|
||||
const harness = createHarness({ probe: async () => { throw new Error('timer failure'); } });
|
||||
harness.service.startDiscovery(5_000);
|
||||
harness.service.startDiscovery(9_000);
|
||||
assert.deepEqual(harness.events, ['timer.set:5000']);
|
||||
assert.equal(harness.snapshot().timerUnrefs, 1);
|
||||
harness.fireTimer();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(harness.warnings, ['timer failure']);
|
||||
harness.service.stopDiscovery();
|
||||
harness.service.stopDiscovery();
|
||||
assert.equal(harness.snapshot().timerClears, 1);
|
||||
assert.equal(harness.snapshot().timerActive, false);
|
||||
});
|
||||
|
||||
test('gateway-auto route preserves validation, operation and exact response envelope', async () => {
|
||||
const calls = [];
|
||||
let body = { enabled: false };
|
||||
const route = createGatewayAutoRoute({
|
||||
appMode: 'client',
|
||||
gatewayAuto: { setEnabled: async (enabled) => { calls.push(['enabled', enabled]); } },
|
||||
readBody: async () => body,
|
||||
withOperation: async (kind, operation) => {
|
||||
calls.push(['operation', kind]);
|
||||
return operation();
|
||||
},
|
||||
readStatePayload: async () => ({ gatewayAuto: { mode: 'local-vpn' }, revision: 7 }),
|
||||
});
|
||||
const response = {
|
||||
writeHead: (status, headers) => { response.status = status; response.headers = headers; },
|
||||
end: (payload) => { response.payload = JSON.parse(payload); },
|
||||
};
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/gateway-auto' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/gateway-auto' }, response), true);
|
||||
assert.deepEqual(calls, [['operation', 'gateway-auto'], ['enabled', false]]);
|
||||
assert.deepEqual(response.payload, {
|
||||
success: true,
|
||||
gatewayAuto: { mode: 'local-vpn' },
|
||||
state: { gatewayAuto: { mode: 'local-vpn' }, revision: 7 },
|
||||
});
|
||||
|
||||
body = { enabled: 'false' };
|
||||
await assert.rejects(
|
||||
route.handle({ method: 'POST', url: '/api/gateway-auto' }, response),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
const gatewayRoute = createGatewayAutoRoute({
|
||||
appMode: 'gateway',
|
||||
gatewayAuto: { setEnabled: async () => {} },
|
||||
readBody: async () => ({ enabled: true }),
|
||||
withOperation: async (_kind, operation) => operation(),
|
||||
readStatePayload: async () => ({}),
|
||||
});
|
||||
await assert.rejects(
|
||||
gatewayRoute.handle({ method: 'POST', url: '/api/gateway-auto' }, response),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
});
|
||||
|
||||
test('gateway-auto route and service are the only displaced owners', () => {
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createGatewayAutoService\(\{/);
|
||||
assert.match(source, /createGatewayAutoRoute\(\{/);
|
||||
assert.doesNotMatch(source, /let gatewayAutoState/);
|
||||
assert.doesNotMatch(source, /gatewayDiscoveryPromise/);
|
||||
assert.doesNotMatch(source, /applyGatewayAutoState/);
|
||||
assert.doesNotMatch(source, /refreshGatewayAutoMode/);
|
||||
assert.doesNotMatch(source, /req\.url\s*===\s*['"]\/api\/gateway-auto['"]/);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createGatewayPresenceRoute } from '../../dist/server/http/routes/gatewayPresenceRoute.js';
|
||||
|
||||
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
|
||||
const nonce = '0123456789abcdef0123456789abcdef';
|
||||
|
||||
function response() {
|
||||
return {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(payload) {
|
||||
this.payload = JSON.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
const events = [];
|
||||
const route = createGatewayPresenceRoute({
|
||||
appMode: overrides.appMode || 'gateway',
|
||||
readState: () => {
|
||||
events.push('state');
|
||||
if (overrides.stateError) throw overrides.stateError;
|
||||
return { subscriptionUrl: overrides.subscriptionUrl ?? subscriptionUrl };
|
||||
},
|
||||
getHwid: () => {
|
||||
events.push('hwid');
|
||||
if (overrides.hwidError) throw overrides.hwidError;
|
||||
return overrides.gatewayId ?? 'gateway-1';
|
||||
},
|
||||
});
|
||||
return { route, events };
|
||||
}
|
||||
|
||||
test('Gateway presence route preserves query nonce, read order and raw response', async () => {
|
||||
const harness = createHarness();
|
||||
const res = response();
|
||||
assert.equal(await harness.route.handle({
|
||||
method: 'GET',
|
||||
url: `/api/gateway-presence?source=client&nonce=${nonce}&nonce=ignored`,
|
||||
}, res), true);
|
||||
assert.deepEqual(harness.events, ['state', 'hwid']);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.deepEqual({
|
||||
...res.payload,
|
||||
proof: '[proof]',
|
||||
}, {
|
||||
success: true,
|
||||
available: true,
|
||||
product: 'harbor',
|
||||
role: 'gateway',
|
||||
protocolVersion: 1,
|
||||
gatewayId: 'gateway-1',
|
||||
transparentRouting: true,
|
||||
proof: '[proof]',
|
||||
});
|
||||
assert.match(res.payload.proof, /^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test('Gateway presence route preserves unavailable client and missing identity responses', async () => {
|
||||
for (const options of [
|
||||
{ appMode: 'client' },
|
||||
{ gatewayId: '' },
|
||||
{ subscriptionUrl: 'https://subscription.example/public-feed' },
|
||||
]) {
|
||||
const harness = createHarness(options);
|
||||
const res = response();
|
||||
await harness.route.handle({
|
||||
method: 'GET',
|
||||
url: `/api/gateway-presence?nonce=${nonce}`,
|
||||
}, res);
|
||||
assert.deepEqual(res.payload, {
|
||||
success: true,
|
||||
available: false,
|
||||
product: 'harbor',
|
||||
role: options.appMode || 'gateway',
|
||||
protocolVersion: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Gateway presence route reads state and HWID before preserving nonce validation errors', async () => {
|
||||
for (const value of [null, 'ABCDEF0123456789ABCDEF0123456789', 'short']) {
|
||||
const harness = createHarness();
|
||||
const url = value === null
|
||||
? '/api/gateway-presence'
|
||||
: `/api/gateway-presence?nonce=${value}`;
|
||||
await assert.rejects(
|
||||
harness.route.handle({ method: 'GET', url }, response()),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
assert.deepEqual(harness.events, ['state', 'hwid']);
|
||||
}
|
||||
});
|
||||
|
||||
test('Gateway presence route guards before dependencies and propagates dependency errors', async () => {
|
||||
for (const [method, url] of [
|
||||
['POST', `/api/gateway-presence?nonce=${nonce}`],
|
||||
['GET', `/api/gateway-presence/extra?nonce=${nonce}`],
|
||||
['GET', `/api/other?nonce=${nonce}`],
|
||||
]) {
|
||||
const harness = createHarness();
|
||||
assert.equal(await harness.route.handle({ method, url }, response()), false);
|
||||
assert.deepEqual(harness.events, []);
|
||||
}
|
||||
|
||||
const stateError = new Error('state failed');
|
||||
const brokenState = createHarness({ stateError });
|
||||
await assert.rejects(
|
||||
brokenState.route.handle({ method: 'GET', url: `/api/gateway-presence?nonce=${nonce}` }, response()),
|
||||
(error) => error === stateError,
|
||||
);
|
||||
assert.deepEqual(brokenState.events, ['state']);
|
||||
|
||||
const hwidError = new Error('hwid failed');
|
||||
const brokenHwid = createHarness({ hwidError });
|
||||
await assert.rejects(
|
||||
brokenHwid.route.handle({ method: 'GET', url: `/api/gateway-presence?nonce=${nonce}` }, response()),
|
||||
(error) => error === hwidError,
|
||||
);
|
||||
assert.deepEqual(brokenHwid.events, ['state', 'hwid']);
|
||||
});
|
||||
|
||||
test('Gateway presence route is the sole HTTP owner', () => {
|
||||
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
const route = readFileSync(
|
||||
new URL('../../src/server/http/routes/gatewayPresenceRoute.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(index, /createGatewayPresenceRoute\(\{/);
|
||||
assert.match(index, /gatewayPresenceRoute\.handle\(req, res\)/);
|
||||
assert.doesNotMatch(index, /\/api\/gateway-presence|buildGatewayPresence|searchParams\.get\(['"]nonce/);
|
||||
assert.match(route, /buildGatewayPresence\(\{/);
|
||||
assert.match(route, /searchParams\.get\(['"]nonce['"]\)/);
|
||||
});
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
probeGatewayPresence,
|
||||
readHostNetworkState,
|
||||
verifyGatewayPresence,
|
||||
} from '../../src/server/gatewayPresence.js';
|
||||
import { createStateSnapshot } from '../../src/shared/contracts/state.js';
|
||||
} from '../../dist/server/gatewayPresence.js';
|
||||
import { createStateSnapshot } from '../../dist/shared/contracts/state.js';
|
||||
|
||||
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
|
||||
const nonce = '0123456789abcdef0123456789abcdef';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setGatewayInterception } from '../../src/server/gatewayRouting.js';
|
||||
import { setGatewayInterception } from '../../dist/server/gatewayRouting.js';
|
||||
|
||||
test('gateway switches only the TProxy PREROUTING jump', () => {
|
||||
const calls = [];
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
renderPrometheusMetrics,
|
||||
sendPrometheusMetrics,
|
||||
} from '../../src/server/prometheusMetrics.js';
|
||||
} from '../../dist/server/prometheusMetrics.js';
|
||||
import { createPrometheusMetricsRoute } from '../../dist/server/http/routes/prometheusMetricsRoute.js';
|
||||
|
||||
const observedAt = '2026-08-08T10:00:00.000Z';
|
||||
const snapshot = {
|
||||
@@ -98,3 +100,97 @@ test('invalid canonical counters fail the scrape instead of publishing corrupt v
|
||||
/Invalid Prometheus counter/,
|
||||
);
|
||||
});
|
||||
|
||||
function routeResponse() {
|
||||
return {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Prometheus route reads one current snapshot and preserves the exact text contract', async () => {
|
||||
let reads = 0;
|
||||
let refreshes = 0;
|
||||
const route = createPrometheusMetricsRoute({
|
||||
deviceInventory: {
|
||||
metricsSnapshot: () => {
|
||||
reads += 1;
|
||||
return snapshot;
|
||||
},
|
||||
refresh: () => { refreshes += 1; },
|
||||
},
|
||||
});
|
||||
const res = routeResponse();
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/metrics?source=prometheus' }, res), true);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(refreshes, 0);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-type'], 'text/plain; version=0.0.4; charset=utf-8');
|
||||
assert.equal(res.body, renderPrometheusMetrics(snapshot));
|
||||
assert.equal(res.body.endsWith('\n'), true);
|
||||
});
|
||||
|
||||
test('Prometheus route preserves path, method and nullable inventory gating', async () => {
|
||||
let reads = 0;
|
||||
const route = createPrometheusMetricsRoute({
|
||||
deviceInventory: { metricsSnapshot: () => { reads += 1; return snapshot; } },
|
||||
});
|
||||
for (const url of ['/metrics/', '/api/metrics', '/']) {
|
||||
assert.equal(await route.handle({ method: 'GET', url }, routeResponse()), false);
|
||||
}
|
||||
await assert.rejects(
|
||||
route.handle({ method: 'POST', url: '/metrics' }, routeResponse()),
|
||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||
);
|
||||
assert.equal(reads, 0);
|
||||
|
||||
const clientRoute = createPrometheusMetricsRoute({ deviceInventory: null });
|
||||
await assert.rejects(
|
||||
clientRoute.handle({ method: 'GET', url: '/metrics' }, routeResponse()),
|
||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
|
||||
test('Prometheus route propagates snapshot and renderer failures unchanged', async () => {
|
||||
const snapshotError = new Error('snapshot failed');
|
||||
const snapshotRoute = createPrometheusMetricsRoute({
|
||||
deviceInventory: { metricsSnapshot: () => { throw snapshotError; } },
|
||||
});
|
||||
await assert.rejects(
|
||||
snapshotRoute.handle({ method: 'GET', url: '/metrics' }, routeResponse()),
|
||||
(error) => error === snapshotError,
|
||||
);
|
||||
|
||||
const rendererRoute = createPrometheusMetricsRoute({
|
||||
deviceInventory: {
|
||||
metricsSnapshot: () => ({ traffic: { gatewayBytes: 'broken', proxyBytes: '0' } }),
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
rendererRoute.handle({ method: 'GET', url: '/metrics' }, routeResponse()),
|
||||
/Invalid Prometheus counter/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Prometheus route is the sole HTTP owner before API and static fallback', () => {
|
||||
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
const route = readFileSync(
|
||||
new URL('../../src/server/http/routes/prometheusMetricsRoute.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(index, /createPrometheusMetricsRoute\(\{ deviceInventory \}\)/);
|
||||
const delegation = index.indexOf('prometheusMetricsRoute.handle(req, res)');
|
||||
const apiDispatch = index.indexOf("requestUrl.pathname.startsWith('/api/')");
|
||||
const staticFallback = index.indexOf(': serveStatic(req, res)');
|
||||
assert.ok(delegation >= 0 && delegation < apiDispatch && apiDispatch < staticFallback);
|
||||
assert.doesNotMatch(index, /['"]\/metrics['"]/);
|
||||
assert.doesNotMatch(index, /metricsSnapshot\(\)|sendPrometheusMetrics/);
|
||||
assert.match(route, /pathname !== '\/metrics'/);
|
||||
assert.match(route, /deviceInventory\.metricsSnapshot\(\)/);
|
||||
assert.doesNotMatch(route, /\.refresh\(/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { finishRollback } from '../../dist/server/services/rollback.js';
|
||||
|
||||
test('shared rollback rethrows the original error after successful ordered restores', async () => {
|
||||
const original = new Error('original');
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
finishRollback(original, [
|
||||
{ run: () => { events.push('first'); } },
|
||||
{ run: async () => { events.push('second'); } },
|
||||
], 'Rollback failed'),
|
||||
(error) => error === original,
|
||||
);
|
||||
assert.deepEqual(events, ['first', 'second']);
|
||||
});
|
||||
|
||||
test('shared rollback continues, preserves aggregate order and message', async () => {
|
||||
const original = new Error('original');
|
||||
const first = new Error('first restore');
|
||||
const second = new Error('second restore');
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
finishRollback(original, [
|
||||
{ run: () => { events.push('first'); throw first; } },
|
||||
{ run: () => { events.push('middle'); } },
|
||||
{ run: () => { events.push('second'); throw second; } },
|
||||
], 'Expected rollback message'),
|
||||
(error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.equal(error.message, 'Expected rollback message');
|
||||
assert.deepEqual(error.errors, [original, first, second]);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(events, ['first', 'middle', 'second']);
|
||||
});
|
||||
|
||||
test('shared rollback maps a runtime restore failure to PROCESS_START_FAILED', async () => {
|
||||
const original = new Error('original');
|
||||
const config = new Error('config restore');
|
||||
const runtime = new Error('runtime restore');
|
||||
await assert.rejects(
|
||||
finishRollback(original, [
|
||||
{ run: () => { throw config; } },
|
||||
{ run: () => { throw runtime; }, runtime: true },
|
||||
], 'Runtime rollback failed'),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'PROCESS_START_FAILED');
|
||||
assert.equal(error.cause.message, 'Runtime rollback failed');
|
||||
assert.deepEqual(error.cause.errors, [original, config, runtime]);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRouteRulesService } from '../../dist/server/features/routing/index.js';
|
||||
import { createRouteRulesRoute } from '../../dist/server/http/routes/routeRulesRoute.js';
|
||||
|
||||
const oldRules = [{ type: 'domain_suffix', value: 'old.example', enabled: true }];
|
||||
const newRules = [{ type: 'domain_suffix', value: 'new.example', enabled: true }];
|
||||
const server = { id: 'server', label: 'Server', host: 'server.example', port: 443, protocol: 'vless' };
|
||||
|
||||
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 config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
||||
const failures = {
|
||||
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
|
||||
...overrides.failures,
|
||||
};
|
||||
let stateUpdateIndex = 0;
|
||||
let tail = Promise.resolve();
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const events = [];
|
||||
|
||||
const serialize = (operation) => {
|
||||
const run = async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
try { return await operation(); } finally { active -= 1; }
|
||||
};
|
||||
const result = tail.then(run, run);
|
||||
tail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
};
|
||||
|
||||
const service = createRouteRulesService({
|
||||
state: {
|
||||
read: () => structuredClone(state),
|
||||
update: (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('state.update');
|
||||
if (failure?.after) throw failure.after;
|
||||
return structuredClone(state);
|
||||
},
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
|
||||
},
|
||||
config: {
|
||||
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;
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
isRunning: async () => overrides.running ?? true,
|
||||
applyCommand: async () => {
|
||||
events.push('runtime.apply');
|
||||
return overrides.commandResult || { ok: true, mutationStarted: true };
|
||||
},
|
||||
restoreRunning: async () => {
|
||||
events.push('runtime.restore');
|
||||
if (failures.runtimeRestore) throw failures.runtimeRestore;
|
||||
},
|
||||
},
|
||||
serialize,
|
||||
runOperation: async (operation) => {
|
||||
events.push('operation');
|
||||
return operation();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
service,
|
||||
events,
|
||||
snapshot: () => structuredClone({ state, config, 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);
|
||||
}
|
||||
|
||||
test('route rules validate strictly, conflict before no-op, and preserve no-op revisions', async () => {
|
||||
const harness = createHarness();
|
||||
assert.throws(() => harness.service.update('bad', 2, undefined), (error) => error.code === 'REQUEST_INVALID');
|
||||
assert.throws(() => harness.service.update(newRules, -1, undefined), (error) => error.code === 'REQUEST_INVALID');
|
||||
await assert.rejects(harness.service.update(oldRules, 1, undefined), (error) => error.code === 'STATE_CONFLICT');
|
||||
const before = harness.snapshot();
|
||||
await harness.service.update(oldRules, 2, undefined);
|
||||
assert.deepEqual(harness.snapshot(), before);
|
||||
assert.deepEqual(harness.events, []);
|
||||
});
|
||||
|
||||
test('route rules support explicit domain revision and legacy global revision with normalization', async () => {
|
||||
const explicit = createHarness();
|
||||
await explicit.service.update([
|
||||
{ type: 'domain_suffix', value: 'NEW.EXAMPLE', enabled: true },
|
||||
{ type: 'domain_suffix', value: 'new.example', enabled: true },
|
||||
], 2, undefined);
|
||||
assert.deepEqual(explicit.snapshot().state.routeRules, newRules);
|
||||
assert.equal(explicit.snapshot().state.routeRulesRevision, 3);
|
||||
|
||||
const legacy = createHarness();
|
||||
await legacy.service.update(newRules, undefined, 10);
|
||||
assert.deepEqual(legacy.snapshot().state.routeRules, newRules);
|
||||
});
|
||||
|
||||
test('route rules state-only path leaves config and applied rules unchanged', async () => {
|
||||
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 },
|
||||
]) {
|
||||
const harness = createHarness({ state, missingSubscription: Boolean(state.selectedServerId) });
|
||||
await harness.service.update(newRules, 0, undefined);
|
||||
assert.deepEqual(harness.snapshot().state.routeRules, newRules);
|
||||
assert.deepEqual(harness.snapshot().state.appliedRouteRules, oldRules);
|
||||
assert.equal(harness.snapshot().config, 'old-config');
|
||||
assert.deepEqual(harness.events, ['operation', 'state.update']);
|
||||
}
|
||||
});
|
||||
|
||||
test('route rules running apply updates active rules while stopped leaves them pending', async () => {
|
||||
const running = createHarness({ running: true });
|
||||
await running.service.update(newRules, 2, undefined);
|
||||
assert.deepEqual(running.snapshot().state.appliedRouteRules, newRules);
|
||||
assert.deepEqual(running.events, ['operation', 'config.write', 'runtime.apply', 'state.update']);
|
||||
|
||||
const stopped = createHarness({ running: false });
|
||||
await stopped.service.update(newRules, 2, undefined);
|
||||
assert.deepEqual(stopped.snapshot().state.appliedRouteRules, oldRules);
|
||||
assert.equal(stopped.events.includes('runtime.apply'), false);
|
||||
});
|
||||
|
||||
test('route rules rollback restores config/domain and honors runtime mutation phase', async () => {
|
||||
for (const failures of [
|
||||
{ configWriteAfter: new Error('config') },
|
||||
{ stateUpdates: [{ after: new Error('state') }] },
|
||||
]) {
|
||||
const harness = createHarness({ failures });
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.update(newRules, 2, undefined));
|
||||
assertDomainRestored(harness.snapshot(), before);
|
||||
}
|
||||
|
||||
const preMutation = new Error('invalid config');
|
||||
const local = createHarness({ commandResult: { ok: false, mutationStarted: false, error: preMutation } });
|
||||
await assert.rejects(local.service.update(newRules, 2, undefined), (error) => error === preMutation);
|
||||
assert.equal(local.events.includes('runtime.restore'), false);
|
||||
|
||||
const postMutation = new Error('remote failed');
|
||||
const remote = createHarness({ commandResult: { ok: false, mutationStarted: true, error: postMutation } });
|
||||
await assert.rejects(remote.service.update(newRules, 2, undefined), (error) => error === postMutation);
|
||||
assert.equal(remote.events.includes('runtime.restore'), true);
|
||||
});
|
||||
|
||||
test('route rules rollback continues and classifies runtime restore failure', async () => {
|
||||
const original = new Error('state failed');
|
||||
const configRestore = new Error('config restore failed');
|
||||
const aggregate = createHarness({
|
||||
failures: { stateUpdates: [original], configRestore },
|
||||
});
|
||||
await assert.rejects(aggregate.service.update(newRules, 2, undefined), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors, [original, configRestore]);
|
||||
return true;
|
||||
});
|
||||
|
||||
const runtimeRestore = new Error('runtime restore failed');
|
||||
const broken = createHarness({
|
||||
failures: { stateUpdates: [original], runtimeRestore },
|
||||
});
|
||||
await assert.rejects(broken.service.update(newRules, 2, undefined), (error) => {
|
||||
assert.equal(error.code, 'PROCESS_START_FAILED');
|
||||
assert.deepEqual(error.cause.errors, [original, runtimeRestore]);
|
||||
return true;
|
||||
});
|
||||
assert.ok(broken.events.filter((event) => event === 'state.update').length >= 1);
|
||||
});
|
||||
|
||||
test('route rules route preserves one adapter and state-only response', async () => {
|
||||
const calls = [];
|
||||
const route = createRouteRulesRoute({
|
||||
routeRules: { update: async (...args) => { calls.push(args); } },
|
||||
readBody: async () => ({ rules: newRules, expectedRulesRevision: 3, expectedRevision: 99 }),
|
||||
sendState: async () => { calls.push('sent'); },
|
||||
});
|
||||
const response = {};
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/route-rules' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'PUT', url: '/api/route-rules' }, response), true);
|
||||
assert.deepEqual(calls, [[newRules, 3, 99], 'sent']);
|
||||
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createRouteRulesRoute\(\{/);
|
||||
assert.doesNotMatch(source, /function applyRouteRules|req\.url === ['"]\/api\/route-rules['"]/);
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { checkServerHealth } from '../../src/server/serverHealth.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
checkServerHealth,
|
||||
createServerHealthService,
|
||||
} from '../../dist/server/features/servers/index.js';
|
||||
import { createServerHealthRoute } from '../../dist/server/http/routes/serverHealthRoute.js';
|
||||
|
||||
test('server health checks cap count and concurrency', async () => {
|
||||
const servers = Array.from({ length: 300 }, (_, index) => ({
|
||||
@@ -24,3 +30,50 @@ test('server health checks cap count and concurrency', async () => {
|
||||
assert.equal(peak, 4);
|
||||
assert.deepEqual(results.map(({ id }) => id), servers.slice(0, 30).map(({ id }) => id));
|
||||
});
|
||||
|
||||
test('server health selection coerces IDs, deduplicates, ignores unknowns, and keeps canonical order', async () => {
|
||||
const servers = [
|
||||
{ id: '1', label: 'One', host: 'one.example', port: 1, protocol: 'vless' },
|
||||
{ id: '2', label: 'Two', host: 'two.example', port: 2, protocol: 'vless' },
|
||||
{ id: '3', label: 'Three', host: 'three.example', port: 3, protocol: 'vless' },
|
||||
];
|
||||
const checked = [];
|
||||
const service = createServerHealthService({
|
||||
readServers: () => servers,
|
||||
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(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']), []);
|
||||
|
||||
const failure = new Error('ping failed');
|
||||
await assert.rejects(
|
||||
createServerHealthService({ readServers: () => servers, ping: async () => { throw failure; } }).check([]),
|
||||
(error) => error === failure,
|
||||
);
|
||||
});
|
||||
|
||||
test('server health route is the only endpoint adapter', async () => {
|
||||
const sent = [];
|
||||
const route = createServerHealthRoute({
|
||||
serverHealth: { check: async (ids) => [{ 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'] }] }]);
|
||||
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createServerHealthRoute\(\{/);
|
||||
assert.doesNotMatch(source, /req\.url === ['"]\/api\/servers\/ping-all['"]/);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from "node:test";
|
||||
|
||||
const {
|
||||
buildSharedProxyInfo,
|
||||
} = await import("../../src/server/sharedProxy.js");
|
||||
} = await import("../../dist/server/sharedProxy.js");
|
||||
const {
|
||||
createSharedProxyRoute,
|
||||
} = await import('../../dist/server/http/routes/sharedProxyRoute.js');
|
||||
|
||||
function response() {
|
||||
return {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(payload) {
|
||||
this.rawPayload = payload;
|
||||
this.payload = JSON.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
const events = [];
|
||||
const refreshError = overrides.refreshError;
|
||||
const route = createSharedProxyRoute({
|
||||
appMode: overrides.appMode ?? 'gateway',
|
||||
proxyPort: overrides.proxyPort ?? 8080,
|
||||
sharedProxyHost: overrides.sharedProxyHost ?? '',
|
||||
refreshRuntime: async () => {
|
||||
events.push('refresh');
|
||||
if (refreshError) throw refreshError;
|
||||
return { running: overrides.running ?? true };
|
||||
},
|
||||
});
|
||||
return { events, route };
|
||||
}
|
||||
|
||||
test("gateway shared proxy info exposes host and socks proxy when running", () => {
|
||||
const info = buildSharedProxyInfo({
|
||||
@@ -22,3 +55,144 @@ test("gateway shared proxy info exposes host and socks proxy when running", () =
|
||||
socksUrl: "socks5://192.168.50.111:8080",
|
||||
});
|
||||
});
|
||||
|
||||
test('shared proxy builder preserves host precedence, IPv6, port and unavailable quirks', () => {
|
||||
const configured = buildSharedProxyInfo({
|
||||
appMode: 'gateway',
|
||||
proxyPort: '9000px',
|
||||
running: true,
|
||||
hostHeader: 'ignored.example:1234',
|
||||
sharedProxyHost: ' proxy.example ',
|
||||
});
|
||||
assert.deepEqual(configured.proxy, {
|
||||
host: 'proxy.example',
|
||||
port: 9000,
|
||||
protocol: 'socks5',
|
||||
httpUrl: 'http://proxy.example:9000',
|
||||
socksUrl: 'socks5://proxy.example:9000',
|
||||
});
|
||||
|
||||
const ipv6 = buildSharedProxyInfo({
|
||||
appMode: 'gateway',
|
||||
proxyPort: 1080,
|
||||
running: true,
|
||||
hostHeader: '[2001:db8::1]:443',
|
||||
});
|
||||
assert.equal(ipv6.proxy.host, '2001:db8::1');
|
||||
|
||||
assert.equal(buildSharedProxyInfo({
|
||||
appMode: 'gateway',
|
||||
proxyPort: 1080,
|
||||
running: true,
|
||||
hostHeader: '',
|
||||
}).available, '');
|
||||
assert.equal(buildSharedProxyInfo({
|
||||
appMode: 'gateway',
|
||||
proxyPort: 65536,
|
||||
running: true,
|
||||
hostHeader: 'gateway.local',
|
||||
}).available, false);
|
||||
});
|
||||
|
||||
test('shared proxy route refreshes once before reading Host and preserves raw JSON response', async () => {
|
||||
const harness = createHarness();
|
||||
const res = response();
|
||||
const headers = {};
|
||||
Object.defineProperty(headers, 'host', {
|
||||
get() {
|
||||
harness.events.push('host');
|
||||
return '192.168.50.111:3456';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(await harness.route.handle({
|
||||
method: 'GET',
|
||||
url: '/api/shared-proxy',
|
||||
headers,
|
||||
}, res), true);
|
||||
assert.deepEqual(harness.events, ['refresh', 'host']);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.equal(res.rawPayload.endsWith('\n'), false);
|
||||
assert.deepEqual(res.payload, {
|
||||
success: true,
|
||||
available: true,
|
||||
mode: 'gateway',
|
||||
proxy: {
|
||||
host: '192.168.50.111',
|
||||
port: 8080,
|
||||
protocol: 'socks5',
|
||||
httpUrl: 'http://192.168.50.111:8080',
|
||||
socksUrl: 'socks5://192.168.50.111:8080',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('shared proxy route still refreshes for client and unavailable responses', async () => {
|
||||
for (const options of [
|
||||
{ appMode: 'client' },
|
||||
{ running: false },
|
||||
{ proxyPort: 'invalid' },
|
||||
]) {
|
||||
const harness = createHarness(options);
|
||||
const res = response();
|
||||
await harness.route.handle({
|
||||
method: 'GET',
|
||||
url: '/api/shared-proxy',
|
||||
headers: { host: 'gateway.local' },
|
||||
}, res);
|
||||
assert.deepEqual(harness.events, ['refresh']);
|
||||
assert.equal(res.payload.available, false);
|
||||
assert.equal(res.payload.proxy, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared proxy route exact guard avoids refresh and errors propagate unchanged', async () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/shared-proxy'],
|
||||
['GET', '/api/shared-proxy?source=client'],
|
||||
['GET', '/api/shared-proxy/'],
|
||||
['GET', '/api/other'],
|
||||
]) {
|
||||
const harness = createHarness();
|
||||
assert.equal(await harness.route.handle({ method, url, headers: {} }, response()), false);
|
||||
assert.deepEqual(harness.events, []);
|
||||
}
|
||||
|
||||
const refreshError = new Error('refresh failed');
|
||||
const brokenRefresh = createHarness({ refreshError });
|
||||
await assert.rejects(
|
||||
brokenRefresh.route.handle({
|
||||
method: 'GET',
|
||||
url: '/api/shared-proxy',
|
||||
headers: { host: 'gateway.local' },
|
||||
}, response()),
|
||||
(error) => error === refreshError,
|
||||
);
|
||||
|
||||
const builderError = new Error('builder failed');
|
||||
const brokenBuilder = createHarness({
|
||||
sharedProxyHost: { toString() { throw builderError; } },
|
||||
});
|
||||
await assert.rejects(
|
||||
brokenBuilder.route.handle({
|
||||
method: 'GET',
|
||||
url: '/api/shared-proxy',
|
||||
headers: { host: 'gateway.local' },
|
||||
}, response()),
|
||||
(error) => error === builderError,
|
||||
);
|
||||
});
|
||||
|
||||
test('shared proxy route is the sole HTTP owner', () => {
|
||||
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
const route = readFileSync(
|
||||
new URL('../../src/server/http/routes/sharedProxyRoute.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(index, /createSharedProxyRoute\(\{/);
|
||||
assert.match(index, /sharedProxyRoute\.handle\(req, res\)/);
|
||||
assert.doesNotMatch(index, /['"]\/api\/shared-proxy['"]|buildSharedProxyInfo/);
|
||||
assert.match(route, /req\.url !== '\/api\/shared-proxy'/);
|
||||
assert.match(route, /buildSharedProxyInfo\(\{/);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ process.env.APP_MODE = 'client';
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-client-test-'));
|
||||
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
|
||||
|
||||
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?client=${Date.now()}`);
|
||||
const { buildGatewayConfig } = await import(`../../dist/server/singbox.js?client=${Date.now()}`);
|
||||
|
||||
const subscriptionConfig = {
|
||||
outbounds: [{
|
||||
|
||||
@@ -8,7 +8,7 @@ process.env.APP_MODE = 'gateway';
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-'));
|
||||
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
|
||||
|
||||
const { buildGatewayConfig } = await import(`../../src/server/singbox.js?gateway=${Date.now()}`);
|
||||
const { buildGatewayConfig } = await import(`../../dist/server/singbox.js?gateway=${Date.now()}`);
|
||||
|
||||
const subscriptionConfig = {
|
||||
outbounds: [{
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createSingboxRuntime } from '../../src/server/singboxRuntime.js';
|
||||
import { createSingboxRuntime } from '../../dist/server/singboxRuntime.js';
|
||||
|
||||
async function waitForStarts(filePath, count) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
assertStateSnapshot,
|
||||
createStateSnapshot,
|
||||
normalizeStoredState,
|
||||
} from '../../src/shared/contracts/state.js';
|
||||
import { createServerId } from '../../src/shared/serverIdentity.js';
|
||||
import { HARBOR_VERSIONS } from '../../src/shared/versions.js';
|
||||
} from '../../dist/shared/contracts/state.js';
|
||||
import { createServerId } from '../../dist/shared/serverIdentity.js';
|
||||
import { HARBOR_VERSIONS } from '../../dist/shared/versions.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
@@ -113,7 +113,7 @@ test('startup discards a rejected cached subscription and returns to first-run',
|
||||
}));
|
||||
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
|
||||
|
||||
const child = spawn(process.execPath, ['src/server/index.js'], {
|
||||
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -254,7 +254,7 @@ setInterval(() => {}, 60_000);
|
||||
}));
|
||||
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['src/server/index.js'], {
|
||||
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -294,6 +294,29 @@ setInterval(() => {}, 60_000);
|
||||
assert.equal(initial.route.localRulesPendingRestart, false);
|
||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||
const stateKeys = Object.keys(initial).sort();
|
||||
assert.deepEqual(stateKeys, [
|
||||
'apiVersion',
|
||||
'configExists',
|
||||
'connection',
|
||||
'fetchedAt',
|
||||
'gatewayAuto',
|
||||
'generatedAt',
|
||||
'hasSubscription',
|
||||
'mode',
|
||||
'operation',
|
||||
'port',
|
||||
'proxyPort',
|
||||
'revision',
|
||||
'route',
|
||||
'selectedTag',
|
||||
'selection',
|
||||
'servers',
|
||||
'singboxRunning',
|
||||
'singboxStartedAt',
|
||||
'subscription',
|
||||
'subscriptionHost',
|
||||
'userInfo',
|
||||
]);
|
||||
let revision = initial.revision;
|
||||
let rulesRevision = initial.route.localRulesRevision;
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
atomicWriteJson,
|
||||
createStateStore,
|
||||
STATE_SCHEMA_VERSION,
|
||||
} from '../../src/server/services/stateStore.js';
|
||||
import { createServerId } from '../../src/shared/serverIdentity.js';
|
||||
} from '../../dist/server/services/stateStore.js';
|
||||
import { createServerId } from '../../dist/shared/serverIdentity.js';
|
||||
|
||||
const fixture = (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-store-'));
|
||||
@@ -17,6 +17,16 @@ const fixture = (t) => {
|
||||
return path.join(directory, 'state.json');
|
||||
};
|
||||
|
||||
test('raw JSON stores stay typed unknown until a migrator validates them', () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../src/server/services/stateStore.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /createJsonStore\(options: RawJsonStoreOptions\): JsonStore<unknown>/);
|
||||
assert.match(source, /const migrate = options\.migrate \|\| \(\(value: unknown\) => value\)/);
|
||||
assert.doesNotMatch(source, /value as T|migrate = \(value\) => value as/);
|
||||
});
|
||||
|
||||
test('data invariant: failure before rename preserves the last successful file', (t) => {
|
||||
const filePath = fixture(t);
|
||||
atomicWriteJson(filePath, { revision: 1 });
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
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 routeRules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
|
||||
|
||||
function parsed(servers = [nextServer]) {
|
||||
return {
|
||||
config: { normalized: true },
|
||||
sourceConfig: { source: true },
|
||||
servers,
|
||||
userInfo: { total: 100 },
|
||||
fetchedAt: '2026-08-08T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
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 running = overrides.running ?? true;
|
||||
let serialized = Promise.resolve();
|
||||
const calls = [];
|
||||
const failures = { ...(overrides.failures || {}) };
|
||||
const timers = [];
|
||||
|
||||
const failOnce = (name) => {
|
||||
const failure = failures[name];
|
||||
if (!failure) return;
|
||||
delete failures[name];
|
||||
throw failure;
|
||||
};
|
||||
|
||||
const dependencies = {
|
||||
provider: {
|
||||
fetchSubscription: overrides.fetchSubscription || (async () => parsed()),
|
||||
selectRefreshedServer: overrides.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 };
|
||||
calls.push('state.update');
|
||||
failOnce('stateUpdateAfter');
|
||||
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');
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
isRunning: async () => running,
|
||||
stop: async () => {
|
||||
calls.push('runtime.stop');
|
||||
failOnce('runtimeStop');
|
||||
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;
|
||||
}
|
||||
running = true;
|
||||
},
|
||||
},
|
||||
gatewayAuto: {
|
||||
read: () => structuredClone(gatewayAuto),
|
||||
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;
|
||||
},
|
||||
scheduler: {
|
||||
setInterval: (callback, intervalMs) => {
|
||||
const timer = {
|
||||
callback,
|
||||
intervalMs,
|
||||
unrefCalls: 0,
|
||||
clearCalls: 0,
|
||||
unref() { this.unrefCalls += 1; },
|
||||
};
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
},
|
||||
clearInterval: (timer) => { timer.clearCalls += 1; },
|
||||
},
|
||||
onRefreshError: overrides.onRefreshError || (() => {}),
|
||||
};
|
||||
|
||||
return {
|
||||
service: createSubscriptionService(dependencies),
|
||||
calls,
|
||||
timers,
|
||||
snapshot: () => structuredClone({ state, cache, 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('subscription import commits source config, clears selection, and leaves runtime stopped', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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('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('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 },
|
||||
});
|
||||
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 removeFailure = new Error('remove failed');
|
||||
const cacheRestoreFailure = new Error('cache restore failed');
|
||||
const cacheBroken = createHarness({
|
||||
failures: { configRemove: removeFailure, cacheWrite: cacheRestoreFailure },
|
||||
});
|
||||
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,
|
||||
);
|
||||
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;
|
||||
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');
|
||||
},
|
||||
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.deepEqual(errors, ['PROVIDER_UNAVAILABLE']);
|
||||
|
||||
stateful.service.stopAutoRefresh();
|
||||
stateful.service.stopAutoRefresh();
|
||||
assert.equal(stateful.timers[0].clearCalls, 1);
|
||||
});
|
||||
|
||||
test('subscription mutation route preserves methods, operation kinds, and response fields', async () => {
|
||||
const operations = [];
|
||||
const sent = [];
|
||||
const service = {
|
||||
importSubscription: async (url) => ({ success: true, imported: url }),
|
||||
refreshSavedSubscription: async () => ({ success: true, refreshed: true }),
|
||||
resetSavedSubscription: async () => { operations.push('reset'); },
|
||||
};
|
||||
const route = createSubscriptionMutationRoute({
|
||||
subscriptionService: service,
|
||||
readBody: async () => ({ url: ' https://new.example/sub ' }),
|
||||
withOperation: async (kind, operation) => {
|
||||
operations.push(kind);
|
||||
return operation();
|
||||
},
|
||||
sendState: async (_res, extra = {}) => { sent.push(extra); },
|
||||
});
|
||||
const response = {};
|
||||
|
||||
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 },
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
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['"]/);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createValidateSubscription } from '../../dist/server/features/subscription/index.js';
|
||||
import { createSubscriptionValidationRoute } from '../../dist/server/http/routes/subscriptionValidationRoute.js';
|
||||
|
||||
test('subscription validation trims once and returns only the provider server count', async () => {
|
||||
const calls = [];
|
||||
const validate = createValidateSubscription(async (url) => {
|
||||
calls.push(url);
|
||||
return { servers: [{ id: 'one' }, { id: 'two' }], privateConfig: 'not returned' };
|
||||
});
|
||||
|
||||
assert.deepEqual(await validate(' https://provider.example/sub '), { servers: 2 });
|
||||
assert.deepEqual(await validate(null), { servers: 2 });
|
||||
assert.deepEqual(calls, ['https://provider.example/sub', 'null']);
|
||||
|
||||
const failure = new Error('provider failed');
|
||||
await assert.rejects(createValidateSubscription(async () => { throw failure; })('url'), (error) => error === failure);
|
||||
});
|
||||
|
||||
test('subscription validation route is the only method/path adapter', async () => {
|
||||
const sent = [];
|
||||
const route = createSubscriptionValidationRoute({
|
||||
validateSubscription: async (url) => ({ servers: String(url).length }),
|
||||
readBody: async () => ({ url: 'trimmed' }),
|
||||
sendState: async (_res, extra) => { sent.push(extra); },
|
||||
});
|
||||
const response = {};
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/subscription/validate' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/subscription/validate' }, response), true);
|
||||
assert.deepEqual(sent, [{ servers: 7 }]);
|
||||
|
||||
const compositionSource = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(compositionSource, /createSubscriptionValidationRoute\(\{/);
|
||||
assert.doesNotMatch(compositionSource, /req\.url === ['"]\/api\/subscription\/validate['"]/);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test';
|
||||
import {
|
||||
parseSubscriptionBody,
|
||||
selectRefreshedServer,
|
||||
} from '../../src/server/subscription.js';
|
||||
} from '../../dist/server/subscription.js';
|
||||
|
||||
const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds }));
|
||||
const outbound = (tag, server, server_port = 443) => ({
|
||||
@@ -76,3 +76,13 @@ test('provider placeholders never become selectable servers', () => {
|
||||
assert.deepEqual(parsed.servers.map((server) => server.label), ['Amsterdam']);
|
||||
assert.equal(parsed.config.outbounds.length, 1);
|
||||
});
|
||||
|
||||
test('subscription parser rejects JSON primitives and arrays', () => {
|
||||
for (const value of [null, [], 42, true, 'provider rejected']) {
|
||||
assert.throws(
|
||||
() => parseSubscriptionBody(JSON.stringify(value)),
|
||||
(error) => error.code === 'SUBSCRIPTION_INVALID',
|
||||
`expected ${JSON.stringify(value)} to be rejected`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+115
-2
@@ -1,8 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildGatewayVersionInfo, buildVersionInfo } from '../../src/server/version.js';
|
||||
import { HARBOR_VERSIONS, versionCompatibility } from '../../src/shared/versions.js';
|
||||
import { buildGatewayVersionInfo, buildVersionInfo } from '../../dist/server/version.js';
|
||||
import { createVersionRoute } from '../../dist/server/http/routes/versionRoute.js';
|
||||
import { HARBOR_VERSIONS, versionCompatibility } from '../../dist/shared/versions.js';
|
||||
|
||||
function response() {
|
||||
return {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(payload) {
|
||||
this.rawPayload = payload;
|
||||
this.payload = JSON.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const controlVersionInfo = {
|
||||
apiVersion: 1,
|
||||
location: 'gateway',
|
||||
components: { gatewayBackend: '0.21.17' },
|
||||
runtime: { singBox: '1.12.13' },
|
||||
};
|
||||
|
||||
test('component versions enforce one Harbor major and one Gateway major.minor', () => {
|
||||
assert.deepEqual(versionCompatibility(HARBOR_VERSIONS), {
|
||||
@@ -49,3 +71,94 @@ test('runtime version info reports the installed sing-box binary', () => {
|
||||
runtime: { singBox: '1.12.13' },
|
||||
});
|
||||
});
|
||||
|
||||
test('version route returns prebuilt local info without a dataplane refresh', async () => {
|
||||
const res = response();
|
||||
const route = createVersionRoute({
|
||||
versionInfo: controlVersionInfo,
|
||||
refreshDataplaneRuntime: null,
|
||||
});
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/version' }, res), true);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.equal(res.rawPayload.endsWith('\n'), false);
|
||||
assert.deepEqual(res.payload, controlVersionInfo);
|
||||
});
|
||||
|
||||
test('version route refreshes split Gateway once and preserves remote runtime overlay', async () => {
|
||||
let refreshes = 0;
|
||||
const route = createVersionRoute({
|
||||
versionInfo: controlVersionInfo,
|
||||
refreshDataplaneRuntime: async () => {
|
||||
refreshes += 1;
|
||||
return {
|
||||
gatewayBackendVersion: '0.21.16',
|
||||
singBoxVersion: '1.12.14',
|
||||
};
|
||||
},
|
||||
});
|
||||
const res = response();
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/version' }, res), true);
|
||||
assert.equal(refreshes, 1);
|
||||
assert.deepEqual(res.payload, {
|
||||
apiVersion: 1,
|
||||
location: 'gateway',
|
||||
components: { gatewayBackend: '0.21.17' },
|
||||
runtime: { dataplaneVersion: '0.21.16', singBox: '1.12.14' },
|
||||
});
|
||||
});
|
||||
|
||||
test('version route keeps remote null fallbacks', async () => {
|
||||
const route = createVersionRoute({
|
||||
versionInfo: controlVersionInfo,
|
||||
refreshDataplaneRuntime: async () => ({}),
|
||||
});
|
||||
const res = response();
|
||||
await route.handle({ method: 'GET', url: '/api/version' }, res);
|
||||
assert.deepEqual(res.payload.runtime, { dataplaneVersion: null, singBox: null });
|
||||
});
|
||||
|
||||
test('version route exact guard avoids refresh and refresh errors propagate unchanged', async () => {
|
||||
let refreshes = 0;
|
||||
const refreshError = new Error('status failed');
|
||||
const route = createVersionRoute({
|
||||
versionInfo: controlVersionInfo,
|
||||
refreshDataplaneRuntime: async () => {
|
||||
refreshes += 1;
|
||||
throw refreshError;
|
||||
},
|
||||
});
|
||||
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/version'],
|
||||
['GET', '/api/version?source=client'],
|
||||
['GET', '/api/version/'],
|
||||
['GET', '/api/other'],
|
||||
]) {
|
||||
assert.equal(await route.handle({ method, url }, response()), false);
|
||||
}
|
||||
assert.equal(refreshes, 0);
|
||||
|
||||
const res = response();
|
||||
await assert.rejects(
|
||||
route.handle({ method: 'GET', url: '/api/version' }, res),
|
||||
(error) => error === refreshError,
|
||||
);
|
||||
assert.equal(refreshes, 1);
|
||||
assert.equal(res.status, undefined);
|
||||
});
|
||||
|
||||
test('version route is the sole HTTP owner', () => {
|
||||
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
const route = readFileSync(
|
||||
new URL('../../src/server/http/routes/versionRoute.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(index, /createVersionRoute\(\{/);
|
||||
assert.match(index, /versionRoute\.handle\(req, res\)/);
|
||||
assert.doesNotMatch(index, /['"]\/api\/version['"]|buildGatewayVersionInfo|sendJson/);
|
||||
assert.match(route, /req\.url !== '\/api\/version'/);
|
||||
assert.match(route, /buildGatewayVersionInfo\(/);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { canAppendRouteRule, normalizeRouteRules } from '../../src/shared/routingRules.js';
|
||||
import { canAppendRouteRule, normalizeRouteRules } from '../../dist/shared/routingRules.js';
|
||||
|
||||
test('local route rules normalize URLs, suffixes and duplicates', () => {
|
||||
assert.deepEqual(normalizeRouteRules([
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { affectedComponents, bumpVersions } from '../scripts/harbor-version.mjs';
|
||||
import {
|
||||
affectedComponents,
|
||||
bumpVersions,
|
||||
parseVersion as parseToolVersion,
|
||||
versionCompatibility as toolCompatibility,
|
||||
versionsFromSource,
|
||||
} from '../scripts/harbor-version.mjs';
|
||||
import {
|
||||
HARBOR_VERSIONS,
|
||||
parseVersion as parseRuntimeVersion,
|
||||
versionCompatibility as runtimeCompatibility,
|
||||
} from '../dist/shared/versions.js';
|
||||
|
||||
const versions = {
|
||||
macClient: '2.4.3',
|
||||
gatewayClient: '2.7.1',
|
||||
gatewayBackend: '2.7.8',
|
||||
};
|
||||
const versionScript = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '../scripts/harbor-version.mjs'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('version paths map to the components actually shipped by this repository', () => {
|
||||
assert.deepEqual(affectedComponents(['src/web/App.jsx']), ['macClient', 'gatewayClient']);
|
||||
assert.deepEqual(affectedComponents(['src/server/index.js']), ['macClient', 'gatewayBackend']);
|
||||
assert.match(versionScript, /git\(\['diff', '--no-renames', '--name-only'/);
|
||||
assert.deepEqual(affectedComponents(['src/web/App.tsx']), ['macClient', 'gatewayClient']);
|
||||
assert.deepEqual(affectedComponents(['vite.config.ts']), ['macClient', 'gatewayClient']);
|
||||
assert.deepEqual(affectedComponents(['tsconfig.web.json']), ['macClient', 'gatewayClient']);
|
||||
assert.deepEqual(affectedComponents(['src/server/index.ts']), ['macClient', 'gatewayBackend']);
|
||||
assert.deepEqual(affectedComponents(['tsconfig.server.json']), ['macClient', 'gatewayBackend']);
|
||||
assert.deepEqual(affectedComponents(['docker-compose.client.local.yml']), ['macClient']);
|
||||
assert.deepEqual(affectedComponents(['install.sh']), ['macClient']);
|
||||
assert.deepEqual(affectedComponents(['package-lock.json']), [
|
||||
@@ -19,9 +40,29 @@ test('version paths map to the components actually shipped by this repository',
|
||||
'gatewayClient',
|
||||
'gatewayBackend',
|
||||
]);
|
||||
assert.deepEqual(affectedComponents(['package.json', 'tsconfig.base.json', '.dockerignore']), [
|
||||
'macClient',
|
||||
'gatewayClient',
|
||||
'gatewayBackend',
|
||||
]);
|
||||
assert.deepEqual(affectedComponents(['scripts/runtime-impact.mjs']), ['gatewayBackend']);
|
||||
assert.deepEqual(affectedComponents(['README.md', 'test/server/version.test.js']), []);
|
||||
});
|
||||
|
||||
test('text-owned version tooling stays in parity with the compiled runtime contract', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(import.meta.dirname, '../src/shared/versions.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.deepEqual(versionsFromSource(source), HARBOR_VERSIONS);
|
||||
for (const value of ['0.21.20', '1.0.0', '', '1.2', '1.2.3.4']) {
|
||||
assert.deepEqual(parseToolVersion(value), parseRuntimeVersion(value));
|
||||
}
|
||||
for (const sample of [versions, HARBOR_VERSIONS, { ...versions, gatewayBackend: '2.8.0' }]) {
|
||||
assert.deepEqual(toolCompatibility(sample), runtimeCompatibility(sample));
|
||||
}
|
||||
});
|
||||
|
||||
test('version bumps follow ecosystem, linked-component and local scopes', () => {
|
||||
assert.deepEqual(bumpVersions(versions, 'major'), {
|
||||
macClient: '3.0.0',
|
||||
|
||||
+103
-1
@@ -2,9 +2,10 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
api,
|
||||
HarborApiError,
|
||||
request,
|
||||
} from '../../src/web/api.js';
|
||||
} from '../../.test-dist/src/web/api/harborClient.js';
|
||||
|
||||
const response = (status, error) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
@@ -53,3 +54,104 @@ test('local unknown errors get a safe message and diagnostic reference', () => {
|
||||
assert.equal(typeof error.correlationId, 'string');
|
||||
assert.ok(error.correlationId.length >= 8);
|
||||
});
|
||||
|
||||
test('typed endpoint facade preserves exact request contracts and raw payload identity', async () => {
|
||||
const calls = [];
|
||||
const payload = { success: true, marker: 'raw' };
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push([url, options]);
|
||||
return { ok: true, status: 200, json: async () => payload };
|
||||
};
|
||||
const signal = new AbortController().signal;
|
||||
try {
|
||||
const cases = [
|
||||
[() => api.version(), '/api/version', {}],
|
||||
[() => api.subscription.validate('https://sub', { signal }), '/api/subscription/validate', {
|
||||
method: 'POST', body: JSON.stringify({ url: 'https://sub' }), signal,
|
||||
}],
|
||||
[() => api.subscription.fetch('https://sub'), '/api/subscription/fetch', {
|
||||
method: 'POST', body: JSON.stringify({ url: 'https://sub' }),
|
||||
}],
|
||||
[() => 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.gatewayAuto.setEnabled(true), '/api/gateway-auto', {
|
||||
method: 'POST', body: JSON.stringify({ enabled: true }),
|
||||
}],
|
||||
[() => api.routeRules.update([{ type: 'domain', value: 'example.com' }], 7), '/api/route-rules', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
rules: [{ type: 'domain', value: 'example.com' }],
|
||||
expectedRulesRevision: 7,
|
||||
}),
|
||||
}],
|
||||
[() => api.devices.list(), '/api/devices', {}],
|
||||
[() => api.devices.refresh(), '/api/devices/refresh', { method: 'POST' }],
|
||||
[() => api.devices.update('dev_1', { alias: 'TV' }, 8), '/api/devices/dev_1', {
|
||||
method: 'PUT', body: JSON.stringify({ alias: 'TV', expectedRevision: 8 }),
|
||||
}],
|
||||
[() => api.devices.setPolicy('dev_1', 'direct', 9), '/api/devices/dev_1/policy', {
|
||||
method: 'PUT', body: JSON.stringify({ mode: 'direct', expectedRevision: 9 }),
|
||||
}],
|
||||
[() => api.diagnostics.connectivity(), '/api/diagnostics/connectivity', {
|
||||
method: 'POST', body: JSON.stringify({ services: [], target: null }),
|
||||
}],
|
||||
[() => 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', {
|
||||
method: 'POST', body: JSON.stringify({ serverIds: ['one', 'two'] }),
|
||||
}],
|
||||
];
|
||||
|
||||
for (const [invoke, url, options] of cases) {
|
||||
assert.equal(await invoke(), payload);
|
||||
const [actualUrl, actualOptions] = calls.at(-1);
|
||||
assert.equal(actualUrl, url);
|
||||
assert.deepEqual(actualOptions, {
|
||||
...options,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('request preserves caller headers, AbortError identity and JSON fallbacks', async () => {
|
||||
let received;
|
||||
const value = { ok: 'raw' };
|
||||
assert.equal(await request('/api/test', {
|
||||
headers: { 'content-type': 'application/custom', 'x-harbor': 'yes' },
|
||||
}, async (url, options) => {
|
||||
received = [url, options];
|
||||
return { ok: true, status: 200, json: async () => value };
|
||||
}), value);
|
||||
assert.deepEqual(received, ['/api/test', {
|
||||
headers: { 'content-type': 'application/custom', 'x-harbor': 'yes' },
|
||||
}]);
|
||||
|
||||
const aborted = Object.assign(new Error('cancelled'), { name: 'AbortError' });
|
||||
await assert.rejects(
|
||||
request('/api/test', {}, async () => { throw aborted; }),
|
||||
(error) => error === aborted,
|
||||
);
|
||||
await assert.rejects(
|
||||
request('/api/test', {}, async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new Error('invalid json'); },
|
||||
})),
|
||||
(error) => error.code === 'UNKNOWN' && error.status === 500,
|
||||
);
|
||||
await assert.rejects(
|
||||
request('/api/test', {}, async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => { throw new Error('invalid json'); },
|
||||
})),
|
||||
(error) => error.code === 'CONTROL_UNREACHABLE' && error.status === 503,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const index = readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
|
||||
const main = readFileSync(new URL('../../src/web/main.tsx', import.meta.url), 'utf8');
|
||||
const app = readFileSync(new URL('../../src/web/App.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('typed main is the sole browser bootstrap owner', () => {
|
||||
assert.match(index, /src="\/src\/web\/main\.tsx"/);
|
||||
assert.doesNotMatch(index, /src="\/src\/web\/App\.tsx"/);
|
||||
assert.match(main, /import \{ App \} from '\.\/App\.js'/);
|
||||
assert.match(main, /import '\.\/styles\/index\.css'/);
|
||||
assert.match(main, /document\.getElementById\('root'\)/);
|
||||
assert.match(main, /throw new Error\('Harbor root element not found'\)/);
|
||||
assert.equal((main.match(/createRoot\(/g) || []).length, 1);
|
||||
assert.match(main, /createRoot\(root\)\.render\(<App \/>\)/);
|
||||
});
|
||||
|
||||
test('App remains the exported composition component without bootstrap side effects', () => {
|
||||
assert.match(app, /export function App\(\)/);
|
||||
assert.doesNotMatch(app, /createRoot|react-dom\/client|styles(?:\/index)?\.css|getElementById\('root'\)/);
|
||||
assert.match(app, /<ClientOverviewPage/);
|
||||
assert.match(app, /<StaleBanner/);
|
||||
});
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
subscriptionDomain,
|
||||
subscriptionDaysLeft,
|
||||
subscriptionUsage,
|
||||
} from '../../src/web/utils/clientControls.js';
|
||||
import { instructionBlocks } from '../../src/web/instructions.js';
|
||||
} from '../../.test-dist/src/web/utils/clientControls.js';
|
||||
import { instructionBlocks } from '../../.test-dist/src/web/features/instructions/instructionBlocks.js';
|
||||
|
||||
test('connection button chooses the only valid client action', () => {
|
||||
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = (file) => readFileSync(new URL(`../../src/web/${file}`, import.meta.url), 'utf8');
|
||||
const app = source('App.tsx');
|
||||
const overview = source('components/ClientOverviewPage.tsx');
|
||||
const subscription = source('features/subscription/SubscriptionFeature.tsx');
|
||||
const devices = source('features/devices/DevicesPanel.tsx');
|
||||
const deviceFeature = source('features/devices/DevicesFeature.tsx');
|
||||
const servers = source('features/servers/ServerPicker.tsx');
|
||||
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.equal((app.match(/actions=\{componentActions\}/g) || []).length, 1);
|
||||
assert.doesNotMatch(app, /componentActions\s*=\s*useMemo|componentActions\s*=\s*\([^)]*\)\s*=>/);
|
||||
});
|
||||
|
||||
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(overview, /refreshDevices: actions\.refreshDevices/);
|
||||
assert.match(deviceFeature, /discover \? refreshDevices\(\) : listDevices\(\)/);
|
||||
assert.match(overview, /<ServerPicker[\s\S]*pingServers=\{actions\.pingServers\}/);
|
||||
assert.match(overview, /useDevicesFeature\(\{[\s\S]*listDevices: actions\.listDevices[\s\S]*refreshDevices: actions\.refreshDevices[\s\S]*updateDevice: actions\.updateDevice[\s\S]*setDevicePolicy: actions\.setDevicePolicy/);
|
||||
assert.match(overview, /<DevicesPanel feature=\{devicesFeature\} \/>/);
|
||||
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(diagnostics, /await runConnectivityDiagnostics\(customServices, target\)/);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const panel = fs.readFileSync(path.join(root, 'src/web/features/connection/ConnectionPanel.tsx'), 'utf8');
|
||||
const routing = fs.readFileSync(path.join(root, 'src/web/features/routing/RoutingFeature.tsx'), 'utf8');
|
||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/connection/index.ts'), 'utf8');
|
||||
|
||||
test('connection feature is the sole always-mounted power panel owner', () => {
|
||||
assert.equal(boundary.trim(), "export { ConnectionPanel } from './ConnectionPanel.js';");
|
||||
assert.match(page, /import \{ ConnectionPanel \} from '\.\.\/features\/connection\/index\.js'/);
|
||||
assert.equal((page.match(/<ConnectionPanel/g) || []).length, 1);
|
||||
assert.match(page, /<main[\s\S]*<ConnectionPanel[\s\S]*<GatewayTrafficSummary/);
|
||||
assert.doesNotMatch(page, /client-power-section|const powerButton|function toggleConnection|confirmingStop|id="stop-connection"|DURATION_MODE_STORAGE_KEY/);
|
||||
assert.match(panel, /\{visible && <section className="client-power-section"/);
|
||||
assert.match(panel, /<ConfirmationDialog[\s\S]*open=\{confirmingStop\}/);
|
||||
assert.ok(panel.indexOf('<ConfirmationDialog') > panel.indexOf('{visible && <section'), 'dialog remains mounted outside the visible section');
|
||||
});
|
||||
|
||||
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, /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, /\{routingSlot\}[\s\S]*client-state-copy[\s\S]*\{serverSlot\}[\s\S]*client-state-detail[\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, /statusSlot=\{<>[\s\S]*InlineError[\s\S]*InlineProgress/);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.match(page, /export function ClientOverviewPage[\s\S]*const \[copyFeedback, setCopyFeedback\]/);
|
||||
assert.doesNotMatch(panel, /const \[copyFeedback, setCopyFeedback\]/);
|
||||
assert.equal((pageBody.match(/className="client-live-region"/g) || []).length, 1);
|
||||
assert.match(page, /onCopyProxy=\{copyProxy\}/);
|
||||
assert.match(panel, /localProxyUrls\(proxyPort, gatewayAddress\)/);
|
||||
assert.match(panel, /onClick=\{\(\) => onCopyProxy\(kind\)\}/);
|
||||
});
|
||||
@@ -11,37 +11,42 @@ import {
|
||||
stabilizeDevicesByTraffic,
|
||||
trafficAxisMid,
|
||||
trafficScaleRatio,
|
||||
} from '../../src/web/utils/format.js';
|
||||
} from '../../.test-dist/src/web/utils/format.js';
|
||||
import { readStyleSource } from './style-source.js';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||
const panel = fs.readFileSync(path.join(root, 'src/web/components/DevicesPanel.jsx'), 'utf8');
|
||||
const chart = fs.readFileSync(path.join(root, 'src/web/components/TrafficChart.jsx'), 'utf8');
|
||||
const api = fs.readFileSync(path.join(root, 'src/web/api.js'), 'utf8');
|
||||
const server = fs.readFileSync(path.join(root, 'src/server/index.js'), 'utf8');
|
||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const connection = fs.readFileSync(path.join(root, 'src/web/features/connection/ConnectionPanel.tsx'), 'utf8');
|
||||
const subscription = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8');
|
||||
const panel = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesPanel.tsx'), 'utf8');
|
||||
const chart = fs.readFileSync(path.join(root, 'src/web/features/devices/TrafficChart.tsx'), 'utf8');
|
||||
const api = fs.readFileSync(path.join(root, 'src/web/api/harborClient.ts'), 'utf8');
|
||||
const server = fs.readFileSync(path.join(root, 'src/server/index.ts'), 'utf8');
|
||||
const deviceRoute = fs.readFileSync(path.join(root, 'src/server/http/routes/deviceInventoryRoute.ts'), 'utf8');
|
||||
const styles = readStyleSource(root);
|
||||
|
||||
test('Gateway device inventory uses the existing accessible responsive drawer', () => {
|
||||
assert.match(overview, /isGateway && <button[\s\S]*client-devices-toggle/);
|
||||
assert.match(overview, /api\.devices\.list\(\)/);
|
||||
assert.match(overview, /api\.devices\.refresh\(\)/);
|
||||
assert.match(overview, /DEVICE_AUTO_REFRESH_MS = 15_000/);
|
||||
assert.match(overview, /setDeviceSnapshot\(\(current\) => !current \|\| next\.revision > current\.revision/);
|
||||
assert.match(overview, /snapshot=\{deviceSnapshot\}[\s\S]*onSnapshot=\{setDeviceSnapshot\}/);
|
||||
assert.match(overview, /isGateway && <DevicesToggle/);
|
||||
assert.match(feature, /discover \? refreshDevices\(\) : listDevices\(\)/);
|
||||
assert.match(feature, /DEVICE_AUTO_REFRESH_MS = 15_000/);
|
||||
assert.match(feature, /setSnapshot\(\(current\) => !current \|\| next\.revision > current\.revision/);
|
||||
assert.match(overview, /<DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
|
||||
assert.doesNotMatch(panel, /AUTO_REFRESH_MS|const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
|
||||
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
||||
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*api\.devices\.list\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*api\.devices\.update\(device\.id, patch, latest\.revision\)/);
|
||||
assert.match(feature, /requestDeviceUpdate\(device\.id, patch, snapshot\.revision\)/);
|
||||
assert.match(feature, /requestError\(caught\)\.code !== 'STATE_CONFLICT'[\s\S]*listDevices\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*requestDeviceUpdate\(device\.id, patch, latest\.revision\)/);
|
||||
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||||
assert.match(panel, /movementAnimations\.current\.get\(id\)\?\.cancel\(\)/);
|
||||
assert.match(panel, /const orderChanged = previousOrder\.current\.length > 0/);
|
||||
assert.match(panel, /previousScrollTop\.current - currentScrollTop/);
|
||||
assert.match(panel, /next\.revision > current\.revision/);
|
||||
assert.match(feature, /next\.revision > current\.revision/);
|
||||
assert.doesNotMatch(panel, /revision >= current\.revision/);
|
||||
assert.match(panel, /prefers-reduced-motion: reduce/);
|
||||
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
||||
assert.match(api, /setPolicy: \(id, mode, expectedRevision\) => request\(`\/api\/devices\/\$\{id\}\/policy`/);
|
||||
assert.match(server, /requestUrl\.pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
||||
assert.match(server, /\/api\\\/devices\\\/\(dev_\[a-f0-9\]\{16\}\)\\\/policy\$[\s\S]*deviceInventory\.setPolicy/);
|
||||
assert.match(api, /setPolicy:[\s\S]*`\/api\/devices\/\$\{id\}\/policy`/);
|
||||
assert.match(server, /createDeviceInventoryRoute\(\{/);
|
||||
assert.match(deviceRoute, /pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
||||
assert.match(deviceRoute, /DEVICE_POLICY_PATH[\s\S]*deviceInventory\.setPolicy/);
|
||||
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
||||
assert.match(panel, /copyText\(device\.ip\)/);
|
||||
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
|
||||
@@ -51,7 +56,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /client-device-name-heading\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}/);
|
||||
assert.match(panel, /className="client-device-alias-input"[\s\S]*onBlur=\{\(\) => saveAlias\(device\)\}[\s\S]*event\.key === 'Enter'[\s\S]*event\.currentTarget\.blur\(\)/);
|
||||
assert.match(panel, /aliasBaseline\.current = \{ id: device\.id, value \}/);
|
||||
assert.match(panel, /style=\{\{ '--alias-width': `\$\{Math\.max\(1, alias\.length\)\}ch` \}\}/);
|
||||
assert.match(panel, /style=\{\{ '--alias-width': `\$\{Math\.max\(1, alias\.length\)\}ch` \} as CSSProperties\}/);
|
||||
assert.doesNotMatch(panel, /aliasWidth|getBoundingClientRect\(\)\.width/);
|
||||
assert.match(panel, /nextAlias === aliasBaseline\.current\.value\.trim\(\)[\s\S]*setEditingId\(\(current\) => current === device\.id \? '' : current\)/);
|
||||
assert.doesNotMatch(panel, /client-device-alias"|Сохранить название|Отменить изменение/);
|
||||
@@ -77,12 +82,12 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/);
|
||||
assert.match(panel, /const hasProxyTraffic = proxyTotal > 0n/);
|
||||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*\{hasProxyTraffic && <span className="is-proxy"><b>Прокси<\/b><TrafficValue value=\{proxyTraffic\} delta=\{trafficDelta\.proxy\}/);
|
||||
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\.trafficHistoryCapacity/);
|
||||
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\?\.trafficHistoryCapacity/);
|
||||
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
||||
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
||||
assert.match(chart, /previousScale\.current !== scale[\s\S]*attributeName="d"[\s\S]*dur="520ms"/);
|
||||
assert.match(chart, /function smoothTrafficPath[\s\S]*const midX = \(previous\.x \+ point\.x\) \/ 2[\s\S]* C /);
|
||||
assert.match(chart, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/);
|
||||
assert.match(chart, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio: number\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/);
|
||||
assert.match(chart, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)/);
|
||||
assert.match(chart, /client-device-traffic-grid[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\}[\s\S]*y1=\{\(100 \+ TRAFFIC_CHART_HEADROOM\) \/ 2\}/);
|
||||
assert.match(chart, /client-device-traffic-cursor[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\} y2="100"/);
|
||||
@@ -101,8 +106,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /source\?\.traffic\?\.proxy\?\.error/);
|
||||
assert.match(panel, /client-device-pin-wrap[\s\S]*client-device-main[\s\S]*client-device-traffic[\s\S]*client-device-policy-wrap/);
|
||||
assert.doesNotMatch(panel, /client-device-details/);
|
||||
assert.match(panel, /api\.devices\.setPolicy\(device\.id, mode, snapshot\.revision\)/);
|
||||
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*latestDevice\.desiredPolicy !== device\.desiredPolicy[\s\S]*api\.devices\.setPolicy\(device\.id, mode, latest\.revision\)/);
|
||||
assert.match(feature, /setDevicePolicy\(device\.id, mode, snapshot\.revision\)/);
|
||||
assert.match(feature, /requestError\(caught\)\.code !== 'STATE_CONFLICT'[\s\S]*latestDevice\.desiredPolicy !== device\.desiredPolicy[\s\S]*setDevicePolicy\(device\.id, mode, latest\.revision\)/);
|
||||
assert.match(panel, /className=\{`client-device-policy is-\$\{displayPolicy\}/);
|
||||
assert.match(panel, /displayPolicy === 'direct' \? <svg[\s\S]*M4 12h15M14 7l5 5-5 5[\s\S]*M12 3 19 6v5/);
|
||||
assert.match(panel, /Полностью обходит sing-box/);
|
||||
@@ -110,7 +115,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
||||
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
||||
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
|
||||
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
||||
assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/);
|
||||
assert.match(styles, /\.client-devices \{\s*width: min\(580px, 100vw\)/);
|
||||
assert.match(styles, /\.client-device \{[\s\S]*--client-device-chart-height: 34px;[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px var\(--client-device-chart-height\);[\s\S]*padding: 10px 8px/);
|
||||
assert.match(styles, /\.client-device\.is-pinned \{[\s\S]*--client-device-chart-height: 72px/);
|
||||
@@ -166,30 +171,29 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
});
|
||||
|
||||
test('Gateway Home reuses the canonical device snapshot for applied route and global traffic', () => {
|
||||
const powerStart = overview.indexOf('<section className="client-power-section"');
|
||||
const trafficStart = overview.indexOf('<section className="client-gateway-summary"');
|
||||
const powerPrefix = overview.slice(powerStart, trafficStart);
|
||||
const powerStart = connection.indexOf('<section className="client-power-section"');
|
||||
const trafficStart = overview.indexOf('<GatewayTrafficSummary');
|
||||
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, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
|
||||
assert.match(overview, /samples=\{globalTraffic\?\.history \|\| \[\]\}[\s\S]*capacity=\{deviceSnapshot\?\.trafficHistoryCapacity \|\| 120\}[\s\S]*routeLabel="Gateway"/);
|
||||
assert.match(overview, /<section className="client-power-section"[\s\S]*client-connection-title[\s\S]*client-gateway-route-summary[\s\S]*client-state-detail[\s\S]*client-proxies[\s\S]*<section className="client-gateway-summary"/);
|
||||
assert.ok(powerStart >= 0 && trafficStart > powerStart, 'traffic summary follows the power section');
|
||||
assert.equal((powerPrefix.match(/<section\b/g) || []).length, (powerPrefix.match(/<\/section>/g) || []).length, 'power section is closed before traffic summary');
|
||||
assert.equal((overview.match(/className="client-gateway-summary"/g) || []).length, 1);
|
||||
assert.doesNotMatch(overview, /<TrafficChart[\s\S]{0,240}scale=/);
|
||||
assert.match(overview, /deviceStatus === 'error' \? deviceError : null/);
|
||||
assert.match(overview, /if \(!isGateway && \(!connected \|\| !state\?\.singboxStartedAt\)\) return undefined/);
|
||||
assert.match(overview, /trafficSourceError[\s\S]*Трафик не обновляется · последние данные/);
|
||||
assert.match(overview, /client-subscription-drawer\$\{subscriptionOpen \? ' is-open' : ''\}/);
|
||||
assert.match(overview, /const confirmingDeleteRef = useRef\(confirmingDelete\)[\s\S]*if \(confirmingDeleteRef\.current\) return/);
|
||||
assert.match(overview, /onClick=\{\(\) => setConfirmingDelete\(true\)\}[\s\S]*open=\{confirmingDelete\}[\s\S]*onCancel=\{\(\) => setConfirmingDelete\(false\)\}/);
|
||||
assert.match(overview, /aria-label=\{isGateway[\s\S]*Остановить Harbor Connect[\s\S]*Запустить Harbor Connect/);
|
||||
assert.match(overview, /className="client-power-control client-tooltip-anchor"[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Сначала добавьте подписку и выберите сервер/);
|
||||
assert.match(overview, /const powerButton = <button[\s\S]*className="client-power"[\s\S]*\{isGateway \? <span[\s\S]*<\/span> : powerButton\}/);
|
||||
assert.match(overview, /isGateway && <DevicesPanel[\s\S]*snapshot=\{deviceSnapshot\}/);
|
||||
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
|
||||
assert.match(feature, /samples=\{globalTraffic\?\.history \|\| \[\]\}[\s\S]*capacity=\{feature\.snapshot\?\.trafficHistoryCapacity \|\| 120\}[\s\S]*routeLabel="Gateway"/);
|
||||
assert.match(connection, /<section className="client-power-section"[\s\S]*client-connection-title[\s\S]*\{serverSlot\}[\s\S]*client-state-detail[\s\S]*client-proxies/);
|
||||
assert.ok(powerStart >= 0 && connectionPanelStart >= 0 && trafficStart > connectionPanelStart, 'traffic summary follows the connection panel');
|
||||
assert.equal((feature.match(/className="client-gateway-summary"/g) || []).length, 1);
|
||||
assert.doesNotMatch(feature, /<TrafficChart[\s\S]{0,240}scale=/);
|
||||
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 client-tooltip-anchor"[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Сначала добавьте подписку и выберите сервер/);
|
||||
assert.match(connection, /const powerButton = <button[\s\S]*className="client-power"[\s\S]*\{isGateway \? <span[\s\S]*<\/span> : powerButton\}/);
|
||||
assert.match(overview, /isGateway && <DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
|
||||
assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseDeviceSnapshot } from '../../.test-dist/src/web/features/devices/deviceSnapshot.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8');
|
||||
const panel = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesPanel.tsx'), 'utf8');
|
||||
const model = fs.readFileSync(path.join(root, 'src/web/features/devices/deviceSnapshot.ts'), 'utf8');
|
||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/devices/index.ts'), 'utf8');
|
||||
|
||||
test('devices feature is the sole public owner and legacy component paths are gone', () => {
|
||||
assert.match(boundary, /DevicesPanel[\s\S]*DevicesToggle,[\s\S]*GatewayTrafficSummary,[\s\S]*useDevicesFeature/);
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/components/DevicesPanel.jsx')), false);
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/components/TrafficChart.jsx')), false);
|
||||
assert.equal((page.match(/useDevicesFeature\(/g) || []).length, 1);
|
||||
assert.equal((page.match(/<DevicesToggle/g) || []).length, 1);
|
||||
assert.equal((page.match(/<GatewayTrafficSummary/g) || []).length, 1);
|
||||
assert.equal((page.match(/<DevicesPanel/g) || []).length, 1);
|
||||
assert.match(page, /useDevicesFeature\(\{[\s\S]*listDevices: actions\.listDevices[\s\S]*refreshDevices: actions\.refreshDevices[\s\S]*updateDevice: actions\.updateDevice[\s\S]*setDevicePolicy: actions\.setDevicePolicy[\s\S]*\}\)/);
|
||||
assert.match(page, /<DevicesPanel feature=\{devicesFeature\} \/>/);
|
||||
assert.doesNotMatch(page, /DEVICE_AUTO_REFRESH_MS|deviceSnapshot|deviceStatus|deviceError|devicesRefreshing|deviceRefreshCycle|devicesPanelRef|devicesToggleRef|devicesCloseRef|function loadDevices|client-devices-toggle|className="client-gateway-summary"/);
|
||||
assert.doesNotMatch([feature, panel].join('\n'), /from ['"][^'"]*\/api\/|ConnectionPanel|SubscriptionFeature|RoutingFeature|DiagnosticsPanel/);
|
||||
assert.doesNotMatch(panel, /listDevices|requestDeviceUpdate|setDevicePolicy|STATE_CONFLICT|DEVICE_POLICY_APPLY_FAILED|parseDeviceSnapshot/);
|
||||
});
|
||||
|
||||
test('device controller preserves Gateway-only polling, monotonic publication and drawer lifecycle', () => {
|
||||
assert.match(feature, /if \(!isGateway\) return undefined;[\s\S]*load\(\)/);
|
||||
assert.match(feature, /discover \? refreshDevices\(\) : listDevices\(\)/);
|
||||
assert.match(feature, /setTimeout\(\(\) => load\(true\), DEVICE_AUTO_REFRESH_MS\)/);
|
||||
assert.match(feature, /setSnapshot\(\(current\) => !current \|\| next\.revision > current\.revision \? next : current\)/);
|
||||
assert.match(feature, /finally \{[\s\S]*setRefreshing\(false\)[\s\S]*setRefreshCycle/);
|
||||
assert.match(feature, /closeRef\.current\?\.focus\(\)[\s\S]*panelRef\.current\?\.contains[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
||||
assert.match(page, /<DevicesToggle[\s\S]*devicesFeature\.toggle\(\)/);
|
||||
assert.match(page, /<GatewayTrafficSummary feature=\{devicesFeature\} now=\{now\}/);
|
||||
});
|
||||
|
||||
test('all unknown inventory results pass one identity-preserving runtime parser', () => {
|
||||
const observedAt = '2026-08-08T12:34:56.000Z';
|
||||
const valid = {
|
||||
revision: 3,
|
||||
devices: [{
|
||||
id: 'dev_0123456789abcdef',
|
||||
alias: null,
|
||||
hostname: null,
|
||||
ip: null,
|
||||
lastSeenAt: null,
|
||||
status: 'online',
|
||||
pinned: true,
|
||||
downloadBytes: '12',
|
||||
uploadBytes: '30',
|
||||
proxyDownloadBytes: '0',
|
||||
proxyUploadBytes: '0',
|
||||
policyStatus: 'applied',
|
||||
policyError: null,
|
||||
desiredPolicy: 'vpn',
|
||||
appliedPolicy: 'vpn',
|
||||
confidence: 'high',
|
||||
trafficHistory: [{ observedAt, gatewayBytes: '42', proxyBytes: '0' }],
|
||||
}],
|
||||
trafficHistoryCapacity: 120,
|
||||
traffic: {
|
||||
gatewayBytes: '42',
|
||||
proxyBytes: '0',
|
||||
totalBytes: '42',
|
||||
gatewayObservedAt: observedAt,
|
||||
proxyObservedAt: null,
|
||||
observedAt: observedAt,
|
||||
history: [{ observedAt, gatewayBytes: '42', proxyBytes: '0' }],
|
||||
},
|
||||
source: {
|
||||
kind: 'neighbor',
|
||||
lastObservedAt: observedAt,
|
||||
error: null,
|
||||
traffic: {
|
||||
lastObservedAt: observedAt,
|
||||
error: null,
|
||||
proxy: { lastObservedAt: null, error: null },
|
||||
},
|
||||
policy: { lastAppliedAt: null, error: null },
|
||||
},
|
||||
extra: { retained: true },
|
||||
};
|
||||
assert.equal(parseDeviceSnapshot(valid), valid);
|
||||
for (const invalid of [
|
||||
null,
|
||||
{},
|
||||
{ ...valid, revision: -1 },
|
||||
{ ...valid, revision: 1.5 },
|
||||
{ ...valid, devices: undefined },
|
||||
{ ...valid, trafficHistoryCapacity: 0 },
|
||||
{ ...valid, traffic: undefined },
|
||||
{ ...valid, source: undefined },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], id: 'not-a-device' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], downloadBytes: 'not-bytes' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], uploadBytes: '-1' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], proxyUploadBytes: 1 }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], status: 'connected' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], desiredPolicy: 'automatic' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], policyStatus: 'queued' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], lastSeenAt: 'not-a-date' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], trafficHistory: [{ observedAt, gatewayBytes: '1' }] }] },
|
||||
{ ...valid, traffic: { ...valid.traffic, totalBytes: -4 } },
|
||||
{ ...valid, traffic: { ...valid.traffic, observedAt: 'not-a-date' } },
|
||||
{ ...valid, source: { ...valid.source, kind: 'arp' } },
|
||||
{ ...valid, source: { ...valid.source, traffic: { proxy: [] } } },
|
||||
]) assert.throws(() => parseDeviceSnapshot(invalid), TypeError);
|
||||
assert.match(feature, /publish\(await \(discover \? refreshDevices\(\) : listDevices\(\)\)\)/);
|
||||
assert.ok((feature.match(/parseDeviceSnapshot\(await/g) || []).length >= 6);
|
||||
assert.match(feature, /DEVICE_POLICY_APPLY_FAILED[\s\S]*publish\(parseDeviceSnapshot\(await listDevices\(\)\)\)/);
|
||||
assert.doesNotMatch(model, /\sas\s(?:DeviceSnapshot|Record<string, unknown>)/);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseConnectivityResult } from '../../.test-dist/src/web/features/diagnostics/connectivityResult.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8');
|
||||
const panel = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8');
|
||||
const model = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/connectivityResult.ts'), 'utf8');
|
||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/index.ts'), 'utf8');
|
||||
|
||||
const ip = { source: 'cloudflare', address: '198.51.100.10', extra: true };
|
||||
const site = { id: 'google', status: 'available', httpStatus: 204, latencyMs: 120 };
|
||||
const pathResult = {
|
||||
available: true,
|
||||
internetAvailable: true,
|
||||
ipv4: { addresses: ['198.51.100.10'], sources: [ip] },
|
||||
ipv6: null,
|
||||
ipv6Source: null,
|
||||
sites: [site],
|
||||
};
|
||||
const valid = {
|
||||
checkedAt: '2026-08-08T12:00:00.000Z',
|
||||
direct: pathResult,
|
||||
vpn: { ...pathResult, server: { id: 'server-1', label: 'Server 1' } },
|
||||
extra: { retained: true },
|
||||
};
|
||||
|
||||
test('diagnostics feature is the sole owner while the conditional panel keeps reset semantics', () => {
|
||||
assert.match(boundary, /ConnectivityDiagnosticsPanel[\s\S]*DiagnosticsToggle,[\s\S]*useDiagnosticsFeature/);
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/components/ConnectivityDiagnosticsPanel.jsx')), false);
|
||||
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, /\{diagnosticsAvailable && <ConnectivityDiagnosticsPanel[\s\S]*feature=\{diagnosticsFeature\}/);
|
||||
assert.match(page, /if \(!diagnosticsAvailable\) diagnosticsFeature\.close\(\)/);
|
||||
assert.doesNotMatch(page, /diagnosticsOpen|setDiagnosticsOpen|diagnosticsPanelRef|diagnosticsToggleRef|diagnosticsCloseRef|client-diagnostics-toggle/);
|
||||
assert.doesNotMatch(feature, /setResult|customServices|localStorage|runConnectivityDiagnostics|activeTarget|removingServiceId/);
|
||||
assert.match(feature, /closeRef\.current\?\.focus\(\)[\s\S]*panelRef\.current\?\.contains[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
||||
assert.doesNotMatch([feature, panel].join('\n'), /from ['"][^'"]*\/api\/|ConnectionPanel|SubscriptionFeature|RoutingFeature|DevicesFeature/);
|
||||
});
|
||||
|
||||
test('unknown target and legacy-full results pass one identity-preserving parser before use', () => {
|
||||
assert.equal(parseConnectivityResult(valid), valid);
|
||||
const legacyFull = {
|
||||
...valid,
|
||||
direct: {
|
||||
...valid.direct,
|
||||
ipv4: { ...valid.direct.ipv4, sources: [ip, { source: 'ipify', address: null }] },
|
||||
sites: [site, { id: 'youtube', status: 'responded', httpStatus: 403, latencyMs: null }],
|
||||
},
|
||||
};
|
||||
assert.equal(parseConnectivityResult(legacyFull), legacyFull);
|
||||
for (const invalid of [
|
||||
null,
|
||||
{},
|
||||
{ ...valid, direct: undefined },
|
||||
{ ...valid, direct: { ...valid.direct, available: 'yes' } },
|
||||
{ ...valid, direct: { ...valid.direct, ipv4: { addresses: [], sources: [{}] } } },
|
||||
{ ...valid, direct: { ...valid.direct, ipv6: 6 } },
|
||||
{ ...valid, direct: { ...valid.direct, sites: [{ ...site, id: '' }] } },
|
||||
{ ...valid, direct: { ...valid.direct, sites: [{ ...site, status: 'blocked' }] } },
|
||||
{ ...valid, direct: { ...valid.direct, sites: [{ ...site, latencyMs: -1 }] } },
|
||||
{ ...valid, vpn: { ...valid.vpn, server: undefined } },
|
||||
{ ...valid, vpn: { ...valid.vpn, server: { id: 1, label: 'Server' } } },
|
||||
]) assert.throws(() => parseConnectivityResult(invalid), TypeError);
|
||||
assert.match(panel, /parseConnectivityResult\(await runConnectivityDiagnostics\(customServices, target\)\)/);
|
||||
assert.match(panel, /const legacyFullResult = partial\.direct\.ipv4\.sources\.length > 1 \|\| partial\.direct\.sites\.length > 1/);
|
||||
assert.match(panel, /next = legacyFullResult \? partial : mergeResult\(next, partial\)/);
|
||||
assert.doesNotMatch(model, /\sas\s(?:ConnectivityResult|Record<string, unknown>)/);
|
||||
});
|
||||
|
||||
test('serial probes, storage and editor behavior stay panel-owned', () => {
|
||||
assert.match(panel, /const targets = \[[\s\S]*CONNECTIVITY_IP_SOURCES\.map[\s\S]*sites\.map/);
|
||||
assert.match(panel, /for \(const target of targets\) \{[\s\S]*setActiveTarget\(target\)[\s\S]*await runConnectivityDiagnostics\(customServices, target\)[\s\S]*if \(legacyFullResult\) break/);
|
||||
assert.match(panel, /CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services'/);
|
||||
assert.match(panel, /HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services'/);
|
||||
assert.match(panel, /slice\(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES\)/);
|
||||
assert.match(panel, /parsed\.protocol !== 'https:'/);
|
||||
assert.match(panel, /document\.startViewTransition\(update\)/);
|
||||
assert.match(panel, /retryable: Boolean\(Reflect\.get\(value, 'retryable'\)\)/);
|
||||
assert.match(panel, /requestDetails\(error\)[\s\S]*requestError\.retryable/);
|
||||
});
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
classifySyncError,
|
||||
harborReducer,
|
||||
initialHarborState,
|
||||
} from '../../src/web/state/harborReducer.js';
|
||||
} from '../../.test-dist/src/web/state/harborReducer.js';
|
||||
import { parseHarborState } from '../../.test-dist/src/web/api/harborClient.js';
|
||||
import { createStateSnapshot } from '../../.test-dist/src/shared/contracts/state.js';
|
||||
|
||||
const snapshot = (revision, desiredServerId = '', serverIds = ['one', 'two']) => ({
|
||||
apiVersion: 1,
|
||||
@@ -26,6 +28,40 @@ function deferred() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('typed Harbor client validates unknown state and isolates wire compatibility fields', () => {
|
||||
const snapshot = createStateSnapshot({
|
||||
storedState: {},
|
||||
runtime: { running: false },
|
||||
gatewayAuto: null,
|
||||
appMode: 'client',
|
||||
configExists: false,
|
||||
subscriptionHost: '',
|
||||
now: new Date('2026-07-11T12:00:00.000Z'),
|
||||
});
|
||||
const parsed = parseHarborState({
|
||||
...snapshot,
|
||||
proxyPort: 9082,
|
||||
configExists: true,
|
||||
gatewayAuto: { available: true },
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed.clientRuntime, {
|
||||
proxyPort: 9082,
|
||||
configured: true,
|
||||
gatewayAvailable: true,
|
||||
});
|
||||
assert.equal(Object.hasOwn(parsed, 'proxyPort'), false);
|
||||
let incompatible;
|
||||
try {
|
||||
parseHarborState({ ...snapshot, revision: -1 });
|
||||
} catch (error) {
|
||||
incompatible = error;
|
||||
}
|
||||
assert.equal(incompatible.code, 'INCOMPATIBLE_API');
|
||||
const failed = harborReducer(initialHarborState, { type: 'sync-failed', error: incompatible });
|
||||
assert.equal(failed.transport.bootStatus, 'incompatible-api');
|
||||
});
|
||||
|
||||
test('data invariant: an older polling promise cannot replace a newer mutation snapshot', async () => {
|
||||
let state = receive(initialHarborState, snapshot(1, 'one'));
|
||||
const poll = deferred();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
|
||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/instructions/index.ts'), 'utf8');
|
||||
const blocks = fs.readFileSync(path.join(root, 'src/web/features/instructions/instructionBlocks.ts'), 'utf8');
|
||||
const prometheus = fs.readFileSync(path.join(root, 'src/web/features/instructions/prometheus.ts'), 'utf8');
|
||||
|
||||
test('instructions feature is the sole owner behind one public boundary', () => {
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/instructions.js')), false);
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/prometheus.js')), false);
|
||||
assert.match(boundary, /InstructionsPanel,[\s\S]*InstructionsToggle,[\s\S]*useInstructionsFeature/);
|
||||
assert.doesNotMatch(boundary, /instructionBlocks|prometheusScrapeConfig|grafanaDashboardJson/);
|
||||
assert.equal((page.match(/useInstructionsFeature\(/g) || []).length, 1);
|
||||
assert.equal((page.match(/<InstructionsToggle/g) || []).length, 1);
|
||||
assert.equal((page.match(/<InstructionsPanel/g) || []).length, 1);
|
||||
assert.match(page, /from '..\/features\/instructions\/index\.js'/);
|
||||
assert.doesNotMatch(page, /InstructionStep|InstructionBlock|instructionBlocks|openInstructionId|instructionsPanelRef|instructionsToggleRef|instructionsCloseRef|setInstructionsOpen|client-instruction-block/);
|
||||
assert.doesNotMatch(feature, /from ['"][^'"]*\/api(?:\/|\.js)|SubscriptionFeature|RoutingFeature|DevicesFeature|DiagnosticsFeature/);
|
||||
});
|
||||
|
||||
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.doesNotMatch(page, /if \(!instructionsAvailable\)|instructionsAvailable/);
|
||||
assert.match(feature, /const \[openInstructionId, setOpenInstructionId\] = useState\(''\)/);
|
||||
assert.match(feature, /function InstructionBlock[\s\S]*const \[copyFeedback, setCopyFeedback\] = useState/);
|
||||
assert.match(feature, /clearTimeout\(copyTimer\.current\)[\s\S]*setTimeout\(\(\) => setCopyFeedback\(null\), 800\)/);
|
||||
assert.match(feature, /requestAnimationFrame\(\(\) => closeRef\.current\?\.focus\(\)\)[\s\S]*panelRef\.current\?\.contains[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
||||
assert.match(feature, /addEventListener\('pointerdown', closeOutside\)/);
|
||||
assert.match(feature, /flushSync[\s\S]*prefers-reduced-motion: reduce[\s\S]*document\.startViewTransition\(update\)/);
|
||||
});
|
||||
|
||||
test('private content helpers preserve guide, copy and monitoring contracts', () => {
|
||||
assert.match(page, /const gatewayAddress = isGateway \? window\.location\.hostname : '127\.0\.0\.1'/);
|
||||
assert.match(page, /const controlHost = window\.location\.host \|\| `\$\{gatewayAddress\}:3456`/);
|
||||
assert.match(page, /port: state\?\.clientRuntime\?\.proxyPort \|\| \(isGateway \? 8080 : 8082\)/);
|
||||
assert.match(blocks, /\.\.\.\(isGateway \? \[\{/);
|
||||
assert.match(blocks, /id: 'router'[\s\S]*id: 'prometheus'/);
|
||||
assert.match(blocks, /prometheusScrapeConfig\(controlHost\)[\s\S]*label: 'Grafana dashboard'/);
|
||||
assert.match(prometheus, /\.\.\/\.\.\/\.\.\/\.\.\/monitoring\/grafana\/harbor-gateway\.json/);
|
||||
assert.match(prometheus, /scrape_interval: 30s[\s\S]*scrape_timeout: 3s[\s\S]*metrics_path: \/metrics/);
|
||||
assert.match(feature, /target="_blank" rel="noreferrer"/);
|
||||
assert.match(feature, /client-copy-label">Скопировать/);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createOperationRegistry,
|
||||
OPERATION_CONFLICTS,
|
||||
operationBlocked,
|
||||
} from '../../src/web/state/operations.js';
|
||||
} from '../../.test-dist/src/web/state/operations.js';
|
||||
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
|
||||
@@ -3,12 +3,14 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readStyleSource } from './style-source.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||
const instructions = fs.readFileSync(path.join(root, 'src/web/instructions.js'), 'utf8');
|
||||
const prometheus = fs.readFileSync(path.join(root, 'src/web/prometheus.js'), 'utf8');
|
||||
const server = fs.readFileSync(path.join(root, 'src/server/index.js'), 'utf8');
|
||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
|
||||
const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/instructionBlocks.ts'), 'utf8');
|
||||
const prometheus = fs.readFileSync(path.join(root, 'src/web/features/instructions/prometheus.ts'), 'utf8');
|
||||
const styles = readStyleSource(root);
|
||||
const dashboard = JSON.parse(fs.readFileSync(path.join(root, 'monitoring/grafana/harbor-gateway.json'), 'utf8'));
|
||||
|
||||
test('Gateway info drawer contains copyable Prometheus and Grafana instructions', () => {
|
||||
@@ -21,21 +23,12 @@ test('Gateway info drawer contains copyable Prometheus and Grafana instructions'
|
||||
assert.match(prometheus, /monitoring\/grafana\/harbor-gateway\.json/);
|
||||
assert.match(prometheus, /scrape_interval: 30s[\s\S]*metrics_path: \/metrics/);
|
||||
assert.match(overview, /const controlHost = window\.location\.host/);
|
||||
assert.match(overview, /client-copy-label">Скопировать/);
|
||||
assert.doesNotMatch(overview, /prometheus-toggle|Prometheus<\/span><\/button>/);
|
||||
assert.match(feature, /client-copy-label">Скопировать/);
|
||||
assert.doesNotMatch(`${overview}\n${feature}`, /prometheus-toggle|Prometheus<\/span><\/button>/);
|
||||
assert.match(styles, /\.client-instruction-copy-button \{[\s\S]*width: 104px;[\s\S]*min-width: 104px/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-copy-feedback/);
|
||||
});
|
||||
|
||||
test('metrics route reads the current snapshot before static fallback', () => {
|
||||
const metricsRoute = server.indexOf("requestUrl.pathname === '/metrics'");
|
||||
const staticFallback = server.indexOf(': serveStatic(req, res)');
|
||||
|
||||
assert.ok(metricsRoute >= 0 && metricsRoute < staticFallback);
|
||||
assert.match(server, /requestUrl\.pathname === '\/metrics'[\s\S]*deviceInventory\.metricsSnapshot\(\)/);
|
||||
assert.doesNotMatch(server.slice(metricsRoute, staticFallback), /deviceInventory\.refresh\(/);
|
||||
});
|
||||
|
||||
test('Grafana dashboard uses one all-or-one device scope and shows active device speed', () => {
|
||||
const expressions = dashboard.panels.flatMap((panel) => panel.targets || []).map(({ expr }) => expr).filter(Boolean);
|
||||
const titles = dashboard.panels.map(({ title }) => title);
|
||||
|
||||
@@ -3,10 +3,19 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readStyleSource } from './style-source.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||
const diagnostics = fs.readFileSync(path.join(root, 'src/web/components/ConnectivityDiagnosticsPanel.jsx'), 'utf8');
|
||||
const styles = readStyleSource(root);
|
||||
const layoutStyles = fs.readFileSync(path.join(root, 'src/web/styles/layout.css'), 'utf8');
|
||||
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const connection = fs.readFileSync(path.join(root, 'src/web/features/connection/ConnectionPanel.tsx'), 'utf8');
|
||||
const subscription = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
|
||||
const routing = fs.readFileSync(path.join(root, 'src/web/features/routing/RoutingFeature.tsx'), 'utf8');
|
||||
const devices = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8');
|
||||
const diagnosticsFeature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8');
|
||||
const diagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8');
|
||||
const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
|
||||
|
||||
function rule(selector, source = styles) {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -69,8 +78,8 @@ test('server rows scroll without moving the subscription column or showing a scr
|
||||
});
|
||||
|
||||
test('tablet and mobile regions use normal flow with viewport-safe widths', () => {
|
||||
const responsive = /@media \(max-width: 920px\) \{([\s\S]*?)\n\}\n\n@media \(max-width: 560px\)/.exec(styles)?.[1] || '';
|
||||
const mobile = /@media \(max-width: 560px\) \{([\s\S]*?)\n\}\n\n@media \(prefers-reduced-motion/.exec(styles)?.[1] || '';
|
||||
const responsive = /@media \(max-width: 920px\) \{([\s\S]*?)\n\}\n\n@media \(max-width: 560px\)/.exec(layoutStyles)?.[1] || '';
|
||||
const mobile = /@media \(max-width: 560px\) \{([\s\S]*?)\n\}\s*$/.exec(layoutStyles)?.[1] || '';
|
||||
|
||||
assert.match(responsive, /grid-template-columns:\s*minmax\(0, 1fr\)/);
|
||||
assert.match(responsive, /\.client-power-section,[\s\S]*\.client-form,[\s\S]*grid-column:\s*1/);
|
||||
@@ -109,21 +118,25 @@ test('secondary menus share one right rail and both drawers open from the right'
|
||||
const zIndex = (selector) => Number(/z-index:\s*(\d+)/.exec(rule(selector))?.[1]);
|
||||
|
||||
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
|
||||
assert.match(component, /client-subscription-toggle[\s\S]*client-instructions-toggle[\s\S]*client-devices-toggle/);
|
||||
assert.match(component, /aria-controls="client-subscription-drawer"/);
|
||||
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
|
||||
assert.match(component, /client-instructions-toggle[\s\S]*client-devices-toggle[\s\S]*client-diagnostics-toggle[\s\S]*client-local-rules-toggle/);
|
||||
assert.match(component, /client-diagnostics-toggle/);
|
||||
assert.match(component, /<SubscriptionToggle[\s\S]*<InstructionsToggle[\s\S]*<DevicesToggle/);
|
||||
assert.match(subscription, /aria-controls="client-subscription-drawer"/);
|
||||
assert.match(component, /<InstructionsToggle[\s\S]*<RoutingToggle/);
|
||||
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
|
||||
assert.match(diagnosticsFeature, /client-diagnostics-toggle/);
|
||||
assert.match(component, /<ConnectivityDiagnosticsPanel/);
|
||||
assert.doesNotMatch(component, /\{isGateway && <button[\s\S]{0,120}diagnosticsToggleRef/);
|
||||
assert.doesNotMatch(component, /diagnosticsToggleRef|diagnosticsPanelRef|diagnosticsCloseRef/);
|
||||
assert.match(component, /<ConnectivityDiagnosticsPanel[\s\S]*isGateway=\{isGateway\}/);
|
||||
assert.match(component, /Локальные правила недоступны: сейчас работают правила Gateway/);
|
||||
assert.match(routing, /Локальные правила недоступны: сейчас работают правила Gateway/);
|
||||
assert.match(rule('.client-secondary-menu'), /right:\s*max\(14px, env\(safe-area-inset-right\)\)/);
|
||||
assert.match(rule('.client-secondary-menu'), /display:\s*grid/);
|
||||
assert.match(disabledRulesLabel, /opacity:\s*0/);
|
||||
assert.match(disabledRulesLabel, /filter:\s*blur\(5px\)/);
|
||||
assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/);
|
||||
assert.match(component, /client-rail-info-ring[\s\S]*client-rail-device-primary[\s\S]*client-rail-device-secondary[\s\S]*client-rail-device-link[\s\S]*client-rail-diagnostics-base[\s\S]*client-rail-diagnostics-pulse[\s\S]*client-rail-rule-knob is-top[\s\S]*client-rail-rule-knob is-bottom/);
|
||||
assert.match(instructions, /client-rail-info-ring/);
|
||||
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
|
||||
assert.match(diagnosticsFeature, /client-rail-diagnostics-base[\s\S]*client-rail-diagnostics-pulse/);
|
||||
assert.match(devices, /client-rail-device-primary[\s\S]*client-rail-device-secondary[\s\S]*client-rail-device-link/);
|
||||
assert.match(routing, /client-rail-rule-knob is-top[\s\S]*client-rail-rule-knob is-bottom/);
|
||||
assert.match(styles, /client-rail-info-refill[\s\S]*client-rail-device-left[\s\S]*client-rail-device-right[\s\S]*client-rail-diagnostics-pulse[\s\S]*client-rail-rule-top[\s\S]*client-rail-rule-bottom/);
|
||||
assert.match(styles, /\.client-rail-diagnostics-pulse \{[\s\S]*stroke-dasharray: 0\.16 0\.84/);
|
||||
assert.match(styles, /@keyframes client-rail-diagnostics-pulse[\s\S]*stroke-dashoffset: 1[\s\S]*stroke-dashoffset: 0/);
|
||||
@@ -141,10 +154,10 @@ test('secondary menus share one right rail and both drawers open from the right'
|
||||
);
|
||||
assert.match(rule('.client-instructions'), /width:\s*min\(470px, 100vw\)/);
|
||||
assert.match(rule('.client-local-rules'), /width:\s*min\(480px, 100vw\)/);
|
||||
assert.match(component, /className={`client-drawer client-instructions/);
|
||||
assert.match(component, /className={`client-drawer client-local-rules/);
|
||||
assert.match(component, /client-drawer client-subscription-drawer/);
|
||||
assert.match(component, /setSubscriptionOpen\(false\)[\s\S]*setDevicesOpen\(false\)[\s\S]*setDiagnosticsOpen\(false\)/);
|
||||
assert.match(instructions, /className={`client-drawer client-instructions/);
|
||||
assert.match(routing, /className={`client-drawer client-local-rules/);
|
||||
assert.match(subscription, /client-drawer client-subscription-drawer/);
|
||||
assert.match(component, /subscriptionFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)[\s\S]*instructionsFeature\.toggle\(\)/);
|
||||
});
|
||||
|
||||
test('connectivity diagnostics render stable compact tables before the first run', () => {
|
||||
@@ -160,13 +173,13 @@ test('connectivity diagnostics render stable compact tables before the first run
|
||||
assert.match(diagnostics, /client-diagnostics-refresh/);
|
||||
assert.match(diagnostics, /isGateway \? 'Gateway' : 'Connect'/);
|
||||
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
|
||||
assert.match(diagnostics, /\{error && <div className="client-diagnostics-feedback"/);
|
||||
assert.match(diagnostics, /\{Boolean\(error\) && <div className="client-diagnostics-feedback"/);
|
||||
assert.doesNotMatch(diagnostics, /SUMMARY_COPY|client-diagnostics-summary|client-diagnostics-time|checkedAt|Прямой маршрут/);
|
||||
assert.doesNotMatch(diagnostics, /PathDetails|client-diagnostics-details|Технические детали/);
|
||||
assert.doesNotMatch(rule('.client-diagnostics-feedback'), /min-height:/);
|
||||
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
|
||||
assert.match(diagnostics, /for \(const target of targets\)/);
|
||||
assert.match(diagnostics, /api\.diagnostics\.connectivity\(customServices, target\)/);
|
||||
assert.match(diagnostics, /runConnectivityDiagnostics\(customServices, target\)/);
|
||||
assert.match(diagnostics, /const target = `ip:\$\{source\.id\}`;[\s\S]*activeTarget === target/);
|
||||
assert.match(diagnostics, /activeTarget === `site:\$\{site\.id\}`/);
|
||||
assert.match(diagnostics, /data-diagnostic-target=\{target\}/);
|
||||
@@ -180,13 +193,13 @@ test('connectivity diagnostics render stable compact tables before the first run
|
||||
test('duration and Gateway access keep stable geometry without tabs', () => {
|
||||
assert.match(rule('.client-state-detail'), /min-height:\s*44px/);
|
||||
assert.match(rule('.client-duration-toggle'), /min-height:\s*44px/);
|
||||
assert.match(component, /client-duration-word-row is-calendar/);
|
||||
assert.match(component, /client-duration-word-row is-clock/);
|
||||
assert.match(connection, /client-duration-word-row is-calendar/);
|
||||
assert.match(connection, /client-duration-word-row is-clock/);
|
||||
assert.match(rule('.client-duration-toggle > .client-tooltip'), /right:\s*calc\(100% \+ 12px\)/);
|
||||
assert.match(component, /\['gateway', 'GATEWAY'\]/);
|
||||
assert.match(component, /\['socks5', 'SOCKS5'\]/);
|
||||
assert.match(component, /\['http', 'HTTP'\]/);
|
||||
assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/);
|
||||
assert.match(connection, /\['gateway', 'GATEWAY'\]/);
|
||||
assert.match(connection, /\['socks5', 'SOCKS5'\]/);
|
||||
assert.match(connection, /\['http', 'HTTP'\]/);
|
||||
assert.doesNotMatch(`${component}\n${connection}`, /client-access-tabs|role="tab"|role="tabpanel"/);
|
||||
assert.match(rule('.client-proxies.is-gateway'), /width:\s*270px/);
|
||||
});
|
||||
|
||||
@@ -218,12 +231,12 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
|
||||
});
|
||||
|
||||
test('subscription validation waits for the provider and keeps diagnostics below errors', () => {
|
||||
assert.match(component, /api\.subscription\.validate\(normalizedSubscriptionUrl/);
|
||||
assert.match(component, /status: 'checking'/);
|
||||
assert.match(component, /status: 'valid'/);
|
||||
assert.match(component, /message: ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message/);
|
||||
assert.match(component, /subscriptionValidationStatus === 'checking' \? '…' : '×'/);
|
||||
assert.match(component, /if \(error\?\.context === 'subscription'\) onDismissError\(\)/);
|
||||
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\(\)/);
|
||||
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/);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/routing/RoutingFeature.tsx'), 'utf8');
|
||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/routing/index.ts'), 'utf8');
|
||||
|
||||
test('routing feature is the sole owner at the four existing composition positions', () => {
|
||||
assert.match(boundary, /RoutingDiscardDialog,[\s\S]*RoutingPanel,[\s\S]*RoutingPendingStatus,[\s\S]*RoutingToggle,[\s\S]*useRoutingFeature/);
|
||||
assert.equal((page.match(/useRoutingFeature\(/g) || []).length, 1);
|
||||
assert.equal((page.match(/<RoutingToggle/g) || []).length, 1);
|
||||
assert.equal((page.match(/<RoutingPendingStatus/g) || []).length, 1);
|
||||
assert.equal((page.match(/<RoutingPanel/g) || []).length, 1);
|
||||
assert.equal((page.match(/<RoutingDiscardDialog/g) || []).length, 1);
|
||||
assert.doesNotMatch(page, /ROUTE_RULE_OPTIONS|function RuleTypePicker|function LocalRulesPanel|localRulesBaselineRef|setLocalRulesDraft|id="discard-local-rules"|client-local-rules-toggle/);
|
||||
assert.doesNotMatch(feature, /from ['"][^'"]*\/api\/|ConnectionPanel|SubscriptionFeature|ServerPicker|DevicesPanel|DiagnosticsPanel/);
|
||||
});
|
||||
|
||||
test('routing controller preserves snapshot drafts, live status and guarded close/save semantics', () => {
|
||||
assert.match(feature, /const savedRules = route\?\.localRules \|\| \[\]/);
|
||||
assert.match(feature, /baselineRef\.current = JSON\.stringify\(savedRules\.map/);
|
||||
assert.match(feature, /setRules\(savedRules\.map\(createLocalRuleDraft\)\)/);
|
||||
assert.match(feature, /setRevision\(route\?\.localRulesRevision \|\| 0\)/);
|
||||
assert.match(feature, /if \(dirty\) \{[\s\S]*setConfirmingClose\(true\);[\s\S]*return false/);
|
||||
assert.match(feature, /const result = routingSaveState\(await onSave\(values, revision\)\)/);
|
||||
assert.match(feature, /if \(!result\) return;[\s\S]*baselineRef\.current = JSON\.stringify\(values\);[\s\S]*setRevision\(result\.localRulesRevision\)/);
|
||||
assert.match(feature, /if \(!connected \|\| !result\.localRulesPendingRestart\) setIsOpen\(false\)/);
|
||||
assert.match(feature, /Number\.isSafeInteger\(localRulesRevision\)[\s\S]*localRulesRevision as number\) < 0[\s\S]*typeof localRulesPendingRestart !== 'boolean'/);
|
||||
});
|
||||
|
||||
test('routing lifecycle and Page orchestration keep the existing guards and blocking scopes', () => {
|
||||
assert.match(feature, /keyboardEvent\.key !== 'Escape' \|\| keyboardEvent\.defaultPrevented/);
|
||||
assert.match(feature, /addEventListener\('beforeunload', warnBeforeUnload\)/);
|
||||
assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*removing: true/);
|
||||
assert.match(feature, /document\.startViewTransition\(update\)/);
|
||||
assert.match(feature, /operationBlocked\(operations, 'routeRules'\) \|\| rules\.some/);
|
||||
assert.match(page, /routingFeature\.isOpen && !routingFeature\.requestClose\(\)/);
|
||||
assert.match(page, /function openRouting\(\) \{[\s\S]*subscriptionFeature\.close\(\)[\s\S]*instructionsFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)[\s\S]*routingFeature\.open\(\)/);
|
||||
assert.match(page, /routingSlot=\{<RoutingPendingStatus[\s\S]*blocked=\{connectionBlocked\}/);
|
||||
assert.match(page, /<RoutingPanel[\s\S]*InlineError[\s\S]*InlineProgress/);
|
||||
});
|
||||
@@ -3,29 +3,35 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readStyleSource } from './style-source.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||
const popup = fs.readFileSync(path.join(root, 'src/web/components/ConfirmationPopup.jsx'), 'utf8');
|
||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const connection = fs.readFileSync(path.join(root, 'src/web/features/connection/ConnectionPanel.tsx'), 'utf8');
|
||||
const subscription = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
|
||||
const routing = fs.readFileSync(path.join(root, 'src/web/features/routing/RoutingFeature.tsx'), 'utf8');
|
||||
const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
|
||||
const dialog = fs.readFileSync(path.join(root, 'src/web/ui/ConfirmationDialog.tsx'), 'utf8');
|
||||
const styles = readStyleSource(root);
|
||||
|
||||
test('rule editor add latency stays constant and dirty exits are guarded', () => {
|
||||
const rowRule = /\.client-local-rule \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '';
|
||||
assert.doesNotMatch(rowRule, /--rule-index|calc\(/);
|
||||
assert.match(component, /addEventListener\('beforeunload', warnBeforeUnload\)/);
|
||||
assert.match(component, /requestCloseLocalRules\(\)/);
|
||||
assert.match(component, /localRulesPendingRestart/);
|
||||
assert.match(component, /activeLocalRules/);
|
||||
assert.match(component, /localRulesRevision/);
|
||||
assert.match(component, /Не сохранено/);
|
||||
assert.match(component, /if \(!runtimeActive\) return \['saved', 'Сохранено'\]/);
|
||||
assert.match(component, /Ждёт перезапуска/);
|
||||
assert.match(component, /Перезапустить VPN/);
|
||||
assert.match(component, /const localRulesPendingRestart = connected && state\?\.route\?\.localRulesPendingRestart === true/);
|
||||
assert.match(component, /if \(!connected \|\| !result\.state\.route\.localRulesPendingRestart\) setLocalRulesOpen\(false\)/);
|
||||
assert.match(component, /client-deletable-row/);
|
||||
assert.match(component, /className="client-delete-strike"[\s\S]*onAnimationEnd/);
|
||||
assert.doesNotMatch(component, /client-rule-delete-cross/);
|
||||
assert.match(component, /className="client-local-rule-delete"/);
|
||||
assert.match(routing, /addEventListener\('beforeunload', warnBeforeUnload\)/);
|
||||
assert.match(routing, /requestClose\(\)/);
|
||||
assert.match(routing, /pendingRestart/);
|
||||
assert.match(routing, /activeLocalRules/);
|
||||
assert.match(routing, /localRulesRevision/);
|
||||
assert.match(routing, /Не сохранено/);
|
||||
assert.match(routing, /if \(!runtimeActive\) return \['saved', 'Сохранено'\]/);
|
||||
assert.match(routing, /Ждёт перезапуска/);
|
||||
assert.match(routing, /Перезапустить VPN/);
|
||||
assert.match(routing, /const pendingRestart = connected && route\?\.localRulesPendingRestart === true/);
|
||||
assert.match(routing, /if \(!connected \|\| !result\.localRulesPendingRestart\) setIsOpen\(false\)/);
|
||||
assert.match(routing, /client-deletable-row/);
|
||||
assert.match(routing, /className="client-delete-strike"[\s\S]*onAnimationEnd/);
|
||||
assert.doesNotMatch(routing, /client-rule-delete-cross/);
|
||||
assert.match(routing, /className="client-local-rule-delete"/);
|
||||
assert.match(styles, /\.client-deletable-row\.is-removing > \.client-delete-strike[\s\S]*client-delete-strike/);
|
||||
assert.match(styles, /\.client-delete-strike \{[\s\S]*z-index: 100[\s\S]*background: transparent/);
|
||||
assert.match(styles, /\.client-deletable-row\.is-removing > :not\(\.client-delete-strike\)[\s\S]*z-index: 0/);
|
||||
@@ -40,37 +46,52 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
|
||||
});
|
||||
|
||||
test('critical confirmations share one accessible blocking popup', () => {
|
||||
assert.match(component, /id="stop-connection"/);
|
||||
assert.match(component, /id="discard-local-rules"/);
|
||||
assert.match(component, /id="delete-subscription"/);
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/components/ConfirmationPopup.jsx')), false);
|
||||
assert.equal(
|
||||
(component.match(/<ConfirmationDialog/g) || []).length
|
||||
+ (connection.match(/<ConfirmationDialog/g) || []).length
|
||||
+ (subscription.match(/<ConfirmationDialog/g) || []).length
|
||||
+ (routing.match(/<ConfirmationDialog/g) || []).length,
|
||||
3,
|
||||
);
|
||||
assert.match(connection, /id="stop-connection"/);
|
||||
assert.match(routing, /id="discard-local-rules"/);
|
||||
assert.match(subscription, /id="delete-subscription"/);
|
||||
assert.doesNotMatch(component, /client-local-rules-discard|client-delete-confirmation/);
|
||||
assert.match(popup, /role="alertdialog"/);
|
||||
assert.match(popup, /aria-modal="true"/);
|
||||
assert.match(popup, /querySelectorAll\(FOCUSABLE\)/);
|
||||
assert.match(popup, /element\.inert = true/);
|
||||
assert.match(popup, /requestAnimationFrame\(\(\) => cancelRef\.current\?\.focus\(\)\)/);
|
||||
assert.match(dialog, /role="alertdialog"/);
|
||||
assert.match(dialog, /aria-modal="true"/);
|
||||
assert.match(dialog, /aria-hidden=\{!open\}[\s\S]*inert=\{!open \? true : undefined\}/);
|
||||
assert.match(dialog, /querySelectorAll<HTMLElement>\(FOCUSABLE\)/);
|
||||
assert.match(dialog, /element\.inert = true/);
|
||||
assert.match(dialog, /requestAnimationFrame\(\(\) => cancelRef\.current\?\.focus\(\)\)/);
|
||||
assert.match(dialog, /event\.key === 'Escape' && !busyRef\.current/);
|
||||
assert.match(dialog, /event\.target === event\.currentTarget && !busy/);
|
||||
assert.match(dialog, /background\.forEach\(\(\{ element, inert \}\) => \{ element\.inert = inert; \}\)/);
|
||||
assert.match(dialog, /document\.body\.style\.overflow = previousOverflow/);
|
||||
assert.match(dialog, /requestAnimationFrame\(\(\) => previousFocus\?\.focus\?\.\(\)\)/);
|
||||
assert.match(dialog, /document\.querySelector\('\.app\.client-app'\) \|\| document\.body/);
|
||||
assert.match(styles, /\.client-confirmation-popup\.is-open[\s\S]*backdrop-filter: blur\(18px\)/);
|
||||
});
|
||||
|
||||
test('power click and native Enter cannot stop VPN without a separate confirmation', () => {
|
||||
assert.match(component, /className="client-power"[\s\S]*type="button"[\s\S]*onClick=\{toggleConnection\}/);
|
||||
assert.match(component, /if \(action\?\.type === 'stop'\) \{[\s\S]*setConfirmingStop\(true\);[\s\S]*return;[\s\S]*\}/);
|
||||
assert.doesNotMatch(component, /if \(action\?\.type === 'stop'\) return onStop\(\)/);
|
||||
assert.match(component, /id="stop-connection"[\s\S]*cancelLabel="Оставить включённым"[\s\S]*confirmLabel="Отключить VPN"[\s\S]*onConfirm=\{stopConnection\}/);
|
||||
assert.match(connection, /className="client-power"[\s\S]*type="button"[\s\S]*onClick=\{toggleConnection\}/);
|
||||
assert.match(connection, /if \(action\?\.type === 'stop'\) \{[\s\S]*setConfirmingStop\(true\);[\s\S]*return;[\s\S]*\}/);
|
||||
assert.doesNotMatch(connection, /if \(action\?\.type === 'stop'\) return onStop\(\)/);
|
||||
assert.match(connection, /id="stop-connection"[\s\S]*cancelLabel="Оставить включённым"[\s\S]*confirmLabel="Отключить VPN"[\s\S]*onConfirm=\{stopConnection\}/);
|
||||
});
|
||||
|
||||
test('copy feedback, drawers and Gateway access actions expose complete semantics', () => {
|
||||
assert.match(component, /className="client-live-region" role="status" aria-live="polite" aria-atomic="true"/);
|
||||
assert.match(component, /Не удалось скопировать/);
|
||||
assert.match(component, /Скопировано/);
|
||||
assert.match(component, /client-copy-feedback[^\n]*\{copyFeedback\.failed \? 'Ошибка' : 'Скопировано'\}/);
|
||||
assert.match(connection, /client-copy-feedback[^\n]*\{copyFeedback\.failed \? 'Ошибка' : 'Скопировано'\}/);
|
||||
assert.doesNotMatch(component, /ГОТОВО/);
|
||||
assert.doesNotMatch(component, />Error<|>Copied</);
|
||||
assert.match(component, /aria-label="Закрыть инструкции"/);
|
||||
assert.match(component, /aria-label="Закрыть локальные правила"/);
|
||||
assert.match(component, /instructionsCloseRef\.current\?\.focus\(\)/);
|
||||
assert.match(component, /localRulesCloseRef\.current\?\.focus\(\)/);
|
||||
assert.match(component, /aria-label={`Скопировать \$\{label\}: \$\{kind === 'gateway' \? gatewayAddress : proxyUrls\[kind\]\}`}/);
|
||||
assert.match(instructions, /aria-label="Закрыть инструкции"/);
|
||||
assert.match(routing, /aria-label="Закрыть локальные правила"/);
|
||||
assert.match(instructions, /closeRef\.current\?\.focus\(\)/);
|
||||
assert.match(routing, /closeRef\.current\?\.focus\(\)/);
|
||||
assert.match(connection, /aria-label={`Скопировать \$\{label\}: \$\{kind === 'gateway' \? gatewayAddress : proxyUrls\[kind\]\}`}/);
|
||||
assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/);
|
||||
assert.match(styles, /\.client-drawer-close \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
|
||||
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/);
|
||||
|
||||
@@ -3,17 +3,32 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readStyleSource } from './style-source.js';
|
||||
|
||||
import {
|
||||
autoServer,
|
||||
filterServers,
|
||||
groupServers,
|
||||
parseServerPingResults,
|
||||
SERVER_RESULT_WINDOW,
|
||||
} from '../../src/web/utils/serverPicker.js';
|
||||
} from '../../.test-dist/src/web/features/servers/serverPickerModel.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const picker = fs.readFileSync(path.join(root, 'src/web/components/ServerPicker.jsx'), 'utf8');
|
||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||
const picker = fs.readFileSync(path.join(root, 'src/web/features/servers/ServerPicker.tsx'), 'utf8');
|
||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/servers/index.ts'), 'utf8');
|
||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const styles = readStyleSource(root);
|
||||
|
||||
test('server picker has one public feature owner without legacy shims', () => {
|
||||
assert.equal(boundary.trim(), "export { ServerPicker } from './ServerPicker.js';");
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/components/ServerPicker.jsx')), false);
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/utils/serverPicker.js')), false);
|
||||
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\)/);
|
||||
});
|
||||
|
||||
const fixtures = (count) => Array.from({ length: count }, (_, index) => ({
|
||||
id: `srv-${String(count - index).padStart(3, '0')}`,
|
||||
@@ -37,6 +52,27 @@ test('server picker handles 1, 30 and 300 stable-ID servers with duplicate label
|
||||
assert.equal(SERVER_RESULT_WINDOW, 60);
|
||||
});
|
||||
|
||||
test('server picker validates unknown ping payloads before publishing results', () => {
|
||||
const result = { id: 'srv-1', ok: true, latency: 12, checkedAt: '2026-08-08T12:00:00.000Z', extra: 'kept' };
|
||||
const failed = { id: 'srv-2', ok: false, latency: null, error: 'timeout', checkedAt: '2026-08-08T12:00:01.000Z', extra: 'kept' };
|
||||
assert.deepEqual(parseServerPingResults({}), []);
|
||||
assert.equal(parseServerPingResults({ results: [result] })[0], result);
|
||||
assert.equal(parseServerPingResults({ results: [failed] })[0], failed);
|
||||
for (const payload of [
|
||||
null,
|
||||
[],
|
||||
{ results: null },
|
||||
{ results: [{}] },
|
||||
{ results: [{ id: '', ok: true }] },
|
||||
{ results: [{ id: 'srv-1', ok: 'yes' }] },
|
||||
{ results: [{ id: 'srv-1', latency: -1 }] },
|
||||
{ results: [{ id: 'srv-1', checkedAt: 123 }] },
|
||||
]) {
|
||||
assert.throws(() => parseServerPingResults(payload), TypeError);
|
||||
}
|
||||
assert.match(picker, /parseServerPingResults\(await pingServers\(ids\)\)/);
|
||||
});
|
||||
|
||||
test('server picker checks health only on manual refresh and bounds the result window', () => {
|
||||
assert.doesNotMatch(overview, /pingAll|servers\.ping/);
|
||||
assert.doesNotMatch(picker, /checkVisible\(\);/);
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
analyzeSelectorList,
|
||||
createStyleWitnesses,
|
||||
createStyleLedger,
|
||||
readStyleSource,
|
||||
readStyleWitnesses,
|
||||
selectorsWithoutWitness,
|
||||
styleLeafPaths,
|
||||
variableReferences,
|
||||
} from './style-source.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const stylesRoot = path.join(root, 'src/web/styles');
|
||||
const indexPath = path.join(stylesRoot, 'index.css');
|
||||
const index = fs.readFileSync(indexPath, 'utf8');
|
||||
const expectedImports = [
|
||||
'./tokens.css',
|
||||
'./base.css',
|
||||
'./features/devices.css',
|
||||
'./features/routing.css',
|
||||
'./features/instructions.css',
|
||||
'./features/connection.css',
|
||||
'./features/subscription.css',
|
||||
'./features/servers.css',
|
||||
'./primitives.css',
|
||||
'./features/diagnostics.css',
|
||||
'./layout.css',
|
||||
'./themes.css',
|
||||
];
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 742,
|
||||
customProperties: 31,
|
||||
declarations: 2793,
|
||||
important: 0,
|
||||
keyframes: 48,
|
||||
media: 8,
|
||||
rules: 824,
|
||||
variableReferences: 323,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: '9e85cea58c179358dac8767ed987433e3e1f4974324999dba1803a49cbd6f4f6',
|
||||
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
|
||||
declarations: '2e733c57dc0d89369e46a0eb44c1d67d09d1d40c28f7c36813c5fec607539be7',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
|
||||
keyframes: '853c54c05759d9db27bea891254913e1651b1f601059ad9e3e2baa2c55ef1b2b',
|
||||
ruleDeclarationSequences: '4a741c5ee09f9cd694e058609b4362cf796c97009d83886c5c8b4b4fa4d73b52',
|
||||
selectors: '2da3202f84a79a8cf68e0cf262d36aa4b7756268133135469c086317dcbb82ff',
|
||||
variableReferences: 'e8dc6951d717dbd6a9aa51a495798a56568b00ce0bbd7fb6edc7c758a81ddb6e',
|
||||
witnesses: '68723a5909eb1a0972a70cde2be75e63e90abda61babe98db713414bda6a0c23',
|
||||
},
|
||||
};
|
||||
|
||||
test('public stylesheet exposes exactly twelve flat semantic owners', () => {
|
||||
const imports = Array.from(index.matchAll(/^@import ['"](\.\/[^'"]+\.css)['"];$/gm), ([, file]) => file);
|
||||
assert.deepEqual(imports, expectedImports);
|
||||
assert.equal(index, `${expectedImports.map((file) => `@import '${file}';`).join('\n')}\n`);
|
||||
assert.equal(new Set(imports).size, imports.length);
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/web/styles.css')), false);
|
||||
|
||||
const actualCssFiles = fs.readdirSync(stylesRoot, { recursive: true, withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.css'))
|
||||
.map((entry) => path.relative(stylesRoot, path.join(entry.parentPath, entry.name)).replaceAll('\\', '/'))
|
||||
.sort();
|
||||
assert.deepEqual(actualCssFiles, ['index.css', ...expectedImports.map((file) => file.slice(2))].sort());
|
||||
|
||||
for (const leaf of styleLeafPaths(root)) {
|
||||
const source = fs.readFileSync(leaf, 'utf8');
|
||||
assert.doesNotMatch(source, /@import|@layer/, path.relative(root, leaf));
|
||||
}
|
||||
});
|
||||
|
||||
test('tokens, shared primitives, and feature styles have one explicit owner', () => {
|
||||
const sources = Object.fromEntries(expectedImports.map((relativePath) => [
|
||||
relativePath,
|
||||
fs.readFileSync(path.resolve(stylesRoot, relativePath), 'utf8'),
|
||||
]));
|
||||
const paletteProperties = [
|
||||
'--client-bg', '--client-panel', '--client-control', '--client-border', '--client-text', '--client-muted',
|
||||
'--harbor-word', '--harbor-connect', '--harbor-gateway', '--client-accent', '--client-accent-soft',
|
||||
];
|
||||
for (const property of paletteProperties) {
|
||||
const owners = Object.entries(sources)
|
||||
.filter(([, source]) => new RegExp(`^\\s*${property.replaceAll('-', '\\-')}:`, 'm').test(source))
|
||||
.map(([owner]) => owner);
|
||||
assert.deepEqual(owners, ['./tokens.css', './themes.css'], property);
|
||||
}
|
||||
for (const [owner, source] of Object.entries(sources)) {
|
||||
assert.doesNotMatch(source, /\[data-theme\]/, owner);
|
||||
}
|
||||
|
||||
for (const keyframe of [
|
||||
'client-local-rule-enter', 'client-local-rule-leave', 'client-delete-strike',
|
||||
'client-delete-content-dim', 'client-spin', 'client-copy-fade',
|
||||
]) {
|
||||
const owners = Object.entries(sources)
|
||||
.filter(([, source]) => new RegExp(`@keyframes ${keyframe}\\b`).test(source))
|
||||
.map(([owner]) => owner);
|
||||
assert.deepEqual(owners, ['./primitives.css'], keyframe);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 686);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
assert.deepEqual(ledger.hashes, acceptedLedger.hashes);
|
||||
});
|
||||
|
||||
test('every live production selector has an expanded DOM witness', () => {
|
||||
const unmatched = selectorsWithoutWitness(readStyleSource(root), readStyleWitnesses(root));
|
||||
assert.deepEqual(unmatched, [
|
||||
'.client-diagnostics-section-title button',
|
||||
'.client-diagnostics-section-title button:disabled',
|
||||
'.client-diagnostics-section-title button:focus-visible',
|
||||
'.client-server small',
|
||||
]);
|
||||
});
|
||||
|
||||
test('JSX witness expansion follows cross-file components, ReactNode slots, portals, and imperative classes', () => {
|
||||
const fixtureWitnesses = createStyleWitnesses([
|
||||
{
|
||||
file: '/fixture/Child.tsx',
|
||||
source: 'export function Child({ slot }) { return <section className="child">{slot}<h2 className="title" /></section>; }',
|
||||
},
|
||||
{
|
||||
file: '/fixture/App.tsx',
|
||||
source: 'export function App() { return <main className="scope"><Child slot={<strong className="slot" />} /></main>; }',
|
||||
},
|
||||
]);
|
||||
const title = fixtureWitnesses.find((witness) => witness.classes.includes('title'));
|
||||
const slot = fixtureWitnesses.find((witness) => witness.classes.includes('slot'));
|
||||
assert.deepEqual(title?.ancestorClasses, ['child', 'scope']);
|
||||
assert.deepEqual(slot?.ancestorClasses, ['child', 'scope']);
|
||||
const localBindingWitnesses = createStyleWitnesses([{
|
||||
file: '/fixture/App.tsx',
|
||||
source: `export function App() {
|
||||
const content = <h2 className="local-title" />;
|
||||
return <main className="local-scope"><section className="local-wrapper">{content}</section></main>;
|
||||
}`,
|
||||
}]);
|
||||
assert.deepEqual(
|
||||
localBindingWitnesses.find((witness) => witness.classes.includes('local-title'))?.ancestorClasses,
|
||||
['local-scope', 'local-wrapper'],
|
||||
);
|
||||
assert.throws(() => createStyleWitnesses([{
|
||||
file: '/fixture/App.tsx',
|
||||
source: 'export function App() { return <Missing />; }',
|
||||
}]), /Unresolved JSX witness component/);
|
||||
assert.throws(() => createStyleWitnesses([{
|
||||
file: '/fixture/App.tsx',
|
||||
source: 'export function App() { const props = {}; return <Child {...props} />; } function Child() { return <div />; }',
|
||||
}]), /Unsupported spread props/);
|
||||
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
const app = witnesses.find((witness) => witness.classes.includes('app') && witness.classes.includes('client-app'));
|
||||
assert.ok(app);
|
||||
const appThemeEdge = createStyleLedger(
|
||||
'.app.client-app { --client-bg: white; } '
|
||||
+ '@media (prefers-color-scheme: dark) { .app.client-app { --client-bg: black; } }',
|
||||
{ witnesses },
|
||||
);
|
||||
assert.equal(appThemeEdge.counts.cascadeEdges, 1);
|
||||
|
||||
const marker = witnesses.find((witness) => witness.classes.includes('client-diagnostics-active-marker'));
|
||||
assert.ok(marker?.classes.includes('is-visible'));
|
||||
assert.ok(marker?.classes.includes('is-moving'));
|
||||
const confirmations = witnesses.filter((witness) => witness.classes.includes('client-confirmation-popup'));
|
||||
assert.ok(confirmations.some((witness) => witness.ancestorClasses.length === 0));
|
||||
assert.ok(confirmations.some((witness) => witness.ancestorClasses.includes('app')));
|
||||
const trafficTooltips = witnesses.filter((witness) => witness.classes.includes('client-device-traffic-point-tooltip'));
|
||||
assert.ok(trafficTooltips.every((witness) => witness.ancestorClasses.length === 0));
|
||||
});
|
||||
|
||||
test('JSX witnesses keep real multi-class collisions and exclude impossible element collisions', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
const collision = createStyleLedger(
|
||||
'.client-copy-button { color: red; } .client-instruction-copy-button { color: blue; }',
|
||||
{ witnesses },
|
||||
);
|
||||
assert.equal(collision.counts.cascadeEdges, 1);
|
||||
|
||||
const impossible = createStyleLedger(
|
||||
'.client-copy-button { color: red; } .client-power { color: blue; }',
|
||||
{ witnesses },
|
||||
);
|
||||
assert.equal(impossible.counts.cascadeEdges, 0);
|
||||
|
||||
const conservativeSibling = createStyleLedger(
|
||||
'.client-subscription-drawer .client-servers { margin-inline: auto; } '
|
||||
+ '.client-server-group + .client-server-group { margin-top: 8px; }',
|
||||
{ witnesses },
|
||||
);
|
||||
assert.equal(conservativeSibling.counts.cascadeEdges, 1);
|
||||
});
|
||||
|
||||
test('selector proof uses the observed level-four grammar and exact specificity', () => {
|
||||
const fixtures = [
|
||||
['#root', [{ a: 1, b: 0, c: 0 }]],
|
||||
['.client-shell:has(.harbor-brand.is-gateway-active)', [{ a: 0, b: 3, c: 0 }]],
|
||||
['.client-instructions-toggle:not(.client-devices-toggle):not(.client-diagnostics-toggle)', [{ a: 0, b: 3, c: 0 }]],
|
||||
[".client-power[aria-checked='true']::before", [{ a: 0, b: 2, c: 1 }]],
|
||||
['.client-instruction-block:nth-child(n)', [{ a: 0, b: 2, c: 0 }]],
|
||||
['.client-confirmation-actions button:hover:not(:disabled), #root', [
|
||||
{ a: 0, b: 3, c: 1 },
|
||||
{ a: 1, b: 0, c: 0 },
|
||||
]],
|
||||
];
|
||||
for (const [selector, expected] of fixtures) {
|
||||
assert.deepEqual(analyzeSelectorList(selector).map((entry) => entry.specificity), expected, selector);
|
||||
}
|
||||
assert.throws(() => analyzeSelectorList('.broken:has('), /Unsupported CSS selector grammar/);
|
||||
assert.throws(() => analyzeSelectorList('& .proof'), /Unsupported CSS selector node nesting/);
|
||||
assert.throws(() => analyzeSelectorList('.proof:is(.active)'), /Unsupported CSS pseudo :is/);
|
||||
});
|
||||
|
||||
test('variable proof preserves nested and fallback references and fails closed', () => {
|
||||
assert.deepEqual(variableReferences('var(--outer, color-mix(in srgb, var(--inner, red), white))'), [
|
||||
{ name: '--outer', fallback: 'color-mix(in srgb, var(--inner, red), white)' },
|
||||
{ name: '--inner', fallback: 'red' },
|
||||
]);
|
||||
assert.throws(() => variableReferences('var(color, red)'), /Invalid var\(\) name/);
|
||||
assert.throws(() => variableReferences('var(--unfinished'), /Unterminated var\(\) reference/);
|
||||
});
|
||||
|
||||
test('ledger mutations expose loss, order changes, duplicate selectors, keyframes, variables, and cascade orientation', () => {
|
||||
const declaration = createStyleLedger('.proof { color: red; background: black; }');
|
||||
const removed = createStyleLedger('.proof { color: red; }');
|
||||
const reordered = createStyleLedger('.proof { background: black; color: red; }');
|
||||
assert.notEqual(removed.hashes.declarations, declaration.hashes.declarations);
|
||||
assert.equal(reordered.hashes.declarations, declaration.hashes.declarations);
|
||||
assert.notEqual(reordered.hashes.ruleDeclarationSequences, declaration.hashes.ruleDeclarationSequences);
|
||||
|
||||
const duplicate = createStyleLedger('.proof { color: red; } .other { color: green; } .proof { color: blue; }');
|
||||
const duplicateReordered = createStyleLedger('.proof { color: blue; } .other { color: green; } .proof { color: red; }');
|
||||
assert.equal(duplicateReordered.hashes.declarations, duplicate.hashes.declarations);
|
||||
assert.notEqual(duplicateReordered.hashes.duplicateSelectors, duplicate.hashes.duplicateSelectors);
|
||||
|
||||
const keyframe = createStyleLedger('@keyframes pulse { from { opacity: 0; } to { opacity: 1; } }');
|
||||
const changedKeyframe = createStyleLedger('@keyframes pulse { from { opacity: 0.1; } to { opacity: 1; } }');
|
||||
assert.notEqual(changedKeyframe.hashes.keyframes, keyframe.hashes.keyframes);
|
||||
|
||||
const variable = createStyleLedger('.proof { color: var(--outer, var(--inner, red)); }');
|
||||
const changedVariable = createStyleLedger('.proof { color: var(--outer, var(--fallback, red)); }');
|
||||
assert.notEqual(changedVariable.hashes.variableReferences, variable.hashes.variableReferences);
|
||||
|
||||
const edge = createStyleLedger('.alpha { color: red; } .beta { color: blue; }');
|
||||
const reversedEdge = createStyleLedger('.beta { color: blue; } .alpha { color: red; }');
|
||||
assert.equal(edge.counts.cascadeEdges, 1);
|
||||
assert.equal(reversedEdge.counts.cascadeEdges, 1);
|
||||
assert.notEqual(reversedEdge.hashes.cascadeEdges, edge.hashes.cascadeEdges);
|
||||
|
||||
const independent = createStyleLedger('.alpha { color: red; } .beta { background: blue; }');
|
||||
const independentReordered = createStyleLedger('.beta { background: blue; } .alpha { color: red; }');
|
||||
assert.equal(independent.counts.cascadeEdges, 0);
|
||||
assert.deepEqual(independentReordered.hashes, independent.hashes);
|
||||
|
||||
for (const shorthand of [
|
||||
'.x { place-items: center; } .y { align-items: start; }',
|
||||
'.x { place-content: center; } .y { justify-content: start; }',
|
||||
'.x { grid-area: 1 / 1; } .y { grid-row: 2; }',
|
||||
]) {
|
||||
assert.equal(createStyleLedger(shorthand).counts.cascadeEdges, 1, shorthand);
|
||||
}
|
||||
assert.equal(createStyleLedger('.a.c.d { color: red; } .x:not(.a.b) { color: blue; }').counts.cascadeEdges, 1);
|
||||
|
||||
assert.throws(() => createStyleLedger('.proof { unknown-proof-property: 1; }'), /Unsupported CSS property/);
|
||||
assert.throws(() => createStyleLedger('@supports (display: grid) { .proof { display: grid; } }'), /Unsupported CSS at-rule/);
|
||||
assert.throws(() => createStyleLedger('@media screen { .proof { color: red; } }'), /Unsupported media grammar/);
|
||||
});
|
||||
|
||||
test('cascade proof dependencies are exact direct dev dependencies', () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
assert.equal(packageJson.devDependencies['@babel/parser'], '7.29.3');
|
||||
assert.equal(packageJson.devDependencies.postcss, '8.5.14');
|
||||
assert.equal(packageJson.devDependencies['postcss-selector-parser'], '7.1.4');
|
||||
assert.equal(packageJson.devDependencies['@csstools/selector-specificity'], '6.0.0');
|
||||
});
|
||||
|
||||
test('main owns one public stylesheet and the regrouped production CSS is deterministic', () => {
|
||||
const main = fs.readFileSync(path.join(root, 'src/web/main.tsx'), 'utf8');
|
||||
assert.equal((main.match(/import ['"]\.\/styles\/index\.css['"]/g) || []).length, 1);
|
||||
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-jheYW1hW.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 103299);
|
||||
assert.equal(sha256(built), '3acfedf526a1d6e867e825692b1dbdf55896d481a3ec19d97b513dd7704ae291');
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { normalizeRequestError } from '../../.test-dist/src/web/features/subscription/requestError.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(root, 'src/web/App.tsx'), 'utf8');
|
||||
const feature = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
|
||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/subscription/index.ts'), 'utf8');
|
||||
|
||||
test('subscription feature is the sole always-mounted lifecycle and view owner', () => {
|
||||
assert.match(boundary, /SubscriptionDeleteDialog,[\s\S]*SubscriptionPanel,[\s\S]*SubscriptionToggle,[\s\S]*useSubscriptionFeature/);
|
||||
assert.match(page, /from '\.\.\/features\/subscription\/index\.js'/);
|
||||
assert.equal((page.match(/useSubscriptionFeature\(/g) || []).length, 1);
|
||||
assert.equal((page.match(/<SubscriptionToggle/g) || []).length, 1);
|
||||
assert.equal((page.match(/<SubscriptionPanel/g) || []).length, 1);
|
||||
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, /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/);
|
||||
assert.doesNotMatch(feature, /from ['"][^'"]*\/api\/|\bapi\./);
|
||||
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/);
|
||||
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\(\)/);
|
||||
});
|
||||
|
||||
test('validation rejection parser preserves structured errors and normalizes non-objects', () => {
|
||||
const retry = () => true;
|
||||
const structured = Object.assign(new Error('provider unavailable'), {
|
||||
name: 'HarborApiError',
|
||||
context: 'subscription',
|
||||
correlationId: 'correlation-1',
|
||||
retryable: true,
|
||||
retry,
|
||||
});
|
||||
assert.deepEqual(normalizeRequestError(structured), {
|
||||
name: 'HarborApiError',
|
||||
context: 'subscription',
|
||||
message: 'provider unavailable',
|
||||
correlationId: 'correlation-1',
|
||||
retryable: true,
|
||||
retry,
|
||||
});
|
||||
assert.deepEqual(normalizeRequestError(null), {
|
||||
name: undefined,
|
||||
context: undefined,
|
||||
message: 'Ссылка подписки недействительна.',
|
||||
correlationId: undefined,
|
||||
retryable: false,
|
||||
retry: null,
|
||||
});
|
||||
assert.equal(normalizeRequestError('provider rejected').message, 'Ссылка подписки недействительна.');
|
||||
});
|
||||
|
||||
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(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/);
|
||||
});
|
||||
Reference in New Issue
Block a user