@esp-vision/device-core 0.3.0 → 0.4.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 +19 -0
- package/README.md +46 -3
- package/package.json +12 -6
- package/schemas/device-actions-v1.json +1 -1
- package/src/browser.js +61 -0
- package/src/browser_rom.js +9 -0
- package/src/browser_rom_compat.js +65 -0
- package/src/firmware.js +4 -0
- package/src/firmware_feed.js +116 -3
- package/src/firmware_io.js +68 -0
- package/src/firmware_release.js +47 -0
- package/src/firmware_updates.js +17 -36
- package/tests/check-browser.mjs +151 -0
- package/tests/check-package.mjs +4 -1
- package/tests/device-checks.mjs +29 -24
- package/tests/fixtures/device-checks.py +14 -6
- package/tests/hardware-check.mjs +11 -4
- package/tests/test-browser-rom.mjs +85 -0
- package/tests/test-device-functional-checks.mjs +45 -6
- package/tests/test-device-hardware-cli.mjs +20 -1
- package/tests/test-firmware-source.mjs +143 -0
- package/tests/test-release-manifest.mjs +214 -0
package/src/firmware_updates.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { loadOfficialFirmware } from './firmware_feed.js'
|
|
5
5
|
import { FirmwareFlashSession, validateFirmwareImage, firmwareMode } from './firmware_flash.js'
|
|
6
|
+
import { validateFirmwareRelease as release } from './firmware_release.js'
|
|
7
|
+
import { downloadBytes, MAX_FIRMWARE_BYTES } from './firmware_io.js'
|
|
6
8
|
|
|
7
9
|
function record(value, keys, name) {
|
|
8
10
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${name} must be an object`)
|
|
@@ -16,38 +18,7 @@ function identity(value) {
|
|
|
16
18
|
if (!value || typeof value.board !== 'string' || !value.board || typeof value.firmwareVersion !== 'string' || !value.firmwareVersion) throw new TypeError('An inspected identity with board and firmwareVersion is required')
|
|
17
19
|
return structuredClone(value)
|
|
18
20
|
}
|
|
19
|
-
|
|
20
|
-
if (!value || typeof value.board !== 'string' || !value.board || typeof value.version !== 'string' || !value.version) throw new TypeError('Release requires board and version')
|
|
21
|
-
const image = value.image
|
|
22
|
-
const revisionValid = image?.chipRevision !== undefined
|
|
23
|
-
? Number.isSafeInteger(image.chipRevision) && image.chipRevision >= 0
|
|
24
|
-
: Number.isSafeInteger(image?.chipRevisionMin) && image.chipRevisionMin >= 0
|
|
25
|
-
&& Number.isSafeInteger(image?.chipRevisionMax) && image.chipRevisionMax >= image.chipRevisionMin
|
|
26
|
-
if (!image || image.evMuxVersion !== 4 || !Number.isSafeInteger(image.chipFamily)
|
|
27
|
-
|| image.chipFamily < 0 || !revisionValid || !Number.isSafeInteger(image.offset) || image.offset < 0
|
|
28
|
-
|| !Number.isSafeInteger(image.bytes) || image.bytes <= 0 || !Number.isSafeInteger(image.partitionSize) || image.bytes > image.partitionSize
|
|
29
|
-
|| !/^[a-f0-9]{64}$/.test(image.sha256 ?? '') || !Number.isSafeInteger(image.partitionTable?.offset) || image.partitionTable.offset < 0
|
|
30
|
-
|| !Number.isSafeInteger(image.partitionTable?.bytes) || image.partitionTable.bytes <= 0
|
|
31
|
-
|| !/^[a-f0-9]{64}$/.test(image.partitionTable?.sha256 ?? '')) throw new TypeError('Release requires complete EV-MUX v4 application/chip/partition/SHA-256 metadata')
|
|
32
|
-
if (mode === 'install') {
|
|
33
|
-
const install = value.install
|
|
34
|
-
if (!install || install.offset !== 0 || !Number.isSafeInteger(install.bytes) || install.bytes <= 0
|
|
35
|
-
|| !Number.isSafeInteger(install.flashSize) || install.flashSize < install.bytes || install.flashSize > 2 ** 30
|
|
36
|
-
|| !Number.isInteger(Math.log2(install.flashSize)) || install.flashSize < 2 ** 18
|
|
37
|
-
|| !/^[a-f0-9]{64}$/.test(install.sha256 ?? '') || !install.bootloader
|
|
38
|
-
|| !Number.isSafeInteger(install.chipRevisionMin) || !Number.isSafeInteger(install.chipRevisionMax)
|
|
39
|
-
|| install.chipRevisionMin < (image.chipRevision ?? image.chipRevisionMin) || install.chipRevisionMax > (image.chipRevision ?? image.chipRevisionMax)
|
|
40
|
-
|| install.chipRevisionMin < 0 || install.chipRevisionMax < install.chipRevisionMin) throw Object.assign(new TypeError('Release lacks qualified complete-install metadata'), { code: 'FIRMWARE_INSTALL_UNAVAILABLE' })
|
|
41
|
-
let end = 0
|
|
42
|
-
for (const part of [install.bootloader, image.partitionTable, image]) {
|
|
43
|
-
if (!Number.isSafeInteger(part.offset) || !Number.isSafeInteger(part.bytes) || part.bytes <= 0
|
|
44
|
-
|| part.offset < end || part.offset + part.bytes > install.bytes
|
|
45
|
-
|| !/^[a-f0-9]{64}$/.test(part.sha256 ?? '')) throw new TypeError('Invalid complete-install segment bounds or hashes')
|
|
46
|
-
end = part.offset + part.bytes
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return structuredClone(value)
|
|
50
|
-
}
|
|
21
|
+
|
|
51
22
|
async function download(url, fetcher, signal) {
|
|
52
23
|
if (typeof url !== 'string' || new URL(url).protocol !== 'https:') throw new TypeError('Firmware URLs must use HTTPS')
|
|
53
24
|
if (typeof fetcher !== 'function') throw new TypeError('Provide fetch or an in-memory catalog/image')
|
|
@@ -64,7 +35,7 @@ export async function checkFirmware(request = {}, connectedIdentity) {
|
|
|
64
35
|
if (request.version !== undefined && (typeof request.version !== 'string' || !request.version.trim())) throw new TypeError('version must be a non-empty firmware version string')
|
|
65
36
|
const mode = firmwareMode(request.mode)
|
|
66
37
|
const current = identity(connectedIdentity ?? request.identity)
|
|
67
|
-
const catalog = request.catalog === undefined ? await loadOfficialFirmware({ board: current.board, fetch: fetcher, signal }) : typeof request.catalog === 'string' ? await (await download(request.catalog, fetcher, signal)).json() : structuredClone(request.catalog)
|
|
38
|
+
const catalog = request.catalog === undefined ? await loadOfficialFirmware({ board: current.board, version: request.version, fetch: fetcher, signal }) : typeof request.catalog === 'string' ? await (await download(request.catalog, fetcher, signal)).json() : structuredClone(request.catalog)
|
|
68
39
|
abort(signal)
|
|
69
40
|
if (catalog?.schemaVersion !== 1 || !Array.isArray(catalog.releases)) throw new TypeError('Catalog requires schemaVersion 1 and releases ordered newest first')
|
|
70
41
|
// Feed ordering is authoritative: firmware versions may be calendar/git labels.
|
|
@@ -88,15 +59,25 @@ export async function updateFirmware(request, connectedIdentity, disconnect) {
|
|
|
88
59
|
if (typeof openLoader !== 'function' || typeof onProgress !== 'function' || typeof onPhase !== 'function' || (reset !== undefined && typeof reset !== 'function')) throw new TypeError('Firmware update requires openLoader and valid callbacks')
|
|
89
60
|
const mode = firmwareMode(request.mode)
|
|
90
61
|
const target = release(request.release, mode)
|
|
91
|
-
const
|
|
62
|
+
const installation = target.install ? {
|
|
92
63
|
...target.image, offset: target.install.offset, bytes: target.install.bytes,
|
|
93
64
|
flashSize: target.install.flashSize, sha256: target.install.sha256, url: target.install.url,
|
|
94
65
|
bootloader: target.install.bootloader, application: target.image,
|
|
95
66
|
chipRevision: undefined, chipRevisionMin: target.install.chipRevisionMin, chipRevisionMax: target.install.chipRevisionMax,
|
|
96
|
-
} :
|
|
67
|
+
} : null
|
|
68
|
+
const image = mode === 'install' ? installation : target.image
|
|
97
69
|
const board = connectedIdentity?.board ?? request.board
|
|
98
70
|
if (!board || board !== target.board || (request.board !== undefined && request.board !== board)) throw new TypeError('Firmware release does not match the connected or explicitly selected board')
|
|
99
|
-
const
|
|
71
|
+
const source = mode === 'update' ? image.source : undefined
|
|
72
|
+
const descriptor = source ?? image
|
|
73
|
+
let bytes = request.data === undefined
|
|
74
|
+
? await downloadBytes(descriptor.url, { fetch: fetcher, signal, maxBytes: MAX_FIRMWARE_BYTES, expectedBytes: descriptor.bytes, immutable: target.image.source !== undefined })
|
|
75
|
+
: request.data instanceof Uint8Array ? request.data.slice() : request.data
|
|
76
|
+
if (source) {
|
|
77
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength !== source.bytes) throw new Error('Complete BIN source size does not match the approved build')
|
|
78
|
+
await validateFirmwareImage(installation, bytes, { mode: 'install' })
|
|
79
|
+
bytes = bytes.slice(source.offset, source.offset + image.bytes)
|
|
80
|
+
}
|
|
100
81
|
await validateFirmwareImage(image, bytes, { mode })
|
|
101
82
|
abort(signal)
|
|
102
83
|
// Validate everything before releasing EV-MUX or acquiring a download port.
|
|
@@ -0,0 +1,151 @@
|
|
|
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 { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
8
|
+
import vm from 'node:vm'
|
|
9
|
+
import { webcrypto } from 'node:crypto'
|
|
10
|
+
import { build } from 'esbuild'
|
|
11
|
+
|
|
12
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
13
|
+
const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-browser-'))
|
|
14
|
+
try {
|
|
15
|
+
// Bundle the actual pinned dependency and its dynamic JSON stubs for browsers.
|
|
16
|
+
const result = await build({
|
|
17
|
+
stdin: { contents: `export { createDevice } from './src/index.js'; export { openBrowserRom } from './src/browser.js'; export { loadReleaseManifest } from './src/firmware.js'; export { ESPLoader as TestLoader } from 'tasmota-webserial-esptool'; export { browserRomCompatibility as testCompatibility } from './src/browser_rom_compat.js';`, resolveDir: root },
|
|
18
|
+
bundle: true, platform: 'browser', format: 'iife', globalName: 'DeviceCore',
|
|
19
|
+
write: false, metafile: true,
|
|
20
|
+
})
|
|
21
|
+
const code = result.outputFiles[0].text
|
|
22
|
+
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('tasmota-webserial-esptool/dist/esp_loader.js')))
|
|
23
|
+
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32p4.json')))
|
|
24
|
+
assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32s31.json')))
|
|
25
|
+
assert.ok(!Object.keys(result.metafile.inputs).some(path => /src\/(node|cli)/.test(path)))
|
|
26
|
+
const context = vm.createContext({ EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
|
|
27
|
+
vm.runInContext(code, context)
|
|
28
|
+
let opens = 0, closes = 0
|
|
29
|
+
const adapter = context.DeviceCore.openBrowserRom({ port: {
|
|
30
|
+
async open() { opens++; throw new Error('real dependency reached SerialPort.open') },
|
|
31
|
+
async close() { closes++ },
|
|
32
|
+
} })
|
|
33
|
+
assert.equal(opens, 0, 'import and construction must not open/reset the port')
|
|
34
|
+
await assert.rejects(adapter.initialize(), /real dependency reached SerialPort.open/)
|
|
35
|
+
assert.equal(opens, 1)
|
|
36
|
+
// Run upstream's actual SLIP parser in both CDC and UART modes. A blank board
|
|
37
|
+
// may emit a boot log before the stub greeting; invalid escapes must still fail.
|
|
38
|
+
for (const cdc of [false, true]) {
|
|
39
|
+
const raw = new context.DeviceCore.TestLoader({}, { log() {}, error() {}, debug() {} })
|
|
40
|
+
context.DeviceCore.testCompatibility(raw)
|
|
41
|
+
raw._isCDCDevice = cdc
|
|
42
|
+
raw.__inputBuffer = []
|
|
43
|
+
raw._inputBuffer.push(71, 97, 114, 0xc0, 79, 72, 65, 73, 0xc0)
|
|
44
|
+
assert.deepEqual(Array.from(await raw.readPacket(100)), [79, 72, 65, 73])
|
|
45
|
+
raw._clearInputBuffer()
|
|
46
|
+
raw._inputBuffer.push(0xc0, 0xdb, 0x47, 0xc0)
|
|
47
|
+
await assert.rejects(raw.readPacket(100), /Invalid SLIP escape/)
|
|
48
|
+
}
|
|
49
|
+
const prototype = context.DeviceCore.TestLoader.prototype
|
|
50
|
+
const originals = Object.fromEntries(['initialize', 'disconnect', 'getSecurityInfo', 'runStub', 'hardReset'].map(key => [key, prototype[key]]))
|
|
51
|
+
let initializeCalls = 0, disconnectCalls = 0
|
|
52
|
+
prototype.disconnect = async function () { disconnectCalls++; await this.port.close() }
|
|
53
|
+
const fakePort = () => ({
|
|
54
|
+
async open() { this.readable = {}; this.writable = {} },
|
|
55
|
+
async close() { this.readable = null; this.writable = null },
|
|
56
|
+
})
|
|
57
|
+
try {
|
|
58
|
+
prototype.initialize = async () => { initializeCalls++ }
|
|
59
|
+
const controller = new AbortController()
|
|
60
|
+
const loader = context.DeviceCore.openBrowserRom({ port: fakePort(), signal: controller.signal })
|
|
61
|
+
const first = loader.initialize()
|
|
62
|
+
assert.equal(loader.initialize(), first, 'initialize must share one ROM connection')
|
|
63
|
+
await first
|
|
64
|
+
controller.abort()
|
|
65
|
+
assert.equal(disconnectCalls, 0, 'abort after initialization must not interrupt flash/readback')
|
|
66
|
+
await loader.disconnect(); await loader.disconnect()
|
|
67
|
+
assert.equal(disconnectCalls, 1)
|
|
68
|
+
assert.equal(initializeCalls, 1)
|
|
69
|
+
prototype.initialize = async () => { throw new Error('sync failed') }
|
|
70
|
+
await assert.rejects(context.DeviceCore.openBrowserRom({ port: fakePort() }).initialize(), /sync failed/)
|
|
71
|
+
assert.equal(disconnectCalls, 2, 'failed ROM sync must release the acquired port')
|
|
72
|
+
let finishOpen
|
|
73
|
+
const waitingPort = fakePort()
|
|
74
|
+
const open = waitingPort.open
|
|
75
|
+
waitingPort.open = async function () {
|
|
76
|
+
await new Promise(resolve => { finishOpen = resolve })
|
|
77
|
+
await open.call(this)
|
|
78
|
+
}
|
|
79
|
+
const cancelled = new AbortController()
|
|
80
|
+
const late = context.DeviceCore.openBrowserRom({ port: waitingPort, signal: cancelled.signal })
|
|
81
|
+
const starting = late.initialize()
|
|
82
|
+
while (!finishOpen) await new Promise(resolve => setTimeout(resolve, 1))
|
|
83
|
+
cancelled.abort()
|
|
84
|
+
await assert.rejects(starting, { name: 'AbortError' })
|
|
85
|
+
finishOpen()
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
87
|
+
assert.equal(disconnectCalls, 3, 'an open completing after cancellation must close')
|
|
88
|
+
assert.equal(waitingPort.readable, null)
|
|
89
|
+
const data = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5])
|
|
90
|
+
const hash = async bytes => Array.from(new Uint8Array(await webcrypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('')
|
|
91
|
+
const release = { board: 'BOARD', version: 'browser-test', image: {
|
|
92
|
+
evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302,
|
|
93
|
+
offset: 65536, bytes: data.byteLength, partitionSize: 4096,
|
|
94
|
+
sha256: await hash(data), partitionTable: { offset: 32768, bytes: table.byteLength, sha256: await hash(table) },
|
|
95
|
+
} }
|
|
96
|
+
let stubs = 0, writes = 0, resets = 0
|
|
97
|
+
prototype.initialize = async function () { this.chipFamily = 0x3280; this.chipRevision = 302 }
|
|
98
|
+
prototype.getSecurityInfo = async () => ({ flags: 1, flashCryptCnt: 0 })
|
|
99
|
+
prototype.runStub = async () => { stubs++; throw new Error('must not launch') }
|
|
100
|
+
const device = context.DeviceCore.createDevice()
|
|
101
|
+
const request = {
|
|
102
|
+
release, board: 'BOARD', data,
|
|
103
|
+
openLoader: ({ signal }) => context.DeviceCore.openBrowserRom({ port: fakePort(), signal }),
|
|
104
|
+
}
|
|
105
|
+
await assert.rejects(device.firmware.update(request), /Secure boot/)
|
|
106
|
+
assert.equal(stubs, 0, 'ROM security must be checked before the browser launches a stub')
|
|
107
|
+
await assert.rejects(device.firmware.update({ ...request, data: new Uint8Array(4) }), /SHA-256/)
|
|
108
|
+
assert.equal(stubs, 0)
|
|
109
|
+
prototype.getSecurityInfo = async () => ({ flags: 0, flashCryptCnt: 0 })
|
|
110
|
+
const writing = new AbortController()
|
|
111
|
+
prototype.runStub = async () => {
|
|
112
|
+
stubs++
|
|
113
|
+
return {
|
|
114
|
+
IS_STUB: true,
|
|
115
|
+
async flashData() { writes++; writing.abort() },
|
|
116
|
+
async readFlash(offset) { return offset === 32768 ? table : data },
|
|
117
|
+
async readRegister() { return 0 },
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
prototype.hardReset = async () => { resets++ }
|
|
121
|
+
const beforeUpdateCloses = disconnectCalls
|
|
122
|
+
const updated = await device.firmware.update({ ...request, signal: writing.signal })
|
|
123
|
+
assert.equal(updated.verified, true)
|
|
124
|
+
assert.equal(updated.needsReset, false)
|
|
125
|
+
assert.equal(stubs, 1); assert.equal(writes, 1); assert.equal(resets, 1)
|
|
126
|
+
assert.equal(disconnectCalls, beforeUpdateCloses + 1, 'write-time abort must preserve complete readback, reset and one cleanup')
|
|
127
|
+
const manifest = { schemaVersion: 1, version: 'browser-test', sourceCommit: 'a'.repeat(40), pipelineId: 1, layout: 'legacy',
|
|
128
|
+
releases: [{ ...release, image: { ...release.image, url: 'https://dl.espressif.com/AE/esp-vision/bin/app.bin' } }],
|
|
129
|
+
website: { schemaVersion: '1.0.0', release: 'browser-test', releases: ['browser-test'], binaryNameTemplate: '{board}-{tag}.bin',
|
|
130
|
+
boards: [{ board: 'BOARD', name: 'Board', slug: 'board', flashable: true }], models: [] }, mcp: null }
|
|
131
|
+
const rawManifest = new TextEncoder().encode(JSON.stringify(manifest)), caches = []
|
|
132
|
+
const snapshot = await context.DeviceCore.loadReleaseManifest({ url: 'https://dl.espressif.com/AE/esp-vision/releases/browser-test/manifest.json', sha256: await hash(rawManifest),
|
|
133
|
+
async fetch(_url, options) {
|
|
134
|
+
caches.push(options.cache)
|
|
135
|
+
if (caches.length === 1) throw new TypeError('Cached response CORS failure from the host realm')
|
|
136
|
+
return new Response(rawManifest)
|
|
137
|
+
} })
|
|
138
|
+
assert.equal(snapshot.version, 'browser-test')
|
|
139
|
+
assert.deepEqual(caches, ['default', 'no-store'], 'real browser bundle must recover a fetch TypeError from host-provided fetch')
|
|
140
|
+
|
|
141
|
+
} finally { Object.assign(prototype, originals) }
|
|
142
|
+
// Node import must not eagerly resolve tasmota's browser-only, extensionless ESM.
|
|
143
|
+
const { openBrowserRom } = await import(pathToFileURL(join(root, 'src/browser.js')))
|
|
144
|
+
assert.equal(typeof openBrowserRom, 'function')
|
|
145
|
+
const dependency = JSON.parse(await readFile(join(root, 'node_modules/tasmota-webserial-esptool/package.json'), 'utf8'))
|
|
146
|
+
assert.equal(dependency.version, '9.4.2')
|
|
147
|
+
const stubsLicense = await readFile(join(root, 'node_modules/tasmota-webserial-esptool/src/stubs/README.md'), 'utf8')
|
|
148
|
+
assert.match(stubsLicense, /dual licensed.*Apache License Version 2.0 or the MIT license/s)
|
|
149
|
+
await writeFile(join(temporary, 'browser.js'), code)
|
|
150
|
+
console.log(JSON.stringify({ browserBundle: true, dependency: dependency.version, bytes: code.length, passiveImports: true, p4AndS31Stubs: true, cleanupAndCancellation: true, coreSafetyChecks: true }))
|
|
151
|
+
} finally { await rm(temporary, { recursive: true, force: true }) }
|
package/tests/check-package.mjs
CHANGED
|
@@ -37,8 +37,11 @@ try {
|
|
|
37
37
|
const api = await import('@esp-vision/device-core');
|
|
38
38
|
if (Object.keys(api).sort().join(',') !== 'createDevice') throw new Error('Root must expose only the application API');
|
|
39
39
|
const { DeviceSession } = await import('@esp-vision/device-core/session');
|
|
40
|
-
const { FirmwareFlashSession } = await import('@esp-vision/device-core/firmware');
|
|
40
|
+
const { FirmwareFlashSession, loadOfficialFirmware, loadReleaseManifest } = await import('@esp-vision/device-core/firmware');
|
|
41
|
+
if (typeof loadOfficialFirmware !== 'function' || typeof loadReleaseManifest !== 'function') throw new Error('Missing public release manifest readers');
|
|
41
42
|
const { tcpTransport, serialTransport, openNodeRom } = await import('@esp-vision/device-core/node');
|
|
43
|
+
const { openBrowserRom } = await import('@esp-vision/device-core/browser');
|
|
44
|
+
if (typeof openBrowserRom !== 'function' || typeof openBrowserRom({ port: { open() { throw new Error('Not opened'); }, close() {} } }).initialize !== 'function') throw new Error('Missing passive browser ROM adapter');
|
|
42
45
|
if (typeof tcpTransport !== 'function' || typeof serialTransport !== 'function') throw new Error('Missing public Node transports');
|
|
43
46
|
const { createNodeDiscovery } = await import('@esp-vision/device-core/discovery/node');
|
|
44
47
|
const { EventEmitter } = await import('node:events');
|
package/tests/device-checks.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Application-level HIL checks. All device I/O goes through createDevice's API.
|
|
3
3
|
import { readFile, mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { dirname } from 'node:path'
|
|
5
|
+
import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
|
|
5
6
|
|
|
6
7
|
export const DEVICE_CHECKS = Object.freeze(['identity', 'memory', 'camera', 'framesizes', 'pixformats', 'orientation', 'ipa_stats', 'snapshot_rate', 'preview', 'image_ops', 'display', 'display_stream'])
|
|
7
8
|
const MARKER = '__EVDEVICECHECK__'
|
|
@@ -39,11 +40,14 @@ function pause(milliseconds, signal) {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export async function runDeviceChecks(device, options, { signal, nonce, report = value => console.log(JSON.stringify(value)) } = {}) {
|
|
42
|
-
const results = [],
|
|
43
|
-
let
|
|
43
|
+
const results = [], namespace = `_evhil_${nonce}`
|
|
44
|
+
let loaded = false, namespaceStarted = false, loadError = null, inputLimit = EV_REPL_INPUT_MAX_BYTES
|
|
44
45
|
const parameters = { frames: options.frames, framesize: options.framesize, pixformat: options.pixformat, displayHold: options.displayHold, screenCapture: Boolean(options.screenOutput) }
|
|
45
46
|
const timeoutFor = name => 1000 * (name === 'display' ? 30 + options.displayHold * 3 : name === 'ipa_stats' ? options.frameTimeout + 40 : options.frameTimeout + Math.max(30, options.frames * (name === 'display_stream' ? 2 : 1)))
|
|
46
47
|
const execute = async (source, timeout = 10000, operationSignal = signal) => {
|
|
48
|
+
// Larger python.execute() requests use temporary files in Device Core.
|
|
49
|
+
// Keep fixtures in RAM by respecting the actual encoded REPL-line budget.
|
|
50
|
+
if (evReplExecInputBytes(source) > inputLimit) throw failure('REPL_INPUT_LIMIT', 'Hardware fixture command exceeds the device REPL input limit')
|
|
47
51
|
const result = await device.python.execute({ source, timeout, maxOutputChars: 16384, signal: operationSignal })
|
|
48
52
|
if (result.ok === false || result.outputTruncated) throw failure('PYTHON_CHECK', 'Device Python check did not complete')
|
|
49
53
|
return result.output
|
|
@@ -52,16 +56,25 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
52
56
|
if (loaded) return
|
|
53
57
|
if (loadError) throw loadError
|
|
54
58
|
try {
|
|
55
|
-
const
|
|
56
|
-
|
|
59
|
+
const info = await device.inspect({ fields: ['capabilities'], signal })
|
|
60
|
+
const advertised = info.capabilities?.limits?.replInputMax
|
|
61
|
+
if (Number.isSafeInteger(advertised) && advertised > 0) inputLimit = Math.min(inputLimit, advertised)
|
|
57
62
|
const source = await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8')
|
|
58
|
-
ownsFixture = true
|
|
59
|
-
await device.files.write({ path: fixturePath, data: source, overwrite: false, signal })
|
|
60
63
|
namespaceStarted = true
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
await execute(`${namespace}={'_source':[]}`)
|
|
65
|
+
const append = chunk => `${namespace}['_source'].append(${JSON.stringify(chunk)})`
|
|
66
|
+
let chunk = ''
|
|
67
|
+
for (const character of source) {
|
|
68
|
+
if (evReplExecInputBytes(append(chunk + character)) > inputLimit) {
|
|
69
|
+
if (!chunk) throw failure('REPL_INPUT_LIMIT', 'Device REPL input limit cannot load hardware fixtures')
|
|
70
|
+
await execute(append(chunk))
|
|
71
|
+
chunk = ''
|
|
72
|
+
}
|
|
73
|
+
chunk += character
|
|
74
|
+
}
|
|
75
|
+
if (chunk) await execute(append(chunk))
|
|
76
|
+
await execute(`exec(''.join(${namespace}['_source']),${namespace});del ${namespace}['_source']`)
|
|
63
77
|
loaded = true
|
|
64
|
-
await device.files.remove({ path: fixturePath, signal })
|
|
65
78
|
} catch (error) { loadError = error; throw error }
|
|
66
79
|
}
|
|
67
80
|
const invoke = async name => {
|
|
@@ -83,8 +96,12 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
83
96
|
let data
|
|
84
97
|
if (name === 'identity') {
|
|
85
98
|
const info = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal })
|
|
99
|
+
if (info.capabilities?.features?.includes('storage.writer.lease')) {
|
|
100
|
+
info.storage = (await device.inspect({ fields: ['storage'], signal })).storage
|
|
101
|
+
}
|
|
86
102
|
if (!info.identity?.board || !info.identity.firmwareVersion || info.capabilities?.protocol?.evMuxVersion !== 4 || info.runtime?.vmReady !== true) throw failure('IDENTITY_INVALID', 'Expected a ready EV-MUX v4 firmware identity')
|
|
87
|
-
data = { identity: info.identity, features: info.capabilities.features, vmReady: info.runtime.vmReady
|
|
103
|
+
data = { identity: info.identity, features: info.capabilities.features, vmReady: info.runtime.vmReady,
|
|
104
|
+
storage: info.storage && Object.fromEntries(Object.entries(info.storage).map(([volume, state]) => [volume, { present: state.present, mounted: state.mounted, writable: state.writable, reason: state.reason }])) }
|
|
88
105
|
} else if (name === 'camera') {
|
|
89
106
|
const image = await device.capture({ source: 'camera', format: 'jpeg', quality: options.quality, timeout: options.frameTimeout * 1000, signal })
|
|
90
107
|
data = await saveJpeg(image, options.output)
|
|
@@ -116,14 +133,8 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
116
133
|
result = { name, status: 'PASS', data }
|
|
117
134
|
} catch (error) {
|
|
118
135
|
cancelled(signal)
|
|
119
|
-
const skip = unsupported(error) && !(name === 'display' && options.screenOutput)
|
|
136
|
+
const skip = unsupported(error) && !options.requireAll && !(name === 'display' && options.screenOutput)
|
|
120
137
|
result = { name, status: skip ? 'SKIP' : 'FAIL', error: { code: error.code ?? 'DEVICE_CHECK_FAILED', message: String(error.message ?? 'Device check failed').slice(0, 240) } }
|
|
121
|
-
if (error.code === 'WRITE_FAILED') {
|
|
122
|
-
try {
|
|
123
|
-
const status = await device.inspect({ fields: ['storage'], timeout: 3000, signal })
|
|
124
|
-
result.storage = Object.fromEntries(Object.entries(status.storage).map(([volume, state]) => [volume, { present: state.present, mounted: state.mounted, status: state.status, writable: state.writable, reason: state.reason }]))
|
|
125
|
-
} catch { /* Preserve the upload failure. */ }
|
|
126
|
-
}
|
|
127
138
|
} finally {
|
|
128
139
|
if (name === 'display' && loaded) {
|
|
129
140
|
try { await execute(`${namespace}['close_display']()`, 5000, AbortSignal.timeout(6000)) }
|
|
@@ -140,13 +151,7 @@ export async function runDeviceChecks(device, options, { signal, nonce, report =
|
|
|
140
151
|
try { await execute(`if ${JSON.stringify(namespace)} in globals():\n ${namespace}.get('close_display',lambda:None)()\n del ${namespace}`, 5000, cleanupSignal) }
|
|
141
152
|
catch (error) { cleanupError = error }
|
|
142
153
|
}
|
|
143
|
-
if (
|
|
144
|
-
try {
|
|
145
|
-
const entries = await device.files.list({ path: '/', signal: cleanupSignal })
|
|
146
|
-
for (const entry of entries) if ([fixturePath, `${fixturePath}.tmp`].includes(entry.path)) await device.files.remove({ path: entry.path, signal: cleanupSignal })
|
|
147
|
-
} catch (error) { cleanupError ??= error }
|
|
148
|
-
}
|
|
149
|
-
if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not clean up the hardware fixture', path: fixturePath } })
|
|
154
|
+
if (cleanupError) record({ name: 'fixture-cleanup', status: 'FAIL', error: { code: 'FIXTURE_CLEANUP_FAILED', message: 'Could not release the hardware fixture namespace' } })
|
|
150
155
|
}
|
|
151
156
|
const summary = { passed: results.filter(result => result.status === 'PASS').length, failed: results.filter(result => result.status === 'FAIL').length, skipped: results.filter(result => result.status === 'SKIP').length }
|
|
152
157
|
report({ check: 'summary', ...summary })
|
|
@@ -6,6 +6,11 @@ All transport, ownership and cleanup are supplied by the public JS SDK.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
def is_unsupported(exc):
|
|
10
|
+
message = str(exc).lower()
|
|
11
|
+
return any(token in message for token in ('esp_err_not_supported', 'esp_err_not_found', 'no module named', 'enodev'))
|
|
12
|
+
|
|
13
|
+
|
|
9
14
|
def framesizes(opts):
|
|
10
15
|
import sensor
|
|
11
16
|
sensor.reset()
|
|
@@ -18,8 +23,10 @@ def framesizes(opts):
|
|
|
18
23
|
img = sensor.snapshot()
|
|
19
24
|
results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
|
|
20
25
|
reportedWidth=sensor.width(), reportedHeight=sensor.height()))
|
|
21
|
-
except Exception:
|
|
22
|
-
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
if not is_unsupported(exc):
|
|
28
|
+
raise
|
|
29
|
+
results.append(dict(name=name, supported=False, reason=str(exc)))
|
|
23
30
|
working = [item for item in results if item['supported']]
|
|
24
31
|
assert working, 'no framesize produced a frame'
|
|
25
32
|
for item in working:
|
|
@@ -39,8 +46,10 @@ def pixformats(opts):
|
|
|
39
46
|
img = sensor.snapshot()
|
|
40
47
|
results.append(dict(name=name, supported=True, width=img.width(), height=img.height(),
|
|
41
48
|
actualBytes=img.size(), expectedBytes=img.width() * img.height() * depth))
|
|
42
|
-
except Exception:
|
|
43
|
-
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
if not is_unsupported(exc):
|
|
51
|
+
raise
|
|
52
|
+
results.append(dict(name=name, supported=False, reason=str(exc)))
|
|
44
53
|
working = [item for item in results if item['supported']]
|
|
45
54
|
assert working, 'no pixel format produced a frame'
|
|
46
55
|
for item in working:
|
|
@@ -311,7 +320,6 @@ def run_check(name, opts):
|
|
|
311
320
|
result = dict(name=name, status='PASS', data=data)
|
|
312
321
|
except Exception as exc:
|
|
313
322
|
message = str(exc)
|
|
314
|
-
|
|
315
|
-
status = 'SKIP' if any(token.lower() in message.lower() for token in unsupported) else 'FAIL'
|
|
323
|
+
status = 'SKIP' if is_unsupported(exc) else 'FAIL'
|
|
316
324
|
result = dict(name=name, status=status, error=dict(type=type(exc).__name__, message=message))
|
|
317
325
|
print('__EVDEVICECHECK__' + json.dumps(result))
|
package/tests/hardware-check.mjs
CHANGED
|
@@ -7,7 +7,7 @@ export { tcpTransport, serialTransport } from '../src/node_transport.js'
|
|
|
7
7
|
import { createDevice } from '../src/index.js'
|
|
8
8
|
import { DEVICE_CHECKS, runDeviceChecks, saveJpeg } from './device-checks.mjs'
|
|
9
9
|
|
|
10
|
-
export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--write] [--capture] [--checks all|CSV] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
|
|
10
|
+
export const usage = 'Usage: npm run test:hardware -- (--tcp HOST[:PORT] | --serial PATH) [--role usj|usb|uart] [--python PYTHON] [--expect-board BOARD] [--expect-firmware-version VERSION] [--write] [--capture] [--checks all|CSV] [--require-all] [--frames N] [--framesize QVGA] [--pixformat RGB565] [--quality 60] [--frame-timeout SECONDS] [--display-hold SECONDS] [--output CAMERA.jpg] [--screen-output LCD.jpg] [--timeout MILLISECONDS]'
|
|
11
11
|
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
12
12
|
|
|
13
13
|
export function parseArgs(args) {
|
|
@@ -16,8 +16,8 @@ export function parseArgs(args) {
|
|
|
16
16
|
for (let index = 0; index < args.length; index++) {
|
|
17
17
|
const option = args[index]
|
|
18
18
|
if (option === '--help') return { help: true }
|
|
19
|
-
if (
|
|
20
|
-
if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
|
|
19
|
+
if (['--write', '--capture', '--require-all'].includes(option)) { options[option === '--require-all' ? 'requireAll' : option.slice(2)] = true; continue }
|
|
20
|
+
if (!['--tcp', '--serial', '--role', '--python', '--timeout', '--checks', '--screen-output', '--output', '--frames', '--display-hold', '--framesize', '--pixformat', '--quality', '--frame-timeout', '--expect-board', '--expect-firmware-version'].includes(option)) throw fail('ARGUMENT', 'Unknown argument')
|
|
21
21
|
const value = args[++index]
|
|
22
22
|
if (!value || value.startsWith('--')) throw fail('ARGUMENT', 'Missing argument value')
|
|
23
23
|
const key = option.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())
|
|
@@ -75,13 +75,20 @@ export async function checkHardware(options, { device = createDevice({ transport
|
|
|
75
75
|
await device.connect({ role: options.role, timeout: Math.min(options.timeout, 30000), signal: controller.signal })
|
|
76
76
|
cancelled()
|
|
77
77
|
const status = await device.inspect({ fields: ['identity', 'capabilities', 'runtime'], signal: controller.signal })
|
|
78
|
+
if (options.expectBoard && status.identity?.board !== options.expectBoard) throw fail('BOARD_MISMATCH', 'Connected board does not match the requested board')
|
|
79
|
+
if (options.expectFirmwareVersion && status.identity?.firmwareVersion !== options.expectFirmwareVersion) throw fail('FIRMWARE_MISMATCH', 'Running firmware does not match the selected build artifact')
|
|
78
80
|
// Only selected public identity fields are logged; never network credentials or Python output.
|
|
79
|
-
report({ check: 'inspect', ok: true, identity: { deviceId: status.identity?.deviceId ?? null, board: status.identity?.board ?? null, hostname: status.identity?.hostname ?? null } })
|
|
81
|
+
report({ check: 'inspect', ok: true, identity: { deviceId: status.identity?.deviceId ?? null, board: status.identity?.board ?? null, hostname: status.identity?.hostname ?? null, firmwareVersion: status.identity?.firmwareVersion ?? null } })
|
|
80
82
|
const marker = `EV_HARDWARE_${nonce}`
|
|
81
83
|
const execution = await device.python.execute({ source: `print('${marker}')`, timeout: 10000, maxOutputChars: 1024, signal: controller.signal })
|
|
82
84
|
if (!execution.ok || !execution.output.split(/\r?\n/).some(line => line.trim() === marker)) throw fail('PYTHON_CHECK', 'Python marker was not received')
|
|
83
85
|
report({ check: 'python', ok: true })
|
|
84
86
|
if (options.write) {
|
|
87
|
+
const storage = await device.inspect({ fields: ['storage'], signal: controller.signal })
|
|
88
|
+
const flash = storage.storage?.flash
|
|
89
|
+
const ready = flash?.present === true && flash?.mounted === true && flash?.writable === true
|
|
90
|
+
report({ check: 'storage', ok: ready, flash: { present: flash?.present ?? null, mounted: flash?.mounted ?? null, writable: flash?.writable ?? null, reason: flash?.reason ?? null } })
|
|
91
|
+
if (!ready) throw fail('STORAGE_UNAVAILABLE', 'File acceptance requires a mounted writable Flash filesystem; no formatting was attempted')
|
|
85
92
|
const entries = await device.files.list({ path: '/', signal: controller.signal })
|
|
86
93
|
if (entries.some(entry => entry.path === path || entry.path === `${path}.tmp`)) throw fail('TEST_PATH_EXISTS', 'Test path already exists; refusing to modify it')
|
|
87
94
|
ownedPath = path
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { openBrowserRom } from '../src/browser.js'
|
|
6
|
+
import { browserRomCompatibility } from '../src/browser_rom_compat.js'
|
|
7
|
+
|
|
8
|
+
const port = () => ({ open() { throw new Error('Port must remain passive') }, close() {} })
|
|
9
|
+
test('browser ROM construction and disconnect are passive without browser globals', async () => {
|
|
10
|
+
const adapter = openBrowserRom({ port: port() })
|
|
11
|
+
assert.equal(adapter.chipFamily, null)
|
|
12
|
+
assert.equal(adapter.chipRevision, null)
|
|
13
|
+
assert.equal(adapter.chipName, null)
|
|
14
|
+
await adapter.disconnect(); await adapter.disconnect()
|
|
15
|
+
await assert.rejects(adapter.initialize(), { code: 'ROM_CLOSED' })
|
|
16
|
+
})
|
|
17
|
+
test('browser ROM validates its port and cancellation before loading the browser dependency', async () => {
|
|
18
|
+
assert.throws(() => openBrowserRom(), /SerialPort/)
|
|
19
|
+
assert.throws(() => openBrowserRom({ port: port(), signal: {} }), /AbortSignal/)
|
|
20
|
+
const controller = new AbortController()
|
|
21
|
+
controller.abort(new Error('cancelled before open'))
|
|
22
|
+
await assert.rejects(openBrowserRom({ port: port(), signal: controller.signal }).initialize(), /cancelled before open/)
|
|
23
|
+
})
|
|
24
|
+
test('SLIP prefix compatibility retries only pre-header bytes within the original deadline', async () => {
|
|
25
|
+
let count = 0
|
|
26
|
+
const loader = browserRomCompatibility({ async readPacket() {
|
|
27
|
+
if (++count < 3) throw new Error('Invalid head of packet (0x47)')
|
|
28
|
+
return [79, 72, 65, 73]
|
|
29
|
+
} })
|
|
30
|
+
assert.deepEqual(await loader.readPacket(100), [79, 72, 65, 73])
|
|
31
|
+
loader.readPacket = browserRomCompatibility({ readPacket: async () => { throw new Error('Invalid SLIP escape (0xdb, 0x47)') } }).readPacket
|
|
32
|
+
await assert.rejects(loader.readPacket(100), /Invalid SLIP escape/)
|
|
33
|
+
})
|
|
34
|
+
test('SLIP log noise cannot extend the operation deadline indefinitely', async () => {
|
|
35
|
+
let count = 0
|
|
36
|
+
const loader = browserRomCompatibility({ async readPacket() {
|
|
37
|
+
count++
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
39
|
+
throw new Error('Invalid head of packet (0x47)')
|
|
40
|
+
} })
|
|
41
|
+
await assert.rejects(loader.readPacket(15), /Invalid head/)
|
|
42
|
+
assert.ok(count <= 4)
|
|
43
|
+
})
|
|
44
|
+
function stubFixture({ native = true, error = 'Timed out waiting for packet header', handshake = true } = {}) {
|
|
45
|
+
let timeout, reads = 0
|
|
46
|
+
const stub = { IS_STUB: true, readPacket: async () => [], checkCommand: (...args) => { timeout = args[3] } }
|
|
47
|
+
const raw = {
|
|
48
|
+
isUsbJtagOrOtg: native,
|
|
49
|
+
memFinish: async () => { if (error) throw new Error(error) },
|
|
50
|
+
readPacket: async () => { reads++; return handshake ? [79, 72, 65, 73] : [] },
|
|
51
|
+
async runStub(skip) {
|
|
52
|
+
assert.equal(skip, true)
|
|
53
|
+
await this.memFinish()
|
|
54
|
+
assert.deepEqual(await this.readPacket(2500), [79, 72, 65, 73], 'OHAI required')
|
|
55
|
+
return stub
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
return { loader: browserRomCompatibility(raw), stub, timeout: () => timeout, reads: () => reads }
|
|
59
|
+
}
|
|
60
|
+
test('native USB may miss MEM_END acknowledgement but still requires OHAI', async () => {
|
|
61
|
+
const fixture = stubFixture()
|
|
62
|
+
assert.equal(await fixture.loader.runStub(true), fixture.stub)
|
|
63
|
+
assert.equal(fixture.reads(), 1)
|
|
64
|
+
await assert.rejects(stubFixture({ handshake: false }).loader.runStub(true), /OHAI required/)
|
|
65
|
+
})
|
|
66
|
+
test('UART timeouts and native USB non-timeout errors retain their failures', async () => {
|
|
67
|
+
await assert.rejects(stubFixture({ native: false }).loader.runStub(true), /Timed out/)
|
|
68
|
+
await assert.rejects(stubFixture({ error: 'Port disconnected' }).loader.runStub(true), /Port disconnected/)
|
|
69
|
+
})
|
|
70
|
+
test('stub launch restores temporary hooks on failure and rejects ROM fallback', async () => {
|
|
71
|
+
const fixture = stubFixture({ handshake: false })
|
|
72
|
+
const finish = fixture.loader.memFinish, packet = fixture.loader.readPacket
|
|
73
|
+
await assert.rejects(fixture.loader.runStub(true))
|
|
74
|
+
assert.equal(fixture.loader.memFinish, finish)
|
|
75
|
+
assert.equal(fixture.loader.readPacket, packet)
|
|
76
|
+
await assert.rejects(browserRomCompatibility({ runStub: async () => ({ IS_STUB: false }) }).runStub(true), /no supported browser flash stub/)
|
|
77
|
+
})
|
|
78
|
+
test('stub FLASH_BEGIN timeout scales with uncompressed erase size and preserves other commands', async () => {
|
|
79
|
+
const fixture = stubFixture()
|
|
80
|
+
const stub = await fixture.loader.runStub(true)
|
|
81
|
+
await stub.checkCommand(2, [0, 0, 128, 0], 0, 3000)
|
|
82
|
+
assert.ok(fixture.timeout() > 240000)
|
|
83
|
+
await stub.checkCommand(3, [], 0, 1234)
|
|
84
|
+
assert.equal(fixture.timeout(), 1234)
|
|
85
|
+
})
|
|
@@ -3,6 +3,7 @@ import test from 'node:test'
|
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
4
|
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
+
import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
|
|
6
7
|
import { tmpdir } from 'node:os'
|
|
7
8
|
import { parseArgs } from './hardware-check.mjs'
|
|
8
9
|
import { DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
|
|
@@ -11,11 +12,11 @@ function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {})
|
|
|
11
12
|
const files = new Map(), calls = [], listeners = new Map()
|
|
12
13
|
return {
|
|
13
14
|
calls, files, listeners,
|
|
14
|
-
async inspect({ fields }) { calls.push(['inspect', fields]); return { identity: { board, firmwareVersion: 'test' }, capabilities: { protocol: { evMuxVersion: 4 }, features: [] }, runtime: { vmReady: true } } },
|
|
15
|
+
async inspect({ fields }) { calls.push(['inspect', fields]); return { identity: { board, firmwareVersion: 'test' }, capabilities: { protocol: { evMuxVersion: 4 }, features: ['storage.writer.lease'], limits: { replInputMax: EV_REPL_INPUT_MAX_BYTES } }, runtime: { vmReady: true }, storage: { flash: { present: true, mounted: false, writable: false } } } },
|
|
15
16
|
files: Object.assign(files, {
|
|
16
|
-
async list() {
|
|
17
|
-
async write(
|
|
18
|
-
async remove({
|
|
17
|
+
async list() { throw new Error('Runtime checks must not access storage') },
|
|
18
|
+
async write() { throw Object.assign(new Error('Flash unmounted'), { code: 'FILE_OPEN_FAILED' }) },
|
|
19
|
+
async remove() { throw new Error('Runtime checks must not access storage') },
|
|
19
20
|
}),
|
|
20
21
|
python: { async execute({ source }) {
|
|
21
22
|
calls.push(['execute', source])
|
|
@@ -48,10 +49,15 @@ test('device check reports reject stale, duplicated and malformed markers', () =
|
|
|
48
49
|
for (const output of ['', valid + '\n' + valid, '__EVDEVICECHECK__bad', valid.replace('memory', 'camera'), valid.replace('PASS', 'UNKNOWN')]) assert.throws(() => parseCheckResult(output, 'memory'), { code: 'CHECK_REPORT_INVALID' })
|
|
49
50
|
})
|
|
50
51
|
|
|
51
|
-
test('functional checks
|
|
52
|
+
test('functional checks load fixtures in RAM despite unmounted storage and consume public preview events', async () => {
|
|
52
53
|
const device = devicePeer(), reports = []
|
|
53
54
|
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory,preview,orientation']), { nonce: 'abc123', report: value => reports.push(value) })
|
|
54
|
-
|
|
55
|
+
const commands = device.calls.filter(call => call[0] === 'execute').map(call => call[1])
|
|
56
|
+
assert.ok(commands.every(source => evReplExecInputBytes(source) <= EV_REPL_INPUT_MAX_BYTES))
|
|
57
|
+
assert.equal(commands.filter(source => source.startsWith("exec(''.join(")).length, 1)
|
|
58
|
+
const loader = commands.filter(source => source.includes("['_source']") || source.includes("={'_source':"))
|
|
59
|
+
const uploaded = loader.filter(source => source.includes("['_source'].append(")).map(source => JSON.parse(source.slice(source.indexOf('.append(') + 8, -1))).join('')
|
|
60
|
+
assert.equal(uploaded, await readFile(new URL('./fixtures/device-checks.py', import.meta.url), 'utf8'))
|
|
55
61
|
assert.equal(device.files.size, 0)
|
|
56
62
|
assert.equal(device.listeners.size, 0)
|
|
57
63
|
assert.equal(reports.find(result => result.check === 'preview').data.received, 1)
|
|
@@ -96,3 +102,36 @@ test('truncated JPEG and missing dimensions are refused before host artifact is
|
|
|
96
102
|
await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216]), mimeType: 'image/jpeg', metadata: { width: 1, height: 1 } }), { code: 'CAPTURE_INVALID' })
|
|
97
103
|
await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216, 255, 217]), mimeType: 'image/jpeg' }), { code: 'CAPTURE_INVALID' })
|
|
98
104
|
})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
test('strict acceptance fails unsupported selected checks without preventing independent identity evidence', async () => {
|
|
108
|
+
const device = devicePeer({ results: { memory: { name: 'memory', status: 'SKIP', error: { message: 'ESP_ERR_NOT_SUPPORTED' } } } }), reports = []
|
|
109
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory,identity', '--require-all']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
110
|
+
assert.equal(reports.find(result => result.check === 'memory').status, 'FAIL')
|
|
111
|
+
assert.equal(reports.find(result => result.check === 'identity').status, 'PASS')
|
|
112
|
+
assert.equal(reports.find(result => result.check === 'identity').data.storage.flash.mounted, false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('failed partial RAM loading still releases its namespace without filesystem cleanup', async () => {
|
|
116
|
+
const device = devicePeer(), reports = [], execute = device.python.execute
|
|
117
|
+
let failed = false
|
|
118
|
+
device.python.execute = async request => {
|
|
119
|
+
if (!failed && request.source.includes("['_source'].append")) { failed = true; throw new Error('fixture load failed') }
|
|
120
|
+
return execute(request)
|
|
121
|
+
}
|
|
122
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'memory']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
123
|
+
assert.ok(device.calls.some(call => call[0] === 'execute' && call[1].includes('del _evhil_abc123')))
|
|
124
|
+
assert.equal(reports.at(-1).failed, 1)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('identity and RAM checks do not require optional storage inspection support', async () => {
|
|
128
|
+
const device = devicePeer(), inspect = device.inspect, reports = []
|
|
129
|
+
device.inspect = async request => {
|
|
130
|
+
assert.ok(!request.fields.includes('storage'))
|
|
131
|
+
const info = await inspect(request)
|
|
132
|
+
info.capabilities.features = []
|
|
133
|
+
return info
|
|
134
|
+
}
|
|
135
|
+
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'identity,memory']), { nonce: 'abc123', report: value => reports.push(value) })
|
|
136
|
+
assert.deepEqual(reports.at(-1), { check: 'summary', passed: 2, failed: 0, skipped: 0 })
|
|
137
|
+
})
|