@esp-vision/device-core 0.1.0 → 0.2.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.
@@ -0,0 +1,189 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import assert from 'node:assert/strict'
3
+ import test from 'node:test'
4
+ import { Buffer } from 'node:buffer'
5
+ import { DeviceSession } from '../src/device_session.js'
6
+ import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
7
+ import { EvRepl, evReplExecInputBytes } from '../src/evrepl.js'
8
+
9
+ function fixture(options = {}) {
10
+ return new DeviceSession({ transport: { async writeBytes() {} }, EvMuxParser, makeEvMuxFrame, EvRepl, ...options })
11
+ }
12
+
13
+ test('Unicode Python is ASCII on the wire and length preflight includes escapes', async () => {
14
+ let command
15
+ const repl = new EvRepl({ writeEvmux() {} })
16
+ repl._execLine = async line => { command = line; return '' }
17
+ const source = "print('你好😀', '\\u4f60')\n\tpass"
18
+ await repl.execPy(source)
19
+ assert.match(command, /\\u4f60\\u597d\\U0001f600/)
20
+ assert.match(command, /\\\\u4f60/)
21
+ assert.ok([...command].every(char => char.charCodeAt(0) < 128))
22
+ assert.equal(evReplExecInputBytes(source), Buffer.byteLength(command + '\r\n'))
23
+ })
24
+
25
+ test('64 KiB reads use bounded output and serialize with other REPL work', async () => {
26
+ const data = Uint8Array.from({ length: 65536 }, (_, i) => i % 256)
27
+ const repl = new EvRepl({ writeEvmux() {} })
28
+ const counts = []
29
+ repl._execPy = async source => {
30
+ const offset = Number(/f.seek\((\d+)\)/.exec(source)[1])
31
+ const count = Number(/f.read\((\d+)\)/.exec(source)[1])
32
+ counts.push(count)
33
+ await Promise.resolve()
34
+ const chunk = data.slice(offset, offset + count)
35
+ return `__EVREAD_OK__${data.length}:${chunk.length}:` + Buffer.from(chunk).toString('hex') + '\n'
36
+ }
37
+ const reading = repl.readFile('/data.bin', { maxBytes: data.length })
38
+ const after = repl.enqueue(() => { assert.equal(counts.length, 64) })
39
+ assert.deepEqual(await reading, data)
40
+ await after
41
+ assert.ok(counts.every(count => count <= 1024))
42
+ await assert.rejects(repl.readFile('/data.bin', { maxBytes: 1023 }), error => error.code === 'DEVICE_FILE_TOO_LARGE')
43
+ })
44
+
45
+ test('file errors, cancellation and a total deadline stop chunk reads', async () => {
46
+ const repl = new EvRepl({ writeEvmux() {} })
47
+ repl._execPy = async () => '__EVREAD_ERROR__OSError(2)\n'
48
+ await assert.rejects(repl.readFile('/missing'), /OSError\(2\)/)
49
+ await assert.rejects(repl.readFile('/file', { timeout: 0 }), /read timeout/)
50
+ const controller = new AbortController()
51
+ let chunks = 0
52
+ repl._execPy = async () => {
53
+ chunks += 1
54
+ controller.abort(new Error('cancelled'))
55
+ return '__EVREAD_OK__2048:1024:' + '00'.repeat(1024) + '\n'
56
+ }
57
+ await assert.rejects(repl.readFile('/file', { signal: controller.signal }), /cancelled/)
58
+ assert.equal(chunks, 1)
59
+ })
60
+
61
+ test('startup drain parses split frames without adopting old sessions', async () => {
62
+ let session
63
+ const frame = makeEvMuxFrame({ channel: 'user.rpc', type: 'event', method: 'hello', payload: '{}' })
64
+ session = fixture({ sleep: async () => {
65
+ session.feedBytes(frame.slice(0, 20))
66
+ session.feedBytes(frame.slice(20))
67
+ } })
68
+ session.rpc = async () => { throw new Error('first request reached') }
69
+ await assert.rejects(session.negotiate({ drainMs: 300 }), /first request reached/)
70
+ assert.equal(session.parser.getDiagnostics().droppedFrames, 1)
71
+ assert.equal(session.parser.getDiagnostics().bufferedBytes, 0)
72
+ assert.equal(session.sessionId, null)
73
+ session.close()
74
+ })
75
+
76
+ test('unfinished startup frames close the transport before any request', async () => {
77
+ let session, writes = 0, disconnects = 0
78
+ session = fixture({
79
+ transport: { async writeBytes() { writes += 1 }, async disconnect() { disconnects += 1 } },
80
+ sleep: async () => { session.feedBytes(new TextEncoder().encode('\x1eEVMUX/1 h=100 p=0 c=00000000\r\n{')) },
81
+ })
82
+ await assert.rejects(session.negotiate({ drainMs: 300 }), error => error.code === 'SERIAL_STARTUP_INCOMPLETE_FRAME')
83
+ assert.equal(writes, 0)
84
+ assert.equal(disconnects, 1)
85
+ assert.equal(session.state, 'disconnected')
86
+ })
87
+
88
+ test('plain console tails do not fail drain and TCP does not wait for drain', async () => {
89
+ for (const role of ['usj', 'tcp']) {
90
+ let sleeps = 0, session
91
+ session = fixture({ sleep: async () => {
92
+ sleeps += 1
93
+ session.feedBytes(new TextEncoder().encode('console\x1eordinary text'))
94
+ } })
95
+ if (role === 'tcp') session.feedBytes(new TextEncoder().encode('\x1eEVMUX/1 h='))
96
+ session.rpc = async () => { throw new Error('first request reached') }
97
+ await assert.rejects(session.negotiate({ role }), /first request reached/)
98
+ assert.equal(sleeps, role === 'tcp' ? 0 : 1)
99
+ session.close()
100
+ session.parser.reset()
101
+ }
102
+ })
103
+
104
+ test('unscoped authorization errors resolve only the matching RPC without adopting state', async () => {
105
+ const session = fixture()
106
+ session.sessionId = 'a'.repeat(32)
107
+ session.vmEpoch = 3
108
+ const pending = { channel: 'debug.rpc', method: 'debug.info', sessionId: session.sessionId, vmEpoch: 3, vmRequest: true }
109
+ session.pendingRpc.set(7, pending)
110
+ const metadata = { seq: 7, channel: 'debug.rpc', method: 'debug.info', type: 'rsp' }
111
+ for (const code of ['NOT_OWNER', 'SESSION_EXPIRED', 'SESSION_REQUIRED']) {
112
+ assert.equal(session._matchesPendingResponse(metadata, { ok: false, error: { code } }), true)
113
+ }
114
+ assert.equal(session._matchesPendingResponse(metadata, { ok: true }), false)
115
+ assert.equal(session._matchesPendingResponse(metadata, { ok: false, error: { code: 'WRITE_FAILED' } }), false)
116
+ assert.equal(session._matchesPendingResponse({ ...metadata, seq: 8 }, { ok: false, error: { code: 'NOT_OWNER' } }), false)
117
+ assert.equal(session.sessionId, 'a'.repeat(32))
118
+ assert.equal(session.vmEpoch, 3)
119
+ session.pendingRpc.clear()
120
+ session.close()
121
+ })
122
+
123
+ test('dropped stdout cannot masquerade as EOF, and changing files fail explicitly', async () => {
124
+ const repl = new EvRepl({ writeEvmux() {} })
125
+ repl._execPy = async () => '__EVREAD_OK__2048:1024:' + '00'.repeat(768) + '\n'
126
+ await assert.rejects(repl.readFile('/file'), error => error.code === 'DEVICE_FILE_READ_INCOMPLETE')
127
+ repl._execPy = async () => '__EVREAD_OK__2048:0:\n'
128
+ await assert.rejects(repl.readFile('/file'), error => error.code === 'DEVICE_FILE_READ_INCOMPLETE')
129
+ let index = 0
130
+ repl._execPy = async () => `__EVREAD_OK__${++index === 1 ? 2048 : 2049}:1024:` + '00'.repeat(1024) + '\n'
131
+ await assert.rejects(repl.readFile('/file'), error => error.code === 'DEVICE_FILE_CHANGED')
132
+ })
133
+
134
+
135
+ test('post-acquisition capabilities accepts a response beyond the old four-second deadline', async t => {
136
+ t.mock.timers.enable({ apis: ['setTimeout'] })
137
+ let session, waitingForCapabilities = false
138
+ const id = 'a'.repeat(32)
139
+ const caps = {
140
+ ok: true, protocol: { evMuxVersion: 4 },
141
+ features: ['capabilities', 'route.changed', 'framed.repl', 'session.firstCome', 'session.keepalive'],
142
+ channels: ['user.rpc', 'debug.rpc', 'repl.stdin', 'repl.stdout', 'repl.stderr', 'repl.signal', 'log.idf', 'preview.frame'],
143
+ limits: { metadataMax: 384, rxPayloadMax: 2048, replInputMax: 511, vmPendingMax: 1, txLargePayloadMax: 262144, txFrameTimeoutMs: 1500, txQueueTimeoutMs: 2000 },
144
+ session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 },
145
+ }
146
+ const reader = new EvMuxParser({ filterFrame: (m, p) => {
147
+ const request = JSON.parse(new TextDecoder().decode(p))
148
+ const acquire = m.method === 'hello' && request.acquire
149
+ const response = m.method === 'capabilities' ? caps : acquire
150
+ ? { ok: true, evMuxVersion: 4, session: { id, vmEpoch: 0, leaseMs: 15000, keepaliveMs: 5000 }, vmReady: false }
151
+ : { ok: true, evMuxVersion: 4, session: null }
152
+ const reply = () => session.feedBytes(makeEvMuxFrame({
153
+ channel: m.channel, type: 'rsp', method: m.method, seq: m.seq,
154
+ ...(acquire ? { sessionId: id, vmEpoch: 0 } : {}), payload: JSON.stringify(response),
155
+ }))
156
+ if (m.method === 'capabilities') {
157
+ waitingForCapabilities = true
158
+ setTimeout(reply, 4500)
159
+ } else reply()
160
+ return false
161
+ } })
162
+ session = fixture({ transport: { async writeBytes(bytes) { reader.feedBytes(bytes) } } })
163
+ t.after(() => session.close())
164
+ const negotiated = session.negotiate({ role: 'tcp', probeRepl: false })
165
+ for (let i = 0; i < 50 && !waitingForCapabilities; i++) await Promise.resolve()
166
+ assert.equal(waitingForCapabilities, true)
167
+ for (let i = 0; i < 10; i++) await Promise.resolve()
168
+ t.mock.timers.tick(4500)
169
+ assert.equal((await negotiated).capabilities.protocol.evMuxVersion, 4)
170
+ assert.equal(session.sessionId, id)
171
+ })
172
+
173
+
174
+ test('a TCP frame survives a three-second receive gap and incomplete frames still expire', t => {
175
+ t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
176
+ let response
177
+ const parser = new EvMuxParser({ rpc: (metadata, payload) => { response = payload } })
178
+ const frame = makeEvMuxFrame({ sid: 'user', channel: 'user.rpc', type: 'rsp', method: 'hello', seq: 1, payload: '{"ok":true}' })
179
+ parser.feedBytes(frame.slice(0, 80))
180
+ t.mock.timers.tick(3500)
181
+ assert.equal(parser.getDiagnostics().droppedFrames, 0)
182
+ parser.feedBytes(frame.slice(80))
183
+ assert.equal(response.ok, true)
184
+ parser.feedBytes(frame.slice(0, 80))
185
+ t.mock.timers.tick(12000)
186
+ assert.equal(parser.getDiagnostics().droppedFrames, 1)
187
+ assert.equal(parser.getDiagnostics().bufferedBytes, 0)
188
+ parser.reset()
189
+ })
@@ -0,0 +1,112 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { test } from 'node:test'
5
+ import { DeviceSession } from '../src/device_session.js'
6
+ import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
7
+ import { EvRepl } from '../src/evrepl.js'
8
+
9
+ function fixture(options = {}) {
10
+ const requests = [], chunks = []; let session, began, finished
11
+ let token
12
+ const peer = new EvMuxParser({ filterFrame(metadata, bytes) {
13
+ const payload = metadata.encoding === 'binary' ? bytes.slice() : JSON.parse(new TextDecoder().decode(bytes))
14
+ requests.push({ metadata, payload })
15
+ let reply = { ok: true }
16
+ if (payload instanceof Uint8Array) {
17
+ assert.equal(metadata.method, 'file.write'); assert.equal(metadata.encoding, 'binary')
18
+ assert.equal(metadata.vmEpoch, 7); assert.equal(metadata.sessionId, 'a'.repeat(32))
19
+ assert.equal(Buffer.from(payload.slice(0, 16)).toString('hex'), token)
20
+ const offset = new DataView(payload.buffer).getUint32(16, true)
21
+ assert.equal(offset, chunks.reduce((sum, chunk) => sum + chunk.length, 0))
22
+ chunks.push(payload.slice(20)); reply.writtenBytes = offset + payload.length - 20
23
+ if (options.badAck) reply.writtenBytes--
24
+ if (options.abort) options.abort.abort()
25
+ } else if (payload.action === 'begin') {
26
+ began = payload; token = payload.uploadId; reply.uploadId = token; reply.chunkBytes = 16384
27
+ if (options.abortBegin) options.abortBegin.abort()
28
+ if (options.failBegin) { options.failBegin = false; throw new Error('begin response lost') }
29
+ } else if (payload.action === 'commit') {
30
+ finished = true; reply.bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0); reply.complete = true
31
+ if (options.badCommit) reply.bytes++
32
+ if (options.failCommit) reply = { ok: false, error: { code: 'HASH_MISMATCH' } }
33
+ }
34
+ session.feedBytes(makeEvMuxFrame({ channel: 'user.rpc', type: 'rsp', method: metadata.method, seq: metadata.seq,
35
+ sessionId: 'a'.repeat(32), vmEpoch: 7, payload: JSON.stringify(reply) }))
36
+ return false
37
+ } })
38
+ session = new DeviceSession({ transport: { async writeBytes(bytes) { peer.feedBytes(bytes) } }, EvMuxParser, makeEvMuxFrame, EvRepl })
39
+ Object.assign(session, { state: 'ready', sessionId: 'a'.repeat(32), vmEpoch: 7, vmReady: true,
40
+ capabilities: { features: ['file.write.binary', 'script.run'] } })
41
+ session.limits.fileWriteChunkMax = 16384
42
+ return { session, requests, chunks, get began() { return began }, get finished() { return finished } }
43
+ }
44
+ for (const size of [0, 1, 4096, 16384, 16385, 2 * 1024 * 1024]) {
45
+ test(`binary upload ${size} bytes is bounded, exact, hashed and committed`, async () => {
46
+ const f = fixture(), data = Uint8Array.from({ length: size }, (_, i) => i & 255), progress = []
47
+ try {
48
+ const result = await f.session.writeFile('/model.bin', data, { encoding: 'binary', onProgress: value => progress.push(value.writtenBytes) })
49
+ assert.equal(result.complete, true); assert.equal(result.bytes, size)
50
+ assert.deepEqual(Buffer.concat(f.chunks), Buffer.from(data)); assert.equal(f.finished, true)
51
+ assert.equal(f.began.sha256, Buffer.from(await crypto.subtle.digest('SHA-256', data)).toString('hex'))
52
+ assert.ok(f.chunks.every(chunk => chunk.length <= 16384)); assert.equal(f.chunks.length, Math.ceil(size / 16384))
53
+ assert.equal(progress.at(-1), size)
54
+ assert.ok(!f.requests.some(request => request.metadata.method === 'script.write'))
55
+ } finally { f.session.close() }
56
+ })
57
+ }
58
+ test('Python upload uses the same byte engine and requests execution only after commit', async () => {
59
+ const f = fixture()
60
+ try {
61
+ await f.session.writeScript('/script.py', "print('中文')", { runAfterWrite: true })
62
+ assert.deepEqual(Buffer.concat(f.chunks), Buffer.from("print('中文')"))
63
+ assert.equal(f.requests.at(-1).metadata.method, 'script.run')
64
+ assert.equal(f.requests.at(-2).payload.action, 'commit')
65
+ } finally { f.session.close() }
66
+ })
67
+ for (const setting of ['badAck', 'badCommit', 'failCommit']) {
68
+ test(`${setting} triggers abort cleanup without publishing success`, async () => {
69
+ const f = fixture({ [setting]: true })
70
+ try {
71
+ await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100)))
72
+ assert.equal(f.requests.at(-1).payload.action, 'abort')
73
+ } finally { f.session.close() }
74
+ })
75
+ }
76
+ test('cancellation after begin still sends an independent abort', async () => {
77
+ const controller = new AbortController(), f = fixture({ abort: controller })
78
+ try {
79
+ await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(32768), { signal: controller.signal }), { name: 'AbortError' })
80
+ assert.equal(f.requests.at(-1).payload.action, 'abort')
81
+ } finally { f.session.close() }
82
+ })
83
+ test('missing binary capability rejects without falling back to script.write', async () => {
84
+ const f = fixture(); f.session.capabilities.features = ['script.write']
85
+ try { await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(10)), { code: 'UNSUPPORTED_OPERATION' }); assert.equal(f.requests.length, 0) }
86
+ finally { f.session.close() }
87
+ })
88
+ test('corrupted binary frames fail CRC before dispatch', () => {
89
+ let calls = 0
90
+ const parser = new EvMuxParser({ filterFrame() { calls++; return false } })
91
+ const bytes = makeEvMuxFrame({ channel: 'user.rpc', type: 'req', method: 'file.write', encoding: 'binary', payload: new Uint8Array([0, 1, 0x1e, 0x1f, 255]) })
92
+ parser.feedBytes(bytes); assert.equal(calls, 1)
93
+ bytes[bytes.length - 2] ^= 1; parser.feedBytes(bytes); assert.equal(calls, 1)
94
+ })
95
+
96
+ test('cancellation during begin still aborts the client-known upload ID', async () => {
97
+ const controller = new AbortController(), f = fixture({ abortBegin: controller })
98
+ try {
99
+ await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100), { signal: controller.signal }), { name: 'AbortError' })
100
+ assert.equal(f.requests.at(-1).payload.action, 'abort')
101
+ assert.equal(f.requests.at(-1).payload.uploadId, f.began.uploadId)
102
+ assert.equal(f.chunks.length, 0)
103
+ } finally { f.session.close() }
104
+ })
105
+ test('a lost begin response still allows abort without knowing a server-generated ID', async () => {
106
+ const f = fixture({ failBegin: true })
107
+ try {
108
+ await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100)), /begin response lost/)
109
+ assert.equal(f.requests.at(-1).payload.action, 'abort')
110
+ assert.equal(f.requests.at(-1).payload.uploadId, f.began.uploadId)
111
+ } finally { f.session.close() }
112
+ })
@@ -0,0 +1,89 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { FirmwareFlashSession, firmwareSha256, validateFirmwareImage } from '../src/firmware_flash.js'
4
+ async function fixture() {
5
+ const bytes = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5, 6]), calls = []
6
+ const image = { evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, offset: 0x10000, bytes: 4, partitionSize: 0x700000, sha256: await firmwareSha256(bytes), partitionTable: { offset: 0x8000, bytes: 3, sha256: await firmwareSha256(table) } }
7
+ const stub = { async readFlash(offset) { calls.push(['read', offset]); return offset === 0x8000 ? table : bytes }, async readRegister() { return 0x1dfbc7f8 }, async writeRegister() { throw new Error('unexpected register write') }, async flashData(_data, progress, offset, compress) { calls.push(['write', offset, compress]); progress(4, 4) } }
8
+ const loader = { chipFamily: image.chipFamily, chipRevision: image.chipRevision, async initialize() { calls.push(['initialize']) }, async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } }, async runStub(skip) { calls.push(['stub', skip]); return stub }, async readRegister() { return 0x1dfbc7f8 }, async writeRegister() { throw new Error('unexpected register write') }, async hardReset() { calls.push(['reset']) }, async disconnect() { calls.push(['close']) } }
9
+ return { bytes, table, image, calls, loader, stub, session: new FirmwareFlashSession(loader, image) }
10
+ }
11
+ test('only exact approved v4 images pass before touching the ROM loader', async () => {
12
+ const f = await fixture()
13
+ await assert.rejects(validateFirmwareImage({ ...f.image, evMuxVersion: 3 }, f.bytes), /v4/)
14
+ await assert.rejects(f.session.connect(new Uint8Array([0xe9, 1, 2, 0])), /SHA-256/)
15
+ assert.deepEqual(f.calls, [['close']])
16
+ })
17
+ test('wrong chip/revision and partition layout fail before any flash write', async () => {
18
+ for (const change of ['revision', 'partition']) {
19
+ const f = await fixture()
20
+ if (change === 'revision') f.loader.chipRevision = 301
21
+ else f.image.partitionTable.sha256 = '0'.repeat(64)
22
+ await assert.rejects(f.session.connect(f.bytes), change === 'revision' ? /revision/ : /Partition layout/)
23
+ assert.ok(!f.calls.some(([operation]) => operation === 'write'))
24
+ assert.equal(f.calls.at(-1)[0], 'close')
25
+ }
26
+ })
27
+ test('application-only write is read back in full before reset, with port cleanup', async () => {
28
+ const f = await fixture()
29
+ await f.session.connect(f.bytes)
30
+ await f.session.flash(f.bytes)
31
+ assert.deepEqual(f.calls, [['initialize'], ['stub', true], ['read', 0x8000], ['write', 0x10000, false], ['read', 0x10000], ['reset'], ['close']])
32
+ })
33
+ test('corrupt readback never reports success or resets into unverified firmware', async () => {
34
+ const f = await fixture()
35
+ await f.session.connect(f.bytes)
36
+ f.stub.readFlash = async () => new Uint8Array([9, 9, 9, 9])
37
+ await assert.rejects(f.session.flash(f.bytes), /verification failed/)
38
+ assert.ok(!f.calls.some(([operation]) => operation === 'reset'))
39
+ assert.equal(f.calls.at(-1)[0], 'close')
40
+ })
41
+ test('reset failure preserves verified result and asks for manual reset', async () => {
42
+ const f = await fixture(), messages = []
43
+ await f.session.connect(f.bytes)
44
+ f.loader.hardReset = async () => { throw new Error('reset unavailable') }
45
+ await f.session.flash(f.bytes, { onPhase: message => messages.push(message) })
46
+ assert.match(messages.at(-1), /verified.*RESET/)
47
+ })
48
+ test('full verification uses bounded 1 KiB packets and a single outstanding packet', async () => {
49
+ const f = await fixture(), bytes = new Uint8Array(65537), reads = []
50
+ f.image.bytes = bytes.length; f.image.sha256 = await firmwareSha256(bytes)
51
+ f.stub.readFlash = async (offset, count, _progress, options) => {
52
+ if (offset === 0x8000) return f.table
53
+ reads.push({ offset, count, options })
54
+ return bytes.slice(offset - f.image.offset, offset - f.image.offset + count)
55
+ }
56
+ await f.session.connect(bytes)
57
+ await f.session.flash(bytes)
58
+ assert.deepEqual(reads.map(item => item.count), [65536, 1])
59
+ for (const read of reads) assert.deepEqual(read.options, { chunkSize: 65536, blockSize: 1024, maxInFlight: 1 })
60
+ })
61
+
62
+ test('unsigned application updates refuse secure boot and flash encryption before loading a stub', async () => {
63
+ for (const security of [{ flags: 1, flashCryptCnt: 0 }, { flags: 0, flashCryptCnt: 1 }]) {
64
+ const f = await fixture()
65
+ f.loader.getSecurityInfo = async () => security
66
+ await assert.rejects(f.session.connect(f.bytes), /signed flashing workflow/)
67
+ assert.deepEqual(f.calls, [['initialize'], ['close']])
68
+ }
69
+ })
70
+
71
+
72
+ test('P4 software download latch is cleared with a mask and verified before reset', async () => {
73
+ const f = await fixture(), registers = []; let register = 0x1dfbc7fc
74
+ f.stub.readRegister = async address => { registers.push(['read', address]); return register }
75
+ f.stub.writeRegister = async (address, value, mask) => { registers.push(['write', address, value, mask]); register &= ~mask }
76
+ f.loader.hardReset = async () => { assert.equal(register, 0x1dfbc7f8); f.calls.push(['reset']) }
77
+ await f.session.connect(f.bytes); await f.session.flash(f.bytes)
78
+ assert.equal(register, 0x1dfbc7f8)
79
+ assert.deepEqual(registers, [['read', 0x50110008], ['write', 0x50110008, 0, 4], ['read', 0x50110008]])
80
+ assert.ok(f.calls.some(([operation]) => operation === 'reset'))
81
+ })
82
+ test('P4 latch clear failure does not reset and retains manual recovery guidance', async () => {
83
+ const f = await fixture(), messages = []
84
+ f.stub.readRegister = async () => 0x1dfbc7fc
85
+ f.stub.writeRegister = async () => {}
86
+ await f.session.connect(f.bytes); await f.session.flash(f.bytes, { onPhase: message => messages.push(message) })
87
+ assert.ok(!f.calls.some(([operation]) => operation === 'reset'))
88
+ assert.match(messages.at(-1), /verified.*RESET/)
89
+ })
@@ -0,0 +1,153 @@
1
+ // SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { EventEmitter } from 'node:events'
5
+ import { test } from 'node:test'
6
+ import { createNodeDiscovery } from '../src/network_discovery_node.js'
7
+ import { NetworkDiscovery, createBridgeDiscovery } from '../src/network_discovery.js'
8
+ import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
9
+ import { EvRepl } from '../src/evrepl.js'
10
+ import { DeviceSession } from '../src/device_session.js'
11
+ const hostname = 'esp-vision-esp32-s3-eye-a1b2c3-0-9-1'
12
+ const instance = hostname + '._esp-vision._tcp.local'
13
+ const device = { deviceId: 'aabbcca1b2c3', hostname, board: 'ESP32_S3_EYE', firmwareVersion: '0.9.1', evMuxVersion: 4, port: 3232, addresses: ['192.168.2.3'] }
14
+ const records = [
15
+ { name: '_esp-vision._tcp.local', type: 'PTR', ttl: 60, data: instance },
16
+ { name: instance, type: 'SRV', ttl: 60, data: { target: hostname + '.local', port: 3232 } },
17
+ { name: instance, type: 'TXT', ttl: 60, data: Object.entries({ txtvers: '1', id: device.deviceId, hostname, board: device.board, fw: device.firmwareVersion, evmux: '4' }).map(([k, v]) => Buffer.from(`${k}=${v}`)) },
18
+ { name: hostname + '.local', type: 'A', ttl: 60, data: '192.168.2.3' },
19
+ ]
20
+ function provider(t, emit) {
21
+ const mdns = new EventEmitter()
22
+ mdns.query = () => { if (!mdns.sent) { mdns.sent = true; emit(mdns) } }
23
+ mdns.destroy = () => { mdns.closed = true }
24
+ t.after(() => assert.equal(mdns.closed, true))
25
+ return createNodeDiscovery({ createMdns: () => mdns })
26
+ }
27
+
28
+ test('split PTR/SRV/TXT/A records resolve, duplicate packets deduplicate and v4 metadata stays intact', async t => {
29
+ const discovery = provider(t, mdns => queueMicrotask(() => {
30
+ mdns.emit('response', { answers: records.slice(0, 2) })
31
+ mdns.emit('response', { additionals: records.slice(2) })
32
+ mdns.emit('response', { answers: records })
33
+ }))
34
+ const results = await discovery.list({ timeout: 50 })
35
+ assert.equal(results.length, 1)
36
+ assert.deepEqual({ ...results[0], expiresAt: undefined, compatible: undefined }, { ...device, expiresAt: undefined, compatible: undefined })
37
+ })
38
+
39
+ test('goodbye removes a device, and an expired address cannot appear online', async t => {
40
+ let now = 1000
41
+ t.mock.method(Date, 'now', () => now)
42
+ const discovery = provider(t, mdns => queueMicrotask(() => {
43
+ mdns.emit('response', { answers: records })
44
+ mdns.emit('response', { answers: [{ ...records[0], ttl: 0 }] })
45
+ now += 1001
46
+ }))
47
+ assert.deepEqual(await discovery.list({ timeout: 50 }), [])
48
+ })
49
+
50
+ test('cache flush preserves addresses split across a burst of packets', async t => {
51
+ let now = 1000
52
+ t.mock.method(Date, 'now', () => now)
53
+ const discovery = provider(t, mdns => queueMicrotask(() => {
54
+ mdns.emit('response', { answers: records })
55
+ now += 500
56
+ mdns.emit('response', { answers: [{ ...records[3], flush: true, data: '192.168.2.4' }] })
57
+ }))
58
+ assert.deepEqual((await discovery.list({ timeout: 50 }))[0].addresses.sort(), ['192.168.2.3', '192.168.2.4'])
59
+ })
60
+
61
+ test('old addresses expire after the cache-flush grace period', async t => {
62
+ let now = 1000
63
+ t.mock.method(Date, 'now', () => now)
64
+ const discovery = provider(t, mdns => queueMicrotask(() => {
65
+ mdns.emit('response', { answers: records })
66
+ now += 2000
67
+ mdns.emit('response', { answers: [{ ...records[3], flush: true, data: '192.168.2.4' }] })
68
+ now += 1001
69
+ }))
70
+ assert.deepEqual((await discovery.list({ timeout: 50 }))[0].addresses, ['192.168.2.4'])
71
+ })
72
+
73
+ test('re-announcement rescues a record during the goodbye grace period', async t => {
74
+ let now = 1000
75
+ t.mock.method(Date, 'now', () => now)
76
+ const discovery = provider(t, mdns => queueMicrotask(() => {
77
+ mdns.emit('response', { answers: records })
78
+ mdns.emit('response', { answers: [{ ...records[0], ttl: 0 }] })
79
+ now += 500
80
+ mdns.emit('response', { answers: [records[0]] })
81
+ now += 1001
82
+ }))
83
+ assert.equal((await discovery.list({ timeout: 50 })).length, 1)
84
+ })
85
+
86
+ test('expired cache entries cannot block discovery at the record limit', async t => {
87
+ let now = 1000
88
+ t.mock.method(Date, 'now', () => now)
89
+ const discovery = provider(t, mdns => queueMicrotask(() => {
90
+ for (let start = 0; start < 2048; start += 256) mdns.emit('response', { answers: Array.from({ length: 256 }, (_, i) => ({
91
+ name: `unrelated-${start + i}.local`, type: 'A', ttl: 1, data: '192.168.2.10',
92
+ })) })
93
+ now += 2000
94
+ mdns.emit('response', { answers: records })
95
+ }))
96
+ assert.equal((await discovery.list({ timeout: 50 })).length, 1)
97
+ })
98
+
99
+ test('cancellation during adapter creation still closes the socket', async () => {
100
+ const controller = new AbortController()
101
+ const mdns = new EventEmitter()
102
+ let closed = false
103
+ mdns.destroy = () => { closed = true }
104
+ mdns.query = () => assert.fail('aborted discovery must not query')
105
+ const discovery = createNodeDiscovery({ createMdns() {
106
+ controller.abort(new Error('cancel during setup'))
107
+ return mdns
108
+ } })
109
+ await assert.rejects(discovery.list({ signal: controller.signal }), /cancel during setup/)
110
+ assert.equal(closed, true)
111
+ })
112
+
113
+ test('SRV targets inconsistent with the advertised hostname are ignored', async t => {
114
+ const discovery = provider(t, mdns => queueMicrotask(() => mdns.emit('response', { answers: [
115
+ records[0], { ...records[1], data: { ...records[1].data, target: 'other.local' } }, records[2],
116
+ { ...records[3], name: 'other.local' },
117
+ ] })))
118
+ assert.deepEqual(await discovery.list({ timeout: 50 }), [])
119
+ })
120
+
121
+ test('cancellation releases mDNS resources and does not resolve a partial device', async t => {
122
+ const controller = new AbortController()
123
+ const discovery = provider(t, () => queueMicrotask(() => controller.abort(new Error('cancel discovery'))))
124
+ await assert.rejects(discovery.list({ timeout: 3000, signal: controller.signal }), /cancel discovery/)
125
+ })
126
+
127
+ test('unrelated/malformed advertisements and public destinations are ignored', async t => {
128
+ const discovery = provider(t, mdns => queueMicrotask(() => mdns.emit('response', { answers: [
129
+ ...records.slice(0, 3), { ...records[3], data: '8.8.8.8' },
130
+ ] })))
131
+ assert.deepEqual(await discovery.list({ timeout: 50 }), [])
132
+ })
133
+
134
+ test('browser bridge URLs stay on the configured origin and reject arbitrary forwarding URLs', async () => {
135
+ const bridge = 'ws://127.0.0.1:8766/device'
136
+ const handle = '/device/' + 'a'.repeat(32)
137
+ const valid = createBridgeDiscovery(bridge, { fetch: async () => ({ ok: true, json: async () => ({ devices: [{ ...device, connectionUrl: handle }] }) }) })
138
+ assert.equal((await valid.list({ timeout: 50 }))[0].connectionUrl, 'ws://127.0.0.1:8766' + handle)
139
+ const invalid = createBridgeDiscovery(bridge, { fetch: async () => ({ ok: true, json: async () => ({ devices: [{ ...device, connectionUrl: 'ws://attacker.example' + handle }] }) }) })
140
+ await assert.rejects(invalid.list({ timeout: 50 }), /Invalid discovered connection URL/)
141
+ const controller = new AbortController(); controller.abort(new Error('already cancelled'))
142
+ await assert.rejects(new NetworkDiscovery({ list() { assert.fail('must not browse') } }).list({ signal: controller.signal }), /already cancelled/)
143
+ })
144
+
145
+ test('a changed IP leading to the wrong device fails before any acquire RPC', async () => {
146
+ const session = new DeviceSession({ transport: { writeBytes() {} }, EvMuxParser, makeEvMuxFrame, EvRepl })
147
+ const requests = []
148
+ session.rpc = async (method, payload) => { requests.push({ method, payload }); return { ...device, deviceId: '000000000000', evMuxVersion: 4 } }
149
+ await assert.rejects(session.negotiate({ role: 'tcp', expectedDevice: device }), error => error.code === 'DEVICE_IDENTITY_MISMATCH')
150
+ assert.deepEqual(requests, [{ method: 'hello', payload: {} }])
151
+ assert.equal(session.sessionId, null)
152
+ session.close()
153
+ })
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { test } from 'node:test'
5
+ import { verifyPublishedIntegrity } from '../scripts/verify-registry.mjs'
6
+
7
+ const integrity = 'sha512-test-content'
8
+ const url = 'https://registry.npmjs.org/%40esp-vision%2Fdevice-core/0.2.0'
9
+ const published = value => new Response(JSON.stringify({ dist: { integrity: value } }))
10
+ function fixture(replies) {
11
+ const calls = [], waits = [], reports = []
12
+ return { calls, waits, reports, options: {
13
+ async fetcher(address, options) {
14
+ calls.push({ address, options })
15
+ const reply = replies.shift()
16
+ if (reply instanceof Error) throw reply
17
+ assert(reply, 'Unexpected verification request')
18
+ return reply
19
+ },
20
+ wait: async milliseconds => { waits.push(milliseconds) },
21
+ report: message => reports.push(message),
22
+ } }
23
+ }
24
+ test('new npm version becomes visible after stale 404 metadata; only uncached reads repeat', async () => {
25
+ const f = fixture([new Response('', { status: 404 }), published(integrity)])
26
+ await verifyPublishedIntegrity(url, integrity, f.options)
27
+ assert.equal(f.calls.length, 2); assert.deepEqual(f.waits, [1000])
28
+ for (const call of f.calls) {
29
+ assert.equal(call.address, url); assert.equal(call.options.cache, 'no-store')
30
+ assert.equal(call.options.headers['Cache-Control'], 'no-cache')
31
+ assert.equal(call.options.method ?? 'GET', 'GET')
32
+ }
33
+ })
34
+ test('verification retries rate limits, temporary registry errors and network failures', async () => {
35
+ const f = fixture([new Response('', { status: 429 }), new Response('', { status: 503 }), new TypeError('network unavailable'), published(integrity)])
36
+ await verifyPublishedIntegrity(url, integrity, f.options)
37
+ assert.deepEqual(f.waits, [1000, 2000, 4000])
38
+ })
39
+ for (const status of [400, 401, 403]) test(`registry HTTP ${status} fails without retries`, async () => {
40
+ const f = fixture([new Response('', { status })])
41
+ await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), new RegExp(`HTTP ${status}`))
42
+ assert.equal(f.calls.length, 1); assert.deepEqual(f.waits, [])
43
+ })
44
+ test('published integrity mismatch fails immediately', async () => {
45
+ const f = fixture([published('sha512-other-content')])
46
+ await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /integrity mismatch/)
47
+ assert.equal(f.calls.length, 1); assert.deepEqual(f.waits, [])
48
+ })
49
+ test('missing published integrity is not accepted', async () => {
50
+ const f = fixture([new Response('{}')])
51
+ await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /integrity mismatch/)
52
+ assert.deepEqual(f.waits, [])
53
+ })
54
+ test('persistent propagation failures stop after six reads with bounded backoff', async () => {
55
+ const f = fixture(Array.from({ length: 6 }, () => new Response('', { status: 404 })))
56
+ await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /after 6 reads/)
57
+ assert.equal(f.calls.length, 6); assert.deepEqual(f.waits, [1000, 2000, 4000, 8000, 16000])
58
+ })