@esp-vision/device-core 0.5.1 → 0.6.1
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 +37 -0
- package/NOTICE +8 -0
- package/README.md +25 -8
- package/package.json +4 -3
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +2 -1
- package/scripts/npm-command.mjs +17 -0
- package/scripts/release-npm.mjs +18 -1
- 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 +92 -29
- package/src/node_rom.py +134 -6
- package/src/node_rom_port.js +219 -0
- package/src/node_serial.py +122 -13
- package/src/node_transport.js +136 -21
- package/src/rom_md5.js +30 -0
- package/tests/check-browser.mjs +76 -123
- package/tests/check-package.mjs +8 -7
- 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-host-checks.mjs +47 -0
- package/tests/test-jpeg-validation.mjs +233 -0
- package/tests/test-node-rom-port.mjs +385 -0
- package/tests/test-node-rom.mjs +231 -0
- package/tests/test-node-serial.mjs +436 -1
- package/tests/test-npm-release-policy.mjs +56 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
4
4
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
5
5
|
import { delimiter, dirname, join, resolve } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
@@ -102,3 +102,438 @@ 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(), error => error.code === 'SERIAL_ERROR' && error.details.exitCode === 2)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
async function failureFixture(t, mode) {
|
|
313
|
+
await mkdir(tempBase, { recursive: true })
|
|
314
|
+
const root = await mkdtemp(join(tempBase, 'serial-failure-')), gate = join(root, 'fail')
|
|
315
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
316
|
+
await writeFile(join(root, 'serial.py'), `
|
|
317
|
+
import os, signal, time
|
|
318
|
+
MODE = ${JSON.stringify(mode)}
|
|
319
|
+
GATE = ${JSON.stringify(gate)}
|
|
320
|
+
SECRET = 'TRANSFERRED_BYTES_MUST_NOT_ESCAPE_' * 100
|
|
321
|
+
class SerialException(Exception):
|
|
322
|
+
pass
|
|
323
|
+
class Serial:
|
|
324
|
+
def __init__(self, *args, **kwargs):
|
|
325
|
+
self.baudrate = kwargs['baudrate']
|
|
326
|
+
def open(self):
|
|
327
|
+
if MODE == 'denied':
|
|
328
|
+
raise SerialException('Could not open COM7: Access is denied ' + SECRET)
|
|
329
|
+
if MODE == 'opening':
|
|
330
|
+
time.sleep(1)
|
|
331
|
+
def read(self, size):
|
|
332
|
+
if os.path.exists(GATE):
|
|
333
|
+
if MODE == 'read':
|
|
334
|
+
raise OSError(5, SECRET)
|
|
335
|
+
if MODE == 'read-private':
|
|
336
|
+
raise SerialException(SECRET)
|
|
337
|
+
if MODE == 'exit':
|
|
338
|
+
os._exit(7)
|
|
339
|
+
if MODE == 'eof':
|
|
340
|
+
os._exit(0)
|
|
341
|
+
if MODE == 'signal':
|
|
342
|
+
os.kill(os.getpid(), signal.SIGKILL)
|
|
343
|
+
time.sleep(.005)
|
|
344
|
+
return b''
|
|
345
|
+
def write(self, data):
|
|
346
|
+
if MODE == 'write':
|
|
347
|
+
raise SerialException('GetOverlappedResult failed ' + SECRET + repr(data))
|
|
348
|
+
return len(data)
|
|
349
|
+
def flush(self):
|
|
350
|
+
pass
|
|
351
|
+
def close(self):
|
|
352
|
+
pass
|
|
353
|
+
`)
|
|
354
|
+
return { root, gate, fail: () => writeFile(gate, '') }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function deadline(promise, message) {
|
|
358
|
+
let timer
|
|
359
|
+
return Promise.race([promise, new Promise((_, reject) => {
|
|
360
|
+
timer = setTimeout(() => reject(new Error(message)), 5000)
|
|
361
|
+
})]).finally(() => clearTimeout(timer))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function transportFailure(t, mode) {
|
|
365
|
+
const f = await failureFixture(t, mode), previous = process.env.PYTHONPATH
|
|
366
|
+
process.env.PYTHONPATH = [f.root, previous].filter(Boolean).join(delimiter)
|
|
367
|
+
t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
|
|
368
|
+
const transport = serialTransport({ python, serial: '/dev/fixture' }), errors = []
|
|
369
|
+
let closed
|
|
370
|
+
const close = new Promise(resolveClose => { closed = resolveClose })
|
|
371
|
+
transport.subscribe({ error: error => errors.push(error), close: closed })
|
|
372
|
+
t.after(() => transport.disconnect())
|
|
373
|
+
await transport.connect({ signal: new AbortController().signal })
|
|
374
|
+
return { ...f, transport, errors, close: () => deadline(close, 'Serial child did not close') }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
test('actual Python reader failure reaches Node with safe OS cause and child status', { skip: !python }, async t => {
|
|
378
|
+
const f = await transportFailure(t, 'read')
|
|
379
|
+
await f.fail()
|
|
380
|
+
await f.close()
|
|
381
|
+
assert.equal(f.errors.length, 1)
|
|
382
|
+
const error = f.errors[0]
|
|
383
|
+
assert.equal(error.code, 'SERIAL_ERROR')
|
|
384
|
+
assert.deepEqual(error.details, { stage: 'read', type: 'OSError', errno: 5, winerror: null,
|
|
385
|
+
message: 'Input/output error', exitCode: 2, signal: null })
|
|
386
|
+
assert.equal(error.cause.name, 'OSError')
|
|
387
|
+
assert.equal(error.cause.errno, 5)
|
|
388
|
+
assert.deepEqual(f.transport.getDiagnostics().lastError, error.details)
|
|
389
|
+
assert.equal(JSON.stringify(error.details).includes('TRANSFERRED_BYTES'), false)
|
|
390
|
+
const snapshot = f.transport.getDiagnostics()
|
|
391
|
+
snapshot.lastError.message = 'changed by caller'
|
|
392
|
+
assert.notEqual(f.transport.getDiagnostics().lastError.message, snapshot.lastError.message)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('actual Python writer failure keeps its API category without exposing payload or arbitrary exception text', { skip: !python }, async t => {
|
|
396
|
+
const f = await transportFailure(t, 'write')
|
|
397
|
+
const payload = new TextEncoder().encode('private transmitted bytes, never a diagnostic')
|
|
398
|
+
await f.transport.writeBytes(payload)
|
|
399
|
+
await f.close()
|
|
400
|
+
assert.equal(f.errors.length, 1)
|
|
401
|
+
assert.equal(f.errors[0].code, 'SERIAL_ERROR')
|
|
402
|
+
assert.deepEqual(f.errors[0].details, { stage: 'write', type: 'SerialException', errno: null,
|
|
403
|
+
winerror: null, message: 'GetOverlappedResult failed', exitCode: 2, signal: null })
|
|
404
|
+
const diagnostic = JSON.stringify(f.transport.getDiagnostics())
|
|
405
|
+
assert.equal(diagnostic.includes('TRANSFERRED_BYTES'), false)
|
|
406
|
+
assert.equal(diagnostic.includes('private transmitted bytes'), false)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('unrecognized Python exception text is omitted and diagnostics remain bounded', { skip: !python }, async t => {
|
|
410
|
+
const f = await transportFailure(t, 'read-private')
|
|
411
|
+
await f.fail()
|
|
412
|
+
await f.close()
|
|
413
|
+
assert.equal(f.errors.length, 1)
|
|
414
|
+
assert.equal(f.errors[0].details.message, 'Serial read failed')
|
|
415
|
+
assert(JSON.stringify(f.errors[0].details).length < 512)
|
|
416
|
+
assert.equal(JSON.stringify(f.errors[0].details).includes('TRANSFERRED_BYTES'), false)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test('unexpected bridge exit zero, nonzero or signal reports one serial error with the actual close status', {
|
|
420
|
+
skip: !python,
|
|
421
|
+
}, async t => {
|
|
422
|
+
for (const mode of ['exit', 'eof', ...(process.platform === 'win32' ? [] : ['signal'])]) {
|
|
423
|
+
const f = await transportFailure(t, mode)
|
|
424
|
+
await f.fail()
|
|
425
|
+
await f.close()
|
|
426
|
+
assert.equal(f.errors.length, 1, mode)
|
|
427
|
+
assert.equal(f.errors[0].code, 'SERIAL_ERROR')
|
|
428
|
+
assert.equal(f.errors[0].details.stage, 'process')
|
|
429
|
+
assert.equal(f.errors[0].details.exitCode, mode === 'exit' ? 7 : mode === 'eof' ? 0 : null)
|
|
430
|
+
assert.equal(f.errors[0].details.signal, mode === 'signal' ? 'SIGKILL' : null)
|
|
431
|
+
}
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('intentional disconnect and connection cancellation do not become serial failures', { skip: !python }, async t => {
|
|
435
|
+
const f = await transportFailure(t, 'idle')
|
|
436
|
+
await f.transport.disconnect()
|
|
437
|
+
await f.close()
|
|
438
|
+
assert.deepEqual(f.errors, [])
|
|
439
|
+
assert.equal(f.transport.getDiagnostics().lastError, undefined)
|
|
440
|
+
const pending = await failureFixture(t, 'opening'), previous = process.env.PYTHONPATH
|
|
441
|
+
process.env.PYTHONPATH = [pending.root, previous].filter(Boolean).join(delimiter)
|
|
442
|
+
t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
|
|
443
|
+
const transport = serialTransport({ python, serial: '/dev/fixture' }), errors = []
|
|
444
|
+
let closed
|
|
445
|
+
const close = new Promise(resolveClose => { closed = resolveClose })
|
|
446
|
+
transport.subscribe({ error: error => errors.push(error), close: closed })
|
|
447
|
+
t.after(() => transport.disconnect())
|
|
448
|
+
const controller = new AbortController(), connecting = transport.connect({ signal: controller.signal })
|
|
449
|
+
const cancelled = assert.rejects(connecting, { code: 'CANCELLED' })
|
|
450
|
+
controller.abort()
|
|
451
|
+
await cancelled
|
|
452
|
+
await deadline(close, 'Cancelled child did not close')
|
|
453
|
+
assert.deepEqual(errors, [])
|
|
454
|
+
assert.equal(transport.getDiagnostics().lastError, undefined)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
test('Python failure stderr is one bounded structured record and never includes transfer data', { skip: !python }, async t => {
|
|
459
|
+
for (const mode of ['read-private', 'write']) {
|
|
460
|
+
const f = await failureFixture(t, mode), stderr = [], stdout = []
|
|
461
|
+
const child = spawn(python, ['-u', adapter, '/dev/fixture'], {
|
|
462
|
+
env: fixturePythonEnv(f.root), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
|
|
463
|
+
})
|
|
464
|
+
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM') })
|
|
465
|
+
let ready
|
|
466
|
+
const started = new Promise(resolveReady => { ready = resolveReady })
|
|
467
|
+
child.stdout.on('data', bytes => stdout.push(bytes))
|
|
468
|
+
child.stderr.on('data', bytes => { stderr.push(bytes); if (/(?:^|\n)READY\r?\n/.test(Buffer.concat(stderr).toString())) ready() })
|
|
469
|
+
const closed = new Promise((resolveClose, reject) => { child.on('error', reject); child.on('close', (code, signal) => resolveClose({ code, signal })) })
|
|
470
|
+
await deadline(started, 'Python fault fixture did not become ready')
|
|
471
|
+
if (mode === 'write') {
|
|
472
|
+
const payload = Buffer.from('private serial bytes')
|
|
473
|
+
const record = Buffer.alloc(5 + payload.length)
|
|
474
|
+
record.writeUInt8(0, 0); record.writeUInt32BE(payload.length, 1); record.set(payload, 5)
|
|
475
|
+
child.stdin.write(record)
|
|
476
|
+
} else await f.fail()
|
|
477
|
+
assert.deepEqual(await deadline(closed, 'Python fault did not terminate'), { code: 2, signal: null })
|
|
478
|
+
const text = Buffer.concat(stderr).toString(), lines = text.trim().split(/\r?\n/)
|
|
479
|
+
assert.equal(lines.filter(line => line === 'READY').length, 1)
|
|
480
|
+
const records = lines.filter(line => line !== 'READY').map(line => JSON.parse(line))
|
|
481
|
+
assert.equal(records.length, 1)
|
|
482
|
+
assert.equal(records[0].kind, 'serial.error')
|
|
483
|
+
assert.equal(records[0].stage, mode === 'write' ? 'write' : 'read')
|
|
484
|
+
assert(Buffer.byteLength(lines.find(line => line !== 'READY')) < 512)
|
|
485
|
+
assert.equal(text.includes('TRANSFERRED_BYTES'), false)
|
|
486
|
+
assert.equal(text.includes('private serial bytes'), false)
|
|
487
|
+
assert.equal(Buffer.concat(stdout).length, 0)
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
test('disconnect before READY settles the pending connection without reporting an unexpected failure', { skip: !python }, async t => {
|
|
493
|
+
const f = await failureFixture(t, 'opening'), previous = process.env.PYTHONPATH
|
|
494
|
+
process.env.PYTHONPATH = [f.root, previous].filter(Boolean).join(delimiter)
|
|
495
|
+
t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
|
|
496
|
+
const transport = serialTransport({ python, serial: '/dev/fixture' }), errors = []
|
|
497
|
+
transport.subscribe({ error: error => errors.push(error) })
|
|
498
|
+
t.after(() => transport.disconnect())
|
|
499
|
+
const connecting = transport.connect({ signal: new AbortController().signal })
|
|
500
|
+
const closed = assert.rejects(connecting, { code: 'SERIAL_CLOSED' })
|
|
501
|
+
await transport.disconnect()
|
|
502
|
+
await deadline(closed, 'Disconnect did not settle the pending connection')
|
|
503
|
+
assert.deepEqual(errors, [])
|
|
504
|
+
assert.equal(transport.getDiagnostics().lastError, undefined)
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
test('spawn failure before READY preserves its bounded process cause and settles once', async t => {
|
|
509
|
+
const transport = serialTransport({ python: join(tempBase, 'missing-python-executable'), serial: '/dev/fixture' }), errors = []
|
|
510
|
+
transport.subscribe({ error: error => errors.push(error) })
|
|
511
|
+
t.after(() => transport.disconnect())
|
|
512
|
+
await assert.rejects(transport.connect({ signal: new AbortController().signal }), error => {
|
|
513
|
+
assert.equal(error.code, 'SERIAL_ERROR')
|
|
514
|
+
assert.equal(error.details.stage, 'process')
|
|
515
|
+
assert.equal(error.details.type, 'ProcessError')
|
|
516
|
+
assert.equal(error.details.exitCode, process.platform === 'win32' ? -4058 : -2)
|
|
517
|
+
assert.equal(error.details.signal, null)
|
|
518
|
+
assert.match(error.details.message, /ENOENT/)
|
|
519
|
+
return true
|
|
520
|
+
})
|
|
521
|
+
assert.equal(errors.length, 1)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
test('Windows-style access denial before READY retains its existing error code without port or exception text', { skip: !python }, async t => {
|
|
525
|
+
const f = await failureFixture(t, 'denied'), previous = process.env.PYTHONPATH
|
|
526
|
+
process.env.PYTHONPATH = [f.root, previous].filter(Boolean).join(delimiter)
|
|
527
|
+
t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
|
|
528
|
+
const transport = serialTransport({ python, serial: 'COM7' }), errors = []
|
|
529
|
+
transport.subscribe({ error: error => errors.push(error) })
|
|
530
|
+
t.after(() => transport.disconnect())
|
|
531
|
+
await assert.rejects(transport.connect({ signal: new AbortController().signal }), error => {
|
|
532
|
+
assert.equal(error.code, 'SERIAL_ACCESS_DENIED')
|
|
533
|
+
assert.equal(error.details.exitCode, 2)
|
|
534
|
+
assert.equal(JSON.stringify(error.details).includes('TRANSFERRED_BYTES'), false)
|
|
535
|
+
assert.equal(error.message.includes('COM7'), false)
|
|
536
|
+
return true
|
|
537
|
+
})
|
|
538
|
+
assert.equal(errors.length, 1)
|
|
539
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
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 copyFile(join(source, 'npm-command.mjs'), join(scripts, 'npm-command.mjs'))
|
|
21
|
+
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/' } }))
|
|
22
|
+
const loader = join(root, 'registry.mjs'), npm = join(root, 'npm.cjs')
|
|
23
|
+
// Invoke the fixture with Node on every platform, without shell/PATH resolution.
|
|
24
|
+
await writeFile(loader, `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'}})} }`)
|
|
25
|
+
await writeFile(npm, `const fs = require('node:fs'); const args = process.argv.slice(2); fs.appendFileSync(process.env.CALL_LOG, args[0] + '\\n');
|
|
26
|
+
if (args[0] === 'whoami') process.stdout.write('esp-vision');
|
|
27
|
+
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'}]}]));
|
|
28
|
+
else if (args[0] !== 'publish') process.exit(2);
|
|
29
|
+
`)
|
|
30
|
+
const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root, CALL_LOG: log, npm_execpath: npm,
|
|
31
|
+
NODE_OPTIONS: `--import ${pathToFileURL(loader).href}`, NPM_PUBLISH_TOKEN: 'fixture', CI_PIPELINE_SOURCE: 'push',
|
|
32
|
+
CI_DEFAULT_BRANCH: 'master', CI_COMMIT_BRANCH: 'master', CI_COMMIT_REF_PROTECTED: 'true' }
|
|
33
|
+
const result = spawnSync(process.execPath, [resolve(scripts, 'release-npm.mjs')], { cwd: root, env, encoding: 'utf8' })
|
|
34
|
+
return { result, calls: await readFile(log, 'utf8').catch(() => '') }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('an existing Core version is skipped without npm writes', async t => {
|
|
38
|
+
const { result, calls } = await fixture(t, true)
|
|
39
|
+
assert.equal(result.status, 0, result.stderr)
|
|
40
|
+
assert.match(result.stdout, /already exists; no new version to publish/)
|
|
41
|
+
assert.equal(calls, '')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a new Core version is published once and its registry integrity verified', async t => {
|
|
45
|
+
const { result, calls } = await fixture(t, false)
|
|
46
|
+
assert.equal(result.status, 0, result.stderr)
|
|
47
|
+
assert.match(result.stdout, /Published and verified/)
|
|
48
|
+
assert.deepEqual(calls.trim().split('\n'), ['pack', 'whoami', 'publish'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('a misclassified Core version is rejected before packaging or publication', async t => {
|
|
52
|
+
const { result, calls } = await fixture(t, false, '1.3.0', 'patch')
|
|
53
|
+
assert.notEqual(result.status, 0)
|
|
54
|
+
assert.match(result.stderr, /A patch release after 1.2.2 must be 1.2.3/)
|
|
55
|
+
assert.equal(calls, '')
|
|
56
|
+
})
|
|
@@ -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
|
+
})
|