@esp-vision/device-core 0.4.0 → 0.4.2

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 CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## 0.4.0 (unreleased)
3
+ ## 0.4.2 (unreleased)
4
+
5
+ ### Fixed
6
+
7
+ - Route browser ROM reset calls through the active flash stub after upload, preserving its two-byte status decoder during P4 watchdog register writes; report reset failure diagnostics without invalidating successful firmware verification.
8
+
9
+ ## 0.4.1 (unreleased)
10
+
11
+ ### Fixed
12
+
13
+ - Fail camera mode checks on allocation, timeout and unexpected errors instead of silently classifying those modes as unsupported; inspect optional storage state only when advertised.
14
+ - Keep hardware-check fixtures in bounded RAM-only Python calls, independent of filesystem availability. Add expected board/firmware and required-check validation, report storage mount state, and reject explicit file acceptance on unmounted Flash without formatting.
15
+
16
+ ## 0.4.0
4
17
 
5
18
  ### Added
6
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esp-vision/device-core",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Shared EV-MUX, framed REPL, device session, and action contracts for ESP Vision clients.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "ESP Vision Device Actions",
4
- "deviceCoreVersion": "0.4.0",
4
+ "deviceCoreVersion": "0.4.2",
5
5
  "schemaVersion": 1,
6
6
  "actions": [
7
7
  {
@@ -15,6 +15,13 @@ export function browserRomCompatibility(loader) {
15
15
  }
16
16
  }
17
17
  }
18
+ // runStub returns a separate loader. Its command replies use two status bytes;
19
+ // the ROM loader still expects four and cannot issue reset register writes.
20
+ let activeStub
21
+ const hardReset = loader.hardReset
22
+ loader.hardReset = function (...args) {
23
+ return activeStub ? activeStub.hardReset(...args) : hardReset.apply(this, args)
24
+ }
18
25
  const runStub = loader.runStub
19
26
  loader.runStub = async function (skipFlashDetection = false) {
20
27
  const memFinish = this.memFinish, packet = this.readPacket
@@ -34,6 +41,7 @@ export function browserRomCompatibility(loader) {
34
41
  const stub = await runStub.call(this, skipFlashDetection)
35
42
  if (!stub.IS_STUB) throw new Error('This device has no supported browser flash stub')
36
43
  browserStubCompatibility(stub)
44
+ activeStub = stub
37
45
  return stub
38
46
  } finally { this.memFinish = memFinish; this.readPacket = packet }
39
47
  }
@@ -112,7 +112,10 @@ export class FirmwareFlashSession {
112
112
  }
113
113
  await this.reset()
114
114
  }
115
- catch { onPhase('Firmware verified. Press RESET or reconnect power to start it.'); return { verified: true, needsReset: true } }
115
+ catch (error) {
116
+ onPhase('Firmware verified. Automatic reset did not complete; wait for runtime reconnection or press RESET.')
117
+ return { verified: true, needsReset: true, resetError: { code: error?.code ?? 'RESET_FAILED', message: error?.message ?? String(error) } }
118
+ }
116
119
  onPhase('Firmware verified and reset. Reconnect in the IDE to verify EV-MUX v4.')
117
120
  return { verified: true, needsReset: false }
118
121
  } catch (error) { this.state = 'error'; throw error }
@@ -23,7 +23,7 @@ try {
23
23
  assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32p4.json')))
24
24
  assert.ok(Object.keys(result.metafile.inputs).some(path => path.includes('stubs/esp32s31.json')))
25
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 })
26
+ const context = vm.createContext({ atob, btoa, EventTarget, URL, TextDecoder, TextEncoder, Uint8Array, AbortController, setTimeout, clearTimeout, console, crypto: webcrypto, structuredClone })
27
27
  vm.runInContext(code, context)
28
28
  let opens = 0, closes = 0
