@esp-vision/device-core 0.6.1 → 0.7.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.
@@ -2,43 +2,15 @@
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 { createDevice } from '../src/index.js'
6
- import { firmwareSha256 } from '../src/firmware_flash.js'
7
-
8
- async function fixture() {
9
- const data = new Uint8Array(0x11000).fill(0xff), calls = []
10
- data.set([0xe9, 1, 2, 3], 0x2000)
11
- data.set([0xaa, 0x50, 4, 5], 0x8000)
12
- data.set([0xe9, 6, 7, 8], 0x10000)
13
- const part = async offset => ({ offset, bytes: 4, sha256: await firmwareSha256(data.subarray(offset, offset + 4)) })
14
- const release = { board: 'BOARD', version: 'new', image: {
15
- evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await part(0x10000)),
16
- partitionSize: 4096, partitionTable: await part(0x8000), url: 'https://example.test/app.bin',
17
- }, install: { offset: 0, bytes: data.length, flashSize: 0x1000000, chipRevisionMin: 302, chipRevisionMax: 302,
18
- sha256: await firmwareSha256(data), bootloader: await part(0x2000), url: 'https://example.test/full.bin' } }
19
- const flash = new Uint8Array(data.length).fill(0xff)
20
- const loader = {
21
- chipFamily: 0x3280, chipRevision: 302,
22
- async initialize() { calls.push('initialize') },
23
- async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } },
24
- async runStub() { calls.push('stub'); return loader },
25
- async flashId() { calls.push('capacity'); return 0x1840ef },
26
- async flashData(buffer, progress, offset) { calls.push(['write', offset]); flash.set(new Uint8Array(buffer), offset); progress(buffer.byteLength, buffer.byteLength) },
27
- async readFlash(offset, count) { calls.push(['read', offset]); return flash.slice(offset, offset + count) },
28
- async readRegister() { return 0 },
29
- async hardReset() { calls.push('reset') },
30
- async disconnect() { calls.push('close') },
31
- }
32
- const device = createDevice(), request = { mode: 'install', release, board: 'BOARD', data, async openLoader() { calls.push('open'); return loader } }
33
- return { data, calls, release, loader, device, request, flash }
34
- }
5
+ import { installFixture as fixture } from './firmware-fixtures.mjs'
35
6
 
36
7
  test('blank device installs through ROM without an EV-MUX transport or existing partition table', async () => {
37
8
  const f = await fixture(), progress = []
38
9
  const result = await f.device.firmware.update({ ...f.request, onProgress: value => progress.push(value) })
39
10
  assert.equal(result.verified, true); assert.equal(result.reconnected, false)
40
- assert.deepEqual(f.flash, f.data)
41
- assert.deepEqual(f.calls, ['open', 'initialize', 'stub', 'capacity', ['write', 0], ['read', 0], ['read', 65536], 'reset', 'close'])
11
+ assert.deepEqual(f.flash.slice(0, f.data.length), f.data)
12
+ assert.ok(f.flash.subarray(0x20000).every(value => value === 0xff))
13
+ assert.deepEqual(f.calls, ['open', 'initialize', 'stub', 'capacity', ['erase', 0x20000, 0x21000], ['read', 0x20000], ['read', 0x30000], ['read', 0x40000], ['write', 0], ['read', 0], ['read', 65536], 'reset', 'close'])
42
14
  assert.equal(progress.at(-1), 100)
43
15
  })
44
16
 
@@ -54,7 +26,7 @@ test('complete image is downloaded from install URL with the requested cancellat
54
26
  await f.device.firmware.update({ ...f.request, data: undefined, signal: controller.signal,
55
27
  async fetch(url, options) { assert.equal(url, f.release.install.url); assert.equal(options.signal, controller.signal); return { ok: true, arrayBuffer: async () => f.data.buffer } },
56
28
  })
57
- assert.deepEqual(f.flash, f.data)
29
+ assert.deepEqual(f.flash.slice(0, f.data.length), f.data)
58
30
  })
59
31
 
