feat: display remote platform round-trip latency
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
function load(file, overrides = {}, globals = {}) {
|
||||
const exports = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, { exports, process, require: name => overrides[name] ?? require(name), ...globals })
|
||||
return exports
|
||||
}
|
||||
const shared = load('../src/shared/remoteLatency.ts')
|
||||
const main = load('../src/main/remoteLatency.ts')
|
||||
const { createRemoteLatencyMonitor } = load('../src/renderer/src/features/platforms/remoteLatencyMonitor.ts', { '../../../../shared/remoteLatency': shared })
|
||||
const i18n = { t: key => key, useLocaleRender() {} }
|
||||
const { default: RemoteLatencyLabel } = load('../src/renderer/src/components/RemoteLatencyLabel.tsx', { '../i18n': i18n, '../../../shared/remoteLatency': shared })
|
||||
const plain = value => JSON.parse(JSON.stringify(value))
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done }); return { promise, resolve } }
|
||||
|
||||
test('ping targets cannot inject commands or options and accept real host formats', () => {
|
||||
for (const host of ['127.0.0.1', '100.64.0.10', 'mk-mini.example.com', 'jetson', '::1', '[2001:db8::1]']) assert.ok(main.remoteLatencyHost(host))
|
||||
for (const host of [null, {}, '', '-f', '--help', 'host; echo BAD', 'host\nother', 'ssh://host:22', 'a'.repeat(254), 'user@host', 'a..b', '/tmp/file']) {
|
||||
assert.throws(() => main.remoteLatencyHost(host), /Invalid/)
|
||||
}
|
||||
assert.equal(main.remoteLatencyHost('[::1]'), '::1')
|
||||
assert.deepEqual(plain(main.remoteLatencyPingArgs('host', 'linux')), ['-n', '-c', '1', '-W', '2', '--', 'host'])
|
||||
assert.deepEqual(plain(main.remoteLatencyPingArgs('host', 'win32')), ['-n', '1', '-w', '2000', 'host'])
|
||||
assert.deepEqual(plain(main.remoteLatencyPingArgs('host', 'darwin')), ['-n', '-c', '1', '-W', '2000', '--', 'host'])
|
||||
})
|
||||
|
||||
test('parses actual echo time, not process duration or packet loss', () => {
|
||||
for (const [output, expected] of [
|
||||
['64 bytes from device: icmp_seq=1 ttl=64 time=42.5 ms', 42.5],
|
||||
['Reply from host: bytes=32 time<1ms TTL=128', 0.5],
|
||||
['응답: 바이트=32 시간=12ms TTL=128', 12],
|
||||
['Reply: time=42,5 ms', 42.5],
|
||||
['100% packet loss, time 2000ms', null], ['Request timed out.', null]
|
||||
]) assert.equal(main.parseRemoteLatency(output), expected)
|
||||
})
|
||||
|
||||
test('runs one bounded ping without a shell and returns null on missing binary or timeout', async () => {
|
||||
for (const error of [null, Error('ENOENT'), Error('timeout')]) {
|
||||
const mock = load('../src/main/remoteLatency.ts', { 'node:child_process': { execFile: (binary, args, options, callback) => {
|
||||
assert.equal(binary, 'ping')
|
||||
assert.equal(options.shell, undefined)
|
||||
assert.equal(options.timeout, 2500)
|
||||
assert.equal(options.maxBuffer, 8192)
|
||||
assert.equal(options.windowsHide, true)
|
||||
assert.equal(options.env.LC_ALL, 'C')
|
||||
assert.equal(args.at(-1), '127.0.0.1')
|
||||
callback(error, 'time=42 ms')
|
||||
} } })
|
||||
assert.equal(await mock.measureRemoteLatency('127.0.0.1'), error ? null : 42)
|
||||
}
|
||||
})
|
||||
|
||||
function harness(read) {
|
||||
const timers = new Map()
|
||||
let id = 0
|
||||
const monitor = createRemoteLatencyMonitor(read, {
|
||||
setInterval: (callback, interval) => { assert.equal(interval, 5000); timers.set(++id, callback); return id },
|
||||
clearInterval: id => timers.delete(id)
|
||||
})
|
||||
return { monitor, timers, sample: async () => { [...timers.values()].forEach(callback => callback()); await tick() } }
|
||||
}
|
||||
|
||||
test('shares each host probe across displays and uses only the last three samples', async () => {
|
||||
const values = [40, 1800, 42, 50]
|
||||
let calls = 0
|
||||
const h = harness(async () => { calls++; return values.shift() })
|
||||
h.monitor.setHosts(['jetson', 'jetson'])
|
||||
assert.equal(h.monitor.getSnapshot().jetson.status, 'measuring')
|
||||
await tick()
|
||||
assert.equal(h.timers.size, 1)
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 40)
|
||||
await h.sample()
|
||||
await h.sample()
|
||||
assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 42)
|
||||
await h.sample()
|
||||
assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 50)
|
||||
h.monitor.stop()
|
||||
assert.equal(h.timers.size, 0)
|
||||
})
|
||||
|
||||
test('does not overlap slow probes and ignores completions after disconnect or host reuse', async () => {
|
||||
const old = deferred(), next = deferred()
|
||||
let calls = 0
|
||||
const h = harness(() => ++calls === 1 ? old.promise : next.promise)
|
||||
h.monitor.setHosts(['jetson'])
|
||||
await h.sample()
|
||||
assert.equal(calls, 1)
|
||||
h.monitor.setHosts([])
|
||||
h.monitor.setHosts(['jetson'])
|
||||
assert.equal(calls, 2)
|
||||
old.resolve(999)
|
||||
await tick()
|
||||
assert.equal(h.monitor.getSnapshot().jetson.status, 'measuring')
|
||||
next.resolve(42)
|
||||
await tick()
|
||||
assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 42)
|
||||
h.monitor.stop()
|
||||
assert.deepEqual(plain(h.monitor.getSnapshot()), {})
|
||||
})
|
||||
|
||||
test('clears stale success immediately after failure and starts a fresh sample window on recovery', async () => {
|
||||
const values = [40, 50, null, 350]
|
||||
const h = harness(async () => values.shift())
|
||||
h.monitor.setHosts(['jetson'])
|
||||
await tick()
|
||||
await h.sample()
|
||||
await h.sample()
|
||||
assert.deepEqual(plain(h.monitor.getSnapshot().jetson), { milliseconds: null, status: 'unavailable' })
|
||||
await h.sample()
|
||||
assert.equal(h.monitor.getSnapshot().jetson.milliseconds, 350)
|
||||
h.monitor.stop()
|
||||
})
|
||||
|
||||
test('adding another target preserves existing samples and removing it stops its probe', async () => {
|
||||
const h = harness(async host => host === 'a' ? 42 : 200)
|
||||
h.monitor.setHosts(['a'])
|
||||
await tick()
|
||||
const before = h.monitor.getSnapshot().a
|
||||
h.monitor.setHosts(['a', 'b'])
|
||||
assert.equal(h.monitor.getSnapshot().a, before)
|
||||
await tick()
|
||||
assert.equal(h.timers.size, 2)
|
||||
h.monitor.setHosts(['a'])
|
||||
assert.equal(h.timers.size, 1)
|
||||
assert.equal(h.monitor.getSnapshot().b, undefined)
|
||||
h.monitor.stop()
|
||||
})
|
||||
|
||||
test('shows only stable-width milliseconds with the requested colors in both themes', () => {
|
||||
for (const dark of [false, true]) {
|
||||
for (const [milliseconds, tone] of [[42, 'good'], [100, 'good'], [101, 'warning'], [250, 'warning'], [251, 'poor']]) {
|
||||
const latency = { milliseconds, status: 'ok' }
|
||||
assert.equal(shared.remoteLatencyTone(latency), tone)
|
||||
const html = renderToStaticMarkup(React.createElement(RemoteLatencyLabel, { latency, dark }))
|
||||
assert.ok(html.includes(`>${milliseconds} ms</span>`))
|
||||
assert.match(html, /w-\[48px\]/)
|
||||
assert.match(html, /tabular-nums/)
|
||||
assert.ok(html.includes(tone === 'good' ? dark ? '#4ade80' : '#15803d' : tone === 'warning' ? dark ? '#facc15' : '#a16207' : dark ? '#f87171' : '#dc2626'))
|
||||
}
|
||||
}
|
||||
assert.equal(renderToStaticMarkup(React.createElement(RemoteLatencyLabel)), '')
|
||||
assert.equal(shared.remoteLatencyText({ milliseconds: 0.02, status: 'ok' }), '1 ms')
|
||||
for (const status of ['measuring', 'unavailable']) {
|
||||
const latency = { milliseconds: null, status }
|
||||
assert.match(renderToStaticMarkup(React.createElement(RemoteLatencyLabel, { latency })), />-- ms<\/span>/)
|
||||
assert.equal(shared.remoteLatencyTone(latency), status === 'measuring' ? 'unknown' : 'poor')
|
||||
}
|
||||
})
|
||||
|
||||
test('sidebar and countdown header use the same label while local platforms have no measurement', () => {
|
||||
const overrides = { '../../i18n': i18n, '../i18n': i18n, '../../components/RemoteLatencyLabel': { default: RemoteLatencyLabel }, './RemoteLatencyLabel': { default: RemoteLatencyLabel }, '../../components/Tooltip': { default: ({ children }) => children } }
|
||||
const { default: TopBar } = load('../src/renderer/src/features/workspace/WorkspaceTopBar.tsx', overrides)
|
||||
const { default: Sidebar } = load('../src/renderer/src/components/Sidebar.tsx', { ...overrides, react: { ...React, useState: () => [false, () => {}], useRef: () => ({ current: null }), useEffect() {} } })
|
||||
const latency = { milliseconds: 42, status: 'ok' }
|
||||
const header = renderToStaticMarkup(React.createElement(TopBar, { platformName: 'MK-mini', remoteLatency: latency, showControlCountdown: true, formatControlRemainingTime: () => '27:13', topToggleButtonClass: () => '', dark: true }))
|
||||
const sidebar = renderToStaticMarkup(Sidebar({ collapsed: false, onToggle() {}, sessionStatus: 'running', runningSessions: [{ id: 'remote', title: 'MK-mini', latency }, { id: 'local', title: 'Local PC' }] }))
|
||||
assert.match(header, /27:13/)
|
||||
assert.equal((header.match(/>42 ms<\/span>/g) ?? []).length, 1)
|
||||
assert.equal((sidebar.match(/>42 ms<\/span>/g) ?? []).length, 1)
|
||||
const appSource = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
|
||||
assert.match(appSource, /platform\.platformKey !== 'futuredrive-local-pc'/)
|
||||
assert.match(appSource, /remoteLatency=\{getRemotePlatformLatency\(workspacePlatform\)\}/)
|
||||
assert.match(appSource, /latency: getRemotePlatformLatency\(platform\)/)
|
||||
})
|
||||
Reference in New Issue
Block a user