feat: support multiple camera previews and concurrent agent code navigation

This commit is contained in:
2026-10-20 10:10:48 +09:00
parent c17763f59d
commit af54e875df
13 changed files with 525 additions and 160 deletions
+101 -68
View File
@@ -1,70 +1,103 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
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 html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'"));
let now = 10000;
let requests = 0;
let ok = true;
let visible = false;
let afterBlob = () => {};
const context = vm.createContext({
ENABLE_CAMERA_PREVIEW: true,
cameraFrameRequestInFlight: false,
cameraFrameRetryAt: 0,
cameraFrameObjectUrl: null,
cameraPreview: {},
latestSlamState: null,
latestSlamStateReceivedAt: now,
document: { hidden: false },
Date: { now: () => now },
BACKEND_URL: 'http://localhost:8765',
AbortSignal,
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} },
setCameraPreviewAvailable(value) { visible = value; },
async fetch() {
requests++;
return { ok, async blob() { afterBlob(); return {}; } };
},
});
vm.runInContext(source, context);
const refresh = () => context.refreshCameraFrame();
const available = (value) => {
context.latestSlamState = { topic_status: { camera_image: { available: value } } };
context.latestSlamStateReceivedAt = now;
};
function harness() {
const exports = {}, requests = [], revoked = [], pending = new Map()
let now = 10000, state = null, receivedAt = now, visible = false, enabledFetch = true
let sequence = 0
function element() {
return { children: [], dataset: {}, src: '', append(...children) { this.children.push(...children); children.forEach(child => { child.parent = this }) }, remove() { this.parent.children = this.parent.children.filter(child => child !== this) }, removeAttribute() { this.src = '' }, addEventListener() {} }
}
const root = element(), document = { hidden: false, createElement: element, addEventListener() {}, removeEventListener() {} }
const globals = { exports, document, AbortController, Date: { now: () => now },
window: { setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {} },
URL: { createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: url => revoked.push(url) },
fetch: async (url, options) => {
requests.push({ url, signal: options.signal })
if (pending.has(url)) await pending.get(url)
return { ok: enabledFetch, blob: async () => ({}) }
}
}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/public/slam-viewer/camera-preview.js'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, globals)
const controller = exports.createCameraPreview({ root, baseUrl: 'http://camera.test', getState: () => ({ state, receivedAt }), onAvailable: value => { visible = value } })
return { root, requests, revoked, pending, document, controller,
state(value) { state = value; receivedAt = now }, advance(ms) { now += ms }, fail(value) { enabledFetch = !value }, visible: () => visible,
settle: () => new Promise(resolve => setImmediate(resolve)) }
}
(async () => {
await refresh();
available(false);
await refresh();
assert.equal(requests, 0, 'Absent camera must not fetch frames');
available(true);
await refresh();
assert.equal(requests, 1);
assert.equal(context.cameraPreview.src, 'blob:frame');
now += 3001;
await refresh();
assert.equal(requests, 1, 'Stale websocket state must stop polling');
assert.equal(visible, false);
available(true);
ok = false;
await refresh();
await refresh();
assert.equal(requests, 2, 'HTTP errors must back off');
now += 5000;
available(true);
ok = true;
afterBlob = () => available(false);
context.cameraPreview.src = '';
await refresh();
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera');
assert.equal(context.cameraFrameRequestInFlight, false);
afterBlob = () => {};
available(true);
await refresh();
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames');
console.log('camera preview polling tests passed');
})().catch((error) => { console.error(error); process.exitCode = 1; });
test('legacy camera polling stays compatible and skips absent, hidden and stale cameras', async () => {
const h = harness()
h.controller.setEnabled(true)
assert.equal(h.requests.length, 0)
h.state({ topic_status: { camera_image: { available: true } } })
h.controller.refresh(); await h.settle()
assert.equal(h.requests[0].url, 'http://camera.test/camera-frame')
assert.equal(h.root.children[0].children[1].src, 'blob:1')
h.document.hidden = true
h.controller.refresh(); await h.settle()
assert.equal(h.requests.length, 1)
h.advance(3001)
h.controller.refresh()
assert.equal(h.visible(), false)
assert.equal(h.root.children.length, 0)
assert.deepEqual(h.revoked, ['blob:1'])
})
test('multiple camera frames are isolated and one unavailable camera cannot hide another', async () => {
const h = harness()
h.state({ cameras: [{ id: 'video0', name: '/dev/video0', available: true }, { id: 'video2', name: '/dev/video2', available: true }] })
h.controller.setEnabled(true); await h.settle()
assert.equal(h.root.children.length, 2)
assert.equal(h.requests.length, 2)
assert.deepEqual(h.requests.map(request => request.url), ['http://camera.test/camera-frame?camera_id=video0', 'http://camera.test/camera-frame?camera_id=video2'])
const first = h.root.children[0].children[1].src
h.state({ cameras: [{ id: 'video2', name: '/dev/video2', available: true }] })
h.controller.refresh(); await h.settle()
assert.equal(h.root.children.length, 1)
assert.equal(h.root.children[0].dataset.cameraId, 'video2')
assert.ok(h.revoked.includes(first))
assert.equal(h.visible(), true)
h.controller.dispose()
assert.equal(h.root.children.length, 0)
assert.equal(h.visible(), false)
})
test('in-flight requests are bounded and late frames cannot resurrect a removed or disabled camera', async () => {
const h = harness()
let release
h.pending.set('http://camera.test/camera-frame?camera_id=video0', new Promise(resolve => { release = resolve }))
h.state({ cameras: [{ id: 'video0', available: true }] })
h.controller.setEnabled(true)
h.controller.refresh()
assert.equal(h.requests.length, 1)
h.controller.setEnabled(false)
assert.equal(h.requests[0].signal.aborted, true)
release(); await h.settle()
assert.equal(h.root.children.length, 0)
assert.equal(h.visible(), false)
})
test('HTTP failures back off per camera and reconnecting restores frames', async () => {
const h = harness()
h.state({ cameras: [{ id: 'video0', available: true }] })
h.fail(true)
h.controller.setEnabled(true); await h.settle()
h.controller.refresh(); await h.settle()
assert.equal(h.requests.length, 1)
h.advance(5000); h.state({ cameras: [{ id: 'video0', available: true }] }); h.fail(false)
h.controller.refresh(); await h.settle()
assert.equal(h.requests.length, 2)
assert.ok(h.root.children[0].children[1].src.startsWith('blob:'))
})
test('camera identifiers are validated, deduplicated and limited without executing names as markup', async () => {
const h = harness()
h.state({ cameras: [{ id: '../etc', available: true }, ...Array.from({ length: 12 }, (_, i) => ({ id: `video${i}`, available: true, name: '<script>unsafe</script>' })), { id: 'video0', available: true }] })
h.controller.setEnabled(true); await h.settle()
assert.equal(h.requests.length, 8)
assert.equal(h.root.children.length, 8)
assert.equal(h.root.children[0].children[0].textContent, '<script>unsafe</script>')
})