@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE +8 -0
  3. package/README.md +24 -7
  4. package/package.json +4 -3
  5. package/schemas/device-actions-v1.json +1 -1
  6. package/scripts/release-npm.mjs +16 -0
  7. package/scripts/version-policy.mjs +71 -0
  8. package/src/browser.js +6 -2
  9. package/src/browser_rom.js +208 -4
  10. package/src/browser_rom_compat.js +86 -59
  11. package/src/browser_serial.js +130 -0
  12. package/src/device.js +5 -4
  13. package/src/device_bindings.js +5 -4
  14. package/src/device_capture.js +11 -0
  15. package/src/device_operations.js +3 -1
  16. package/src/device_session.js +263 -10
  17. package/src/evmux.js +43 -4
  18. package/src/firmware_flash.js +1 -1
  19. package/src/jpeg_validate.js +132 -0
  20. package/src/node_inventory.js +1 -1
  21. package/src/node_rom.js +76 -27
  22. package/src/node_rom.py +68 -2
  23. package/src/node_rom_port.js +211 -0
  24. package/src/node_serial.py +47 -1
  25. package/src/node_transport.js +76 -10
  26. package/src/rom_md5.js +30 -0
  27. package/tests/check-browser.mjs +76 -123
  28. package/tests/check-package.mjs +1 -1
  29. package/tests/device-checks.mjs +6 -2
  30. package/tests/fixtures/device-checks.py +11 -1
  31. package/tests/jpeg-fixtures.mjs +39 -0
  32. package/tests/test-browser-rom.mjs +204 -67
  33. package/tests/test-browser-serial.mjs +143 -0
  34. package/tests/test-device-api.mjs +40 -7
  35. package/tests/test-device-functional-checks.mjs +12 -2
  36. package/tests/test-device-operations-api.mjs +4 -3
  37. package/tests/test-device-reset.mjs +292 -0
  38. package/tests/test-firmware-flash.mjs +1 -1
  39. package/tests/test-jpeg-validation.mjs +233 -0
  40. package/tests/test-node-rom-port.mjs +305 -0
  41. package/tests/test-node-rom.mjs +160 -0
  42. package/tests/test-node-serial.mjs +206 -0
  43. package/tests/test-npm-release-policy.mjs +62 -0
  44. package/tests/test-observed-capture.mjs +131 -0
  45. package/tests/test-uart-baud.mjs +180 -0
  46. package/tests/test-version-policy.mjs +37 -0
@@ -102,3 +102,209 @@ test('non-POSIX serial backends do not receive the unsupported exclusive option'
102
102
  })
103
103
  assert.equal(result.status, 0, result.stderr || result.error?.message || 'Python fixture failed')
104
104
  })
