@esp-vision/device-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import assert from 'node:assert/strict'
8
+ import {
9
+ DEVICE_ACTION_DESCRIPTORS,
10
+ DEVICE_ACTION_SCHEMA_VERSION,
11
+ DeviceActionError,
12
+ DeviceActionGateway,
13
+ DeviceActionRegistry,
14
+ DeviceLease,
15
+ OperationCoordinator,
16
+ } from '../src/device_actions.js'
17
+ import {
18
+ DeviceEvidenceBuffer,
19
+ EPHEMERAL_PYTHON_PATH,
20
+ EphemeralPythonController,
21
+ bindSessionDeviceActions,
22
+ } from '../src/device_bindings.js'
23
+
24
+ function deferred() {
25
+ let resolve
26
+ const promise = new Promise((done) => { resolve = done })
27
+ return { promise, resolve }
28
+ }
29
+
30
+ async function testRegistryContract() {
31
+ const registry = new DeviceActionRegistry()
32
+ assert.equal(registry.schemaVersion, DEVICE_ACTION_SCHEMA_VERSION)
33
+ assert.equal(DEVICE_ACTION_SCHEMA_VERSION, 1)
34
+ assert.deepEqual(
35
+ registry.list().map(({ name }) => name),
36
+ DEVICE_ACTION_DESCRIPTORS.map(({ name }) => name),
37
+ )
38
+ const input = registry.validate('python.execute', { code: 'print(1)' })
39
+ assert.equal(input.timeoutMs, 8000)
40
+ assert.throws(
41
+ () => registry.validate('python.execute', { code: 'print(1)', surprise: true }),
42
+ (error) => error instanceof DeviceActionError && error.code === 'INVALID_INPUT',
43
+ )
44
+ assert.deepEqual(
45
+ registry.validate('deviceFile.remove', { path: '/agent-test.py' }),
46
+ { path: '/agent-test.py', kind: 'file' },
47
+ )
48
+ assert.deepEqual(
49
+ registry.validate('device.control', { operation: 'bootloader' }),
50
+ { operation: 'bootloader', confirmBootloader: false },
51
+ )
52
+ assert.deepEqual(
53
+ registry.validate('python.runEphemeral', { code: 'print(1)' }),
54
+ { code: 'print(1)', maxDurationMs: 180000 },
55
+ )
56
+ assert.match(registry.descriptor('device.rpc').description, /reuse the exact same ID and payload/)
57
+ }
58
+
59
+ async function testNetworkOperationIdReuse() {
60
+ const registry = new DeviceActionRegistry()
61
+ bindSessionDeviceActions(registry)
62
+ const calls = []
63
+ const session = {
64
+ async rpc(method, payload, options) {
65
+ calls.push({ method, payload, options })
66
+ return { ok: true }
67
+ },
68
+ }
69
+ const operationId = '44444444444444444444444444444444'
70
+ const payload = { action: 'network.configure', operationId, ssid: 'mock-network', password: 'mock-secret' }
71
+ const handler = registry.handler('device.rpc')
72
+ await handler({ method: 'device.control', payload, timeoutMs: 1000 }, { session, signal: null })
73
+ await handler({ method: 'device.control', payload, timeoutMs: 1000 }, { session, signal: null })
74
+ assert.deepEqual(calls.map(({ payload: request }) => request.operationId), [operationId, operationId])
75
+ assert.ok(calls.every(({ method, options }) => method === 'device.control' && options.timeout === 1000))
76
+ }
77
+
78
+ function fakeEphemeralSession({ cleanupFails = false } = {}) {
79
+ let current = null
80
+ return {
81
+ repl: {
82
+ async removeFile(path) {
83
+ assert.equal(path, EPHEMERAL_PYTHON_PATH)
84
+ if (cleanupFails) throw new Error('device disconnected before cleanup')
85
+ },
86
+ },
87
+ async stopForRepl() { return true },
88
+ scriptRunSnapshot() {
89
+ return current ? { path: EPHEMERAL_PYTHON_PATH, output: current.output } : null
90
+ },
91
+ runScript(path, code, { signal, onStarted }) {
92
+ assert.equal(path, EPHEMERAL_PYTHON_PATH)
93
+ current = { code, output: '' }
94
+ onStarted()
95
+ return new Promise((resolve, reject) => {
96
+ current.finish = (output = '') => {
97
+ current.output = output
98
+ resolve({ output })
99
+ }
100
+ current.fail = (message) => reject(new Error(message))
101
+ signal.addEventListener('abort', () => {
102
+ const error = new Error('stopped')
103
+ error.name = 'AbortError'
104
+ reject(error)
105
+ }, { once: true })
106
+ })
107
+ },
108
+ appendOutput(value) { current.output += value },
109
+ finish(value) { current.finish(value) },
110
+ fail(value) { current.fail(value) },
111
+ }
112
+ }
113
+
114
+ async function testEphemeralRunLifecycle() {
115
+ const evidence = new DeviceEvidenceBuffer(10)
116
+ const session = fakeEphemeralSession()
117
+ const controller = new EphemeralPythonController(session, { evidence, generation: 'generation-7' })
118
+ const started = await controller.start('print("ready")', { maxDurationMs: 5000 })
119
+ assert.match(started.runId, /^generation-7:/)
120
+ assert.equal(started.status, 'running')
121
+ await assert.rejects(controller.start('print("duplicate")'), /already active/)
122
+ session.appendOutput('ready\n')
123
+ const observed = await controller.observe(started.runId, { waitMs: 0 })
124
+ assert.equal(observed.output, 'ready\n')
125
+ assert.equal(observed.finished, false)
126
+ session.finish('ready\ndone\n')
127
+ const finished = await controller.observe(started.runId, { waitMs: 100 })
128
+ assert.equal(finished.status, 'finished')
129
+ assert.equal(finished.cleaned, true)
130
+ assert.equal(finished.output, 'ready\ndone\n')
131
+ await assert.rejects(controller.observe('generation-6:old', { waitMs: 0 }), /stale/)
132
+ assert.equal(evidence.snapshot().length, 2)
133
+ }
134
+
135
+ async function testEphemeralTimeoutAndDisconnect() {
136
+ const timedSession = fakeEphemeralSession()
137
+ const timedController = new EphemeralPythonController(timedSession, { generation: 'generation-9' })
138
+ const timed = await timedController.start('while True: pass')
139
+ timedSession.fail('script run timeout after 1000ms')
140
+ const timedResult = await timedController.observe(timed.runId, { waitMs: 100 })
141
+ assert.equal(timedResult.status, 'timed-out')
142
+ assert.equal(timedResult.cleaned, true)
143
+
144
+ const disconnectedSession = fakeEphemeralSession({ cleanupFails: true })
145
+ const disconnectedController = new EphemeralPythonController(disconnectedSession, { generation: 'generation-10' })
146
+ const disconnected = await disconnectedController.start('print("disconnect")')
147
+ disconnectedSession.fail('serial closed')
148
+ const disconnectedResult = await disconnectedController.observe(disconnected.runId, { waitMs: 100 })
149
+ assert.equal(disconnectedResult.status, 'failed')
150
+ assert.equal(disconnectedResult.cleaned, false)
151
+ assert.equal(disconnectedResult.residualPath, EPHEMERAL_PYTHON_PATH)
152
+ }
153
+
154
+ async function testEphemeralStopAndCleanupFailure() {
155
+ const session = fakeEphemeralSession({ cleanupFails: true })
156
+ const controller = new EphemeralPythonController(session, { generation: 'generation-8' })
157
+ const started = await controller.start('while True: pass')
158
+ const stopped = await controller.stop(started.runId)
159
+ assert.equal(stopped.status, 'stopped')
160
+ assert.equal(stopped.finished, true)
161
+ assert.equal(stopped.cleaned, false)
162
+ assert.equal(stopped.residualPath, EPHEMERAL_PYTHON_PATH)
163
+ assert.match(stopped.cleanupError, /disconnected/)
164
+ await assert.rejects(controller.start('print(2)', { signal: AbortSignal.abort() }), /cancelled|aborted/i)
165
+ }
166
+
167
+ async function testBootloaderAuthorization() {
168
+ const registry = new DeviceActionRegistry()
169
+ const lease = new DeviceLease()
170
+ const token = lease.acquire('agent')
171
+ const session = { state: 'ready' }
172
+ let calls = 0
173
+ registry.bind('device.control', async () => { calls += 1 })
174
+ const gateway = new DeviceActionGateway({
175
+ registry,
176
+ lease,
177
+ getSession: () => session,
178
+ authorize: async (descriptor, input) => (
179
+ descriptor.name !== 'device.control'
180
+ || input.operation !== 'bootloader'
181
+ || input.confirmBootloader === true
182
+ ),
183
+ })
184
+ await assert.rejects(
185
+ gateway.invoke('device.control', { operation: 'bootloader' }, {
186
+ actor: 'agent',
187
+ requestId: 'bootloader-denied',
188
+ }),
189
+ (error) => error.code === 'ACTION_NOT_AUTHORIZED',
190
+ )
191
+ await gateway.invoke('device.control', {
192
+ operation: 'bootloader',
193
+ confirmBootloader: true,
194
+ }, {
195
+ actor: 'agent',
196
+ requestId: 'bootloader-approved',
197
+ })
198
+ assert.equal(calls, 1)
199
+ token.release()
200
+ }
201
+
202
+ async function testLeaseOwnership() {
203
+ const lease = new DeviceLease()
204
+ const first = lease.acquire('agent')
205
+ const second = lease.acquire('agent')
206
+ assert.throws(
207
+ () => lease.acquire('unknown-client'),
208
+ (error) => error.code === 'INVALID_ACTOR',
209
+ )
210
+ lease.assertAccess('agent')
211
+ assert.deepEqual(lease.snapshot().actors, ['agent'])
212
+ assert.equal(lease.snapshot().holders, 2)
213
+ second.release()
214
+ first.release()
215
+ assert.equal(lease.snapshot().state, 'free')
216
+ }
217
+
218
+ async function testCoordinatorFifoAndCancellation() {
219
+ const coordinator = new OperationCoordinator()
220
+ const gate = deferred()
221
+ const order = []
222
+ const first = coordinator.run({
223
+ actor: 'agent',
224
+ requestId: 'agent-active',
225
+ actionName: 'deviceTest.run',
226
+ }, async () => {
227
+ order.push('agent-active')
228
+ await gate.promise
229
+ })
230
+ const second = coordinator.run({
231
+ actor: 'agent',
232
+ requestId: 'agent-queued-1',
233
+ actionName: 'device.inspect',
234
+ }, async () => { order.push('agent-queued-1') })
235
+ const third = coordinator.run({
236
+ actor: 'agent',
237
+ requestId: 'agent-queued-2',
238
+ actionName: 'device.inspect',
239
+ }, async () => { order.push('agent-queued-2') })
240
+ gate.resolve()
241
+ await Promise.all([first, second, third])
242
+ assert.deepEqual(order, ['agent-active', 'agent-queued-1', 'agent-queued-2'])
243
+
244
+ let wasAborted = false
245
+ const controller = new AbortController()
246
+ const activeAgent = coordinator.run({
247
+ actor: 'agent',
248
+ requestId: 'cancel-me',
249
+ actionName: 'deviceTest.run',
250
+ signal: controller.signal,
251
+ }, async (signal) => {
252
+ await new Promise((resolve, reject) => {
253
+ signal.addEventListener('abort', () => {
254
+ wasAborted = true
255
+ reject(signal.reason)
256
+ }, { once: true })
257
+ })
258
+ })
259
+ await Promise.resolve()
260
+ controller.abort(new Error('cancelled by test'))
261
+ await assert.rejects(activeAgent)
262
+ assert.equal(wasAborted, true)
263
+ coordinator.close()
264
+ }
265
+
266
+ async function testGateway() {
267
+ const registry = new DeviceActionRegistry()
268
+ const lease = new DeviceLease()
269
+ const token = lease.acquire('agent')
270
+ const session = { state: 'ready', marker: 'session' }
271
+ registry.bind('device.inspect', async (input, context) => ({
272
+ input,
273
+ actor: context.actor,
274
+ session: context.session.marker,
275
+ }))
276
+ const gateway = new DeviceActionGateway({ registry, lease, getSession: () => session })
277
+ const result = await gateway.invoke('device.inspect', { includeFiles: false }, {
278
+ actor: 'agent',
279
+ requestId: 'inspect-1',
280
+ })
281
+ assert.equal(result.actor, 'agent')
282
+ assert.equal(result.input.logLimit, 100)
283
+ assert.equal(result.session, 'session')
284
+ token.release()
285
+ await assert.rejects(
286
+ gateway.invoke('device.inspect', {}, { actor: 'agent', requestId: 'inspect-2' }),
287
+ (error) => error.code === 'DEVICE_LEASE_REQUIRED',
288
+ )
289
+ }
290
+
291
+ async function testBusyRecovery() {
292
+ const registry = new DeviceActionRegistry()
293
+ registry.bind('device.connect', async () => {
294
+ const error = new Error('open failed: Device or resource busy')
295
+ error.code = 'EBUSY'
296
+ throw error
297
+ })
298
+ const gateway = new DeviceActionGateway({ registry })
299
+ await assert.rejects(
300
+ gateway.invoke('device.connect', { port: '/dev/test' }, {
301
+ actor: 'agent',
302
+ requestId: 'busy-connect',
303
+ }),
304
+ (error) => error.code === 'DEVICE_BUSY' && /explicit handoff/.test(error.recovery),
305
+ )
306
+ }
307
+
308
+ async function testPathRecovery() {
309
+ for (const code of ['INVALID_PATH', 'PATH_TOO_LONG']) {
310
+ const registry = new DeviceActionRegistry()
311
+ registry.bind('deviceFile.write', async () => {
312
+ const error = new Error('Device RPC path exceeds the firmware path contract')
313
+ error.code = code
314
+ throw error
315
+ })
316
+ const lease = new DeviceLease()
317
+ const handle = lease.acquire('agent')
318
+ const gateway = new DeviceActionGateway({ registry, lease, getSession: () => ({ state: 'ready' }) })
319
+ await assert.rejects(
320
+ gateway.invoke('deviceFile.write', { path: '/test.py', content: '' }, {
321
+ actor: 'agent', requestId: `path-${code}`,
322
+ }),
323
+ (error) => error.code === code && /path/.test(error.recovery),
324
+ )
325
+ handle.release()
326
+ }
327
+ }
328
+
329
+ async function testFirmwareVersionRecovery() {
330
+ const registry = new DeviceActionRegistry()
331
+ registry.bind('device.connect', async () => {
332
+ const error = new Error('acquire requires evMuxVersion 4')
333
+ error.code = 'VERSION_MISMATCH'
334
+ throw error
335
+ })
336
+ const gateway = new DeviceActionGateway({ registry })
337
+ await assert.rejects(
338
+ gateway.invoke('device.connect', { port: '/dev/test' }, { actor: 'agent', requestId: 'v4-required' }),
339
+ (error) => error.code === 'FIRMWARE_INCOMPATIBLE' && /explicitly supports EV-MUX v4/.test(error.recovery),
340
+ )
341
+ }
342
+
343
+ async function testControlCancellationBinding() {
344
+ const registry = new DeviceActionRegistry()
345
+ bindSessionDeviceActions(registry)
346
+ const lease = new DeviceLease()
347
+ const token = lease.acquire('agent')
348
+ const controller = new AbortController()
349
+ const session = {
350
+ state: 'ready',
351
+ control: async (operation, { signal }) => {
352
+ assert.equal(operation, 'soft-reset')
353
+ assert.ok(signal, 'the shared control binding must forward cancellation')
354
+ return new Promise((_resolve, reject) => {
355
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true })
356
+ queueMicrotask(() => controller.abort())
357
+ })
358
+ },
359
+ }
360
+ const gateway = new DeviceActionGateway({ registry, lease, getSession: () => session })
361
+ try {
362
+ await assert.rejects(gateway.invoke('device.control', { operation: 'soft-reset' }, {
363
+ actor: 'agent', requestId: 'reset-cancelled', signal: controller.signal,
364
+ }), (error) => error.code === 'ACTION_CANCELLED')
365
+ } finally { token.release() }
366
+ }
367
+
368
+ await testRegistryContract()
369
+ await testControlCancellationBinding()
370
+ await testNetworkOperationIdReuse()
371
+ await testBootloaderAuthorization()
372
+ await testLeaseOwnership()
373
+ await testCoordinatorFifoAndCancellation()
374
+ await testGateway()
375
+ await testBusyRecovery()
376
+ await testPathRecovery()
377
+ await testFirmwareVersionRecovery()
378
+ await testEphemeralRunLifecycle()
379
+ await testEphemeralStopAndCleanupFailure()
380
+ await testEphemeralTimeoutAndDisconnect()
381
+ console.log('Device action contract tests passed')
@@ -0,0 +1,184 @@
1
+ // SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import * as api from '../src/index.js'
6
+ import { DeviceSession, EV_MUX_REQUIRED_FEATURES, EV_MUX_REQUIRED_CHANNELS, EV_MUX_DEFAULT_LIMITS } from '../src/device_session.js'
7
+ import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
8
+ import { EvRepl } from '../src/evrepl.js'
9
+
10
+ const capabilities = { ok: true, protocol: { evMuxVersion: 4 },
11
+ features: [...EV_MUX_REQUIRED_FEATURES, 'debug.capture_frame', 'script.write', 'script.run', 'device.control'],
12
+ channels: EV_MUX_REQUIRED_CHANNELS, limits: { ...EV_MUX_DEFAULT_LIMITS, txLargePayloadMax: 262144 },
13
+ session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 } }
14
+ function peer({ silent = false, screen = false } = {}) {
15
+ let handlers, subscriptions = 0, disconnects = 0
16
+ const requests = [], order = [], id = 'a'.repeat(32)
17
+ const stdout = payload => handlers?.data(makeEvMuxFrame({ channel: 'repl.stdout', type: 'data', sessionId: id, vmEpoch: 1, payload }))
18
+ const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
19
+ if (silent) return false
20
+ const payload = metadata.channel.endsWith('.rpc') ? JSON.parse(new TextDecoder().decode(bytes)) : new TextDecoder().decode(bytes)
21
+ requests.push({ metadata, payload })
22
+ if (metadata.channel === 'repl.stdin') { stdout(`${payload}\r\n${payload.includes('EVIDE_BOOT_OK') ? 'EVIDE_BOOT_OK' : 'result'}\r\n>>> `); return false }
23
+ if (!metadata.channel.endsWith('.rpc')) return false
24
+ let result = { ok: true }
25
+ if (metadata.method === 'hello') result = { ok: true, evMuxVersion: 4, deviceId: 'e8f60ae75642', hostname: 'esp-vision-test', board: 'TEST', firmware: { version: 'test' }, vmReady: true, session: { id, vmEpoch: 1, leaseMs: 15000, keepaliveMs: 5000 } }
26
+ if (metadata.method === 'capabilities') result = { ...capabilities, features: [...capabilities.features, ...(screen ? ['debug.capture_screen'] : [])] }
27
+ if (metadata.method === 'session.keepalive') result = { ok: true, sessionId: id, vmEpoch: 1 }
28
+ if (metadata.method === 'debug.capture_frame' || (screen && metadata.method === 'debug.capture_screen')) {
29
+ handlers.data(makeEvMuxFrame({ channel: 'debug.rpc', type: 'rsp', method: metadata.method, seq: metadata.seq, sessionId: id, vmEpoch: 1, contentType: 'image/jpeg', width: 2, height: 1, payload: new Uint8Array([255,216,0,255,217]) })); return false
30
+ }
31
+ handlers.data(makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq, ...((metadata.method !== 'capabilities' && (metadata.method !== 'hello' || payload.acquire)) ? {sessionId: id, vmEpoch: 1} : {}), payload: JSON.stringify(result) }))
32
+ return false
33
+ } })
34
+ const transport = {
35
+ subscribe(value) { handlers = value; subscriptions++; order.push('subscribe'); return () => { subscriptions--; order.push('unsubscribe') } },
36
+ async connect() { order.push('connect') },
37
+ async disconnect() { disconnects++; order.push('disconnect') },
38
+ async writeBytes(bytes) { parser.feedBytes(bytes) },
39
+ }
40
+ return { transport, requests, order, stdout, close: () => handlers.close(), get subscriptions() { return subscriptions }, get disconnects() { return disconnects } }
41
+ }
42
+
43
+ test('root exports only the application API; protocols remain explicit adapters', () => {
44
+ assert.deepEqual(Object.keys(api).sort(), ['createDevice'])
45
+ const device = api.createDevice()
46
+ assert.equal(device.session, undefined)
47
+ assert.equal(device.repl, undefined)
48
+ assert.equal(device.rpc, undefined)
49
+ assert.deepEqual(Object.keys(device.python).sort(), ['execute', 'input'])
50
+ assert.deepEqual(Object.keys(device.files).sort(), ['list','mkdir','read','remove','write'])
51
+ })
52
+
53
+ test('facade subscribes before connection, negotiates, streams input and disposes exactly once', async () => {
54
+ const p = peer(), d = api.createDevice({ transport: p.transport }), output = []
55
+ const unsubscribe = d.on('python.output', value => output.push(value))
56
+ const ready = await d.connect({ role: 'tcp' })
57
+ assert.equal(ready.identity.deviceId, 'e8f60ae75642')
58
+ assert.deepEqual(p.order.slice(0, 2), ['subscribe','connect'])
59
+ assert.equal(p.subscriptions, 1)
60
+ await d.python.input('print(2)\r')
61
+ assert.ok(output.some(text => text.includes('result')))
62
+ unsubscribe(); const count = output.length; p.stdout('ignored\r\n'); assert.equal(output.length,count)
63
+ await Promise.all([d.disconnect(), d.disconnect()])
64
+ assert.equal(p.subscriptions,0); assert.equal(p.disconnects,1)
65
+ assert.ok(p.requests.some(x => x.metadata.method === 'session.release'))
66
+ await assert.rejects(d.files.read({path:'/x'}), {code:'DEVICE_NOT_READY'})
67
+ })
68
+
69
+ test('capture returns binary bytes and unsupported LCD capture never captures the camera', async () => {
70
+ const p = peer(), d = api.createDevice({transport:p.transport})
71
+ try {
72
+ await d.connect({role:'tcp'})
73
+ const image = await d.capture({source:'camera',quality:70})
74
+ assert.deepEqual(image.data,new Uint8Array([255,216,0,255,217]))
75
+ assert.equal(image.mimeType,'image/jpeg')
76
+ const count=p.requests.length
77
+ await assert.rejects(d.capture({source:'screen'}),{code:'UNSUPPORTED_OPERATION'})
78
+ assert.equal(p.requests.length,count)
79
+ } finally {await d.disconnect()}
80
+ })
81
+
82
+ test('listener failures cannot break parser delivery and are diagnosed without leaking error text', async () => {
83
+ const p=peer(),d=api.createDevice({transport:p.transport}),diagnostics=[]
84
+ d.on('python.output',()=>{throw new Error('secret listener text')})
85
+ d.on('diagnostic',value=>diagnostics.push(value))
86
+ try {await d.connect({role:'tcp'});p.stdout('hello\r\n');assert.ok(diagnostics.some(x=>x.event==='listener.error'));assert.ok(!JSON.stringify(diagnostics).includes('secret listener text'))}finally{await d.disconnect()}
87
+ })
88
+
89
+ test('connection timeout and cancellation dispose reader and transport', async () => {
90
+ const p=peer({silent:true}),d=api.createDevice({transport:p.transport})
91
+ await assert.rejects(d.connect({role:'tcp',timeout:20}),{code:'TIMEOUT'})
92
+ assert.equal(p.subscriptions,0);assert.equal(p.disconnects,1)
93
+ const controller=new AbortController();controller.abort(new Error('cancelled'))
94
+ await assert.rejects(d.connect({role:'tcp',signal:controller.signal}),/cancelled/)
95
+ assert.equal(p.disconnects,1)
96
+ })
97
+
98
+ test('transport loss clears the connection and permits reconnection',async()=>{
99
+ const p=peer(),d=api.createDevice({transport:p.transport})
100
+ await d.connect({role:'tcp'});p.close();await d.disconnect()
101
+ assert.equal(p.subscriptions,0)
102
+ await d.connect({role:'tcp'});await d.disconnect();assert.equal(p.disconnects,2)
103
+ })
104
+
105
+ test('discovery works before any transport is connected',async()=>{
106
+ const d=api.createDevice({discovery:{async list(){return [{deviceId:'e8f60ae75642',hostname:'esp-vision-test',board:'TEST',firmwareVersion:'1',evMuxVersion:4,port:3232,addresses:['192.168.1.2']}]}}})
107
+ assert.equal((await d.discover({timeout:50}))[0].compatible,true)
108
+ })
109
+
110
+ test('binary capture results correlate by sequence even when responses arrive in reverse order',async()=>{
111
+ let s;const requests=[]
112
+ const parser=new EvMuxParser({filterFrame(metadata){requests.push(metadata);return false}})
113
+ s=new DeviceSession({transport:{async writeBytes(b){parser.feedBytes(b)}},EvMuxParser,EvRepl,makeEvMuxFrame})
114
+ s.state='ready';s.sessionId='a'.repeat(32);s.vmEpoch=1;s.vmReady=true;s.capabilities={features:[]}
115
+ try {
116
+ const first=s.captureImage('debug.capture_frame'),second=s.captureImage('debug.capture_frame')
117
+ first.catch(()=>{});second.catch(()=>{})
118
+ await new Promise(setImmediate)
119
+ for(const [index,payload] of [[1,22],[0,11]]){const req=requests[index];s.feedBytes(makeEvMuxFrame({channel:'debug.rpc',type:'rsp',method:req.method,seq:req.seq,sessionId:s.sessionId,vmEpoch:1,contentType:'image/jpeg',payload:new Uint8Array([payload])}))}
120
+ assert.deepEqual((await first).data,new Uint8Array([11]));assert.deepEqual((await second).data,new Uint8Array([22]))
121
+ }finally{s.close()}
122
+ })
123
+
124
+ test('long Unicode paths stay within REPL budget for directory reads and inline execution',async()=>{
125
+ const repl=new EvRepl({writeEvmux(){}}),path='/'+ 'é'.repeat(61),commands=[]
126
+ repl.execPy=async source=>{commands.push(source);assert.ok(repl.execInputBytes(source)<=511);return source.includes('__EVLIST__')?'__EVLIST__[]\n':''}
127
+ assert.deepEqual(await repl.listFs(path),[])
128
+ assert.equal(commands.length,2,'Long escaped paths are assigned separately from directory code')
129
+ const command=repl.createScriptRunCommand(path)
130
+ assert.ok(new TextEncoder().encode(command).length<=511)
131
+ if(repl.scriptRunPrelude)assert.ok(repl.execInputBytes(repl.scriptRunPrelude)<=511)
132
+ repl.cancelScriptEndSentinel()
133
+ })
134
+
135
+ test('inspection snapshots cannot change internal capabilities',async()=>{
136
+ const p=peer(),d=api.createDevice({transport:p.transport})
137
+ try{
138
+ await d.connect({role:'tcp'})
139
+ const value=await d.inspect({fields:['runtime']});value.runtime.capabilities.features.length=0
140
+ const image=await d.capture({source:'camera'});assert.equal(image.mimeType,'image/jpeg')
141
+ }finally{await d.disconnect()}
142
+ })
143
+
144
+ test('file read and removal keep the longest escaped paths below REPL input limits',async()=>{
145
+ const repl=new EvRepl({writeEvmux(){}}),path='/'+ 'é'.repeat(61),sources=[]
146
+ repl._execPy=async source=>{
147
+ sources.push(source);assert.ok(repl.execInputBytes(source)<=511)
148
+ return source.includes('__EVREAD_OK__')?'__EVREAD_OK__1:1:ff\n':source.includes("'ok':True")?'true\n'+JSON.stringify({ok:true}):''
149
+ }
150
+ assert.deepEqual(await repl.readFile(path),new Uint8Array([255]))
151
+ await repl.removeFile(path);await repl.removeDir(path)
152
+ assert.ok(sources.some(s=>s.includes('__evcore_read_path=')))
153
+ assert.ok(sources.some(s=>s.includes('__evcore_fs_path=')))
154
+ })
155
+
156
+ test('LCD capture uses its advertised screen method and preserves dimensions and quality', async () => {
157
+ const p = peer({ screen: true }), d = api.createDevice({ transport: p.transport })
158
+ try {
159
+ await d.connect({ role: 'tcp' })
160
+ const image = await d.capture({ source: 'screen', format: 'jpeg', quality: 85 })
161
+ assert.equal(image.mimeType, 'image/jpeg')
162
+ assert.equal(image.metadata.width, 2)
163
+ assert.equal(image.metadata.height, 1)
164
+ assert.deepEqual(image.data, new Uint8Array([255,216,0,255,217]))
165
+ const request = p.requests.find(x => x.metadata.method === 'debug.capture_screen')
166
+ assert.deepEqual(request.payload, { quality: 85 })
167
+ assert.equal(p.requests.some(x => x.metadata.method === 'debug.capture_frame'), false)
168
+ } finally { await d.disconnect() }
169
+ })
170
+
171
+ test('firmware facade trusts connected identity and releases EV-MUX before opening ROM', async () => {
172
+ const { firmwareSha256 } = await import('../src/firmware_flash.js')
173
+ const p = peer(), device = api.createDevice({ transport: p.transport })
174
+ const ready = await device.connect({ role: 'tcp' }), data = new Uint8Array([0xe9, 1])
175
+ const release = { board: ready.identity.board, version: 'next', image: { evMuxVersion: 4, chipFamily: 1, chipRevision: 0, offset: 65536, bytes: 2, partitionSize: 4096, sha256: await firmwareSha256(data), partitionTable: { offset: 32768, bytes: 1, sha256: '0'.repeat(64) } } }
176
+ const result = await device.firmware.check({ catalog: { schemaVersion: 1, releases: [release] }, identity: { board: 'OTHER', firmwareVersion: 'wrong' } })
177
+ assert.equal(result.available, true); assert.equal(result.currentVersion, ready.identity.firmwareVersion)
178
+ await assert.rejects(device.firmware.update({ release, data, async openLoader() {
179
+ assert.equal(p.disconnects, 1)
180
+ assert.equal(p.subscriptions, 0)
181
+ throw new Error('expected port-selection failure')
182
+ } }), /expected port-selection failure/)
183
+ await assert.rejects(device.inspect(), { code: 'DEVICE_NOT_READY' })
184
+ })