@esp-vision/device-core 0.5.1 → 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 (46) hide show
  1. package/CHANGELOG.md +25 -0
  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-functional-checks.mjs +12 -2
  36. package/tests/test-device-operations-api.mjs +4 -3
  37. package/tests/test-device-reset.mjs +292 -0
  38. package/tests/test-firmware-flash.mjs +1 -1
  39. package/tests/test-jpeg-validation.mjs +233 -0
  40. package/tests/test-node-rom-port.mjs +305 -0
  41. package/tests/test-node-rom.mjs +160 -0
  42. package/tests/test-node-serial.mjs +206 -0
  43. package/tests/test-npm-release-policy.mjs +62 -0
  44. package/tests/test-observed-capture.mjs +131 -0
  45. package/tests/test-uart-baud.mjs +180 -0
  46. package/tests/test-version-policy.mjs +37 -0
@@ -0,0 +1,305 @@
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 { prepareNodeRomPort, recoverNodeRomPort } from '../src/node_rom_port.js'
6
+
7
+ const MAC = 'e8f60ae75642'
8
+ const native = (path = '/dev/ttyACM0', extra = {}) => ({ path, vendorId: 0x303a, productId: 0x4003, serialNumber: MAC, location: '1-2:1.0', ...extra })
9
+ const rom = (path = '/dev/ttyACM1', extra = {}) => native(path, { productId: 18, location: '1-2', ...extra })
10
+ const failure = (code, message = code) => Object.assign(new Error(message), { code })
11
+ function fixture(options = {}) {
12
+ let clock = 0, enumerations = 0
13
+ const calls = { connect: [], control: [], python: [], disconnect: 0, creates: 0, realpath: [], enumerate: [] }
14
+ const controller = options.controller ?? new AbortController()
15
+ const connected = { identity: { deviceId: MAC }, capabilities: { sinks: [
16
+ { id: 'usb', kind: 'usb_otg_cdc', owner: true, state: 'active', ready: true },
17
+ ] }, ...options.connected }
18
+ const device = {
19
+ async connect(request) { calls.connect.push(request); if (options.connectError) throw options.connectError; return connected },
20
+ python: { async execute(request) { calls.python.push(request); return options.pythonResult ?? { ok: true, outputTruncated: false, output: `__EV_ROM_ID__${MAC}\n` } } },
21
+ async control(request) {
22
+ calls.control.push(request)
23
+ if (options.control) return options.control(controller)
24
+ if (options.controlError) throw options.controlError
25
+ return { ok: true, operation: 'bootloader', acknowledged: false, outcome: 'unknown' }
26
+ },
27
+ async disconnect() { calls.disconnect += 1; if (options.cleanupError) throw options.cleanupError },
28
+ }
29
+ const snapshots = options.snapshots ?? [[native()], [rom()]]
30
+ const dependencies = {
31
+ now: () => clock,
32
+ async sleep(ms, signal) { clock += ms; if (options.onSleep) options.onSleep(controller, clock); signal?.throwIfAborted() },
33
+ async realpath(path) { calls.realpath.push(path); return options.realpath ? options.realpath(path, clock) : path },
34
+ async listSerial(request) {
35
+ calls.enumerate.push(request)
36
+ if (options.onEnumerate) options.onEnumerate(controller, ++enumerations)
37
+ if (options.enumerationError) throw options.enumerationError
38
+ if (options.enumerationDelay) clock += options.enumerationDelay
39
+ return snapshots[Math.min(enumerations++ , snapshots.length - 1)]
40
+ },
41
+ serialTransport(request) { return request },
42
+ createDevice(request) { calls.creates += 1; calls.transport = request.transport; return device },
43
+ }
44
+ const run = (overrides = {}) => prepareNodeRomPort({ port: '/dev/ttyACM0', python: 'selected-python', timeout: 200, signal: controller.signal, ...overrides }, dependencies)
45
+ const recover = (previous, overrides = {}) => recoverNodeRomPort(previous, { python: 'selected-python', timeout: 200, signal: controller.signal, ...overrides }, dependencies)
46
+ return { calls, run, recover, controller, now: () => clock }
47
+ }
48
+
49
+ test('imports are passive; ROM, USJ, UART and unknown selections never create an EV-MUX session', async () => {
50
+ for (const descriptor of [rom('/dev/ttyACM0'), native('/dev/ttyACM0', { productId: 0x1001 }),
51
+ native('/dev/ttyACM0', { vendorId: 0x10c4, productId: 0xea60 }), native('/dev/ttyACM0', { vendorId: null, productId: null })]) {
52
+ const f = fixture({ snapshots: [[descriptor]] })
53
+ const result = await f.run()
54
+ assert.equal(result.port, '/dev/ttyACM0')
55
+ assert.equal(f.calls.creates, 0)
56
+ assert.equal(f.calls.control.length, 0)
57
+ assert.equal(result.expectedMac, descriptor.productId === 18 ? MAC : null)
58
+ }
59
+ })
60
+
61
+ test('a missing exact by-id path may settle, then existing ROM uses its canonical OS path', async () => {
62
+ const selected = '/dev/serial/by-id/exact-selected'
63
+ const f = fixture({ realpath: (_, time) => { if (time < 100) throw failure('ENOENT'); return '/dev/ttyACM1' }, snapshots: [[rom()]] })
64
+ const result = await f.run({ port: selected })
65
+ assert.equal(result.port, '/dev/ttyACM1')
66
+ assert.equal(result.expectedMac, MAC)
67
+ assert.equal(result.transition.requested, false)
68
+ assert.equal(result.transition.location, '1-2')
69
+ assert.equal(f.calls.creates, 0)
70
+ assert.ok(f.calls.realpath.every(path => path === selected))
71
+ })
72
+
73
+ test('a vanished selected by-id never authorizes filename-based selection of another board', async () => {
74
+ const f = fixture({ realpath: () => { throw failure('ENOENT') }, snapshots: [[rom('/dev/ttyACM8')]] })
75
+ await assert.rejects(f.run({ port: `/dev/serial/by-id/usb-${MAC}` }), { code: 'ROM_PORT_LOST' })
76
+ assert.equal(f.calls.creates, 0)
77
+ assert.equal(f.calls.enumerate.length, 0)
78
+ assert.equal(f.now(), 200)
79
+ })
80
+
81
+ test('verified native runtime transitions once and binds only matching ROM serial plus location', async () => {
82
+ const f = fixture()
83
+ const result = await f.run()
84
+ assert.equal(result.port, '/dev/ttyACM1')
85
+ assert.equal(result.expectedMac, MAC)
86
+ assert.deepEqual(result.transition.command, { attempted: true, acknowledged: false, outcome: 'unknown' })
87
+ assert.equal(result.transition.observed, 'rom')
88
+ assert.equal(result.transition.cleanup.ok, true)
89
+ assert.equal(f.calls.connect.length, 1)
90
+ assert.deepEqual(f.calls.transport, { serial: '/dev/ttyACM0', role: 'usb', python: 'selected-python' })
91
+ assert.equal(f.calls.connect[0].probeRepl, false)
92
+ assert.equal(f.calls.connect[0].autoBaud, false)
93
+ assert.equal(f.calls.connect[0].role, 'usb')
94
+ assert.equal(f.calls.control.length, 1)
95
+ assert.equal(f.calls.disconnect, 1)
96
+ assert.equal(f.calls.python.length, 0)
97
+ })
98
+
99
+ test('legacy null deviceId uses one small public Python unique_id probe before bootloader', async () => {
100
+ const f = fixture({ connected: { identity: { deviceId: null } } })
101
+ const result = await f.run()
102
+ assert.equal(result.transition.identitySource, 'machine.unique_id')
103
+ assert.equal(f.calls.python.length, 1)
104
+ assert.ok(Buffer.byteLength(f.calls.python[0].source) < 511)
105
+ assert.equal(f.calls.python[0].completion, 'wait')
106
+ assert.equal(f.calls.control.length, 1)
107
+ })
108
+
109
+ test('wrong or malformed non-null identity never falls back to Python or resets', async () => {
110
+ for (const id of ['e8f60ae75653', 'unknown']) {
111
+ const f = fixture({ connected: { identity: { deviceId: id } } })
112
+ await assert.rejects(f.run(), { code: 'ROM_RUNTIME_IDENTITY_MISMATCH' })
113
+ assert.equal(f.calls.python.length, 0)
114
+ assert.equal(f.calls.control.length, 0)
115
+ assert.equal(f.calls.disconnect, 1)
116
+ }
117
+ })
118
+
119
+ test('incomplete legacy unique_id output cannot authorize reset', async () => {
120
+ for (const result of [{ ok: true, outputTruncated: true, output: `__EV_ROM_ID__${MAC}\n` },
121
+ { ok: true, outputTruncated: false, output: `__EV_ROM_ID__${MAC}\n__EV_ROM_ID__${MAC}\n` },
122
+ { ok: true, outputTruncated: false, output: 'Traceback\n' }]) {
123
+ const f = fixture({ connected: { identity: { deviceId: null } }, pythonResult: result })
124
+ await assert.rejects(f.run(), { code: 'ROM_RUNTIME_IDENTITY_UNAVAILABLE' })
125
+ assert.equal(f.calls.control.length, 0)
126
+ assert.equal(f.calls.disconnect, 1)
127
+ }
128
+ })
129
+
130
+ test('missing serial or location and ambiguous selected descriptors fail without opening a device', async () => {
131
+ for (const extra of [{ serialNumber: null }, { serialNumber: 'non-MAC' }, { location: null }]) {
132
+ const f = fixture({ snapshots: [[native('/dev/ttyACM0', extra)]] })
133
+ await assert.rejects(f.run(), { code: 'ROM_PORT_IDENTITY_REQUIRED' })
134
+ assert.equal(f.calls.creates, 0)
135
+ }
136
+ const f = fixture({ snapshots: [[native(), native()]] })
137
+ await assert.rejects(f.run(), { code: 'ROM_PORT_AMBIGUOUS' })
138
+ assert.equal(f.calls.creates, 0)
139
+ })
140
+
141
+ test('an inactive USB owner or different owner cannot authorize bootloader', async () => {
142
+ for (const sinks of [[{ id: 'usb', kind: 'usb_otg_cdc', owner: true, state: 'idle', ready: true }],
143
+ [{ id: 'usj', kind: 'usb_serial_jtag', owner: true, state: 'active', ready: true }]]) {
144
+ const f = fixture({ connected: { capabilities: { sinks } } })
145
+ await assert.rejects(f.run(), { code: 'ROM_RUNTIME_INTERFACE_MISMATCH' })
146
+ assert.equal(f.calls.control.length, 0)
147
+ assert.equal(f.calls.disconnect, 1)
148
+ }
149
+ })
150
+
151
+ test('other MACs or physical locations never replace the selected device', async () => {
152
+ const f = fixture({ snapshots: [[native()], [rom('/dev/ttyACM1', { serialNumber: 'e8f60ae75653' }), rom('/dev/ttyACM2', { location: '1-3' })]] })
153
+ await assert.rejects(f.run(), { code: 'ROM_PORT_REBIND_TIMEOUT' })
154
+ assert.equal(f.calls.creates, 1)
155
+ assert.equal(f.calls.control.length, 1)
156
+ assert.equal(f.calls.disconnect, 1)
157
+ assert.equal(f.now(), 200)
158
+ })
159
+
160
+ test('two matching ROM interfaces are ambiguous and never selected', async () => {
161
+ const f = fixture({ snapshots: [[native()], [rom('/dev/ttyACM1'), rom('/dev/ttyACM2')]] })
162
+ await assert.rejects(f.run(), { code: 'ROM_PORT_AMBIGUOUS' })
163
+ assert.equal(f.calls.control.length, 1)
164
+ })
165
+
166
+ test('unknown command outcome and cleanup failure remain separate from observed ROM', async () => {
167
+ const controlError = Object.assign(failure('TIMEOUT'), { reset: { outcome: 'unknown', acknowledged: false } })
168
+ const f = fixture({ controlError, cleanupError: failure('SESSION_RELEASE_TIMEOUT') })
169
+ const result = await f.run()
170
+ assert.equal(result.transition.observed, 'rom')
171
+ assert.equal(result.transition.command.acknowledged, false)
172
+ assert.equal(result.transition.command.outcome, 'unknown')
173
+ assert.equal(result.transition.command.error.code, 'TIMEOUT')
174
+ assert.equal(result.transition.cleanup.ok, false)
175
+ assert.equal(result.transition.cleanup.error.code, 'SESSION_RELEASE_TIMEOUT')
176
+ assert.equal(f.calls.control.length, 1)
177
+ })
178
+
179
+ test('ordinary handshake or command errors are returned without bootloader retry', async () => {
180
+ const error = failure('SESSION_BUSY')
181
+ const f = fixture({ connectError: error })
182
+ await assert.rejects(f.run(), value => value === error)
183
+ assert.equal(f.calls.control.length, 0)
184
+ assert.equal(f.calls.disconnect, 1)
185
+ const other = fixture({ controlError: failure('TIMEOUT') })
186
+ await assert.rejects(other.run(), { code: 'TIMEOUT' })
187
+ assert.equal(other.calls.control.length, 1)
188
+ assert.equal(other.calls.enumerate.length, 1)
189
+ })
190
+
191
+ test('Windows COM aliases and pyserial interface suffixes bind the same physical USB port', async () => {
192
+ const f = fixture({ snapshots: [[native('COM8', { location: '1-5:x.0' })], [rom('COM9', { location: '1-5' })]] })
193
+ const result = await f.run({ port: '\\\\.\\com8' })
194
+ assert.equal(result.port, 'COM9')
195
+ assert.equal(result.expectedMac, MAC)
196
+ assert.equal(f.calls.realpath.length, 0)
197
+ })
198
+
199
+ test('enumeration finishing after the deadline does not accept a late ROM match', async () => {
200
+ const f = fixture({ enumerationDelay: 250 })
201
+ await assert.rejects(f.run(), { code: 'ROM_PORT_REBIND_TIMEOUT' })
202
+ assert.equal(f.calls.control.length, 1)
203
+ })
204
+
205
+ test('cancel before entry, during command, and during rebind never replays control', async () => {
206
+ const reason = Object.assign(new Error('cancelled'), { name: 'AbortError' })
207
+ const before = fixture(); before.controller.abort(reason)
208
+ await assert.rejects(before.run(), value => value === reason)
209
+ assert.equal(before.calls.creates, 0)
210
+ const during = fixture({ control(controller) { controller.abort(reason); throw reason } })
211
+ await assert.rejects(during.run(), value => value === reason)
212
+ assert.equal(during.calls.control.length, 1)
213
+ assert.equal(during.calls.disconnect, 1)
214
+ const poll = fixture({ snapshots: [[native()], []], onSleep(controller) { controller.abort(reason) } })
215
+ await assert.rejects(poll.run(), value => value === reason)
216
+ assert.equal(poll.calls.control.length, 1)
217
+ assert.equal(poll.calls.disconnect, 1)
218
+ })
219
+
220
+ test('unavailable descriptors leave the exact canonical selection untouched and unprobed', async () => {
221
+ const f = fixture({ enumerationError: failure('SERIAL_UNAVAILABLE'), realpath: () => '/dev/ttyACM7' })
222
+ const result = await f.run({ port: '/dev/serial/by-id/exact' })
223
+ assert.deepEqual(result, { port: '/dev/ttyACM7', expectedMac: null, transition: null })
224
+ assert.equal(f.calls.creates, 0)
225
+ })
226
+
227
+
228
+ const previousRom = (extra = {}) => ({ port: '/dev/ttyACM0', expectedMac: MAC,
229
+ transition: { observed: 'rom', requested: false, serial: MAC, location: '1-2' }, ...extra })
230
+
231
+ test('confirmed UVC runtime PID4023 uses the same identity-verified one-shot handoff', async () => {
232
+ const f = fixture({ snapshots: [[native('/dev/ttyACM0', { productId: 0x4023 })], [rom()]] })
233
+ const result = await f.run()
234
+ assert.equal(result.expectedMac, MAC)
235
+ assert.equal(result.transition.observed, 'rom')
236
+ assert.equal(f.calls.control.length, 1)
237
+ const other = fixture({ snapshots: [[native('/dev/ttyACM0', { productId: 0x4021 })]] })
238
+ assert.equal((await other.run()).transition, null)
239
+ assert.equal(other.calls.creates, 0)
240
+ })
241
+
242
+ test('read-only initialization recovery verifies same-board runtime and reuses production handoff once', async () => {
243
+ for (const productId of [0x4003, 0x4023]) {
244
+ const runtime = native('/dev/ttyACM2', { productId })
245
+ const f = fixture({ snapshots: [[runtime], [runtime], [rom('/dev/ttyACM3')]] })
246
+ const result = await f.recover(previousRom())
247
+ assert.equal(result.port, '/dev/ttyACM3')
248
+ assert.equal(result.expectedMac, MAC)
249
+ assert.deepEqual(result.transition.recovery, { previousPort: '/dev/ttyACM0', runtimePid: productId })
250
+ assert.equal(f.calls.transport.serial, '/dev/ttyACM2')
251
+ assert.equal(f.calls.control.length, 1)
252
+ assert.equal(f.calls.disconnect, 1)
253
+ }
254
+ })
255
+
256
+ test('recovery never repeats a previous active handoff or uses an incomplete previous binding', async () => {
257
+ for (const previous of [previousRom({ expectedMac: 'unknown' }), previousRom({ transition: null }),
258
+ previousRom({ transition: { observed: 'rom', requested: true, serial: MAC, location: '1-2' } }),
259
+ previousRom({ transition: { observed: 'rom', requested: false, serial: 'e8f60ae75653', location: '1-2' } }),
260
+ previousRom({ transition: { observed: 'rom', requested: false, serial: MAC, location: null } })]) {
261
+ const f = fixture()
262
+ assert.equal(await f.recover(previous), null)
263
+ assert.equal(f.calls.enumerate.length, 0)
264
+ assert.equal(f.calls.creates, 0)
265
+ }
266
+ })
267
+
268
+ test('same ROM or no runtime returns null without issuing a bootloader command', async () => {
269
+ for (const snapshot of [[rom('/dev/ttyACM1')], [], [native('/dev/ttyACM8', { serialNumber: 'e8f60ae75653', location: '2-3' })]]) {
270
+ const f = fixture({ snapshots: [snapshot] })
271
+ assert.equal(await f.recover(previousRom()), null)
272
+ assert.equal(f.calls.creates, 0)
273
+ assert.equal(f.calls.control.length, 0)
274
+ }
275
+ })
276
+
277
+ test('recovery rejects wrong MAC, moved location and ambiguous matching runtimes', async () => {
278
+ for (const runtime of [native('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' }), native('/dev/ttyACM2', { location: '2-3' })]) {
279
+ const f = fixture({ snapshots: [[runtime]] })
280
+ await assert.rejects(f.recover(previousRom()), { code: 'ROM_RUNTIME_IDENTITY_MISMATCH' })
281
+ assert.equal(f.calls.creates, 0)
282
+ }
283
+ const f = fixture({ snapshots: [[native('/dev/ttyACM2'), native('/dev/ttyACM3')]] })
284
+ await assert.rejects(f.recover(previousRom()), { code: 'ROM_PORT_AMBIGUOUS' })
285
+ assert.equal(f.calls.creates, 0)
286
+ })
287
+
288
+ test('recovery rechecks identity before connecting when a selected OS path was reused', async () => {
289
+ const correct = native('/dev/ttyACM2')
290
+ const wrong = native('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' })
291
+ const f = fixture({ snapshots: [[correct], [wrong]] })
292
+ await assert.rejects(f.recover(previousRom()), { code: 'ROM_RUNTIME_IDENTITY_MISMATCH' })
293
+ assert.equal(f.calls.creates, 0)
294
+ assert.equal(f.calls.control.length, 0)
295
+ })
296
+
297
+ test('read-only recovery honors cancel and fresh-enumeration deadline', async () => {
298
+ const reason = Object.assign(new Error('cancelled'), { name: 'AbortError' })
299
+ const cancelled = fixture(); cancelled.controller.abort(reason)
300
+ await assert.rejects(cancelled.recover(previousRom()), value => value === reason)
301
+ assert.equal(cancelled.calls.creates, 0)
302
+ const late = fixture({ snapshots: [[native()]], enumerationDelay: 250 })
303
+ await assert.rejects(late.recover(previousRom()), { code: 'ROM_PORT_REBIND_TIMEOUT' })
304
+ assert.equal(late.calls.creates, 0)
305
+ })
@@ -0,0 +1,160 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { EventEmitter } from 'node:events'
6
+ import { PassThrough, Writable } from 'node:stream'
7
+ import { openNodeRom } from '../src/node_rom.js'
8
+
9
+ function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeError } = {}) {
10
+ const calls = [], requests = []
11
+ const child = new EventEmitter()
12
+ child.exitCode = null
13
+ child.stdout = new PassThrough()
14
+ child.stdin = new Writable({
15
+ write(data, encoding, done) {
16
+ const request = JSON.parse(data.toString())
17
+ requests.push(request)
18
+ queueMicrotask(() => child.stdout.write(JSON.stringify(request.method === 'initialize' && initializeError
19
+ ? { id: request.id, error: 'ROM_OPERATION_FAILED', method: 'initialize', detail: initializeError }
20
+ : { id: request.id, result: request.method === 'initialize'
21
+ ? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' } : null }) + '\n'))
22
+ done()
23
+ },
24
+ final(done) { child.exitCode = 0; queueMicrotask(() => child.emit('exit', 0)); done() },
25
+ })
26
+ child.kill = () => { child.exitCode = 1; child.emit('exit', 1) }
27
+ const selected = { port: '/dev/rebound-rom', expectedMac: expected, transition: { requested: true } }
28
+ const dependencies = {
29
+ async preparePort(options) { calls.push(['prepare', options]); return prepare ? prepare(options) : selected },
30
+ spawn(...args) { calls.push(['spawn', ...args]); return child },
31
+ }
32
+ return { calls, requests, child, selected, dependencies }
33
+ }
34
+
35
+ test('Node ROM process uses the qualified rebound port and verifies its actual MAC before writes', async () => {
36
+ const f = fixture(), loader = openNodeRom({ port: '/dev/runtime', python: 'test-python' }, f.dependencies)
37
+ try {
38
+ await loader.initialize()
39
+ assert.equal(f.calls[1][2][2], '/dev/rebound-rom')
40
+ assert.equal(loader.port, '/dev/rebound-rom')
41
+ assert.equal(loader.chipMac, 'e8f60ae75642')
42
+ assert.equal(loader.connectionMode, 'no-reset')
43
+ assert.deepEqual(loader.usbTransition, { requested: true })
44
+ await loader.flashData(new Uint8Array([1, 2]), () => {}, 65536)
45
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'write'])
46
+ } finally { await loader.disconnect() }
47
+ })
48
+
49
+ test('a ROM MAC mismatch closes the process and cannot be followed by a write', async () => {
50
+ const f = fixture({ expected: '001122334455' }), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
51
+ await assert.rejects(loader.initialize(), { code: 'ROM_IDENTITY_CHANGED' })
52
+ await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 65536), { code: 'ROM_CLOSED' })
53
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize'])
54
+ assert.equal(f.child.exitCode, 0)
55
+ })
56
+
57
+ test('initialization is single use and never repeats a bootloader handoff', async () => {
58
+ const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
59
+ try {
60
+ await loader.initialize()
61
+ await assert.rejects(loader.initialize(), { code: 'ROM_ALREADY_INITIALIZED' })
62
+ assert.equal(f.calls.filter(c => c[0] === 'prepare').length, 1)
63
+ assert.equal(f.calls.filter(c => c[0] === 'spawn').length, 1)
64
+ } finally { await loader.disconnect() }
65
+ })
66
+
67
+ test('flash operations before initialization fail without spawning or sending a process request', async () => {
68
+ const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
69
+ await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 65536), { code: 'ROM_NOT_INITIALIZED' })
70
+ assert.equal(f.calls.length, 0)
71
+ assert.equal(f.requests.length, 0)
72
+ await loader.disconnect()
73
+ })
74
+
75
+ test('disconnect cancels a pending port handoff before any ROM process can start', async () => {
76
+ const f = fixture({ prepare: ({ signal }) => new Promise((resolve, reject) => {
77
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true })
78
+ }) })
79
+ const loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
80
+ const initializing = loader.initialize()
81
+ const rejected = assert.rejects(initializing, { code: 'ROM_CLOSED' })
82
+ await loader.disconnect()
83
+ await rejected
84
+ assert.equal(f.calls.filter(c => c[0] === 'spawn').length, 0)
85
+ })
86
+
87
+ test('handoff failure retains its cause and cannot replay on the same loader', async () => {
88
+ const reason = Object.assign(new Error('ROM did not re-enumerate'), { code: 'ROM_PORT_LOST' })
89
+ const f = fixture({ prepare: () => { throw reason } }), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
90
+ await assert.rejects(loader.initialize(), error => error === reason)
91
+ await assert.rejects(loader.initialize(), { code: 'ROM_CLOSED' })
92
+ assert.equal(f.calls.length, 1)
93
+ })
94
+
95
+ test('underlying ROM initialization diagnostics survive cleanup and prevent writes', async () => {
96
+ const f = fixture({ initializeError: 'No serial data received' }), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
97
+ await assert.rejects(loader.initialize(), error => error.code === 'ROM_OPERATION_FAILED'
98
+ && error.operation === 'initialize' && error.cause === 'No serial data received')
99
+ await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 65536), { code: 'ROM_CLOSED' })
100
+ assert.equal(f.requests.length, 1)
101
+ })
102
+
103
+ test('one read-only ROM sync failure may hand off the verified same runtime before any write', async () => {
104
+ const first = fixture({ initializeError: 'No serial data received' }), second = fixture()
105
+ const oldPort = { port: '/dev/rom', expectedMac: 'e8f60ae75642', transition: { observed: 'rom', requested: false } }
106
+ const rebound = { port: '/dev/rebound', expectedMac: oldPort.expectedMac, transition: { observed: 'rom', requested: true } }
107
+ let starts = 0, recoveries = 0
108
+ const loader = openNodeRom({ port: '/dev/rom' }, {
109
+ preparePort: async () => oldPort,
110
+ recoverPort: async previous => { assert.equal(previous, oldPort); recoveries++; return rebound },
111
+ spawn: () => (++starts === 1 ? first.child : second.child),
112
+ })
113
+ try {
114
+ await loader.initialize()
115
+ assert.equal(starts, 2)
116
+ assert.equal(recoveries, 1)
117
+ assert.equal(loader.usbTransition.previousInitialization.detail, 'No serial data received')
118
+ assert.deepEqual(first.requests.map(r => r.method), ['initialize'])
119
+ assert.deepEqual(second.requests.map(r => r.method), ['initialize'])
120
+ await loader.flashData(new Uint8Array([1]), () => {}, 65536)
121
+ assert.deepEqual(second.requests.map(r => r.method), ['initialize', 'write'])
122
+ } finally { await loader.disconnect() }
123
+ })
124
+
125
+ test('an initialization that already requested bootloader cannot request it again', async () => {
126
+ const f = fixture({ initializeError: 'Still no serial data' })
127
+ f.selected.transition = { observed: 'rom', requested: true }
128
+ let recoveries = 0
129
+ const loader = openNodeRom({ port: '/dev/runtime' }, { ...f.dependencies,
130
+ recoverPort: async () => { recoveries++; throw Error('Must not replay bootloader') },
131
+ })
132
+ await assert.rejects(loader.initialize(), error => error.cause === 'Still no serial data')
133
+ assert.equal(recoveries, 0)
134
+ assert.equal(f.calls.filter(c => c[0] === 'spawn').length, 1)
135
+ })
136
+
137
+ test('absence of a confirmed runtime preserves the initial ROM sync failure without replay', async () => {
138
+ const f = fixture({ initializeError: 'No serial data received' })
139
+ f.selected.transition = { observed: 'rom', requested: false }
140
+ const loader = openNodeRom({ port: '/dev/rom' }, { ...f.dependencies, recoverPort: async () => null })
141
+ await assert.rejects(loader.initialize(), error => error.code === 'ROM_OPERATION_FAILED'
142
+ && error.operation === 'initialize' && error.cause === 'No serial data received')
143
+ assert.equal(f.requests.length, 1)
144
+ })
145
+
146
+ test('a second initialization sync failure cannot trigger a third process or handoff', async () => {
147
+ const first = fixture({ initializeError: 'First sync failed' }), second = fixture({ initializeError: 'Second sync failed' })
148
+ const selected = { port: '/dev/rom', expectedMac: 'e8f60ae75642', transition: { observed: 'rom', requested: false } }
149
+ let starts = 0, recoveries = 0
150
+ const loader = openNodeRom({ port: '/dev/rom' }, {
151
+ preparePort: async () => selected,
152
+ recoverPort: async () => { recoveries++; return { ...selected, transition: { observed: 'rom', requested: true } } },
153
+ spawn: () => (++starts === 1 ? first.child : second.child),
154
+ })
155
+ await assert.rejects(loader.initialize(), error => error.cause === 'Second sync failed'
156
+ && error.transition.previousInitialization.detail === 'First sync failed')
157
+ assert.equal(starts, 2)
158
+ assert.equal(recoveries, 1)
159
+ assert.equal(first.requests.length + second.requests.length, 2)
160
+ })