@esp-vision/device-core 0.5.0 → 0.6.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/NOTICE +8 -0
  3. package/README.md +24 -7
  4. package/package.json +4 -3
  5. package/schemas/device-actions-v1.json +1 -1
  6. package/scripts/release-npm.mjs +16 -0
  7. package/scripts/version-policy.mjs +71 -0
  8. package/src/browser.js +6 -2
  9. package/src/browser_rom.js +208 -4
  10. package/src/browser_rom_compat.js +86 -59
  11. package/src/browser_serial.js +130 -0
  12. package/src/device.js +5 -4
  13. package/src/device_bindings.js +5 -4
  14. package/src/device_capture.js +11 -0
  15. package/src/device_operations.js +3 -1
  16. package/src/device_session.js +263 -10
  17. package/src/evmux.js +43 -4
  18. package/src/firmware_flash.js +1 -1
  19. package/src/jpeg_validate.js +132 -0
  20. package/src/node_inventory.js +1 -1
  21. package/src/node_rom.js +76 -27
  22. package/src/node_rom.py +68 -2
  23. package/src/node_rom_port.js +211 -0
  24. package/src/node_serial.py +47 -1
  25. package/src/node_transport.js +76 -10
  26. package/src/rom_md5.js +30 -0
  27. package/tests/check-browser.mjs +76 -123
  28. package/tests/check-package.mjs +1 -1
  29. package/tests/device-checks.mjs +6 -2
  30. package/tests/fixtures/device-checks.py +11 -1
  31. package/tests/jpeg-fixtures.mjs +39 -0
  32. package/tests/test-browser-rom.mjs +204 -67
  33. package/tests/test-browser-serial.mjs +143 -0
  34. package/tests/test-device-api.mjs +40 -7
  35. package/tests/test-device-execution.mjs +30 -3
  36. package/tests/test-device-functional-checks.mjs +12 -2
  37. package/tests/test-device-operations-api.mjs +4 -3
  38. package/tests/test-device-reset.mjs +292 -0
  39. package/tests/test-firmware-flash.mjs +1 -1
  40. package/tests/test-jpeg-validation.mjs +233 -0
  41. package/tests/test-node-rom-port.mjs +305 -0
  42. package/tests/test-node-rom.mjs +160 -0
  43. package/tests/test-node-serial.mjs +206 -0
  44. package/tests/test-npm-release-policy.mjs +62 -0
  45. package/tests/test-observed-capture.mjs +131 -0
  46. package/tests/test-uart-baud.mjs +180 -0
  47. package/tests/test-version-policy.mjs +37 -0
@@ -2,84 +2,221 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import test from 'node:test'
4
4
  import assert from 'node:assert/strict'
5
+ import { createHash } from 'node:crypto'
5
6
  import { openBrowserRom } from '../src/browser.js'
6
- import { browserRomCompatibility } from '../src/browser_rom_compat.js'
7
+ import { createOfficialBrowserLoader, browserRomInterface, ROM_UART_BAUD_RATES } from '../src/browser_rom.js'
8
+ import { browserTransportCompatibility, browserRomCompatibility, readBrowserFlash, romWords } from '../src/browser_rom_compat.js'
9
+ import { romMd5 } from '../src/rom_md5.js'
7
10
 
