feat: support multiple camera previews and concurrent agent code navigation
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
const {test} = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const {createHash} = require('node:crypto')
|
||||
const {spawnSync} = require('node:child_process')
|
||||
|
||||
test('remote visualization stages the bundled bridge over stdin and mounts that revision read-only', async () => {
|
||||
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
const fn = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'startRemoteVisualizationBridge')
|
||||
assert.ok(fn)
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-camera-bridge-'))
|
||||
const resource = path.resolve(__dirname, '../resources/slam-ros-bridge')
|
||||
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"
|
||||
let launch, transfers = 0
|
||||
const context = {
|
||||
normalizeVisualizationStack: () => 'slam3d', normalizeRemoteTarget: target => target,
|
||||
prepareRemoteRosNetworkConfig: async () => null, visualizationBridgeContainerName: () => 'slam-ros-bridge',
|
||||
resolveVisualizationBridgeImage: () => 'fixture:arm64', bridgeArtifact: () => null,
|
||||
slamRosBridgeImage: 'slam:local',
|
||||
bridgeContainerCommand: () => 'python3 /app/bridge.py', resolveResourceContextPath: () => resource,
|
||||
readFile: fs.readFile, join: path.join, createHash, quoteShellSingle: quote,
|
||||
expandRemotePathVar: () => [], reportVisualizationProgress() {},
|
||||
runUnqueuedSshOutput: async (_target, command, timeout, input) => {
|
||||
transfers++
|
||||
assert.equal(timeout, 30000)
|
||||
assert.ok(input.length > 1000)
|
||||
const {spawn} = require('node:child_process')
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn('bash', ['-c', command], {env: {...process.env, HOME: dir}, stdio:['pipe','ignore','pipe']})
|
||||
let error = ''
|
||||
child.stderr.on('data', data => {error += data})
|
||||
child.on('error', reject)
|
||||
child.on('close', code => code === 0 ? resolve() : reject(Error(error)))
|
||||
child.stdin.end(input)
|
||||
})
|
||||
return ''
|
||||
},
|
||||
runSsh: async (_target, command) => { launch = command }
|
||||
}
|
||||
vm.runInNewContext(ts.transpileModule(fn.getText(source), {compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText, context)
|
||||
try {
|
||||
await context.startRemoteVisualizationBridge({workspaceRoot:'/workspace'}, 'slam3d', '77')
|
||||
assert.equal(transfers, 1)
|
||||
assert.match(launch, /-v "\$HOME\/\.autonomy-studio\/runtime\/bridges\/[a-f0-9]{64}:\/app:ro"/)
|
||||
const roots = await fs.readdir(path.join(dir, '.autonomy-studio/runtime/bridges'))
|
||||
for (const name of ['bridge.py','requirements.txt']) assert.equal(await fs.readFile(path.join(dir,'.autonomy-studio/runtime/bridges',roots[0],name),'utf8'), await fs.readFile(path.join(resource,name),'utf8'))
|
||||
const syntax = spawnSync('bash', ['-n'], {input:launch,encoding:'utf8'})
|
||||
assert.equal(syntax.status, 0, syntax.stderr)
|
||||
assert.ok(launch.includes('docker run'))
|
||||
} finally {await fs.rm(dir,{recursive:true,force:true})}
|
||||
})
|
||||
@@ -35,7 +35,10 @@ for (const script of [run, build]) {
|
||||
}
|
||||
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
|
||||
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
|
||||
assert.match(run, /--device "\$CAMERA_DEVICE:\$CAMERA_DEVICE"/);
|
||||
assert.match(run, /--device "\$camera_device:\$camera_device"/);
|
||||
assert.match(run, /CAMERA_DEVICE" = \/dev/);
|
||||
assert.match(run, /mapfile -t CAMERA_DEVICES/);
|
||||
assert.doesNotMatch(run, /-v \/dev:\/dev/);
|
||||
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
|
||||
assert.match(run, /--device "\$capture_device:\$capture_device"/);
|
||||
assert.match(run, /FASTDDS_DEFAULT_PROFILES_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>')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user