@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.
- package/CHANGELOG.md +25 -2
- package/README.md +23 -13
- package/package.json +1 -5
- package/schemas/device-actions-v1.json +1 -1
- package/src/device.js +17 -6
- package/src/device_files.js +5 -4
- package/src/device_python.js +4 -3
- package/src/device_session.js +127 -43
- package/src/evrepl.js +196 -22
- package/src/node.js +10 -0
- package/src/node_inventory.js +7 -4
- package/src/node_serial.py +9 -1
- package/src/node_transport.js +36 -7
- package/tests/check-package.mjs +6 -9
- package/tests/device-checks.mjs +2 -2
- package/tests/fixtures/device-checks.py +205 -1
- package/tests/test-device-api.mjs +96 -6
- package/tests/test-device-execution.mjs +235 -16
- package/tests/test-device-files-api.mjs +85 -1
- package/tests/test-device-functional-checks.mjs +9 -0
- package/tests/test-device-hardware-cli.mjs +8 -0
- package/tests/test-device-python-api.mjs +29 -2
- package/tests/test-firmware-install.mjs +0 -17
- package/tests/test-node-adapter-assets.mjs +16 -0
- package/tests/test-node-inventory.mjs +136 -0
- package/tests/test-node-serial.mjs +104 -0
- package/src/cli.js +0 -185
- package/tests/test-cli-output.mjs +0 -31
- package/tests/test-cli-ports.mjs +0 -50
- package/tests/test-cli.mjs +0 -98
|
@@ -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) =>
|
|
18
|
-
|
|
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
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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('
|
|
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',
|
|
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 =>
|
|
360
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
162
361
|
assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'))
|
|
163
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
})
|