Files
harbor-net/test/web/live-traffic-contract.test.js
T

349 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/);
});