422 lines
38 KiB
JavaScript
422 lines
38 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,
|
||
deviceFilterCounts,
|
||
filterDevices,
|
||
formatLastSeen,
|
||
isNewDevice,
|
||
positiveByteDelta,
|
||
sortDevicesByTraffic,
|
||
stabilizeDevicesByTraffic,
|
||
trafficAxisMid,
|
||
trafficBytesPerSecond,
|
||
trafficScaleRatio,
|
||
} from '../../.test-dist/src/web/utils/format.js';
|
||
import { readStyleSource } from './style-source.js';
|
||
|
||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
|
||
const connection = fs.readFileSync(path.join(root, 'src/web/features/connection/ConnectionPanel.tsx'), 'utf8');
|
||
const subscription = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
|
||
const feature = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8');
|
||
const panel = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesPanel.tsx'), 'utf8');
|
||
const chart = fs.readFileSync(path.join(root, 'src/web/features/devices/TrafficChart.tsx'), 'utf8');
|
||
const api = fs.readFileSync(path.join(root, 'src/web/api/harborClient.ts'), 'utf8');
|
||
const server = fs.readFileSync(path.join(root, 'src/server/index.ts'), 'utf8');
|
||
const deviceRoute = fs.readFileSync(path.join(root, 'src/server/http/routes/deviceInventoryRoute.ts'), 'utf8');
|
||
const styles = readStyleSource(root);
|
||
|
||
test('Gateway device inventory uses the existing accessible responsive drawer', () => {
|
||
assert.match(overview, /isGateway && <DevicesToggle/);
|
||
assert.match(feature, /discover \? refreshDevices\(\) : listDevices\(\)/);
|
||
assert.match(feature, /DEVICE_AUTO_REFRESH_MS = 15_000/);
|
||
assert.match(feature, /setSnapshot\(\(current\) => !current \|\| next\.revision > current\.revision/);
|
||
assert.match(overview, /<DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
|
||
assert.doesNotMatch(panel, /AUTO_REFRESH_MS|const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
|
||
assert.match(feature, /requestDeviceUpdate\(device\.id, patch, snapshot\.revision\)/);
|
||
assert.match(feature, /requestError\(caught\)\.code !== 'STATE_CONFLICT'[\s\S]*listDevices\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*requestDeviceUpdate\(device\.id, patch, latest\.revision\)/);
|
||
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||
assert.match(panel, /const activeAnimation = movementAnimations\.current\.get\(id\)[\s\S]*activeAnimation\.commitStyles\(\)[\s\S]*activeAnimation\.cancel\(\)[\s\S]*interruptedPositions\.set/);
|
||
assert.match(panel, /previousPositions\.current\.size > 0/);
|
||
assert.match(panel, /previousScrollTop\.current - currentScrollTop/);
|
||
assert.match(feature, /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, /resetTraffic:[\s\S]*request\('\/api\/devices\/traffic'[\s\S]*method: 'DELETE'/);
|
||
assert.match(api, /setPolicy:[\s\S]*`\/api\/devices\/\$\{id\}\/policy`/);
|
||
assert.match(server, /createDeviceInventoryRoute\(\{/);
|
||
assert.match(deviceRoute, /pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
||
assert.match(deviceRoute, /pathname === '\/api\/devices\/traffic'[\s\S]*deviceInventory\.resetTraffic/);
|
||
assert.match(deviceRoute, /DEVICE_POLICY_PATH[\s\S]*deviceInventory\.setPolicy/);
|
||
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
||
assert.match(panel, /copyText\(value\)/);
|
||
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
|
||
assert.match(panel, /const title = device\.alias \|\| device\.hostname \|\| device\.ip \|\| 'Неизвестное устройство'/);
|
||
assert.match(panel, /const newDevice = isNewDevice\(device\.firstSeenAt\)/);
|
||
assert.match(panel, /client-device-new-badge[\s\S]*aria-hidden="true"[\s\S]*>NEW<[\s\S]*client-device-new-a11y[\s\S]*Новое устройство/);
|
||
assert.match(panel, /displayPolicy === 'direct'[\s\S]*newDevice[\s\S]*Новое устройство идёт напрямую\. Нажмите, чтобы разрешить VPN через правила Gateway/);
|
||
assert.match(panel, /client-device-fallback-name[\s\S]*client-device-identity-details[\s\S]*<b>IP<\/b>[\s\S]*<b>MAC<\/b>[\s\S]*<b>Host<\/b>/);
|
||
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
|
||
assert.match(panel, /\{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">/);
|
||
assert.match(panel, /className=\{`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, /style=\{\{ '--alias-width': `\$\{Math\.max\(1, alias\.length\)\}ch` \} as CSSProperties\}/);
|
||
assert.doesNotMatch(panel, /aliasWidth|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, /`\$\{field\} \$\{value\} скопирован`/);
|
||
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.match(panel, /role="group" aria-label=\{`Технические данные устройства \$\{title\}`\}/);
|
||
assert.match(panel, /onClick=\{\(\) => copyDeviceValue\(device, 'IP', device\.ip!\)\}/);
|
||
assert.match(panel, /onClick=\{\(\) => copyDeviceValue\(device, 'MAC', device\.mac\)\}/);
|
||
assert.match(panel, /\{device\.hostname && <button[\s\S]*copyDeviceValue\(device, 'Hostname', device\.hostname!\)/);
|
||
assert.doesNotMatch(panel, /identityTooltipId|Hostname: \{device\.hostname/);
|
||
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
||
assert.match(panel, /filterDevices\(allDevices, tags,[\s\S]*stabilizeDevicesByTraffic\(filteredDevices, sortDirection, previousIds\)[\s\S]*stabilizeDevicesByTraffic\(allDevices, sortDirection, previousIds\)[\s\S]*trafficOrder\.current = \{ direction: sortDirection, ids: canonical\.ids \}/);
|
||
assert.match(panel, /Трафик временно не обновляется/);
|
||
assert.match(panel, /Вход — накопленные Gateway\/Proxy\. Выход — приблизительно через VPN или Direct с запуска текущего учёта\./);
|
||
assert.match(panel, /const displayedTotal = trafficView === 'outbound'[\s\S]*`≈ \$\{formatByteString\(outboundTotal\.toString\(\)\)\}`[\s\S]*const trafficLabel = trafficView === 'outbound' \? 'Выход' : 'Вход'/);
|
||
assert.match(panel, /client-device-traffic-total[\s\S]*<b>\{trafficLabel\}<\/b><TrafficValue value=\{displayedTotal\}/);
|
||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>VPN<\/b>[\s\S]*<b>Direct<\/b>[\s\S]*<b>Другое<\/b>/);
|
||
assert.match(panel, /<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=\{trafficView === 'outbound' \? device\.outboundTrafficHistory \|\| \[\] : device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\?\.trafficHistoryCapacity[\s\S]*series=\{trafficView\}/);
|
||
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
||
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
||
assert.match(panel, /useState<'outbound' \| 'inbound'>\('outbound'\)[\s\S]*aria-pressed=\{trafficView === 'outbound'\}[\s\S]*>Выход<\/button>[\s\S]*aria-pressed=\{trafficView === 'inbound'\}[\s\S]*>Вход<\/button>/);
|
||
assert.match(chart, /function trafficPathAnimationSource[\s\S]*previousByTime[\s\S]*gatewayY: 100[\s\S]*attributeName="d"[\s\S]*dur="520ms"/);
|
||
assert.match(chart, /function smoothTrafficPath[\s\S]*const midX = \(previous\.x \+ point\.x\) \/ 2[\s\S]* C /);
|
||
assert.match(chart, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio: number\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/);
|
||
assert.match(chart, /proxy: byteString\(outbound\.directTrackedBytes\) \+ byteString\(outbound\.directIpv4Bytes\)/);
|
||
assert.match(chart, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)[\s\S]*unknownY: trafficChartY\(trafficScaleRatio\(unknown, max, scale\)\)/);
|
||
assert.match(chart, /client-device-traffic-grid[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\}[\s\S]*y1=\{\(100 \+ TRAFFIC_CHART_HEADROOM\) \/ 2\}/);
|
||
assert.match(chart, /client-device-traffic-cursor[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\} y2="100"/);
|
||
assert.doesNotMatch(panel, /key=\{latest\}/);
|
||
assert.match(chart, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
|
||
assert.match(chart, /onPointerMove=\{trackPointer\}/);
|
||
assert.match(chart, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
|
||
assert.match(chart, /series === 'outbound'[\s\S]*'is-vpn'[\s\S]*'is-direct-total'[\s\S]*'is-unknown'/);
|
||
assert.match(chart, /client-device-traffic-lines[\s\S]*visibleLines\.map[\s\S]*smoothTrafficPath\(previous, line\.yKey\)[\s\S]*client-device-traffic-cursor[\s\S]*visibleLines\.map/);
|
||
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
|
||
assert.match(chart, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/);
|
||
assert.match(chart, /Примерно[\s\S]*VPN[\s\S]*Direct[\s\S]*через sing-box[\s\S]*мимо sing-box · IPv4[\s\S]*Маршрут не определён · sing-box[\s\S]*Оценка за 15-секундный интервал/);
|
||
assert.match(chart, /hovered\.proxy - hovered\.directIpv4 > 0n && hovered\.directIpv4 > 0n && <>[\s\S]*через sing-box[\s\S]*мимо sing-box · IPv4/);
|
||
assert.match(chart, /client-device-traffic-legend[\s\S]*valueKey === 'gateway'[\s\S]*>VPN<\/span>[\s\S]*valueKey === 'proxy'[\s\S]*>Direct<\/span>[\s\S]*valueKey === 'unknown'[\s\S]*>Другое<\/span>/);
|
||
assert.doesNotMatch(chart, /Direct ≈|>\?<\/span>/);
|
||
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)[\s\S]*positiveByteDelta\(previous\.vpn, vpn\)[\s\S]*positiveByteDelta\(previous\.direct, direct\)[\s\S]*positiveByteDelta\(previous\.unknown, unknown\)/);
|
||
assert.match(panel, /trafficView === 'outbound' \? trafficDelta\.outboundTotal : trafficDelta\.total/);
|
||
assert.match(panel, /<b>VPN<\/b><TrafficValue[^>]*delta=\{trafficDelta\.vpn\}[\s\S]*<b>Direct<\/b><TrafficValue[^>]*delta=\{trafficDelta\.direct\}[\s\S]*<b>Другое<\/b><TrafficValue[^>]*delta=\{trafficDelta\.unknown\}/);
|
||
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(feature, /setDevicePolicy\(device\.id, mode, snapshot\.revision\)/);
|
||
assert.match(feature, /requestError\(caught\)\.code !== 'STATE_CONFLICT'[\s\S]*latestDevice\.desiredPolicy !== device\.desiredPolicy[\s\S]*setDevicePolicy\(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, /<Drawer[\s\S]*className="client-instructions client-devices"/);
|
||
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
||
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
||
assert.match(panel, /const \[pinCollapses, setPinCollapses\][\s\S]*animationDone[\s\S]*result: saved \? 'saved' : 'failed'/);
|
||
assert.match(panel, /const compact = deprioritized && !collapsing[\s\S]*const expanded = device\.pinned && !collapsing[\s\S]*const showDetails = !compact && \(device\.pinned \|\| collapsing\)[\s\S]*pinned=\{showDetails\}[\s\S]*onCollapseEnd=/);
|
||
assert.match(panel, /Закреплённые[\s\S]*Фоновые[\s\S]*Остальные/);
|
||
assert.match(panel, /client-device-group-heading[\s\S]*role=\{groupStart \? 'heading' : undefined\}[\s\S]*aria-level=\{groupStart \? 3 : undefined\}/);
|
||
assert.match(panel, /toggleDeprioritized[\s\S]*deprioritized: !deprioritized[\s\S]*deprioritized: true/);
|
||
assert.match(panel, /client-device-deprioritize[\s\S]*aria-pressed=\{deprioritized\}[\s\S]*Вернуть \$\{title\} в основной список[\s\S]*Убрать \$\{title\} в фон/);
|
||
assert.match(panel, /\{!compact && <TrafficChart/);
|
||
assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/);
|
||
assert.match(panel, /maxLength=\{64\}[\s\S]*autoFocus/);
|
||
assert.match(styles, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
|
||
assert.match(styles, /\.client-drawer\.client-devices \{[\s\S]*width: min\(780px, 100vw\)/);
|
||
assert.match(styles, /\.client-devices-layout \{[\s\S]*grid-template-columns: 168px minmax\(0, 1fr\);[\s\S]*gap: 28px/);
|
||
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-deprioritized \{[\s\S]*grid-template-rows: 34px;[\s\S]*padding-block: 6px/);
|
||
assert.match(styles, /\.client-device\.is-deprioritized \.client-device-main \{[\s\S]*grid-column: 1 \/ 4/);
|
||
assert.match(styles, /\.client-device-deprioritize-wrap \{[\s\S]*grid-column: 4;[\s\S]*grid-row: 2/);
|
||
assert.match(styles, /\.client-device\.is-deprioritized \.client-device-deprioritize-wrap \{[\s\S]*grid-row: 1/);
|
||
assert.match(styles, /\.client-device-group-heading \{[\s\S]*text-transform: var\(--type-label-transform\)/);
|
||
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-expand 520ms/);
|
||
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-plot \{[\s\S]*client-device-traffic-plot-collapse 520ms/);
|
||
assert.match(styles, /\.client-device\.is-collapsing \.client-device-traffic-axis \{[\s\S]*top: 0;[\s\S]*height: 58px/);
|
||
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.match(styles, /\.client-device-traffic-breakdown \{[^}]*background: color-mix\(in oklch, var\(--client-bg\) 84%, transparent\);[^}]*backdrop-filter: blur\(8px\)/);
|
||
assert.doesNotMatch(styles, /\.client-device-traffic-breakdown \{[^}]*box-shadow:/);
|
||
assert.match(styles, /\.client-device-traffic-chart \{[\s\S]*grid-column: 1 \/ 4;[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\);[\s\S]*column-gap: 8px/);
|
||
assert.match(styles, /\.client-device-traffic-plot \{[\s\S]*grid-column: 2;/);
|
||
assert.match(styles, /\.client-device-traffic-time \{[\s\S]*grid-column: 2;/);
|
||
assert.match(styles, /\.client-device-traffic-grid line \{[\s\S]*vector-effect: non-scaling-stroke/);
|
||
assert.match(styles, /\.client-device-traffic-lines path,[\s\S]*stroke-width: 1\.8/);
|
||
assert.match(styles, /\.client-device-traffic-cursor \.is-point \{[\s\S]*stroke-width: 4;[\s\S]*stroke-linecap: round;[\s\S]*vector-effect: non-scaling-stroke/);
|
||
assert.match(styles, /\.client-device-traffic-axis > \.is-max \{[\s\S]*top: var\(--traffic-chart-top\)/);
|
||
assert.match(styles, /\.client-device-traffic-axis > \.is-mid \{[\s\S]*top: var\(--traffic-chart-mid\)/);
|
||
assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/);
|
||
assert.doesNotMatch(styles, /client-device-traffic-shift|client-device-traffic-line-draw/);
|
||
assert.match(styles, /\.client-device-traffic-lines \.is-upload \{[\s\S]*stroke-dasharray: 5 4/);
|
||
assert.match(styles, /\.client-device-traffic-lines \.is-vpn[\s\S]*\.is-direct-total[\s\S]*\.is-unknown/);
|
||
assert.match(styles, /\.client-device-traffic-breakdown \.is-vpn[\s\S]*\.is-direct-total[\s\S]*\.is-unknown/);
|
||
assert.match(styles, /@keyframes client-device-traffic-plot-expand[\s\S]*scaleY\(0\.3448275862\)[\s\S]*scaleY\(1\)/);
|
||
assert.match(styles, /@keyframes client-device-traffic-plot-collapse[\s\S]*scaleY\(2\.9\)[\s\S]*scaleY\(1\)/);
|
||
assert.match(styles, /@keyframes client-device-traffic-detail-in[\s\S]*opacity: 0[\s\S]*opacity: 1/);
|
||
assert.match(styles, /@keyframes client-device-traffic-detail-out[\s\S]*opacity: 1[\s\S]*opacity: 0/);
|
||
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: var\(--type-item-title\)/);
|
||
assert.match(styles, /\.client-device-alias-trigger\.is-custom-name \{[\s\S]*font: var\(--type-section-title\)/);
|
||
assert.match(styles, /\.client-device-new-badge \{[\s\S]*height: 14px;[\s\S]*flex: 0 0 auto;[\s\S]*padding: 0 4px;[\s\S]*border: 1px solid color-mix\(in oklch, var\(--client-accent\)[\s\S]*border-radius: 999px;[\s\S]*background: var\(--client-accent-soft\);[\s\S]*font: var\(--type-micro\)/);
|
||
assert.match(styles, /\.client-device-new-a11y \{[\s\S]*position: absolute;[\s\S]*width: 1px;[\s\S]*clip-path: inset\(50%\)/);
|
||
assert.doesNotMatch(styles, /client-device-new-badge[^{]*\{[^}]*(?:transition|animation):/);
|
||
assert.match(styles, /\.client-device-alias-input \{[\s\S]*width: var\(--alias-width\)[\s\S]*caret-color: var\(--client-accent\)[\s\S]*font: var\(--type-section-title\)[\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-identity-copy span \{[\s\S]*font: var\(--type-data\)/);
|
||
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font: var\(--type-micro\)/);
|
||
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font: var\(--type-control\)/);
|
||
assert.match(styles, /\.client-device-identity-details \{[\s\S]*top: 100%;[\s\S]*pointer-events: none/);
|
||
assert.match(styles, /\.client-device-name-heading:hover > \.client-device-identity-details,[\s\S]*pointer-events: auto/);
|
||
assert.match(styles, /\.client-device-identity-copy\.is-copied \{[\s\S]*client-device-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 > h4 \{[\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(panel, /client-devices-reset-wrap client-tooltip-anchor[\s\S]*aria-label="Обнулить трафик устройств"[\s\S]*<Tooltip>Обнулить трафик устройств<\/Tooltip>/);
|
||
assert.doesNotMatch(panel, /className="client-devices-reset"[\s\S]{0,320}<span>/);
|
||
assert.match(styles, /\.client-devices-kicker \{[^}]*flex-wrap: nowrap/);
|
||
assert.match(panel, /<div className="client-devices-kicker">[\s\S]*<header className="client-instructions-header client-devices-header">/);
|
||
assert.match(styles, /\.client-devices-kicker \{[^}]*position: sticky;[^}]*top: 0;[^}]*z-index: 30/);
|
||
assert.match(styles, /\.client-devices-kicker \.client-tooltip \{[^}]*top: calc\(100% \+ 6px\);[^}]*bottom: auto/);
|
||
assert.match(styles, /\.client-devices-reset-wrap \{[^}]*margin-left: auto/);
|
||
assert.match(styles, /\.client-devices-reset-wrap > \.client-tooltip \{[^}]*white-space: nowrap/);
|
||
assert.match(styles, /\.client-devices-reset-wrap\.client-tooltip-anchor:hover > \.client-tooltip,[\s\S]*transform: translate\(0, 0\)/);
|
||
assert.match(styles, /\.client-devices-reset \{[\s\S]*oklch\(0\.68 0\.15 28\)[\s\S]*\.client-devices-reset:hover:not\(:disabled\) svg[\s\S]*rotate\(-360deg\)/);
|
||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-identity-details[\s\S]*\.client-device-identity-copy[\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[\s\S]*transition: none;[\s\S]*animation: none/);
|
||
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 copy feedback stays keyed per device', () => {
|
||
assert.match(panel, /copyTimers = useRef\(new Map[\s\S]*copyTimers\.current\.get\(device\.id\)[\s\S]*copyTimers\.current\.set\(device\.id/);
|
||
assert.match(panel, /copyAttempts = useRef\(new Map[\s\S]*copyAttempts\.current\.set\(device\.id, attempt\)[\s\S]*await copyText\(value\)[\s\S]*copyAttempts\.current\.get\(device\.id\) !== attempt/);
|
||
assert.match(panel, /setCopyFeedback\(\(current\) => \(\{ \.\.\.current, \[device\.id\]: \{ \.\.\.feedback, field \} \}\)\)/);
|
||
assert.match(panel, /const feedback = copyFeedback\[device\.id\]/);
|
||
});
|
||
|
||
test('device tags keep filtering, assignment and management accessible at every width', () => {
|
||
assert.match(panel, /Найти устройство/);
|
||
assert.match(panel, /'all', label: 'Все'[\s\S]*'new', label: 'Новые'[\s\S]*'pinned', label: 'Закреплённые'[\s\S]*'background', label: 'Фоновые'[\s\S]*'untagged', label: 'Без тегов'/);
|
||
assert.match(panel, /if \(value === 'untagged'\) setSelectedTagIds\(\[\]\)/);
|
||
assert.match(panel, /if \(systemFilter === 'untagged'\) setSystemFilter\('all'\)/);
|
||
assert.match(panel, /deviceFilterCounts\(allDevices, tags\)/);
|
||
assert.match(panel, /По этим фильтрам устройств нет\.[\s\S]*Сбросить фильтры/);
|
||
assert.match(panel, /deviceTags = tags\.filter[\s\S]*client-device-tag-trigger[\s\S]*\+\{deviceTags\.length - 1\}[\s\S]*'\+ тег'/);
|
||
assert.match(panel, /role="dialog"[\s\S]*aria-modal="true"[\s\S]*Теги устройства \{tagPopover\.title\}/);
|
||
assert.match(panel, /contentRef\.current\.inert = true[\s\S]*layoutRef\.current\.inert = true/);
|
||
assert.match(panel, /FOCUSABLE[\s\S]*event\.key === 'Escape'[\s\S]*document\.activeElement === first/);
|
||
assert.match(panel, /catalogChanged[\s\S]*Список тегов изменился\. Откройте теги устройства снова\./);
|
||
assert.match(panel, /Теги доступны после обновления Gateway\./);
|
||
assert.match(panel, /Управление тегами[\s\S]*Переименовать тег[\s\S]*Удалить тег/);
|
||
assert.match(panel, /placeholder="Найти устройство"[\s\S]*Результатов: \{devices\.length\}/);
|
||
assert.match(panel, />Назад<\/button>[\s\S]*Не удалось сохранить теги\./);
|
||
assert.match(panel, /restoreTagTriggerFocus[\s\S]*searchRef\.current,[\s\S]*filterButtonRef\.current,[\s\S]*candidate\?\.isConnected && !candidate\.closest\('\[inert\]'\)/);
|
||
assert.match(panel, /updateDeviceTags\(device, tagPopover\.draft, tagPopover\.baseline\)/);
|
||
assert.match(feature, /updateDeviceTags\(device: Device, tagIds: string\[\], baselineTagIds: string\[\]\)[\s\S]*sameStringList\(currentDevice\.tagIds, baselineTagIds\)[\s\S]*sameStringList\(latestDevice\.tagIds, baselineTagIds\)/);
|
||
assert.match(feature, /renameTag\(tag: DeviceTag, name: string, baselineName: string\)[\s\S]*snapshot\.tags\.find[\s\S]*name !== baselineName[\s\S]*latestTag\.name !== baselineName/);
|
||
assert.match(panel, /Тег больше не будет доступен для назначения\. Устройства и маршруты не изменятся\./);
|
||
assert.match(panel, /Тег исчезнет у одного устройства\. Само устройство и его маршрут не изменятся\./);
|
||
assert.match(panel, /Сами устройства и их маршруты не изменятся\./);
|
||
assert.match(feature, /deleteDeviceTag\(tag\.id, snapshot\.revision\)[\s\S]*STATE_CONFLICT[\s\S]*listDevices\(\)[\s\S]*return 'conflict'/);
|
||
assert.doesNotMatch(feature, /deleteDeviceTag\(tag\.id, latest\.revision\)/);
|
||
assert.match(styles, /@media \(max-width: 640px\)[\s\S]*width: min\(300px, calc\(100vw - 48px\)\)[\s\S]*translateX\(-100%\)[\s\S]*260ms cubic-bezier\(0\.16, 1, 0\.3, 1\)/);
|
||
assert.match(panel, /activeAnimation\.commitStyles\(\)[\s\S]*activeAnimation\.cancel\(\)[\s\S]*opacity: 0, transform: 'translateX\(-6px\)'[\s\S]*duration: 160[\s\S]*opacity: 0, transform: 'translateX\(6px\)'[\s\S]*opacity: 1, transform: 'translateX\(0\)'/);
|
||
assert.match(styles, /client-device-tag-popover-in 160ms ease[\s\S]*translateY\(-6px\)[\s\S]*client-device-tag-popover-out 160ms ease forwards/);
|
||
assert.match(styles, /client-devices-rail-backdrop \{[\s\S]*opacity: 0[\s\S]*opacity 260ms cubic-bezier\(0\.16, 1, 0\.3, 1\)[\s\S]*client-devices-rail-backdrop\.is-open[\s\S]*opacity: 1/);
|
||
assert.match(styles, /client-device-tag-trigger \{[\s\S]*height: 14px[\s\S]*flex: 0 0 auto/);
|
||
assert.match(styles, /@media \(hover: hover\)[\s\S]*client-device-tag-trigger:not\(\.has-tag\)[\s\S]*opacity: 0/);
|
||
assert.match(styles, /@media \(hover: none\)[\s\S]*client-device-tag-trigger:not\(\.has-tag\)[\s\S]*opacity: 1/);
|
||
assert.match(styles, /prefers-reduced-motion: reduce[\s\S]*client-devices-rail[\s\S]*client-devices-rail-view[\s\S]*client-device-tag-popover/);
|
||
});
|
||
|
||
test('Gateway Home reuses the canonical device snapshot for applied route and global traffic', () => {
|
||
const powerStart = connection.indexOf('<section className={`client-power-section');
|
||
const trafficStart = overview.indexOf('<GatewayTrafficSummary');
|
||
const connectionPanelStart = overview.indexOf('<ConnectionPanel');
|
||
|
||
assert.match(overview, /const appliedServerId = state\?\.selection\?\.appliedServerId \|\| ''/);
|
||
assert.match(overview, /const appliedServer = appliedProfile\?\.servers\.find[\s\S]*state\.selection\.appliedServerSnapshot/);
|
||
assert.match(overview, /const mainIdentity = gatewayDirect[\s\S]*: connected[\s\S]*appliedProfile && appliedServer[\s\S]*`\$\{subscriptionDomain\(appliedProfile\.subscription\.host\)\} · \$\{appliedServer\.label\}`/);
|
||
assert.match(overview, /const switchIdentity = gatewayDirect[\s\S]*switchingServer && operationProfile && operationServer[\s\S]*`Переключаем на \$\{subscriptionDomain\(operationProfile\.subscription\.host\)\} · \$\{operationServer\.label\}`/);
|
||
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity \|\| failoverIdentity\} \/>\}/);
|
||
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
|
||
assert.match(feature, /const history = globalTraffic\?\.history \|\| \[\][\s\S]*samples=\{history\}[\s\S]*routeLabel="Gateway"[\s\S]*series="speed"/);
|
||
assert.match(connection, /<section className=\{`client-power-section[\s\S]*client-state-detail[\s\S]*client-connection-title[\s\S]*\{serverSlot\}[\s\S]*client-proxies/);
|
||
assert.ok(powerStart >= 0 && connectionPanelStart >= 0 && trafficStart > connectionPanelStart, 'traffic summary follows the connection panel');
|
||
assert.equal((feature.match(/className="client-gateway-summary"/g) || []).length, 1);
|
||
assert.doesNotMatch(feature, /<TrafficChart[\s\S]{0,240}scale=/);
|
||
assert.match(feature, /feature\.status === 'error' \? feature\.error : null/);
|
||
assert.match(overview, /if \(!isGateway && \(!connected \|\| !state\?\.connection\?\.startedAt\)\) return undefined/);
|
||
assert.match(feature, /trafficSourceError[\s\S]*Трафик не обновляется · последние данные/);
|
||
assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"[\s\S]*open=\{drawerOpen\}/);
|
||
assert.match(subscription, /const deleteIdRef = useRef\(deleteId\)[\s\S]*if \(deleteIdRef\.current\) return/);
|
||
assert.match(subscription, /requestDelete: \(profileId: string\) => setDeleteId\(profileId\)[\s\S]*open=\{Boolean\(feature\.deleteProfile\)\}[\s\S]*onCancel=\{feature\.cancelDelete\}[\s\S]*onConfirm=\{feature\.confirmDelete\}/);
|
||
assert.match(connection, /aria-label=\{remoteOwned[\s\S]*isGateway[\s\S]*Остановить Harbor Connect[\s\S]*Запустить Harbor Connect/);
|
||
assert.match(connection, /className=\{`client-power-control\$\{powerUnavailable \? ' client-tooltip-anchor' : ''\}`\}[\s\S]*aria-describedby=\{powerUnavailable \? 'gateway-power-unavailable'[\s\S]*Подключением управляет Harbor Gateway[\s\S]*Сначала добавьте подписку и выберите сервер/);
|
||
assert.match(connection, /const powerButton = <button[\s\S]*className="client-power"[\s\S]*client-power-control[\s\S]*\{brandSlot\}[\s\S]*\{powerButton\}/);
|
||
assert.match(overview, /isGateway && hasSubscription && <DevicesPanel[\s\S]*feature=\{devicesFeature\}/);
|
||
assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
|
||
});
|
||
|
||
test('traffic chart scale compares every visible route before choosing the maximum', () => {
|
||
assert.match(chart, /function trafficSeriesMax[\s\S]*Object\.values\(value\)\.reduce[\s\S]*item > current \? item : current/);
|
||
});
|
||
|
||
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('new-device marker uses an exact seven-day window', () => {
|
||
const now = Date.parse('2026-08-31T12:00:00.000Z');
|
||
assert.equal(isNewDevice(new Date(now - (7 * 24 * 60 * 60 * 1000 - 1_000)).toISOString(), now), true);
|
||
assert.equal(isNewDevice(new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(), now), false);
|
||
assert.equal(isNewDevice(new Date(now + 1_000).toISOString(), now), false);
|
||
assert.equal(isNewDevice('not-a-date', now), false);
|
||
});
|
||
|
||
test('device filters combine system and search with tag OR while counts stay absolute', () => {
|
||
const now = Date.parse('2026-08-31T12:00:00.000Z');
|
||
const tags = [
|
||
{ id: 'tag_0000000000000001', name: 'Умный дом' },
|
||
{ id: 'tag_0000000000000002', name: 'Работа' },
|
||
];
|
||
const devices = Array.from({ length: 100 }, (_, index) => ({
|
||
id: `device-${index}`,
|
||
alias: index === 3 ? 'Телевизор' : `Клиент ${index}`,
|
||
hostname: index === 4 ? 'office-mac.local' : null,
|
||
ip: `192.168.50.${index + 1}`,
|
||
mac: `00:11:22:33:44:${index.toString(16).padStart(2, '0')}`,
|
||
firstSeenAt: new Date(now - (index < 7 ? index : 10) * 86_400_000).toISOString(),
|
||
pinned: index === 3,
|
||
deprioritized: index === 5,
|
||
tagIds: index === 3 ? [tags[0].id] : index === 4 ? [tags[1].id] : [],
|
||
}));
|
||
|
||
assert.deepEqual(filterDevices(devices, tags, {
|
||
system: 'all', tagIds: tags.map(({ id }) => id), query: 'local',
|
||
}, now).map(({ id }) => id), ['device-4']);
|
||
assert.deepEqual(filterDevices(devices, tags, {
|
||
system: 'pinned', tagIds: [tags[0].id, tags[1].id], query: 'умный',
|
||
}, now).map(({ id }) => id), ['device-3']);
|
||
assert.equal(filterDevices(devices, tags, {
|
||
system: 'untagged', tagIds: [], query: '192.168.50.',
|
||
}, now).length, 98);
|
||
|
||
assert.deepEqual(deviceFilterCounts(devices, tags, now), {
|
||
all: 100,
|
||
new: 7,
|
||
pinned: 1,
|
||
background: 1,
|
||
untagged: 98,
|
||
byTag: { [tags[0].id]: 1, [tags[1].id]: 1 },
|
||
});
|
||
|
||
const canonical = stabilizeDevicesByTraffic(devices, 'desc');
|
||
const visible = filterDevices(devices, tags, {
|
||
system: 'all', tagIds: [], query: '192.168.50.1',
|
||
}, now);
|
||
const filteredOrder = stabilizeDevicesByTraffic(visible, 'desc', canonical.ids).devices;
|
||
assert.deepEqual(filteredOrder.map(({ id }) => id), canonical.devices
|
||
.filter(({ id }) => visible.some((device) => device.id === id))
|
||
.map(({ id }) => id));
|
||
assert.deepEqual(stabilizeDevicesByTraffic(devices, 'desc', canonical.ids).ids, canonical.ids);
|
||
});
|
||
|
||
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);
|
||
assert.equal(trafficBytesPerSecond('15000', '2026-08-07T13:00:00.000Z', '2026-08-07T13:00:15.000Z'), 1000n);
|
||
assert.equal(trafficBytesPerSecond('15000', 'invalid', '2026-08-07T13:00:15.000Z'), 0n);
|
||
|
||
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' },
|
||
{ id: 'f', deprioritized: true, uploadBytes: '999999', downloadBytes: '0' },
|
||
];
|
||
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['e', 'b', 'a', 'c', 'd', 'f']);
|
||
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['e', 'c', 'd', 'a', 'b', 'f']);
|
||
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'],
|
||
);
|
||
assert.deepEqual(
|
||
stabilizeDevicesByTraffic([
|
||
{ id: 'a', deprioritized: true, uploadBytes: '999' },
|
||
{ id: 'b', uploadBytes: '1' },
|
||
{ id: 'c', pinned: true, uploadBytes: '0' },
|
||
], 'desc', ['a', 'b', 'c']).ids,
|
||
['c', 'b', 'a'],
|
||
);
|
||
});
|