60
32
  test('install selection skips app-only releases and rejects explicit unqualified versions', async () => {
@@ -97,13 +69,14 @@ for (const change of ['wrong-chip', 'wrong-revision', 'secure-boot', 'encryption
97
69
  if (change === 'unknown-flash') f.loader.flashId = async () => 0xffffff
98
70
  if (change === 'missing-capacity') delete f.loader.flashId
99
71
  await assert.rejects(f.device.firmware.update(f.request))
100
- assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
72
+ assert.ok(!f.calls.some(call => Array.isArray(call) && ['write', 'erase'].includes(call[0])))
101
73
  assert.equal(f.calls.at(-1), 'close')
102
74
  })
103
75
  }
104
76
 
105
77
  test('installation with damaged readback does not reset or report success', async () => {
106
- const f = await fixture(); f.loader.readFlash = async (_offset, count) => new Uint8Array(count)
78
+ const f = await fixture(), read = f.loader.readFlash
79
+ f.loader.readFlash = async (offset, count) => offset >= 0x20000 ? read(offset, count) : new Uint8Array(count)
107
80
  await assert.rejects(f.device.firmware.update(f.request), /verification failed/)
108
81
  assert.ok(!f.calls.includes('reset')); assert.equal(f.calls.at(-1), 'close')
109
82
  })
@@ -4,19 +4,20 @@ import test from 'node:test'
4
4
  import assert from 'node:assert/strict'
5
5
  import { createDevice } from '../src/index.js'
6
6
  import { firmwareSha256 } from '../src/firmware.js'
7
+ import { partitionTable } from './firmware-fixtures.mjs'
7
8
 
8
9
  async function fixture() {
9
10
  const full = new Uint8Array(0x11000).fill(0xff), appOffset = 0x10000, appLength = 4, calls = []
10
- full.set([0xe9, 1, 2, 3], 0x2000); full.set([0xaa, 0x50, 4, 5], 0x8000); full.set([0xe9, 6, 7, 8], appOffset)
11
- const segment = async offset => ({ offset, bytes: 4, sha256: await firmwareSha256(full.subarray(offset, offset + 4)) })
11
+ full.set([0xe9, 1, 2, 3], 0x2000); full.set(partitionTable(), 0x8000); full.set([0xe9, 6, 7, 8], appOffset)
12
+ const segment = async (offset, size = 4) => ({ offset, bytes: size, sha256: await firmwareSha256(full.subarray(offset, offset + size)) })
12
13
  const source = { url: 'https://example.test/full.bin', bytes: full.length, sha256: await firmwareSha256(full), offset: appOffset }
13
14
  const release = { board: 'BOARD', version: 'new', image: {
14
- evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await segment(appOffset)), partitionSize: 4096,
15
- partitionTable: await segment(0x8000), url: source.url, source,
15
+ evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, ...(await segment(appOffset)), partitionSize: 0x10000,
16
+ partitionTable: await segment(0x8000, 0xc00), url: source.url, source,
16
17
  }, install: { url: source.url, offset: 0, bytes: full.length, sha256: source.sha256, flashSize: 0x1000000,
17
18
  chipRevisionMin: 302, chipRevisionMax: 302, bootloader: await segment(0x2000) } }
18
- const flash = new Uint8Array(full.length).fill(0x5a)
19
- flash.set(full.subarray(0x8000, 0x8004), 0x8000)
19
+ const flash = new Uint8Array(0x41000).fill(0x5a)
20
+ flash.set(full.subarray(0x8000, 0x8c00), 0x8000)
20
21
  const before = flash.slice()
21
22
  const loader = {
22
23
  chipFamily: 0x3280, chipRevision: 302,
@@ -24,6 +25,7 @@ async function fixture() {
24
25
  async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } },
25
26
  async runStub() { calls.push('stub'); return loader },
26
27
  async flashId() { return 0x1840ef },
28
+ async eraseRegion(offset, size) { calls.push(['erase', offset, size]); flash.fill(0xff, offset, offset + size) },
27
29
  async flashData(buffer, progress, offset) { calls.push(['write', offset, buffer.byteLength]); flash.set(new Uint8Array(buffer), offset); progress(buffer.byteLength, buffer.byteLength) },
28
30
  async readFlash(offset, bytes) { return flash.slice(offset, offset + bytes) },
29
31
  async readRegister() { return 0 },
@@ -89,7 +91,8 @@ test('downloaded full BIN enforces approved length during streaming before openi
89
91
  test('single BIN explicit install writes the complete container without slicing it', async () => {
90
92
  const f = await fixture()
91
93
  assert.equal((await f.device.firmware.update({ ...f.request, data: f.full, mode: 'install' })).verified, true)
92
- assert.deepEqual(f.flash, f.full)
94
+ assert.deepEqual(f.flash.slice(0, f.full.length), f.full)
95
+ assert.ok(f.flash.subarray(0x20000).every(value => value === 0xff))
93
96
  assert.deepEqual(f.calls.find(call => Array.isArray(call) && call[0] === 'write'), ['write', 0, f.full.length])
94
97
  })
95
98
  test('single BIN application update still refuses changed partition layouts with no install fallback', async () => {
@@ -0,0 +1,153 @@
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 { FirmwareFlashSession, firmwareSha256 } from '../src/firmware_flash.js'
6
+ import { installFixture, partitionTable } from './firmware-fixtures.mjs'
7
+
8
+ const entries = () => [
9
+ { label: 'nvs', type: 1, subtype: 2, offset: 0x9000, size: 0x6000 },
10
+ { label: 'factory', type: 0, subtype: 0, offset: 0x10000, size: 0x10000 },
11
+ { label: 'ffat', type: 1, subtype: 0x81, offset: 0x20000, size: 0x21000 },
12
+ ]
13
+ async function replaceTable(f, raw) {
14
+ f.data.fill(0xff, 0x8000, 0x8c00)
15
+ f.data.set(raw, 0x8000)
16
+ f.release.image.partitionTable.bytes = raw.length
17
+ f.release.image.partitionTable.sha256 = await firmwareSha256(raw)
18
+ f.release.install.sha256 = await firmwareSha256(f.data)
19
+ }
20
+ const destructive = f => f.calls.filter(call => Array.isArray(call) && ['erase', 'write'].includes(call[0]))
21
+
22
+ for (const mutation of ['no-ffat', 'legacy-vfs', 'wrong-type', 'wrong-subtype', 'encrypted', 'readonly', 'unknown-flags',
23
+ 'unaligned-offset', 'unaligned-size', 'zero-size', 'outside-flash', 'overlap', 'duplicate-label', 'wrong-app', 'reserved-range']) {
24
+ test(`install refuses ${mutation} in a hash-approved table before opening ROM`, async () => {
25
+ const f = await installFixture(), parts = entries(), fat = parts[2]
26
+ if (mutation === 'no-ffat') parts.pop()
27
+ if (mutation === 'legacy-vfs') fat.label = 'vfs'
28
+ if (mutation === 'wrong-type') fat.type = 0x40
29
+ if (mutation === 'wrong-subtype') fat.subtype = 0x82
30
+ if (mutation === 'encrypted') fat.flags = 1
31
+ if (mutation === 'readonly') fat.flags = 2
32
+ if (mutation === 'unknown-flags') fat.flags = 4
33
+ if (mutation === 'unaligned-offset') fat.offset++
34
+ if (mutation === 'unaligned-size') fat.size--
35
+ if (mutation === 'zero-size') fat.size = 0
36
+ if (mutation === 'outside-flash') fat.size = 0x1000000
37
+ if (mutation === 'overlap') fat.offset = 0x1f000
38
+ if (mutation === 'duplicate-label') parts[0].label = 'ffat'
39
+ if (mutation === 'wrong-app') parts[1].size = 0x1000
40
+ if (mutation === 'reserved-range') parts[0].offset = 0x8000
41
+ await replaceTable(f, partitionTable(parts))
42
+ await assert.rejects(f.device.firmware.update(f.request), error => ['FIRMWARE_PARTITION_INVALID', 'FIRMWARE_FATFS_UNAVAILABLE'].includes(error.code))
43
+ assert.deepEqual(f.calls, [])
44
+ })
45
+ }
46
+
47
+ for (const mutation of ['bad-magic', 'bad-md5', 'duplicate-md5', 'entry-after-md5', 'data-after-end', 'short-table', 'missing-end', 'empty']) {
48
+ test(`install refuses ${mutation} despite an approved outer SHA`, async () => {
49
+ const f = await installFixture(), raw = partitionTable(entries())
50
+ if (mutation === 'bad-magic') raw[0] ^= 1
51
+ if (mutation === 'bad-md5') raw[112] ^= 1
52
+ if (mutation === 'duplicate-md5') raw.copyWithin(128, 96, 128)
53
+ if (mutation === 'entry-after-md5') raw.copyWithin(128, 0, 32)
54
+ if (mutation === 'data-after-end') raw[200] = 0
55
+ if (mutation === 'empty') raw.fill(0xff)
56
+ await replaceTable(f, mutation === 'short-table' ? raw.subarray(0, 129) : mutation === 'missing-end' ? raw.subarray(0, 128) : raw)
57
+ await assert.rejects(f.device.firmware.update(f.request), { code: 'FIRMWARE_PARTITION_INVALID' })
58
+ assert.deepEqual(f.calls, [])
59
+ })
60
+ }
61
+
62
+ test('historical tables without MD5 are accepted with their SHA and FF end marker', async () => {
63
+ const f = await installFixture()
64
+ await replaceTable(f, partitionTable(entries(), { md5: false }))
65
+ assert.equal((await f.device.firmware.update(f.request)).verified, true)
66
+ assert.deepEqual(destructive(f), [['erase', 0x20000, 0x21000], ['write', 0]])
67
+ })
68
+
69
+ for (const failure of ['erase', 'read', 'short-read', 'not-erased', 'missing-erase', 'missing-read', 'missing-write']) {
70
+ test(`${failure} cannot write firmware, reset, or report complete installation`, async () => {
71
+ const f = await installFixture(), progress = [], read = f.loader.readFlash
72
+ if (failure === 'erase') f.loader.eraseRegion = async () => { f.calls.push(['erase', 0x20000, 0x21000]); throw new Error('erase denied') }
73
+ if (failure === 'read') f.loader.readFlash = async () => { throw new Error('port lost') }
74
+ if (failure === 'short-read') f.loader.readFlash = async () => new Uint8Array(1).fill(0xff)
75
+ if (failure === 'not-erased') f.loader.readFlash = async (offset, count) => { const result = await read(offset, count); result[0] = 0; return result }
76
+ if (failure === 'missing-erase') delete f.loader.eraseRegion
77
+ if (failure === 'missing-read') delete f.loader.readFlash
78
+ if (failure === 'missing-write') delete f.loader.flashData
79
+ await assert.rejects(f.device.firmware.update({ ...f.request, onProgress: value => progress.push(value) }))
80
+ assert.ok(!destructive(f).some(call => call[0] === 'write'))
81
+ assert.ok(!f.calls.includes('reset'))
82
+ assert.ok(!progress.includes(100))
83
+ assert.equal(f.calls.at(-1), 'close')
84
+ assert.ok(destructive(f).length <= 1, 'never retry an uncertain erase')
85
+ if (failure.startsWith('missing-')) assert.deepEqual(destructive(f), [])
86
+ })
87
+ }
88
+
89
+ for (const point of ['before-erase', 'during-erase', 'during-read', 'last-erase-progress', 'before-write']) {
90
+ test(`cancellation ${point} stops installation before any firmware write`, async () => {
91
+ const f = await installFixture(), controller = new AbortController(), reason = new Error('cancel install')
92
+ const erase = f.loader.eraseRegion, read = f.loader.readFlash
93
+ if (point === 'during-erase') f.loader.eraseRegion = async (...args) => { await erase(...args); controller.abort(reason) }
94
+ if (point === 'during-read') f.loader.readFlash = async (...args) => { const result = await read(...args); controller.abort(reason); return result }
95
+ await assert.rejects(f.device.firmware.update({ ...f.request, signal: controller.signal,
96
+ onPhase: text => { if ((point === 'before-erase' && text.startsWith('Erasing')) || (point === 'before-write' && text.startsWith('Installing'))) controller.abort(reason) },
97
+ onProgress: percent => { if (point === 'last-erase-progress' && percent === 20) controller.abort(reason) },
98
+ }), error => error === reason)
99
+ assert.ok(!destructive(f).some(call => call[0] === 'write'))
100
+ assert.ok(!f.calls.includes('reset'))
101
+ })
102
+ }
103
+
104
+ test('application update preserves the FAT sentinel and never reads or erases FFAT', async () => {
105
+ const f = await installFixture(), table = f.release.image.partitionTable
106
+ f.flash.set(f.data.subarray(table.offset, table.offset + table.bytes), table.offset)
107
+ const before = f.flash.slice()
108
+ await f.device.firmware.update({ ...f.request, mode: 'update', data: f.data.slice(0x10000, 0x10004) })
109
+ assert.deepEqual(f.flash.subarray(0x20000), before.subarray(0x20000))
110
+ assert.deepEqual(destructive(f), [['write', 0x10000]])
111
+ assert.deepEqual(f.calls.filter(call => Array.isArray(call) && call[0] === 'read'), [['read', 0x8000], ['read', 0x10000]])
112
+ })
113
+
114
+ function installationImage(f) {
115
+ return { ...f.release.image, ...f.release.install, application: f.release.image,
116
+ chipRevision: undefined, chipRevisionMin: 302, chipRevisionMax: 302 }
117
+ }
118
+
119
+ test('metadata and Buffer mutations cannot change qualified erase or write ranges', async () => {
120
+ const f = await installFixture(), original = f.data.slice(), buffer = Buffer.from(f.data)
121
+ const image = installationImage(f), session = new FirmwareFlashSession(f.loader, image, { mode: 'install' })
122
+ await session.connect(buffer)
123
+ image.sha256 = '0'.repeat(64); image.flashSize = 0x4000000; image.partitionTable.offset = 0
124
+ session.mode = 'update'
125
+ await session.flash(buffer, { onPhase: () => buffer.fill(0) })
126
+ assert.deepEqual(destructive(f), [['erase', 0x20000, 0x21000], ['write', 0]])
127
+ assert.deepEqual(f.flash.slice(0, original.length), original)
128
+ })
129
+
130
+ test('a different image after connection rejects before erase even if public metadata changes', async () => {
131
+ const f = await installFixture(), session = new FirmwareFlashSession(f.loader, installationImage(f), { mode: 'install' })
132
+ await session.connect(f.data)
133
+ f.data[0] ^= 1
134
+ session.image.sha256 = await firmwareSha256(f.data)
135
+ await assert.rejects(session.flash(f.data), /SHA-256/)
136
+ assert.deepEqual(destructive(f), [])
137
+ })
138
+
139
+ test('secure download mode refuses installation before stub or erase', async () => {
140
+ const f = await installFixture()
141
+ f.loader.getSecurityInfo = async () => ({ flags: 4, flashCryptCnt: 0 })
142
+ await assert.rejects(f.device.firmware.update(f.request), /signed flashing workflow/)
143
+ assert.deepEqual(f.calls, ['open', 'initialize', 'close'])
144
+ })
145
+
146
+ for (const security of [{ flags: -8, flashCryptCnt: 0 }, { flags: 0, flashCryptCnt: -3 }, { flags: 0x100000000, flashCryptCnt: 0 }]) {
147
+ test(`invalid ROM security data ${JSON.stringify(security)} rejects before erase`, async () => {
148
+ const f = await installFixture()
149
+ f.loader.getSecurityInfo = async () => security
150
+ await assert.rejects(f.device.firmware.update(f.request), /security mode/)
151
+ assert.deepEqual(f.calls, ['open', 'initialize', 'close'])
152
+ })
153
+ }
@@ -6,7 +6,7 @@ import { EventEmitter } from 'node:events'
6
6
  import { PassThrough, Writable } from 'node:stream'
7
7
  import { openNodeRom } from '../src/node_rom.js'
8
8
 
9
- function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeError, operationError, readResult = 'QUI=' } = {}) {
9
+ function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeError, operationError, readResult = 'QUI=', stubResult = { stubRunning: true }, holdErase = false } = {}) {
10
10
  const calls = [], requests = []
11
11
  const child = new EventEmitter()
12
12
  child.exitCode = null
@@ -15,11 +15,14 @@ function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeErro
15
15
  write(data, encoding, done) {
16
16
  const request = JSON.parse(data.toString())
17
17
  requests.push(request)
18
- const detail = request.method === 'initialize' ? initializeError : operationError
18
+ const detail = request.method === 'initialize' ? initializeError
19
+ : typeof operationError === 'function' ? operationError(request) : operationError
20
+ if (request.method === 'eraseRegion' && holdErase) { done(); return }
19
21
  queueMicrotask(() => child.stdout.write(JSON.stringify(detail
20
22
  ? { id: request.id, error: 'ROM_OPERATION_FAILED', method: request.method, detail }
21
23
  : { id: request.id, result: request.method === 'initialize'
22
- ? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' } : request.method === 'read' ? readResult : null }) + '\n'))
24
+ ? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' }
25
+ : request.method === 'read' ? readResult : request.method === 'stub' ? stubResult : null }) + '\n'))
23
26
  done()
24
27
  },
25
28
  final(done) { child.exitCode = 0; queueMicrotask(() => child.emit('exit', 0)); done() },
@@ -229,3 +232,84 @@ test('incomplete read results are rejected without progress, recovery, or duplic
229
232
  assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'read'])
230
233
  } finally { await loader.disconnect() }
