@esp-vision/device-core 0.4.2 → 0.5.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.
@@ -1,29 +1,71 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  import assert from 'node:assert/strict'
3
3
  import test from 'node:test'
4
+ import { createPythonApi } from '../src/device_python.js'
4
5
  import { DeviceSession } from '../src/device_session.js'
5
6
  import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
6
7
  import { EvRepl } from '../src/evrepl.js'
7
8
 
8
- function executionFixture({ short = false, failWrite = false, failStop = false, stalledReset = false } = {}) {
9
+ function executionFixture({ short = false, failWrite = false, failStop = false, stalledReset = false, deferExec = false, deferRun = false, deferStart = false, features = ['file.write.binary', 'script.run', 'device.control'] } = {}) {
9
10
  const requests = []
10
11
  let running = false
11
12
  let sentinel = null
13
+ let startMarker = null
14
+ let startMarkerEmitted = false
15
+ let pendingExec = null
12
16
  let terminal = ''
17
+ let startAndCompletionInSameFrame = false
13
18
  let session
14
19
  let resetRequested = false
15
20
  let resetPolls = 0
16
21
  let uploaded = 0
17
- const stdout = (payload) => session.feedBytes(makeEvMuxFrame({
18
- channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
19
- }))
22
+ const stdout = (payload) => {
23
+ if (startMarker && sentinel?.marker && String(payload).includes(startMarker) && String(payload).includes(sentinel.marker)) {
24
+ startAndCompletionInSameFrame = true
25
+ }
26
+ session.feedBytes(makeEvMuxFrame({
27
+ channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
28
+ }))
29
+ }
30
+ const finishFast = () => {
31
+ if (startMarker && !startMarkerEmitted && sentinel) {
32
+ startMarkerEmitted = true
33
+ running = true
34
+ const completionMarker = sentinel.marker
35
+ stdout(`${startMarker}\r\n${completionMarker}\r\n>>> `)
36
+ running = false
37
+ sentinel = null
38
+ startMarker = null
39
+ startMarkerEmitted = false
40
+ return
41
+ }
42
+ finish()
43
+ }
44
+ const enter = () => {
45
+ if (!startMarker || startMarkerEmitted) return
46
+ startMarkerEmitted = true
47
+ running = true
48
+ const split = Math.floor(startMarker.length / 2)
49
+ stdout(startMarker.slice(0, split))
50
+ stdout(`${startMarker.slice(split)}\r`)
51
+ stdout('\n')
52
+ }
20
53
  const finish = () => {
54
+ if (startMarker && !startMarkerEmitted) enter()
21
55
  running = false
22
56
  if (sentinel) {
23
- stdout(`${sentinel.line}${sentinel.marker}\r\n>>> `)
57
+ stdout(`${sentinel.startMarker ? '' : sentinel.line}${sentinel.marker}\r\n>>> `)
24
58
  sentinel = null
59
+ startMarker = null
60
+ startMarkerEmitted = false
25
61
  }
26
62
  }
63
+ const finishExec = () => {
64
+ if (!pendingExec) return
65
+ const command = pendingExec
66
+ pendingExec = null
67
+ stdout(`${command}>>> `)
68
+ }
27
69
  const parser = new EvMuxParser({
28
70
  filterFrame: (metadata, bytes) => {
29
71
  const text = new TextDecoder().decode(bytes)
@@ -35,10 +77,27 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
35
77
  resetRequested = true
36
78
  return
37
79
  }
38
- const match = /print\("(~~EVSCRIPT-END:[^"]+)"\)/.exec(text)
39
- if (match) {
40
- sentinel = { line: text, marker: match[1] }
41
- if (short) finish()
80
+ const marker = /~~EVSCRIPT-END:[A-Za-z0-9-]+~~/.exec(text)?.[0]
81
+ if (marker) {
82
+ startMarker = /~~EVSCRIPT-START:[A-Za-z0-9-]+~~/.exec(text)?.[0] ?? null
83
+ startMarkerEmitted = false
84
+ sentinel = { line: text.trimEnd(), marker, startMarker }
85
+ if (startMarker) {
86
+ const echoSplit = Math.floor(text.length / 2)
87
+ stdout(text.slice(0, echoSplit))
88
+ stdout(`${text.slice(echoSplit)}\r\n`)
89
+ }
90
+ if (deferRun) {
91
+ if (startMarker && !deferStart) enter()
92
+ running = true
93
+ stdout('')
94
+ } else if (short && startMarker && !deferStart) finishFast()
95
+ else {
96
+ if (startMarker && !deferStart) enter()
97
+ if (short) finish()
98
+ }
99
+ } else if (deferExec && text.startsWith('exec(')) {
100
+ pendingExec = text
42
101
  } else {
43
102
  stdout(`${text}\r\n>>> `)
44
103
  }
@@ -85,10 +144,79 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
85
144
  session.vmEpoch = 1
86
145
  session.vmReady = true
87
146
  session.limits.fileWriteChunkMax = 16384
88
- session.capabilities = { features: ['file.write.binary', 'script.run', 'device.control'] }
89
- return { session, requests, finish, get terminal() { return terminal }, get running() { return running } }
147
+ session.capabilities = { features }
148
+ return { session, requests, finish, enter, finishExec, get terminal() { return terminal }, get running() { return running },
149
+ get startAndCompletionInSameFrame() { return startAndCompletionInSameFrame } }
90
150
  }
91
151
 
152
+ test('inline source assignment splits at the REPL limit without changing source text', () => {
153
+ const repl = new EvRepl({ writeEvmux: async () => {} })
154
+ const variableName = '__evcore_python_boundary'
155
+ const source = `${'x'.repeat(470)}😀\nprint("done")`
156
+ assert.ok(repl.execInputBytes(source) <= repl.inputMaxBytes)
157
+ const assignments = repl.createInlineSourceAssignments(source, variableName)
158
+ assert.ok(assignments.length > 1, 'the private assignment prefix requires safe chunks at this boundary')
159
+ assert.ok(assignments.every(statement => repl.execInputBytes(statement) <= repl.inputMaxBytes))
160
+ const rebuilt = assignments.map((statement, index) => JSON.parse(statement.slice(variableName.length + (index === 0 ? 1 : 2)))).join('')
161
+ assert.equal(rebuilt, source)
162
+ const command = repl.createInlineScriptRunCommand(variableName)
163
+ assert.ok(new TextEncoder().encode(command).length <= repl.inputMaxBytes)
164
+ assert.match(command, new RegExp(`exec\\(${variableName}\\)`))
165
+ assert.match(command, new RegExp(`del ${variableName}`))
166
+ })
167
+
168
+ test('python.execute small source waits past 30 seconds without requiring device storage', async (t) => {
169
+ t.mock.timers.enable({ apis: ['setTimeout'] })
170
+ const fixture = executionFixture({ deferRun: true, features: [] })
171
+ t.after(() => fixture.session.close())
172
+ let settled = false
173
+ const execution = fixture.session.executePython('while True: pass').then(output => {
174
+ settled = true
175
+ return output
176
+ })
177
+ await new Promise(resolve => setImmediate(resolve))
178
+ assert.equal(fixture.session.runCapture?.timer, null, 'omitted timeout leaves completion pending')
179
+ assert.equal(fixture.session.runCapture?.path, null, 'small source does not create a device file')
180
+ assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.channel === 'repl.stdin' && String(payload).includes('__evcore_python_')))
181
+ assert.ok(!fixture.requests.some(({ metadata }) => ['file.write', 'script.run'].includes(metadata.method)))
182
+ t.mock.timers.tick(31000)
183
+ await Promise.resolve()
184
+ assert.equal(settled, false, 'silent Python still waits beyond the former fixed completion timeout')
185
+ fixture.finish()
186
+ assert.match(await execution, /^\s*$/)
187
+ })
188
+
189
+ test('Python deadlines include REPL setup and cancellation releases API ownership', async (t) => {
190
+ t.mock.timers.enable({ apis: ['setTimeout'] })
191
+ const timed = executionFixture({ deferExec: true })
192
+ t.after(() => timed.session.close())
193
+ const state = {}
194
+ const python = createPythonApi({ session: timed.session, state })
195
+ const execution = python.execute({ source: 'print(42)', timeout: 1000 })
196
+ const timedOut = assert.rejects(execution, { code: 'TIMEOUT' })
197
+ await new Promise(resolve => setImmediate(resolve))
198
+ t.mock.timers.tick(1000)
199
+ await timedOut
200
+ assert.equal(state.operation, null, 'deadline releases the operation lock even during setup')
201
+
202
+ const cancelled = executionFixture({ deferRun: true })
203
+ t.after(() => cancelled.session.close())
204
+ const cancelledState = {}
205
+ const cancelledPython = createPythonApi({ session: cancelled.session, state: cancelledState })
206
+ const controller = new AbortController()
207
+ const cancellation = cancelledPython.execute({ source: 'while True: pass', signal: controller.signal })
208
+ const aborted = assert.rejects(cancellation, { name: 'AbortError' })
209
+ await new Promise(resolve => setImmediate(resolve))
210
+ assert.ok(cancelledState.operation, 'the operation remains owned while Python is still running')
211
+ controller.abort()
212
+ await aborted
213
+ await new Promise(resolve => setImmediate(resolve))
214
+ assert.equal(cancelledState.operation, null, 'cancellation releases the operation lock')
215
+ assert.equal(cancelledState.run, null, 'cancellation does not leave a false active-run claim')
216
+ assert.ok(cancelled.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'),
217
+ 'cancellation requests a stop but the API still reports cancellation, not success')
218
+ })
219
+
92
220
  test('interactive execution survives the bounded-run deadline, streams output, and completes on shared stop', async (t) => {
93
221
  t.mock.timers.enable({ apis: ['setTimeout'] })
94
222
  const fixture = executionFixture()
@@ -111,11 +239,75 @@ test('interactive execution survives the bounded-run deadline, streams output, a
111
239
  assert.ok(!fixture.terminal.includes('~~EVSCRIPT-END'), 'shared completion markers never leak into terminal output')
112
240
  })
113
241
 
114
- test('short scripts can complete while startScript is returning', async () => {
242
+ test('startScript waits for the VM-entry marker and ignores the echoed marker in its command', async () => {
243
+ const fixture = executionFixture({ deferStart: true })
244
+ let resolved = false
245
+ const starting = fixture.session.startScript('/main.py', undefined, { inlineCompletion: true }).then(run => {
246
+ resolved = true
247
+ return run
248
+ })
249
+ await new Promise(resolve => setImmediate(resolve))
250
+ assert.equal(resolved, false, 'a successful stdin write is not evidence that MicroPython entered the runner')
251
+ assert.equal(fixture.running, false)
252
+ assert.equal(fixture.session.runCapture?.timer, null, 'interactive start acknowledgement does not create an aggregate run deadline')
253
+ fixture.enter()
254
+ const run = await starting
255
+ assert.equal(fixture.running, true)
256
+ assert.doesNotMatch(fixture.terminal, /~~EVSCRIPT-START:/, 'the private entry marker is filtered from terminal output')
257
+ fixture.finish()
258
+ await run.done
259
+ fixture.session.close()
260
+ })
261
+
262
+ test('startScript entry acknowledgement has a bounded setup wait', async (t) => {
263
+ t.mock.timers.enable({ apis: ['setTimeout'] })
264
+ const fixture = executionFixture({ deferStart: true })
265
+ const starting = fixture.session.startScript('/main.py', undefined, { inlineCompletion: true })
266
+ const timedOut = assert.rejects(starting, { code: 'SCRIPT_START_TIMEOUT' })
267
+ await new Promise(resolve => setImmediate(resolve))
268
+ t.mock.timers.tick(5000)
269
+ await timedOut
270
+ assert.equal(fixture.session.scriptRunSnapshot(), null)
271
+ fixture.session.close()
272
+ })
273
+
274
+ test('cancellation while waiting for VM entry rejects start and releases the run capture', async () => {
275
+ const fixture = executionFixture({ deferStart: true })
276
+ const controller = new AbortController()
277
+ const starting = fixture.session.startScript('/main.py', undefined, {
278
+ inlineCompletion: true,
279
+ signal: controller.signal,
280
+ })
281
+ const cancelled = assert.rejects(starting, { name: 'AbortError' })
282
+ await new Promise(resolve => setImmediate(resolve))
283
+ controller.abort()
284
+ await cancelled
285
+ await new Promise(resolve => setImmediate(resolve))
286
+ assert.equal(fixture.session.scriptRunSnapshot(), null)
287
+ assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'))
288
+ fixture.session.close()
289
+ })
290
+
291
+ test('closing the transport while waiting for VM entry rejects start and releases the run capture', async () => {
292
+ const fixture = executionFixture({ deferStart: true })
293
+ const starting = fixture.session.startScript('/main.py', undefined, { inlineCompletion: true })
294
+ const disconnected = assert.rejects(starting, /device disconnected/)
295
+ await new Promise(resolve => setImmediate(resolve))
296
+ assert.ok(fixture.session.repl.scriptStartWaiter, 'startup is waiting for the VM-entry acknowledgement')
297
+ fixture.session.close(new Error('device disconnected'))
298
+ await disconnected
299
+ assert.equal(fixture.session.scriptRunSnapshot(), null)
300
+ assert.equal(fixture.session.repl.scriptStartWaiter, null)
301
+ assert.doesNotMatch(fixture.terminal, /~~EVSCRIPT-START:/)
302
+ })
303
+
304
+ test('short scripts can complete in the same frame while startScript is returning', async () => {
115
305
  const fixture = executionFixture({ short: true })
116
- const run = await fixture.session.startScript('/short.py', 'print(1)')
306
+ const run = await fixture.session.startScript('/short.py', undefined, { inlineCompletion: true })
307
+ assert.equal(fixture.startAndCompletionInSameFrame, true)
117
308
  assert.equal((await run.done).path, '/short.py')
118
309
  assert.equal(fixture.session.scriptRunSnapshot(), null)
310
+ assert.doesNotMatch(fixture.terminal, /~~EVSCRIPT-START:/)
119
311
  fixture.session.close()
120
312
  })
121
313
 
@@ -149,18 +341,45 @@ test('failed stop does not claim execution has finished', async () => {
149
341
  fixture.session.close()
150
342
  })
151
343
 
152
- test('bounded Agent execution still times out and sends a shared interrupt', async () => {
344
+ test('bounded Agent execution still times out and sends a shared interrupt', async (t) => {
345
+ t.mock.timers.enable({ apis: ['setTimeout'] })
153
346
  const fixture = executionFixture()
347
+ t.after(() => fixture.session.close())
348
+ // Isolate the running-script deadline from the separately covered upload
349
+ // and REPL setup. A real 30 ms startup budget races CI scheduling.
350
+ fixture.session.stopForRepl = async () => true
351
+ fixture.session.writeScript = async () => {}
154
352
  let started
155
353
  const starting = new Promise(resolve => { started = resolve })
156
354
  const done = fixture.session.runScript('/trial.py', 'while True: pass', { timeout: 30, onStarted: started })
157
355
  const rejected = assert.rejects(done, (error) => error.code === 'SCRIPT_TIMEOUT')
158
356
  await starting
357
+ t.mock.timers.tick(30)
159
358
  await rejected
160
359
  assert.equal(fixture.session.scriptRunSnapshot(), null)
161
- await new Promise(resolve => setTimeout(resolve, 0))
360
+ await new Promise(resolve => setImmediate(resolve))
162
361
  assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'))
163
- fixture.session.close()
362
+ })
363
+
364
+ test('bounded start handles reject when the deadline precedes VM entry', async (t) => {
365
+ t.mock.timers.enable({ apis: ['setTimeout'] })
366
+ const fixture = executionFixture({ deferStart: true })
367
+ t.after(() => fixture.session.close())
368
+ fixture.session.stopForRepl = async () => true
369
+ fixture.session.writeScript = async () => {}
370
+ let started = false
371
+ const starting = fixture.session.startScript('/trial.py', undefined, {
372
+ inlineCompletion: true,
373
+ timeout: 30,
374
+ onStarted() { started = true },
375
+ })
376
+ const rejected = assert.rejects(starting, { code: 'SCRIPT_TIMEOUT' })
377
+ await new Promise(resolve => setImmediate(resolve))
378
+ t.mock.timers.tick(30)
379
+ await rejected
380
+ assert.equal(started, false)
381
+ assert.equal(fixture.session.scriptRunSnapshot(), null)
382
+ assert.equal(fixture.session.runStarting, false)
164
383
  })
165
384
 
166
385
  test('VM reset and disconnect settle interactive completion and allow the next VM run', async () => {
@@ -35,8 +35,92 @@ test('file upload selects encoding and creation policy from public parameters',
35
35
  test('file read is binary by default with explicit UTF-8 decoding and bounded options', async () => {
36
36
  const { files, calls } = fixture()
37
37
  assert.ok(await files.read({ path: '/model.bin' }) instanceof Uint8Array)
38
+ assert.deepEqual(calls[0][2], { timeout: null, chunkTimeout: 30000, maxBytes: null, signal: null })
38
39
  assert.equal(await files.read({ path: '/a.txt', encoding: 'utf-8', maxBytes: 512 }), '你好')
39
- assert.equal(calls[1][2].maxBytes, 512)
40
+ assert.deepEqual(calls[1][2], { timeout: null, chunkTimeout: 30000, maxBytes: 512, signal: null })
41
+ })
42
+
43
+ test('files.read forwards aggregate and per-command timeouts and validates both', async () => {
44
+ const { files, calls } = fixture()
45
+ await files.read({ path: '/a', timeout: 90000, chunkTimeout: 4500, maxBytes: 2048 })
46
+ assert.deepEqual(calls[0][2], { timeout: 90000, chunkTimeout: 4500, maxBytes: 2048, signal: null })
47
+ await files.read({ path: '/a', timeout: null, chunkTimeout: 12000 })
48
+ assert.deepEqual(calls[1][2], { timeout: null, chunkTimeout: 12000, maxBytes: null, signal: null })
49
+ for (const timeout of [0, -1, NaN, Infinity, '100']) {
50
+ await assert.rejects(files.read({ path: '/a', timeout }), RangeError)
51
+ }
52
+ for (const chunkTimeout of [null, 0, -1, NaN, Infinity, '100']) {
53
+ await assert.rejects(files.read({ path: '/a', chunkTimeout }), RangeError)
54
+ }
55
+ assert.equal(calls.length, 2, 'invalid timeout settings must fail before device I/O')
56
+ })
57
+
58
+ function installSimulatedFileRead(repl, data, { commandDurationMs, advanceClock }) {
59
+ const budgets = []
60
+ repl._execPy = async (source, timeout) => {
61
+ budgets.push(timeout)
62
+ if (commandDurationMs > timeout) {
63
+ advanceClock(timeout)
64
+ throw Object.assign(new Error('repl exec timeout'), { code: 'TIMEOUT' })
65
+ }
66
+ advanceClock(commandDurationMs)
67
+ const offset = Number(/f.seek\((\d+)\)/.exec(source)[1])
68
+ const count = Number(/f.read\((\d+)\)/.exec(source)[1])
69
+ const chunk = data.slice(offset, offset + count)
70
+ const hex = [...chunk].map(byte => byte.toString(16).padStart(2, '0')).join('')
71
+ return `__EVREAD_OK__${data.length}:${chunk.length}:${hex}\n`
72
+ }
73
+ return budgets
74
+ }
75
+
76
+ test('a 56 KiB file read can take longer than 30 seconds with a fresh bound per REPL command', async () => {
77
+ const repl = new EvRepl({ writeEvmux() {} })
78
+ const data = Uint8Array.from({ length: 56 * 1024 }, (_, index) => index % 256)
79
+ const originalNow = Date.now
80
+ let now = 100000
81
+ Date.now = () => now
82
+ try {
83
+ const budgets = installSimulatedFileRead(repl, data, {
84
+ commandDurationMs: 1000,
85
+ advanceClock: duration => { now += duration },
86
+ })
87
+ assert.deepEqual(await repl.readFile('/slow.bin'), data)
88
+ assert.equal(now - 100000, 56000)
89
+ assert.equal(budgets.length, 56)
90
+ assert.ok(budgets.every(timeout => timeout === 30000))
91
+ } finally {
92
+ Date.now = originalNow
93
+ }
94
+ })
95
+
96
+ test('file reads keep per-command bounds and an optional aggregate deadline', async () => {
97
+ const data = Uint8Array.from({ length: 4096 }, (_, index) => index % 256)
98
+ const originalNow = Date.now
99
+ let now = 200000
100
+ Date.now = () => now
101
+ try {
102
+ const chunkedRepl = new EvRepl({ writeEvmux() {} })
103
+ const chunkBudgets = installSimulatedFileRead(chunkedRepl, data, {
104
+ commandDurationMs: 4000,
105
+ advanceClock: duration => { now += duration },
106
+ })
107
+ assert.deepEqual(await chunkedRepl.readFile('/slow.bin', { timeout: 20000, chunkTimeout: 5000 }), data)
108
+ assert.deepEqual(chunkBudgets, [5000, 5000, 5000, 5000])
109
+
110
+ now = 300000
111
+ const totalTimeoutRepl = new EvRepl({ writeEvmux() {} })
112
+ const totalBudgets = installSimulatedFileRead(totalTimeoutRepl, data, {
113
+ commandDurationMs: 8000,
114
+ advanceClock: duration => { now += duration },
115
+ })
116
+ await assert.rejects(
117
+ totalTimeoutRepl.readFile('/slow.bin', { timeout: 25000, chunkTimeout: 30000 }),
118
+ { code: 'TIMEOUT' },
119
+ )
120
+ assert.deepEqual(totalBudgets, [25000, 17000, 9000, 1000])
121
+ } finally {
122
+ Date.now = originalNow
123
+ }
40
124
  })
41
125
 
42
126
  test('file listing passes scoped path and recursion without walking the entire device', async () => {
@@ -135,3 +135,12 @@ test('identity and RAM checks do not require optional storage inspection support
135
135
  await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'identity,memory']), { nonce: 'abc123', report: value => reports.push(value) })
136
136
  assert.deepEqual(reports.at(-1), { check: 'summary', passed: 2, failed: 0, skipped: 0 })
137
137
  })
138
+
139
+ test('P4 issue regressions are included in the strict target-check path', async () => {
140
+ const required = ['camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy']
141
+ assert.ok(required.every(name => DEVICE_CHECKS.includes(name)))
142
+ const device = devicePeer(), reports = []
143
+ await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', required.join(','), '--require-all']),
144
+ { nonce: 'issue910', report: value => reports.push(value) })
145
+ assert.deepEqual(reports.at(-1), { check: 'summary', passed: required.length, failed: 0, skipped: 0 })
146
+ })
@@ -19,9 +19,17 @@ test('default hardware acceptance exercises real facade over a fake EV-MUX TCP p
19
19
  const server = createServer(socket => {
20
20
  sockets.add(socket)
21
21
  socket.on('close', () => sockets.delete(socket))
22
+ let pythonMarker = null
22
23
  const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
23
24
  const text = new TextDecoder().decode(bytes)
24
25
  if (metadata.channel === 'repl.stdin') {
26
+ pythonMarker ??= /EV_HARDWARE_[a-z0-9]+/.exec(text)?.[0] ?? null
27
+ const completionMarker = /~~EVSCRIPT-END:[A-Za-z0-9-]+~~/.exec(text)?.[0]
28
+ if (completionMarker) {
29
+ socket.write(makeEvMuxFrame({ channel: 'repl.stdout', type: 'data', sessionId, vmEpoch: 1,
30
+ payload: `${text}\r\n${pythonMarker ?? ''}\r\n${completionMarker}\r\n>>> ` }))
31
+ return false
32
+ }
25
33
  const marker = /EV_HARDWARE_[a-z0-9]+/.exec(text)?.[0] ?? 'EVIDE_BOOT_OK'
26
34
  socket.write(makeEvMuxFrame({ channel: 'repl.stdout', type: 'data', sessionId, vmEpoch: 1, payload: `${text}\r\n${marker}\r\n>>> ` }))
27
35
  return false
@@ -7,6 +7,7 @@ import { createFilesApi } from '../src/device_files.js'
7
7
  function fixture() {
8
8
  const calls = [], state = {}
9
9
  const session = {
10
+ state: 'ready',
10
11
  repl: { writeStdin: async data => calls.push(['input', data]), removeFile: async path => calls.push(['remove', path]) },
11
12
  executePython: async (source, options) => { calls.push(['source', source, options]); return '42\r\n' },
12
13
  runScript: async (path, content, options) => { calls.push(['run', path, content, options]); return { ok: true, output: 'existing', outputTruncated: false } },
@@ -19,18 +20,28 @@ test('source wait and path wait return the same result shape without rewriting p
19
20
  const { python, calls } = fixture()
20
21
  assert.deepEqual(await python.execute({ source: 'print(42)' }), { ok: true, path: null, output: '42\n', outputTruncated: false })
21
22
  assert.deepEqual(await python.execute({ path: '/existing.py' }), { ok: true, path: '/existing.py', output: 'existing', outputTruncated: false })
23
+ assert.equal(calls[0][2].timeout, null, 'wait execution has no aggregate deadline by default')
22
24
  assert.deepEqual(calls[1].slice(0, 3), ['run', '/existing.py', undefined])
25
+ assert.equal(calls[1][3].timeout, null, 'path execution has no aggregate deadline by default')
23
26
  assert.equal(calls[1][3].inlineCompletion, true)
24
27
  })
25
28
 
26
29
  test('execution rejects ambiguous input, unsupported modes, unsafe paths and invalid deadlines before I/O', async () => {
27
30
  const { python, calls } = fixture()
28
- for (const request of [{}, { source: '', path: '/a.py' }, { source: 0 }, { path: '../a.py' }, { path: '/a/../b.py' }, { source: '', completion: 'other' }, { source: '', timeout: 0 }, { source: '', completion: 'start', timeout: 3 }]) {
31
+ for (const request of [{}, { source: '', path: '/a.py' }, { source: 0 }, { path: '../a.py' }, { path: '/a/../b.py' }, { source: '', completion: 'other' }, { source: '', timeout: 0 }, { source: '', completion: 'start', timeout: 3 }, { source: '', completion: 'start', timeout: null }]) {
29
32
  await assert.rejects(python.execute(request))
30
33
  }
31
34
  assert.equal(calls.length, 0)
32
35
  })
33
36
 
37
+ test('wait execution accepts null and forwards an explicit positive deadline', async () => {
38
+ const { python, calls } = fixture()
39
+ await python.execute({ source: 'print(42)', timeout: null })
40
+ await python.execute({ source: 'print(42)', timeout: 12000 })
41
+ assert.equal(calls[0][2].timeout, null)
42
+ assert.equal(calls[1][2].timeout, 12000)
43
+ })
44
+
34
45
  test('pre-aborted execution never starts Python', async () => {
35
46
  const { python, calls } = fixture()
36
47
  await assert.rejects(python.execute({ source: '', signal: AbortSignal.abort() }), { name: 'AbortError' })
@@ -76,6 +87,21 @@ test('started source uses a private temporary file and removes it only after com
76
87
  assert.deepEqual(calls.at(-1), ['remove', handle.path])
77
88
  })
78
89
 
90
+ test('disconnect skips remote cleanup for a started temporary source', async () => {
91
+ const { python, calls, session, state } = fixture()
92
+ let rejectDone
93
+ session.stopForRepl = async () => { calls.push(['stop']); return true }
94
+ session.startScript = async () => ({ done: new Promise((_resolve, reject) => { rejectDone = reject }) })
95
+ const handle = await python.execute({ source: 'while True: pass', completion: 'start' })
96
+ const disconnected = assert.rejects(handle.done, /device disconnected/)
97
+ session.state = 'disconnected'
98
+ rejectDone(new Error('device disconnected'))
99
+ await disconnected
100
+ assert.equal(calls.some(([kind]) => kind === 'stop' || kind === 'remove'), false)
101
+ assert.equal(state.operation, null)
102
+ assert.equal(state.run, null)
103
+ })
104
+
79
105
  test('execution failures release the operation and retain the original error', async () => {
80
106
  const { python, session, state } = fixture()
81
107
  const failure = Object.assign(new Error('disconnected'), { code: 'SESSION_REQUIRED' })
@@ -123,7 +149,8 @@ test('interactive script input receives only user data, never the completion mar
123
149
  stdout(`${text}\r\nreceived:${text.trim()}\r\n${session.repl.scriptEndMarker}\r\n>>> `)
124
150
  } else if (text.includes('/interactive.py')) {
125
151
  waitingForInput = true
126
- stdout(`${text}\r\nNAME?`)
152
+ const startMarker = /~~EVSCRIPT-START:[A-Za-z0-9-]+~~/.exec(text)?.[0]
153
+ stdout(`${text}\r\n${startMarker ? `${startMarker}\r\n` : ''}NAME?`)
127
154
  } else stdout(`${text}\r\n>>> `)
128
155
  return
129
156
  }
@@ -4,7 +4,6 @@ import { test } from 'node:test'
4
4
  import assert from 'node:assert/strict'
5
5
  import { createDevice } from '../src/index.js'
6
6
  import { firmwareSha256 } from '../src/firmware_flash.js'
7
- import { parseCli, runCli } from '../src/cli.js'
8
7
 
9
8
  async function fixture() {
10
9
  const data = new Uint8Array(0x11000).fill(0xff), calls = []
@@ -108,19 +107,3 @@ test('installation with damaged readback does not reset or report success', asyn
108
107
  await assert.rejects(f.device.firmware.update(f.request), /verification failed/)
109
108
  assert.ok(!f.calls.includes('reset')); assert.equal(f.calls.at(-1), 'close')
110
109
  })
111
-
112
- test('CLI install uses ROM directly and passes mode through both check and update', async () => {
113
- const f = await fixture(), checked = []
114
- const options = parseCli(['firmware', 'update', '--mode', 'install', '--board', 'BOARD', '--port', '/dev/ttyACM0', '--version', 'new'])
115
- const result = await runCli(options, {
116
- transportFor: () => { throw new Error('No application transport should be acquired') },
117
- async readFile() { throw new Error('No user catalog required') },
118
- createDevice: () => ({ firmware: {
119
- async check(request) { checked.push(request.mode); return { release: f.release } },
120
- async update(request) { assert.equal(request.mode, 'install'); return { verified: true } },
121
- }, async disconnect() {} }),
122
- })
123
- assert.equal(result.verified, true); assert.deepEqual(checked, ['install'])
124
- assert.throws(() => parseCli(['firmware', 'update', '--mode', 'install', '--port', 'COM3', '--version', 'new']), /explicit --board/)
125
- assert.throws(() => parseCli(['firmware', 'update', '--mode', 'install', '--tcp', 'host', '--board', 'BOARD', '--version', 'new']), /local --port/)
126
- })
@@ -0,0 +1,16 @@
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 { readFile } from 'node:fs/promises'
5
+ import { test } from 'node:test'
6
+ import { nodeAdapterAssets } from '../src/node.js'
7
+
8
+ test('public Node asset URLs identify the exact passive SDK adapters', async () => {
9
+ const assets = nodeAdapterAssets()
10
+ assert.ok(Object.isFrozen(assets))
11
+ assert.deepEqual(Object.keys(assets).sort(), ['rom', 'serial'])
12
+ assert.match(await readFile(assets.serial, 'utf8'), /serial/)
13
+ assert.match(await readFile(assets.rom, 'utf8'), /esptool/)
14
+ assets.serial.pathname = '/changed-by-consumer'
15
+ assert.ok(nodeAdapterAssets().serial.pathname.endsWith('/node_serial.py'))
16
+ })