@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
|
@@ -2,84 +2,221 @@
|
|
|
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 { createHash } from 'node:crypto'
|
|
5
6
|
import { openBrowserRom } from '../src/browser.js'
|
|
6
|
-
import {
|
|
7
|
+
import { createOfficialBrowserLoader, browserRomInterface, ROM_UART_BAUD_RATES } from '../src/browser_rom.js'
|
|
8
|
+
import { browserTransportCompatibility, browserRomCompatibility, readBrowserFlash, romWords } from '../src/browser_rom_compat.js'
|
|
9
|
+
import { romMd5 } from '../src/rom_md5.js'
|
|
7
10
|
|
|
8
|
-
const
|
|
9
|
-
test('browser ROM construction and disconnect
|
|
10
|
-
const adapter = openBrowserRom({ port:
|
|
11
|
+
const passivePort = () => ({ open() { throw new Error('Port must remain passive') }, close() {} })
|
|
12
|
+
test('browser ROM construction and disconnect remain passive without browser globals', async () => {
|
|
13
|
+
const adapter = openBrowserRom({ port: passivePort() })
|
|
11
14
|
assert.equal(adapter.chipFamily, null)
|
|
12
15
|
assert.equal(adapter.chipRevision, null)
|
|
13
16
|
assert.equal(adapter.chipName, null)
|
|
14
17
|
await adapter.disconnect(); await adapter.disconnect()
|
|
15
18
|
await assert.rejects(adapter.initialize(), { code: 'ROM_CLOSED' })
|
|
16
19
|
})
|
|
17
|
-
test('browser ROM validates its port and cancellation before loading the
|
|
20
|
+
test('browser ROM validates its port and cancellation before loading the dependency', async () => {
|
|
18
21
|
assert.throws(() => openBrowserRom(), /SerialPort/)
|
|
19
|
-
assert.throws(() => openBrowserRom({ port:
|
|
22
|
+
assert.throws(() => openBrowserRom({ port: passivePort(), signal: {} }), /AbortSignal/)
|
|
20
23
|
const controller = new AbortController()
|
|
21
24
|
controller.abort(new Error('cancelled before open'))
|
|
22
|
-
await assert.rejects(openBrowserRom({ port:
|
|
23
|
-
})
|
|
24
|
-
test('
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
assert.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
})
|
|
34
|
-
test('
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
25
|
+
await assert.rejects(openBrowserRom({ port: passivePort(), signal: controller.signal }).initialize(), /cancelled before open/)
|
|
26
|
+
})
|
|
27
|
+
test('interface discovery separates known bridge, OTG and JTAG; custom descriptors need evidence', () => {
|
|
28
|
+
const port = (usbVendorId, usbProductId) => ({ getInfo: () => ({ usbVendorId, usbProductId }) })
|
|
29
|
+
assert.equal(browserRomInterface(port(0x10c4, 0xea60)), 'uart')
|
|
30
|
+
assert.equal(browserRomInterface(port(0x303a, 32)), 'usb-otg')
|
|
31
|
+
assert.equal(browserRomInterface(port(0x303a, 0x1001)), 'usb-jtag')
|
|
32
|
+
assert.equal(browserRomInterface(port(0x303a, 0x1234)), 'unknown')
|
|
33
|
+
assert.equal(browserRomInterface(port(undefined, undefined)), 'unknown')
|
|
34
|
+
assert.equal(browserRomInterface(port(1, 2), 'uart'), 'uart')
|
|
35
|
+
assert.throws(() => browserRomInterface(port(1, 2), 'usb'), /interface/)
|
|
36
|
+
})
|
|
37
|
+
test('ROM transport MD5 matches independent known vectors and multi-block data', () => {
|
|
38
|
+
for (const input of ['', 'a', 'abc', 'message digest', 'a'.repeat(55), 'b'.repeat(56), 'c'.repeat(65), 'x'.repeat(65536)]) {
|
|
39
|
+
const bytes = new TextEncoder().encode(input)
|
|
40
|
+
assert.equal(Buffer.from(romMd5(bytes)).toString('hex'), createHash('md5').update(bytes).digest('hex'))
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
function transportFixture() {
|
|
44
|
+
return browserTransportCompatibility({ device: { readable: {}, writable: {} }, buffer: new Uint8Array(0),
|
|
45
|
+
appendArray(a, b) { return new Uint8Array([...a, ...b]) }, async write() {},
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
test('SLIP keeps a valid packet after boot noise, trailing packets and escaped bytes', async () => {
|
|
49
|
+
const transport = transportFixture()
|
|
50
|
+
transport.buffer = new Uint8Array([71, 97, 114, 0xc0, 0xc0, 79, 72, 65, 73, 0xc0, 0xc0, 0xdb, 0xdc, 0xdb, 0xdd, 0xc0])
|
|
51
|
+
assert.deepEqual(await transport.read(100), new Uint8Array([79, 72, 65, 73]))
|
|
52
|
+
assert.deepEqual(await transport.read(100), new Uint8Array([0xc0, 0xdb]))
|
|
53
|
+
transport.buffer = new Uint8Array([0xc0, 0xdb, 0x47, 0xc0])
|
|
54
|
+
await assert.rejects(transport.read(100), { code: 'ROM_SLIP_ESCAPE' })
|
|
55
|
+
})
|
|
56
|
+
test('SLIP preserves state across partial incoming chunks and has a total packet deadline', async () => {
|
|
57
|
+
const transport = transportFixture()
|
|
58
|
+
transport.buffer = new Uint8Array([0xc0, 1, 0xdb])
|
|
59
|
+
const later = setTimeout(() => { transport.buffer = new Uint8Array([0xdc, 2, 0xc0]) }, 5)
|
|
60
|
+
try { assert.deepEqual(await transport.read(100), new Uint8Array([1, 0xc0, 2])) } finally { clearTimeout(later) }
|
|
61
|
+
transport.buffer = new Uint8Array([0xc0, 1])
|
|
62
|
+
await assert.rejects(transport.read(15), { code: 'ROM_READ_TIMEOUT' })
|
|
63
|
+
transport.device.readable = null
|
|
64
|
+
await assert.rejects(transport.read(100), { code: 'ROM_PORT_LOST' })
|
|
65
|
+
transport.device.writable = null
|
|
66
|
+
await assert.rejects(transport.write(new Uint8Array()), { code: 'ROM_PORT_LOST' })
|
|
67
|
+
})
|
|
68
|
+
function readFixture({ data = new Uint8Array([1, 2, 3]), packets, digest = romMd5(data), delay = 0 } = {}) {
|
|
69
|
+
const received = [], reads = [...(packets ?? [data]), digest]
|
|
70
|
+
const loader = { IS_STUB: true, ESP_READ_FLASH: 0xd2, transport: { baudrate: 2000000,
|
|
71
|
+
async read() { if (delay) await new Promise(resolve => setTimeout(resolve, delay)); const packet = reads.shift(); if (!packet) throw new Error('missing packet'); return packet },
|
|
72
|
+
async write(bytes) { received.push(new DataView(bytes.buffer).getUint32(0, true)) },
|
|
73
|
+
}, async checkCommand(description, opcode, payload) { assert.equal(description, 'read flash'); assert.equal(opcode, 0xd2); assert.deepEqual(payload, romWords(0x20000, data.length, 1024, 1)); return 0 } }
|
|
74
|
+
return { loader, data, received, reads }
|
|
75
|
+
}
|
|
76
|
+
test('readback ACKs partial packets and consumes the delayed trailing digest before returning', async () => {
|
|
77
|
+
const f = readFixture({ packets: [new Uint8Array([1]), new Uint8Array([2, 3])], delay: 3 })
|
|
78
|
+
assert.deepEqual(await readBrowserFlash(f.loader, 0x20000, 3), f.data)
|
|
79
|
+
assert.deepEqual(f.received, [1, 3]); assert.equal(f.reads.length, 0)
|
|
80
|
+
})
|
|
81
|
+
test('readback rejects malformed packets, incomplete data, incorrect or absent digest', async () => {
|
|
82
|
+
for (const config of [{ packets: [new Uint8Array(0)] }, { packets: [new Uint8Array([1, 2, 3, 4])] }, { digest: new Uint8Array(16) }, { digest: new Uint8Array(15) }]) {
|
|
83
|
+
const f = readFixture(config)
|
|
84
|
+
await assert.rejects(readBrowserFlash(f.loader, 0x20000, 3), /packet|digest/i)
|
|
85
|
+
}
|
|
86
|
+
const f = readFixture(); f.loader.transport.read = async () => { throw Object.assign(new Error('timeout waiting for trailing digest'), { code: 'ROM_READ_TIMEOUT' }) }
|
|
87
|
+
await assert.rejects(readBrowserFlash(f.loader, 0x20000, 3), { code: 'ROM_READ_TIMEOUT' })
|
|
88
|
+
})
|
|
89
|
+
test('native USB MEM_END timeout exception never bypasses validated stub greeting', async () => {
|
|
90
|
+
const native = browserRomCompatibility({ memFinish: async () => { throw new Error('Timed out receiving a complete ROM packet') } }, { nativeUsb: true })
|
|
91
|
+
await native.memFinish()
|
|
92
|
+
const uart = browserRomCompatibility({ memFinish: async () => { throw new Error('Timed out') } })
|
|
93
|
+
await assert.rejects(uart.memFinish(), /Timed out/)
|
|
94
|
+
const lost = browserRomCompatibility({ memFinish: async () => { throw new Error('Port disconnected') } }, { nativeUsb: true })
|
|
95
|
+
await assert.rejects(lost.memFinish(), /disconnected/)
|
|
96
|
+
})
|
|
97
|
+
function officialFixture({ failRates = [], usb = false, security = { flags: 0, flashCryptCnt: 0 }, failWrite = false, recoverSecurity, recoverFamily, detach = false, reopenResets = false, flashId = 0x1840ef, stubStarts = true } = {}) {
|
|
98
|
+
const events = [], calls = [], loaders = [], opens = [], reads = []
|
|
99
|
+
const port = { readable: null, writable: null,
|
|
100
|
+
getInfo: () => ({ usbVendorId: usb ? 0x303a : 0x10c4, usbProductId: usb ? 32 : 0xea60 }),
|
|
101
|
+
async open({ baudRate }) { opens.push(baudRate); this.readable = {}; this.writable = {} },
|
|
102
|
+
async close() { this.readable = null; this.writable = null },
|
|
103
|
+
}
|
|
104
|
+
class FakeTransport {
|
|
105
|
+
constructor(device) { this.device = device; this.buffer = new Uint8Array(0); this.baudrate = 115200 }
|
|
106
|
+
appendArray(a, b) { return new Uint8Array([...a, ...b]) }
|
|
107
|
+
flushInput() { this.buffer = new Uint8Array(0) }
|
|
108
|
+
async disconnect() { calls.push('close'); await port.close() }
|
|
109
|
+
async changeBaudrate(rate) { calls.push(['retune', rate]); this.baudrate = rate; if (detach) throw Object.assign(new Error('Port detached'), { code: 'ROM_PORT_LOST' }); await port.close(); await port.open({ baudRate: rate }); if (reopenResets) loaders.at(-1).IS_STUB = false }
|
|
110
|
+
async write() {}
|
|
111
|
+
async setDTR(value) { calls.push(['dtr', value]) }
|
|
112
|
+
async setRTS(value) { calls.push(['rts', value]) }
|
|
113
|
+
}
|
|
114
|
+
class FakeLoader {
|
|
115
|
+
constructor({ transport }) { this.transport = transport; this.CHIP_DETECT_MAGIC_REG_ADDR = 0x40001000; this.ESP_CHANGE_BAUDRATE = 15; this.ESP_READ_FLASH = 0xd2; this.chip = { CHIP_NAME: loaders.length && recoverFamily ? recoverFamily : 'ESP32-S31', getChipRevision: async () => 0, postConnect: async () => calls.push('postConnect') }; loaders.push(this) }
|
|
116
|
+
async detectChip(mode) { calls.push(['detect', mode]); await port.open({ baudRate: 115200 }); this.transport.baudrate = 115200 }
|
|
117
|
+
async getSecurityInfo() { return loaders.length > 1 && recoverSecurity ? recoverSecurity : security }
|
|
118
|
+
async runStub() { calls.push('stub'); this.IS_STUB = stubStarts }
|
|
119
|
+
async readReg() { if (!this.IS_STUB || failRates.includes(this.transport.baudrate)) throw new Error('speed probe timeout'); return 123 }
|
|
120
|
+
async readFlashId() { return flashId }
|
|
121
|
+
async writeReg(...args) { calls.push(['register-write', ...args]) }
|
|
122
|
+
async checkCommand(description, _opcode, request) {
|
|
123
|
+
if (description === 'change baud rate') { calls.push(['change', new DataView(request.buffer).getUint32(0, true)]); return 0 }
|
|
124
|
+
const length = new DataView(request.buffer).getUint32(4, true), data = new Uint8Array(length)
|
|
125
|
+
const frame = bytes => new Uint8Array([0xc0, ...bytes, 0xc0])
|
|
126
|
+
// All-zero bytes and their digest need no SLIP escaping in this fixture.
|
|
127
|
+
const payload = []
|
|
128
|
+
for (let offset = 0; offset < length; offset += 1024) payload.push(...frame(data.slice(offset, offset + 1024)))
|
|
129
|
+
const md5 = romMd5(data)
|
|
130
|
+
const escaped = []
|
|
131
|
+
for (const byte of md5) { if (byte === 0xc0) escaped.push(0xdb, 0xdc); else if (byte === 0xdb) escaped.push(0xdb, 0xdd); else escaped.push(byte) }
|
|
132
|
+
payload.push(0xc0, ...escaped, 0xc0); this.transport.buffer = new Uint8Array(payload); reads.push(length)
|
|
133
|
+
return 0
|
|
134
|
+
}
|
|
135
|
+
async writeFlash(options) { calls.push(['write', options.compress, options.eraseAll, options.fileArray[0].address]); if (failWrite) throw new Error('write interrupted'); options.reportProgress(0, 10, 10) }
|
|
57
136
|
}
|
|
58
|
-
|
|
137
|
+
const adapter = createOfficialBrowserLoader({ ESPLoader: FakeLoader, Transport: FakeTransport }, port, {}, { onStatus: event => events.push(event) })
|
|
138
|
+
return { adapter, events, calls, port, loaders, opens, reads }
|
|
59
139
|
}
|
|
60
|
-
test('
|
|
61
|
-
const
|
|
62
|
-
assert.equal(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
await
|
|
82
|
-
assert.
|
|
83
|
-
|
|
84
|
-
|
|
140
|
+
test('ROM UART starts at 115200, checks candidates in order and stops at the first success', async () => {
|
|
141
|
+
const f = officialFixture({ failRates: [2000000, 1500000] })
|
|
142
|
+
await f.adapter.initialize(); assert.equal(f.adapter.chipFamily, 0x3231)
|
|
143
|
+
const stub = await f.adapter.runStub(true)
|
|
144
|
+
assert.equal(stub.IS_STUB, true)
|
|
145
|
+
assert.equal(f.adapter.baudRate, 921600)
|
|
146
|
+
assert.deepEqual(f.events.filter(event => event.phase === 'baud-testing').map(event => event.baudRate), ROM_UART_BAUD_RATES.slice(0, 3))
|
|
147
|
+
assert.equal(f.loaders.length, 3, 'failed candidates must have fresh synchronized, qualified stubs')
|
|
148
|
+
assert.deepEqual(f.opens, [115200, 2000000, 115200, 1500000, 115200, 921600])
|
|
149
|
+
await stub.flashData(new ArrayBuffer(4), () => {}, 0x10000, false)
|
|
150
|
+
assert.deepEqual(f.calls.find(call => Array.isArray(call) && call[0] === 'write'), ['write', true, false, 0x10000])
|
|
151
|
+
await f.adapter.disconnect()
|
|
152
|
+
})
|
|
153
|
+
test('all high rates fail safely back to validated 115200', async () => {
|
|
154
|
+
const f = officialFixture({ failRates: ROM_UART_BAUD_RATES.slice(0, -1) })
|
|
155
|
+
await f.adapter.initialize(); await f.adapter.runStub()
|
|
156
|
+
assert.equal(f.adapter.baudRate, 115200)
|
|
157
|
+
assert.deepEqual(f.events.filter(event => event.phase === 'baud-testing').map(event => event.baudRate), ROM_UART_BAUD_RATES)
|
|
158
|
+
})
|
|
159
|
+
test('port reopen that resets the board does not count as an initialized high-speed stub', async () => {
|
|
160
|
+
const f = officialFixture({ reopenResets: true })
|
|
161
|
+
await f.adapter.initialize(); await f.adapter.runStub()
|
|
162
|
+
assert.equal(f.adapter.baudRate, 115200)
|
|
163
|
+
assert.equal(f.loaders.length, 5)
|
|
164
|
+
})
|
|
165
|
+
test('native USB connects directly to ready ROM without UART rate or signal changes', async () => {
|
|
166
|
+
const f = officialFixture({ usb: true })
|
|
167
|
+
await f.adapter.initialize(); await f.adapter.runStub()
|
|
168
|
+
assert.deepEqual(f.calls[0], ['detect', 'no_reset'])
|
|
169
|
+
assert.equal(f.adapter.interface, 'usb-otg'); assert.equal(f.adapter.baudRate, null)
|
|
170
|
+
assert.ok(!f.calls.some(call => Array.isArray(call) && ['retune', 'rts', 'dtr'].includes(call[0])))
|
|
171
|
+
})
|
|
172
|
+
test('recovery rechecks chip identity and security before another stub or write', async () => {
|
|
173
|
+
for (const setting of [{ recoverSecurity: { flags: 1, flashCryptCnt: 0 }, code: 'ROM_SECURITY' }, { recoverFamily: 'ESP32-S3', code: 'ROM_IDENTITY_CHANGED' }]) {
|
|
174
|
+
const f = officialFixture({ failRates: [2000000], ...setting })
|
|
175
|
+
await f.adapter.initialize()
|
|
176
|
+
await assert.rejects(f.adapter.runStub(), { code: setting.code })
|
|
177
|
+
assert.equal(f.calls.filter(call => call === 'stub').length, 1)
|
|
178
|
+
assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
test('write interruption invalidates the session and never retries or resumes compressed data', async () => {
|
|
182
|
+
const f = officialFixture({ failWrite: true })
|
|
183
|
+
await f.adapter.initialize(); const stub = await f.adapter.runStub()
|
|
184
|
+
await assert.rejects(stub.flashData(new ArrayBuffer(4), () => {}, 0, true), /write interrupted/)
|
|
185
|
+
await assert.rejects(stub.readFlash(0, 4), /write interrupted/)
|
|
186
|
+
assert.equal(f.calls.filter(call => Array.isArray(call) && call[0] === 'write').length, 1)
|
|
187
|
+
assert.equal(f.port.readable, null)
|
|
188
|
+
})
|
|
189
|
+
test('detached download port is reported instead of blind fallback using a stale handle', async () => {
|
|
190
|
+
const f = officialFixture({ detach: true })
|
|
191
|
+
await f.adapter.initialize()
|
|
192
|
+
await assert.rejects(f.adapter.runStub(), { code: 'ROM_PORT_LOST' })
|
|
193
|
+
assert.equal(f.loaders.length, 1)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('S31 native USB reset uses the watchdog without stale-port control signals', async () => {
|
|
197
|
+
const f = officialFixture({ usb: true })
|
|
198
|
+
await f.adapter.initialize(); await f.adapter.runStub(); await f.adapter.hardReset()
|
|
199
|
+
assert.deepEqual(f.calls.filter(call => Array.isArray(call) && call[0] === 'register-write'), [
|
|
200
|
+
['register-write', 0x20801018, 0x50d83aa1], ['register-write', 0x20801004, 2000], ['register-write', 0x20801000, 0xd0000102], ['register-write', 0x20801018, 0],
|
|
201
|
+
])
|
|
202
|
+
assert.ok(!f.calls.some(call => Array.isArray(call) && ['rts', 'dtr'].includes(call[0])))
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('SPI RDID normalizes only the documented24bits while rejecting non-register values', async () => {
|
|
206
|
+
for (const raw of [-15187898, 0xff184046]) {
|
|
207
|
+
const f = officialFixture({ usb: true, flashId: raw })
|
|
208
|
+
await f.adapter.initialize(); const stub = await f.adapter.runStub()
|
|
209
|
+
assert.equal(await stub.flashId(), 0x184046)
|
|
210
|
+
}
|
|
211
|
+
const f = officialFixture({ usb: true, flashId: NaN })
|
|
212
|
+
await f.adapter.initialize(); const stub = await f.adapter.runStub()
|
|
213
|
+
await assert.rejects(stub.flashId(), { code: 'ROM_FLASH_ID' })
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('missing native stub handshake never creates a usable stub or starts a write', async () => {
|
|
217
|
+
const f = officialFixture({ usb: true, stubStarts: false })
|
|
218
|
+
await f.adapter.initialize()
|
|
219
|
+
await assert.rejects(f.adapter.runStub(), { code: 'ROM_STUB_REQUIRED' })
|
|
220
|
+
assert.ok(!f.calls.some(call => Array.isArray(call) && call[0] === 'write'))
|
|
221
|
+
assert.equal(f.port.readable, null)
|
|
85
222
|
})
|
|
@@ -0,0 +1,143 @@
|
|
|
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 { serialTransport } from '../src/browser.js'
|
|
6
|
+
|
|
7
|
+
class Port extends EventTarget {
|
|
8
|
+
opens = []
|
|
9
|
+
openOptions = []
|
|
10
|
+
closes = 0
|
|
11
|
+
writes = []
|
|
12
|
+
lines = []
|
|
13
|
+
rejected = []
|
|
14
|
+
async open(options) {
|
|
15
|
+
const { baudRate } = options
|
|
16
|
+
this.openOptions.push(options)
|
|
17
|
+
this.opens.push(baudRate)
|
|
18
|
+
if (this.rejected.includes(baudRate)) throw new Error('unsupported speed')
|
|
19
|
+
assert.ok(!this.readable && !this.writable)
|
|
20
|
+
this.readable = new ReadableStream({ start: controller => { this.input = controller } })
|
|
21
|
+
this.writable = new WritableStream({ write: async bytes => {
|
|
22
|
+
if (this.writeGate) await this.writeGate
|
|
23
|
+
this.writes.push(new Uint8Array(bytes))
|
|
24
|
+
} })
|
|
25
|
+
}
|
|
26
|
+
async close() {
|
|
27
|
+
assert.ok(!this.readable?.locked, 'read lock must be released before SerialPort.close')
|
|
28
|
+
assert.ok(!this.writable?.locked, 'write lock must be released before SerialPort.close')
|
|
29
|
+
this.readable = this.writable = null
|
|
30
|
+
this.closes++
|
|
31
|
+
}
|
|
32
|
+
async setSignals(signals) { this.lines.push(signals) }
|
|
33
|
+
forget() { throw new Error('Internal retune must preserve browser authorization') }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('browser runtime adapter is passive and reopens its authorized port without disconnect events', async () => {
|
|
37
|
+
const port = new Port(), transport = serialTransport({ port }), data = [], events = []
|
|
38
|
+
assert.deepEqual(port.opens, [])
|
|
39
|
+
const unsubscribe = transport.subscribe({ data: bytes => data.push(...bytes), close: () => events.push('close'), error: error => events.push(error.code) })
|
|
40
|
+
await transport.connect()
|
|
41
|
+
assert.deepEqual(port.opens, [115200])
|
|
42
|
+
port.input.enqueue(new Uint8Array([1, 2]))
|
|
43
|
+
await transport.setBaudRate(2000000)
|
|
44
|
+
port.input.enqueue(new Uint8Array([3, 4]))
|
|
45
|
+
await transport.writeBytes(new Uint8Array([5, 6]))
|
|
46
|
+
assert.deepEqual(data, [1, 2, 3, 4])
|
|
47
|
+
assert.deepEqual(port.opens, [115200, 2000000])
|
|
48
|
+
assert.deepEqual(port.openOptions, [{ baudRate: 115200, bufferSize: 256 * 1024 }, { baudRate: 2000000, bufferSize: 256 * 1024 }])
|
|
49
|
+
assert.equal(port.closes, 1)
|
|
50
|
+
assert.deepEqual(events, [])
|
|
51
|
+
assert.deepEqual(transport.getDiagnostics(), { baudRate: 2000000, connected: true, reopening: false })
|
|
52
|
+
assert.deepEqual(port.lines, [{ dataTerminalReady: true, requestToSend: false }, { dataTerminalReady: true, requestToSend: false }])
|
|
53
|
+
await transport.disconnect()
|
|
54
|
+
await transport.disconnect()
|
|
55
|
+
assert.equal(port.closes, 2)
|
|
56
|
+
assert.deepEqual(events, [])
|
|
57
|
+
unsubscribe()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('browser retune waits for writes to finish and a rejected rate can reopen baseline', async () => {
|
|
61
|
+
const port = new Port(), transport = serialTransport({ port })
|
|
62
|
+
transport.subscribe({})
|
|
63
|
+
await transport.connect()
|
|
64
|
+
let release
|
|
65
|
+
port.writeGate = new Promise(resolve => { release = resolve })
|
|
66
|
+
const writing = transport.writeBytes(new Uint8Array([0xff, 0, 0x1e]))
|
|
67
|
+
const changing = transport.setBaudRate(2000000)
|
|
68
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
69
|
+
assert.equal(port.closes, 0)
|
|
70
|
+
release()
|
|
71
|
+
await writing
|
|
72
|
+
await changing
|
|
73
|
+
assert.deepEqual(port.writes[0], new Uint8Array([0xff, 0, 0x1e]))
|
|
74
|
+
port.rejected.push(1500000)
|
|
75
|
+
await assert.rejects(transport.setBaudRate(1500000), /unsupported speed/)
|
|
76
|
+
await transport.setBaudRate(115200)
|
|
77
|
+
assert.equal(transport.getDiagnostics().baudRate, 115200)
|
|
78
|
+
assert.deepEqual(port.opens, [115200, 2000000, 1500000, 115200])
|
|
79
|
+
await transport.disconnect()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('physical browser disconnect is reported while internal reopen remains subscribed', async () => {
|
|
83
|
+
const port = new Port(), transport = serialTransport({ port }), events = []
|
|
84
|
+
transport.subscribe({ close: () => events.push('close') })
|
|
85
|
+
await transport.connect()
|
|
86
|
+
await transport.setBaudRate(921600)
|
|
87
|
+
assert.deepEqual(events, [])
|
|
88
|
+
port.dispatchEvent(new Event('disconnect'))
|
|
89
|
+
assert.deepEqual(events, ['close'])
|
|
90
|
+
await transport.disconnect()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('native USB ports without modem control lines can still connect', async () => {
|
|
94
|
+
const port = new Port()
|
|
95
|
+
port.setSignals = async () => { throw Object.assign(new Error('No control lines'), { name: 'NotSupportedError' }) }
|
|
96
|
+
const transport = serialTransport({ port })
|
|
97
|
+
await transport.connect()
|
|
98
|
+
await transport.writeBytes(new Uint8Array([1]))
|
|
99
|
+
assert.deepEqual(port.writes, [new Uint8Array([1])])
|
|
100
|
+
await transport.disconnect()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('browser cancellation releases a port that finishes opening late', async () => {
|
|
104
|
+
const port = new Port(), original = port.open.bind(port), transport = serialTransport({ port })
|
|
105
|
+
let finish
|
|
106
|
+
const started = new Promise(resolve => {
|
|
107
|
+
port.open = async options => { await new Promise(release => { finish = release; resolve() }); await original(options) }
|
|
108
|
+
})
|
|
109
|
+
const controller = new AbortController()
|
|
110
|
+
const connecting = transport.connect({ signal: controller.signal })
|
|
111
|
+
await started
|
|
112
|
+
controller.abort()
|
|
113
|
+
await assert.rejects(connecting, { name: 'AbortError' })
|
|
114
|
+
await transport.disconnect()
|
|
115
|
+
finish()
|
|
116
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
117
|
+
assert.equal(port.closes, 1)
|
|
118
|
+
assert.equal(port.readable, null)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('an aborted retune cannot complete later at a stale rate after baseline recovery', async () => {
|
|
122
|
+
const port = new Port(), original = port.open.bind(port), transport = serialTransport({ port }), controller = new AbortController()
|
|
123
|
+
await transport.connect()
|
|
124
|
+
let finish
|
|
125
|
+
const started = new Promise(resolve => {
|
|
126
|
+
port.open = async options => {
|
|
127
|
+
if (options.baudRate === 2000000) await new Promise(release => { finish = release; resolve() })
|
|
128
|
+
await original(options)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
const changing = transport.setBaudRate(2000000, { signal: controller.signal })
|
|
132
|
+
await started
|
|
133
|
+
controller.abort()
|
|
134
|
+
const failed = assert.rejects(changing, { name: 'AbortError' })
|
|
135
|
+
const recovery = transport.setBaudRate(115200)
|
|
136
|
+
finish()
|
|
137
|
+
await failed
|
|
138
|
+
await recovery
|
|
139
|
+
assert.deepEqual(port.opens, [115200, 2000000, 115200])
|
|
140
|
+
assert.equal(port.closes, 2, 'the stale high-rate open is closed before baseline is acquired')
|
|
141
|
+
assert.equal(transport.getDiagnostics().baudRate, 115200)
|
|
142
|
+
await transport.disconnect()
|
|
143
|
+
})
|
|
@@ -6,9 +6,10 @@ import * as api from '../src/index.js'
|
|
|
6
6
|
import { DeviceSession, EV_MUX_REQUIRED_FEATURES, EV_MUX_REQUIRED_CHANNELS, EV_MUX_DEFAULT_LIMITS } from '../src/device_session.js'
|
|
7
7
|
import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
|
|
8
8
|
import { EvRepl } from '../src/evrepl.js'
|
|
9
|
+
import { jpegFixture } from './jpeg-fixtures.mjs'
|
|
9
10
|
|
|
10
11
|
const capabilities = { ok: true, protocol: { evMuxVersion: 4 },
|
|
11
|
-
features: [...EV_MUX_REQUIRED_FEATURES, 'debug.capture_frame', 'script.write', 'script.run', 'device.control'],
|
|
12
|
+
features: [...EV_MUX_REQUIRED_FEATURES, 'debug.capture_frame', 'debug.capture_frame.observed.v1', 'script.write', 'script.run', 'device.control'],
|
|
12
13
|
channels: EV_MUX_REQUIRED_CHANNELS, limits: { ...EV_MUX_DEFAULT_LIMITS, txLargePayloadMax: 262144 },
|
|
13
14
|
session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 } }
|
|
14
15
|
function peer({ silent = false, screen = false, vmReady = true, interactiveScripts = false } = {}) {
|
|
@@ -44,7 +45,7 @@ function peer({ silent = false, screen = false, vmReady = true, interactiveScrip
|
|
|
44
45
|
if (metadata.method === 'capabilities') result = { ...capabilities, features: [...capabilities.features, ...(screen ? ['debug.capture_screen'] : [])] }
|
|
45
46
|
if (metadata.method === 'session.keepalive') result = { ok: true, sessionId: id, vmEpoch: 1 }
|
|
46
47
|
if (metadata.method === 'debug.capture_frame' || (screen && metadata.method === 'debug.capture_screen')) {
|
|
47
|
-
handlers.data(makeEvMuxFrame({ channel: 'debug.rpc', type: 'rsp', method: metadata.method, seq: metadata.seq, sessionId: id, vmEpoch: 1, contentType: 'image/jpeg', width: 2, height: 1, payload:
|
|
48
|
+
handlers.data(makeEvMuxFrame({ channel: 'debug.rpc', type: 'rsp', method: metadata.method, seq: metadata.seq, sessionId: id, vmEpoch: 1, contentType: 'image/jpeg', encoding: 'binary', width: 2, height: 1, payload: jpegFixture().data })); return false
|
|
48
49
|
}
|
|
49
50
|
handlers.data(makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq, ...((metadata.method !== 'capabilities' && (metadata.method !== 'hello' || payload.acquire)) ? {sessionId: id, vmEpoch: 1} : {}), payload: JSON.stringify(result) }))
|
|
50
51
|
return false
|
|
@@ -86,6 +87,38 @@ test('facade subscribes before connection, negotiates, streams input and dispose
|
|
|
86
87
|
await assert.rejects(d.files.read({path:'/x'}), {code:'DEVICE_NOT_READY'})
|
|
87
88
|
})
|
|
88
89
|
|
|
90
|
+
test('hard reset disposes an open transport without releasing its expired lease and permits reconnect', async () => {
|
|
91
|
+
const p = peer(), d = api.createDevice({ transport: p.transport })
|
|
92
|
+
try {
|
|
93
|
+
await d.connect({ role: 'tcp', probeRepl: false })
|
|
94
|
+
assert.deepEqual(await d.control({ action: 'reset', mode: 'hard' }), { ok: true })
|
|
95
|
+
assert.equal(p.disconnects, 0, 'the transport remains open after the reset ACK')
|
|
96
|
+
await d.disconnect()
|
|
97
|
+
assert.equal(p.disconnects, 1)
|
|
98
|
+
assert.equal(p.subscriptions, 0)
|
|
99
|
+
assert.equal(p.requests.filter(request => request.metadata.method === 'session.release').length, 0)
|
|
100
|
+
await d.connect({ role: 'tcp', probeRepl: false })
|
|
101
|
+
await d.disconnect()
|
|
102
|
+
assert.equal(p.disconnects, 2)
|
|
103
|
+
assert.equal(p.requests.filter(request => request.metadata.method === 'session.release').length, 1,
|
|
104
|
+
'normal disconnect still releases the new session')
|
|
105
|
+
} finally { await d.disconnect() }
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('bootloader submission survives facade cleanup without releasing the obsolete lease', async () => {
|
|
109
|
+
const p = peer(), d = api.createDevice({ transport: p.transport })
|
|
110
|
+
try {
|
|
111
|
+
await d.connect({ role: 'tcp', probeRepl: false })
|
|
112
|
+
assert.deepEqual(await d.control({ action: 'bootloader' }), {
|
|
113
|
+
ok: true, operation: 'bootloader', acknowledged: false, outcome: 'unknown',
|
|
114
|
+
})
|
|
115
|
+
await d.disconnect()
|
|
116
|
+
assert.equal(p.disconnects, 1)
|
|
117
|
+
assert.equal(p.subscriptions, 0)
|
|
118
|
+
assert.equal(p.requests.filter(r => r.metadata.method === 'session.release').length, 0)
|
|
119
|
+
} finally { await d.disconnect() }
|
|
120
|
+
})
|
|
121
|
+
|
|
89
122
|
test('connect can acquire and inspect a session without interrupting the REPL', async () => {
|
|
90
123
|
const p = peer({ vmReady: false }), d = api.createDevice({ transport: p.transport })
|
|
91
124
|
try {
|
|
@@ -113,7 +146,7 @@ test('capture returns binary bytes and unsupported LCD capture never captures th
|
|
|
113
146
|
try {
|
|
114
147
|
await d.connect({role:'tcp'})
|
|
115
148
|
const image = await d.capture({source:'camera',quality:70})
|
|
116
|
-
assert.deepEqual(image.data,
|
|
149
|
+
assert.deepEqual(image.data,jpegFixture().data)
|
|
117
150
|
assert.equal(image.mimeType,'image/jpeg')
|
|
118
151
|
const count=p.requests.length
|
|
119
152
|
await assert.rejects(d.capture({source:'screen'}),{code:'UNSUPPORTED_OPERATION'})
|
|
@@ -200,13 +233,13 @@ test('binary capture results correlate by sequence even when responses arrive in
|
|
|
200
233
|
let s;const requests=[]
|
|
201
234
|
const parser=new EvMuxParser({filterFrame(metadata){requests.push(metadata);return false}})
|
|
202
235
|
s=new DeviceSession({transport:{async writeBytes(b){parser.feedBytes(b)}},EvMuxParser,EvRepl,makeEvMuxFrame})
|
|
203
|
-
s.state='ready';s.sessionId='a'.repeat(32);s.vmEpoch=1;s.vmReady=true;s.capabilities={features:[]}
|
|
236
|
+
s.state='ready';s.sessionId='a'.repeat(32);s.vmEpoch=1;s.vmReady=true;s.capabilities={features:['debug.capture_frame', 'debug.capture_frame.observed.v1']}
|
|
204
237
|
try {
|
|
205
238
|
const first=s.captureImage('debug.capture_frame'),second=s.captureImage('debug.capture_frame')
|
|
206
239
|
first.catch(()=>{});second.catch(()=>{})
|
|
207
240
|
await new Promise(setImmediate)
|
|
208
|
-
for(const [index,
|
|
209
|
-
assert.deepEqual((await first).data,
|
|
241
|
+
for(const [index,name] of [[1,'alternate'],[0,'tiny']]){const req=requests[index];s.feedBytes(makeEvMuxFrame({channel:'debug.rpc',type:'rsp',method:req.method,seq:req.seq,sessionId:s.sessionId,vmEpoch:1,contentType:'image/jpeg',encoding:'binary',width:2,height:1,payload:jpegFixture(name).data}))}
|
|
242
|
+
assert.deepEqual((await first).data,jpegFixture().data);assert.deepEqual((await second).data,jpegFixture('alternate').data)
|
|
210
243
|
}finally{s.close()}
|
|
211
244
|
})
|
|
212
245
|
|
|
@@ -251,7 +284,7 @@ test('LCD capture uses its advertised screen method and preserves dimensions and
|
|
|
251
284
|
assert.equal(image.mimeType, 'image/jpeg')
|
|
252
285
|
assert.equal(image.metadata.width, 2)
|
|
253
286
|
assert.equal(image.metadata.height, 1)
|
|
254
|
-
assert.deepEqual(image.data,
|
|
287
|
+
assert.deepEqual(image.data, jpegFixture().data)
|
|
255
288
|
const request = p.requests.find(x => x.metadata.method === 'debug.capture_screen')
|
|
256
289
|
assert.deepEqual(request.payload, { quality: 85 })
|
|
257
290
|
assert.equal(p.requests.some(x => x.metadata.method === 'debug.capture_frame'), false)
|
|
@@ -7,6 +7,7 @@ import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
|
|
|
7
7
|
import { tmpdir } from 'node:os'
|
|
8
8
|
import { parseArgs } from './hardware-check.mjs'
|
|
9
9
|
import { DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
|
|
10
|
+
import { jpegFixture } from './jpeg-fixtures.mjs'
|
|
10
11
|
|
|
11
12
|
function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {}) {
|
|
12
13
|
const files = new Map(), calls = [], listeners = new Map()
|
|
@@ -30,7 +31,7 @@ function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {})
|
|
|
30
31
|
async capture({ source }) {
|
|
31
32
|
calls.push(['capture', source])
|
|
32
33
|
if (source === 'screen' && screenFails) throw Object.assign(new Error('Not supported'), { code: 'UNSUPPORTED_OPERATION' })
|
|
33
|
-
return { data:
|
|
34
|
+
return { data: jpegFixture('screen').data, mimeType: 'image/jpeg', metadata: { width: 240, height: 320 } }
|
|
34
35
|
},
|
|
35
36
|
}
|
|
36
37
|
}
|
|
@@ -90,7 +91,7 @@ test('screen JPEG is captured while display fixture is alive and saved on host',
|
|
|
90
91
|
const output = join(directory, 'screen.jpg'), device = devicePeer()
|
|
91
92
|
try {
|
|
92
93
|
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'display', '--screen-output', output]), { nonce: 'abc123', report() {} })
|
|
93
|
-
assert.deepEqual(await readFile(output), Buffer.from(
|
|
94
|
+
assert.deepEqual(await readFile(output), Buffer.from(jpegFixture('screen').data))
|
|
94
95
|
const captureAt = device.calls.findIndex(call => call[0] === 'capture' && call[1] === 'screen')
|
|
95
96
|
const closeAt = device.calls.findIndex(call => call[0] === 'execute' && call[1].includes("['close_display']()"))
|
|
96
97
|
assert.ok(captureAt > 0 && closeAt > captureAt)
|
|
@@ -98,6 +99,15 @@ test('screen JPEG is captured while display fixture is alive and saved on host',
|
|
|
98
99
|
} finally { await rm(directory, { recursive: true, force: true }) }
|
|
99
100
|
})
|
|
100
101
|
|
|
102
|
+
test('camera acceptance explicitly publishes an application snapshot before passive observation', async () => {
|
|
103
|
+
const device = devicePeer()
|
|
104
|
+
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'camera']), { nonce: 'abc123', report() {} })
|
|
105
|
+
const published = device.calls.findIndex(call => call[0] === 'execute' && call[1].includes("['run_check'](\"camera\""))
|
|
106
|
+
const observed = device.calls.findIndex(call => call[0] === 'capture' && call[1] === 'camera')
|
|
107
|
+
assert.ok(published > 0 && observed > published)
|
|
108
|
+
assert.equal(device.files.size, 0)
|
|
109
|
+
})
|
|
110
|
+
|
|
101
111
|
test('truncated JPEG and missing dimensions are refused before host artifact is written', async () => {
|
|
102
112
|
await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216]), mimeType: 'image/jpeg', metadata: { width: 1, height: 1 } }), { code: 'CAPTURE_INVALID' })
|
|
103
113
|
await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216, 255, 217]), mimeType: 'image/jpeg' }), { code: 'CAPTURE_INVALID' })
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import test from 'node:test'
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
4
|
import { createDeviceOperations } from '../src/device_operations.js'
|
|
5
|
+
import { jpegFixture } from './jpeg-fixtures.mjs'
|
|
5
6
|
|
|
6
|
-
function fixture({ enabled = true, features = ['network.background', 'network.configure', 'debug.capture_frame', 'device.control', 'storage.writer.lease'] } = {}) {
|
|
7
|
+
function fixture({ enabled = true, features = ['network.background', 'network.configure', 'debug.capture_frame', 'debug.capture_frame.observed.v1', 'device.control', 'storage.writer.lease'] } = {}) {
|
|
7
8
|
const calls = []
|
|
8
9
|
let operation = null
|
|
9
10
|
let sequence = 0
|
|
@@ -17,7 +18,7 @@ function fixture({ enabled = true, features = ['network.background', 'network.co
|
|
|
17
18
|
async forgetNetwork(opts) { calls.push(['forget', opts]); enabled = false; operation = { id: String(++sequence), state: 'succeeded' }; return { accepted: true, operationId: operation.id } },
|
|
18
19
|
async rpc(method, payload, opts) { calls.push(['rpc', method, payload, opts]); return { ok: true } },
|
|
19
20
|
async control(command, opts) { calls.push(['control', command, opts]); return { ok: true } },
|
|
20
|
-
async captureImage(method, payload, opts) { calls.push(['capture', method, payload, opts]); return { data:
|
|
21
|
+
async captureImage(method, payload, opts) { calls.push(['capture', method, payload, opts]); return { data: jpegFixture().data, mimeType: 'image/jpeg', metadata: { seq: 1, width: 2, height: 1 } } },
|
|
21
22
|
}
|
|
22
23
|
return { session, state, calls, api: createDeviceOperations({ session, state }) }
|
|
23
24
|
}
|
|
@@ -107,7 +108,7 @@ test('control maps explicit commands, validates mode and can interrupt Python bu
|
|
|
107
108
|
test('capture returns binary JPEG, forwards quality and never substitutes camera for unsupported screen', async () => {
|
|
108
109
|
const { api, calls, state } = fixture()
|
|
109
110
|
const image = await api.capture({ source: 'camera', format: 'jpeg', quality: 80 })
|
|
110
|
-
assert.deepEqual(image.data,
|
|
111
|
+
assert.deepEqual(image.data, jpegFixture().data)
|
|
111
112
|
assert.equal(calls[0][1], 'debug.capture_frame')
|
|
112
113
|
assert.deepEqual(calls[0][2], { quality: 80 })
|
|
113
114
|
await assert.rejects(api.capture({ source: 'screen' }), error => error.code === 'UNSUPPORTED_OPERATION')
|