231
234
  })
235
+
236
+ test('Node region erase requires the validated stub and sends only the exact qualified range', async () => {
237
+ const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
238
+ try {
239
+ await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_NOT_INITIALIZED' })
240
+ await loader.initialize()
241
+ await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_STUB_REQUIRED' })
242
+ const stub = await loader.runStub()
243
+ assert.equal(stub, loader); assert.equal(stub.IS_STUB, true)
244
+ await stub.eraseRegion(0x710000, 0x8f0000)
245
+ await stub.eraseRegion(0xfffff000, 4096)
246
+ assert.deepEqual(f.requests.map(r => [r.method, r.args]), [
247
+ ['initialize', []], ['stub', []], ['eraseRegion', [0x710000, 0x8f0000]],
248
+ ['eraseRegion', [0xfffff000, 4096]],
249
+ ])
250
+ } finally { await loader.disconnect() }
251
+ assert.equal(loader.IS_STUB, false)
252
+ await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_CLOSED' })
253
+ })
254
+
255
+ test('invalid Node erase ranges issue no child request', async () => {
256
+ const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
257
+ try {
258
+ await loader.initialize(); await loader.runStub()
259
+ for (const [address, size] of [[-4096, 4096], [1, 4096], [0, 0], [0, 1], [0, 0x100000000],
260
+ [0xfffff000, 8192], [NaN, 4096], [true, 4096], [0, true], [0x100000000, 4096]]) {
261
+ await assert.rejects(loader.eraseRegion(address, size), RangeError)
262
+ }
263
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub'])
264
+ } finally { await loader.disconnect() }
265
+ })
266
+
267
+ test('unconfirmed Node stub responses close the process before any erase', async () => {
268
+ for (const stubResult of [null, {}, { stubRunning: false }, { stubRunning: 1 }]) {
269
+ const f = fixture({ stubResult }), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
270
+ await loader.initialize()
271
+ await assert.rejects(loader.runStub(), { code: 'ROM_STUB_REQUIRED' })
272
+ await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_CLOSED' })
273
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub'])
274
+ assert.equal(f.child.exitCode, 0)
275
+ }
276
+ })
277
+
278
+ test('Node erase failure keeps official diagnostics and prevents later writes or recovery', async () => {
279
+ const f = fixture({ operationError: request => request.method === 'eraseRegion' ? 'Erase command rejected' : undefined })
280
+ let recoveries = 0
281
+ const loader = openNodeRom({ port: '/dev/runtime' }, { ...f.dependencies,
282
+ recoverPort: async () => { recoveries++; throw new Error('Erase must not recover') },
283
+ })
284
+ await loader.initialize(); await loader.runStub()
285
+ await assert.rejects(loader.eraseRegion(0x710000, 4096), error => error.code === 'ROM_OPERATION_FAILED'
286
+ && error.operation === 'eraseRegion' && error.cause === 'Erase command rejected')
287
+ await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 0), { code: 'ROM_CLOSED' })
288
+ await assert.rejects(loader.eraseRegion(0x710000, 4096), { code: 'ROM_CLOSED' })
289
+ assert.equal(recoveries, 0)
290
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub', 'eraseRegion'])
291
+ })
292
+
293
+ test('Node erase cancellation or port loss closes the in-flight request without success or retry', async () => {
294
+ for (const cancelled of [true, false]) {
295
+ const f = fixture({ holdErase: true }), controller = new AbortController()
296
+ const loader = openNodeRom({ port: '/dev/runtime', signal: controller.signal }, f.dependencies)
297
+ await loader.initialize(); await loader.runStub()
298
+ const erasing = loader.eraseRegion(0x710000, 4096)
299
+ const failed = assert.rejects(erasing, { code: cancelled ? 'CANCELLED' : 'ROM_CLOSED' })
300
+ if (cancelled) controller.abort()
301
+ else { f.child.exitCode = 1; f.child.emit('exit', 1) }
302
+ await failed
303
+ await assert.rejects(loader.flashData(new Uint8Array([1]), () => {}, 0), { code: 'ROM_CLOSED' })
304
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub', 'eraseRegion'])
305
+ }
306
+ })
307
+
308
+ test('a previously cancelled Node erase never sends a command', async () => {
309
+ const f = fixture(), controller = new AbortController()
310
+ const loader = openNodeRom({ port: '/dev/runtime', signal: controller.signal }, f.dependencies)
311
+ await loader.initialize(); await loader.runStub(); controller.abort()
312
+ await assert.rejects(loader.eraseRegion(0x710000, 4096), { name: 'AbortError' })
313
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'stub'])
314
+ assert.equal(f.child.exitCode, 0)
315
+ })