481 lines
25 KiB
JavaScript
481 lines
25 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import test from 'node:test';
|
||
import { createElement } from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
|
||
import { TrafficGroupRow } from '../../.test-dist/src/web/features/traffic/TrafficFeature.js';
|
||
import { HistoryRows } from '../../.test-dist/src/web/features/traffic/TrafficHistoryPanel.js';
|
||
import { emptyTrafficHistory, parseTrafficHistoryQuery } from '../../.test-dist/src/shared/trafficHistory.js';
|
||
|
||
import { assertLiveTrafficSnapshot } from '../../.test-dist/src/shared/liveTraffic.js';
|
||
import {
|
||
groupTrafficConnections,
|
||
reconcileTrafficGroups,
|
||
sortTrafficGroups,
|
||
summarizeTrafficOrigins,
|
||
trafficConnectionMatchesFilters,
|
||
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 history = source('src/web/features/traffic/TrafficHistoryPanel.tsx');
|
||
const controls = source('src/web/features/traffic/TrafficControls.tsx');
|
||
|
||
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 \|\| view !== 'live'\) 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[\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, /aria-label="Найти устройство"/);
|
||
assert.match(feature, /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|sessionStorage/);
|
||
assert.match(feature, /TrafficHistoryPanel/);
|
||
});
|
||
|
||
test('traffic retention and grouping use canonical server settings and the frozen server observation clock', () => {
|
||
assert.doesNotMatch(feature, /localStorage|sessionStorage/);
|
||
assert.match(feature, /TRAFFIC_RETENTION_OPTIONS\.map/);
|
||
assert.match(feature, /feature\.updateSettings\(settings\)/);
|
||
assert.match(api, /updateSettings:[\s\S]*\/api\/traffic\/settings[\s\S]*expectedRevision/);
|
||
assert.match(app, /onUpdateTrafficSettings=[\s\S]*api\.traffic\.updateSettings/);
|
||
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\(selectedConnections, grouping\)/);
|
||
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
|
||
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
|
||
});
|
||
|
||
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 groups sort by bounded frequency or latest start without changing the groups', () => {
|
||
const groups = groupTrafficConnections([
|
||
trafficConnection('latest', {
|
||
startedAt: '2026-08-31T09:59:00.000Z',
|
||
destination: { domain: 'latest.test' },
|
||
}),
|
||
...['a', 'b', 'c'].map((id, index) => trafficConnection(`popular-${id}`, {
|
||
startedAt: `2026-08-31T09:0${index}:00.000Z`,
|
||
destination: { domain: 'popular.test' },
|
||
})),
|
||
...['a', 'b'].map((id) => trafficConnection(`frequent-recent-${id}`, {
|
||
startedAt: '2026-08-31T09:59:00.000Z',
|
||
destination: { domain: 'frequent-recent.test' },
|
||
})),
|
||
]);
|
||
const original = groups.map(({ label }) => label);
|
||
|
||
assert.deepEqual(sortTrafficGroups(groups, 'popular').map(({ label }) => label), [
|
||
'popular.test',
|
||
'frequent-recent.test',
|
||
'latest.test',
|
||
]);
|
||
assert.deepEqual(sortTrafficGroups(groups, 'recent').map(({ label }) => label), [
|
||
'frequent-recent.test',
|
||
'latest.test',
|
||
'popular.test',
|
||
]);
|
||
assert.deepEqual(groups.map(({ label }) => label), original);
|
||
assert.match(feature, /const \{ grouping, sort: sortMode, retentionSeconds \} = feature\.settings/);
|
||
assert.match(feature, /role="group" aria-label="Сортировка соединений"/);
|
||
assert.match(feature, /\['popular', 'Популярные'\][\s\S]*\['recent', 'Последние'\]/);
|
||
assert.match(feature, /aria-pressed=\{sortMode === value\}/);
|
||
});
|
||
|
||
test('site grouping combines devices with exact per-device totals while device grouping keeps them separate', () => {
|
||
const first = trafficConnection('phone', {
|
||
origin: { kind: 'device', id: 'dev_0000000000000001', label: 'Телефон', provenance: 'source-ip' },
|
||
source: { ip: '192.168.50.10' },
|
||
destination: { domain: 'youtube.com' },
|
||
traffic: { uploadBytes: '10', downloadBytes: '20' },
|
||
});
|
||
const second = trafficConnection('tv', {
|
||
origin: { kind: 'device', id: 'dev_0000000000000002', label: 'ТВ', provenance: 'source-ip' },
|
||
source: { ip: '192.168.50.11' },
|
||
destination: { domain: 'youtube.com' },
|
||
traffic: { uploadBytes: '30', downloadBytes: '40' },
|
||
});
|
||
|
||
assert.equal(groupTrafficConnections([first, second], 'device').length, 2);
|
||
const [site] = groupTrafficConnections([first, second], 'site');
|
||
assert.equal(site.connections.length, 2);
|
||
assert.equal(site.origins.length, 2);
|
||
assert.deepEqual(site.traffic, {
|
||
uploadBytes: '40',
|
||
downloadBytes: '60',
|
||
uploadBytesPerSecond: '2',
|
||
downloadBytesPerSecond: '4',
|
||
});
|
||
assert.deepEqual(site.origins.map(({ label, connections, traffic }) => ({ label, connections, traffic })), [
|
||
{ label: 'ТВ', connections: 1, traffic: { uploadBytes: '30', downloadBytes: '40' } },
|
||
{ label: 'Телефон', connections: 1, traffic: { uploadBytes: '10', downloadBytes: '20' } },
|
||
]);
|
||
|
||
const ranked = summarizeTrafficOrigins([first, first, second]);
|
||
assert.deepEqual(ranked.map(({ label, connections }) => [label, connections]), [['Телефон', 2], ['ТВ', 1]]);
|
||
assert.equal(trafficConnectionMatchesFilters(first, 'vpn', 'recognized'), true);
|
||
assert.equal(trafficConnectionMatchesFilters(first, '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\(selectedConnections, grouping\)/);
|
||
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
|
||
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate, !reorder\)/);
|
||
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, /displayedGroups\.map\(\(row\) => <TrafficGroupRow[\s\S]*key=\{row\.group\.id\}/);
|
||
});
|
||
|
||
test('polling updates values without reshuffling readable rows, while explicit sorting can reorder', () => {
|
||
const groups = groupTrafficConnections([
|
||
trafficConnection('a', { destination: { domain: 'a.test' } }),
|
||
trafficConnection('b', { destination: { domain: 'b.test' } }),
|
||
trafficConnection('c', { destination: { domain: 'c.test' } }),
|
||
]);
|
||
const [a, b, c] = groups;
|
||
const updated = { ...b, traffic: { ...b.traffic, downloadBytes: '9007199254740993' } };
|
||
for (const immediate of [false, true]) {
|
||
let rows = reconcileTrafficGroups([], [a, b], immediate, true);
|
||
rows = reconcileTrafficGroups(rows, [updated, c, a], immediate, true);
|
||
assert.deepEqual(rows.map(({ group }) => group.label), ['a.test', 'b.test', 'c.test']);
|
||
assert.equal(rows[1].group.traffic.downloadBytes, '9007199254740993');
|
||
const sorted = reconcileTrafficGroups(rows, [c, updated, a], immediate, false);
|
||
assert.deepEqual(sorted.map(({ group }) => group.label), ['c.test', 'b.test', 'a.test']);
|
||
rows = reconcileTrafficGroups(rows, [c, a], immediate, true);
|
||
assert.equal(rows.some((row) => row.group.id === b.id), !immediate);
|
||
rows = reconcileTrafficGroups(rows, [a, updated, c], immediate, true);
|
||
assert.equal(rows.find((row) => row.group.id === b.id).exiting, false);
|
||
}
|
||
});
|
||
|
||
test('minimal traffic rows distinguish current rates from historical totals and completed connections', () => {
|
||
const [group] = groupTrafficConnections([trafficConnection('current', { traffic: {
|
||
downloadBytes: '9007199254740993', uploadBytes: '1048576',
|
||
downloadBytesPerSecond: '2097152', uploadBytesPerSecond: '1024',
|
||
} })]);
|
||
const render = (group, expanded = false) => renderToStaticMarkup(createElement(TrafficGroupRow, {
|
||
group, expanded, exiting: false, onExited() {}, onToggle() {},
|
||
}));
|
||
const current = render(group);
|
||
assert.match(current, /aria-label="Скачивание: 2,0 МБ\/с"/);
|
||
assert.match(current, /aria-label="Отправка: 1,0 КБ\/с"/);
|
||
assert.doesNotMatch(current, /<svg|<img|<dl|8,0 ПБ|tls/);
|
||
assert.match(render(group, true), /<dt>Скачано<\/dt><dd>8,0 ПБ<\/dd>/);
|
||
const completed = render({ ...group, activeCount: 0, recentCount: 1 });
|
||
assert.match(completed, /aria-label="Скачивание: соединение завершено"/);
|
||
assert.doesNotMatch(completed, /МБ\/с/);
|
||
const snapshot = emptyTrafficHistory(parseTrafficHistoryQuery(new URLSearchParams()));
|
||
snapshot.rows = [{ key: 'example.com', label: 'example.com', downloadBytes: '9007199254740993', uploadBytes: '1048576', route: 'vpn' }];
|
||
const historical = renderToStaticMarkup(createElement(HistoryRows, {
|
||
snapshot, active: true, load() { throw new Error('Collapsed history must not load details'); },
|
||
}));
|
||
assert.match(historical, /aria-label="Скачивание: 8,0 ПБ"/);
|
||
assert.match(historical, /aria-label="Отправка: 1,0 МБ"/);
|
||
assert.doesNotMatch(historical, /<svg|<img|\/с|VPN|Загружаем/);
|
||
});
|
||
|
||
test('traffic modes retain mounted state and stop hidden polling; menu Escape stays inside the drawer', () => {
|
||
assert.doesNotMatch(feature, /view === 'history' && <TrafficHistoryPanel/);
|
||
assert.match(feature, /<TrafficHistoryPanel active=\{feature.isOpen && !feature.paused && feature.view === 'history'\}/);
|
||
assert.match(feature, /aria-hidden=\{feature.view !== 'history'\} inert=\{feature.view !== 'history' \|\| undefined\}/);
|
||
assert.match(history, /if \(!active\) return/);
|
||
assert.match(history, /controller.abort\(\); clearTimeout\(timer\)/);
|
||
assert.match(history, /until: snapshot.query.until/);
|
||
assert.match(controls, /event.stopPropagation\(\);[\s\S]*trigger.current\?\.focus\(\)/);
|
||
assert.match(controls, /aria-hidden=\{!open\} inert=\{!open \|\| undefined\}/);
|
||
assert.match(controls, /getComputedStyle\(node\).opacity;[\s\S]*animation.cancel\(\)/);
|
||
assert.match(controls, /media.addEventListener\('change', reduce\)/);
|
||
assert.match(controls, /media.removeEventListener\('change', reduce\)/);
|
||
});
|
||
|
||
test('traffic styling reserves readable columns and one list scroll owner with calm 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, /width: min\(800px, 100vw\)/);
|
||
assert.match(styles, /@media \(max-width: 480px\)/);
|
||
assert.match(styles, /\.client-traffic-scroll \{[\s\S]*min-height: 0;[\s\S]*overflow-y: auto/);
|
||
assert.match(styles, /\.client-traffic-pause \{[\s\S]*width: 112px/);
|
||
assert.match(styles, /\.client-traffic-columns,[\s\S]*grid-template-columns: minmax\(0, 1fr\) var\(--traffic-value-width\) var\(--traffic-value-width\)/);
|
||
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.doesNotMatch(rowMotion, /transform|height|width|margin|padding|scale|filter/);
|
||
}
|
||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*animation: none;[\s\S]*transition: none/);
|
||
});
|