@esp-vision/device-core 0.5.1 → 0.6.1

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/NOTICE +8 -0
  3. package/README.md +25 -8
  4. package/package.json +4 -3
  5. package/schemas/device-actions-v1.json +1 -1
  6. package/scripts/generate-action-schema.mjs +2 -1
  7. package/scripts/npm-command.mjs +17 -0
  8. package/scripts/release-npm.mjs +18 -1
  9. package/scripts/version-policy.mjs +71 -0
  10. package/src/browser.js +6 -2
  11. package/src/browser_rom.js +208 -4
  12. package/src/browser_rom_compat.js +86 -59
  13. package/src/browser_serial.js +130 -0
  14. package/src/device.js +5 -4
  15. package/src/device_bindings.js +5 -4
  16. package/src/device_capture.js +11 -0
  17. package/src/device_operations.js +3 -1
  18. package/src/device_session.js +263 -10
  19. package/src/evmux.js +43 -4
  20. package/src/firmware_flash.js +1 -1
  21. package/src/jpeg_validate.js +132 -0
  22. package/src/node_inventory.js +1 -1
  23. package/src/node_rom.js +92 -29
  24. package/src/node_rom.py +134 -6
  25. package/src/node_rom_port.js +219 -0
  26. package/src/node_serial.py +122 -13
  27. package/src/node_transport.js +136 -21
  28. package/src/rom_md5.js +30 -0
  29. package/tests/check-browser.mjs +76 -123
  30. package/tests/check-package.mjs +8 -7
  31. package/tests/device-checks.mjs +6 -2
  32. package/tests/fixtures/device-checks.py +11 -1
  33. package/tests/jpeg-fixtures.mjs +39 -0
  34. package/tests/test-browser-rom.mjs +204 -67
  35. package/tests/test-browser-serial.mjs +143 -0
  36. package/tests/test-device-api.mjs +40 -7
  37. package/tests/test-device-functional-checks.mjs +12 -2
  38. package/tests/test-device-operations-api.mjs +4 -3
  39. package/tests/test-device-reset.mjs +292 -0
  40. package/tests/test-firmware-flash.mjs +1 -1
  41. package/tests/test-host-checks.mjs +47 -0
  42. package/tests/test-jpeg-validation.mjs +233 -0
  43. package/tests/test-node-rom-port.mjs +385 -0
  44. package/tests/test-node-rom.mjs +231 -0
  45. package/tests/test-node-serial.mjs +436 -1
  46. package/tests/test-npm-release-policy.mjs +56 -0
  47. package/tests/test-observed-capture.mjs +131 -0
  48. package/tests/test-uart-baud.mjs +180 -0
  49. package/tests/test-version-policy.mjs +37 -0
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  import assert from 'node:assert/strict'
3
- import { spawnSync } from 'node:child_process'
3
+ import { spawn, spawnSync } from 'node:child_process'
4
4
  import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
5
5
  import { delimiter, dirname, join, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
@@ -102,3 +102,438 @@ test('non-POSIX serial backends do not receive the unsupported exclusive option'
102
102
  })
103
103
  assert.equal(result.status, 0, result.stderr || result.error?.message || 'Python fixture failed')
104
104
  })