8
- const port = () => ({ open() { throw new Error('Port must remain passive') }, close() {} })
9
- test('browser ROM construction and disconnect are passive without browser globals', async () => {
10
- const adapter = openBrowserRom({ port: port() })
11
+ const passivePort = () => ({ open() { throw new Error('Port must remain passive') }, close() {} })
12
+ test('browser ROM construction and disconnect remain passive without browser globals', async () => {
13
+ const adapter = openBrowserRom({ port: passivePort() })
11
14
  assert.equal(adapter.chipFamily, null)
12
15
  assert.equal(adapter.chipRevision, null)
13
16
  assert.equal(adapter.chipName, null)
14
17
  await adapter.disconnect(); await adapter.disconnect()
15
18
  await assert.rejects(adapter.initialize(), { code: 'ROM_CLOSED' })
16
19
  })
17
- test('browser ROM validates its port and cancellation before loading the browser dependency', async () => {
20
+ test('browser ROM validates its port and cancellation before loading the dependency', async () => {
18
21
  assert.throws(() => openBrowserRom(), /SerialPort/)
19
- assert.throws(() => openBrowserRom({ port: port(), signal: {} }), /AbortSignal/)
22
+ assert.throws(() => openBrowserRom({ port: passivePort(), signal: {} }), /AbortSignal/)
20
23
  const controller = new AbortController()
21
24
  controller.abort(new Error('cancelled before open'))
22
- await assert.rejects(openBrowserRom({ port: port(), signal: controller.signal }).initialize(), /cancelled before open/)
23
- })
24
- test('SLIP prefix compatibility retries only pre-header bytes within the original deadline', async () => {
25
- let count = 0
26
- const loader = browserRomCompatibility({ async readPacket() {
27
- if (++count < 3) throw new Error('Invalid head of packet (0x47)')
28
- return [79, 72, 65, 73]
29
- } })
30
- assert.deepEqual(await loader.readPacket(100), [79, 72, 65, 73])
31
- loader.readPacket = browserRomCompatibility({ readPacket: async () => { throw new Error('Invalid SLIP escape (0xdb, 0x47)') } }).readPacket
32
- await assert.rejects(loader.readPacket(100), /Invalid SLIP escape/)
33
- })
34
- test('SLIP log noise cannot extend the operation deadline indefinitely', async () => {
35
- let count = 0
36
- const loader = browserRomCompatibility({ async readPacket() {
37
- count++
38
- await new Promise(resolve => setTimeout(resolve, 5))
39
- throw new Error('Invalid head of packet (0x47)')
40
- } })
41
- await assert.rejects(loader.readPacket(15), /Invalid head/)
42
- assert.ok(count <= 4)
43
- })
44
- function stubFixture({ native = true, error = 'Timed out waiting for packet header', handshake = true } = {}) {
45
- let timeout, reads = 0
46
- const stub = { IS_STUB: true, readPacket: async () => [], checkCommand: (...args) => { timeout = args[3] } }
47
- const raw = {
48
- isUsbJtagOrOtg: native,
49
- memFinish: async () => { if (error) throw new Error(error) },
50
- readPacket: async () => { reads++; return handshake ? [79, 72, 65, 73] : [] },
51
- async runStub(skip) {
52
- assert.equal(skip, true)
53
- await this.memFinish()
54
- assert.deepEqual(await this.readPacket(2500), [79, 72, 65, 73], 'OHAI required')
55
- return stub
56
- },
25
+ await assert.rejects(openBrowserRom({ port: passivePort(), signal: controller.signal }).initialize(), /cancelled before open/)
26
+ })
27
+ test('interface discovery separates known bridge, OTG and JTAG; custom descriptors need evidence', () => {
28
+ const port = (usbVendorId, usbProductId) => ({ getInfo: () => ({ usbVendorId, usbProductId }) })
29
+ assert.equal(browserRomInterface(port(0x10c4, 0xea60)), 'uart')
30
+ assert.equal(browserRomInterface(port(0x303a, 32)), 'usb-otg')
31
+ assert.equal(browserRomInterface(port(0x303a, 0x1001)), 'usb-jtag')
32
+ assert.equal(browserRomInterface(port(0x303a, 0x1234)), 'unknown')
33
+ assert.equal(browserRomInterface(port(undefined, undefined)), 'unknown')
34
+ assert.equal(browserRomInterface(port(1, 2), 'uart'), 'uart')
35
+ assert.throws(() => browserRomInterface(port(1, 2), 'usb'), /interface/)
36
+ })
37
+ test('ROM transport MD5 matches independent known vectors and multi-block data', () => {
38
+ for (const input of ['', 'a', 'abc', 'message digest', 'a'.repeat(55), 'b'.repeat(56), 'c'.repeat(65), 'x'.repeat(65536)]) {
39
+ const bytes = new TextEncoder().encode(input)
40
+ assert.equal(Buffer.from(romMd5(bytes)).toString('hex'), createHash('md5').update(bytes).digest('hex'))
41
+ }
42
+ })
43
+ function transportFixture() {
44
+ return browserTransportCompatibility({ device: { readable: {}, writable: {} }, buffer: new Uint8Array(0),
45
+ appendArray(a, b) { return new Uint8Array([...a, ...b]) }, async write() {},
46
+ })
47
+ }
48
+ test('SLIP keeps a valid packet after boot noise, trailing packets and escaped bytes', async () => {
49
+ const transport = transportFixture()
50
+ transport.buffer = new Uint8Array([71, 97, 114, 0xc0, 0xc0, 79, 72, 65, 73, 0xc0, 0xc0, 0xdb, 0xdc, 0xdb, 0xdd, 0xc0])
51
+ assert.deepEqual(await transport.read(100), new Uint8Array([79, 72, 65, 73]))
52
+ assert.deepEqual(await transport.read(100), new Uint8Array([0xc0, 0xdb]))
53
+ transport.buffer = new Uint8Array([0xc0, 0xdb, 0x47, 0xc0])
54
+ await assert.rejects(transport.read(100), { code: 'ROM_SLIP_ESCAPE' })
55
+ })
56
+ test('SLIP preserves state across partial incoming chunks and has a total packet deadline', async () => {
57
+ const transport = transportFixture()
58
+ transport.buffer = new Uint8Array([0xc0, 1, 0xdb])
59
+ const later = setTimeout(() => { transport.buffer = new Uint8Array([0xdc, 2, 0xc0]) }, 5)
60
+ try { assert.deepEqual(await transport.read(100), new Uint8Array([1, 0xc0, 2])) } finally { clearTimeout(later) }
61
+ transport.buffer = new Uint8Array([0xc0, 1])
62
+ await assert.rejects(transport.read(15), { code: 'ROM_READ_TIMEOUT' })
63
+ transport.device.readable = null
64
+ await assert.rejects(transport.read(100), { code: 'ROM_PORT_LOST' })
65
+ transport.device.writable = null
66
+ await assert.rejects(transport.write(new Uint8Array()), { code: 'ROM_PORT_LOST' })
67
+ })
68
+ function readFixture({ data = new Uint8Array([1, 2, 3]), packets, digest = romMd5(data), delay = 0 } = {}) {
69
+ const received = [], reads = [...(packets ?? [data]), digest]
70
+ const loader = { IS_STUB: true, ESP_READ_FLASH: 0xd2, transport: { baudrate: 2000000,
71
+ async read() { if (delay) await new Promise(resolve => setTimeout(resolve, delay)); const packet = reads.shift(); if (!packet) throw new Error('missing packet'); return packet },
72
+ async write(bytes) { received.push(new DataView(bytes.buffer).getUint32(0, true)) },
73
+ }, async checkCommand(description, opcode, payload) { assert.equal(description, 'read flash'); assert.equal(opcode, 0xd2); assert.deepEqual(payload, romWords(0x20000, data.length, 1024, 1)); return 0 } }
74
+ return { loader, data, received, reads }
75
+ }
76
+ test('readback ACKs partial packets and consumes the delayed trailing digest before returning', async () => {
77
+ const f = readFixture({ packets: [new Uint8Array([1]), new Uint8Array([2, 3])], delay: 3 })
78
+ assert.deepEqual(await readBrowserFlash(f.loader, 0x20000, 3), f.data)
79
+ assert.deepEqual(f.received, [1, 3]); assert.equal(f.reads.length, 0)
80
+ })
81
+ test('readback rejects malformed packets, incomplete data, incorrect or absent digest', async () => {
82
+ for (const config of [{ packets: [new Uint8Array(0)] }, { packets: [new Uint8Array([1, 2, 3, 4])] }, { digest: new Uint8Array(16) }, { digest: new Uint8Array(15) }]) {
83
+ const f = readFixture(config)
84
+ await assert.rejects(readBrowserFlash(f.loader, 0x20000, 3), /packet|digest/i)
85
+ }
86
+ const f = readFixture(); f.loader.transport.read = async () => { throw Object.assign(new Error('timeout waiting for trailing digest'), { code: 'ROM_READ_TIMEOUT' }) }
87
+ await assert.rejects(readBrowserFlash(f.loader, 0x20000, 3), { code: 'ROM_READ_TIMEOUT' })
88
+ })
89
+ test('native USB MEM_END timeout exception never bypasses validated stub greeting', async () => {
90
+ const native = browserRomCompatibility({ memFinish: async () => { throw new Error('Timed out receiving a complete ROM packet') } }, { nativeUsb: true })
91
+ await native.memFinish()
92
+ const uart = browserRomCompatibility({ memFinish: async () => { throw new Error('Timed out') } })
93
+ await assert.rejects(uart.memFinish(), /Timed out/)
94
+ const lost = browserRomCompatibility({ memFinish: async () => { throw new Error('Port disconnected') } }, { nativeUsb: true })
95
+ await assert.rejects(lost.memFinish(), /disconnected/)
96
+ })
97
+ function officialFixture({ failRates = [], usb = false, security = { flags: 0, flashCryptCnt: 0 }, failWrite = false, recoverSecurity, recoverFamily, detach = false, reopenResets = false, flashId = 0x1840ef, stubStarts = true } = {}) {
98
+ const events = [], calls = [], loaders = [], opens = [], reads = []
99
+ const port = { readable: null, writable: null,
100
+ getInfo: () => ({ usbVendorId: usb ? 0x303a : 0x10c4, usbProductId: usb ? 32 : 0xea60 }),
101
+ async open({ baudRate }) { opens.push(baudRate); this.readable = {}; this.writable = {} },
102
+ async close() { this.readable = null; this.writable = null },
103
+ }
104
+ class FakeTransport {
105
+ constructor(device) { this.device = device; this.buffer = new Uint8Array(0); this.baudrate = 115200 }
106
+ appendArray(a, b) { return new Uint8Array([...a, ...b]) }
107
+ flushInput() { this.buffer = new Uint8Array(0) }
108
+ async disconnect() { calls.push('close'); await port.close() }
109
+ async changeBaudrate(rate) { calls.push(['retune', rate]); this.baudrate = rate; if (detach) throw Object.assign(new Error('Port detached'), { code: 'ROM_PORT_LOST' }); await port.close(); await port.open({ baudRate: rate }); if (reopenResets) loaders.at(-1).IS_STUB = false }
110
+ async write() {}
111
+ async setDTR(value) { calls.push(['dtr', value]) }
112
+ async setRTS(value) { calls.push(['rts', value]) }
113
+ }
114
+ class FakeLoader {
115
+ constructor({ transport }) { this.transport = transport; this.CHIP_DETECT_MAGIC_REG_ADDR = 0x40001000; this.ESP_CHANGE_BAUDRATE = 15; this.ESP_READ_FLASH = 0xd2; this.chip = { CHIP_NAME: loaders.length && recoverFamily ? recoverFamily : 'ESP32-S31', getChipRevision: async () => 0, postConnect: async () => calls.push('postConnect') }; loaders.push(this) }
116
+ async detectChip(mode) { calls.push(['detect', mode]); await port.open({ baudRate: 115200 }); this.transport.baudrate = 115200 }
117
+ async getSecurityInfo() { return loaders.length > 1 && recoverSecurity ? recoverSecurity : security }
118
+ async runStub() { calls.push('stub'); this.IS_STUB = stubStarts }
119
+ async readReg() { if (!this.IS_STUB || failRates.includes(this.transport.baudrate)) throw new Error('speed probe timeout'); return 123 }
120
+ async readFlashId() { return flashId }
121
+ async writeReg(...args) { calls.push(['register-write', ...args]) }
122
+ async checkCommand(description, _opcode, request) {
123
+ if (description === 'change baud rate') { calls.push(['change', new DataView(request.buffer).getUint32(0, true)]); return 0 }
124
+ const length = new DataView(request.buffer).getUint32(4, true), data = new Uint8Array(length)
125
+ const frame = bytes => new Uint8Array([0xc0, ...bytes, 0xc0])
126
+ // All-zero bytes and their digest need no SLIP escaping in this fixture.
127
+ const payload = []
128
+ for (let offset = 0; offset < length; offset += 1024) payload.push(...frame(data.slice(offset, offset + 1024)))
129
+ const md5 = romMd5(data)
130
+ const escaped = []
131
+ for (const byte of md5) { if (byte === 0xc0) escaped.push(0xdb, 0xdc); else if (byte === 0xdb) escaped.push(0xdb, 0xdd); else escaped.push(byte) }
132
+ payload.push(0xc0, ...escaped, 0xc0); this.transport.buffer = new Uint8Array(payload); reads.push(length)
133
+ return 0
134
+ }
135
+ async writeFlash(options) { calls.push(['write', options.compress, options.eraseAll, options.fileArray[0].address]); if (failWrite) throw new Error('write interrupted'); options.reportProgress(0, 10, 10) }
57
136
  }
58
- return { loader: browserRomCompatibility(raw), stub, timeout: () => timeout, reads: () => reads }
137
+ const adapter = createOfficialBrowserLoader({ ESPLoader: FakeLoader, Transport: FakeTransport }, port, {}, { onStatus: event => events.push(event) })
138
+ return { adapter, events, calls, port, loaders, opens, reads }
59
139
  }
60
- test('native USB may miss MEM_END acknowledgement but still requires OHAI', async () => {
61
- const fixture = stubFixture()
62
- assert.equal(await fixture.loader.runStub(true), fixture.stub)
63
- assert.equal(fixture.reads(), 1)
64
- await assert.rejects(stubFixture({ handshake: false }).loader.runStub(true), /OHAI required/)
65
- })
66
- test('UART timeouts and native USB non-timeout errors retain their failures', async () => {
67
- await assert.rejects(stubFixture({ native: false }).loader.runStub(true), /Timed out/)
68
- await assert.rejects(stubFixture({ error: 'Port disconnected' }).loader.runStub(true), /Port disconnected/)
69
- })
70
- test('stub launch restores temporary hooks on failure and rejects ROM fallback', async () => {
71
- const fixture = stubFixture({ handshake: false })
72
- const finish = fixture.loader.memFinish, packet = fixture.loader.readPacket
73
- await assert.rejects(fixture.loader.runStub(true))
74
- assert.equal(fixture.loader.memFinish, finish)
75
- assert.equal(fixture.loader.readPacket, packet)
76
- await assert.rejects(browserRomCompatibility({ runStub: async () => ({ IS_STUB: false }) }).runStub(true), /no supported browser flash stub/)
77
- })
78
- test('stub FLASH_BEGIN timeout scales with uncompressed erase size and preserves other commands', async () => {
79
- const fixture = stubFixture()
80
- const stub = await fixture.loader.runStub(true)
81
- await stub.checkCommand(2, [0, 0, 128, 0], 0, 3000)
82
- assert.ok(fixture.timeout() > 240000)
83
- await stub.checkCommand(3, [], 0, 1234)
84
- assert.equal(fixture.timeout(), 1234)
140
+ test('ROM UART starts at 115200, checks candidates in order and stops at the first success', async () => {
141
+ const f = officialFixture({ failRates: [2000000, 1500000] })
142
+ await f.adapter.initialize(); assert.equal(f.adapter.chipFamily, 0x3231)
143
+ const stub = await f.adapter.runStub(true)
144
+ assert.equal(stub.IS_STUB, true)
145
+ assert.equal(f.adapter.baudRate, 921600)
146
+ assert.deepEqual(f.events.filter(event => event.phase === 'baud-testing').map(event => event.baudRate), ROM_UART_BAUD_RATES.slice(0, 3))
147
+ assert.equal(f.loaders.length, 3, 'failed candidates must have fresh synchronized, qualified stubs')
148
+ assert.deepEqual(f.opens, [115200, 2000000, 115200, 1500000, 115200, 921600])
149
+ await stub.flashData(new ArrayBuffer(4), () => {}, 0x10000, false)
150
+ assert.deepEqual(f.calls.find(call => Array.isArray(call) && call[0] === 'write'), ['write', true, false, 0x10000])
151
+ await f.adapter.disconnect()
152
+ })
153
+ test('all high rates fail safely back to validated 115200', async () => {
154
+ const f = officialFixture({ failRates: ROM_UART_BAUD_RATES.slice(0, -1) })
155
+ await f.adapter.initialize(); await f.adapter.runStub()
156
+ assert.equal(f.adapter.baudRate, 115200)
157
+ assert.deepEqual(f.events.filter(event => event.phase === 'baud-testing').map(event => event.baudRate), ROM_UART_BAUD_RATES)
158
+ })
159
+ test('port reopen that resets the board does not count as an initialized high-speed stub', async () => {
160
+ const f = officialFixture({ reopenResets: true })
161
+ await f.adapter.initialize(); await f.adapter.runStub()
162
+ assert.equal(f.adapter.baudRate, 115200)
163
+ assert.equal(f.loaders.length, 5)
164
+ })
165
+ test('native USB connects directly to ready ROM without UART rate or signal changes', async () => {
166
+ const f = officialFixture({ usb: true })
167
+ await f.adapter.initialize(); await f.adapter.runStub()
168
+ assert.deepEqual(f.calls[0], ['detect', 'no_reset'])
169
+ assert.equal(f.adapter.interface, 'usb-otg'); assert.equal(f.adapter.baudRate, null)
170
+ assert.ok(!f.calls.some(call => Array.isArray(call) && ['retune', 'rts', 'dtr'].includes(call[0])))
171
+ })
172
+ test('recovery rechecks chip identity and security before another stub or write', async () => {
173
+ for (const setting of [{ recoverSecurity: { flags: 1, flashCryptCnt: 0 }, code: 'ROM_SECURITY' }, { recoverFamily: 'ESP32-S3', code: 'ROM_IDENTITY_CHANGED' }]) {
174
+ const f = officialFixture({ failRates: [2000000], ...setting })
175
+ await f.adapter.initialize()
176
+ await assert.rejects(f.adapter.runStub(), { code: setting.code })
177
+ assert.equal(f.calls.filter(call => call === 'stub').length, 1)
178
+ assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
179
+ }
180
+ })
181
+ test('write interruption invalidates the session and never retries or resumes compressed data', async () => {
182
+ const f = officialFixture({ failWrite: true })
183
+ await f.adapter.initialize(); const stub = await f.adapter.runStub()
184
+ await assert.rejects(stub.flashData(new ArrayBuffer(4), () => {}, 0, true), /write interrupted/)
185
+ await assert.rejects(stub.readFlash(0, 4), /write interrupted/)
186
+ assert.equal(f.calls.filter(call => Array.isArray(call) && call[0] === 'write').length, 1)
187
+ assert.equal(f.port.readable, null)
188
+ })
189
+ test('detached download port is reported instead of blind fallback using a stale handle', async () => {
190
+ const f = officialFixture({ detach: true })
191
+ await f.adapter.initialize()
192
+ await assert.rejects(f.adapter.runStub(), { code: 'ROM_PORT_LOST' })
193
+ assert.equal(f.loaders.length, 1)
194
+ })
195
+
196
+ test('S31 native USB reset uses the watchdog without stale-port control signals', async () => {
197
+ const f = officialFixture({ usb: true })
198
+ await f.adapter.initialize(); await f.adapter.runStub(); await f.adapter.hardReset()
199
+ assert.deepEqual(f.calls.filter(call => Array.isArray(call) && call[0] === 'register-write'), [
200
+ ['register-write', 0x20801018, 0x50d83aa1], ['register-write', 0x20801004, 2000], ['register-write', 0x20801000, 0xd0000102], ['register-write', 0x20801018, 0],
201
+ ])
202
+ assert.ok(!f.calls.some(call => Array.isArray(call) && ['rts', 'dtr'].includes(call[0])))
203
+ })
204
+
205
+ test('SPI RDID normalizes only the documented24bits while rejecting non-register values', async () => {
206
+ for (const raw of [-15187898, 0xff184046]) {
207
+ const f = officialFixture({ usb: true, flashId: raw })
208
+ await f.adapter.initialize(); const stub = await f.adapter.runStub()
209
+ assert.equal(await stub.flashId(), 0x184046)
210
+ }
211
+ const f = officialFixture({ usb: true, flashId: NaN })
212
+ await f.adapter.initialize(); const stub = await f.adapter.runStub()
213
+ await assert.rejects(stub.flashId(), { code: 'ROM_FLASH_ID' })
214
+ })
215
+
216
+ test('missing native stub handshake never creates a usable stub or starts a write', async () => {
217
+ const f = officialFixture({ usb: true, stubStarts: false })
218
+ await f.adapter.initialize()
219
+ await assert.rejects(f.adapter.runStub(), { code: 'ROM_STUB_REQUIRED' })
220
+ assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
221
+ assert.equal(f.port.readable, null)
85
222
  })
@@ -0,0 +1,143 @@
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 { serialTransport } from '../src/browser.js'
6
+
7
+ class Port extends EventTarget {
8
+ opens = []
9
+ openOptions = []
10
+ closes = 0
11
+ writes = []
12
+ lines = []
13
+ rejected = []
14
+ async open(options) {
15
+ const { baudRate } = options
16
+ this.openOptions.push(options)
17
+ this.opens.push(baudRate)
18
+ if (this.rejected.includes(baudRate)) throw new Error('unsupported speed')
19
+ assert.ok(!this.readable && !this.writable)
20
+ this.readable = new ReadableStream({ start: controller => { this.input = controller } })
21
+ this.writable = new WritableStream({ write: async bytes => {
22
+ if (this.writeGate) await this.writeGate
23
+ this.writes.push(new Uint8Array(bytes))
24
+ } })
25
+ }
26
+ async close() {
27
+ assert.ok(!this.readable?.locked, 'read lock must be released before SerialPort.close')
28
+ assert.ok(!this.writable?.locked, 'write lock must be released before SerialPort.close')
29
+ this.readable = this.writable = null
30
+ this.closes++
31
+ }
32
+ async setSignals(signals) { this.lines.push(signals) }
33
+ forget() { throw new Error('Internal retune must preserve browser authorization') }
34
+ }
35
+
36
+ test('browser runtime adapter is passive and reopens its authorized port without disconnect events', async () => {
37
+ const port = new Port(), transport = serialTransport({ port }), data = [], events = []
38
+ assert.deepEqual(port.opens, [])
39
+ const unsubscribe = transport.subscribe({ data: bytes => data.push(...bytes), close: () => events.push('close'), error: error => events.push(error.code) })
40
+ await transport.connect()
41
+ assert.deepEqual(port.opens, [115200])
42
+ port.input.enqueue(new Uint8Array([1, 2]))
43
+ await transport.setBaudRate(2000000)
44
+ port.input.enqueue(new Uint8Array([3, 4]))
45
+ await transport.writeBytes(new Uint8Array([5, 6]))
46
+ assert.deepEqual(data, [1, 2, 3, 4])
47
+ assert.deepEqual(port.opens, [115200, 2000000])
48
+ assert.deepEqual(port.openOptions, [{ baudRate: 115200, bufferSize: 256 * 1024 }, { baudRate: 2000000, bufferSize: 256 * 1024 }])
49
+ assert.equal(port.closes, 1)
50
+ assert.deepEqual(events, [])
51
+ assert.deepEqual(transport.getDiagnostics(), { baudRate: 2000000, connected: true, reopening: false })
52
+ assert.deepEqual(port.lines, [{ dataTerminalReady: true, requestToSend: false }, { dataTerminalReady: true, requestToSend: false }])
53
+ await transport.disconnect()
54
+ await transport.disconnect()
55
+ assert.equal(port.closes, 2)
56
+ assert.deepEqual(events, [])
57
+ unsubscribe()
58
+ })
59
+
60
+ test('browser retune waits for writes to finish and a rejected rate can reopen baseline', async () => {
61
+ const port = new Port(), transport = serialTransport({ port })
62
+ transport.subscribe({})
63
+ await transport.connect()
64
+ let release
65
+ port.writeGate = new Promise(resolve => { release = resolve })
66
+ const writing = transport.writeBytes(new Uint8Array([0xff, 0, 0x1e]))
67
+ const changing = transport.setBaudRate(2000000)
68
+ await new Promise(resolve => setTimeout(resolve, 1))
69
+ assert.equal(port.closes, 0)
70
+ release()
71
+ await writing
72
+ await changing
73
+ assert.deepEqual(port.writes[0], new Uint8Array([0xff, 0, 0x1e]))
74
+ port.rejected.push(1500000)
75
+ await assert.rejects(transport.setBaudRate(1500000), /unsupported speed/)
76
+ await transport.setBaudRate(115200)
77
+ assert.equal(transport.getDiagnostics().baudRate, 115200)
78
+ assert.deepEqual(port.opens, [115200, 2000000, 1500000, 115200])
79
+ await transport.disconnect()
80
+ })
81
+
82
+ test('physical browser disconnect is reported while internal reopen remains subscribed', async () => {
83
+ const port = new Port(), transport = serialTransport({ port }), events = []
84
+ transport.subscribe({ close: () => events.push('close') })
85
+ await transport.connect()
86
+ await transport.setBaudRate(921600)
87
+ assert.deepEqual(events, [])
88
+ port.dispatchEvent(new Event('disconnect'))
89
+ assert.deepEqual(events, ['close'])
90
+ await transport.disconnect()
91
+ })
92
+
93
+ test('native USB ports without modem control lines can still connect', async () => {
94
+ const port = new Port()
95
+ port.setSignals = async () => { throw Object.assign(new Error('No control lines'), { name: 'NotSupportedError' }) }
96
+ const transport = serialTransport({ port })
97
+ await transport.connect()
98
+ await transport.writeBytes(new Uint8Array([1]))
99
+ assert.deepEqual(port.writes, [new Uint8Array([1])])
100
+ await transport.disconnect()
101
+ })
102
+
103
+ test('browser cancellation releases a port that finishes opening late', async () => {
104
+ const port = new Port(), original = port.open.bind(port), transport = serialTransport({ port })
105
+ let finish
106
+ const started = new Promise(resolve => {
107
+ port.open = async options => { await new Promise(release => { finish = release; resolve() }); await original(options) }
108
+ })
109
+ const controller = new AbortController()
110
+ const connecting = transport.connect({ signal: controller.signal })
111
+ await started
112
+ controller.abort()
113
+ await assert.rejects(connecting, { name: 'AbortError' })
114
+ await transport.disconnect()
115
+ finish()
116
+ await new Promise(resolve => setTimeout(resolve, 1))
117
+ assert.equal(port.closes, 1)
118
+ assert.equal(port.readable, null)
119
+ })
120
+
121
+ test('an aborted retune cannot complete later at a stale rate after baseline recovery', async () => {
122
+ const port = new Port(), original = port.open.bind(port), transport = serialTransport({ port }), controller = new AbortController()
123
+ await transport.connect()
124
+ let finish
125
+ const started = new Promise(resolve => {
126
+ port.open = async options => {
127
+ if (options.baudRate === 2000000) await new Promise(release => { finish = release; resolve() })
128
+ await original(options)
129
+ }
130
+ })
131
+ const changing = transport.setBaudRate(2000000, { signal: controller.signal })
132
+ await started
133
+ controller.abort()
134
+ const failed = assert.rejects(changing, { name: 'AbortError' })
135
+ const recovery = transport.setBaudRate(115200)
136
+ finish()
137
+ await failed
138
+ await recovery
139
+ assert.deepEqual(port.opens, [115200, 2000000, 115200])
140
+ assert.equal(port.closes, 2, 'the stale high-rate open is closed before baseline is acquired')
141
+ assert.equal(transport.getDiagnostics().baudRate, 115200)
142
+ await transport.disconnect()
143
+ })
@@ -6,9 +6,10 @@ import * as api from '../src/index.js'
6
6
  import { DeviceSession, EV_MUX_REQUIRED_FEATURES, EV_MUX_REQUIRED_CHANNELS, EV_MUX_DEFAULT_LIMITS } from '../src/device_session.js'
7
7
  import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
8
8
  import { EvRepl } from '../src/evrepl.js'
9
+ import { jpegFixture } from './jpeg-fixtures.mjs'
9
10
 
10
11
  const capabilities = { ok: true, protocol: { evMuxVersion: 4 },
11
- features: [...EV_MUX_REQUIRED_FEATURES, 'debug.capture_frame', 'script.write', 'script.run', 'device.control'],
12
+ features: [...EV_MUX_REQUIRED_FEATURES, 'debug.capture_frame', 'debug.capture_frame.observed.v1', 'script.write', 'script.run', 'device.control'],
12
13
  channels: EV_MUX_REQUIRED_CHANNELS, limits: { ...EV_MUX_DEFAULT_LIMITS, txLargePayloadMax: 262144 },
13
14
  session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 } }
14
15
  function peer({ silent = false, screen = false, vmReady = true, interactiveScripts = false } = {}) {
@@ -44,7 +45,7 @@ function peer({ silent = false, screen = false, vmReady = true, interactiveScrip
44
45
  if (metadata.method === 'capabilities') result = { ...capabilities, features: [...capabilities.features, ...(screen ? ['debug.capture_screen'] : [])] }
45
46
  if (metadata.method === 'session.keepalive') result = { ok: true, sessionId: id, vmEpoch: 1 }
46
47
  if (metadata.method === 'debug.capture_frame' || (screen && metadata.method === 'debug.capture_screen')) {
47
- 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
48
+ handlers.data(makeEvMuxFrame({ channel: 'debug.rpc', type: 'rsp', method: metadata.method, seq: metadata.seq, sessionId: id, vmEpoch: 1, contentType: 'image/jpeg', encoding: 'binary', width: 2, height: 1, payload: jpegFixture().data })); return false
48
49
  }
49
50
  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) }))
50
51
  return false
@@ -86,6 +87,38 @@ test('facade subscribes before connection, negotiates, streams input and dispose
86
87
  await assert.rejects(d.files.read({path:'/x'}), {code:'DEVICE_NOT_READY'})
87
88
  })
88
89
 
90
+ test('hard reset disposes an open transport without releasing its expired lease and permits reconnect', async () => {
91
+ const p = peer(), d = api.createDevice({ transport: p.transport })
92
+ try {
93
+ await d.connect({ role: 'tcp', probeRepl: false })
94
+ assert.deepEqual(await d.control({ action: 'reset', mode: 'hard' }), { ok: true })
95
+ assert.equal(p.disconnects, 0, 'the transport remains open after the reset ACK')
96
+ await d.disconnect()
97
+ assert.equal(p.disconnects, 1)
98
+ assert.equal(p.subscriptions, 0)
99
+ assert.equal(p.requests.filter(request => request.metadata.method === 'session.release').length, 0)
100
+ await d.connect({ role: 'tcp', probeRepl: false })
101
+ await d.disconnect()
102
+ assert.equal(p.disconnects, 2)
103
+ assert.equal(p.requests.filter(request => request.metadata.method === 'session.release').length, 1,
104
+ 'normal disconnect still releases the new session')
105
+ } finally { await d.disconnect() }
106
+ })
107
+
108
+ test('bootloader submission survives facade cleanup without releasing the obsolete lease', async () => {
109
+ const p = peer(), d = api.createDevice({ transport: p.transport })
110
+ try {
111
+ await d.connect({ role: 'tcp', probeRepl: false })
112
+ assert.deepEqual(await d.control({ action: 'bootloader' }), {
113
+ ok: true, operation: 'bootloader', acknowledged: false, outcome: 'unknown',
114
+ })
115
+ await d.disconnect()
116
+ assert.equal(p.disconnects, 1)
117
+ assert.equal(p.subscriptions, 0)
118
+ assert.equal(p.requests.filter(r => r.metadata.method === 'session.release').length, 0)
119
+ } finally { await d.disconnect() }
120
+ })
121
+
89
122
  test('connect can acquire and inspect a session without interrupting the REPL', async () => {
90
123
  const p = peer({ vmReady: false }), d = api.createDevice({ transport: p.transport })
91
124
  try {
@@ -113,7 +146,7 @@ test('capture returns binary bytes and unsupported LCD capture never captures th
113
146
  try {
114
147
  await d.connect({role:'tcp'})
115
148
  const image = await d.capture({source:'camera',quality:70})
116
- assert.deepEqual(image.data,new Uint8Array([255,216,0,255,217]))
149
+ assert.deepEqual(image.data,jpegFixture().data)
117
150
  assert.equal(image.mimeType,'image/jpeg')
118
151
  const count=p.requests.length
119
152
  await assert.rejects(d.capture({source:'screen'}),{code:'UNSUPPORTED_OPERATION'})
@@ -200,13 +233,13 @@ test('binary capture results correlate by sequence even when responses arrive in
200
233
  let s;const requests=[]
201
234
  const parser=new EvMuxParser({filterFrame(metadata){requests.push(metadata);return false}})
202
235
  s=new DeviceSession({transport:{async writeBytes(b){parser.feedBytes(b)}},EvMuxParser,EvRepl,makeEvMuxFrame})
203
- s.state='ready';s.sessionId='a'.repeat(32);s.vmEpoch=1;s.vmReady=true;s.capabilities={features:[]}
236
+ s.state='ready';s.sessionId='a'.repeat(32);s.vmEpoch=1;s.vmReady=true;s.capabilities={features:['debug.capture_frame', 'debug.capture_frame.observed.v1']}
204
237
  try {
205
238
  const first=s.captureImage('debug.capture_frame'),second=s.captureImage('debug.capture_frame')
206
239
  first.catch(()=>{});second.catch(()=>{})
207
240
  await new Promise(setImmediate)
208
- 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])}))}
209
- assert.deepEqual((await first).data,new Uint8Array([11]));assert.deepEqual((await second).data,new Uint8Array([22]))
241
+ for(const [index,name] of [[1,'alternate'],[0,'tiny']]){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',encoding:'binary',width:2,height:1,payload:jpegFixture(name).data}))}
242
+ assert.deepEqual((await first).data,jpegFixture().data);assert.deepEqual((await second).data,jpegFixture('alternate').data)
210
243
  }finally{s.close()}
211
244
  })
212
245
 
@@ -251,7 +284,7 @@ test('LCD capture uses its advertised screen method and preserves dimensions and
251
284
  assert.equal(image.mimeType, 'image/jpeg')
252
285
  assert.equal(image.metadata.width, 2)
253
286
  assert.equal(image.metadata.height, 1)
254
- assert.deepEqual(image.data, new Uint8Array([255,216,0,255,217]))
287
+ assert.deepEqual(image.data, jpegFixture().data)
255
288
  const request = p.requests.find(x => x.metadata.method === 'debug.capture_screen')
256
289
  assert.deepEqual(request.payload, { quality: 85 })
257
290
  assert.equal(p.requests.some(x => x.metadata.method === 'debug.capture_frame'), false)
@@ -341,18 +341,45 @@ test('failed stop does not claim execution has finished', async () => {
341
341
  fixture.session.close()
342
342
  })
343
343
 
344
- test('bounded Agent execution still times out and sends a shared interrupt', async () => {
344
+ test('bounded Agent execution still times out and sends a shared interrupt', async (t) => {
345
+ t.mock.timers.enable({ apis: ['setTimeout'] })
345
346
  const fixture = executionFixture()
347
+ t.after(() => fixture.session.close())
348
+ // Isolate the running-script deadline from the separately covered upload
349
+ // and REPL setup. A real 30 ms startup budget races CI scheduling.
350
+ fixture.session.stopForRepl = async () => true
351
+ fixture.session.writeScript = async () => {}
346
352
  let started
347
353
  const starting = new Promise(resolve => { started = resolve })
348
354
  const done = fixture.session.runScript('/trial.py', 'while True: pass', { timeout: 30, onStarted: started })
349
355
  const rejected = assert.rejects(done, (error) => error.code === 'SCRIPT_TIMEOUT')
350
356
  await starting
357
+ t.mock.timers.tick(30)
351
358
  await rejected
352
359
  assert.equal(fixture.session.scriptRunSnapshot(), null)
353
- await new Promise(resolve => setTimeout(resolve, 0))
360
+ await new Promise(resolve => setImmediate(resolve))
354
361
  assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'))
355
- fixture.session.close()
362
+ })
363
+
364
+ test('bounded start handles reject when the deadline precedes VM entry', async (t) => {
365
+ t.mock.timers.enable({ apis: ['setTimeout'] })
366
+ const fixture = executionFixture({ deferStart: true })
367
+ t.after(() => fixture.session.close())
368
+ fixture.session.stopForRepl = async () => true
369
+ fixture.session.writeScript = async () => {}
370
+ let started = false
371
+ const starting = fixture.session.startScript('/trial.py', undefined, {
372
+ inlineCompletion: true,
373
+ timeout: 30,
374
+ onStarted() { started = true },
375
+ })
376
+ const rejected = assert.rejects(starting, { code: 'SCRIPT_TIMEOUT' })
377
+ await new Promise(resolve => setImmediate(resolve))
378
+ t.mock.timers.tick(30)
379
+ await rejected
380
+ assert.equal(started, false)
381
+ assert.equal(fixture.session.scriptRunSnapshot(), null)
382
+ assert.equal(fixture.session.runStarting, false)
356
383
  })
357
384
 
358
385
  test('VM reset and disconnect settle interactive completion and allow the next VM run', async () => {
@@ -7,6 +7,7 @@ import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
7
7
  import { tmpdir } from 'node:os'
8
8
  import { parseArgs } from './hardware-check.mjs'
9
9
  import { DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
10
+ import { jpegFixture } from './jpeg-fixtures.mjs'
10
11
 
11
12
  function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {}) {
12
13
  const files = new Map(), calls = [], listeners = new Map()
@@ -30,7 +31,7 @@ function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {})
30
31
  async capture({ source }) {
31
32
  calls.push(['capture', source])
32
33
  if (source === 'screen' && screenFails) throw Object.assign(new Error('Not supported'), { code: 'UNSUPPORTED_OPERATION' })
33
- return { data: new Uint8Array([255, 216, 0, 255, 217]), mimeType: 'image/jpeg', metadata: { width: 240, height: 320 } }
34
+ return { data: jpegFixture('screen').data, mimeType: 'image/jpeg', metadata: { width: 240, height: 320 } }
34
35
  },
35
36
  }
36
37
  }
@@ -90,7 +91,7 @@ test('screen JPEG is captured while display fixture is alive and saved on host',
90
91
  const output = join(directory, 'screen.jpg'), device = devicePeer()
91
92
  try {
92
93
  await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'display', '--screen-output', output]), { nonce: 'abc123', report() {} })
93
- assert.deepEqual(await readFile(output), Buffer.from([255, 216, 0, 255, 217]))
94
+ assert.deepEqual(await readFile(output), Buffer.from(jpegFixture('screen').data))
94
95
  const captureAt = device.calls.findIndex(call => call[0] === 'capture' && call[1] === 'screen')
95
96
  const closeAt = device.calls.findIndex(call => call[0] === 'execute' && call[1].includes("['close_display']()"))
96
97
  assert.ok(captureAt > 0 && closeAt > captureAt)
@@ -98,6 +99,15 @@ test('screen JPEG is captured while display fixture is alive and saved on host',
98
99
  } finally { await rm(directory, { recursive: true, force: true }) }
99
100
  })
100
101
 
102
+ test('camera acceptance explicitly publishes an application snapshot before passive observation', async () => {
103
+ const device = devicePeer()
104
+ await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'camera']), { nonce: 'abc123', report() {} })
105
+ const published = device.calls.findIndex(call => call[0] === 'execute' && call[1].includes("['run_check'](\"camera\""))
106
+ const observed = device.calls.findIndex(call => call[0] === 'capture' && call[1] === 'camera')
107
+ assert.ok(published > 0 && observed > published)
108
+ assert.equal(device.files.size, 0)
109
+ })
110
+
101
111
  test('truncated JPEG and missing dimensions are refused before host artifact is written', async () => {
102
112
  await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216]), mimeType: 'image/jpeg', metadata: { width: 1, height: 1 } }), { code: 'CAPTURE_INVALID' })
103
113
  await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216, 255, 217]), mimeType: 'image/jpeg' }), { code: 'CAPTURE_INVALID' })