Files
harbor-net/test/web/device-inventory-contract.test.js
T
dokril fbf22e0b88
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
Refine inline device alias editing
2026-08-07 21:54:41 +03:00

200 lines
16 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 { fileURLToPath } from 'node:url';
import {
formatByteString,
formatLastSeen,
positiveByteDelta,
sortDevicesByTraffic,
stabilizeDevicesByTraffic,
trafficAxisMid,
trafficScaleRatio,
} 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, /const orderChanged = previousOrder\.current\.length > 0/);
assert.match(panel, /previousScrollTop\.current - currentScrollTop/);
assert.match(panel, /next\.revision > current\.revision/);
assert.doesNotMatch(panel, /revision >= current\.revision/);
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, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/);
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
assert.match(panel, /\{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">/);
assert.match(panel, /client-device-name-heading\$\{hasName \? '' : ' is-address-only'\}\$\{editing \? ' is-editing' : ''\}/);
assert.match(panel, /className="client-device-alias-input"[\s\S]*onBlur=\{\(\) => saveAlias\(device\)\}[\s\S]*event\.key === 'Enter'[\s\S]*event\.currentTarget\.blur\(\)/);
assert.match(panel, /aliasBaseline\.current = \{ id: device\.id, value \}/);
assert.match(panel, /aliasWidth\.current = `\$\{width\}px`[\s\S]*startEditing\(device, event\.currentTarget\.getBoundingClientRect\(\)\.width\)/);
assert.match(panel, /nextAlias === aliasBaseline\.current\.value\.trim\(\)[\s\S]*setEditingId\(\(current\) => current === device\.id \? '' : current\)/);
assert.doesNotMatch(panel, /client-device-alias"|Сохранить название|Отменить изменение/);
assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/);
assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/);
assert.match(panel, /COPY_FEEDBACK_MS = 800/);
assert.match(panel, /IP скопирован/);
assert.doesNotMatch(panel, /client-device-name-feedback|client-device-name-primary/);
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, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/);
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, /previousScale\.current !== scale[\s\S]*attributeName="points"[\s\S]*dur="520ms"/);
assert.doesNotMatch(panel, /key=\{latest\}/);
assert.match(panel, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
assert.match(panel, /onPointerMove=\{trackPointer\}/);
assert.match(panel, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
assert.match(panel, /client-device-traffic-grid[\s\S]*polyline className="is-gateway"[\s\S]*polyline className="is-proxy"/);
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
assert.match(panel, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.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\.is-pinned \.client-device-traffic-chart \{[\s\S]*grid-template-columns: 52px minmax\(0, 1fr\)/);
assert.match(styles, /\.client-device-traffic-grid line \{[\s\S]*vector-effect: non-scaling-stroke/);
assert.match(styles, /\.client-device-traffic-lines polyline,[\s\S]*stroke-width: 1\.8/);
assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/);
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-line-draw[\s\S]*stroke-dashoffset: 0/);
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-alias-trigger \{[\s\S]*font: 700 14px\/1\.2/);
assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*client-device-alias-edit-in 360ms/);
assert.match(styles, /@keyframes client-device-alias-edit-in[\s\S]*color: var\(--client-accent\)[\s\S]*filter: blur\(2px\)/);
assert.doesNotMatch(styles, /\.client-device-alias \{/);
assert.match(styles, /\.client-device-ip \{[\s\S]*font: 600 12px\/1\.2/);
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-separator \{[\s\S]*color: var\(--client-muted\)/);
assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/);
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding: 11px 0 0/);
assert.match(styles, /\.client-device-main > h3\.is-address-only \{[\s\S]*flex: 0 1 auto/);
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-wrap \{[\s\S]*flex: 0 0 23px;[\s\S]*width: 23px/);
assert.doesNotMatch(styles, /\.client-device-edit-wrap \{[^}]*position: absolute/);
assert.match(styles, /\.client-device-edit\.is-writing svg \{[\s\S]*client-device-pencil-write 620ms/);
assert.match(styles, /@keyframes client-device-pencil-write[\s\S]*0%, 100%[\s\S]*translate\(1px, -1px\) rotate\(-5deg\)/);
assert.match(styles, /\.client-device-traffic-plot \{[\s\S]*height: 100%;[\s\S]*min-height: 0;[\s\S]*overflow: hidden/);
assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/);
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-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\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.equal(trafficScaleRatio('50', '100', 'linear'), 0.5);
assert.equal(Math.round(trafficScaleRatio('9', '99', 'log') * 100), 50);
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']);
assert.deepEqual(
stabilizeDevicesByTraffic([
{ id: 'a', uploadBytes: '999' },
{ id: 'b', uploadBytes: '1' },
], 'desc', ['b', 'a']).ids,
['b', 'a'],
);
assert.deepEqual(
stabilizeDevicesByTraffic([
{ id: 'a', uploadBytes: '999' },
{ id: 'b', pinned: true, uploadBytes: '1' },
], 'desc', ['a', 'b']).ids,
['b', 'a'],
);
});