@esp-vision/device-core 0.6.0 → 0.7.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 +29 -0
- package/README.md +8 -5
- package/package.json +1 -1
- 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 +2 -1
- package/src/browser.js +5 -2
- package/src/browser_rom.js +55 -5
- package/src/device_session.js +85 -9
- package/src/evmux.js +15 -0
- package/src/firmware_flash.js +84 -26
- package/src/firmware_partitions.js +57 -0
- package/src/firmware_updates.js +5 -2
- package/src/node_rom.js +49 -7
- package/src/node_rom.py +92 -5
- package/src/node_rom_port.js +27 -19
- package/src/node_serial.py +75 -12
- package/src/node_transport.js +63 -14
- package/src/rom_identity.js +9 -0
- package/tests/check-browser.mjs +23 -4
- package/tests/check-package.mjs +15 -7
- package/tests/device-checks.mjs +89 -7
- package/tests/firmware-fixtures.mjs +60 -0
- package/tests/fixtures/device-checks.py +357 -1
- package/tests/hardware-check.mjs +3 -2
- package/tests/test-browser-rom.mjs +169 -3
- package/tests/test-ci-check-selection.mjs +31 -0
- package/tests/test-device-functional-checks.mjs +275 -3
- package/tests/test-device-v4-alignment.mjs +261 -7
- package/tests/test-firmware-flash.mjs +22 -20
- package/tests/test-firmware-install.mjs +8 -35
- package/tests/test-firmware-source.mjs +10 -7
- package/tests/test-host-checks.mjs +47 -0
- package/tests/test-install-erase.mjs +153 -0
- package/tests/test-node-rom-port.mjs +81 -1
- package/tests/test-node-rom.mjs +159 -4
- package/tests/test-node-serial.mjs +231 -2
- package/tests/test-npm-release-policy.mjs +3 -9
|
@@ -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'
|
|
@@ -306,5 +306,234 @@ test('Node drain rejects promptly on explicit disconnect or Python bridge exit',
|
|
|
306
306
|
await f.transport.disconnect()
|
|
307
307
|
await rejected
|
|
308
308
|
const exited = await drainFixture(t, ['exit'])
|
|
309
|
-
await assert.rejects(exited.transport.drain(),
|
|
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)
|
|
310
539
|
})
|
|
@@ -17,23 +17,17 @@ async function fixture(t, published, version = '1.2.3', type = 'patch') {
|
|
|
17
17
|
await copyFile(join(source, 'release-npm.mjs'), join(scripts, 'release-npm.mjs'))
|
|
18
18
|
await copyFile(join(source, 'verify-registry.mjs'), join(scripts, 'verify-registry.mjs'))
|
|
19
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'))
|
|
20
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/' } }))
|
|
21
22
|
const loader = join(root, 'registry.mjs'), npm = join(root, 'npm.cjs')
|
|
22
23
|
// Invoke the fixture with Node on every platform, without shell/PATH resolution.
|
|
23
|
-
await writeFile(loader, `
|
|
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'}})} }`)
|
|
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'}})} }`)
|
|
31
25
|
await writeFile(npm, `const fs = require('node:fs'); const args = process.argv.slice(2); fs.appendFileSync(process.env.CALL_LOG, args[0] + '\\n');
|
|
32
26
|
if (args[0] === 'whoami') process.stdout.write('esp-vision');
|
|
33
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'}]}]));
|
|
34
28
|
else if (args[0] !== 'publish') process.exit(2);
|
|
35
29
|
`)
|
|
36
|
-
const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root, CALL_LOG: log,
|
|
30
|
+
const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root, CALL_LOG: log, npm_execpath: npm,
|
|
37
31
|
NODE_OPTIONS: `--import ${pathToFileURL(loader).href}`, NPM_PUBLISH_TOKEN: 'fixture', CI_PIPELINE_SOURCE: 'push',
|
|
38
32
|
CI_DEFAULT_BRANCH: 'master', CI_COMMIT_BRANCH: 'master', CI_COMMIT_REF_PROTECTED: 'true' }
|
|
39
33
|
const result = spawnSync(process.execPath, [resolve(scripts, 'release-npm.mjs')], { cwd: root, env, encoding: 'utf8' })
|