349 lines
18 KiB
JavaScript
349 lines
18 KiB
JavaScript
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/);
|
||
});
|