105
+
106
+ test('Node live retune drains without reopening and binary payload cannot become a bridge command', { skip: !python }, async t => {
107
+ await mkdir(tempBase, { recursive: true })
108
+ const root = await mkdtemp(join(tempBase, 'serial-retune-'))
109
+ t.after(() => rm(root, { recursive: true, force: true }))
110
+ const stateFile = join(root, 'state.jsonl')
111
+ await writeFile(join(root, 'serial.py'), `
112
+ import json, queue
113
+
114
+ class SerialException(Exception):
115
+ pass
116
+
117
+ def record(event, **fields):
118
+ with open(${JSON.stringify(stateFile)}, 'a', encoding='utf-8') as stream:
119
+ stream.write(json.dumps(dict(event=event, **fields)) + '\\n')
120
+
121
+ class Serial:
122
+ def __init__(self, *args, **kwargs):
123
+ self.queue = queue.Queue()
124
+ self._baudrate = kwargs['baudrate']
125
+ @property
126
+ def baudrate(self):
127
+ return self._baudrate
128
+ @baudrate.setter
129
+ def baudrate(self, value):
130
+ record('baud', value=value)
131
+ if value == 1500000:
132
+ raise ValueError('fixture unsupported baud')
133
+ self._baudrate = value
134
+ def open(self):
135
+ record('open', baud=self.baudrate, rts=self.rts, dtr=self.dtr)
136
+ def read(self, size):
137
+ try:
138
+ return self.queue.get(timeout=0.01)
139
+ except queue.Empty:
140
+ return b''
141
+ def write(self, data):
142
+ record('write', data=data.hex())
143
+ self.queue.put(data)
144
+ return len(data)
145
+ def flush(self):
146
+ record('drain')
147
+ def close(self):
148
+ record('close')
149
+ `)
150
+ const previous = process.env.PYTHONPATH
151
+ process.env.PYTHONPATH = [root, previous].filter(Boolean).join(delimiter)
152
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
153
+ const transport = serialTransport({ python, serial: '/dev/fixture' }), received = []
154
+ let finish
155
+ const echoed = new Promise(resolveEcho => { finish = resolveEcho })
156
+ const data = new Uint8Array([0, 1, 255, ...new TextEncoder().encode('\n{"id":99,"baudRate":1}\n'), 0])
157
+ transport.subscribe({ data(bytes) { received.push(...bytes); if (received.length === data.length) finish() } })
158
+ t.after(() => transport.disconnect())
159
+ await transport.connect({ signal: new AbortController().signal })
160
+ await transport.setBaudRate(2000000)
161
+ await transport.writeBytes(data)
162
+ await Promise.race([echoed, new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error('Binary echo timed out')), 2000); timer.unref() })])
163
+ assert.deepEqual(new Uint8Array(received), data)
164
+ await assert.rejects(transport.setBaudRate(1500000), { code: 'SERIAL_BAUD_UNSUPPORTED' })
165
+ await transport.setBaudRate(115200)
166
+ assert.equal(transport.getDiagnostics().baudRate, 115200)
167
+ const state = (await readFile(stateFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line))
168
+ assert.equal(state.filter(event => event.event === 'open').length, 1)
169
+ assert.deepEqual(state.filter(event => event.event === 'baud').map(event => event.value), [2000000, 1500000, 115200])
170
+ assert.equal(state.filter(event => event.event === 'drain').length, 3)
171
+ assert.deepEqual(state.find(event => event.event === 'open'), { event: 'open', baud: 115200, rts: false, dtr: true })
172
+ })
173
+
174
+ async function drainFixture(t, flushPlan = ['gate']) {
175
+ await mkdir(tempBase, { recursive: true })
176
+ const root = await mkdtemp(join(tempBase, 'serial-drain-'))
177
+ t.after(() => rm(root, { recursive: true, force: true }))
178
+ const stateFile = join(root, 'state.jsonl'), gateFile = join(root, 'flush-gate')
179
+ await writeFile(join(root, 'serial.py'), `
180
+ import json, os, sys, time
181
+
182
+ PLAN = json.loads(${JSON.stringify(JSON.stringify(flushPlan))})
183
+
184
+ class SerialException(Exception):
185
+ pass
186
+
187
+ def record(event, **fields):
188
+ with open(${JSON.stringify(stateFile)}, 'a', encoding='utf-8') as stream:
189
+ stream.write(json.dumps(dict(event=event, **fields)) + '\\n')
190
+
191
+ class Serial:
192
+ def __init__(self, *args, **kwargs):
193
+ self.baudrate = kwargs['baudrate']
194
+ self.flush_count = 0
195
+ def open(self):
196
+ record('open', baud=self.baudrate)
197
+ def read(self, size):
198
+ time.sleep(0.01)
199
+ return b''
200
+ def write(self, data):
201
+ # Exercise the production bridge's partial serial-write loop.
202
+ written = min(3, len(data))
203
+ record('write', data=data[:written].hex())
204
+ return written
205
+ def flush(self):
206
+ self.flush_count += 1
207
+ count = self.flush_count
208
+ mode = PLAN[min(count - 1, len(PLAN) - 1)]
209
+ record('drain_start', count=count, baud=self.baudrate)
210
+ if mode == 'error':
211
+ raise SerialException('fixture drain failure: private port details')
212
+ if mode == 'exit':
213
+ raise SystemExit(2)
214
+ if mode == 'wrong_ack':
215
+ print(json.dumps({'id': 1, 'ok': True, 'baudRate': self.baudrate}), file=sys.stderr, flush=True)
216
+ elif mode == 'gate':
217
+ while not os.path.exists(${JSON.stringify(gateFile)}):
218
+ time.sleep(0.005)
219
+ else:
220
+ time.sleep(mode)
221
+ record('drain_end', count=count, baud=self.baudrate)
222
+ def close(self):
223
+ record('close')
224
+ `)
225
+ const previous = process.env.PYTHONPATH
226
+ process.env.PYTHONPATH = [root, previous].filter(Boolean).join(delimiter)
227
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
228
+ const transport = serialTransport({ python, serial: '/dev/fixture' })
229
+ t.after(() => transport.disconnect())
230
+ await transport.connect({ signal: new AbortController().signal })
231
+ const state = async () => (await readFile(stateFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line))
232
+ const waitFor = async (event, count = 1) => {
233
+ const deadline = Date.now() + 3000
234
+ while (Date.now() < deadline) {
235
+ const records = await state()
236
+ if (records.filter(record => record.event === event).length >= count) return records
237
+ await new Promise(resolveWait => setTimeout(resolveWait, 5))
238
+ }
239
+ throw new Error(`Serial fixture did not reach ${event}`)
240
+ }
241
+ return { transport, state, waitFor, unblock: () => writeFile(gateFile, '') }
242
+ }
243
+
244
+ test('Node drain waits for real Python port.flush after all partial byte writes without changing baud', { skip: !python }, async t => {
245
+ const f = await drainFixture(t)
246
+ const bytes = new TextEncoder().encode('import machine; machine.bootloader()\r\n')
247
+ await f.transport.writeBytes(bytes)
248
+ let completed = false
249
+ const draining = f.transport.drain().then(() => { completed = true })
250
+ const before = await f.waitFor('drain_start')
251
+ assert.equal(completed, false, 'accepting the child stdin record is not a drain ACK')
252
+ assert.deepEqual(Buffer.from(before.filter(record => record.event === 'write').map(record => record.data).join(''), 'hex'), Buffer.from(bytes))
253
+ assert.equal(before.some(record => record.event === 'drain_end'), false)
254
+ await f.unblock()
255
+ await draining
256
+ assert.equal(f.transport.getDiagnostics().baudRate, 115200)
257
+ await f.transport.disconnect()
258
+ const after = await f.state()
259
+ assert.deepEqual(after.filter(record => ['open', 'drain_start', 'drain_end', 'close'].includes(record.event)).map(record => record.event), ['open', 'drain_start', 'drain_end', 'close'])
260
+ assert.deepEqual(after.filter(record => record.event.startsWith('drain')).map(record => record.baud), [115200, 115200])
261
+ })
262
+
263
+ test('Node drain distinguishes flush rejection and a baud-shaped ACK from successful drain', { skip: !python }, async t => {
264
+ const f = await drainFixture(t, ['error', 0])
265
+ await assert.rejects(f.transport.drain(), { code: 'SERIAL_DRAIN_FAILED', message: 'Serial adapter could not drain pending output' })
266
+ await f.transport.drain()
267
+ assert.equal(f.transport.getDiagnostics().baudRate, 115200)
268
+ await f.transport.disconnect()
269
+ const wrong = await drainFixture(t, ['wrong_ack'])
270
+ await assert.rejects(wrong.transport.drain(), { code: 'SERIAL_DRAIN_FAILED' })
271
+ assert.equal(wrong.transport.getDiagnostics().baudRate, 115200)
272
+ })
273
+
274
+ test('Node drain preserves pre-cancellation and ignores a cancelled request late ACK', { skip: !python }, async t => {
275
+ const f = await drainFixture(t, ['gate', 0.05])
276
+ const before = new AbortController(), reason = Object.assign(new Error('cancel drain'), { code: 'CANCELLED' })
277
+ before.abort(reason)
278
+ await assert.rejects(f.transport.drain({ signal: before.signal }), error => error === reason)
279
+ assert.equal((await f.state()).some(record => record.event === 'drain_start'), false)
280
+ const controller = new AbortController()
281
+ const pending = f.transport.drain({ signal: controller.signal })
282
+ const rejected = assert.rejects(pending, error => error === reason)
283
+ await f.waitFor('drain_start')
284
+ controller.abort(reason)
285
+ await rejected
286
+ const next = f.transport.drain()
287
+ await f.unblock()
288
+ await next
289
+ assert.equal((await f.state()).filter(record => record.event === 'drain_end').length, 2, 'the previous ID must not resolve the next request')
290
+ })
291
+
292
+ test('Node drain timeout remains distinct and its late ACK cannot resolve the next drain', { skip: !python }, async t => {
293
+ const f = await drainFixture(t, ['gate', 0.05])
294
+ await assert.rejects(f.transport.drain(), { code: 'SERIAL_DRAIN_TIMEOUT', message: 'Serial drain timed out' })
295
+ const next = f.transport.drain()
296
+ await f.unblock()
297
+ await next
298
+ assert.equal((await f.state()).filter(record => record.event === 'drain_end').length, 2)
299
+ })
300
+
301
+ test('Node drain rejects promptly on explicit disconnect or Python bridge exit', { skip: !python }, async t => {
302
+ const f = await drainFixture(t)
303
+ const pending = f.transport.drain()
304
+ const rejected = assert.rejects(pending, { code: 'SERIAL_CLOSED' })
305
+ await f.waitFor('drain_start')
306
+ await f.transport.disconnect()
307
+ await rejected
308
+ const exited = await drainFixture(t, ['exit'])
309
+ await assert.rejects(exited.transport.drain(), error => error.code === 'SERIAL_ERROR' && error.details.exitCode === 2)
310
+ })
311
+
312
+ async function failureFixture(t, mode) {
313
+ await mkdir(tempBase, { recursive: true })
314
+ const root = await mkdtemp(join(tempBase, 'serial-failure-')), gate = join(root, 'fail')
315
+ t.after(() => rm(root, { recursive: true, force: true }))
316
+ await writeFile(join(root, 'serial.py'), `
317
+ import os, signal, time
318
+ MODE = ${JSON.stringify(mode)}
319
+ GATE = ${JSON.stringify(gate)}
320
+ SECRET = 'TRANSFERRED_BYTES_MUST_NOT_ESCAPE_' * 100
321
+ class SerialException(Exception):
322
+ pass
323
+ class Serial:
324
+ def __init__(self, *args, **kwargs):
325
+ self.baudrate = kwargs['baudrate']
326
+ def open(self):
327
+ if MODE == 'denied':
328
+ raise SerialException('Could not open COM7: Access is denied ' + SECRET)
329
+ if MODE == 'opening':
330
+ time.sleep(1)
331
+ def read(self, size):
332
+ if os.path.exists(GATE):
333
+ if MODE == 'read':
334
+ raise OSError(5, SECRET)
335
+ if MODE == 'read-private':
336
+ raise SerialException(SECRET)
337
+ if MODE == 'exit':
338
+ os._exit(7)
339
+ if MODE == 'eof':
340
+ os._exit(0)
341
+ if MODE == 'signal':
342
+ os.kill(os.getpid(), signal.SIGKILL)
343
+ time.sleep(.005)
344
+ return b''
345
+ def write(self, data):
346
+ if MODE == 'write':
347
+ raise SerialException('GetOverlappedResult failed ' + SECRET + repr(data))
348
+ return len(data)
349
+ def flush(self):
350
+ pass
351
+ def close(self):
352
+ pass
353
+ `)
354
+ return { root, gate, fail: () => writeFile(gate, '') }
355
+ }
356
+
357
+ function deadline(promise, message) {
358
+ let timer
359
+ return Promise.race([promise, new Promise((_, reject) => {
360
+ timer = setTimeout(() => reject(new Error(message)), 5000)
361
+ })]).finally(() => clearTimeout(timer))
362
+ }
363
+
364
+ async function transportFailure(t, mode) {
365
+ const f = await failureFixture(t, mode), previous = process.env.PYTHONPATH
366
+ process.env.PYTHONPATH = [f.root, previous].filter(Boolean).join(delimiter)
367
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
368
+ const transport = serialTransport({ python, serial: '/dev/fixture' }), errors = []
369
+ let closed
370
+ const close = new Promise(resolveClose => { closed = resolveClose })
371
+ transport.subscribe({ error: error => errors.push(error), close: closed })
372
+ t.after(() => transport.disconnect())
373
+ await transport.connect({ signal: new AbortController().signal })
374
+ return { ...f, transport, errors, close: () => deadline(close, 'Serial child did not close') }
375
+ }
376
+
377
+ test('actual Python reader failure reaches Node with safe OS cause and child status', { skip: !python }, async t => {
378
+ const f = await transportFailure(t, 'read')
379
+ await f.fail()
380
+ await f.close()
381
+ assert.equal(f.errors.length, 1)
382
+ const error = f.errors[0]
383
+ assert.equal(error.code, 'SERIAL_ERROR')
384
+ assert.deepEqual(error.details, { stage: 'read', type: 'OSError', errno: 5, winerror: null,
385
+ message: 'Input/output error', exitCode: 2, signal: null })
386
+ assert.equal(error.cause.name, 'OSError')
387
+ assert.equal(error.cause.errno, 5)
388
+ assert.deepEqual(f.transport.getDiagnostics().lastError, error.details)
389
+ assert.equal(JSON.stringify(error.details).includes('TRANSFERRED_BYTES'), false)
390
+ const snapshot = f.transport.getDiagnostics()
391
+ snapshot.lastError.message = 'changed by caller'
392
+ assert.notEqual(f.transport.getDiagnostics().lastError.message, snapshot.lastError.message)
393
+ })
394
+
395
+ test('actual Python writer failure keeps its API category without exposing payload or arbitrary exception text', { skip: !python }, async t => {
396
+ const f = await transportFailure(t, 'write')
397
+ const payload = new TextEncoder().encode('private transmitted bytes, never a diagnostic')
398
+ await f.transport.writeBytes(payload)
399
+ await f.close()
400
+ assert.equal(f.errors.length, 1)
401
+ assert.equal(f.errors[0].code, 'SERIAL_ERROR')
402
+ assert.deepEqual(f.errors[0].details, { stage: 'write', type: 'SerialException', errno: null,
403
+ winerror: null, message: 'GetOverlappedResult failed', exitCode: 2, signal: null })
404
+ const diagnostic = JSON.stringify(f.transport.getDiagnostics())
405
+ assert.equal(diagnostic.includes('TRANSFERRED_BYTES'), false)
406
+ assert.equal(diagnostic.includes('private transmitted bytes'), false)
407
+ })
408
+
409
+ test('unrecognized Python exception text is omitted and diagnostics remain bounded', { skip: !python }, async t => {
410
+ const f = await transportFailure(t, 'read-private')
411
+ await f.fail()
412
+ await f.close()
413
+ assert.equal(f.errors.length, 1)
414
+ assert.equal(f.errors[0].details.message, 'Serial read failed')
415
+ assert(JSON.stringify(f.errors[0].details).length < 512)
416
+ assert.equal(JSON.stringify(f.errors[0].details).includes('TRANSFERRED_BYTES'), false)
417
+ })
418
+
419
+ test('unexpected bridge exit zero, nonzero or signal reports one serial error with the actual close status', {
420
+ skip: !python,
421
+ }, async t => {
422
+ for (const mode of ['exit', 'eof', ...(process.platform === 'win32' ? [] : ['signal'])]) {
423
+ const f = await transportFailure(t, mode)
424
+ await f.fail()
425
+ await f.close()
426
+ assert.equal(f.errors.length, 1, mode)
427
+ assert.equal(f.errors[0].code, 'SERIAL_ERROR')
428
+ assert.equal(f.errors[0].details.stage, 'process')
429
+ assert.equal(f.errors[0].details.exitCode, mode === 'exit' ? 7 : mode === 'eof' ? 0 : null)
430
+ assert.equal(f.errors[0].details.signal, mode === 'signal' ? 'SIGKILL' : null)
431
+ }
432
+ })
433
+
434
+ test('intentional disconnect and connection cancellation do not become serial failures', { skip: !python }, async t => {
435
+ const f = await transportFailure(t, 'idle')
436
+ await f.transport.disconnect()
437
+ await f.close()
438
+ assert.deepEqual(f.errors, [])
439
+ assert.equal(f.transport.getDiagnostics().lastError, undefined)
440
+ const pending = await failureFixture(t, 'opening'), previous = process.env.PYTHONPATH
441
+ process.env.PYTHONPATH = [pending.root, previous].filter(Boolean).join(delimiter)
442
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
443
+ const transport = serialTransport({ python, serial: '/dev/fixture' }), errors = []
444
+ let closed
445
+ const close = new Promise(resolveClose => { closed = resolveClose })
446
+ transport.subscribe({ error: error => errors.push(error), close: closed })
447
+ t.after(() => transport.disconnect())
448
+ const controller = new AbortController(), connecting = transport.connect({ signal: controller.signal })
449
+ const cancelled = assert.rejects(connecting, { code: 'CANCELLED' })
450
+ controller.abort()
451
+ await cancelled
452
+ await deadline(close, 'Cancelled child did not close')
453
+ assert.deepEqual(errors, [])
454
+ assert.equal(transport.getDiagnostics().lastError, undefined)
455
+ })
456
+
457
+
458
+ test('Python failure stderr is one bounded structured record and never includes transfer data', { skip: !python }, async t => {
459
+ for (const mode of ['read-private', 'write']) {
460
+ const f = await failureFixture(t, mode), stderr = [], stdout = []
461
+ const child = spawn(python, ['-u', adapter, '/dev/fixture'], {
462
+ env: fixturePythonEnv(f.root), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
463
+ })
464
+ t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM') })
465
+ let ready
466
+ const started = new Promise(resolveReady => { ready = resolveReady })
467
+ child.stdout.on('data', bytes => stdout.push(bytes))
468
+ child.stderr.on('data', bytes => { stderr.push(bytes); if (/(?:^|\n)READY\r?\n/.test(Buffer.concat(stderr).toString())) ready() })
469
+ const closed = new Promise((resolveClose, reject) => { child.on('error', reject); child.on('close', (code, signal) => resolveClose({ code, signal })) })
470
+ await deadline(started, 'Python fault fixture did not become ready')
471
+ if (mode === 'write') {
472
+ const payload = Buffer.from('private serial bytes')
473
+ const record = Buffer.alloc(5 + payload.length)
474
+ record.writeUInt8(0, 0); record.writeUInt32BE(payload.length, 1); record.set(payload, 5)
475
+ child.stdin.write(record)
476
+ } else await f.fail()
477
+ assert.deepEqual(await deadline(closed, 'Python fault did not terminate'), { code: 2, signal: null })
478
+ const text = Buffer.concat(stderr).toString(), lines = text.trim().split(/\r?\n/)
479
+ assert.equal(lines.filter(line => line === 'READY').length, 1)
480
+ const records = lines.filter(line => line !== 'READY').map(line => JSON.parse(line))
481
+ assert.equal(records.length, 1)
482
+ assert.equal(records[0].kind, 'serial.error')
483
+ assert.equal(records[0].stage, mode === 'write' ? 'write' : 'read')
484
+ assert(Buffer.byteLength(lines.find(line => line !== 'READY')) < 512)
485
+ assert.equal(text.includes('TRANSFERRED_BYTES'), false)
486
+ assert.equal(text.includes('private serial bytes'), false)
487
+ assert.equal(Buffer.concat(stdout).length, 0)
488
+ }
489
+ })
490
+
491
+
492
+ test('disconnect before READY settles the pending connection without reporting an unexpected failure', { skip: !python }, async t => {
493
+ const f = await failureFixture(t, 'opening'), previous = process.env.PYTHONPATH
494
+ process.env.PYTHONPATH = [f.root, previous].filter(Boolean).join(delimiter)
495
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
496
+ const transport = serialTransport({ python, serial: '/dev/fixture' }), errors = []
497
+ transport.subscribe({ error: error => errors.push(error) })
498
+ t.after(() => transport.disconnect())
499
+ const connecting = transport.connect({ signal: new AbortController().signal })
500
+ const closed = assert.rejects(connecting, { code: 'SERIAL_CLOSED' })
501
+ await transport.disconnect()
502
+ await deadline(closed, 'Disconnect did not settle the pending connection')
503
+ assert.deepEqual(errors, [])
504
+ assert.equal(transport.getDiagnostics().lastError, undefined)
505
+ })
506
+
507
+
508
+ test('spawn failure before READY preserves its bounded process cause and settles once', async t => {
509
+ const transport = serialTransport({ python: join(tempBase, 'missing-python-executable'), serial: '/dev/fixture' }), errors = []
510
+ transport.subscribe({ error: error => errors.push(error) })
511
+ t.after(() => transport.disconnect())
512
+ await assert.rejects(transport.connect({ signal: new AbortController().signal }), error => {
513
+ assert.equal(error.code, 'SERIAL_ERROR')
514
+ assert.equal(error.details.stage, 'process')
515
+ assert.equal(error.details.type, 'ProcessError')
516
+ assert.equal(error.details.exitCode, process.platform === 'win32' ? -4058 : -2)
517
+ assert.equal(error.details.signal, null)
518
+ assert.match(error.details.message, /ENOENT/)
519
+ return true
520
+ })
521
+ assert.equal(errors.length, 1)
522
+ })
523
+
524
+ test('Windows-style access denial before READY retains its existing error code without port or exception text', { skip: !python }, async t => {
525
+ const f = await failureFixture(t, 'denied'), previous = process.env.PYTHONPATH
526
+ process.env.PYTHONPATH = [f.root, previous].filter(Boolean).join(delimiter)
527
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
528
+ const transport = serialTransport({ python, serial: 'COM7' }), errors = []
529
+ transport.subscribe({ error: error => errors.push(error) })
530
+ t.after(() => transport.disconnect())
531
+ await assert.rejects(transport.connect({ signal: new AbortController().signal }), error => {
532
+ assert.equal(error.code, 'SERIAL_ACCESS_DENIED')
533
+ assert.equal(error.details.exitCode, 2)
534
+ assert.equal(JSON.stringify(error.details).includes('TRANSFERRED_BYTES'), false)
535
+ assert.equal(error.message.includes('COM7'), false)
536
+ return true
537
+ })
538
+ assert.equal(errors.length, 1)
539
+ })
@@ -0,0 +1,56 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { spawnSync } from 'node:child_process'
5
+ import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
6
+ import { tmpdir } from 'node:os'
7
+ import { join, resolve } from 'node:path'
8
+ import { fileURLToPath, pathToFileURL } from 'node:url'
9
+ import { test } from 'node:test'
10
+
11
+ const source = fileURLToPath(new URL('../scripts/', import.meta.url))
12
+ async function fixture(t, published, version = '1.2.3', type = 'patch') {
13
+ const root = await mkdtemp(join(tmpdir(), 'core-release-policy-'))
14
+ t.after(() => rm(root, { recursive: true, force: true }))
15
+ const scripts = join(root, 'scripts'), log = join(root, 'calls')
16
+ await mkdir(scripts)
17
+ await copyFile(join(source, 'release-npm.mjs'), join(scripts, 'release-npm.mjs'))
18
+ await copyFile(join(source, 'verify-registry.mjs'), join(scripts, 'verify-registry.mjs'))
19
+ await copyFile(join(source, 'version-policy.mjs'), join(scripts, 'version-policy.mjs'))
20
+ await copyFile(join(source, 'npm-command.mjs'), join(scripts, 'npm-command.mjs'))
21
+ await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@esp-vision/device-core', version, espVisionReleaseType: type, license: 'Apache-2.0', publishConfig: { access: 'public', registry: 'https://registry.npmjs.org/' } }))
22
+ const loader = join(root, 'registry.mjs'), npm = join(root, 'npm.cjs')
23
+ // Invoke the fixture with Node on every platform, without shell/PATH resolution.
24
+ await writeFile(loader, `let reads = 0; globalThis.fetch = async () => { reads++; const found = ${published} || reads === 2 || reads >= 4; return {ok:found,status:found?200:404,json:async()=>reads===2?({versions:{'1.2.2':{}}}):({dist:{integrity:'sha512-fixture'}})} }`)
25
+ await writeFile(npm, `const fs = require('node:fs'); const args = process.argv.slice(2); fs.appendFileSync(process.env.CALL_LOG, args[0] + '\\n');
26
+ if (args[0] === 'whoami') process.stdout.write('esp-vision');
27
+ else if (args[0] === 'pack') process.stdout.write(JSON.stringify([{name:'@esp-vision/device-core',version:'1.2.3',filename:'core.tgz',integrity:'sha512-fixture',files:[{path:'LICENSE'},{path:'NOTICE'}]}]));
28
+ else if (args[0] !== 'publish') process.exit(2);
29
+ `)
30
+ const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root, CALL_LOG: log, npm_execpath: npm,
31
+ NODE_OPTIONS: `--import ${pathToFileURL(loader).href}`, NPM_PUBLISH_TOKEN: 'fixture', CI_PIPELINE_SOURCE: 'push',
32
+ CI_DEFAULT_BRANCH: 'master', CI_COMMIT_BRANCH: 'master', CI_COMMIT_REF_PROTECTED: 'true' }
33
+ const result = spawnSync(process.execPath, [resolve(scripts, 'release-npm.mjs')], { cwd: root, env, encoding: 'utf8' })
34
+ return { result, calls: await readFile(log, 'utf8').catch(() => '') }
35
+ }
36
+
37
+ test('an existing Core version is skipped without npm writes', async t => {
38
+ const { result, calls } = await fixture(t, true)
39
+ assert.equal(result.status, 0, result.stderr)
40
+ assert.match(result.stdout, /already exists; no new version to publish/)
41
+ assert.equal(calls, '')
42
+ })
43
+
44
+ test('a new Core version is published once and its registry integrity verified', async t => {
45
+ const { result, calls } = await fixture(t, false)
46
+ assert.equal(result.status, 0, result.stderr)
47
+ assert.match(result.stdout, /Published and verified/)
48
+ assert.deepEqual(calls.trim().split('\n'), ['pack', 'whoami', 'publish'])
49
+ })
50
+
51
+ test('a misclassified Core version is rejected before packaging or publication', async t => {
52
+ const { result, calls } = await fixture(t, false, '1.3.0', 'patch')
53
+ assert.notEqual(result.status, 0)
54
+ assert.match(result.stderr, /A patch release after 1.2.2 must be 1.2.3/)
55
+ assert.equal(calls, '')
56
+ })
@@ -0,0 +1,131 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import test from 'node:test'
5
+ import { DeviceSession } from '../src/device_session.js'
6
+ import { createDeviceOperations } from '../src/device_operations.js'
7
+ import { DeviceActionRegistry } from '../src/device_actions.js'
8
+ import { bindSessionDeviceActions } from '../src/device_bindings.js'
9
+ import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
10
+ import { EvRepl } from '../src/evrepl.js'
11
+ import { jpegFixture } from './jpeg-fixtures.mjs'
12
+
13
+ const safeFeature = 'debug.capture_frame.observed.v1'
14
+ function fixture({ features = ['debug.capture_frame', safeFeature, 'debug.capture_screen', 'device.control'], cameraError = null, pending = false } = {}) {
15
+ let session
16
+ const requests = [], id = 'a'.repeat(32), state = {}
17
+ const jpeg = jpegFixture('small').data
18
+ const replyImage = (request, { sessionId = request.sessionId, vmEpoch = request.vmEpoch } = {}) => session.feedBytes(makeEvMuxFrame({
19
+ channel: request.channel, type: 'rsp', method: request.method, seq: request.seq, sessionId, vmEpoch,
20
+ contentType: 'image/jpeg', encoding: 'binary', width: 4, height: 3, payload: jpeg,
21
+ }))
22
+ const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
23
+ const payload = JSON.parse(new TextDecoder().decode(bytes))
24
+ requests.push({ ...metadata, payload })
25
+ if (metadata.method === 'debug.capture_frame' && pending) return false
26
+ if (metadata.method === 'debug.capture_screen' || (metadata.method === 'debug.capture_frame' && !cameraError)) replyImage(metadata)
27
+ else session.feedBytes(makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq,
28
+ sessionId: metadata.sessionId, vmEpoch: metadata.vmEpoch ?? session.vmEpoch,
29
+ payload: JSON.stringify(cameraError && metadata.method === 'debug.capture_frame'
30
+ ? { ok: false, error: { code: cameraError, message: 'No safe camera observation is available' } } : { ok: true }) }))
31
+ return false
32
+ } })
33
+ session = new DeviceSession({ transport: { async writeBytes(bytes) { parser.feedBytes(bytes) } }, EvMuxParser, makeEvMuxFrame, EvRepl })
34
+ session.state = 'ready'
35
+ session.sessionId = id
36
+ session.vmEpoch = 1
37
+ session.vmReady = true
38
+ session.capabilities = { features }
39
+ return { session, requests, replyImage, state, jpeg, api: createDeviceOperations({ session, state }) }
40
+ }
41
+
42
+ test('old unsafe camera firmware is rejected before public, RPC, raw-frame or action requests are sent', async t => {
43
+ for (const features of [[], ['debug.capture_frame'], [safeFeature]]) {
44
+ const f = fixture({ features }); t.after(() => f.session.close())
45
+ const denied = error => error.code === 'UNSUPPORTED_OPERATION' && /update.*firmware/i.test(error.message)
46
+ await assert.rejects(f.api.capture({ source: 'camera' }), denied)
47
+ await assert.rejects(f.session.captureImage('debug.capture_frame'), denied)
48
+ await assert.rejects(f.session.rpc('debug.capture_frame'), denied)
49
+ await assert.rejects(f.session.writeFrame('debug.rpc', 'req', 'debug.capture_frame', '{}'), denied)
50
+ const registry = new DeviceActionRegistry()
51
+ bindSessionDeviceActions(registry)
52
+ await assert.rejects(registry.handler('frame.capture')({ timeoutMs: 1000 }, { session: f.session }), denied)
53
+ assert.deepEqual(f.requests, [])
54
+ assert.equal(f.session.pendingRpc.size, 0)
55
+ assert.equal(f.state.operation, undefined)
56
+ }
57
+ })
58
+
59
+ test('negotiated camera observation returns correlated JPEG bytes and forwards quality', async t => {
60
+ const f = fixture(); t.after(() => f.session.close())
61
+ const image = await f.api.capture({ source: 'camera', quality: 73 })
62
+ assert.deepEqual(image.data, f.jpeg)
63
+ assert.equal(image.mimeType, 'image/jpeg')
64
+ assert.equal(image.metadata.width, 4)
65
+ assert.equal(image.metadata.height, 3)
66
+ assert.equal(f.requests.length, 1)
67
+ assert.equal(f.requests[0].method, 'debug.capture_frame')
68
+ assert.deepEqual(f.requests[0].payload, { quality: 73 })
69
+ assert.equal(f.state.operation, null)
70
+ })
71
+
72
+ test('no published camera frame and observer allocation failures retain explicit firmware errors', async t => {
73
+ for (const cameraError of ['CAMERA_FRAME_NOT_READY', 'NO_MEM']) {
74
+ const f = fixture({ cameraError }); t.after(() => f.session.close())
75
+ await assert.rejects(f.api.capture({ source: 'camera' }), { code: cameraError })
76
+ assert.equal(f.requests.length, 1, 'no sensor initialization, retry or capture fallback is synthesized')
77
+ assert.equal(f.session.lastDebugImage, null)
78
+ assert.equal(f.state.operation, null)
79
+ }
80
+ })
81
+
82
+ test('screen capture retains its independent capability without a camera observation feature', async t => {
83
+ const f = fixture({ features: ['debug.capture_frame', 'debug.capture_screen'] }); t.after(() => f.session.close())
84
+ assert.deepEqual((await f.api.capture({ source: 'screen' })).data, f.jpeg)
85
+ await assert.rejects(f.api.capture({ source: 'camera' }), { code: 'UNSUPPORTED_OPERATION' })
86
+ assert.deepEqual(f.requests.map(request => request.method), ['debug.capture_screen'])
87
+ })
88
+
89
+ test('camera cancellation and timeout release the operation and ignore stale binary responses', async t => {
90
+ const f = fixture({ pending: true }); t.after(() => f.session.close())
91
+ const controller = new AbortController()
92
+ const capture = f.api.capture({ source: 'camera', signal: controller.signal })
93
+ const cancelled = assert.rejects(capture, { name: 'AbortError' })
94
+ await new Promise(setImmediate)
95
+ controller.abort()
96
+ await cancelled
97
+ assert.equal(f.state.operation, null)
98
+ assert.equal(f.session.pendingRpc.size, 0)
99
+ f.replyImage(f.requests[0])
100
+ assert.deepEqual((await f.api.capture({ source: 'screen' })).data, f.jpeg)
101
+ await assert.rejects(f.api.capture({ source: 'camera', timeout: 20 }), { code: 'TIMEOUT' })
102
+ assert.equal(f.state.operation, null)
103
+ assert.equal(f.session.pendingRpc.size, 0)
104
+ })
105
+
106
+ test('soft-reset epochs invalidate a pending camera observation and reject the old image', async t => {
107
+ const f = fixture({ pending: true }); t.after(() => f.session.close())
108
+ const before = f.api.capture({ source: 'camera' })
109
+ const invalidated = assert.rejects(before, { code: 'VM_EPOCH_CHANGED' })
110
+ await new Promise(setImmediate)
111
+ f.session.feedBytes(makeEvMuxFrame({ channel: 'user.rpc', type: 'event', method: 'hello', sessionId: f.session.sessionId, vmEpoch: 2,
112
+ payload: JSON.stringify({ evMuxVersion: 4, session: { id: f.session.sessionId, vmEpoch: 2 }, vmEpoch: 2, vmReady: true }) }))
113
+ await invalidated
114
+ f.replyImage(f.requests[0])
115
+ assert.equal(f.session.vmEpoch, 2)
116
+ assert.deepEqual((await f.api.capture({ source: 'screen' })).data, f.jpeg)
117
+ assert.equal(f.requests.at(-1).vmEpoch, 2)
118
+ })
119
+
120
+ test('acknowledged hard reset discards a pending safe camera response and its old session', async t => {
121
+ const f = fixture({ pending: true }); t.after(() => f.session.close())
122
+ const capture = f.session.captureImage('debug.capture_frame')
123
+ const invalidated = assert.rejects(capture, { code: 'DEVICE_RESET' })
124
+ await new Promise(setImmediate)
125
+ await f.session.control('hard-reset')
126
+ await invalidated
127
+ f.replyImage(f.requests[0])
128
+ assert.equal(f.session.state, 'disconnected')
129
+ assert.equal(f.session.sessionId, null)
130
+ assert.equal(f.session.pendingRpc.size, 0)
131
+ })