29
29
  const adapter = context.DeviceCore.openBrowserRom({ port: {
@@ -46,6 +46,29 @@ try {
46
46
  raw._inputBuffer.push(0xc0, 0xdb, 0x47, 0xc0)
47
47
  await assert.rejects(raw.readPacket(100), /Invalid SLIP escape/)
48
48
  }
49
+ // Exercise real upstream stub status decoding. A ROM reset consumes four
50
+ // status bytes while the running stub replies with two; route reset to it.
51
+ {
52
+ const raw = new context.DeviceCore.TestLoader({ getInfo: () => ({ usbVendorId: 0x303a, usbProductId: 0x1001 }) }, { log() {}, error() {}, debug() {} })
53
+ raw.chipFamily = 0x3280; raw.chipRevision = 302
54
+ raw.memBegin = raw.memBlock = raw.memFinish = async () => {}
55
+ raw.readPacket = async () => new Uint8Array([79, 72, 65, 73])
56
+ context.DeviceCore.testCompatibility(raw)
57
+ const stub = await raw.runStub(true)
58
+ let writes = 0
59
+ stub.detectUsbConnectionType = async () => true
60
+ stub.getUsbMode = async () => ({ mode: 'usb-jtag-serial' })
61
+ stub.sendCommand = async () => { writes++ }
62
+ stub.getResponse = async () => [0, new Uint8Array([0, 0])]
63
+ // Confirm the original ROM instance rejects the exact same stub response.
64
+ raw.detectUsbConnectionType = stub.detectUsbConnectionType
65
+ raw.getUsbMode = stub.getUsbMode
66
+ raw.sendCommand = async () => {}
67
+ raw.getResponse = stub.getResponse
68
+ await assert.rejects(context.DeviceCore.TestLoader.prototype.hardReset.call(raw), /status|response|enough/i)
69
+ await raw.hardReset()
70
+ assert.equal(writes, 4, 'P4 watchdog writes must use the running stub status decoder')
71
+ }
49
72
  const prototype = context.DeviceCore.TestLoader.prototype
50
73
  const originals = Object.fromEntries(['initialize', 'disconnect', 'getSecurityInfo', 'runStub', 'hardReset'].map(key => [key, prototype[key]]))
51
74
  let initializeCalls = 0, disconnectCalls = 0
@@ -115,6 +138,7 @@ try {
115
138
  async flashData() { writes++; writing.abort() },
116
139
  async readFlash(offset) { return offset === 32768 ? table : data },
117
140
  async readRegister() { return 0 },
141
+ async hardReset() { resets++ },
118
142
  }
119
143
  }
120
144
  prototype.hardReset = async () => { resets++ }
@@ -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 = [], fixturePath = `/evhil_${nonce}.py`, namespace = `_evhil_${nonce}`
43
- let ownsFixture = false, loaded = false, namespaceStarted = false, loadError = null
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 entries = await device.files.list({ path: '/', signal })
56
- if (entries.some(entry => [fixturePath, `${fixturePath}.tmp`].includes(entry.path))) throw failure('TEST_PATH_EXISTS', 'Fixture path already exists')
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
- const loader = `with open(${JSON.stringify(fixturePath)}) as _fixture:\n exec(_fixture.read(),globals())`
62
- await execute(`${namespace}={};exec(${JSON.stringify(loader)},${namespace})`)
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 (ownsFixture) {
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
- results.append(dict(name=name, supported=False))
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
- results.append(dict(name=name, supported=False))
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
- unsupported = ('ESP_ERR_NOT_SUPPORTED', 'ESP_ERR_NOT_FOUND', 'no module named', 'ENODEV')
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))
@@ -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 (option === '--write' || option === '--capture') { options[option.slice(2)] = true; continue }
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
@@ -80,7 +80,10 @@ test('failed readback never reports verified or resets the device', async () =>
80
80
  })
81
81
  test('reset failure returns verified with explicit manual reset requirement', async () => {
82
82
  const f = await fixture(); f.loader.hardReset = async () => { throw new Error('reset unavailable') }
83
- assert.equal((await f.device.firmware.update(f.request)).needsReset, true)
83
+ const result = await f.device.firmware.update(f.request)
84
+ assert.equal(result.needsReset, true)
85
+ assert.equal(result.verified, true)
86
+ assert.deepEqual(result.resetError, { code: 'RESET_FAILED', message: 'reset unavailable' })
84
87
  })
85
88
 
86
89
  test('firmware check lists all board versions and selects an explicit older version', async () => {
@@ -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() { return [...files.keys()].map(path => ({ path })) },
17
- async write({ path, data, overwrite }) { calls.push(['write', path]); assert.equal(overwrite, false); files.set(path, data) },
18
- async remove({ path }) { calls.push(['remove', path]); files.delete(path) },
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 stage their shared fixture once and consume preview via public events', async () => {
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
- assert.equal(device.calls.filter(call => call[0] === 'write').length, 1)
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
+ })
@@ -54,7 +54,7 @@ function fakeDevice({ mismatch = false, collision = false, writeFails = false }
54
54
  return {
55
55
  calls, files,
56
56
  async connect() { calls.push('connect') },
57
- async inspect() { return { identity: { board: 'TEST' }, network: { password: 'NEVER_PRINT' } } },
57
+ async inspect() { return { identity: { board: 'TEST', firmwareVersion: 'test' }, storage: { flash: { present: true, mounted: true, writable: true } }, network: { password: 'NEVER_PRINT' } } },
58
58
  python: { async execute() { return { ok: true, output: 'EV_HARDWARE_abc123\n' } } },
59
59
  files: Object.assign(files, {
60
60
  async list() { return [...files.keys()].map(path => ({ path })) },
@@ -101,3 +101,22 @@ test('missing optional Python executable fails cleanly without opening a port',
101
101
  await assert.rejects(transport.connect({ signal: AbortSignal.timeout(1000) }), { code: 'SERIAL_ERROR' })
102
102
  } finally { unsubscribe(); await transport.disconnect() }
103
103
  })
104
+
105
+
106
+ test('expected board and firmware must match before any Python or file operations', async () => {
107
+ for (const args of [['--expect-board', 'OTHER'], ['--expect-firmware-version', 'stale']]) {
108
+ const device = fakeDevice()
109
+ device.python.execute = async () => assert.fail('Wrong device must not execute Python')
110
+ await assert.rejects(checkHardware(parseArgs(['--tcp', 'localhost', ...args]), { device, report() {} }), { code: args[0] === '--expect-board' ? 'BOARD_MISMATCH' : 'FIRMWARE_MISMATCH' })
111
+ assert.deepEqual(device.calls, ['connect', 'disconnect'])
112
+ }
113
+ })
114
+
115
+ test('explicit file acceptance reports unmounted storage and fails without writing or formatting', async () => {
116
+ const device = fakeDevice(), inspect = device.inspect, reports = []
117
+ device.inspect = async request => ({ ...await inspect(request), storage: { flash: { present: true, mounted: false, writable: true, reason: 'HOST_WRITER_ACTIVE' } } })
118
+ await assert.rejects(checkHardware(parseArgs(['--tcp', 'localhost', '--write']), { device, nonce: 'abc123', report: value => reports.push(value) }), { code: 'STORAGE_UNAVAILABLE' })
119
+ assert.deepEqual(device.calls, ['connect', 'disconnect'])
120
+ assert.equal(reports.find(result => result.check === 'storage').flash.mounted, false)
121
+ assert.equal(reports.find(result => result.check === 'storage').ok, false)
122
+ })