105
+
106
+ test('Node live retune drains without reopening and binary payload cannot become a bridge command', { skip: !python }, async t => {
107
+ await mkdir(tempBase, { recursive: true })
108
+ const root = await mkdtemp(join(tempBase, 'serial-retune-'))
109
+ t.after(() => rm(root, { recursive: true, force: true }))
110
+ const stateFile = join(root, 'state.jsonl')
111
+ await writeFile(join(root, 'serial.py'), `
112
+ import json, queue
113
+
114
+ class SerialException(Exception):
115
+ pass
116
+
117
+ def record(event, **fields):
118
+ with open(${JSON.stringify(stateFile)}, 'a', encoding='utf-8') as stream:
119
+ stream.write(json.dumps(dict(event=event, **fields)) + '\\n')
120
+
121
+ class Serial:
122
+ def __init__(self, *args, **kwargs):
123
+ self.queue = queue.Queue()
124
+ self._baudrate = kwargs['baudrate']
125
+ @property
126
+ def baudrate(self):
127
+ return self._baudrate
128
+ @baudrate.setter
129
+ def baudrate(self, value):
130
+ record('baud', value=value)
131
+ if value == 1500000:
132
+ raise ValueError('fixture unsupported baud')
133
+ self._baudrate = value
134
+ def open(self):
135
+ record('open', baud=self.baudrate, rts=self.rts, dtr=self.dtr)
136
+ def read(self, size):
137
+ try:
138
+ return self.queue.get(timeout=0.01)
139
+ except queue.Empty:
140
+ return b''
141
+ def write(self, data):
142
+ record('write', data=data.hex())
143
+ self.queue.put(data)
144
+ return len(data)
145
+ def flush(self):
146
+ record('drain')
147
+ def close(self):
148
+ record('close')
149
+ `)
150
+ const previous = process.env.PYTHONPATH
151
+ process.env.PYTHONPATH = [root, previous].filter(Boolean).join(delimiter)
152
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
153
+ const transport = serialTransport({ python, serial: '/dev/fixture' }), received = []
154
+ let finish
155
+ const echoed = new Promise(resolveEcho => { finish = resolveEcho })
156
+ const data = new Uint8Array([0, 1, 255, ...new TextEncoder().encode('\n{"id":99,"baudRate":1}\n'), 0])
157
+ transport.subscribe({ data(bytes) { received.push(...bytes); if (received.length === data.length) finish() } })
158
+ t.after(() => transport.disconnect())
159
+ await transport.connect({ signal: new AbortController().signal })
160
+ await transport.setBaudRate(2000000)
161
+ await transport.writeBytes(data)
162
+ await Promise.race([echoed, new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error('Binary echo timed out')), 2000); timer.unref() })])
163
+ assert.deepEqual(new Uint8Array(received), data)
164
+ await assert.rejects(transport.setBaudRate(1500000), { code: 'SERIAL_BAUD_UNSUPPORTED' })
165
+ await transport.setBaudRate(115200)
166
+ assert.equal(transport.getDiagnostics().baudRate, 115200)
167
+ const state = (await readFile(stateFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line))
168
+ assert.equal(state.filter(event => event.event === 'open').length, 1)
169
+ assert.deepEqual(state.filter(event => event.event === 'baud').map(event => event.value), [2000000, 1500000, 115200])
170
+ assert.equal(state.filter(event => event.event === 'drain').length, 3)
171
+ assert.deepEqual(state.find(event => event.event === 'open'), { event: 'open', baud: 115200, rts: false, dtr: true })
172
+ })
173
+
174
+ async function drainFixture(t, flushPlan = ['gate']) {
175
+ await mkdir(tempBase, { recursive: true })
176
+ const root = await mkdtemp(join(tempBase, 'serial-drain-'))
177
+ t.after(() => rm(root, { recursive: true, force: true }))
178
+ const stateFile = join(root, 'state.jsonl'), gateFile = join(root, 'flush-gate')
179
+ await writeFile(join(root, 'serial.py'), `
180
+ import json, os, sys, time
181
+
182
+ PLAN = json.loads(${JSON.stringify(JSON.stringify(flushPlan))})
183
+
184
+ class SerialException(Exception):
185
+ pass
186
+
187
+ def record(event, **fields):
188
+ with open(${JSON.stringify(stateFile)}, 'a', encoding='utf-8') as stream:
189
+ stream.write(json.dumps(dict(event=event, **fields)) + '\\n')
190
+
191
+ class Serial:
192
+ def __init__(self, *args, **kwargs):
193
+ self.baudrate = kwargs['baudrate']
194
+ self.flush_count = 0
195
+ def open(self):
196
+ record('open', baud=self.baudrate)
197
+ def read(self, size):
198
+ time.sleep(0.01)
199
+ return b''
200
+ def write(self, data):
201
+ # Exercise the production bridge's partial serial-write loop.
202
+ written = min(3, len(data))
203
+ record('write', data=data[:written].hex())
204
+ return written
205
+ def flush(self):
206
+ self.flush_count += 1
207
+ count = self.flush_count
208
+ mode = PLAN[min(count - 1, len(PLAN) - 1)]
209
+ record('drain_start', count=count, baud=self.baudrate)
210
+ if mode == 'error':
211
+ raise SerialException('fixture drain failure: private port details')
212
+ if mode == 'exit':
213
+ raise SystemExit(2)
214
+ if mode == 'wrong_ack':
215
+ print(json.dumps({'id': 1, 'ok': True, 'baudRate': self.baudrate}), file=sys.stderr, flush=True)
216
+ elif mode == 'gate':
217
+ while not os.path.exists(${JSON.stringify(gateFile)}):
218
+ time.sleep(0.005)
219
+ else:
220
+ time.sleep(mode)
221
+ record('drain_end', count=count, baud=self.baudrate)
222
+ def close(self):
223
+ record('close')
224
+ `)
225
+ const previous = process.env.PYTHONPATH
226
+ process.env.PYTHONPATH = [root, previous].filter(Boolean).join(delimiter)
227
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
228
+ const transport = serialTransport({ python, serial: '/dev/fixture' })
229
+ t.after(() => transport.disconnect())
230
+ await transport.connect({ signal: new AbortController().signal })
231
+ const state = async () => (await readFile(stateFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line))
232
+ const waitFor = async (event, count = 1) => {
233
+ const deadline = Date.now() + 3000
234
+ while (Date.now() < deadline) {
235
+ const records = await state()
236
+ if (records.filter(record => record.event === event).length >= count) return records
237
+ await new Promise(resolveWait => setTimeout(resolveWait, 5))
238
+ }
239
+ throw new Error(`Serial fixture did not reach ${event}`)
240
+ }
241
+ return { transport, state, waitFor, unblock: () => writeFile(gateFile, '') }
242
+ }
243
+
244
+ test('Node drain waits for real Python port.flush after all partial byte writes without changing baud', { skip: !python }, async t => {
245
+ const f = await drainFixture(t)
246
+ const bytes = new TextEncoder().encode('import machine; machine.bootloader()\r\n')
247
+ await f.transport.writeBytes(bytes)
248
+ let completed = false
249
+ const draining = f.transport.drain().then(() => { completed = true })
250
+ const before = await f.waitFor('drain_start')
251
+ assert.equal(completed, false, 'accepting the child stdin record is not a drain ACK')
252
+ assert.deepEqual(Buffer.from(before.filter(record => record.event === 'write').map(record => record.data).join(''), 'hex'), Buffer.from(bytes))
253
+ assert.equal(before.some(record => record.event === 'drain_end'), false)
254
+ await f.unblock()
255
+ await draining
256
+ assert.equal(f.transport.getDiagnostics().baudRate, 115200)
257
+ await f.transport.disconnect()
258
+ const after = await f.state()
259
+ assert.deepEqual(after.filter(record => ['open', 'drain_start', 'drain_end', 'close'].includes(record.event)).map(record => record.event), ['open', 'drain_start', 'drain_end', 'close'])
260
+ assert.deepEqual(after.filter(record => record.event.startsWith('drain')).map(record => record.baud), [115200, 115200])
261
+ })
262
+
263
+ test('Node drain distinguishes flush rejection and a baud-shaped ACK from successful drain', { skip: !python }, async t => {
264
+ const f = await drainFixture(t, ['error', 0])
265
+ await assert.rejects(f.transport.drain(), { code: 'SERIAL_DRAIN_FAILED', message: 'Serial adapter could not drain pending output' })
266
+ await f.transport.drain()
267
+ assert.equal(f.transport.getDiagnostics().baudRate, 115200)
268
+ await f.transport.disconnect()
269
+ const wrong = await drainFixture(t, ['wrong_ack'])
270
+ await assert.rejects(wrong.transport.drain(), { code: 'SERIAL_DRAIN_FAILED' })
271
+ assert.equal(wrong.transport.getDiagnostics().baudRate, 115200)
272
+ })
273
+
274
+ test('Node drain preserves pre-cancellation and ignores a cancelled request late ACK', { skip: !python }, async t => {
275
+ const f = await drainFixture(t, ['gate', 0.05])
276
+ const before = new AbortController(), reason = Object.assign(new Error('cancel drain'), { code: 'CANCELLED' })
277
+ before.abort(reason)
278
+ await assert.rejects(f.transport.drain({ signal: before.signal }), error => error === reason)
279
+ assert.equal((await f.state()).some(record => record.event === 'drain_start'), false)
280
+ const controller = new AbortController()
281
+ const pending = f.transport.drain({ signal: controller.signal })
282
+ const rejected = assert.rejects(pending, error => error === reason)
283
+ await f.waitFor('drain_start')
284
+ controller.abort(reason)
285
+ await rejected
286
+ const next = f.transport.drain()
287
+ await f.unblock()
288
+ await next
289
+ assert.equal((await f.state()).filter(record => record.event === 'drain_end').length, 2, 'the previous ID must not resolve the next request')
290
+ })
291
+
292
+ test('Node drain timeout remains distinct and its late ACK cannot resolve the next drain', { skip: !python }, async t => {
293
+ const f = await drainFixture(t, ['gate', 0.05])
294
+ await assert.rejects(f.transport.drain(), { code: 'SERIAL_DRAIN_TIMEOUT', message: 'Serial drain timed out' })
295
+ const next = f.transport.drain()
296
+ await f.unblock()
297
+ await next
298
+ assert.equal((await f.state()).filter(record => record.event === 'drain_end').length, 2)
299
+ })
300
+
301
+ test('Node drain rejects promptly on explicit disconnect or Python bridge exit', { skip: !python }, async t => {
302
+ const f = await drainFixture(t)
303
+ const pending = f.transport.drain()
304
+ const rejected = assert.rejects(pending, { code: 'SERIAL_CLOSED' })
305
+ await f.waitFor('drain_start')
306
+ await f.transport.disconnect()
307
+ await rejected
308
+ const exited = await drainFixture(t, ['exit'])
309
+ await assert.rejects(exited.transport.drain(), { code: 'SERIAL_CLOSED' })
310
+ })
@@ -0,0 +1,62 @@
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 { spawnSync } from 'node:child_process'
5
+ import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
6
+ import { tmpdir } from 'node:os'
7
+ import { join, resolve } from 'node:path'
8
+ import { fileURLToPath, pathToFileURL } from 'node:url'
9
+ import { test } from 'node:test'
10
+
11
+ const source = fileURLToPath(new URL('../scripts/', import.meta.url))
12
+ async function fixture(t, published, version = '1.2.3', type = 'patch') {
13
+ const root = await mkdtemp(join(tmpdir(), 'core-release-policy-'))
14
+ t.after(() => rm(root, { recursive: true, force: true }))
15
+ const scripts = join(root, 'scripts'), log = join(root, 'calls')
16
+ await mkdir(scripts)
17
+ await copyFile(join(source, 'release-npm.mjs'), join(scripts, 'release-npm.mjs'))
18
+ await copyFile(join(source, 'verify-registry.mjs'), join(scripts, 'verify-registry.mjs'))
19
+ await copyFile(join(source, 'version-policy.mjs'), join(scripts, 'version-policy.mjs'))
20
+ await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@esp-vision/device-core', version, espVisionReleaseType: type, license: 'Apache-2.0', publishConfig: { access: 'public', registry: 'https://registry.npmjs.org/' } }))
21
+ const loader = join(root, 'registry.mjs'), npm = join(root, 'npm.cjs')
22
+ // Invoke the fixture with Node on every platform, without shell/PATH resolution.
23
+ await writeFile(loader, `import childProcess from 'node:child_process';
24
+ import { syncBuiltinESMExports } from 'node:module';
25
+ const spawnSync = childProcess.spawnSync;
26
+ childProcess.spawnSync = (command, ...args) => command === 'npm'
27
+ ? spawnSync(process.execPath, [${JSON.stringify(npm)}, ...args[0]], args[1])
28
+ : spawnSync(command, ...args);
29
+ syncBuiltinESMExports();
30
+ let reads = 0; globalThis.fetch = async () => { reads++; const found = ${published} || reads === 2 || reads >= 4; return {ok:found,status:found?200:404,json:async()=>reads===2?({versions:{'1.2.2':{}}}):({dist:{integrity:'sha512-fixture'}})} }`)
31
+ await writeFile(npm, `const fs = require('node:fs'); const args = process.argv.slice(2); fs.appendFileSync(process.env.CALL_LOG, args[0] + '\\n');
32
+ if (args[0] === 'whoami') process.stdout.write('esp-vision');
33
+ else if (args[0] === 'pack') process.stdout.write(JSON.stringify([{name:'@esp-vision/device-core',version:'1.2.3',filename:'core.tgz',integrity:'sha512-fixture',files:[{path:'LICENSE'},{path:'NOTICE'}]}]));
34
+ else if (args[0] !== 'publish') process.exit(2);
35
+ `)
36
+ const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root, CALL_LOG: log,
37
+ NODE_OPTIONS: `--import ${pathToFileURL(loader).href}`, NPM_PUBLISH_TOKEN: 'fixture', CI_PIPELINE_SOURCE: 'push',
38
+ CI_DEFAULT_BRANCH: 'master', CI_COMMIT_BRANCH: 'master', CI_COMMIT_REF_PROTECTED: 'true' }
39
+ const result = spawnSync(process.execPath, [resolve(scripts, 'release-npm.mjs')], { cwd: root, env, encoding: 'utf8' })
40
+ return { result, calls: await readFile(log, 'utf8').catch(() => '') }
41
+ }
42
+
43
+ test('an existing Core version is skipped without npm writes', async t => {
44
+ const { result, calls } = await fixture(t, true)
45
+ assert.equal(result.status, 0, result.stderr)
46
+ assert.match(result.stdout, /already exists; no new version to publish/)
47
+ assert.equal(calls, '')
48
+ })
49
+
50
+ test('a new Core version is published once and its registry integrity verified', async t => {
51
+ const { result, calls } = await fixture(t, false)
52
+ assert.equal(result.status, 0, result.stderr)
53
+ assert.match(result.stdout, /Published and verified/)
54
+ assert.deepEqual(calls.trim().split('\n'), ['pack', 'whoami', 'publish'])
55
+ })
56
+
57
+ test('a misclassified Core version is rejected before packaging or publication', async t => {
58
+ const { result, calls } = await fixture(t, false, '1.3.0', 'patch')
59
+ assert.notEqual(result.status, 0)
60
+ assert.match(result.stderr, /A patch release after 1.2.2 must be 1.2.3/)
61
+ assert.equal(calls, '')
62
+ })
@@ -0,0 +1,131 @@
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 { createDeviceOperations } from '../src/device_operations.js'
7
+ import { DeviceActionRegistry } from '../src/device_actions.js'
8
+ import { bindSessionDeviceActions } from '../src/device_bindings.js'
9
+ import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
10
+ import { EvRepl } from '../src/evrepl.js'
11
+ import { jpegFixture } from './jpeg-fixtures.mjs'
12
+
13
+ const safeFeature = 'debug.capture_frame.observed.v1'
14
+ function fixture({ features = ['debug.capture_frame', safeFeature, 'debug.capture_screen', 'device.control'], cameraError = null, pending = false } = {}) {
15
+ let session
16
+ const requests = [], id = 'a'.repeat(32), state = {}
17
+ const jpeg = jpegFixture('small').data
18
+ const replyImage = (request, { sessionId = request.sessionId, vmEpoch = request.vmEpoch } = {}) => session.feedBytes(makeEvMuxFrame({
19
+ channel: request.channel, type: 'rsp', method: request.method, seq: request.seq, sessionId, vmEpoch,
20
+ contentType: 'image/jpeg', encoding: 'binary', width: 4, height: 3, payload: jpeg,
21
+ }))
22
+ const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
23
+ const payload = JSON.parse(new TextDecoder().decode(bytes))
24
+ requests.push({ ...metadata, payload })
25
+ if (metadata.method === 'debug.capture_frame' && pending) return false
26
+ if (metadata.method === 'debug.capture_screen' || (metadata.method === 'debug.capture_frame' && !cameraError)) replyImage(metadata)
27
+ else session.feedBytes(makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq,
28
+ sessionId: metadata.sessionId, vmEpoch: metadata.vmEpoch ?? session.vmEpoch,
29
+ payload: JSON.stringify(cameraError && metadata.method === 'debug.capture_frame'
30
+ ? { ok: false, error: { code: cameraError, message: 'No safe camera observation is available' } } : { ok: true }) }))
31
+ return false
32
+ } })
33
+ session = new DeviceSession({ transport: { async writeBytes(bytes) { parser.feedBytes(bytes) } }, EvMuxParser, makeEvMuxFrame, EvRepl })
34
+ session.state = 'ready'
35
+ session.sessionId = id
36
+ session.vmEpoch = 1
37
+ session.vmReady = true
38
+ session.capabilities = { features }
39
+ return { session, requests, replyImage, state, jpeg, api: createDeviceOperations({ session, state }) }
40
+ }
41
+
42
+ test('old unsafe camera firmware is rejected before public, RPC, raw-frame or action requests are sent', async t => {
43
+ for (const features of [[], ['debug.capture_frame'], [safeFeature]]) {
44
+ const f = fixture({ features }); t.after(() => f.session.close())
45
+ const denied = error => error.code === 'UNSUPPORTED_OPERATION' && /update.*firmware/i.test(error.message)
46
+ await assert.rejects(f.api.capture({ source: 'camera' }), denied)
47
+ await assert.rejects(f.session.captureImage('debug.capture_frame'), denied)
48
+ await assert.rejects(f.session.rpc('debug.capture_frame'), denied)
49
+ await assert.rejects(f.session.writeFrame('debug.rpc', 'req', 'debug.capture_frame', '{}'), denied)
50
+ const registry = new DeviceActionRegistry()
51
+ bindSessionDeviceActions(registry)
52
+ await assert.rejects(registry.handler('frame.capture')({ timeoutMs: 1000 }, { session: f.session }), denied)
53
+ assert.deepEqual(f.requests, [])
54
+ assert.equal(f.session.pendingRpc.size, 0)
55
+ assert.equal(f.state.operation, undefined)
56
+ }
57
+ })
58
+
59
+ test('negotiated camera observation returns correlated JPEG bytes and forwards quality', async t => {
60
+ const f = fixture(); t.after(() => f.session.close())
61
+ const image = await f.api.capture({ source: 'camera', quality: 73 })
62
+ assert.deepEqual(image.data, f.jpeg)
63
+ assert.equal(image.mimeType, 'image/jpeg')
64
+ assert.equal(image.metadata.width, 4)
65
+ assert.equal(image.metadata.height, 3)
66
+ assert.equal(f.requests.length, 1)
67
+ assert.equal(f.requests[0].method, 'debug.capture_frame')
68
+ assert.deepEqual(f.requests[0].payload, { quality: 73 })
69
+ assert.equal(f.state.operation, null)
70
+ })
71
+
72
+ test('no published camera frame and observer allocation failures retain explicit firmware errors', async t => {
73
+ for (const cameraError of ['CAMERA_FRAME_NOT_READY', 'NO_MEM']) {
74
+ const f = fixture({ cameraError }); t.after(() => f.session.close())
75
+ await assert.rejects(f.api.capture({ source: 'camera' }), { code: cameraError })
76
+ assert.equal(f.requests.length, 1, 'no sensor initialization, retry or capture fallback is synthesized')
77
+ assert.equal(f.session.lastDebugImage, null)
78
+ assert.equal(f.state.operation, null)
79
+ }
80
+ })
81
+
82
+ test('screen capture retains its independent capability without a camera observation feature', async t => {
83
+ const f = fixture({ features: ['debug.capture_frame', 'debug.capture_screen'] }); t.after(() => f.session.close())
84
+ assert.deepEqual((await f.api.capture({ source: 'screen' })).data, f.jpeg)
85
+ await assert.rejects(f.api.capture({ source: 'camera' }), { code: 'UNSUPPORTED_OPERATION' })
86
+ assert.deepEqual(f.requests.map(request => request.method), ['debug.capture_screen'])
87
+ })
88
+
89
+ test('camera cancellation and timeout release the operation and ignore stale binary responses', async t => {
90
+ const f = fixture({ pending: true }); t.after(() => f.session.close())
91
+ const controller = new AbortController()
92
+ const capture = f.api.capture({ source: 'camera', signal: controller.signal })
93
+ const cancelled = assert.rejects(capture, { name: 'AbortError' })
94
+ await new Promise(setImmediate)
95
+ controller.abort()
96
+ await cancelled
97
+ assert.equal(f.state.operation, null)
98
+ assert.equal(f.session.pendingRpc.size, 0)
99
+ f.replyImage(f.requests[0])
100
+ assert.deepEqual((await f.api.capture({ source: 'screen' })).data, f.jpeg)
101
+ await assert.rejects(f.api.capture({ source: 'camera', timeout: 20 }), { code: 'TIMEOUT' })
102
+ assert.equal(f.state.operation, null)
103
+ assert.equal(f.session.pendingRpc.size, 0)
104
+ })
105
+
106
+ test('soft-reset epochs invalidate a pending camera observation and reject the old image', async t => {
107
+ const f = fixture({ pending: true }); t.after(() => f.session.close())
108
+ const before = f.api.capture({ source: 'camera' })
109
+ const invalidated = assert.rejects(before, { code: 'VM_EPOCH_CHANGED' })
110
+ await new Promise(setImmediate)
111
+ f.session.feedBytes(makeEvMuxFrame({ channel: 'user.rpc', type: 'event', method: 'hello', sessionId: f.session.sessionId, vmEpoch: 2,
112
+ payload: JSON.stringify({ evMuxVersion: 4, session: { id: f.session.sessionId, vmEpoch: 2 }, vmEpoch: 2, vmReady: true }) }))
113
+ await invalidated
114
+ f.replyImage(f.requests[0])
115
+ assert.equal(f.session.vmEpoch, 2)
116
+ assert.deepEqual((await f.api.capture({ source: 'screen' })).data, f.jpeg)
117
+ assert.equal(f.requests.at(-1).vmEpoch, 2)
118
+ })
119
+
120
+ test('acknowledged hard reset discards a pending safe camera response and its old session', async t => {
121
+ const f = fixture({ pending: true }); t.after(() => f.session.close())
122
+ const capture = f.session.captureImage('debug.capture_frame')
123
+ const invalidated = assert.rejects(capture, { code: 'DEVICE_RESET' })
124
+ await new Promise(setImmediate)
125
+ await f.session.control('hard-reset')
126
+ await invalidated
127
+ f.replyImage(f.requests[0])
128
+ assert.equal(f.session.state, 'disconnected')
129
+ assert.equal(f.session.sessionId, null)
130
+ assert.equal(f.session.pendingRpc.size, 0)
131
+ })
@@ -0,0 +1,180 @@
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 { createDevice } from '../src/index.js'
6
+ import { DeviceSession, UART_BAUD_CANDIDATES, EV_MUX_DEFAULT_LIMITS, EV_MUX_REQUIRED_CHANNELS, EV_MUX_REQUIRED_FEATURES } from '../src/device_session.js'
7
+ import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
8
+ import { EvRepl } from '../src/evrepl.js'
9
+
10
+ function peer({ owner = 'uart', support = true, rejectSpeeds = [], corruptSpeeds = [], corruptFrames = [], silentSpeeds = [], dropCommit = false, lostPrepare = false, lostCommitAndRelease = false, resetOnChange = false } = {}) {
11
+ let listener, session, hostBaud = 115200, deviceBaud = 115200, active = false, token, commitDrops = 0
12
+ const id = 'a'.repeat(32), requests = [], changed = [], diagnostics = [], sleeps = []
13
+ const caps = { ok: true, protocol: { evMuxVersion: 4 }, features: EV_MUX_REQUIRED_FEATURES,
14
+ channels: EV_MUX_REQUIRED_CHANNELS, limits: { ...EV_MUX_DEFAULT_LIMITS, txLargePayloadMax: 262144 },
15
+ session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 },
16
+ ...(support ? { uartBaud: { baseline: 115200, current: 115200, candidates: UART_BAUD_CANDIDATES, probeBytes: 512, timeoutMs: 5000, switchDelayMs: 100 } } : {}),
17
+ }
18
+ const reset = () => { active = false; deviceBaud = 115200; token = null }
19
+ const reader = new EvMuxParser({ filterFrame(metadata, bytes) {
20
+ if (hostBaud !== deviceBaud) return false
21
+ const payload = JSON.parse(new TextDecoder().decode(bytes))
22
+ requests.push({ method: metadata.method, payload, baudRate: hostBaud, state: session?.state })
23
+ let result = { ok: true }, acquired = false
24
+ if (metadata.method === 'hello') {
25
+ if (payload.acquire) { active = true; acquired = true }
26
+ result = { ok: true, evMuxVersion: 4, deviceId: 'test', hostname: 'test', vmReady: false,
27
+ session: active ? { id, owner, vmEpoch: 1, leaseMs: 15000, keepaliveMs: 5000 } : null }
28
+ } else if (metadata.method === 'capabilities') result = caps
29
+ else if (metadata.method === 'transport.uart.prepare') {
30
+ token = payload.token
31
+ if (lostPrepare && payload.baudRate === 2000000) { deviceBaud = payload.baudRate; return false }
32
+ result = { ok: true, ...payload, timeoutMs: 5000, switchDelayMs: 100 }
33
+ } else if (metadata.method === 'transport.uart.probe') {
34
+ if (silentSpeeds.includes(hostBaud)) return false
35
+ result = { ok: true, token, data: corruptSpeeds.includes(hostBaud) ? payload.data + 'x' : payload.data }
36
+ } else if (metadata.method === 'transport.uart.commit') {
37
+ if (lostCommitAndRelease && hostBaud === 2000000) return false
38
+ if (dropCommit && commitDrops++ === 0) return false
39
+ result = { ok: true, token, baudRate: deviceBaud }
40
+ }
41
+ if (metadata.method === 'session.release' && lostCommitAndRelease && hostBaud === 2000000) return false
42
+ const response = makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq,
43
+ ...((acquired || !['hello', 'capabilities'].includes(metadata.method)) ? { sessionId: id, vmEpoch: 1 } : {}), payload: JSON.stringify(result) })
44
+ if (metadata.method === 'transport.uart.probe' && corruptFrames.includes(hostBaud)) response[response.length - 2] ^= 1
45
+ listener.data(response)
46
+ if (metadata.method === 'transport.uart.prepare') deviceBaud = payload.baudRate
47
+ if (metadata.method === 'session.release') reset()
48
+ return false
49
+ } })
50
+ const transport = {
51
+ subscribe(value) { listener = value; return () => { listener = null } },
52
+ async connect() { hostBaud = 115200 },
53
+ async disconnect() {},
54
+ async writeBytes(bytes) { reader.feedBytes(bytes) },
55
+ async setBaudRate(speed) {
56
+ changed.push(speed)
57
+ if (rejectSpeeds.includes(speed)) throw Object.assign(new Error('unsupported speed'), { code: 'SERIAL_BAUD_UNSUPPORTED' })
58
+ hostBaud = speed
59
+ if (resetOnChange && speed !== 115200) reset()
60
+ },
61
+ }
62
+ const sleep = async ms => { sleeps.push(ms); if (ms >= 1000) reset() }
63
+ session = new DeviceSession({ transport, EvMuxParser, makeEvMuxFrame, EvRepl, sleep, handlers: { diagnostic: event => diagnostics.push(event) } })
64
+ listener = { data: bytes => session.feedBytes(bytes) }
65
+ return { transport, session, requests, changed, diagnostics, sleeps }
66
+ }
67
+
68
+ test('runtime tests 2 Mbps first with varied bidirectional probes and stops after success', async () => {
69
+ const p = peer()
70
+ try {
71
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
72
+ assert.deepEqual(p.changed, [2000000])
73
+ assert.deepEqual(p.requests.filter(x => x.method === 'transport.uart.probe').map(x => x.payload.data.length), [32, 32, 128, 128, 512, 512])
74
+ assert.equal(p.requests.filter(x => x.method === 'transport.uart.commit').length, 1)
75
+ assert.equal(p.session.capabilities.uartBaud.current, 2000000)
76
+ assert.ok(p.requests.every(x => x.state !== 'ready'), 'no request or event can observe ready before verification')
77
+ assert.equal(p.session.state, 'ready')
78
+ } finally { p.session.close() }
79
+ })
80
+
81
+ test('corrupt echoes and adapter rejection recover at baseline and select first passing lower speed', async () => {
82
+ const p = peer({ corruptSpeeds: [2000000], rejectSpeeds: [1500000] })
83
+ try {
84
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
85
+ assert.deepEqual(p.changed, [2000000, 115200, 1500000, 115200, 921600])
86
+ assert.deepEqual(p.requests.filter(x => x.method === 'transport.uart.prepare').map(x => x.payload.baudRate), [2000000, 1500000, 921600])
87
+ assert.equal(p.requests.filter(x => x.method === 'hello' && x.payload.acquire).length, 3)
88
+ assert.equal(p.session.capabilities.uartBaud.current, 921600)
89
+ } finally { p.session.close() }
90
+ })
91
+
92
+ test('all high speeds can fail while retaining a fully verified 115200 connection', async () => {
93
+ const p = peer({ corruptSpeeds: UART_BAUD_CANDIDATES.slice(0, -1) })
94
+ try {
95
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
96
+ assert.deepEqual(p.requests.filter(x => x.method === 'transport.uart.prepare').map(x => x.payload.baudRate), UART_BAUD_CANDIDATES)
97
+ assert.equal(p.session.capabilities.uartBaud.current, 115200)
98
+ assert.equal(p.session.state, 'ready')
99
+ } finally { p.session.close() }
100
+ })
101
+
102
+ test('a timed out new-rate probe and a reopen-triggered reset both recover before the next candidate', async () => {
103
+ for (const options of [{ silentSpeeds: [2000000] }, { corruptFrames: [2000000] }, { resetOnChange: true }]) {
104
+ const p = peer(options)
105
+ try {
106
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
107
+ assert.equal(p.session.capabilities.uartBaud.current, options.resetOnChange ? 115200 : 1500000)
108
+ assert.ok(p.sleeps.some(ms => ms >= (options.resetOnChange ? 1000 : 150)), 'wait for release completion or independent rollback before reacquiring')
109
+ } finally { p.session.close() }
110
+ }
111
+ })
112
+
113
+ test('a lost commit acknowledgement retries the idempotent token without downgrading', async () => {
114
+ const p = peer({ dropCommit: true })
115
+ try {
116
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
117
+ assert.deepEqual(p.changed, [2000000])
118
+ const commits = p.requests.filter(x => x.method === 'transport.uart.commit')
119
+ assert.equal(commits.length, 2)
120
+ assert.equal(commits[0].payload.token, commits[1].payload.token)
121
+ } finally { p.session.close() }
122
+ })
123
+
124
+ test('lost prepare and committed-session ACKs wait full rollback bounds after the last failed release', async () => {
125
+ for (const options of [{ lostPrepare: true }, { lostCommitAndRelease: true }]) {
126
+ const p = peer(options)
127
+ try {
128
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
129
+ assert.ok(p.sleeps.includes(options.lostPrepare ? 5250 : 15250), 'prepare delay and lease renewal must not shorten the recovery window')
130
+ assert.equal(p.session.capabilities.uartBaud.current, 1500000)
131
+ } finally { p.session.close() }
132
+ }
133
+ })
134
+
135
+ test('a timed out adapter retune receives cancellation before baseline recovery', async () => {
136
+ const p = peer(), original = p.transport.setBaudRate
137
+ let aborted = false
138
+ p.transport.setBaudRate = (speed, options) => speed !== 2000000 ? original(speed, options) : new Promise((_, reject) => {
139
+ options.signal.addEventListener('abort', () => { aborted = true; reject(options.signal.reason) }, { once: true })
140
+ })
141
+ try {
142
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
143
+ assert.equal(aborted, true)
144
+ assert.equal(p.session.capabilities.uartBaud.current, 1500000)
145
+ } finally { p.session.close() }
146
+ })
147
+
148
+ test('USB owners, old firmware and transports lacking a retune method keep baseline', async () => {
149
+ for (const options of [{ owner: 'usb' }, { owner: 'usj' }, { owner: 'tcp' }, { support: false }, { noAdapter: true }]) {
150
+ const p = peer(options)
151
+ if (options.noAdapter) delete p.transport.setBaudRate
152
+ try {
153
+ await p.session.negotiate({ drainMs: 0, probeRepl: false })
154
+ assert.deepEqual(p.changed, [])
155
+ assert.ok(!p.requests.some(x => x.method.startsWith('transport.uart.')))
156
+ } finally { p.session.close() }
157
+ }
158
+ })
159
+
160
+ test('public connect publishes ready only after runtime speed selection', async () => {
161
+ const p = peer(), events = []
162
+ const device = createDevice({ transport: p.transport })
163
+ device.on('state', event => events.push(event.state))
164
+ device.on('diagnostic', event => { if (event.event === 'uart.baud.selected') events.push('selected') })
165
+ try {
166
+ await device.connect({ probeRepl: false })
167
+ assert.ok(events.indexOf('selected') >= 0)
168
+ assert.ok(events.indexOf('ready') > events.indexOf('selected'))
169
+ } finally { await device.disconnect() }
170
+ })
171
+
172
+ test('public passive identity probes can explicitly disable retuning', async () => {
173
+ const p = peer(), device = createDevice({ transport: p.transport })
174
+ for (const autoBaud of [null, 'false', 0]) await assert.rejects(device.connect({ autoBaud }), { name: 'TypeError' })
175
+ try {
176
+ await device.connect({ probeRepl: false, autoBaud: false })
177
+ assert.deepEqual(p.changed, [])
178
+ assert.ok(!p.requests.some(x => x.method.startsWith('transport.uart.')))
179
+ } finally { await device.disconnect() }
180
+ })
@@ -0,0 +1,37 @@
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 { cliBundlesCore, declaredCoreSupports, highestStableVersion, inMinorRange, minorRange, validateVersionStep } from '../scripts/version-policy.mjs'
6
+
7
+ test('minor dependency ranges accept patch updates and reject adjacent minor or major versions', () => {
8
+ for (const version of ['0.5.0', '0.5.1', '0.5.99']) assert.equal(inMinorRange('~0.5.0', version), true)
9
+ for (const version of ['0.4.9', '0.6.0', '1.5.0']) assert.equal(inMinorRange('~0.5.0', version), false)
10
+ for (const value of ['^0.5.0', '0.5.1', '~0.5.1', '*', '~0.05.0']) assert.throws(() => minorRange(value))
11
+ assert.equal(declaredCoreSupports('0.5.1', '0.5.1'), true)
12
+ assert.equal(declaredCoreSupports('0.5.1', '0.5.2'), false)
13
+ assert.equal(declaredCoreSupports('~0.5.0', '0.5.2'), true)
14
+ })
15
+
16
+ test('only the next patch, minor or major version matches its declared release type', () => {
17
+ for (const [kind, next] of [['patch', '0.5.2'], ['minor', '0.6.0'], ['major', '1.0.0']]) {
18
+ assert.doesNotThrow(() => validateVersionStep('0.5.1', next, kind))
19
+ }
20
+ for (const [kind, next] of [['patch', '0.6.0'], ['patch', '0.5.3'], ['minor', '0.5.2'], ['minor', '0.7.0'], ['major', '0.6.0'], ['major', '1.1.0'], ['feature', '0.6.0']]) {
21
+ assert.throws(() => validateVersionStep('0.5.1', next, kind))
22
+ }
23
+ assert.throws(() => validateVersionStep('0.5.1', '0.5.2-rc.1', 'patch'))
24
+ })
25
+
26
+ test('registry baseline is the numerically highest stable published version', () => {
27
+ assert.equal(highestStableVersion(['0.9.9', '0.10.1', '0.10.0', '0.11.0-beta.1', '0.0.0-stage', '1.0.0']), '1.0.0')
28
+ assert.equal(highestStableVersion(['0.0.0-stage']), undefined)
29
+ })
30
+
31
+ test('a bundled CLI identifies the actual Core patch even when its range allows others', () => {
32
+ const cli = { devDependencies: { '@esp-vision/device-core': '~0.5.0' }, espVision: { resolved: { deviceCore: '0.5.1' } } }
33
+ assert.equal(cliBundlesCore(cli, '0.5.1'), true)
34
+ assert.equal(cliBundlesCore(cli, '0.5.2'), false)
35
+ assert.equal(cliBundlesCore({ devDependencies: { '@esp-vision/device-core': '0.5.1' } }, '0.5.1'), true)
36
+ assert.equal(cliBundlesCore({ devDependencies: { '@esp-vision/device-core': '~0.5.0' } }, '0.5.1'), false)
37
+ })