@esp-vision/device-core 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/NOTICE +8 -0
- package/README.md +24 -7
- package/package.json +4 -3
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/release-npm.mjs +16 -0
- 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 +76 -27
- package/src/node_rom.py +68 -2
- package/src/node_rom_port.js +211 -0
- package/src/node_serial.py +47 -1
- package/src/node_transport.js +76 -10
- package/src/rom_md5.js +30 -0
- package/tests/check-browser.mjs +76 -123
- package/tests/check-package.mjs +1 -1
- 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-jpeg-validation.mjs +233 -0
- package/tests/test-node-rom-port.mjs +305 -0
- package/tests/test-node-rom.mjs +160 -0
- package/tests/test-node-serial.mjs +206 -0
- package/tests/test-npm-release-policy.mjs +62 -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,292 @@
|
|
|
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 { 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({ reset = 'ack', release = 'ack' } = {}) {
|
|
10
|
+
let session, respondReset, respondRelease
|
|
11
|
+
const requests = [], owner = 'a'.repeat(32)
|
|
12
|
+
const reply = (metadata, payload, owned = true) => session.feedBytes(makeEvMuxFrame({
|
|
13
|
+
channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq,
|
|
14
|
+
...(owned ? { sessionId: owner, vmEpoch: 1 } : {}), payload: JSON.stringify(payload),
|
|
15
|
+
}))
|
|
16
|
+
const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
|
|
17
|
+
const payload = metadata.channel.endsWith('.rpc') ? JSON.parse(new TextDecoder().decode(bytes)) : new TextDecoder().decode(bytes)
|
|
18
|
+
requests.push({ metadata, payload })
|
|
19
|
+
if (metadata.method === 'device.control' && payload.action === 'reset') {
|
|
20
|
+
if (['SESSION_REQUIRED', 'SESSION_EXPIRED', 'NOT_OWNER'].includes(reset)) {
|
|
21
|
+
reply(metadata, { ok: false, error: { code: reset, message: 'Session is no longer owned' } }, false)
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
respondReset = () => reply(metadata, reset === 'reject'
|
|
25
|
+
? { ok: false, error: { code: 'RESET_DENIED', message: 'Reset was rejected' } }
|
|
26
|
+
: { ok: true, action: 'reset' })
|
|
27
|
+
if (reset === 'write-error') throw Object.assign(new Error('Write failed'), { code: 'TRANSPORT_WRITE' })
|
|
28
|
+
if (reset === 'ack' || reset === 'reject') respondReset()
|
|
29
|
+
} else if (metadata.method === 'session.release') {
|
|
30
|
+
respondRelease = () => reply(metadata, { ok: true, accepted: true })
|
|
31
|
+
if (release === 'ack') respondRelease()
|
|
32
|
+
}
|
|
33
|
+
return false
|
|
34
|
+
} })
|
|
35
|
+
session = new DeviceSession({ transport: { async writeBytes(bytes) { parser.feedBytes(bytes) } }, EvMuxParser, makeEvMuxFrame, EvRepl })
|
|
36
|
+
session.state = 'ready'
|
|
37
|
+
session.sessionId = owner
|
|
38
|
+
session.vmEpoch = 1
|
|
39
|
+
session.vmReady = true
|
|
40
|
+
session.capabilities = { features: ['device.control'] }
|
|
41
|
+
return { session, requests, owner, ack: () => respondReset(), ackRelease: () => respondRelease() }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('hard-reset ACK invalidates the old session even while USJ remains open', async t => {
|
|
45
|
+
const f = fixture(); t.after(() => f.session.close())
|
|
46
|
+
f.session._startKeepalive()
|
|
47
|
+
const reset = f.session.control('hard-reset')
|
|
48
|
+
assert.equal(f.session.state, 'resetting')
|
|
49
|
+
assert.equal(f.session.keepaliveTimer, null)
|
|
50
|
+
assert.deepEqual(await reset, { ok: true, action: 'reset' })
|
|
51
|
+
assert.equal(f.session.state, 'disconnected')
|
|
52
|
+
assert.equal(f.session.sessionId, null)
|
|
53
|
+
assert.equal(f.session.keepaliveTimer, null)
|
|
54
|
+
assert.deepEqual(await f.session.release(), { ok: true, accepted: false })
|
|
55
|
+
assert.equal(f.requests.filter(r => r.metadata.method === 'session.release').length, 0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('reset timeout stays an unknown failure and cannot become a cleanup timeout', async t => {
|
|
59
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
60
|
+
const f = fixture({ reset: 'silent' }); t.after(() => f.session.close())
|
|
61
|
+
const reset = f.session.control('hard-reset', { timeout: 50 })
|
|
62
|
+
const rejected = assert.rejects(reset, error => error.code === 'TIMEOUT'
|
|
63
|
+
&& error.message === 'EV-MUX device.control timeout' && error.reset?.outcome === 'unknown')
|
|
64
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
65
|
+
t.mock.timers.tick(50)
|
|
66
|
+
await rejected
|
|
67
|
+
assert.equal(f.session.sessionId, null)
|
|
68
|
+
await f.session.release()
|
|
69
|
+
assert.equal(f.requests.length, 1, 'an uncertain reset is neither replayed nor followed by stale release')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('transport loss and a partial write retain their original reset failures', async t => {
|
|
73
|
+
for (const mode of ['silent', 'write-error']) {
|
|
74
|
+
const f = fixture({ reset: mode }); t.after(() => f.session.close())
|
|
75
|
+
const result = f.session.control('hard-reset')
|
|
76
|
+
const expected = mode === 'silent' ? 'LINK_LOST' : 'TRANSPORT_WRITE'
|
|
77
|
+
const rejected = assert.rejects(result, error => error.code === expected && error.reset?.outcome === 'unknown')
|
|
78
|
+
if (mode === 'silent') f.session.close(Object.assign(new Error('Link lost'), { code: expected }))
|
|
79
|
+
await rejected
|
|
80
|
+
assert.equal(f.session.sessionId, null)
|
|
81
|
+
await f.session.release()
|
|
82
|
+
assert.equal(f.requests.length, 1)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('pre-cancelled reset leaves the lease live; cancellation after submission invalidates it', async t => {
|
|
87
|
+
for (const before of [true, false]) {
|
|
88
|
+
const f = fixture({ reset: 'silent' }); t.after(() => f.session.close())
|
|
89
|
+
const controller = new AbortController()
|
|
90
|
+
const reason = Object.assign(new Error('Cancelled'), { code: 'CANCELLED' })
|
|
91
|
+
if (before) controller.abort(reason)
|
|
92
|
+
const reset = f.session.control('hard-reset', { signal: controller.signal })
|
|
93
|
+
const rejected = assert.rejects(reset, error => error === reason)
|
|
94
|
+
if (!before) controller.abort(reason)
|
|
95
|
+
await rejected
|
|
96
|
+
assert.equal(f.session.sessionId, before ? f.owner : null)
|
|
97
|
+
assert.equal(f.requests.length, before ? 0 : 1)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('a confirmed reset rejection retains its live lease and normal release behavior', async t => {
|
|
102
|
+
const f = fixture({ reset: 'reject' }); t.after(() => f.session.close())
|
|
103
|
+
await assert.rejects(f.session.control('hard-reset'), { code: 'RESET_DENIED' })
|
|
104
|
+
assert.equal(f.session.state, 'ready')
|
|
105
|
+
assert.equal(f.session.sessionId, f.owner)
|
|
106
|
+
assert.notEqual(f.session.keepaliveTimer, null)
|
|
107
|
+
await f.session.release()
|
|
108
|
+
assert.equal(f.requests.filter(r => r.metadata.method === 'session.release').length, 1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('an ownership rejection discards the obsolete lease without claiming an unknown reset outcome', async t => {
|
|
112
|
+
for (const code of ['SESSION_REQUIRED', 'SESSION_EXPIRED', 'NOT_OWNER']) {
|
|
113
|
+
const f = fixture({ reset: code }); t.after(() => f.session.close())
|
|
114
|
+
await assert.rejects(f.session.control('hard-reset'), error => error.code === code && !error.reset)
|
|
115
|
+
assert.equal(f.session.state, 'disconnected')
|
|
116
|
+
assert.equal(f.session.sessionId, null)
|
|
117
|
+
await f.session.release()
|
|
118
|
+
assert.equal(f.requests.length, 1)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('ordinary release timeouts and release failures after a rejected reset remain visible', async t => {
|
|
123
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
124
|
+
for (const rejectedReset of [false, true]) {
|
|
125
|
+
const f = fixture({ reset: 'reject', release: 'silent' }); t.after(() => f.session.close())
|
|
126
|
+
if (rejectedReset) await assert.rejects(f.session.control('hard-reset'), { code: 'RESET_DENIED' })
|
|
127
|
+
const released = f.session.release({ timeout: 50 })
|
|
128
|
+
const rejected = assert.rejects(released, { code: 'TIMEOUT', message: 'EV-MUX session.release timeout' })
|
|
129
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
130
|
+
t.mock.timers.tick(50)
|
|
131
|
+
await rejected
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('release joins a pending reset; concurrent commands cannot write during reset', async t => {
|
|
136
|
+
const f = fixture({ reset: 'defer' }); t.after(() => f.session.close())
|
|
137
|
+
const resetting = f.session.control('hard-reset')
|
|
138
|
+
await assert.rejects(f.session.control('hard-reset'), { code: 'DEVICE_RESETTING' })
|
|
139
|
+
await assert.rejects(f.session.control('stop'), { code: 'DEVICE_RESETTING' })
|
|
140
|
+
await assert.rejects(f.session.negotiate({ drainMs: 0 }), { code: 'DEVICE_RESETTING' })
|
|
141
|
+
assert.equal(f.session.state, 'resetting')
|
|
142
|
+
assert.equal(f.requests.length, 1)
|
|
143
|
+
const released = f.session.release()
|
|
144
|
+
f.ack()
|
|
145
|
+
await resetting
|
|
146
|
+
assert.deepEqual(await released, { ok: true, accepted: false })
|
|
147
|
+
assert.equal(f.requests.length, 1)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('accepted reset settles pre-existing RPCs and clears pending timers', async t => {
|
|
151
|
+
const f = fixture(); t.after(() => f.session.close())
|
|
152
|
+
const pending = f.session.rpc('debug.info', { scope: 'transport.stats' })
|
|
153
|
+
const rejected = assert.rejects(pending, { code: 'DEVICE_RESET' })
|
|
154
|
+
await f.session.control('hard-reset')
|
|
155
|
+
await rejected
|
|
156
|
+
assert.equal(f.session.pendingRpc.size, 0)
|
|
157
|
+
assert.equal(f.session.keepaliveTimer, null)
|
|
158
|
+
assert.equal(f.session.resetPromise, null)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('a release already in flight excludes a new destructive reset', async t => {
|
|
162
|
+
const f = fixture({ release: 'defer' }); t.after(() => f.session.close())
|
|
163
|
+
const releasing = f.session.release()
|
|
164
|
+
assert.equal(f.session.state, 'ready', 'release has not received its ACK yet')
|
|
165
|
+
await assert.rejects(f.session.control('hard-reset'), { code: 'DEVICE_RELEASING' })
|
|
166
|
+
assert.deepEqual(f.requests.map(r => r.metadata.method), ['session.release'])
|
|
167
|
+
f.ackRelease()
|
|
168
|
+
assert.deepEqual(await releasing, { ok: true, accepted: true })
|
|
169
|
+
assert.equal(f.session.sessionId, null)
|
|
170
|
+
assert.equal(f.session.pendingRpc.size, 0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
function replResetFixture({ write = async () => {} } = {}) {
|
|
174
|
+
const f = fixture()
|
|
175
|
+
const original = f.session.transport.writeBytes
|
|
176
|
+
f.session.transport.writeBytes = async bytes => {
|
|
177
|
+
await original(bytes)
|
|
178
|
+
const request = f.requests.at(-1)
|
|
179
|
+
if (request.metadata.channel.startsWith('repl.')) await write(request)
|
|
180
|
+
}
|
|
181
|
+
return f
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
test('bootloader submission invalidates the lease without claiming a ROM acknowledgement', async t => {
|
|
185
|
+
const f = replResetFixture(); t.after(() => f.session.close())
|
|
186
|
+
f.session._startKeepalive()
|
|
187
|
+
assert.deepEqual(await f.session.control('bootloader'), {
|
|
188
|
+
ok: true, operation: 'bootloader', acknowledged: false, outcome: 'unknown',
|
|
189
|
+
})
|
|
190
|
+
assert.equal(f.session.sessionId, null)
|
|
191
|
+
assert.equal(f.session.keepaliveTimer, null)
|
|
192
|
+
assert.deepEqual(await f.session.release(), { ok: true, accepted: false })
|
|
193
|
+
assert.deepEqual(f.requests.map(r => r.metadata.channel), ['repl.signal', 'repl.stdin'])
|
|
194
|
+
assert.match(f.requests[1].payload, /machine\.bootloader\(\)/)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('release joins a pending bootloader write and excludes concurrent commands', async t => {
|
|
198
|
+
let complete
|
|
199
|
+
const f = replResetFixture({ write: request => request.metadata.channel === 'repl.stdin'
|
|
200
|
+
? new Promise(resolve => { complete = resolve }) : undefined })
|
|
201
|
+
t.after(() => f.session.close())
|
|
202
|
+
const bootloader = f.session.control('bootloader')
|
|
203
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
204
|
+
assert.equal(typeof complete, 'function')
|
|
205
|
+
await assert.rejects(f.session.control('bootloader'), { code: 'DEVICE_RESETTING' })
|
|
206
|
+
await assert.rejects(f.session.control('stop'), { code: 'DEVICE_RESETTING' })
|
|
207
|
+
await assert.rejects(f.session.control('hard-reset'), { code: 'DEVICE_RESETTING' })
|
|
208
|
+
const released = f.session.release()
|
|
209
|
+
complete()
|
|
210
|
+
await bootloader
|
|
211
|
+
assert.deepEqual(await released, { ok: true, accepted: false })
|
|
212
|
+
assert.equal(f.requests.length, 2)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('bootloader partial writes preserve the original failure without stale release or replay', async t => {
|
|
216
|
+
const failure = Object.assign(new Error('USB interface removed during write'), { code: 'USB_GONE' })
|
|
217
|
+
const f = replResetFixture({ write: request => {
|
|
218
|
+
if (request.metadata.channel === 'repl.stdin') throw failure
|
|
219
|
+
} }); t.after(() => f.session.close())
|
|
220
|
+
await assert.rejects(f.session.control('bootloader'), error => error === failure
|
|
221
|
+
&& error.reset?.outcome === 'unknown' && error.reset.acknowledged === false)
|
|
222
|
+
await f.session.release()
|
|
223
|
+
assert.equal(f.session.sessionId, null)
|
|
224
|
+
assert.equal(f.requests.length, 2)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('pre-cancelled bootloader leaves its lease; cancellation during submission invalidates it', async t => {
|
|
228
|
+
for (const before of [true, false]) {
|
|
229
|
+
const controller = new AbortController()
|
|
230
|
+
const reason = Object.assign(new Error('Cancelled'), { code: 'CANCELLED' })
|
|
231
|
+
const f = replResetFixture({ write: request => request.metadata.channel === 'repl.stdin'
|
|
232
|
+
? new Promise(() => {}) : undefined }); t.after(() => f.session.close())
|
|
233
|
+
if (before) controller.abort(reason)
|
|
234
|
+
const result = f.session.control('bootloader', { signal: controller.signal })
|
|
235
|
+
const rejected = assert.rejects(result, error => error === reason)
|
|
236
|
+
if (!before) {
|
|
237
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
238
|
+
controller.abort(reason)
|
|
239
|
+
}
|
|
240
|
+
await rejected
|
|
241
|
+
assert.equal(f.session.sessionId, before ? f.owner : null)
|
|
242
|
+
assert.equal(f.requests.length, before ? 0 : 2)
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('a bounded bootloader write timeout remains unknown and never becomes a release timeout', async t => {
|
|
247
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
248
|
+
const f = replResetFixture({ write: request => request.metadata.channel === 'repl.stdin'
|
|
249
|
+
? new Promise(() => {}) : undefined }); t.after(() => f.session.close())
|
|
250
|
+
const result = f.session.control('bootloader', { timeout: 50 })
|
|
251
|
+
const rejected = assert.rejects(result, error => error.code === 'TIMEOUT'
|
|
252
|
+
&& error.message === 'Device reset submission timed out' && error.reset?.outcome === 'unknown')
|
|
253
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
254
|
+
t.mock.timers.tick(50)
|
|
255
|
+
await rejected
|
|
256
|
+
await f.session.release()
|
|
257
|
+
assert.equal(f.requests.length, 2)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('failure before a bootloader command retains its live lease and normal cleanup', async t => {
|
|
261
|
+
const failure = Object.assign(new Error('Interrupt write failed'), { code: 'WRITE_FAILED' })
|
|
262
|
+
const f = replResetFixture({ write: () => { throw failure } }); t.after(() => f.session.close())
|
|
263
|
+
await assert.rejects(f.session.control('bootloader'), error => error === failure && !error.reset)
|
|
264
|
+
assert.equal(f.session.sessionId, f.owner)
|
|
265
|
+
assert.equal(f.session.state, 'ready')
|
|
266
|
+
await f.session.release()
|
|
267
|
+
assert.deepEqual(f.requests.map(r => r.metadata.channel), ['repl.signal', 'user.rpc'])
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('bootloader cleanup waits for an actual transport drain, without treating it as a device ACK', async t => {
|
|
271
|
+
let drained
|
|
272
|
+
const f = replResetFixture(); t.after(() => f.session.close())
|
|
273
|
+
f.session.transport.drain = () => new Promise(resolve => { drained = resolve })
|
|
274
|
+
const resetting = f.session.control('bootloader')
|
|
275
|
+
const release = f.session.release()
|
|
276
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
277
|
+
assert.equal(f.session.state, 'resetting')
|
|
278
|
+
assert.equal(f.session.sessionId, f.owner)
|
|
279
|
+
drained()
|
|
280
|
+
assert.equal((await resetting).acknowledged, false)
|
|
281
|
+
assert.deepEqual(await release, { ok: true, accepted: false })
|
|
282
|
+
assert.equal(f.requests.length, 2)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('a release already in flight excludes a bootloader command', async t => {
|
|
286
|
+
const f = fixture({ release: 'defer' }); t.after(() => f.session.close())
|
|
287
|
+
const releasing = f.session.release()
|
|
288
|
+
await assert.rejects(f.session.control('bootloader'), { code: 'DEVICE_RELEASING' })
|
|
289
|
+
assert.equal(f.requests.length, 1)
|
|
290
|
+
f.ackRelease()
|
|
291
|
+
await releasing
|
|
292
|
+
})
|
|
@@ -28,7 +28,7 @@ test('application-only write is read back in full before reset, with port cleanu
|
|
|
28
28
|
const f = await fixture()
|
|
29
29
|
await f.session.connect(f.bytes)
|
|
30
30
|
await f.session.flash(f.bytes)
|
|
31
|
-
assert.deepEqual(f.calls, [['initialize'], ['stub', true], ['read', 0x8000], ['write', 0x10000,
|
|
31
|
+
assert.deepEqual(f.calls, [['initialize'], ['stub', true], ['read', 0x8000], ['write', 0x10000, true], ['read', 0x10000], ['reset'], ['close']])
|
|
32
32
|
})
|
|
33
33
|
test('corrupt readback never reports success or resets into unverified firmware', async () => {
|
|
34
34
|
const f = await fixture()
|
|
@@ -0,0 +1,233 @@
|
|
|
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 { validateJpegImage } from '../src/jpeg_validate.js'
|
|
6
|
+
import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
|
|
7
|
+
import { DeviceSession } from '../src/device_session.js'
|
|
8
|
+
import { EvRepl } from '../src/evrepl.js'
|
|
9
|
+
import { DeviceActionRegistry } from '../src/device_actions.js'
|
|
10
|
+
import { bindSessionDeviceActions } from '../src/device_bindings.js'
|
|
11
|
+
import { JPEG_FIXTURES, jpegFixture } from './jpeg-fixtures.mjs'
|
|
12
|
+
|
|
13
|
+
const invalidImage = error => error.code === 'INVALID_IMAGE' && /^Invalid JPEG image: /.test(error.message)
|
|
14
|
+
const join = (...parts) => Uint8Array.from(parts.flatMap(part => Array.from(part)))
|
|
15
|
+
const change = (bytes, offset, value) => { const result = bytes.slice(); result[offset] = value; return result }
|
|
16
|
+
// Locate a known fixture's header segments only; this is not a JPEG validator.
|
|
17
|
+
function headers(bytes) {
|
|
18
|
+
const result = []
|
|
19
|
+
for (let start = 2; start < bytes.length;) {
|
|
20
|
+
const marker = bytes[start + 1], length = bytes[start + 2] * 256 + bytes[start + 3]
|
|
21
|
+
result.push({ marker, start, end: start + length + 2 })
|
|
22
|
+
if (marker === 0xda) break
|
|
23
|
+
start += length + 2
|
|
24
|
+
}
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
const header = (bytes, marker) => headers(bytes).find(segment => segment.marker === marker)
|
|
28
|
+
const replace = (bytes, segment, replacement) => join(bytes.subarray(0, segment.start), replacement, bytes.subarray(segment.end))
|
|
29
|
+
const segment = (marker, content) => join([0xff, marker, (content.length + 2) >> 8, (content.length + 2) & 255], content)
|
|
30
|
+
|
|
31
|
+
test('self-created baseline and progressive JPEG fixtures pass structural and metadata checks', () => {
|
|
32
|
+
for (const name of Object.keys(JPEG_FIXTURES)) {
|
|
33
|
+
const image = jpegFixture(name)
|
|
34
|
+
assert.deepEqual(validateJpegImage(image.data, image), { width: image.width, height: image.height })
|
|
35
|
+
}
|
|
36
|
+
const image = jpegFixture(), sos = header(image.data, 0xda)
|
|
37
|
+
// Metadata markers and unused 16-bit tables are bounded but do not alter pixels.
|
|
38
|
+
const unusedQuant = segment(0xdb, [0x13, ...Array.from({ length: 64 }, () => [0, 1]).flat()])
|
|
39
|
+
const enriched = join(image.data.subarray(0, sos.start), segment(0xfe, [65, 66]), unusedQuant, image.data.subarray(sos.start))
|
|
40
|
+
assert.deepEqual(validateJpegImage(enriched, image), { width: 2, height: 1 })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('JPEG validation rejects fake images, every truncated prefix, trailing bytes and mismatched dimensions', () => {
|
|
44
|
+
const image = jpegFixture()
|
|
45
|
+
for (const bytes of [new Uint8Array(), Uint8Array.of(0xff, 0xd8, 0xff, 0xd9), join(image.data, [0]), change(image.data, 0, 0)]) {
|
|
46
|
+
assert.throws(() => validateJpegImage(bytes, image), invalidImage)
|
|
47
|
+
}
|
|
48
|
+
for (let length = 0; length < image.data.length; length++) {
|
|
49
|
+
assert.throws(() => validateJpegImage(image.data.subarray(0, length), image), invalidImage, `prefix ${length}`)
|
|
50
|
+
}
|
|
51
|
+
assert.throws(() => validateJpegImage(Array.from(image.data), image), invalidImage)
|
|
52
|
+
for (const metadata of [{}, { width: '2', height: 1 }, { width: 2.5, height: 1 }, { width: 2, height: 0 }, { width: 2, height: 65536 }, { width: 3, height: 1 }]) {
|
|
53
|
+
assert.throws(() => validateJpegImage(image.data, metadata), invalidImage)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('JPEG table, component and scan references fail closed within segment bounds', () => {
|
|
58
|
+
const image = jpegFixture(), bytes = image.data
|
|
59
|
+
const sof = header(bytes, 0xc0), dqt = header(bytes, 0xdb), dht = header(bytes, 0xc4), sos = header(bytes, 0xda)
|
|
60
|
+
const oversized = change(change(bytes, dqt.start + 2, 0xff), dqt.start + 3, 0xff)
|
|
61
|
+
const used16bitQuant = replace(bytes, dqt, segment(0xdb, [0x10, ...Array.from({ length: 64 }, () => [0, 1]).flat()]))
|
|
62
|
+
const repeatedSof = join(bytes.subarray(0, sos.start), bytes.subarray(sof.start, sof.end), bytes.subarray(sos.start))
|
|
63
|
+
const missingCoverage = replace(bytes, sos, segment(0xda, [1, bytes[sos.start + 5], bytes[sos.start + 6], 0, 63, 0]))
|
|
64
|
+
const malformed = [
|
|
65
|
+
oversized, change(bytes, dqt.start + 5, 0), used16bitQuant,
|
|
66
|
+
change(bytes, dht.start + 4, 0x24), // unsupported Huffman selector
|
|
67
|
+
change(bytes, dht.start + 5, 3), // more one-bit codes than available
|
|
68
|
+
change(bytes, dht.start + 5, 2), // entirely occupied code space, reserved all-ones code
|
|
69
|
+
change(bytes, sof.start + 1, 0xc3), // unsupported lossless frame
|
|
70
|
+
change(bytes, sof.start + 4, 12), // unsupported sample precision
|
|
71
|
+
change(bytes, sof.start + 13, bytes[sof.start + 10]), // repeated component id
|
|
72
|
+
change(bytes, sof.start + 11, 0x50), // invalid sampling factor
|
|
73
|
+
change(bytes, sof.start + 12, 3), // missing quantization table
|
|
74
|
+
repeatedSof, missingCoverage,
|
|
75
|
+
change(bytes, sos.start + 5, 99), // undefined component
|
|
76
|
+
change(bytes, sos.start + 6, 0x33), // missing Huffman tables
|
|
77
|
+
change(bytes, sos.end - 2, 62), // sequential scans must include coefficients 0..63
|
|
78
|
+
]
|
|
79
|
+
for (const candidate of malformed) assert.throws(() => validateJpegImage(candidate, image), invalidImage)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('entropy stuffing, restart order and every restart segment require bounded nonempty data', () => {
|
|
83
|
+
const image = jpegFixture(), sos = header(image.data, 0xda)
|
|
84
|
+
const prefix = image.data.subarray(0, sos.start), scanHeader = image.data.subarray(sos.start, sos.end)
|
|
85
|
+
const dri = segment(0xdd, [0, 1]), eoi = [0xff, 0xd9]
|
|
86
|
+
const malformed = [
|
|
87
|
+
join(prefix, scanHeader, eoi),
|
|
88
|
+
join(prefix, scanHeader, [1, 0xff, 0xff, 0, 2], eoi),
|
|
89
|
+
join(prefix, scanHeader, [1, 0xff, 0xd0, 2], eoi), // no DRI
|
|
90
|
+
join(prefix, dri, scanHeader, [0xff, 0xd0, 1], eoi), // empty first segment
|
|
91
|
+
join(prefix, dri, scanHeader, [1, 0xff, 0xd0], eoi), // empty last segment
|
|
92
|
+
join(prefix, dri, scanHeader, [1, 0xff, 0xd1, 2], eoi), // sequence must start at RST0
|
|
93
|
+
join(prefix, dri, scanHeader, [1, 0xff, 0xd0, 2, 0xff, 0xd0, 3], eoi),
|
|
94
|
+
]
|
|
95
|
+
for (const candidate of malformed) assert.throws(() => validateJpegImage(candidate, image), invalidImage)
|
|
96
|
+
// These mutations demonstrate structural scope only; entropy is deliberately not decoded.
|
|
97
|
+
assert.doesNotThrow(() => validateJpegImage(join(prefix, dri, scanHeader, [1, 0xff, 0xd0, 2, 0xff, 0xd1, 3], eoi), image))
|
|
98
|
+
assert.doesNotThrow(() => validateJpegImage(join(prefix, scanHeader, [1, 0xff, 0, 2], eoi), image))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('progressive scan layout and initial component coverage are validated without decoding coefficients', () => {
|
|
102
|
+
const image = jpegFixture('progressive'), bytes = image.data, sos = header(bytes, 0xda)
|
|
103
|
+
assert.throws(() => validateJpegImage(change(bytes, sos.end - 2, 63), image), invalidImage, 'a DC scan must end at coefficient zero')
|
|
104
|
+
assert.throws(() => validateJpegImage(change(bytes, sos.end - 1, 0x31), image), invalidImage, 'successive approximation must progress by one bit')
|
|
105
|
+
assert.throws(() => validateJpegImage(change(bytes, sos.start + 6, 0x30), image), invalidImage, 'initial DC scan needs its selected Huffman table')
|
|
106
|
+
const noInitialDc = change(bytes, sos.end - 1, 0x10)
|
|
107
|
+
assert.throws(() => validateJpegImage(noInitialDc, image), invalidImage, 'refinement alone does not establish initial component coverage')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
function fixture(t) {
|
|
111
|
+
const requests = [], events = { preview: [], debugImage: [], rpcResponse: [], diagnostic: [] }
|
|
112
|
+
const recorder = new EvMuxParser({ filterFrame(metadata) { requests.push(metadata); return false } })
|
|
113
|
+
const session = new DeviceSession({ transport: { async writeBytes(bytes) { recorder.feedBytes(bytes) } },
|
|
114
|
+
handlers: Object.fromEntries(Object.keys(events).map(name => [name, (...args) => events[name].push(args)])), EvMuxParser, makeEvMuxFrame, EvRepl })
|
|
115
|
+
session.state = 'ready'; session.sessionId = 'a'.repeat(32); session.vmEpoch = 1; session.vmReady = true
|
|
116
|
+
session.capabilities = { features: ['debug.capture_frame', 'debug.capture_frame.observed.v1', 'debug.capture_screen'] }
|
|
117
|
+
t.after(() => session.close())
|
|
118
|
+
const imageFrame = (request, { image = jpegFixture(), payload = image.data, ...overrides } = {}) => makeEvMuxFrame({
|
|
119
|
+
...request, channel: request.channel ?? 'debug.rpc', type: 'rsp', method: request.method ?? 'debug.capture_frame',
|
|
120
|
+
sessionId: session.sessionId, vmEpoch: 1, contentType: 'image/jpeg', encoding: 'binary',
|
|
121
|
+
width: image.width, height: image.height, payload, ...overrides,
|
|
122
|
+
})
|
|
123
|
+
const jsonFrame = (request, payload, overrides = {}) => makeEvMuxFrame({ ...request, type: 'rsp', sessionId: session.sessionId, vmEpoch: 1, payload, ...overrides })
|
|
124
|
+
return { session, requests, events, imageFrame, jsonFrame }
|
|
125
|
+
}
|
|
126
|
+
const tick = () => new Promise(setImmediate)
|
|
127
|
+
function feedFragments(session, bytes) {
|
|
128
|
+
for (let cursor = 0, size = 1; cursor < bytes.length; size = size % 19 + 1) {
|
|
129
|
+
session.feedBytes(bytes.subarray(cursor, cursor + size)); cursor += size
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test('CRC-valid malformed camera and screen JPEGs reject correlated captures immediately with no image callback', async t => {
|
|
134
|
+
const f = fixture(t)
|
|
135
|
+
for (const method of ['debug.capture_frame', 'debug.capture_screen']) {
|
|
136
|
+
const pending = f.session.captureImage(method, {}, { timeout: 5000 })
|
|
137
|
+
const rejected = assert.rejects(pending, invalidImage)
|
|
138
|
+
await tick()
|
|
139
|
+
feedFragments(f.session, f.imageFrame(f.requests.at(-1), { payload: Uint8Array.of(0xff, 0xd8, 0xff, 0xd9) }))
|
|
140
|
+
await rejected
|
|
141
|
+
assert.equal(f.session.pendingRpc.size, 0)
|
|
142
|
+
}
|
|
143
|
+
assert.equal(f.events.debugImage.length, 0); assert.equal(f.events.preview.length, 0); assert.equal(f.events.rpcResponse.length, 0)
|
|
144
|
+
assert.equal(f.session.parser.getDiagnostics().invalidImageFrames, 2)
|
|
145
|
+
assert.equal(f.session.parser.getDiagnostics().droppedFrames, 2)
|
|
146
|
+
assert.equal(f.events.diagnostic.filter(([value]) => value.event === 'image.invalid').length, 2)
|
|
147
|
+
const next = f.session.captureImage('debug.capture_frame'); await tick()
|
|
148
|
+
feedFragments(f.session, f.imageFrame(f.requests.at(-1)))
|
|
149
|
+
assert.deepEqual((await next).data, jpegFixture().data, 'a valid image recovers after rejected frames')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('a CRC-damaged image is dropped before JPEG validation and does not consume the matching request', async t => {
|
|
153
|
+
const f = fixture(t), pending = f.session.captureImage('debug.capture_frame'); await tick()
|
|
154
|
+
const request = f.requests.at(-1), frame = f.imageFrame(request)
|
|
155
|
+
frame[frame.length - jpegFixture().data.length - 1] = 0 // corrupt SOI, retaining the original frame CRC
|
|
156
|
+
feedFragments(f.session, frame)
|
|
157
|
+
assert.equal(f.session.pendingRpc.size, 1)
|
|
158
|
+
assert.equal(f.session.parser.getDiagnostics().invalidImageFrames, 0)
|
|
159
|
+
assert.equal(f.session.parser.getDiagnostics().droppedFrames, 1)
|
|
160
|
+
assert.equal(f.events.debugImage.length, 0)
|
|
161
|
+
f.session.feedBytes(f.imageFrame(request))
|
|
162
|
+
assert.deepEqual((await pending).data, jpegFixture().data)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('invalid previews drop a complete frame, retain pending requests and recover on fragmented valid preview', async t => {
|
|
166
|
+
const f = fixture(t)
|
|
167
|
+
const pending = f.session.rpc('debug.info', { scope: 'transport.stats' }); await tick()
|
|
168
|
+
const request = f.requests.at(-1)
|
|
169
|
+
for (const overrides of [{ payload: Uint8Array.of(0xff, 0xd8, 0xff, 0xd9) }, { width: 99 }, { encoding: 'text' }, { type: 'rsp' }]) {
|
|
170
|
+
feedFragments(f.session, f.imageFrame({ channel: 'preview.frame', seq: request.seq }, { type: 'data', ...overrides }))
|
|
171
|
+
assert.equal(f.session.pendingRpc.size, 1)
|
|
172
|
+
}
|
|
173
|
+
assert.equal(f.events.preview.length, 0); assert.equal(f.events.debugImage.length, 0)
|
|
174
|
+
feedFragments(f.session, f.imageFrame({ channel: 'preview.frame', seq: request.seq }, { type: 'data' }))
|
|
175
|
+
assert.equal(f.events.preview.length, 1)
|
|
176
|
+
assert.deepEqual(f.events.preview[0][2], jpegFixture().data)
|
|
177
|
+
const stats = f.session.parser.getDiagnostics()
|
|
178
|
+
assert.equal(stats.invalidImageFrames, 4); assert.equal(stats.droppedFrames, 4); assert.equal(stats.frames, 1)
|
|
179
|
+
f.session.feedBytes(f.jsonFrame(request, JSON.stringify({ ok: true, stats: {} })))
|
|
180
|
+
assert.equal((await pending).ok, true)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('capture JSON success, malformed JSON and nonbinary JPEG reject while firmware capture errors remain explicit', async t => {
|
|
184
|
+
const f = fixture(t)
|
|
185
|
+
for (const response of [
|
|
186
|
+
request => f.jsonFrame(request, JSON.stringify({ ok: true })),
|
|
187
|
+
request => f.jsonFrame(request, '{'),
|
|
188
|
+
request => f.jsonFrame(request, JSON.stringify({ ok: false })),
|
|
189
|
+
request => f.imageFrame(request, { encoding: 'json' }),
|
|
190
|
+
request => f.imageFrame(request, { contentType: 'image/png' }),
|
|
191
|
+
request => f.jsonFrame(request, JSON.stringify({ ok: false, error: { code: 'NOT_OWNER' } }), { contentType: 'image/jpeg', sessionId: undefined, vmEpoch: undefined }),
|
|
192
|
+
]) {
|
|
193
|
+
// Even lower-level generic RPC callers cannot accept a JPEG-less capture success.
|
|
194
|
+
const pending = f.session.rpc('debug.capture_frame', {}, { timeout: 5000 })
|
|
195
|
+
const rejected = assert.rejects(pending, invalidImage); await tick()
|
|
196
|
+
f.session.feedBytes(response(f.requests.at(-1))); await rejected
|
|
197
|
+
}
|
|
198
|
+
const pending = f.session.captureImage('debug.capture_screen'); const rejected = assert.rejects(pending, { code: 'JPEG_FAILED' }); await tick()
|
|
199
|
+
f.session.feedBytes(f.jsonFrame(f.requests.at(-1), JSON.stringify({ ok: false, error: { code: 'JPEG_FAILED', message: 'Encoding failed' } })))
|
|
200
|
+
await rejected
|
|
201
|
+
assert.equal(f.events.debugImage.length, 0); assert.equal(f.events.rpcResponse.length, 1)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('malformed stale image responses cannot reject captures or unrelated RPCs', async t => {
|
|
205
|
+
const f = fixture(t), pending = f.session.captureImage('debug.capture_frame'), query = f.session.rpc('debug.info', { scope: 'transport.stats' })
|
|
206
|
+
await tick()
|
|
207
|
+
const capture = f.requests.find(request => request.method === 'debug.capture_frame'), info = f.requests.find(request => request.method === 'debug.info')
|
|
208
|
+
for (const overrides of [{ seq: capture.seq + 100 }, { sessionId: 'b'.repeat(32) }, { vmEpoch: 2 }, { method: 'debug.capture_screen' }, { seq: info.seq }]) {
|
|
209
|
+
f.session.feedBytes(f.imageFrame(capture, { payload: Uint8Array.of(0xff, 0xd8, 0xff, 0xd9), ...overrides }))
|
|
210
|
+
f.session.feedBytes(f.jsonFrame({ ...capture, ...overrides }, JSON.stringify({ ok: true }), overrides))
|
|
211
|
+
assert.equal(f.session.pendingRpc.size, 2)
|
|
212
|
+
}
|
|
213
|
+
assert.equal(f.session.parser.getDiagnostics().invalidImageFrames, 0, 'correlation rejects stale responses before JPEG validation')
|
|
214
|
+
assert.equal(f.events.debugImage.length, 0)
|
|
215
|
+
f.session.feedBytes(f.jsonFrame(info, JSON.stringify({ ok: true, stats: { marker: 7 } })))
|
|
216
|
+
f.session.feedBytes(f.imageFrame(capture))
|
|
217
|
+
assert.equal((await query).stats.marker, 7); assert.deepEqual((await pending).data, jpegFixture().data)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('frame.capture action returns its own binary image when concurrent responses arrive in reverse order', async t => {
|
|
221
|
+
const f = fixture(t), registry = new DeviceActionRegistry(); bindSessionDeviceActions(registry)
|
|
222
|
+
const capture = registry.handler('frame.capture')
|
|
223
|
+
const first = capture({ timeoutMs: 5000 }, { session: f.session }), second = capture({ timeoutMs: 5000 }, { session: f.session })
|
|
224
|
+
await tick()
|
|
225
|
+
const firstRequest = f.requests[0], secondRequest = f.requests[1]
|
|
226
|
+
f.session.feedBytes(f.imageFrame(secondRequest, { image: jpegFixture('small') }))
|
|
227
|
+
f.session.feedBytes(f.imageFrame(firstRequest, { image: jpegFixture('alternate') }))
|
|
228
|
+
const [a, b] = await Promise.all([first, second])
|
|
229
|
+
assert.equal(a.base64, JPEG_FIXTURES.alternate.base64); assert.equal(a.metadata.seq, firstRequest.seq)
|
|
230
|
+
assert.equal(a.metadata.width, 2); assert.equal(a.metadata.height, 1)
|
|
231
|
+
assert.equal(b.base64, JPEG_FIXTURES.small.base64); assert.equal(b.metadata.seq, secondRequest.seq)
|
|
232
|
+
assert.equal(b.metadata.width, 4); assert.equal(b.metadata.height, 3)
|
|
233
|
+
})
|