Merge branch 'codex/task-057-059'
# Conflicts: # src/shared/versions.ts # test/web/style-boundaries.test.js
This commit is contained in:
@@ -95,6 +95,32 @@ test('classifier covers current dataplane reachability without promoting its cli
|
||||
});
|
||||
});
|
||||
|
||||
test('native traffic contracts and collector restart both Gateway processes', () => {
|
||||
for (const file of [
|
||||
'buf.gen.yaml',
|
||||
'proto/sing-box/v1.14.0-rc.5/daemon/started_service.proto',
|
||||
'src/server/gatewayNativeRuntime.ts',
|
||||
'src/server/generated/daemon/started_service_pb.ts',
|
||||
'src/server/services/liveTrafficService.ts',
|
||||
'src/shared/liveTraffic.ts',
|
||||
]) {
|
||||
assert.deepEqual(classifyRuntimeImpact([file]), {
|
||||
affectedComponents: ['control', 'dataplane'],
|
||||
restartScope: 'both',
|
||||
}, file);
|
||||
}
|
||||
for (const file of [
|
||||
'tools/test-singbox-client-rc.sh',
|
||||
'tools/test-singbox-gateway-native-traffic.sh',
|
||||
'tools/test-singbox-native-traffic.sh',
|
||||
]) {
|
||||
assert.deepEqual(classifyRuntimeImpact([file]), {
|
||||
affectedComponents: [],
|
||||
restartScope: 'none',
|
||||
}, file);
|
||||
}
|
||||
});
|
||||
|
||||
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'],
|
||||
@@ -118,8 +144,12 @@ test('release-critical inputs restart both and unknown paths fail closed', () =>
|
||||
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' })
|
||||
test('every repository path has an explicit ownership class', () => {
|
||||
const tracked = execFileSync(
|
||||
'git',
|
||||
['ls-files', '--cached', '--others', '--exclude-standard'],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
)
|
||||
.trim()
|
||||
.split('\n');
|
||||
assert.doesNotThrow(() => classifyRuntimeImpact(tracked));
|
||||
|
||||
@@ -50,9 +50,11 @@ test('macOS installer removes stale archive files without deleting local state',
|
||||
assert.match(installer, /--exclude='\.git'/);
|
||||
});
|
||||
|
||||
test('macOS installer refreshes a stale pinned sing-box version', () => {
|
||||
test('macOS installer pins sing-box 1.14.0-rc.5 and native traffic together', () => {
|
||||
const installer = fs.readFileSync(path.join(root, 'scripts', 'install-macos-client.sh'), 'utf8');
|
||||
|
||||
assert.match(installer, /TARGET_SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.13\.18\}"/);
|
||||
assert.match(installer, /TARGET_SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"/);
|
||||
assert.match(installer, /TARGET_TRAFFIC_SOURCE="\$\{SING_BOX_TRAFFIC_SOURCE:-native\}"/);
|
||||
assert.match(installer, /set_env_value SINGBOX_VERSION "\$TARGET_SINGBOX_VERSION"/);
|
||||
assert.match(installer, /set_env_value SING_BOX_TRAFFIC_SOURCE "\$TARGET_TRAFFIC_SOURCE"/);
|
||||
});
|
||||
|
||||
@@ -152,22 +152,37 @@ test('production paths use only the compiled dispatcher', () => {
|
||||
assert.match(workflow, /command -v npm[^']+command -v git[^']+test -x \/bin\/bash/);
|
||||
assert.doesNotMatch(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"/);
|
||||
assert.match(legacyBuild, /npm run build:production && docker build/);
|
||||
assert.match(legacyBuild, /docker run --rm[^;]+sing-box version[^;]+grep -Fx/);
|
||||
assert.match(legacyBuild, /docker run --rm --entrypoint sing-box[^;]+version[^;]+grep -Fx/);
|
||||
assert.match(dockerignore, /^dist$/m);
|
||||
});
|
||||
|
||||
test('every shipped build defaults to sing-box 1.13.18', () => {
|
||||
for (const file of [
|
||||
'.env.example',
|
||||
'.gitea/workflows/gateway-build.yml',
|
||||
'Dockerfile',
|
||||
'Dockerfile.client',
|
||||
'Dockerfile.runtime-base',
|
||||
'docker-compose.client.yml',
|
||||
'docker-compose.gateway.yml',
|
||||
'scripts/build-on-107-deploy-111.sh',
|
||||
'scripts/build-runtime-base.sh',
|
||||
test('Mac client builds default to exact sing-box 1.14.0-rc.5', () => {
|
||||
assert.match(
|
||||
fs.readFileSync(path.join(root, 'Dockerfile.client'), 'utf8'),
|
||||
/^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m,
|
||||
);
|
||||
assert.match(
|
||||
fs.readFileSync(path.join(root, 'docker-compose.client.yml'), 'utf8'),
|
||||
/SINGBOX_VERSION: \$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Gateway builds use the Mac-qualified exact sing-box 1.14.0-rc.5', () => {
|
||||
for (const [file, pin] of [
|
||||
['.env.example', /^SINGBOX_VERSION=1\.14\.0-rc\.5$/m],
|
||||
['.gitea/workflows/gateway-build.yml', /^\s*SINGBOX_VERSION: 1\.14\.0-rc\.5$/m],
|
||||
['Dockerfile', /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m],
|
||||
['Dockerfile.runtime-base', /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m],
|
||||
['docker-compose.gateway.yml', /SINGBOX_VERSION: \$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}/],
|
||||
['scripts/build-on-107-deploy-111.sh', /^SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"$/m],
|
||||
['scripts/build-runtime-base.sh', /^SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"$/m],
|
||||
]) {
|
||||
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), /SINGBOX_VERSION[^\n]*1\.13\.18/);
|
||||
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), pin);
|
||||
}
|
||||
const gatewayDockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
|
||||
for (const dependency of ['@bufbuild/protobuf', '@connectrpc/connect', '@connectrpc/connect-node']) {
|
||||
assert.match(gatewayDockerfile, new RegExp(`/src/node_modules/${dependency.replace('/', '\\/')}`));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ test('control uses the dataplane socket protocol', async () => {
|
||||
assert.equal(traffic.running, true);
|
||||
const domainTraffic = await client.observeDomainTraffic();
|
||||
assert.equal(domainTraffic.running, true);
|
||||
const liveTraffic = await client.observeLiveTraffic();
|
||||
assert.equal(liveTraffic.running, true);
|
||||
await client.observeDevicePolicy();
|
||||
await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]);
|
||||
await client.runConnectivityDiagnostics(
|
||||
@@ -47,6 +49,7 @@ test('control uses the dataplane socket protocol', async () => {
|
||||
'GET /devices /run/dataplane.sock',
|
||||
'GET /device-traffic /run/dataplane.sock',
|
||||
'GET /domain-traffic /run/dataplane.sock',
|
||||
'GET /traffic/live /run/dataplane.sock',
|
||||
'GET /device-policy /run/dataplane.sock',
|
||||
'PUT /device-policy /run/dataplane.sock',
|
||||
'POST /diagnostics/connectivity /run/dataplane.sock',
|
||||
@@ -59,16 +62,16 @@ test('control uses the dataplane socket protocol', async () => {
|
||||
'POST /restart /run/dataplane.sock',
|
||||
'POST /stop /run/dataplane.sock',
|
||||
]);
|
||||
assert.deepEqual(requests[6].body, { devices: [{ id: 'dev_0011223344556677' }] });
|
||||
assert.deepEqual(requests[7].body, {
|
||||
assert.deepEqual(requests[7].body, { devices: [{ id: 'dev_0011223344556677' }] });
|
||||
assert.deepEqual(requests[8].body, {
|
||||
services: [{ id: 'custom-test', url: 'https://example.com' }],
|
||||
target: 'site:custom-test',
|
||||
});
|
||||
assert.equal(requests[7].timeoutMs, 25_000);
|
||||
assert.deepEqual(requests[8].body, { config: { outbounds: [] } });
|
||||
assert.deepEqual(requests[9].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
||||
assert.equal(requests[9].timeoutMs, 19_000);
|
||||
assert.deepEqual(requests[11].body, { role: 'reserve' });
|
||||
assert.equal(requests[8].timeoutMs, 25_000);
|
||||
assert.deepEqual(requests[9].body, { config: { outbounds: [] } });
|
||||
assert.deepEqual(requests[10].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
||||
assert.equal(requests[10].timeoutMs, 19_000);
|
||||
assert.deepEqual(requests[12].body, { role: 'reserve' });
|
||||
});
|
||||
|
||||
test('connectivity diagnostics expose a retryable domain error', async () => {
|
||||
|
||||
@@ -33,6 +33,16 @@ test('gateway deploy updates control without recreating dataplane', () => {
|
||||
assert.doesNotMatch(workflow, /grep -Eq/);
|
||||
});
|
||||
|
||||
test('Gateway native API credentials remain private to the dataplane volume', () => {
|
||||
assert.match(compose, /SING_BOX_TRAFFIC_SOURCE: \$\{SING_BOX_TRAFFIC_SOURCE:-snapshot\}/);
|
||||
assert.match(compose, /vpn-proxy-dataplane:[\s\S]*SING_BOX_API_SECRET: \/var\/lib\/sing-box\/api\.secret[\s\S]*sing-box-cache:\/var\/lib\/sing-box/);
|
||||
assert.doesNotMatch(
|
||||
compose.match(/vpn-proxy-control:[\s\S]*?(?=\nvolumes:)/)?.[0] || '',
|
||||
/SING_BOX_API_SECRET|sing-box-cache|19091/,
|
||||
);
|
||||
assert.doesNotMatch(compose.match(/ports:[\s\S]*?volumes:/)?.[0] || '', /19091/);
|
||||
});
|
||||
|
||||
test('manual hard deploy safely forces the existing full Gateway path', () => {
|
||||
assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:\s*\n\s+hard_deploy:[\s\S]*default: false[\s\S]*type: boolean/);
|
||||
assert.match(workflow, /env:\s*\n\s+HARD_DEPLOY_INPUT: \$\{\{ inputs\.hard_deploy \}\}/);
|
||||
|
||||
@@ -29,8 +29,9 @@ const observation = (ip, mac = '00:11:22:33:44:55', deviceInterface = 'eth0') =>
|
||||
|
||||
test('dataplane exposes cached traffic snapshots without making accounting a readiness dependency', () => {
|
||||
assert.match(dataplaneSource, /req\.method === 'GET' && req\.url === '\/device-traffic'[\s\S]*traffic\.snapshot\(\)/);
|
||||
assert.match(dataplaneSource, /ready = true;[\s\S]*deviceTrafficAccountingEnabled[\s\S]*setImmediate[\s\S]*traffic\.refresh\(\)/);
|
||||
assert.match(dataplaneSource, /traffic\.refresh\(\)\.catch/);
|
||||
assert.match(dataplaneSource, /async function refreshDeviceTraffic\(\)[\s\S]*traffic\.refresh\(\)/);
|
||||
assert.match(dataplaneSource, /ready = true;[\s\S]*deviceTrafficAccountingEnabled[\s\S]*setImmediate[\s\S]*refreshDeviceTraffic\(\)/);
|
||||
assert.match(dataplaneSource, /refreshDeviceTraffic\(\)\.catch/);
|
||||
});
|
||||
|
||||
test('traffic selection keeps only unambiguous IPv4 neighbors', () => {
|
||||
|
||||
@@ -16,6 +16,40 @@ const connection = (connectionId, type, host, upload, download, sourceIP = devic
|
||||
download,
|
||||
chains,
|
||||
});
|
||||
const nativeConnection = (connectionId, uploadBytes, downloadBytes, overrides = {}) => ({
|
||||
id: connectionId,
|
||||
startedAt: '2026-08-31T10:00:00.000Z',
|
||||
closedAt: null,
|
||||
inbound: { tag: 'tproxy-in', type: 'tproxy' },
|
||||
network: 'tcp',
|
||||
protocol: 'tls',
|
||||
source: { ip: device.ip, port: 54_000 },
|
||||
destination: { domain: 'example.com', ip: null, port: 443, provenance: 'sing-box' },
|
||||
origin: { kind: 'device', id, label: 'MacBook', provenance: 'source-ip' },
|
||||
route: {
|
||||
kind: 'vpn',
|
||||
scope: 'local-sing-box',
|
||||
outbound: 'channel-selector',
|
||||
outboundType: 'selector',
|
||||
chain: ['channel-primary', 'channel-selector'],
|
||||
rule: 'final',
|
||||
},
|
||||
traffic: {
|
||||
uploadBytes,
|
||||
downloadBytes,
|
||||
uploadBytesPerSecond: '0',
|
||||
downloadBytesPerSecond: '0',
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
const nativeBatch = (connections, overrides = {}) => ({
|
||||
epoch: 'sing-box-100',
|
||||
observedAt: '2026-08-31T10:00:00.000Z',
|
||||
reset: false,
|
||||
connections,
|
||||
closedIds: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('sing-box route traffic keeps vpn, direct and unknown deltas separate', async () => {
|
||||
let response = { connections: [
|
||||
@@ -238,3 +272,73 @@ test('failover activity is zero-work while disabled and uses the existing connec
|
||||
service.disableActivity();
|
||||
assert.equal(service.activitySnapshot(500), null);
|
||||
});
|
||||
|
||||
test('native lifecycle batches keep decimal precision and dedupe a same-epoch reconnect reset and final tail', () => {
|
||||
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||
const service = createDomainTrafficService({
|
||||
observe: () => ({ connections: [] }),
|
||||
devices: () => [],
|
||||
now: () => now,
|
||||
});
|
||||
const initial = nativeConnection('native', '9007199254740993', '10');
|
||||
service.ingestNative(nativeBatch([initial], { reset: true }));
|
||||
now = new Date('2026-08-31T10:00:01.000Z');
|
||||
service.ingestNative(nativeBatch([initial], {
|
||||
reset: true,
|
||||
observedAt: now.toISOString(),
|
||||
}));
|
||||
|
||||
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740993');
|
||||
assert.equal(service.snapshot().source.activeConnections, 1);
|
||||
|
||||
service.enableActivity();
|
||||
now = new Date('2026-08-31T10:00:02.000Z');
|
||||
service.ingestNative(nativeBatch([
|
||||
nativeConnection('native', '9007199254740998', '15', { closedAt: now.toISOString() }),
|
||||
], {
|
||||
observedAt: now.toISOString(),
|
||||
closedIds: ['native'],
|
||||
}));
|
||||
|
||||
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998');
|
||||
assert.equal(service.snapshot().series[0].downloadBytes, '15');
|
||||
assert.equal(service.snapshot().source.activeConnections, 0);
|
||||
assert.equal(service.activitySnapshot(0).state, 'active');
|
||||
|
||||
now = new Date('2026-08-31T10:00:03.000Z');
|
||||
service.ingestNative(nativeBatch([
|
||||
nativeConnection('native', '9007199254740998', '15'),
|
||||
], {
|
||||
reset: true,
|
||||
observedAt: now.toISOString(),
|
||||
}));
|
||||
|
||||
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998');
|
||||
assert.equal(service.snapshot().series[0].downloadBytes, '15');
|
||||
assert.equal(service.snapshot().source.activeConnections, 1);
|
||||
|
||||
now = new Date('2026-08-31T10:00:14.000Z');
|
||||
service.ingestNative(nativeBatch([], { observedAt: now.toISOString() }));
|
||||
assert.equal(service.snapshot().observedAt, now.toISOString());
|
||||
assert.equal(service.snapshot().source.activeConnections, 1);
|
||||
assert.equal(service.activitySnapshot(0).state, 'quiet');
|
||||
});
|
||||
|
||||
test('one native batch accounts every final tail before lifecycle and UI caps', () => {
|
||||
const service = createDomainTrafficService({
|
||||
observe: () => ({ connections: [] }),
|
||||
devices: () => [],
|
||||
});
|
||||
const connections = Array.from({ length: 2_049 }, (_, index) => (
|
||||
nativeConnection(`closed-${index}`, '1', '1', { closedAt: '2026-08-31T10:00:00.000Z' })
|
||||
));
|
||||
|
||||
service.ingestNative(nativeBatch(connections, {
|
||||
reset: true,
|
||||
closedIds: connections.map(({ id: connectionId }) => connectionId),
|
||||
}));
|
||||
|
||||
assert.equal(service.snapshot().series[0].uploadBytes, '2049');
|
||||
assert.equal(service.snapshot().series[0].downloadBytes, '2049');
|
||||
assert.equal(service.snapshot().source.activeConnections, 0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
const singboxUrl = pathToFileURL(path.resolve('dist/server/singbox.js')).href;
|
||||
const subscriptionConfig = {
|
||||
outbounds: [{
|
||||
type: 'vless',
|
||||
tag: 'vpn',
|
||||
server: 'vpn.example.test',
|
||||
server_port: 443,
|
||||
uuid: '00000000-0000-4000-8000-000000000000',
|
||||
tls: { enabled: true },
|
||||
}],
|
||||
};
|
||||
|
||||
function run(source, { component = 'dataplane', socket = '/tmp/harbor-test.sock' } = {}) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-gateway-native-config-'));
|
||||
const script = `
|
||||
const { buildGatewayConfig, buildDualChannelGatewayConfig } = await import(${JSON.stringify(singboxUrl)});
|
||||
const subscription = ${JSON.stringify(subscriptionConfig)};
|
||||
process.stdout.write(JSON.stringify({
|
||||
single: buildGatewayConfig(subscription, 'vpn'),
|
||||
dual: buildDualChannelGatewayConfig({
|
||||
primary: { subscriptionConfig: subscription, selectedServerId: 'vpn' },
|
||||
reserve: { subscriptionConfig: subscription, selectedServerId: 'vpn' },
|
||||
}),
|
||||
}));
|
||||
`;
|
||||
try {
|
||||
return spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: path.resolve('.'),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
APP_MODE: 'gateway',
|
||||
APP_COMPONENT: component,
|
||||
DATA_DIR: directory,
|
||||
SING_BOX_CACHE: path.join(directory, 'cache.db'),
|
||||
SING_BOX_TRAFFIC_SOURCE: source,
|
||||
DATAPLANE_SOCKET: socket,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('Gateway snapshot keeps the 1.13-compatible config and Clash traffic API', () => {
|
||||
const result = run('snapshot', { component: '', socket: '' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const configs = JSON.parse(result.stdout);
|
||||
for (const config of Object.values(configs)) {
|
||||
assert.equal(config.services, undefined);
|
||||
assert.deepEqual(config.dns, { independent_cache: true });
|
||||
assert.deepEqual(config.experimental.clash_api, { external_controller: '127.0.0.1:19090' });
|
||||
}
|
||||
});
|
||||
|
||||
test('Gateway shadow and native add one secret-free loopback API to single and dual configs', () => {
|
||||
for (const source of ['shadow', 'native']) {
|
||||
const result = run(source);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const configs = JSON.parse(result.stdout);
|
||||
for (const config of Object.values(configs)) {
|
||||
assert.deepEqual(config.services, [{
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: 19091,
|
||||
dashboard: false,
|
||||
}]);
|
||||
assert.equal(JSON.stringify(config).includes('secret'), false);
|
||||
assert.deepEqual(config.dns, {});
|
||||
assert.deepEqual(config.experimental.clash_api, { external_controller: '127.0.0.1:19090' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Gateway shadow and native reject combined or socket-less topology', () => {
|
||||
for (const options of [
|
||||
{ component: '', socket: '' },
|
||||
{ component: 'control', socket: '' },
|
||||
{ component: 'dataplane', socket: '' },
|
||||
]) {
|
||||
const result = run('native', options);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /require split control\/dataplane topology/);
|
||||
}
|
||||
});
|
||||
|
||||
test('Gateway rejects unknown traffic sources', () => {
|
||||
const result = run('disabled');
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /must be snapshot, shadow or native/);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
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 {
|
||||
ensureGatewayNativeApiSecret,
|
||||
materializeGatewayNativeConfig,
|
||||
} from '../../dist/server/gatewayNativeRuntime.js';
|
||||
import { createSingboxRuntime } from '../../dist/server/singboxRuntime.js';
|
||||
|
||||
const apiService = {
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: 19091,
|
||||
dashboard: false,
|
||||
};
|
||||
|
||||
function mode(filePath) {
|
||||
return fs.statSync(filePath).mode & 0o777;
|
||||
}
|
||||
|
||||
async function waitForJson(filePath) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${filePath}`);
|
||||
}
|
||||
|
||||
test('Gateway native materialization keeps a stable 0600 secret out of shared config', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-secret-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const secretPath = path.join(directory, 'api.secret');
|
||||
const runtimeConfigPath = path.join(directory, 'runtime-config.json');
|
||||
const config = { services: [apiService], inbounds: [], outbounds: [] };
|
||||
|
||||
const first = materializeGatewayNativeConfig(config, {
|
||||
apiPort: 19091,
|
||||
secretPath,
|
||||
runtimeConfigPath,
|
||||
});
|
||||
const second = materializeGatewayNativeConfig(config, {
|
||||
apiPort: 19091,
|
||||
secretPath,
|
||||
runtimeConfigPath,
|
||||
});
|
||||
|
||||
assert.match(first.secret, /^[0-9a-f]{64}$/);
|
||||
assert.equal(second.secret, first.secret);
|
||||
assert.equal(first.warning, null);
|
||||
assert.equal(mode(secretPath), 0o600);
|
||||
assert.equal(mode(runtimeConfigPath), 0o600);
|
||||
assert.equal(JSON.stringify(config).includes(first.secret), false);
|
||||
assert.equal(JSON.parse(fs.readFileSync(runtimeConfigPath, 'utf8')).services[0].secret, first.secret);
|
||||
});
|
||||
|
||||
test('Gateway native secret rejects symlinks and repairs regular-file permissions', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-secret-mode-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const regularPath = path.join(directory, 'regular.secret');
|
||||
fs.writeFileSync(regularPath, 'a'.repeat(64), { mode: 0o644 });
|
||||
assert.equal(ensureGatewayNativeApiSecret(regularPath), 'a'.repeat(64));
|
||||
assert.equal(mode(regularPath), 0o600);
|
||||
|
||||
const linkPath = path.join(directory, 'linked.secret');
|
||||
fs.symlinkSync(regularPath, linkPath);
|
||||
assert.throws(() => ensureGatewayNativeApiSecret(linkPath));
|
||||
});
|
||||
|
||||
test('materialization strips every API service and returns a warning on unsafe input', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-safe-config-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const config = {
|
||||
services: [
|
||||
apiService,
|
||||
{ type: 'api', listen: '0.0.0.0', listen_port: 19092, dashboard: false },
|
||||
{ type: 'resolved' },
|
||||
],
|
||||
};
|
||||
const result = materializeGatewayNativeConfig(config, {
|
||||
apiPort: 19091,
|
||||
secretPath: path.join(directory, 'api.secret'),
|
||||
runtimeConfigPath: path.join(directory, 'runtime-config.json'),
|
||||
});
|
||||
const runtimeConfig = JSON.parse(fs.readFileSync(result.configPath, 'utf8'));
|
||||
|
||||
assert.equal(result.secret, null);
|
||||
assert.match(result.warning, /expected exactly one native API service/);
|
||||
assert.deepEqual(runtimeConfig.services, [{ type: 'resolved' }]);
|
||||
});
|
||||
|
||||
test('runtime starts the VPN-safe config and reports native materialization warnings', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-runtime-'));
|
||||
const binDirectory = path.join(directory, 'bin');
|
||||
const configPath = path.join(directory, 'shared.json');
|
||||
const capturedPath = path.join(directory, 'captured.json');
|
||||
fs.mkdirSync(binDirectory);
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
services: [{ ...apiService, listen: '0.0.0.0' }],
|
||||
inbounds: [],
|
||||
outbounds: [],
|
||||
}));
|
||||
fs.writeFileSync(path.join(binDirectory, 'sing-box'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const configPath = process.argv[process.argv.indexOf('-c') + 1];
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (config.services?.some((service) => service.type === 'api')) process.exit(7);
|
||||
if (process.argv[2] === 'check') process.exit(0);
|
||||
fs.writeFileSync(process.env.HARBOR_CAPTURED_CONFIG, JSON.stringify(config));
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
setInterval(() => {}, 60_000);
|
||||
`);
|
||||
fs.chmodSync(path.join(binDirectory, 'sing-box'), 0o755);
|
||||
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.PATH = `${binDirectory}:${previousPath}`;
|
||||
process.env.HARBOR_CAPTURED_CONFIG = capturedPath;
|
||||
const runtime = createSingboxRuntime({
|
||||
configPath,
|
||||
nativeApi: {
|
||||
apiPort: 19091,
|
||||
secretPath: path.join(directory, 'api.secret'),
|
||||
runtimeConfigPath: path.join(directory, 'runtime-config.json'),
|
||||
},
|
||||
});
|
||||
t.after(async () => {
|
||||
await runtime.stop();
|
||||
process.env.PATH = previousPath;
|
||||
delete process.env.HARBOR_CAPTURED_CONFIG;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const checked = runtime.checkConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
|
||||
assert.match(checked.warning, /must be unauthenticated base config/);
|
||||
const state = await runtime.apply();
|
||||
assert.equal(state.running, true);
|
||||
assert.match(state.nativeApiWarning, /must be unauthenticated base config/);
|
||||
assert.equal(runtime.nativeApiSecret, null);
|
||||
assert.equal((await waitForJson(capturedPath)).services, undefined);
|
||||
});
|
||||
|
||||
test('snapshot runtime strips a native API left by the previous mode before starting sing-box', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-snapshot-runtime-'));
|
||||
const binDirectory = path.join(directory, 'bin');
|
||||
const configPath = path.join(directory, 'shared.json');
|
||||
const runtimeConfigPath = path.join(directory, 'runtime-config.json');
|
||||
const capturedPath = path.join(directory, 'captured.json');
|
||||
fs.mkdirSync(binDirectory);
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
services: [apiService],
|
||||
inbounds: [],
|
||||
outbounds: [],
|
||||
}));
|
||||
fs.writeFileSync(path.join(binDirectory, 'sing-box'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const configPath = process.argv[process.argv.indexOf('-c') + 1];
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (config.services?.some((service) => service.type === 'api')) process.exit(7);
|
||||
if (process.argv[2] === 'check') process.exit(0);
|
||||
fs.writeFileSync(process.env.HARBOR_CAPTURED_CONFIG, JSON.stringify(config));
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
setInterval(() => {}, 60_000);
|
||||
`);
|
||||
fs.chmodSync(path.join(binDirectory, 'sing-box'), 0o755);
|
||||
|
||||
const previousPath = process.env.PATH;
|
||||
process.env.PATH = `${binDirectory}:${previousPath}`;
|
||||
process.env.HARBOR_CAPTURED_CONFIG = capturedPath;
|
||||
const runtime = createSingboxRuntime({ configPath, gatewayRuntimeConfigPath: runtimeConfigPath });
|
||||
t.after(async () => {
|
||||
await runtime.stop();
|
||||
process.env.PATH = previousPath;
|
||||
delete process.env.HARBOR_CAPTURED_CONFIG;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const state = await runtime.apply();
|
||||
assert.equal(state.running, true);
|
||||
assert.equal(mode(runtimeConfigPath), 0o600);
|
||||
assert.equal((await waitForJson(capturedPath)).services, undefined);
|
||||
});
|
||||
@@ -0,0 +1,692 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ConnectionEventType } from '../../dist/server/generated/daemon/started_service_pb.js';
|
||||
import { createLiveTrafficLedger } from '../../dist/server/services/liveTrafficService.js';
|
||||
|
||||
function connection(id, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
inbound: 'mixed-in',
|
||||
inboundType: 'mixed',
|
||||
network: 'tcp',
|
||||
source: '127.0.0.1:54000',
|
||||
destination: '203.0.113.10:443',
|
||||
domain: 'example.test',
|
||||
protocol: 'tls',
|
||||
createdAt: 1_700_000_000_000n,
|
||||
closedAt: 0n,
|
||||
uplinkTotal: 0n,
|
||||
downlinkTotal: 0n,
|
||||
outbound: 'test-vpn',
|
||||
outboundType: 'vless',
|
||||
rule: 'final',
|
||||
chainList: ['test-vpn'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function event(type, id, overrides = {}) {
|
||||
return {
|
||||
type,
|
||||
id,
|
||||
uplinkDelta: 0n,
|
||||
downlinkDelta: 0n,
|
||||
closedAt: 0n,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function status(connectionsIn, uplinkTotal, downlinkTotal) {
|
||||
return { connectionsIn, uplinkTotal, downlinkTotal };
|
||||
}
|
||||
|
||||
test('ledger applies NEW, UPDATE and only the final CLOSED tail once', () => {
|
||||
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||
const ledger = createLiveTrafficLedger({ now: () => now });
|
||||
ledger.beginEpoch(1_700_000_000_000n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({ reset: true, events: [] });
|
||||
ledger.applyStatus(status(0, 0n, 0n));
|
||||
|
||||
const opened = connection('a', { uplinkTotal: 10n, downlinkTotal: 20n });
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'a', { connection: opened })],
|
||||
});
|
||||
let snapshot = ledger.snapshot();
|
||||
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||
uploadBytes: '10',
|
||||
downloadBytes: '20',
|
||||
uploadBytesPerSecond: '0',
|
||||
downloadBytesPerSecond: '0',
|
||||
});
|
||||
assert.equal(snapshot.connections[0].origin.label, 'Этот Mac');
|
||||
assert.equal(snapshot.connections[0].closedAt, null);
|
||||
assert.equal(snapshot.connections[0].route.kind, 'vpn');
|
||||
|
||||
now = new Date('2026-08-31T10:00:01.000Z');
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'a', {
|
||||
uplinkDelta: 5n,
|
||||
downlinkDelta: 7n,
|
||||
})],
|
||||
});
|
||||
snapshot = ledger.snapshot();
|
||||
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||
uploadBytes: '15',
|
||||
downloadBytes: '27',
|
||||
uploadBytesPerSecond: '5',
|
||||
downloadBytesPerSecond: '7',
|
||||
});
|
||||
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'a', {
|
||||
connection: connection('a', {
|
||||
closedAt: BigInt(now.getTime()),
|
||||
uplinkTotal: 18n,
|
||||
downlinkTotal: 30n,
|
||||
}),
|
||||
})],
|
||||
});
|
||||
const firstClosed = ledger.snapshot().connections[0];
|
||||
now = new Date('2026-08-31T10:00:10.000Z');
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'a', {
|
||||
connection: connection('a', { uplinkTotal: 999n, downlinkTotal: 999n }),
|
||||
})],
|
||||
});
|
||||
ledger.applyStatus(status(0, 18n, 30n));
|
||||
snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.source.state, 'live');
|
||||
assert.equal(snapshot.summary.active, 0);
|
||||
assert.equal(snapshot.summary.recent, 1);
|
||||
assert.equal(snapshot.connections[0].closedAt, firstClosed.closedAt);
|
||||
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||
uploadBytes: '18',
|
||||
downloadBytes: '30',
|
||||
uploadBytesPerSecond: '0',
|
||||
downloadBytesPerSecond: '0',
|
||||
});
|
||||
assert.equal(snapshot.source.unattributedUploadBytes, '0');
|
||||
assert.equal(snapshot.source.unattributedDownloadBytes, '0');
|
||||
ledger.markStopped();
|
||||
snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.source.state, 'stopped');
|
||||
assert.equal(snapshot.summary.recent, 0);
|
||||
});
|
||||
|
||||
test('connection churn does not clear rates before the next update tick', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: true,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'a', {
|
||||
connection: connection('a'),
|
||||
})],
|
||||
});
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'a', {
|
||||
uplinkDelta: 5n,
|
||||
downlinkDelta: 7n,
|
||||
})],
|
||||
});
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'b', {
|
||||
connection: connection('b'),
|
||||
})],
|
||||
});
|
||||
|
||||
const active = new Map(ledger.snapshot().connections.map((item) => [item.id, item]));
|
||||
assert.equal(active.get('a').traffic.uploadBytesPerSecond, '5');
|
||||
assert.equal(active.get('a').traffic.downloadBytesPerSecond, '7');
|
||||
assert.equal(active.get('b').traffic.uploadBytesPerSecond, '0');
|
||||
});
|
||||
|
||||
test('a connection completed between polls remains visible until the exact 30 second boundary', () => {
|
||||
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||
const ledger = createLiveTrafficLedger({ now: () => now });
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [
|
||||
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'quick', {
|
||||
connection: connection('quick', {
|
||||
createdAt: BigInt(now.getTime()),
|
||||
uplinkTotal: 2n,
|
||||
downlinkTotal: 3n,
|
||||
}),
|
||||
}),
|
||||
event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'quick', {
|
||||
uplinkDelta: 5n,
|
||||
downlinkDelta: 7n,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
let snapshot = ledger.snapshot();
|
||||
assert.deepEqual(snapshot.summary, {
|
||||
active: 0,
|
||||
recent: 1,
|
||||
visible: 1,
|
||||
recognized: 0,
|
||||
unresolved: 0,
|
||||
unresolvedOrigin: 0,
|
||||
truncated: false,
|
||||
});
|
||||
assert.equal(snapshot.connections[0].closedAt, '2026-08-31T10:00:00.000Z');
|
||||
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||
uploadBytes: '7',
|
||||
downloadBytes: '10',
|
||||
uploadBytesPerSecond: '0',
|
||||
downloadBytesPerSecond: '0',
|
||||
});
|
||||
|
||||
now = new Date('2026-08-31T10:00:29.999Z');
|
||||
assert.equal(ledger.snapshot().summary.recent, 1);
|
||||
now = new Date('2026-08-31T10:00:30.000Z');
|
||||
snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.summary.recent, 0);
|
||||
assert.equal(snapshot.connections.length, 0);
|
||||
});
|
||||
|
||||
test('a connection already closed in an RC5 reset remains visible as recent', () => {
|
||||
const ledger = createLiveTrafficLedger({ now: () => new Date('2026-08-31T10:00:02.000Z') });
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: true,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'reset-closed', {
|
||||
connection: connection('reset-closed', {
|
||||
createdAt: BigInt(Date.parse('2026-08-31T10:00:00.000Z')),
|
||||
closedAt: BigInt(Date.parse('2026-08-31T10:00:01.000Z')),
|
||||
uplinkTotal: 3n,
|
||||
downlinkTotal: 7n,
|
||||
}),
|
||||
})],
|
||||
});
|
||||
|
||||
const snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.summary.active, 0);
|
||||
assert.equal(snapshot.summary.recent, 1);
|
||||
assert.equal(snapshot.connections[0].id, 'reset-closed');
|
||||
assert.equal(snapshot.connections[0].closedAt, '2026-08-31T10:00:01.000Z');
|
||||
});
|
||||
|
||||
test('a genuinely newer lifecycle with the same UUID supersedes its recent tombstone', () => {
|
||||
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||
const ledger = createLiveTrafficLedger({ now: () => now });
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({ reset: true, events: [] });
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'revived', {
|
||||
connection: connection('revived', { createdAt: BigInt(now.getTime()), uplinkTotal: 3n }),
|
||||
})],
|
||||
});
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'revived')],
|
||||
});
|
||||
|
||||
const firstClosedAt = ledger.snapshot().connections[0].closedAt;
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'revived', {
|
||||
connection: connection('revived', { createdAt: BigInt(now.getTime()), uplinkTotal: 999n }),
|
||||
})],
|
||||
});
|
||||
assert.equal(ledger.snapshot().summary.active, 0);
|
||||
assert.equal(ledger.snapshot().connections[0].closedAt, firstClosedAt);
|
||||
|
||||
now = new Date('2026-08-31T10:00:01.000Z');
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'revived', {
|
||||
connection: connection('revived', { createdAt: BigInt(now.getTime()), uplinkTotal: 4n }),
|
||||
})],
|
||||
});
|
||||
const snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.summary.active, 1);
|
||||
assert.equal(snapshot.summary.recent, 0);
|
||||
assert.equal(snapshot.connections[0].closedAt, null);
|
||||
assert.equal(snapshot.connections[0].traffic.uploadBytes, '4');
|
||||
});
|
||||
|
||||
test('reset reconciles active deltas without treating earlier closed traffic as a gap', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: true,
|
||||
events: [
|
||||
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'closed-a', {
|
||||
connection: connection('closed-a', { uplinkTotal: 100n }),
|
||||
}),
|
||||
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'active-b', {
|
||||
connection: connection('active-b', { uplinkTotal: 10n }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
ledger.applyStatus(status(2, 110n, 0n));
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'closed-a')],
|
||||
});
|
||||
ledger.applyStatus(status(1, 110n, 0n));
|
||||
ledger.applyConnections({
|
||||
reset: true,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'active-b', {
|
||||
connection: connection('active-b', { uplinkTotal: 20n }),
|
||||
})],
|
||||
});
|
||||
ledger.applyStatus(status(1, 120n, 0n));
|
||||
|
||||
const snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.source.state, 'live');
|
||||
assert.equal(snapshot.source.unattributedUploadBytes, '0');
|
||||
assert.equal(snapshot.summary.active, 1);
|
||||
assert.equal(snapshot.summary.recent, 1);
|
||||
assert.equal(snapshot.connections.find(({ id }) => id === 'active-b').traffic.uploadBytes, '20');
|
||||
});
|
||||
|
||||
test('reset can revive the same lifecycle tombstone without counting its totals twice', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'same', {
|
||||
connection: connection('same', { uplinkTotal: 10n }),
|
||||
})],
|
||||
});
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'same')],
|
||||
});
|
||||
ledger.applyConnections({
|
||||
reset: true,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'same', {
|
||||
connection: connection('same', { uplinkTotal: 10n }),
|
||||
})],
|
||||
});
|
||||
ledger.applyStatus(status(1, 10n, 0n));
|
||||
ledger.applyStatus(status(1, 10n, 0n));
|
||||
ledger.applyStatus(status(1, 10n, 0n));
|
||||
|
||||
const snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.source.state, 'live');
|
||||
assert.equal(snapshot.source.unattributedUploadBytes, '0');
|
||||
assert.equal(snapshot.summary.active, 1);
|
||||
assert.equal(snapshot.summary.recent, 0);
|
||||
});
|
||||
|
||||
test('destination hostnames remain recognized without protocol sniffing', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: true,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'hostname', {
|
||||
connection: connection('hostname', {
|
||||
destination: 'Example.COM:443',
|
||||
domain: '',
|
||||
protocol: '',
|
||||
}),
|
||||
})],
|
||||
});
|
||||
|
||||
const snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.summary.recognized, 1);
|
||||
assert.deepEqual(snapshot.connections[0].destination, {
|
||||
domain: 'example.com',
|
||||
ip: null,
|
||||
port: 443,
|
||||
provenance: 'sing-box',
|
||||
});
|
||||
});
|
||||
|
||||
test('CLOSED keeps newly available native domain, protocol and route metadata', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'late-metadata', {
|
||||
connection: connection('late-metadata', {
|
||||
domain: '',
|
||||
protocol: '',
|
||||
uplinkTotal: 1n,
|
||||
downlinkTotal: 2n,
|
||||
}),
|
||||
})],
|
||||
});
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'late-metadata', {
|
||||
connection: connection('late-metadata', {
|
||||
domain: 'recognized.example',
|
||||
protocol: 'http2',
|
||||
outbound: 'direct',
|
||||
outboundType: 'direct',
|
||||
chainList: ['direct'],
|
||||
rule: 'domain-final',
|
||||
uplinkTotal: 3n,
|
||||
downlinkTotal: 5n,
|
||||
}),
|
||||
})],
|
||||
});
|
||||
|
||||
const closed = ledger.snapshot().connections[0];
|
||||
assert.equal(closed.destination.domain, 'recognized.example');
|
||||
assert.equal(closed.protocol, 'http2');
|
||||
assert.deepEqual(closed.route, {
|
||||
kind: 'direct',
|
||||
scope: 'local-sing-box',
|
||||
outbound: 'direct',
|
||||
outboundType: 'direct',
|
||||
chain: ['direct'],
|
||||
rule: 'domain-final',
|
||||
});
|
||||
assert.equal(closed.traffic.uploadBytes, '3');
|
||||
assert.equal(closed.traffic.downloadBytes, '5');
|
||||
});
|
||||
|
||||
test('CLOSED partial route metadata keeps route kind consistent with its outbound', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'partial-route', {
|
||||
connection: connection('partial-route'),
|
||||
})],
|
||||
});
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'partial-route', {
|
||||
connection: connection('partial-route', {
|
||||
outbound: '',
|
||||
outboundType: '',
|
||||
chainList: ['late-hop'],
|
||||
rule: 'late-rule',
|
||||
}),
|
||||
})],
|
||||
});
|
||||
|
||||
const route = ledger.snapshot().connections[0].route;
|
||||
assert.equal(route.kind, 'vpn');
|
||||
assert.equal(route.outbound, 'test-vpn');
|
||||
assert.equal(route.outboundType, 'vless');
|
||||
assert.deepEqual(route.chain, ['late-hop']);
|
||||
assert.equal(route.rule, 'late-rule');
|
||||
});
|
||||
|
||||
test('transport errors preserve the last observed data timestamp', () => {
|
||||
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||
const ledger = createLiveTrafficLedger({ now: () => now });
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({ reset: true, events: [] });
|
||||
ledger.applyStatus(status(0, 0n, 0n));
|
||||
const lastGood = ledger.snapshot();
|
||||
|
||||
now = new Date('2026-08-31T10:01:00.000Z');
|
||||
ledger.markTransportError(new Error('stream ended'));
|
||||
const firstStale = ledger.snapshot();
|
||||
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.markTransportError(new Error('stream ended again'));
|
||||
const stale = ledger.snapshot();
|
||||
|
||||
assert.equal(stale.source.state, 'stale');
|
||||
assert.equal(stale.observedAt, lastGood.observedAt);
|
||||
assert.equal(firstStale.observedAt, lastGood.observedAt);
|
||||
assert.equal(stale.sequence, lastGood.sequence + 3);
|
||||
});
|
||||
|
||||
test('reset is idempotent, a restart creates a clean epoch, and settled UUIDs are bounded', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
const active = connection('active', { uplinkTotal: 10n, downlinkTotal: 20n });
|
||||
const closed = connection('closed', {
|
||||
closedAt: 1_700_000_001_000n,
|
||||
uplinkTotal: 5n,
|
||||
downlinkTotal: 7n,
|
||||
});
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
const reset = {
|
||||
reset: true,
|
||||
events: [
|
||||
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'active', { connection: active }),
|
||||
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'closed', { connection: closed }),
|
||||
],
|
||||
};
|
||||
const firstReset = ledger.applyConnections(reset);
|
||||
ledger.applyStatus(status(1, 15n, 27n));
|
||||
const repeatedReset = ledger.applyConnections(reset);
|
||||
ledger.applyStatus(status(1, 15n, 27n));
|
||||
assert.equal(ledger.snapshot().source.state, 'live');
|
||||
assert.equal(ledger.snapshot().source.unattributedUploadBytes, '0');
|
||||
assert.deepEqual(ledger.snapshot().connections.map(({ id }) => id), ['active']);
|
||||
assert.equal(ledger.snapshot().summary.recent, 0);
|
||||
assert.deepEqual(firstReset.connections.map(({ id }) => id), ['active', 'closed']);
|
||||
assert.deepEqual(repeatedReset.connections.map(({ id }) => id), ['active']);
|
||||
|
||||
const settledEvents = Array.from({ length: 2_049 }, (_, index) => {
|
||||
const id = `settled-${String(index).padStart(4, '0')}`;
|
||||
return event(ConnectionEventType.CONNECTION_EVENT_CLOSED, id);
|
||||
});
|
||||
ledger.applyConnections({ reset: false, events: settledEvents });
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [
|
||||
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'settled-0000', {
|
||||
connection: connection('settled-0000'),
|
||||
}),
|
||||
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'settled-2048', {
|
||||
connection: connection('settled-2048'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
ledger.snapshot().connections.map(({ id }) => id).sort(),
|
||||
['active', 'settled-0000'],
|
||||
);
|
||||
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [
|
||||
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'epoch-recent', {
|
||||
connection: connection('epoch-recent'),
|
||||
}),
|
||||
event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'epoch-recent'),
|
||||
],
|
||||
});
|
||||
assert.equal(ledger.snapshot().summary.recent, 1);
|
||||
|
||||
ledger.beginEpoch(101n, '1.14.0-rc.5', 4);
|
||||
const restarted = ledger.snapshot();
|
||||
assert.equal(restarted.epoch, 'sing-box-101');
|
||||
assert.equal(restarted.source.state, 'connecting');
|
||||
assert.equal(restarted.summary.active, 0);
|
||||
assert.equal(restarted.summary.recent, 0);
|
||||
assert.equal(restarted.source.unattributedUploadBytes, '0');
|
||||
});
|
||||
|
||||
test('three consecutive status mismatches degrade without assigning the byte gap', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: true,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'a', {
|
||||
connection: connection('a', { uplinkTotal: 10n, downlinkTotal: 20n }),
|
||||
})],
|
||||
});
|
||||
|
||||
assert.ok(ledger.applyStatus(status(1, 15n, 27n)));
|
||||
assert.ok(ledger.applyStatus(status(1, 15n, 27n)));
|
||||
assert.equal(ledger.snapshot().source.state, 'live');
|
||||
assert.equal(ledger.applyStatus(status(1, 15n, 27n)), null);
|
||||
let snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.source.state, 'degraded');
|
||||
assert.equal(snapshot.source.unattributedUploadBytes, '5');
|
||||
assert.equal(snapshot.source.unattributedDownloadBytes, '7');
|
||||
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||
uploadBytes: '10',
|
||||
downloadBytes: '20',
|
||||
uploadBytesPerSecond: '0',
|
||||
downloadBytesPerSecond: '0',
|
||||
});
|
||||
|
||||
assert.ok(ledger.applyStatus(status(1, 10n, 20n)));
|
||||
snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.source.state, 'live');
|
||||
assert.equal(snapshot.source.unattributedUploadBytes, '0');
|
||||
assert.equal(snapshot.source.unattributedDownloadBytes, '0');
|
||||
});
|
||||
|
||||
test('a missing NEW does not double-count UPDATE before an absolute CLOSED total', () => {
|
||||
const ledger = createLiveTrafficLedger({ now: () => new Date('2023-11-14T22:13:21.000Z') });
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({ reset: true, events: [] });
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'missed', {
|
||||
uplinkDelta: 10n,
|
||||
downlinkDelta: 20n,
|
||||
})],
|
||||
});
|
||||
ledger.applyStatus(status(0, 10n, 20n));
|
||||
assert.equal(ledger.snapshot().source.unattributedUploadBytes, '10');
|
||||
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'missed', {
|
||||
connection: connection('missed', {
|
||||
closedAt: 1_700_000_001_000n,
|
||||
uplinkTotal: 15n,
|
||||
downlinkTotal: 27n,
|
||||
}),
|
||||
})],
|
||||
});
|
||||
ledger.applyStatus(status(0, 15n, 27n));
|
||||
let snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.source.unattributedUploadBytes, '15');
|
||||
assert.equal(snapshot.source.unattributedDownloadBytes, '27');
|
||||
assert.equal(snapshot.summary.recent, 1);
|
||||
assert.equal(snapshot.connections[0].closedAt, '2023-11-14T22:13:21.000Z');
|
||||
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||
uploadBytes: '15',
|
||||
downloadBytes: '27',
|
||||
uploadBytesPerSecond: '0',
|
||||
downloadBytesPerSecond: '0',
|
||||
});
|
||||
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'metadata-less', {
|
||||
uplinkDelta: 3n,
|
||||
downlinkDelta: 4n,
|
||||
})],
|
||||
});
|
||||
ledger.applyStatus(status(0, 18n, 31n));
|
||||
snapshot = ledger.snapshot();
|
||||
assert.equal(snapshot.source.unattributedUploadBytes, '18');
|
||||
assert.equal(snapshot.source.unattributedDownloadBytes, '31');
|
||||
});
|
||||
|
||||
test('snapshot caps visibility at 256 while summary covers every active connection', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [
|
||||
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'recent', { connection: connection('recent') }),
|
||||
event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'recent'),
|
||||
],
|
||||
});
|
||||
const events = Array.from({ length: 257 }, (_, index) => {
|
||||
const id = index === 255 ? 'z-tie' : index === 256 ? 'a-tie' : `id-${String(index).padStart(3, '0')}`;
|
||||
const createdAt = index >= 255 ? 1_700_000_000_255n : 1_700_000_000_000n + BigInt(index);
|
||||
return event(ConnectionEventType.CONNECTION_EVENT_NEW, id, {
|
||||
connection: connection(id, {
|
||||
createdAt,
|
||||
domain: index % 2 === 0 ? `service-${index}.example` : '',
|
||||
destination: `203.0.113.${index % 255}:443`,
|
||||
outbound: index % 3 === 0 ? 'direct' : 'test-vpn',
|
||||
outboundType: index % 3 === 0 ? 'direct' : 'vless',
|
||||
}),
|
||||
});
|
||||
});
|
||||
ledger.applyConnections({ reset: true, events });
|
||||
const snapshot = ledger.snapshot();
|
||||
|
||||
assert.deepEqual(snapshot.summary, {
|
||||
active: 257,
|
||||
recent: 1,
|
||||
visible: 256,
|
||||
recognized: 129,
|
||||
unresolved: 128,
|
||||
unresolvedOrigin: 0,
|
||||
truncated: true,
|
||||
});
|
||||
assert.equal(snapshot.connections.length, 256);
|
||||
assert.deepEqual(snapshot.connections.slice(0, 2).map(({ id }) => id), ['a-tie', 'z-tie']);
|
||||
assert.equal(snapshot.connections.some(({ id }) => id === 'id-000'), false);
|
||||
assert.equal(snapshot.connections.some(({ id }) => id === 'recent'), false);
|
||||
});
|
||||
|
||||
test('gateway projection uses selector chains and resolves every active origin from the current device map', () => {
|
||||
let deviceVisible = false;
|
||||
const ledger = createLiveTrafficLedger({
|
||||
gateway: true,
|
||||
resolveOrigin: (sourceIp) => deviceVisible
|
||||
? { kind: 'device', id: 'device-a', label: 'MacBook', provenance: 'source-ip' }
|
||||
: { kind: 'unknown', id: null, label: sourceIp, provenance: 'unknown' },
|
||||
});
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
const first = ledger.applyConnections({
|
||||
reset: true,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'gateway', {
|
||||
connection: connection('gateway', {
|
||||
inbound: 'tproxy-in',
|
||||
inboundType: 'tproxy',
|
||||
source: '192.168.50.7:54000',
|
||||
outbound: 'channel-selector',
|
||||
outboundType: 'selector',
|
||||
chainList: ['channel-primary', 'channel-selector'],
|
||||
}),
|
||||
})],
|
||||
});
|
||||
|
||||
assert.equal(first.connections[0].route.kind, 'vpn');
|
||||
assert.equal(ledger.snapshot().summary.unresolvedOrigin, 1);
|
||||
deviceVisible = true;
|
||||
assert.equal(ledger.snapshot().summary.unresolvedOrigin, 0);
|
||||
assert.equal(ledger.snapshot().connections[0].origin.id, 'device-a');
|
||||
|
||||
const second = ledger.applyConnections({
|
||||
reset: false,
|
||||
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'gateway', { uplinkDelta: 1n })],
|
||||
});
|
||||
assert.equal(second.connections[0].origin.id, 'device-a');
|
||||
assert.equal(second.connections[0].traffic.uploadBytes, '1');
|
||||
assert.equal(ledger.snapshot().capabilities.deviceAttribution, true);
|
||||
});
|
||||
|
||||
test('recent closed storage is bounded independently from the 256-row response cap', () => {
|
||||
const ledger = createLiveTrafficLedger();
|
||||
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||
const events = Array.from({ length: 2_049 }, (_, index) => {
|
||||
const id = `recent-${String(index).padStart(4, '0')}`;
|
||||
return event(ConnectionEventType.CONNECTION_EVENT_CLOSED, id, { connection: connection(id) });
|
||||
});
|
||||
const projection = ledger.applyConnections({ reset: false, events });
|
||||
|
||||
const snapshot = ledger.snapshot();
|
||||
assert.equal(projection.connections.length, 2_049);
|
||||
assert.equal(projection.closedIds.length, 2_049);
|
||||
assert.equal(snapshot.summary.active, 0);
|
||||
assert.equal(snapshot.summary.recent, 2_048);
|
||||
assert.equal(snapshot.summary.visible, 256);
|
||||
assert.equal(snapshot.summary.truncated, true);
|
||||
assert.equal(snapshot.connections[0].id, 'recent-0001');
|
||||
assert.equal(snapshot.connections.some(({ id }) => id === 'recent-0000'), false);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createLiveTrafficRoute,
|
||||
enrichLiveTrafficDeviceLabels,
|
||||
} from '../../dist/server/http/routes/liveTrafficRoute.js';
|
||||
|
||||
function response() {
|
||||
return {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(payload) {
|
||||
this.rawPayload = payload;
|
||||
this.payload = JSON.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
apiVersion: 1,
|
||||
epoch: 'sing-box-1700000000000',
|
||||
sequence: 7,
|
||||
observedAt: '2026-08-31T10:00:00.000Z',
|
||||
capabilities: {
|
||||
lifecycle: true,
|
||||
deviceAttribution: false,
|
||||
applicationAttribution: false,
|
||||
},
|
||||
source: {
|
||||
transport: 'native',
|
||||
state: 'live',
|
||||
completeness: 'lifecycle',
|
||||
singBoxVersion: '1.14.0-rc.5',
|
||||
singBoxApiVersion: 4,
|
||||
error: null,
|
||||
unattributedUploadBytes: '0',
|
||||
unattributedDownloadBytes: '0',
|
||||
},
|
||||
summary: {
|
||||
active: 0,
|
||||
recent: 0,
|
||||
visible: 0,
|
||||
recognized: 0,
|
||||
unresolved: 0,
|
||||
unresolvedOrigin: 0,
|
||||
truncated: false,
|
||||
},
|
||||
connections: [],
|
||||
};
|
||||
|
||||
test('split Gateway control reads the cached socket while combined Gateway keeps no collector', () => {
|
||||
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(index, /const liveTraffic = clientLiveTraffic \|\| \(remoteDataplane \? \{[\s\S]*observeLiveTraffic\(\)[\s\S]*\} : null\)/);
|
||||
assert.match(index, /createLiveTrafficRoute\(\{[\s\S]*traffic: liveTraffic,[\s\S]*deviceInventory: remoteDataplane \? deviceInventory : null/);
|
||||
assert.match(index, /clientLiveTraffic\?\.start\(\)/);
|
||||
assert.doesNotMatch(index, /liveTraffic\?\.start\(\)/);
|
||||
});
|
||||
|
||||
test('GET returns the injected cached snapshot without another data-source operation', async () => {
|
||||
let snapshots = 0;
|
||||
const route = createLiveTrafficRoute({
|
||||
traffic: {
|
||||
snapshot() {
|
||||
snapshots += 1;
|
||||
return snapshot;
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = response();
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/live?ignored=1' }, res), true);
|
||||
assert.equal(snapshots, 1);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.headers, { 'content-type': 'application/json; charset=utf-8' });
|
||||
assert.deepEqual(res.payload, snapshot);
|
||||
});
|
||||
|
||||
test('async Gateway snapshots are validated and known dev labels are enriched without changing summary', async () => {
|
||||
const gatewaySnapshot = {
|
||||
...snapshot,
|
||||
capabilities: { ...snapshot.capabilities, deviceAttribution: true },
|
||||
summary: {
|
||||
active: 300,
|
||||
recent: 0,
|
||||
visible: 256,
|
||||
recognized: 300,
|
||||
unresolved: 0,
|
||||
unresolvedOrigin: 299,
|
||||
truncated: true,
|
||||
},
|
||||
connections: Array.from({ length: 256 }, (_, index) => ({
|
||||
id: `connection-${String(index).padStart(3, '0')}`,
|
||||
startedAt: new Date(Date.parse(snapshot.observedAt) - index * 1000).toISOString(),
|
||||
closedAt: null,
|
||||
inbound: { tag: 'tproxy-in', type: 'tproxy' },
|
||||
network: 'tcp',
|
||||
protocol: 'tls',
|
||||
source: { ip: index === 0 ? '192.168.50.7' : '192.168.50.8', port: 50_000 + index },
|
||||
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
|
||||
origin: index === 0
|
||||
? { kind: 'device', id: 'dev_0011223344556677', label: '192.168.50.7', provenance: 'source-ip' }
|
||||
: { kind: 'unknown', id: null, label: 'Неизвестное устройство', provenance: 'unknown' },
|
||||
route: {
|
||||
kind: 'vpn',
|
||||
scope: 'local-sing-box',
|
||||
outbound: 'proxy',
|
||||
outboundType: 'selector',
|
||||
chain: ['proxy'],
|
||||
rule: 'default',
|
||||
},
|
||||
traffic: {
|
||||
uploadBytes: '10',
|
||||
downloadBytes: '20',
|
||||
uploadBytesPerSecond: '1',
|
||||
downloadBytesPerSecond: '2',
|
||||
},
|
||||
})),
|
||||
};
|
||||
const original = structuredClone(gatewaySnapshot);
|
||||
const route = createLiveTrafficRoute({
|
||||
traffic: { snapshot: async () => gatewaySnapshot },
|
||||
deviceInventory: {
|
||||
snapshot: () => ({
|
||||
devices: [{
|
||||
id: 'dev_0011223344556677',
|
||||
alias: 'Гостиная',
|
||||
hostname: 'tv.local',
|
||||
ip: '192.168.50.7',
|
||||
}],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const res = response();
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/live' }, res), true);
|
||||
assert.equal(res.payload.connections[0].origin.label, 'Гостиная');
|
||||
assert.equal(res.payload.connections[1].origin.kind, 'unknown');
|
||||
assert.deepEqual(res.payload.summary, gatewaySnapshot.summary);
|
||||
assert.deepEqual(gatewaySnapshot, original);
|
||||
});
|
||||
|
||||
test('device label enrichment follows alias, hostname and IP without identifying unknown origins', () => {
|
||||
const connection = {
|
||||
id: 'connection-1',
|
||||
startedAt: snapshot.observedAt,
|
||||
closedAt: null,
|
||||
inbound: { tag: 'tproxy-in', type: 'tproxy' },
|
||||
network: 'tcp',
|
||||
protocol: 'tls',
|
||||
source: { ip: '192.168.50.7', port: 50_000 },
|
||||
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
|
||||
origin: { kind: 'device', id: 'dev_0011223344556677', label: '192.168.50.7', provenance: 'source-ip' },
|
||||
route: { kind: 'vpn', scope: 'local-sing-box', outbound: 'proxy', outboundType: 'selector', chain: ['proxy'], rule: 'default' },
|
||||
traffic: { uploadBytes: '1', downloadBytes: '2', uploadBytesPerSecond: '0', downloadBytesPerSecond: '0' },
|
||||
};
|
||||
const source = {
|
||||
...snapshot,
|
||||
capabilities: { ...snapshot.capabilities, deviceAttribution: true },
|
||||
summary: { ...snapshot.summary, active: 1, visible: 1, recognized: 1 },
|
||||
connections: [connection],
|
||||
};
|
||||
const labels = (device) => enrichLiveTrafficDeviceLabels(source, { devices: [device] })
|
||||
.connections[0].origin.label;
|
||||
|
||||
assert.equal(labels({ id: connection.origin.id, alias: ' ТВ ', hostname: 'tv.local', ip: '192.168.50.7' }), 'ТВ');
|
||||
assert.equal(labels({ id: connection.origin.id, alias: '', hostname: 'tv.local', ip: '192.168.50.7' }), 'tv.local');
|
||||
assert.equal(labels({ id: connection.origin.id, alias: '', hostname: null, ip: '192.168.50.7' }), '192.168.50.7');
|
||||
assert.equal(labels({ id: 'dev_ffffffffffffffff', alias: 'Чужой', ip: connection.source.ip }), connection.origin.label);
|
||||
const unknown = { ...source, connections: [{ ...connection, origin: { kind: 'unknown', id: null, label: 'Неизвестно', provenance: 'unknown' } }] };
|
||||
assert.equal(enrichLiveTrafficDeviceLabels(unknown, { devices: [{ ...connection.origin, id: 'dev_0011223344556677', alias: 'Не угадывать' }] }).connections[0].origin.label, 'Неизвестно');
|
||||
});
|
||||
|
||||
test('route rejects malformed cached snapshots before responding', async () => {
|
||||
const route = createLiveTrafficRoute({
|
||||
traffic: { snapshot: async () => ({ ...snapshot, apiVersion: 2 }) },
|
||||
});
|
||||
await assert.rejects(
|
||||
route.handle({ method: 'GET', url: '/api/traffic/live' }, response()),
|
||||
/apiVersion 1/,
|
||||
);
|
||||
});
|
||||
|
||||
test('route ignores other paths and rejects mutation methods without reading the cache', async () => {
|
||||
let snapshots = 0;
|
||||
const route = createLiveTrafficRoute({
|
||||
traffic: { snapshot: () => { snapshots += 1; return snapshot; } },
|
||||
});
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/history' }, response()), false);
|
||||
await assert.rejects(
|
||||
route.handle({ method: 'POST', url: '/api/traffic/live' }, response()),
|
||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||
);
|
||||
assert.equal(snapshots, 0);
|
||||
});
|
||||
|
||||
test('route is unavailable when no traffic collector exists', async () => {
|
||||
const route = createLiveTrafficRoute({ traffic: null });
|
||||
await assert.rejects(
|
||||
route.handle({ method: 'GET', url: '/api/traffic/live' }, response()),
|
||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||
);
|
||||
});
|
||||
@@ -40,6 +40,28 @@ const snapshot = {
|
||||
},
|
||||
domainTraffic: {
|
||||
observedAt,
|
||||
source: {
|
||||
error: null,
|
||||
mode: 'shadow',
|
||||
writer: 'snapshot',
|
||||
activeConnections: 4,
|
||||
native: {
|
||||
state: 'degraded',
|
||||
epoch: 'epoch-1',
|
||||
sequence: 7,
|
||||
observedAt,
|
||||
active: 5,
|
||||
unattributedUploadBytes: '11',
|
||||
unattributedDownloadBytes: '22',
|
||||
},
|
||||
shadow: {
|
||||
activeDifference: 1,
|
||||
uploadDifferenceBytes: '-30',
|
||||
downloadDifferenceBytes: '40',
|
||||
routeMismatches: 2,
|
||||
deviceMismatches: 3,
|
||||
},
|
||||
},
|
||||
overflowConnections: '2',
|
||||
attributionEvents: {
|
||||
unresolved_host: '3',
|
||||
@@ -97,6 +119,15 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na
|
||||
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unresolved_host"\} 3/);
|
||||
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unknown_device"\} 4/);
|
||||
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/);
|
||||
assert.match(output, /harbor_traffic_collector_info\{mode="shadow",writer="snapshot"\} 1/);
|
||||
assert.match(output, /harbor_traffic_collector_state\{state="degraded"\} 1/);
|
||||
assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="download"\} 22/);
|
||||
assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="upload"\} 11/);
|
||||
assert.match(output, /harbor_traffic_shadow_active_difference 1/);
|
||||
assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="download"\} 40/);
|
||||
assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="upload"\} -30/);
|
||||
assert.match(output, /harbor_traffic_shadow_route_mismatches 2/);
|
||||
assert.match(output, /harbor_traffic_shadow_device_mismatches 3/);
|
||||
assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/);
|
||||
assert.doesNotMatch(output, /harbor_device_(?:direct_ipv4_packet|singbox_tracked)_bytes_total\{[^\n]*(?:name|ip|mac|server)=/);
|
||||
assert.equal(output.endsWith('\n'), true);
|
||||
@@ -122,6 +153,15 @@ test('Prometheus response uses the negotiated legacy text contract without mutat
|
||||
assert.deepEqual(snapshot, before);
|
||||
});
|
||||
|
||||
test('legacy combined Gateway snapshots keep existing metrics without collector diagnostics', () => {
|
||||
const legacy = structuredClone(snapshot);
|
||||
legacy.domainTraffic.source = { error: null, activeConnections: 0 };
|
||||
const output = renderPrometheusMetrics(legacy);
|
||||
|
||||
assert.match(output, /harbor_singbox_tracked_bytes_total/);
|
||||
assert.doesNotMatch(output, /harbor_traffic_collector_info/);
|
||||
});
|
||||
|
||||
test('invalid canonical counters fail the scrape instead of publishing corrupt values', () => {
|
||||
const invalid = { traffic: { gatewayBytes: 'broken', proxyBytes: '0' }, devices: [] };
|
||||
assert.throws(
|
||||
@@ -135,6 +175,12 @@ test('invalid canonical counters fail the scrape instead of publishing corrupt v
|
||||
const invalidRoute = structuredClone(snapshot);
|
||||
invalidRoute.domainTraffic.routes[0].outbound = 'vpn-server-tag';
|
||||
assert.throws(() => renderPrometheusMetrics(invalidRoute), /Invalid sing-box outbound labels/);
|
||||
const invalidCollector = structuredClone(snapshot);
|
||||
invalidCollector.domainTraffic.source.mode = 'future';
|
||||
assert.throws(() => renderPrometheusMetrics(invalidCollector), /Invalid traffic collector labels/);
|
||||
const invalidShadow = structuredClone(snapshot);
|
||||
invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5';
|
||||
assert.throws(() => renderPrometheusMetrics(invalidShadow), /Invalid Prometheus gauge/);
|
||||
});
|
||||
|
||||
function routeResponse() {
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { create } from '@bufbuild/protobuf';
|
||||
import { connectNodeAdapter } from '@connectrpc/connect-node';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import http2 from 'node:http2';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
ConnectionEventsSchema,
|
||||
StartedAtSchema,
|
||||
StartedService,
|
||||
StatusSchema,
|
||||
VersionSchema,
|
||||
} from '../../dist/server/generated/daemon/started_service_pb.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
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');
|
||||
process.env.SING_BOX_TRAFFIC_SOURCE = 'snapshot';
|
||||
|
||||
const {
|
||||
buildDualChannelGatewayConfig,
|
||||
@@ -124,3 +140,257 @@ test('cached dual-channel outbounds must match the applied provider fingerprints
|
||||
config.outbounds[2].outbounds = ['channel-primary'];
|
||||
assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false);
|
||||
});
|
||||
|
||||
function request(socketPath, pathname, method = 'GET', body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? null : JSON.stringify(body);
|
||||
const value = http.request({
|
||||
socketPath,
|
||||
path: pathname,
|
||||
method,
|
||||
...(payload ? { headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } } : {}),
|
||||
}, (response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))));
|
||||
});
|
||||
value.on('error', reject);
|
||||
value.end(payload);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSocket(socketPath, child, stderr) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (child.exitCode !== null) throw new Error(`dataplane exited: ${stderr()}`);
|
||||
try {
|
||||
const status = await request(socketPath, '/status');
|
||||
if (status.ready) return;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(`dataplane did not start: ${stderr()}`);
|
||||
}
|
||||
|
||||
async function startDataplane(mode, apiPort) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `harbor-${mode}-`));
|
||||
const socketPath = path.join(directory, 'dataplane.sock');
|
||||
const configPath = path.join(directory, 'sing-box.json');
|
||||
const binDirectory = path.join(directory, 'bin');
|
||||
fs.mkdirSync(binDirectory);
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
services: [{
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: 19091,
|
||||
dashboard: false,
|
||||
}],
|
||||
inbounds: [],
|
||||
outbounds: [],
|
||||
}));
|
||||
for (const [name, source] of [
|
||||
['sing-box', `#!/bin/sh
|
||||
if [ "$1" = check ]; then exit 0; fi
|
||||
trap 'exit 0' TERM INT
|
||||
while :; do sleep 1; done
|
||||
`],
|
||||
['ip', `#!/bin/sh
|
||||
printf '%s\n' '[{"dst":"192.168.50.7","lladdr":"00:11:22:33:44:55","dev":"en0","state":["REACHABLE"]}]'
|
||||
`],
|
||||
['iptables', '#!/bin/sh\nexit 0\n'],
|
||||
['iptables-restore', '#!/bin/sh\nexit 0\n'],
|
||||
]) {
|
||||
const executable = path.join(binDirectory, name);
|
||||
fs.writeFileSync(executable, source);
|
||||
fs.chmodSync(executable, 0o755);
|
||||
}
|
||||
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
APP_MODE: 'gateway',
|
||||
APP_COMPONENT: 'dataplane',
|
||||
DATA_DIR: directory,
|
||||
DATAPLANE_SOCKET: socketPath,
|
||||
DEVICE_TRAFFIC_ACCOUNTING_ENABLED: 'false',
|
||||
SING_BOX_API_PORT: String(apiPort),
|
||||
SING_BOX_CACHE: path.join(directory, 'cache.db'),
|
||||
SING_BOX_CONFIG: configPath,
|
||||
SING_BOX_API_SECRET: path.join(directory, 'api.secret'),
|
||||
SING_BOX_RUNTIME_CONFIG: path.join(directory, 'runtime-config.json'),
|
||||
SING_BOX_TRAFFIC_SOURCE: mode,
|
||||
PATH: `${binDirectory}:${process.env.PATH || ''}`,
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
let error = '';
|
||||
child.stderr.on('data', (chunk) => { error += chunk; });
|
||||
const stop = async () => {
|
||||
if (child.exitCode === null) {
|
||||
const exited = new Promise((resolve) => child.once('exit', resolve));
|
||||
child.kill('SIGTERM');
|
||||
await exited;
|
||||
}
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
};
|
||||
try {
|
||||
await waitForSocket(socketPath, child, () => error);
|
||||
return { socketPath, child, stop };
|
||||
} catch (reason) {
|
||||
await stop();
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForAbort(signal) {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) resolve();
|
||||
else signal.addEventListener('abort', resolve, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSnapshot(socketPath, pathname, predicate, method = 'GET', body) {
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (Date.now() < deadline) {
|
||||
const snapshot = await request(socketPath, pathname, method, body);
|
||||
if (predicate(snapshot)) return snapshot;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(`timed out waiting for ${pathname}`);
|
||||
}
|
||||
|
||||
function nativeRoutes(router, source) {
|
||||
router.service(StartedService, {
|
||||
getVersion: () => create(VersionSchema, { version: '1.14.0-rc.5', apiVersion: 4 }),
|
||||
getStartedAt: () => create(StartedAtSchema, { startedAt: 1_700_000_000_000n }),
|
||||
async *subscribeConnections(_request, context) {
|
||||
yield create(ConnectionEventsSchema, {
|
||||
reset: true,
|
||||
events: [{
|
||||
type: 0,
|
||||
id: 'native-1',
|
||||
connection: {
|
||||
id: 'native-1',
|
||||
inbound: 'tproxy-in',
|
||||
inboundType: 'tproxy',
|
||||
network: 'tcp',
|
||||
source: '192.168.50.7:54000',
|
||||
destination: '203.0.113.10:443',
|
||||
domain: 'native.example',
|
||||
protocol: 'tls',
|
||||
createdAt: 1_700_000_000_000n,
|
||||
uplinkTotal: 101n,
|
||||
downlinkTotal: 202n,
|
||||
outbound: 'channel-selector',
|
||||
outboundType: 'selector',
|
||||
chainList: ['channel-primary', 'channel-selector'],
|
||||
},
|
||||
}],
|
||||
});
|
||||
await waitForAbort(context.signal);
|
||||
},
|
||||
async *subscribeStatus(_request, context) {
|
||||
while (!context.signal.aborted) {
|
||||
yield create(StatusSchema, {
|
||||
connectionsIn: source.mismatch ? 2 : 1,
|
||||
uplinkTotal: source.mismatch ? 999n : 101n,
|
||||
downlinkTotal: source.mismatch ? 999n : 202n,
|
||||
});
|
||||
await Promise.race([
|
||||
waitForAbort(context.signal),
|
||||
new Promise((resolve) => setTimeout(resolve, 100)),
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('Gateway modes keep snapshot compare-only and make native the sole canonical writer', async (t) => {
|
||||
let connectionReads = 0;
|
||||
const nativeSource = { mismatch: false };
|
||||
const clash = http.createServer((req, res) => {
|
||||
if (req.url === '/connections') connectionReads += 1;
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ connections: [{
|
||||
id: 'legacy-1',
|
||||
upload: 11,
|
||||
download: 22,
|
||||
metadata: {
|
||||
type: 'tproxy/tproxy-in',
|
||||
host: 'legacy.example',
|
||||
sourceIP: '192.168.50.7',
|
||||
},
|
||||
chains: ['channel-primary'],
|
||||
}] }));
|
||||
});
|
||||
await new Promise((resolve) => clash.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => clash.close(resolve)));
|
||||
const apiPort = clash.address().port;
|
||||
const native = http2.createServer(connectNodeAdapter({
|
||||
routes: (router) => nativeRoutes(router, nativeSource),
|
||||
}));
|
||||
await new Promise((resolve) => native.listen(19091, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => native.close(resolve)));
|
||||
|
||||
for (const mode of ['snapshot', 'shadow', 'native']) {
|
||||
connectionReads = 0;
|
||||
const dataplane = await startDataplane(mode, apiPort);
|
||||
try {
|
||||
const live = mode === 'snapshot'
|
||||
? await request(dataplane.socketPath, '/traffic/live')
|
||||
: await waitForSnapshot(
|
||||
dataplane.socketPath,
|
||||
'/traffic/live',
|
||||
(snapshot) => snapshot.source.state === 'live',
|
||||
);
|
||||
const domain = await waitForSnapshot(
|
||||
dataplane.socketPath,
|
||||
'/domain-traffic',
|
||||
(snapshot) => snapshot.tracked[0]?.uploadBytes === (mode === 'native' ? '101' : '11'),
|
||||
);
|
||||
assert.equal(domain.source.mode, mode);
|
||||
assert.equal(domain.source.writer, mode === 'native' ? 'native' : 'snapshot');
|
||||
assert.equal(domain.source.native === null, mode === 'snapshot');
|
||||
assert.equal(domain.source.shadow === null, mode !== 'shadow');
|
||||
assert.equal(live.source.state, mode === 'snapshot' ? 'disabled' : 'live');
|
||||
assert.equal(connectionReads > 0, mode !== 'native');
|
||||
if (mode === 'shadow') {
|
||||
assert.equal(domain.source.native.active, 1);
|
||||
assert.equal(domain.source.shadow.uploadDifferenceBytes, '90');
|
||||
assert.equal(domain.source.shadow.downloadDifferenceBytes, '180');
|
||||
}
|
||||
if (mode === 'native') {
|
||||
assert.equal(connectionReads, 0);
|
||||
assert.equal(live.connections[0].id, 'native-1');
|
||||
assert.deepEqual(domain.tracked, [{
|
||||
source: 'gateway',
|
||||
outbound: 'vpn',
|
||||
uploadBytes: '101',
|
||||
downloadBytes: '202',
|
||||
}]);
|
||||
await request(dataplane.socketPath, '/failover/activity', 'PUT', { enabled: true });
|
||||
await waitForSnapshot(
|
||||
dataplane.socketPath,
|
||||
'/failover/activity/read',
|
||||
(response) => response.activity?.state === 'quiet',
|
||||
'POST',
|
||||
{ thresholdBytesPerSecond: 0 },
|
||||
);
|
||||
nativeSource.mismatch = true;
|
||||
await waitForSnapshot(
|
||||
dataplane.socketPath,
|
||||
'/traffic/live',
|
||||
(snapshot) => snapshot.source.state === 'degraded',
|
||||
);
|
||||
const degradedActivity = await request(
|
||||
dataplane.socketPath,
|
||||
'/failover/activity/read',
|
||||
'POST',
|
||||
{ thresholdBytesPerSecond: 0 },
|
||||
);
|
||||
assert.equal(degradedActivity.activity, null);
|
||||
}
|
||||
} finally {
|
||||
await dataplane.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
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 { pathToFileURL } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createLiveTrafficService } from '../../dist/server/services/liveTrafficService.js';
|
||||
|
||||
const subscriptionConfig = {
|
||||
outbounds: [{
|
||||
type: 'vless',
|
||||
tag: 'test-vpn',
|
||||
server: 'vpn.example.test',
|
||||
server_port: 443,
|
||||
uuid: '00000000-0000-4000-8000-000000000000',
|
||||
tls: { enabled: true },
|
||||
}],
|
||||
};
|
||||
|
||||
function buildClientConfig(trafficSource) {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), `harbor-native-${trafficSource}-`));
|
||||
const singboxUrl = pathToFileURL(path.resolve('dist/server/singbox.js')).href;
|
||||
const script = `
|
||||
const { buildGatewayConfig } = await import(${JSON.stringify(singboxUrl)});
|
||||
const config = buildGatewayConfig(${JSON.stringify(subscriptionConfig)}, 'test-vpn');
|
||||
process.stdout.write(JSON.stringify(config));
|
||||
`;
|
||||
try {
|
||||
return JSON.parse(execFileSync(process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: path.resolve('.'),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
APP_MODE: 'client',
|
||||
DATA_DIR: dataDir,
|
||||
SING_BOX_CACHE: path.join(dataDir, 'cache.db'),
|
||||
SING_BOX_TRAFFIC_SOURCE: trafficSource,
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function abortableStream(value, signal) {
|
||||
return (async function* stream() {
|
||||
yield value;
|
||||
await new Promise((_, reject) => {
|
||||
const abort = () => reject(signal.reason || new Error('aborted'));
|
||||
if (signal.aborted) abort();
|
||||
else signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
}());
|
||||
}
|
||||
|
||||
async function waitFor(check, timeout = 1_000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (!check()) {
|
||||
if (Date.now() >= deadline) throw new Error('Timed out waiting for native traffic collector');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
test('client native mode adds the private API service and removes the retired DNS option', () => {
|
||||
const config = buildClientConfig('native');
|
||||
assert.deepEqual(config.services, [{
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: 19091,
|
||||
dashboard: false,
|
||||
}]);
|
||||
assert.deepEqual(config.dns, {});
|
||||
assert.equal(config.experimental.clash_api, undefined);
|
||||
});
|
||||
|
||||
test('client disabled mode omits the API service and keeps the 1.13-compatible DNS option', () => {
|
||||
const config = buildClientConfig('disabled');
|
||||
assert.equal(config.services, undefined);
|
||||
assert.deepEqual(config.dns, { independent_cache: true });
|
||||
assert.equal(config.experimental.clash_api, undefined);
|
||||
});
|
||||
|
||||
test('native collector authenticates all RC5 lifecycle calls and uses one-second Go duration intervals', async () => {
|
||||
const requests = [];
|
||||
const connection = {
|
||||
id: 'connection-1',
|
||||
inbound: 'mixed-in',
|
||||
inboundType: 'mixed',
|
||||
network: 'tcp',
|
||||
source: '127.0.0.1:54000',
|
||||
destination: '203.0.113.10:443',
|
||||
domain: 'example.test',
|
||||
protocol: 'tls',
|
||||
createdAt: 1_700_000_000_000n,
|
||||
closedAt: 0n,
|
||||
uplinkTotal: 12n,
|
||||
downlinkTotal: 34n,
|
||||
outbound: 'test-vpn',
|
||||
outboundType: 'vless',
|
||||
rule: 'final',
|
||||
chainList: ['test-vpn'],
|
||||
};
|
||||
const clientFactory = (port) => {
|
||||
requests.push(['factory', port]);
|
||||
return {
|
||||
async getVersion(_input, { signal, headers }) {
|
||||
requests.push(['version', signal instanceof AbortSignal, headers?.authorization]);
|
||||
return { version: '1.14.0-rc.5', apiVersion: 4 };
|
||||
},
|
||||
async getStartedAt(_input, { signal, headers }) {
|
||||
requests.push(['started-at', signal instanceof AbortSignal, headers?.authorization]);
|
||||
return { startedAt: 1_700_000_000_000n };
|
||||
},
|
||||
subscribeConnections({ interval }, { signal, headers }) {
|
||||
requests.push(['connections', interval, headers?.authorization]);
|
||||
return abortableStream({
|
||||
reset: true,
|
||||
events: [{ type: 0, id: connection.id, connection }],
|
||||
}, signal);
|
||||
},
|
||||
subscribeStatus({ interval }, { signal, headers }) {
|
||||
requests.push(['status', interval, headers?.authorization]);
|
||||
return abortableStream({
|
||||
connectionsIn: 1,
|
||||
uplinkTotal: 12n,
|
||||
downlinkTotal: 34n,
|
||||
}, signal);
|
||||
},
|
||||
};
|
||||
};
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: true,
|
||||
isRuntimeRunning: () => true,
|
||||
authorization: () => 'test-secret',
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
service.start();
|
||||
await waitFor(() => service.snapshot().source.state === 'live');
|
||||
const snapshot = service.snapshot();
|
||||
assert.equal(snapshot.epoch, 'sing-box-1700000000000');
|
||||
assert.equal(snapshot.source.singBoxVersion, '1.14.0-rc.5');
|
||||
assert.equal(snapshot.source.singBoxApiVersion, 4);
|
||||
assert.equal(snapshot.connections[0].destination.domain, 'example.test');
|
||||
assert.deepEqual(requests, [
|
||||
['factory', 19091],
|
||||
['version', true, 'Bearer test-secret'],
|
||||
['started-at', true, 'Bearer test-secret'],
|
||||
['connections', 1_000_000_000n, 'Bearer test-secret'],
|
||||
['status', 1_000_000_000n, 'Bearer test-secret'],
|
||||
]);
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
test('disabled collector stays cached and never constructs a native client', async () => {
|
||||
let factoryCalls = 0;
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: false,
|
||||
isRuntimeRunning: () => true,
|
||||
clientFactory: () => {
|
||||
factoryCalls += 1;
|
||||
throw new Error('must not connect');
|
||||
},
|
||||
});
|
||||
|
||||
service.start();
|
||||
assert.equal(service.snapshot().source.state, 'disabled');
|
||||
assert.equal(factoryCalls, 0);
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
test('a native runtime materialization error is cached as incompatible without constructing a client', async () => {
|
||||
let factoryCalls = 0;
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: false,
|
||||
unavailableError: 'secret file unavailable at https://private.example/path',
|
||||
isRuntimeRunning: () => true,
|
||||
clientFactory: () => {
|
||||
factoryCalls += 1;
|
||||
throw new Error('must not connect');
|
||||
},
|
||||
});
|
||||
|
||||
service.start();
|
||||
assert.equal(service.snapshot().source.state, 'incompatible');
|
||||
assert.equal(service.snapshot().source.error, 'secret file unavailable at [endpoint]');
|
||||
assert.equal(factoryCalls, 0);
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
for (const incompatible of [
|
||||
{ version: '1.13.18', apiVersion: 4 },
|
||||
{ version: '1.14.0-rc.5', apiVersion: 5 },
|
||||
]) {
|
||||
test(`collector rejects unqualified sing-box ${incompatible.version} API ${incompatible.apiVersion}`, async () => {
|
||||
let startedAtCalls = 0;
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: true,
|
||||
isRuntimeRunning: () => true,
|
||||
clientFactory: () => ({
|
||||
async getVersion() { return incompatible; },
|
||||
async getStartedAt() {
|
||||
startedAtCalls += 1;
|
||||
return { startedAt: 1n };
|
||||
},
|
||||
subscribeConnections() { throw new Error('must not subscribe'); },
|
||||
subscribeStatus() { throw new Error('must not subscribe'); },
|
||||
}),
|
||||
});
|
||||
|
||||
service.start();
|
||||
await waitFor(() => service.snapshot().source.state === 'incompatible');
|
||||
assert.equal(service.snapshot().source.singBoxVersion, incompatible.version);
|
||||
assert.equal(service.snapshot().source.singBoxApiVersion, incompatible.apiVersion);
|
||||
assert.equal(startedAtCalls, 0);
|
||||
await service.stop();
|
||||
});
|
||||
}
|
||||
|
||||
test('collector reconnects when either RC5 stream ends and cancels its sibling', async () => {
|
||||
let factoryCalls = 0;
|
||||
let authorizationCalls = 0;
|
||||
const attachedSecrets = [];
|
||||
let firstStatusSignal;
|
||||
const pending = (signal) => (async function* stream() {
|
||||
await new Promise((_, reject) => {
|
||||
const abort = () => reject(signal.reason || new Error('aborted'));
|
||||
if (signal.aborted) abort();
|
||||
else signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
}());
|
||||
const clientFactory = () => {
|
||||
factoryCalls += 1;
|
||||
const attempt = factoryCalls;
|
||||
return {
|
||||
async getVersion(_input, { headers }) {
|
||||
attachedSecrets.push(headers.authorization);
|
||||
return { version: '1.14.0-rc.5', apiVersion: 4 };
|
||||
},
|
||||
async getStartedAt() {
|
||||
return { startedAt: 1_700_000_000_000n };
|
||||
},
|
||||
subscribeConnections(_input, { signal }) {
|
||||
return attempt === 1 ? (async function* ended() {})() : pending(signal);
|
||||
},
|
||||
subscribeStatus(_input, { signal }) {
|
||||
if (attempt === 1) firstStatusSignal = signal;
|
||||
return pending(signal);
|
||||
},
|
||||
};
|
||||
};
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: true,
|
||||
isRuntimeRunning: () => true,
|
||||
authorization: () => `secret-${++authorizationCalls}`,
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
service.start();
|
||||
await waitFor(() => factoryCalls >= 2, 2_000);
|
||||
assert.equal(firstStatusSignal.aborted, true);
|
||||
assert.equal(factoryCalls, 2);
|
||||
assert.deepEqual(attachedSecrets, ['Bearer secret-1', 'Bearer secret-2']);
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
test('a failed lifecycle projection is retained across one collector reconnect', async () => {
|
||||
let factoryCalls = 0;
|
||||
const projected = [];
|
||||
const idleStream = (signal) => (async function* stream() {
|
||||
await new Promise((_, reject) => {
|
||||
const abort = () => reject(signal.reason || new Error('aborted'));
|
||||
if (signal.aborted) abort();
|
||||
else signal.addEventListener('abort', abort, { once: true });
|
||||
});
|
||||
}());
|
||||
const connection = {
|
||||
id: 'projection',
|
||||
inbound: 'tproxy-in',
|
||||
inboundType: 'tproxy',
|
||||
network: 'tcp',
|
||||
source: '192.168.50.7:54000',
|
||||
destination: '203.0.113.10:443',
|
||||
domain: 'example.test',
|
||||
protocol: 'tls',
|
||||
createdAt: 1_700_000_000_000n,
|
||||
closedAt: 0n,
|
||||
uplinkTotal: 1n,
|
||||
downlinkTotal: 0n,
|
||||
outbound: 'channel-selector',
|
||||
outboundType: 'selector',
|
||||
rule: 'final',
|
||||
chainList: ['channel-primary', 'channel-selector'],
|
||||
};
|
||||
const clientFactory = () => {
|
||||
factoryCalls += 1;
|
||||
const attempt = factoryCalls;
|
||||
return {
|
||||
async getVersion() {
|
||||
return { version: '1.14.0-rc.5', apiVersion: 4 };
|
||||
},
|
||||
async getStartedAt() {
|
||||
return { startedAt: 1_700_000_000_000n };
|
||||
},
|
||||
subscribeConnections(_input, { signal }) {
|
||||
return (async function* stream() {
|
||||
yield { reset: true, events: [{ type: 0, id: connection.id, connection }] };
|
||||
await new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason || new Error('aborted')), { once: true });
|
||||
});
|
||||
}());
|
||||
},
|
||||
subscribeStatus(_input, { signal }) {
|
||||
return attempt === 1
|
||||
? idleStream(signal)
|
||||
: (async function* stream() {
|
||||
while (!signal.aborted) {
|
||||
yield { connectionsIn: 1, uplinkTotal: 1n, downlinkTotal: 0n };
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
}());
|
||||
},
|
||||
};
|
||||
};
|
||||
const service = createLiveTrafficService({
|
||||
port: 19091,
|
||||
enabled: true,
|
||||
gateway: true,
|
||||
isRuntimeRunning: () => true,
|
||||
clientFactory,
|
||||
onProjection: async (batch) => {
|
||||
projected.push(batch.connections[0]?.traffic.uploadBytes || 'heartbeat');
|
||||
if (projected.length === 1) throw new Error('writer unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
service.start();
|
||||
await waitFor(() => factoryCalls >= 2
|
||||
&& projected.includes('heartbeat')
|
||||
&& service.snapshot().source.state === 'live', 2_000);
|
||||
assert.equal(projected[0], '1');
|
||||
assert.ok(projected.filter((value) => value === '1').length >= 2);
|
||||
assert.ok(projected.includes('heartbeat'));
|
||||
assert.equal(factoryCalls, 2);
|
||||
assert.equal(service.snapshot().source.error, null);
|
||||
assert.equal(service.snapshot().connections[0].route.kind, 'vpn');
|
||||
await service.stop();
|
||||
});
|
||||
@@ -11,6 +11,35 @@ import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
|
||||
test('startup recovery rejects a materialized native API secret as shared config truth', async (t) => {
|
||||
const server = {
|
||||
id: 'server-a',
|
||||
label: 'Server A',
|
||||
host: 'a.example',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
};
|
||||
const fixture = await startClientFixture(t, {
|
||||
state: profileState(server),
|
||||
config: {
|
||||
...generatedConfig(server),
|
||||
services: [{
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: 19091,
|
||||
dashboard: false,
|
||||
secret: 'a'.repeat(64),
|
||||
}],
|
||||
},
|
||||
trafficSource: 'native',
|
||||
});
|
||||
|
||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
||||
assert.equal(fixture.state.connection.process, 'stopped');
|
||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
||||
});
|
||||
|
||||
function listen(server, ...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
@@ -121,6 +150,7 @@ async function startClientFixture(t, {
|
||||
config,
|
||||
hostNetwork,
|
||||
gatewayPresencePort,
|
||||
trafficSource,
|
||||
}) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
|
||||
const { binDirectory, markerPath } = fakeSingbox(directory);
|
||||
@@ -148,6 +178,7 @@ async function startClientFixture(t, {
|
||||
: hostNetworkPath,
|
||||
...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}),
|
||||
HARBOR_TEST_RUN_MARKER: markerPath,
|
||||
...(trafficSource ? { SING_BOX_TRAFFIC_SOURCE: trafficSource } : {}),
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
@@ -363,6 +394,34 @@ test('boot rejects an existing config owned by a different applied target', asyn
|
||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
||||
});
|
||||
|
||||
test('disabled traffic source rejects an existing config with any API service', async (t) => {
|
||||
const server = {
|
||||
id: 'server-a',
|
||||
label: 'Server A',
|
||||
host: 'a.example',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
};
|
||||
const fixture = await startClientFixture(t, {
|
||||
state: profileState(server),
|
||||
config: {
|
||||
...generatedConfig(server),
|
||||
services: [{
|
||||
type: 'api',
|
||||
listen: '0.0.0.0',
|
||||
listen_port: 19091,
|
||||
dashboard: false,
|
||||
}],
|
||||
},
|
||||
trafficSource: 'disabled',
|
||||
});
|
||||
|
||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
||||
assert.equal(fixture.state.connection.process, 'stopped');
|
||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
||||
});
|
||||
|
||||
test('stopped Gateway boot explicitly stops an already running remote dataplane', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-'));
|
||||
const socketPath = path.join(directory, 'dataplane.sock');
|
||||
|
||||
@@ -131,6 +131,7 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
||||
expectedRevision: 10,
|
||||
}),
|
||||
}],
|
||||
[() => api.traffic.live(), '/api/traffic/live', {}],
|
||||
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
|
||||
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
|
||||
[() => api.servers.ping('profile-1', ['one', 'two']), '/api/profiles/profile-1/servers/ping', {
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { assertLiveTrafficSnapshot } from '../../.test-dist/src/shared/liveTraffic.js';
|
||||
import {
|
||||
groupTrafficConnections,
|
||||
reconcileTrafficGroups,
|
||||
trafficGroupMatches,
|
||||
} from '../../.test-dist/src/web/features/traffic/trafficRows.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const source = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const app = source('src/web/App.tsx');
|
||||
const api = source('src/web/api/harborClient.ts');
|
||||
const page = source('src/web/components/ClientOverviewPage.tsx');
|
||||
const feature = source('src/web/features/traffic/TrafficFeature.tsx');
|
||||
const rowModel = source('src/web/features/traffic/trafficRows.ts');
|
||||
const boundary = source('src/web/features/traffic/index.ts');
|
||||
const styles = source('src/web/styles/features/traffic.css');
|
||||
const primitives = source('src/web/styles/primitives.css');
|
||||
|
||||
const validSnapshot = {
|
||||
apiVersion: 1,
|
||||
epoch: 'epoch-1',
|
||||
sequence: 1,
|
||||
observedAt: '2026-08-31T10:00:00.000Z',
|
||||
capabilities: { lifecycle: true, deviceAttribution: false, applicationAttribution: false },
|
||||
source: {
|
||||
transport: 'native',
|
||||
state: 'live',
|
||||
completeness: 'lifecycle',
|
||||
singBoxVersion: '1.14.0-rc.5',
|
||||
singBoxApiVersion: 1,
|
||||
error: null,
|
||||
unattributedUploadBytes: '0',
|
||||
unattributedDownloadBytes: '0',
|
||||
},
|
||||
summary: {
|
||||
active: 1,
|
||||
recent: 0,
|
||||
visible: 1,
|
||||
recognized: 1,
|
||||
unresolved: 0,
|
||||
unresolvedOrigin: 0,
|
||||
truncated: false,
|
||||
},
|
||||
connections: [{
|
||||
id: 'connection-1',
|
||||
startedAt: '2026-08-31T09:59:59.000Z',
|
||||
closedAt: null,
|
||||
inbound: { tag: 'mixed-in', type: 'mixed' },
|
||||
network: 'tcp',
|
||||
protocol: 'tls',
|
||||
source: { ip: '127.0.0.1', port: 53000 },
|
||||
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
|
||||
origin: { kind: 'this-mac', id: null, label: 'Этот Mac', provenance: 'client-runtime' },
|
||||
route: {
|
||||
kind: 'vpn',
|
||||
scope: 'local-sing-box',
|
||||
outbound: 'proxy',
|
||||
outboundType: 'selector',
|
||||
chain: ['proxy'],
|
||||
rule: 'default',
|
||||
},
|
||||
traffic: {
|
||||
uploadBytes: '10',
|
||||
downloadBytes: '20',
|
||||
uploadBytesPerSecond: '1',
|
||||
downloadBytesPerSecond: '2',
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
function trafficConnection(id, overrides = {}) {
|
||||
const base = validSnapshot.connections[0];
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
id,
|
||||
inbound: { ...base.inbound, ...overrides.inbound },
|
||||
source: { ...base.source, ...overrides.source },
|
||||
destination: { ...base.destination, ...overrides.destination },
|
||||
origin: { ...base.origin, ...overrides.origin },
|
||||
route: { ...base.route, ...overrides.route },
|
||||
traffic: { ...base.traffic, ...overrides.traffic },
|
||||
};
|
||||
}
|
||||
|
||||
test('live traffic runtime boundary accepts the DTO and rejects inconsistent or malformed snapshots', () => {
|
||||
assert.equal(assertLiveTrafficSnapshot(validSnapshot), validSnapshot);
|
||||
assert.throws(() => assertLiveTrafficSnapshot({
|
||||
...validSnapshot,
|
||||
summary: { ...validSnapshot.summary, visible: 0 },
|
||||
}), /Inconsistent traffic summary/);
|
||||
assert.throws(() => assertLiveTrafficSnapshot({
|
||||
...validSnapshot,
|
||||
connections: [{
|
||||
...validSnapshot.connections[0],
|
||||
route: { ...validSnapshot.connections[0].route, outbound: 42 },
|
||||
}],
|
||||
}), /Invalid traffic connection/);
|
||||
assert.throws(() => assertLiveTrafficSnapshot({
|
||||
...validSnapshot,
|
||||
summary: { ...validSnapshot.summary, active: 0, recent: 1, recognized: 0 },
|
||||
connections: [{
|
||||
...validSnapshot.connections[0],
|
||||
closedAt: '2026-08-31T10:00:00.000Z',
|
||||
}],
|
||||
}), /Invalid closed traffic rate/);
|
||||
assert.throws(() => assertLiveTrafficSnapshot({
|
||||
...validSnapshot,
|
||||
summary: { ...validSnapshot.summary, active: 2, visible: 2, recognized: 2 },
|
||||
connections: [validSnapshot.connections[0], validSnapshot.connections[0]],
|
||||
}), /Invalid traffic connection identity/);
|
||||
assert.throws(() => assertLiveTrafficSnapshot({
|
||||
...validSnapshot,
|
||||
connections: [{ ...validSnapshot.connections[0], closedAt: '2026-08-31 10:00:00Z' }],
|
||||
}), /Invalid traffic connection identity/);
|
||||
assert.throws(() => assertLiveTrafficSnapshot({
|
||||
...validSnapshot,
|
||||
connections: [{
|
||||
...validSnapshot.connections[0],
|
||||
traffic: { ...validSnapshot.connections[0].traffic, uploadBytes: 10 },
|
||||
}],
|
||||
}), /Invalid traffic byte value/);
|
||||
});
|
||||
|
||||
test('Mac and Gateway traffic drawers use one feature boundary and the cached read-only endpoint', () => {
|
||||
assert.match(boundary, /TrafficPanel,[\s\S]*TrafficToggle,[\s\S]*useTrafficFeature/);
|
||||
assert.match(api, /traffic: \{[\s\S]*live: \(\) => request\('\/api\/traffic\/live'\)/);
|
||||
assert.match(app, /loadLiveTraffic: api\.traffic\.live/);
|
||||
assert.match(page, /useTrafficFeature\(\{[\s\S]*enabled: true,[\s\S]*isGateway,[\s\S]*loadLiveTraffic: actions\.loadLiveTraffic/);
|
||||
assert.match(feature, /interface TrafficFeatureOptions \{[\s\S]*isGateway: boolean/);
|
||||
assert.match(feature, /return \{[\s\S]*isGateway,[\s\S]*isOpen/);
|
||||
const rail = page.slice(page.indexOf('<nav'), page.indexOf('</nav>'));
|
||||
const devices = rail.indexOf('<DevicesToggle');
|
||||
const traffic = rail.indexOf('<TrafficToggle');
|
||||
const diagnostics = rail.indexOf('<DiagnosticsToggle');
|
||||
assert.ok(devices >= 0 && traffic > devices && diagnostics > traffic);
|
||||
assert.equal((rail.match(/<TrafficToggle/g) || []).length, 1);
|
||||
assert.doesNotMatch(rail, /!isGateway\s*&&\s*<TrafficToggle/);
|
||||
assert.match(page, /\{hasSubscription && <TrafficPanel feature=\{trafficFeature\} \/>}/);
|
||||
assert.doesNotMatch(page, /!isGateway && hasSubscription && <TrafficPanel/);
|
||||
assert.doesNotMatch(page, /if \(isGateway\) trafficFeature\.close\(\)/);
|
||||
assert.match(page, /DRAWER_ORDER = \['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'\]/);
|
||||
assert.doesNotMatch(feature, /from ['"][^'"]*\/api(?:\/|\.js)/);
|
||||
});
|
||||
|
||||
test('traffic polling runs every second only while the drawer is open and unpaused', () => {
|
||||
assert.match(feature, /const POLL_MS = 1_000/);
|
||||
assert.match(feature, /if \(!enabled \|\| !isOpen \|\| paused\) return undefined/);
|
||||
assert.match(feature, /assertLiveTrafficSnapshot\(await loadLiveTraffic\(\)\)/);
|
||||
assert.match(feature, /setSnapshot\(next\)[\s\S]*setRequestState\('ready'\)/);
|
||||
assert.match(feature, /catch \{[\s\S]*setRequestState\('error'\)/);
|
||||
assert.match(feature, /timer = setTimeout\(poll, POLL_MS\)/);
|
||||
assert.match(feature, /cancelled = true[\s\S]*clearTimeout\(timer\)/);
|
||||
assert.match(feature, /aria-pressed=\{feature\.paused\}[\s\S]*Продолжить[\s\S]*Пауза/);
|
||||
assert.doesNotMatch(feature, /setInterval|WebSocket|EventSource/);
|
||||
});
|
||||
|
||||
test('traffic drawer exposes the requested truthful states and accessible controls', () => {
|
||||
for (const copy of [
|
||||
'VPN остановлен',
|
||||
'Активных соединений пока нет',
|
||||
'Инспектор трафика временно недоступен',
|
||||
'Эта версия sing-box не поддерживает инспектор трафика',
|
||||
'Инспектор трафика выключен в настройках Harbor Connect.',
|
||||
'Инспектор трафика выключен в настройках Harbor Gateway.',
|
||||
'Показан последний полученный снимок',
|
||||
'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.',
|
||||
'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.',
|
||||
]) assert.match(feature, new RegExp(copy.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.match(feature, /\{feature\.isGateway \? 'GATEWAY' : 'MAC'\} · \{snapshot\?\.summary\.active \|\| 0\} АКТИВНЫХ/);
|
||||
assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./);
|
||||
assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./);
|
||||
assert.match(feature, /type="search"[\s\S]*aria-label="Найти домен, сервис или IP"/);
|
||||
assert.match(feature, /role="group" aria-label="Фильтр по маршруту"/);
|
||||
assert.match(feature, /role="group" aria-label="Фильтр по качеству распознавания"/);
|
||||
assert.match(feature, /aria-pressed=\{routeFilter === value\}/);
|
||||
assert.match(feature, /aria-pressed=\{qualityFilter === value\}/);
|
||||
assert.match(feature, /const \[expandedId, setExpandedId\] = useState\(''\)/);
|
||||
assert.match(feature, /aria-expanded=\{expanded\}[\s\S]*aria-controls=\{detailsId\}/);
|
||||
assert.match(feature, /Источник[\s\S]*Назначение[\s\S]*Правило[\s\S]*Цепочка/);
|
||||
assert.doesNotMatch(feature, /closeConnection|reroute|history|sessionStorage/);
|
||||
});
|
||||
|
||||
test('traffic retention is local, bounded to approved choices and uses the frozen server observation clock', () => {
|
||||
assert.match(feature, /const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds'/);
|
||||
assert.match(feature, /const RETENTION_OPTIONS = \[5, 10, 30\] as const/);
|
||||
assert.match(feature, /Number\(localStorage\.getItem\(RETENTION_STORAGE_KEY\)\)/);
|
||||
assert.match(feature, /RETENTION_OPTIONS\.includes\(value as RetentionSeconds\)[\s\S]*: 10/);
|
||||
assert.match(feature, /localStorage\.setItem\(RETENTION_STORAGE_KEY, String\(seconds\)\)/);
|
||||
assert.match(feature, /Показывать завершённые/);
|
||||
assert.match(feature, /aria-label="Время показа завершённых соединений"/);
|
||||
assert.match(feature, /snapshot\?\.observedAt \? Date\.parse\(snapshot\.observedAt\) : Number\.NaN/);
|
||||
assert.match(feature, /connection\.closedAt === null \|\| !Number\.isFinite\(snapshotTime\)/);
|
||||
assert.match(feature, /snapshotTime - Date\.parse\(connection\.closedAt\) < retentionSeconds \* 1_000/);
|
||||
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
|
||||
assert.match(feature, /trafficGroups\.filter\([\s\S]*trafficGroupMatches/);
|
||||
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
|
||||
assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./);
|
||||
});
|
||||
|
||||
test('traffic groups combine compatible UUIDs with exact byte sums and whole-group search', () => {
|
||||
const groups = groupTrafficConnections([
|
||||
trafficConnection('active', {
|
||||
destination: { domain: 'yandex.ru', ip: '203.0.113.1' },
|
||||
traffic: {
|
||||
uploadBytes: '10',
|
||||
downloadBytes: '20',
|
||||
uploadBytesPerSecond: '1',
|
||||
downloadBytesPerSecond: '2',
|
||||
},
|
||||
}),
|
||||
trafficConnection('closed-a', {
|
||||
closedAt: '2026-08-31T09:59:59.500Z',
|
||||
destination: { domain: 'yandex.ru', ip: '203.0.113.2' },
|
||||
traffic: {
|
||||
uploadBytes: '30',
|
||||
downloadBytes: '40',
|
||||
uploadBytesPerSecond: '0',
|
||||
downloadBytesPerSecond: '0',
|
||||
},
|
||||
}),
|
||||
trafficConnection('closed-b', {
|
||||
closedAt: '2026-08-31T09:59:59.700Z',
|
||||
destination: { domain: 'yandex.ru', ip: '203.0.113.2' },
|
||||
traffic: {
|
||||
uploadBytes: '9007199254740993',
|
||||
downloadBytes: '9007199254740995',
|
||||
uploadBytesPerSecond: '0',
|
||||
downloadBytesPerSecond: '0',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(groups.length, 1);
|
||||
assert.equal(groups[0].label, 'yandex.ru');
|
||||
assert.equal(groups[0].activeCount, 1);
|
||||
assert.equal(groups[0].recentCount, 2);
|
||||
assert.deepEqual(groups[0].connections.map(({ id }) => id), ['active', 'closed-a', 'closed-b']);
|
||||
assert.deepEqual(groups[0].destinationIps, ['203.0.113.1', '203.0.113.2']);
|
||||
assert.deepEqual(groups[0].traffic, {
|
||||
uploadBytes: '9007199254741033',
|
||||
downloadBytes: '9007199254741055',
|
||||
uploadBytesPerSecond: '1',
|
||||
downloadBytesPerSecond: '2',
|
||||
});
|
||||
assert.equal(trafficGroupMatches(groups[0], '203.0.113.2', 'all', 'all'), true);
|
||||
assert.equal(trafficGroupMatches(groups[0], 'missing.test', 'all', 'all'), false);
|
||||
assert.equal(trafficGroupMatches(groups[0], '', 'vpn', 'recognized'), true);
|
||||
assert.equal(trafficGroupMatches(groups[0], '', 'direct', 'all'), false);
|
||||
});
|
||||
|
||||
test('traffic grouping keeps incompatible and unknown destinations separate', () => {
|
||||
const base = trafficConnection('base', { destination: { domain: 'example.com', ip: '203.0.113.1' } });
|
||||
const same = trafficConnection('same', { destination: { domain: 'Example.COM', ip: '203.0.113.2' } });
|
||||
assert.equal(groupTrafficConnections([base, same]).length, 1);
|
||||
|
||||
const variants = [
|
||||
['domain', { destination: { domain: 'www.example.com', ip: '203.0.113.1' } }],
|
||||
['port', { destination: { domain: 'example.com', ip: '203.0.113.1', port: 80 } }],
|
||||
['network', { network: 'udp' }],
|
||||
['protocol', { protocol: 'http' }],
|
||||
['origin', { origin: { kind: 'device', id: 'device-1', label: 'iPhone', provenance: 'source-ip' } }],
|
||||
['route kind', { route: { kind: 'other' } }],
|
||||
['route outbound', { route: { outbound: 'other-proxy' } }],
|
||||
['route outbound type', { route: { outboundType: 'vless' } }],
|
||||
['route chain', { route: { chain: ['proxy', 'hop'] } }],
|
||||
['route rule', { route: { rule: 'other-rule' } }],
|
||||
];
|
||||
for (const [name, overrides] of variants) {
|
||||
assert.equal(groupTrafficConnections([base, trafficConnection(String(name), overrides)]).length, 2, name);
|
||||
}
|
||||
|
||||
assert.equal(groupTrafficConnections([
|
||||
trafficConnection('ip-a', { destination: { domain: null, ip: '203.0.113.1' } }),
|
||||
trafficConnection('ip-b', { destination: { domain: null, ip: '203.0.113.2' } }),
|
||||
]).length, 2);
|
||||
assert.equal(groupTrafficConnections([
|
||||
trafficConnection('unknown-a', { destination: { domain: null, ip: null, provenance: 'unknown' } }),
|
||||
trafficConnection('unknown-b', { destination: { domain: null, ip: null, provenance: 'unknown' } }),
|
||||
]).length, 2);
|
||||
const deviceOrigin = { kind: 'device', id: 'device-1', label: 'iPhone', provenance: 'source-ip' };
|
||||
const [deviceGroup] = groupTrafficConnections([
|
||||
trafficConnection('device', { origin: deviceOrigin }),
|
||||
]);
|
||||
assert.deepEqual(deviceGroup.origin, deviceOrigin);
|
||||
assert.match(feature, /const source = onlyConnection[\s\S]*group\.origin\.label[\s\S]*onlyConnection\.source\.ip[\s\S]*group\.connections\.length/);
|
||||
});
|
||||
|
||||
test('traffic groups stay mounted and inert through exit while the same group cancels removal', () => {
|
||||
const group = (id, count = 1) => ({ id, connections: Array.from({ length: count }) });
|
||||
let rows = reconcileTrafficGroups([], [group('a'), group('b'), group('c')], false);
|
||||
rows = reconcileTrafficGroups(rows, [group('b', 2)], false);
|
||||
assert.deepEqual(rows.map((row) => [row.group.id, row.group.connections.length, row.exiting]), [
|
||||
['a', 1, true],
|
||||
['b', 2, false],
|
||||
['c', 1, true],
|
||||
]);
|
||||
rows = reconcileTrafficGroups(rows, [group('a'), group('b', 3), group('c')], false);
|
||||
assert.deepEqual(rows.map((row) => [row.group.id, row.group.connections.length, row.exiting]), [
|
||||
['a', 1, false],
|
||||
['b', 3, false],
|
||||
['c', 1, false],
|
||||
]);
|
||||
assert.deepEqual(reconcileTrafficGroups(rows, [], true), []);
|
||||
assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/);
|
||||
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
|
||||
assert.match(feature, /trafficGroupMatches\(group, query, routeFilter, qualityFilter\)/);
|
||||
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/);
|
||||
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
|
||||
assert.match(feature, /inert=\{exiting \|\| undefined\}/);
|
||||
assert.match(feature, /event\.target === event\.currentTarget[\s\S]*event\.animationName === 'client-traffic-connection-out'/);
|
||||
assert.match(feature, /row\.group\.id !== id \|\| !row\.exiting/);
|
||||
assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*media\.addEventListener\('change', update\)[\s\S]*media\.removeEventListener\('change', update\)/);
|
||||
assert.match(feature, /const immediate = reducedMotion[\s\S]*\['disabled', 'incompatible', 'stopped'\]\.includes/);
|
||||
assert.match(feature, /aria-label="Группы активных и недавно завершённых соединений"/);
|
||||
assert.match(feature, /group\.connections\.length > 1[\s\S]*×\$\{group\.connections\.length\}/);
|
||||
assert.match(feature, /group\.activeCount > 0 && <strong>/);
|
||||
assert.match(feature, /displayedGroups\.map\(\(row\) => <TrafficGroupRow[\s\S]*key=\{row\.group\.id\}/);
|
||||
assert.match(feature, /<b>Активных распознано<\/b>/);
|
||||
assert.match(feature, /<b>Активных требует внимания<\/b>/);
|
||||
});
|
||||
|
||||
test('traffic styling preserves the shared drawer geometry and minimal motion', () => {
|
||||
assert.match(styles, /\.client-traffic-toggle svg \{[\s\S]*width: 24px;[\s\S]*height: 24px/);
|
||||
assert.match(primitives, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
|
||||
assert.match(styles, /@media \(max-width: 768px\) \{[\s\S]*\.client-traffic \{[\s\S]*width: 100vw/);
|
||||
assert.doesNotMatch(styles, /overflow-y:\s*(?:auto|scroll)/);
|
||||
assert.match(styles, /\.client-traffic-meta button \{[\s\S]*width: 96px/);
|
||||
assert.match(styles, /\.client-traffic-details \{[\s\S]*animation: client-traffic-details-in 180ms/);
|
||||
assert.match(styles, /\.client-traffic-connection \{[\s\S]*animation: client-traffic-connection-in 420ms/);
|
||||
assert.match(styles, /\.client-traffic-connection\.is-exiting \{[\s\S]*animation: client-traffic-connection-out 240ms/);
|
||||
const detailMotion = /@keyframes client-traffic-details-in \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '';
|
||||
assert.match(detailMotion, /opacity:/);
|
||||
assert.match(detailMotion, /translateY/);
|
||||
assert.doesNotMatch(detailMotion, /height|width|margin|padding|scale|filter/);
|
||||
for (const name of ['client-traffic-connection-in', 'client-traffic-connection-out']) {
|
||||
const rowMotion = new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`).exec(styles)?.[1] || '';
|
||||
assert.match(rowMotion, /opacity:/);
|
||||
assert.match(rowMotion, /translateY/);
|
||||
assert.doesNotMatch(rowMotion, /height|width|margin|padding|scale|filter/);
|
||||
}
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details \{[\s\S]*animation: none/);
|
||||
});
|
||||
@@ -128,7 +128,7 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
|
||||
assert.match(component, /<SubscriptionToggle[\s\S]*<InstructionsToggle[\s\S]*<DevicesToggle/);
|
||||
assert.match(subscription, /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(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<TrafficToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
|
||||
assert.match(diagnosticsFeature, /client-diagnostics-toggle/);
|
||||
assert.match(component, /<ConnectivityDiagnosticsPanel/);
|
||||
assert.doesNotMatch(component, /diagnosticsToggleRef|diagnosticsPanelRef|diagnosticsCloseRef/);
|
||||
@@ -142,7 +142,7 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
|
||||
assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/);
|
||||
assert.match(subscription, /client-rail-subscription-back[\s\S]*client-rail-subscription-front[\s\S]*client-rail-subscription-lines/);
|
||||
assert.match(instructions, /client-rail-book-page-position is-left[\s\S]*client-rail-book-page is-left[\s\S]*client-rail-book-page-position is-right[\s\S]*client-rail-book-page is-right[\s\S]*client-rail-book-spine/);
|
||||
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
|
||||
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<TrafficToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
|
||||
assert.match(diagnosticsFeature, /client-rail-diagnostics-trace[\s\S]*client-rail-diagnostics-position[\s\S]*client-rail-diagnostics-glass/);
|
||||
assert.match(devices, /client-rail-device-monitor[\s\S]*M5 19h5\.5M7 15v4[\s\S]*client-rail-device-phone[\s\S]*client-rail-device-link[\s\S]*M19 16v3h-5\.5/);
|
||||
assert.match(routing, /client-rail-rule-track[\s\S]*client-rail-rule-knob-position is-top[\s\S]*client-rail-rule-knob is-top[\s\S]*client-rail-rule-knob-position is-bottom[\s\S]*client-rail-rule-knob is-bottom/);
|
||||
@@ -167,7 +167,7 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
|
||||
assert.match(instructions, /<Drawer[\s\S]*className="client-instructions"/);
|
||||
assert.match(routing, /<Drawer[\s\S]*className="client-local-rules"/);
|
||||
assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"/);
|
||||
assert.match(component, /const DRAWER_ORDER = \['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'\]/);
|
||||
assert.match(component, /const DRAWER_ORDER = \['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'\]/);
|
||||
assert.match(component, /function switchDrawer\(target: DrawerKey\)[\s\S]*from\.inert = true[\s\S]*translateY\(\$\{direction \* 100\}%\)[\s\S]*translateY\(\$\{-direction \* 100\}%\)/);
|
||||
assert.match(component, /const \[drawerSwitchTarget, setDrawerSwitchTarget\] = useState<DrawerKey \| null>\(null\)/);
|
||||
assert.match(component, /const activeRailDrawer = drawerSwitchTarget && drawerControls\[drawerSwitchTarget\]\.isOpen[\s\S]*drawerControls\[drawer\]\.isOpen/);
|
||||
|
||||
@@ -33,36 +33,37 @@ const expectedImports = [
|
||||
'./features/diagnostics.css',
|
||||
'./features/failover.css',
|
||||
'./features/activity-journal.css',
|
||||
'./features/traffic.css',
|
||||
'./layout.css',
|
||||
'./themes.css',
|
||||
];
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 1120,
|
||||
cascadeEdges: 1126,
|
||||
customProperties: 115,
|
||||
declarations: 4455,
|
||||
declarations: 4676,
|
||||
important: 0,
|
||||
keyframes: 52,
|
||||
media: 19,
|
||||
rules: 1193,
|
||||
variableReferences: 1138,
|
||||
keyframes: 55,
|
||||
media: 22,
|
||||
rules: 1262,
|
||||
variableReferences: 1214,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: 'd8eeaf20637638f97dd826ae7486469367fb7f5c7e2d32a6fb1d64647e8b52e0',
|
||||
cascadeEdges: '1f3c75839bd37bb312b9aed2987ed61571e5148f8b23c8288424cb193c7a8dda',
|
||||
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
||||
declarations: '89b645d94c44f0dcfea1a7c5f653a8124f0c87057a9898437419ddce813271d2',
|
||||
declarations: '616fc7cb72a9db509f6b41e1808118c2e2be7da6ce4c078dfa2eb4a963f0dbb1',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||
keyframes: '405688c9a452aa9d54e9c50dd30abb13143fdb5910f63f4474ecefcc800311e6',
|
||||
ruleDeclarationSequences: '12a5a2deec8a8521f49551d9a29c1950a01bce225f1b387def47304fcfe1c960',
|
||||
selectors: 'fa7cc612da0fc1e3804a884032e9ab99e0bb0776b29a9e40d44a2891de198067',
|
||||
variableReferences: '1cbaf891cd2df7c91003d8879a205075211678ad40a817b746c4dde54830ad6d',
|
||||
witnesses: 'bae0329346060aeeef91dee449ccee7187e68501c0b5b082026312e7c21a4798',
|
||||
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
|
||||
ruleDeclarationSequences: '3a65866ea25506c6fd79b68bfbbdeddd495f6957dbd99dec6a814b816ba4cca2',
|
||||
selectors: '56e1c67b35230649a3c69b2aea67c1929580700f1b4357ee05dfb11012d72655',
|
||||
variableReferences: '71722a81fa16b3586ae9ee5890f79176729dfdf7fe58c4d4994755f8059aa034',
|
||||
witnesses: '2272ed9b07e02edd232c33a971c4cc5171f6a612ccaa36418933f0fd6bb465c1',
|
||||
},
|
||||
};
|
||||
|
||||
test('public stylesheet exposes exactly fourteen flat semantic owners', () => {
|
||||
test('public stylesheet exposes exactly fifteen 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`);
|
||||
@@ -211,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 1198);
|
||||
assert.equal(witnesses.length, 1292);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
@@ -408,8 +409,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||
|
||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||
assert.deepEqual(assets, ['index-C2yOT86L.css']);
|
||||
assert.deepEqual(assets, ['index-tJmxnB8a.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 169625);
|
||||
assert.equal(sha256(built), 'a9bb2c83ab62798e762f4338be1869a6d29366903f83f90d4abcaa2855e44d8e');
|
||||
assert.equal(built.byteLength, 176543);
|
||||
assert.equal(sha256(built), '38a283204c656164c38c17aa03f41c931544e05b5732248807d911b2645bf970');
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
|
||||
|
||||
assert.equal((production.match(/<Tooltip\b/g) || []).length, 16);
|
||||
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
|
||||
assert.equal((production.match(/<RailAction\b/g) || []).length, 7);
|
||||
assert.equal((production.match(/<Drawer\b/g) || []).length, 7);
|
||||
assert.equal((production.match(/<RailAction\b/g) || []).length, 8);
|
||||
assert.equal((production.match(/<Drawer\b/g) || []).length, 8);
|
||||
assert.doesNotMatch(production, /className="client-tooltip"|className="client-copy-label"|className="client-drawer-close"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user