165 lines
14 KiB
JavaScript
165 lines
14 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import test from 'node:test';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
formatByteString,
|
||
formatLastSeen,
|
||
positiveByteDelta,
|
||
sortDevicesByTraffic,
|
||
trafficAxisMid,
|
||
trafficSampleMetrics,
|
||
} from '../../src/web/utils/format.js';
|
||
|
||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||
const panel = fs.readFileSync(path.join(root, 'src/web/components/DevicesPanel.jsx'), 'utf8');
|
||
const api = fs.readFileSync(path.join(root, 'src/web/api.js'), 'utf8');
|
||
const server = fs.readFileSync(path.join(root, 'src/server/index.js'), 'utf8');
|
||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||
|
||
test('Gateway device inventory uses the existing accessible responsive drawer', () => {
|
||
assert.match(overview, /isGateway && <button[\s\S]*client-devices-toggle/);
|
||
assert.match(panel, /api\.devices\.list\(\)/);
|
||
assert.match(panel, /api\.devices\.refresh\(\)/);
|
||
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
||
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*api\.devices\.list\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*api\.devices\.update\(device\.id, patch, latest\.revision\)/);
|
||
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||
assert.match(panel, /movementAnimations\.current\.get\(id\)\?\.cancel\(\)/);
|
||
assert.match(panel, /prefers-reduced-motion: reduce/);
|
||
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
||
assert.match(api, /setPolicy: \(id, mode, expectedRevision\) => request\(`\/api\/devices\/\$\{id\}\/policy`/);
|
||
assert.match(server, /requestUrl\.pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
||
assert.match(server, /\/api\\\/devices\\\/\(dev_\[a-f0-9\]\{16\}\)\\\/policy\$[\s\S]*deviceInventory\.setPolicy/);
|
||
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
||
assert.match(panel, /copyText\(device\.ip\)/);
|
||
assert.match(panel, /client-device-name-primary[\s\S]*client-device-name-ip[\s\S]*client-device-name-feedback/);
|
||
assert.match(panel, /client-device-name-feedback[\s\S]*'copied!'/);
|
||
assert.match(panel, /COPY_FEEDBACK_MS = 5_000/);
|
||
assert.match(panel, /client-device-name-feedback[\s\S]*client-device-edit-wrap[\s\S]*client-device-last-seen/);
|
||
assert.doesNotMatch(panel, /client-device-title/);
|
||
assert.match(panel, /online \? 'В сети' : <TextMorph from="Не в сети" to=\{seen\.relative\} \/>/);
|
||
assert.match(panel, /aria-label=\{`\$\{online \? 'В сети' : 'Не в сети'\}\. Последний контакт: \$\{seen\.tooltip\}`\}/);
|
||
assert.doesNotMatch(panel, /client-device-status|STATUS_LABELS/);
|
||
assert.doesNotMatch(panel, /client-text-morph-goo/);
|
||
assert.doesNotMatch(panel, /client-device-addresses|client-device-manufacturer|client-device-meta/);
|
||
assert.doesNotMatch(panel, /device\.interface/);
|
||
assert.doesNotMatch(panel, /device\.mac/);
|
||
assert.doesNotMatch(panel, /client-device-identity|Пояснение идентификации устройства|ⓘ/);
|
||
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
||
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
|
||
assert.match(panel, /Трафик временно не обновляется/);
|
||
assert.match(panel, /Учитывается только трафик, который прошёл через Harbor/);
|
||
assert.match(panel, /client-device-traffic-total[\s\S]*<b>Всего<\/b><TrafficValue value=\{totalTraffic\} delta=\{trafficDelta\.total\}/);
|
||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/);
|
||
assert.match(panel, /const hasProxyTraffic = proxyTotal > 0n/);
|
||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*\{hasProxyTraffic && <span className="is-proxy"><b>Прокси<\/b><TrafficValue value=\{proxyTraffic\} delta=\{trafficDelta\.proxy\}/);
|
||
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\.trafficHistoryCapacity/);
|
||
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
||
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
||
assert.match(panel, /client-device-traffic-bar-tooltip[\s\S]*<time dateTime=\{sample\.observedAt\}>\{time\}<\/time>[\s\S]*<strong>Всего \{formatByteString\(total\)\}<\/strong>/);
|
||
assert.match(panel, /trafficAxisMid\(max, scale\)[\s\S]*client-device-traffic-axis[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
|
||
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
|
||
assert.match(panel, /\{proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(proxy\)\}<\/span>\}/);
|
||
assert.match(panel, /<time dateTime=\{samples\[0\]\.observedAt\}>[\s\S]*<span>15 с<\/span>/);
|
||
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
|
||
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
|
||
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
|
||
assert.match(panel, /source\?\.traffic\?\.proxy\?\.error/);
|
||
assert.match(panel, /client-device-pin-wrap[\s\S]*client-device-main[\s\S]*client-device-traffic[\s\S]*client-device-policy-wrap/);
|
||
assert.doesNotMatch(panel, /client-device-details/);
|
||
assert.match(panel, /api\.devices\.setPolicy\(device\.id, mode, snapshot\.revision\)/);
|
||
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*latestDevice\.desiredPolicy !== device\.desiredPolicy[\s\S]*api\.devices\.setPolicy\(device\.id, mode, latest\.revision\)/);
|
||
assert.match(panel, /className=\{`client-device-policy is-\$\{displayPolicy\}/);
|
||
assert.match(panel, /displayPolicy === 'direct' \? <svg[\s\S]*M4 12h15M14 7l5 5-5 5[\s\S]*M12 3 19 6v5/);
|
||
assert.match(panel, /Полностью обходит sing-box/);
|
||
assert.match(panel, /client-drawer client-instructions client-devices/);
|
||
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
||
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
||
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
|
||
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
||
assert.match(styles, /\.client-devices \{\s*width: min\(580px, 100vw\)/);
|
||
assert.match(styles, /\.client-device \{[\s\S]*--client-device-chart-height: 34px;[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px var\(--client-device-chart-height\);[\s\S]*padding: 10px 8px/);
|
||
assert.match(styles, /\.client-device\.is-pinned \{[\s\S]*--client-device-chart-height: 72px/);
|
||
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-chart \{[\s\S]*client-device-chart-expand 620ms/);
|
||
assert.match(styles, /\.client-device-main \{[\s\S]*display: flex;[\s\S]*align-items: center/);
|
||
assert.match(styles, /\.client-device-traffic-total,[\s\S]*\.client-device-traffic-breakdown > span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/);
|
||
assert.match(styles, /\.client-device-traffic-breakdown > span \{[\s\S]*translateY\(-12px\)[\s\S]*\.client-device-traffic:hover \.client-device-traffic-breakdown > span,[\s\S]*opacity: 1[\s\S]*translateY\(0\)/);
|
||
assert.doesNotMatch(styles, /\.client-device-traffic-breakdown \{[^}]*background:|\.client-device-traffic-breakdown \{[^}]*box-shadow:/);
|
||
assert.match(styles, /\.client-device-traffic-chart \{[\s\S]*grid-column: 2 \/ 4;[\s\S]*grid-row: 2/);
|
||
assert.match(styles, /\.client-device-traffic-track \{[\s\S]*client-device-traffic-shift 560ms/);
|
||
assert.match(styles, /\.client-device-traffic-track \{[^}]*gap: 0/);
|
||
assert.match(styles, /\.client-device-traffic-bar-fill \{[\s\S]*inset: 0 -2px/);
|
||
assert.match(styles, /\.client-device-traffic-bar:hover \.client-device-traffic-bar-tooltip \{[\s\S]*opacity: 1[\s\S]*visibility: visible/);
|
||
assert.match(styles, /\.client-device-traffic-bar\.is-edge-left[\s\S]*\.client-device-traffic-bar\.is-edge-right/);
|
||
assert.match(styles, /@keyframes client-device-traffic-shift[\s\S]*translateX\(calc\(100% \/ var\(--sample-count\)\)\)[\s\S]*translateX\(0\)/);
|
||
assert.match(styles, /@keyframes client-device-traffic-grow[\s\S]*scaleY\(0\)[\s\S]*scaleY\(1\)/);
|
||
assert.match(styles, /@keyframes client-device-chart-expand[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(1\)/);
|
||
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
|
||
assert.match(styles, /\.client-device-name \{[\s\S]*font: 700 14px\/1\.2/);
|
||
assert.match(styles, /\.client-device-name-ip \{[\s\S]*font-size: 12px/);
|
||
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font-size: 8\.5px/);
|
||
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font-size: 10px/);
|
||
assert.match(styles, /\.client-device-name > span \{[^}]*opacity: 0/);
|
||
assert.match(styles, /\.client-device-name > \.client-device-name-primary \{[^}]*opacity: 1/);
|
||
assert.match(styles, /\.client-device-name > span \{[^}]*transition: opacity 360ms[^}]*transform 480ms/);
|
||
assert.match(styles, /\.client-device-name:hover \.client-device-name-primary[\s\S]*translateY\(-0\.18em\)/);
|
||
assert.match(styles, /\.client-device-name:hover \.client-device-name-ip[\s\S]*opacity: 1[\s\S]*translateY\(0\) scale\(1\)/);
|
||
assert.match(styles, /\.client-device-name\.is-copied \.client-device-name-feedback[\s\S]*opacity: 1/);
|
||
assert.match(styles, /\.client-device-name-feedback \{[^}]*font-size: 9px/);
|
||
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding-top: 11px/);
|
||
assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/);
|
||
assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/);
|
||
assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/);
|
||
assert.match(styles, /\.client-text-morph-value \{[\s\S]*transition: opacity 360ms[\s\S]*filter 480ms/);
|
||
assert.match(styles, /\.client-device-last-seen:hover \.client-text-morph-value\.is-relative[\s\S]*opacity: 1/);
|
||
assert.match(styles, /\.client-device-pin-wrap\.client-tooltip-anchor:hover > \.client-tooltip[\s\S]*translate\(0, 0\)/);
|
||
assert.match(styles, /\.client-device-pin:hover:not\(:disabled\) svg[\s\S]*translateY\(-2px\) rotate\(-12deg\)/);
|
||
assert.match(styles, /\.client-device-pin\[aria-pressed="true"\] svg/);
|
||
assert.match(styles, /\.client-device-edit:hover svg[\s\S]*translate\(1px, -1px\) rotate\(-4deg\)/);
|
||
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
|
||
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-name > span[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-track[\s\S]*\.client-text-morph-value/);
|
||
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
||
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
||
});
|
||
|
||
test('device last-seen copy is compact with precise accessible and relative forms', () => {
|
||
const lastSeen = new Date(2026, 7, 7, 13, 15);
|
||
const now = new Date(2026, 7, 7, 13, 27);
|
||
assert.deepEqual(
|
||
formatLastSeen(lastSeen.toISOString(), now),
|
||
{
|
||
label: 'Сегодня, 13:15',
|
||
relative: '12 минут назад',
|
||
tooltip: '7 августа 2026 г. в 13:15 (12 минут назад)',
|
||
},
|
||
);
|
||
});
|
||
|
||
test('device traffic formatting and sorting preserve uint64 precision and canonical ties', () => {
|
||
assert.equal(formatByteString('9007199254740993'), '8,0 ПБ');
|
||
assert.equal(formatByteString('1536'), '1,5 КБ');
|
||
assert.equal(formatByteString('invalid'), '0 Б');
|
||
assert.equal(positiveByteDelta('1048576', '3145728'), '2,0 МБ');
|
||
assert.equal(positiveByteDelta('3145728', '3145728'), '');
|
||
assert.equal(positiveByteDelta('3145728', '1048576'), '');
|
||
assert.deepEqual(trafficSampleMetrics('75', '25', '200'), { height: 50, gatewayShare: 75 });
|
||
assert.deepEqual(trafficSampleMetrics('1', '0', '1000'), { height: 10, gatewayShare: 100 });
|
||
assert.deepEqual(trafficSampleMetrics('0', '0', '0'), { height: 0, gatewayShare: 0 });
|
||
assert.deepEqual(trafficSampleMetrics('10', '0', '100', 'log'), { height: 52, gatewayShare: 100 });
|
||
assert.equal(trafficAxisMid('100', 'linear'), 50n);
|
||
assert.equal(trafficAxisMid('99', 'log'), 9n);
|
||
|
||
const devices = [
|
||
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },
|
||
{ id: 'b', uploadBytes: '9007199254740992', downloadBytes: '2' },
|
||
{ id: 'd', uploadBytes: '15', downloadBytes: '5', proxyUploadBytes: '100' },
|
||
{ id: 'c', uploadBytes: '10', downloadBytes: '10', proxyDownloadBytes: '100' },
|
||
{ id: 'e', pinned: true, uploadBytes: '0', downloadBytes: '0' },
|
||
];
|
||
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['e', 'b', 'a', 'c', 'd']);
|
||
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['e', 'c', 'd', 'a', 'b']);
|
||
});
|