@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.
- package/CHANGELOG.md +37 -0
- package/NOTICE +8 -0
- package/README.md +25 -8
- package/package.json +4 -3
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +2 -1
- package/scripts/npm-command.mjs +17 -0
- package/scripts/release-npm.mjs +18 -1
- package/scripts/version-policy.mjs +71 -0
- package/src/browser.js +6 -2
- package/src/browser_rom.js +208 -4
- package/src/browser_rom_compat.js +86 -59
- package/src/browser_serial.js +130 -0
- package/src/device.js +5 -4
- package/src/device_bindings.js +5 -4
- package/src/device_capture.js +11 -0
- package/src/device_operations.js +3 -1
- package/src/device_session.js +263 -10
- package/src/evmux.js +43 -4
- package/src/firmware_flash.js +1 -1
- package/src/jpeg_validate.js +132 -0
- package/src/node_inventory.js +1 -1
- package/src/node_rom.js +92 -29
- package/src/node_rom.py +134 -6
- package/src/node_rom_port.js +219 -0
- package/src/node_serial.py +122 -13
- package/src/node_transport.js +136 -21
- package/src/rom_md5.js +30 -0
- package/tests/check-browser.mjs +76 -123
- package/tests/check-package.mjs +8 -7
- package/tests/device-checks.mjs +6 -2
- package/tests/fixtures/device-checks.py +11 -1
- package/tests/jpeg-fixtures.mjs +39 -0
- package/tests/test-browser-rom.mjs +204 -67
- package/tests/test-browser-serial.mjs +143 -0
- package/tests/test-device-api.mjs +40 -7
- package/tests/test-device-functional-checks.mjs +12 -2
- package/tests/test-device-operations-api.mjs +4 -3
- package/tests/test-device-reset.mjs +292 -0
- package/tests/test-firmware-flash.mjs +1 -1
- package/tests/test-host-checks.mjs +47 -0
- package/tests/test-jpeg-validation.mjs +233 -0
- package/tests/test-node-rom-port.mjs +385 -0
- package/tests/test-node-rom.mjs +231 -0
- package/tests/test-node-serial.mjs +436 -1
- package/tests/test-npm-release-policy.mjs +56 -0
- package/tests/test-observed-capture.mjs +131 -0
- package/tests/test-uart-baud.mjs +180 -0
- package/tests/test-version-policy.mjs +37 -0
|
@@ -0,0 +1,385 @@
|
|
|
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('persistent ROM or absence exhausts only passive observation before returning null', 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.now(), 200)
|
|
273
|
+
assert.equal(f.calls.enumerate.length, 4)
|
|
274
|
+
assert.equal(f.calls.creates, 0)
|
|
275
|
+
assert.equal(f.calls.control.length, 0)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('recovery rejects wrong MAC, moved location and ambiguous matching runtimes', async () => {
|
|
280
|
+
for (const runtime of [native('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' }), native('/dev/ttyACM2', { location: '2-3' })]) {
|
|
281
|
+
const f = fixture({ snapshots: [[runtime]] })
|
|
282
|
+
await assert.rejects(f.recover(previousRom()), { code: 'ROM_RUNTIME_IDENTITY_MISMATCH' })
|
|
283
|
+
assert.equal(f.calls.creates, 0)
|
|
284
|
+
}
|
|
285
|
+
const f = fixture({ snapshots: [[native('/dev/ttyACM2'), native('/dev/ttyACM3')]] })
|
|
286
|
+
await assert.rejects(f.recover(previousRom()), { code: 'ROM_PORT_AMBIGUOUS' })
|
|
287
|
+
assert.equal(f.calls.creates, 0)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('recovery rechecks identity before connecting when a selected OS path was reused', async () => {
|
|
291
|
+
const correct = native('/dev/ttyACM2')
|
|
292
|
+
const wrong = native('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' })
|
|
293
|
+
const f = fixture({ snapshots: [[correct], [wrong]] })
|
|
294
|
+
await assert.rejects(f.recover(previousRom()), { code: 'ROM_RUNTIME_IDENTITY_MISMATCH' })
|
|
295
|
+
assert.equal(f.calls.creates, 0)
|
|
296
|
+
assert.equal(f.calls.control.length, 0)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('read-only recovery honors cancel and fresh-enumeration deadline', async () => {
|
|
300
|
+
const reason = Object.assign(new Error('cancelled'), { name: 'AbortError' })
|
|
301
|
+
const cancelled = fixture(); cancelled.controller.abort(reason)
|
|
302
|
+
await assert.rejects(cancelled.recover(previousRom()), value => value === reason)
|
|
303
|
+
assert.equal(cancelled.calls.creates, 0)
|
|
304
|
+
const late = fixture({ snapshots: [[native()]], enumerationDelay: 250 })
|
|
305
|
+
await assert.rejects(late.recover(previousRom()), { code: 'ROM_PORT_REBIND_TIMEOUT' })
|
|
306
|
+
assert.equal(late.calls.creates, 0)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
test('read-only recovery observes a real 2.4-second missing-device window before one handoff', async () => {
|
|
311
|
+
const runtime = native('/dev/ttyACM2', { productId: 0x4023 })
|
|
312
|
+
const f = fixture({ snapshots: [...Array.from({ length: 48 }, () => []), [runtime], [runtime], [rom('/dev/ttyACM3')]] })
|
|
313
|
+
const result = await f.recover(previousRom(), { timeout: 5000 })
|
|
314
|
+
assert.equal(f.now(), 2400)
|
|
315
|
+
assert.equal(result.port, '/dev/ttyACM3')
|
|
316
|
+
assert.equal(result.transition.recovery.runtimePid, 0x4023)
|
|
317
|
+
assert.equal(f.calls.control.length, 1)
|
|
318
|
+
assert.equal(f.calls.connect.length, 1)
|
|
319
|
+
assert.equal(f.calls.disconnect, 1)
|
|
320
|
+
assert.equal(f.calls.enumerate[48].timeout, 2600)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('a stale ROM followed by disappearance is observed passively until the same runtime returns', async () => {
|
|
324
|
+
const runtime = native('/dev/ttyACM2')
|
|
325
|
+
const snapshots = [...Array.from({ length: 10 }, () => [rom('/dev/ttyACM0')]),
|
|
326
|
+
...Array.from({ length: 38 }, () => []), [runtime], [runtime], [rom('/dev/ttyACM3')]]
|
|
327
|
+
const f = fixture({ snapshots })
|
|
328
|
+
const result = await f.recover(previousRom(), { timeout: 5000 })
|
|
329
|
+
assert.equal(f.now(), 2400)
|
|
330
|
+
assert.equal(result.port, '/dev/ttyACM3')
|
|
331
|
+
assert.equal(f.calls.creates, 1)
|
|
332
|
+
assert.equal(f.calls.control.length, 1)
|
|
333
|
+
assert.equal(f.calls.disconnect, 1)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('a conflicting MAC or location seen after waiting fails before opening any runtime', async () => {
|
|
337
|
+
for (const conflicting of [native('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' }),
|
|
338
|
+
native('/dev/ttyACM2', { location: '2-3' }), rom('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' }),
|
|
339
|
+
rom('/dev/ttyACM2', { location: '2-3' })]) {
|
|
340
|
+
const f = fixture({ snapshots: [[], [], [conflicting]] })
|
|
341
|
+
await assert.rejects(f.recover(previousRom()), { code: 'ROM_RUNTIME_IDENTITY_MISMATCH' })
|
|
342
|
+
assert.equal(f.now(), 100)
|
|
343
|
+
assert.equal(f.calls.creates, 0)
|
|
344
|
+
assert.equal(f.calls.control.length, 0)
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('late duplicate runtime or simultaneous ROM/runtime descriptors remain ambiguous', async () => {
|
|
349
|
+
for (const second of [native('/dev/ttyACM3'), rom('/dev/ttyACM3')]) {
|
|
350
|
+
const f = fixture({ snapshots: [[], [native('/dev/ttyACM2'), second]] })
|
|
351
|
+
await assert.rejects(f.recover(previousRom()), { code: 'ROM_PORT_AMBIGUOUS' })
|
|
352
|
+
assert.equal(f.now(), 50)
|
|
353
|
+
assert.equal(f.calls.creates, 0)
|
|
354
|
+
assert.equal(f.calls.control.length, 0)
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('cancel during passive recovery sleep never creates a session or sends bootloader', async () => {
|
|
359
|
+
const reason = Object.assign(new Error('cancelled during native USB observation'), { name: 'AbortError' })
|
|
360
|
+
const f = fixture({ snapshots: [[]], onSleep(controller, clock) { if (clock >= 150) controller.abort(reason) } })
|
|
361
|
+
await assert.rejects(f.recover(previousRom(), { timeout: 5000 }), value => value === reason)
|
|
362
|
+
assert.equal(f.now(), 150)
|
|
363
|
+
assert.equal(f.calls.creates, 0)
|
|
364
|
+
assert.equal(f.calls.control.length, 0)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('late runtime enumeration does not start handoff after the passive deadline', async () => {
|
|
368
|
+
const runtime = native('/dev/ttyACM2')
|
|
369
|
+
const f = fixture({ snapshots: [[runtime]], enumerationDelay: 201 })
|
|
370
|
+
await assert.rejects(f.recover(previousRom()), { code: 'ROM_PORT_REBIND_TIMEOUT' })
|
|
371
|
+
assert.equal(f.calls.creates, 0)
|
|
372
|
+
assert.equal(f.calls.control.length, 0)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('a runtime handshake error after settling is propagated without another observation or handoff', async () => {
|
|
376
|
+
const runtime = native('/dev/ttyACM2')
|
|
377
|
+
const reason = failure('SESSION_BUSY')
|
|
378
|
+
const f = fixture({ snapshots: [[], [runtime], [runtime]], connectError: reason })
|
|
379
|
+
await assert.rejects(f.recover(previousRom()), value => value === reason)
|
|
380
|
+
assert.equal(f.now(), 50)
|
|
381
|
+
assert.equal(f.calls.connect.length, 1)
|
|
382
|
+
assert.equal(f.calls.creates, 1)
|
|
383
|
+
assert.equal(f.calls.control.length, 0)
|
|
384
|
+
assert.equal(f.calls.disconnect, 1)
|
|
385
|
+
})
|
|
@@ -0,0 +1,231 @@
|
|
|
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, operationError, readResult = 'QUI=' } = {}) {
|
|
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
|
+
const detail = request.method === 'initialize' ? initializeError : operationError
|
|
19
|
+
queueMicrotask(() => child.stdout.write(JSON.stringify(detail
|
|
20
|
+
? { id: request.id, error: 'ROM_OPERATION_FAILED', method: request.method, detail }
|
|
21
|
+
: { id: request.id, result: request.method === 'initialize'
|
|
22
|
+
? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' } : request.method === 'read' ? readResult : null }) + '\n'))
|
|
23
|
+
done()
|
|
24
|
+
},
|
|
25
|
+
final(done) { child.exitCode = 0; queueMicrotask(() => child.emit('exit', 0)); done() },
|
|
26
|
+
})
|
|
27
|
+
child.kill = () => { child.exitCode = 1; child.emit('exit', 1) }
|
|
28
|
+
const selected = { port: '/dev/rebound-rom', expectedMac: expected, transition: { requested: true } }
|
|
29
|
+
const dependencies = {
|
|
30
|
+
async preparePort(options) { calls.push(['prepare', options]); return prepare ? prepare(options) : selected },
|
|
31
|
+
spawn(...args) { calls.push(['spawn', ...args]); return child },
|
|
32
|
+
}
|
|
33
|
+
return { calls, requests, child, selected, dependencies }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('Node ROM process uses the qualified rebound port and verifies its actual MAC before writes', async () => {
|
|
37
|
+
const f = fixture(), loader = openNodeRom({ port: '/dev/runtime', python: 'test-python' }, f.dependencies)
|
|
38
|
+
try {
|
|
39
|
+
await loader.initialize()
|
|
40
|
+
assert.equal(f.calls[1][2][2], '/dev/rebound-rom')
|
|
41
|
+
assert.equal(loader.port, '/dev/rebound-rom')
|
|
42
|
+
assert.equal(loader.chipMac, 'e8f60ae75642')
|
|
43
|
+
assert.equal(loader.connectionMode, 'no-reset')
|
|
44
|
+
assert.deepEqual(loader.usbTransition, { requested: true })
|
|
45
|
+
await loader.flashData(new Uint8Array([1, 2]), () => {}, 65536)
|
|
46
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'write'])
|
|
47
|
+
} finally { await loader.disconnect() }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a ROM MAC mismatch closes the process and cannot be followed by a write', async () => {
|
|
51
|
+
const f = fixture({ expected: '001122334455' }), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
52
|
+
await assert.rejects(loader.initialize(), { code: 'ROM_IDENTITY_CHANGED' })
|
|
53
|
+
await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 65536), { code: 'ROM_CLOSED' })
|
|
54
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize'])
|
|
55
|
+
assert.equal(f.child.exitCode, 0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('initialization is single use and never repeats a bootloader handoff', async () => {
|
|
59
|
+
const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
60
|
+
try {
|
|
61
|
+
await loader.initialize()
|
|
62
|
+
await assert.rejects(loader.initialize(), { code: 'ROM_ALREADY_INITIALIZED' })
|
|
63
|
+
assert.equal(f.calls.filter(c => c[0] === 'prepare').length, 1)
|
|
64
|
+
assert.equal(f.calls.filter(c => c[0] === 'spawn').length, 1)
|
|
65
|
+
} finally { await loader.disconnect() }
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('flash operations before initialization fail without spawning or sending a process request', async () => {
|
|
69
|
+
const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
70
|
+
await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 65536), { code: 'ROM_NOT_INITIALIZED' })
|
|
71
|
+
assert.equal(f.calls.length, 0)
|
|
72
|
+
assert.equal(f.requests.length, 0)
|
|
73
|
+
await loader.disconnect()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('disconnect cancels a pending port handoff before any ROM process can start', async () => {
|
|
77
|
+
const f = fixture({ prepare: ({ signal }) => new Promise((resolve, reject) => {
|
|
78
|
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
|
79
|
+
}) })
|
|
80
|
+
const loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
81
|
+
const initializing = loader.initialize()
|
|
82
|
+
const rejected = assert.rejects(initializing, { code: 'ROM_CLOSED' })
|
|
83
|
+
await loader.disconnect()
|
|
84
|
+
await rejected
|
|
85
|
+
assert.equal(f.calls.filter(c => c[0] === 'spawn').length, 0)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('handoff failure retains its cause and cannot replay on the same loader', async () => {
|
|
89
|
+
const reason = Object.assign(new Error('ROM did not re-enumerate'), { code: 'ROM_PORT_LOST' })
|
|
90
|
+
const f = fixture({ prepare: () => { throw reason } }), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
91
|
+
await assert.rejects(loader.initialize(), error => error === reason)
|
|
92
|
+
await assert.rejects(loader.initialize(), { code: 'ROM_CLOSED' })
|
|
93
|
+
assert.equal(f.calls.length, 1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('underlying ROM initialization diagnostics survive cleanup and prevent writes', async () => {
|
|
97
|
+
const f = fixture({ initializeError: 'No serial data received' }), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
98
|
+
await assert.rejects(loader.initialize(), error => error.code === 'ROM_OPERATION_FAILED'
|
|
99
|
+
&& error.operation === 'initialize' && error.cause === 'No serial data received')
|
|
100
|
+
await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 65536), { code: 'ROM_CLOSED' })
|
|
101
|
+
assert.equal(f.requests.length, 1)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('one read-only ROM sync failure may hand off the verified same runtime before any write', async () => {
|
|
105
|
+
const first = fixture({ initializeError: 'No serial data received' }), second = fixture()
|
|
106
|
+
const oldPort = { port: '/dev/rom', expectedMac: 'e8f60ae75642', transition: { observed: 'rom', requested: false } }
|
|
107
|
+
const rebound = { port: '/dev/rebound', expectedMac: oldPort.expectedMac, transition: { observed: 'rom', requested: true } }
|
|
108
|
+
let starts = 0, recoveries = 0
|
|
109
|
+
const loader = openNodeRom({ port: '/dev/rom' }, {
|
|
110
|
+
preparePort: async () => oldPort,
|
|
111
|
+
recoverPort: async previous => { assert.equal(previous, oldPort); recoveries++; return rebound },
|
|
112
|
+
spawn: () => (++starts === 1 ? first.child : second.child),
|
|
113
|
+
})
|
|
114
|
+
try {
|
|
115
|
+
await loader.initialize()
|
|
116
|
+
assert.equal(starts, 2)
|
|
117
|
+
assert.equal(recoveries, 1)
|
|
118
|
+
assert.equal(loader.usbTransition.previousInitialization.detail, 'No serial data received')
|
|
119
|
+
assert.deepEqual(first.requests.map(r => r.method), ['initialize'])
|
|
120
|
+
assert.deepEqual(second.requests.map(r => r.method), ['initialize'])
|
|
121
|
+
await loader.flashData(new Uint8Array([1]), () => {}, 65536)
|
|
122
|
+
assert.deepEqual(second.requests.map(r => r.method), ['initialize', 'write'])
|
|
123
|
+
} finally { await loader.disconnect() }
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('an initialization that already requested bootloader cannot request it again', async () => {
|
|
127
|
+
const f = fixture({ initializeError: 'Still no serial data' })
|
|
128
|
+
f.selected.transition = { observed: 'rom', requested: true }
|
|
129
|
+
let recoveries = 0
|
|
130
|
+
const loader = openNodeRom({ port: '/dev/runtime' }, { ...f.dependencies,
|
|
131
|
+
recoverPort: async () => { recoveries++; throw Error('Must not replay bootloader') },
|
|
132
|
+
})
|
|
133
|
+
await assert.rejects(loader.initialize(), error => error.cause === 'Still no serial data')
|
|
134
|
+
assert.equal(recoveries, 0)
|
|
135
|
+
assert.equal(f.calls.filter(c => c[0] === 'spawn').length, 1)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('absence of a confirmed runtime preserves the initial ROM sync failure without replay', async () => {
|
|
139
|
+
const f = fixture({ initializeError: 'No serial data received' })
|
|
140
|
+
f.selected.transition = { observed: 'rom', requested: false }
|
|
141
|
+
const loader = openNodeRom({ port: '/dev/rom' }, { ...f.dependencies, recoverPort: async () => null })
|
|
142
|
+
await assert.rejects(loader.initialize(), error => error.code === 'ROM_OPERATION_FAILED'
|
|
143
|
+
&& error.operation === 'initialize' && error.cause === 'No serial data received')
|
|
144
|
+
assert.equal(f.requests.length, 1)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('a second initialization sync failure cannot trigger a third process or handoff', async () => {
|
|
148
|
+
const first = fixture({ initializeError: 'First sync failed' }), second = fixture({ initializeError: 'Second sync failed' })
|
|
149
|
+
const selected = { port: '/dev/rom', expectedMac: 'e8f60ae75642', transition: { observed: 'rom', requested: false } }
|
|
150
|
+
let starts = 0, recoveries = 0
|
|
151
|
+
const loader = openNodeRom({ port: '/dev/rom' }, {
|
|
152
|
+
preparePort: async () => selected,
|
|
153
|
+
recoverPort: async () => { recoveries++; return { ...selected, transition: { observed: 'rom', requested: true } } },
|
|
154
|
+
spawn: () => (++starts === 1 ? first.child : second.child),
|
|
155
|
+
})
|
|
156
|
+
await assert.rejects(loader.initialize(), error => error.cause === 'Second sync failed'
|
|
157
|
+
&& error.transition.previousInitialization.detail === 'First sync failed')
|
|
158
|
+
assert.equal(starts, 2)
|
|
159
|
+
assert.equal(recoveries, 1)
|
|
160
|
+
assert.equal(first.requests.length + second.requests.length, 2)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
test('post-initialize read or write failures never start recovery, a second process, or replay data', async () => {
|
|
165
|
+
for (const method of ['read', 'write']) {
|
|
166
|
+
const f = fixture({ operationError: 'Original operation failed' })
|
|
167
|
+
f.selected.transition = { observed: 'rom', requested: false }
|
|
168
|
+
let recoveries = 0, progresses = 0
|
|
169
|
+
const loader = openNodeRom({ port: '/dev/rom' }, { ...f.dependencies,
|
|
170
|
+
recoverPort: async () => { recoveries++; throw Error('Read/write must not recover') },
|
|
171
|
+
})
|
|
172
|
+
try {
|
|
173
|
+
await loader.initialize()
|
|
174
|
+
const operation = method === 'read' ? loader.readFlash(0x8000, 1024)
|
|
175
|
+
: loader.flashData(new Uint8Array([1, 2, 3]), () => { progresses++ }, 0x10000)
|
|
176
|
+
await assert.rejects(operation, error => error.code === 'ROM_OPERATION_FAILED'
|
|
177
|
+
&& error.operation === method && error.cause === 'Original operation failed')
|
|
178
|
+
assert.equal(recoveries, 0)
|
|
179
|
+
assert.equal(progresses, 0)
|
|
180
|
+
assert.equal(f.calls.filter(c => c[0] === 'spawn').length, 1)
|
|
181
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize', method])
|
|
182
|
+
} finally { await loader.disconnect() }
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
test('Node ROM reads forward default and explicit budgets and report progress only after complete data', async () => {
|
|
188
|
+
const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
189
|
+
const progress = []
|
|
190
|
+
try {
|
|
191
|
+
await loader.initialize()
|
|
192
|
+
assert.deepEqual(await loader.readFlash(0x8000, 2, (...args) => progress.push(args)), new Uint8Array([65, 66]))
|
|
193
|
+
assert.deepEqual(await loader.readFlash(0x10000, 2, undefined,
|
|
194
|
+
{ chunkSize: 65536, blockSize: 4096, maxInFlight: 2 }), new Uint8Array([65, 66]))
|
|
195
|
+
assert.deepEqual(f.requests.map(r => [r.method, r.args]), [['initialize', []],
|
|
196
|
+
['read', [0x8000, 2, { blockSize: 1024, maxInFlight: 1 }]],
|
|
197
|
+
['read', [0x10000, 2, { blockSize: 4096, maxInFlight: 2 }]]])
|
|
198
|
+
assert.equal(progress.length, 1)
|
|
199
|
+
assert.deepEqual(progress[0], [new Uint8Array([65, 66]), 2, 2])
|
|
200
|
+
} finally { await loader.disconnect() }
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('invalid read ranges, progress, or packet budgets never issue a child process RPC', async () => {
|
|
204
|
+
const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
|
|
205
|
+
try {
|
|
206
|
+
await loader.initialize()
|
|
207
|
+
for (const [offset, count, progress, options] of [[-1, 2], [0, 0], [0, 0x100000000], [0xffffffff, 2], [0, 2, null],
|
|
208
|
+
[0, 2, undefined, null], [0, 2, undefined, []], [0, 2, undefined, { blockSize: 512 }],
|
|
209
|
+
[0, 2, undefined, { blockSize: true }], [0, 2, undefined, { blockSize: null }], [0, 2, undefined, { maxInFlight: 0 }],
|
|
210
|
+
[0, 2, undefined, { maxInFlight: 65 }], [0, 2, undefined, { maxInFlight: null }], [0, 2, undefined, { maxInFlight: 1.5 }],
|
|
211
|
+
[0, 2, undefined, { unknown: 1 }], [0, 2, undefined, { chunkSize: 1 }]]) {
|
|
212
|
+
await assert.rejects(loader.readFlash(offset, count, progress, options), error => error instanceof TypeError || error instanceof RangeError)
|
|
213
|
+
}
|
|
214
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize'])
|
|
215
|
+
} finally { await loader.disconnect() }
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('incomplete read results are rejected without progress, recovery, or duplicate read', async () => {
|
|
219
|
+
const f = fixture({ readResult: 'QQ==' })
|
|
220
|
+
let recovered = 0, progressed = 0
|
|
221
|
+
const loader = openNodeRom({ port: '/dev/runtime' }, { ...f.dependencies,
|
|
222
|
+
recoverPort: async () => { recovered++; throw Error('Read must not recover') },
|
|
223
|
+
})
|
|
224
|
+
try {
|
|
225
|
+
await loader.initialize()
|
|
226
|
+
await assert.rejects(loader.readFlash(0, 2, () => { progressed++ }), { code: 'ROM_READ_LENGTH', operation: 'read' })
|
|
227
|
+
assert.equal(recovered, 0)
|
|
228
|
+
assert.equal(progressed, 0)
|
|
229
|
+
assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'read'])
|
|
230
|
+
} finally { await loader.disconnect() }
|
|
231
|
+
})
|