@esp-vision/device-core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -17
- package/README.md +48 -13
- package/package.json +8 -4
- package/schemas/device-actions-v1.json +2 -2
- package/scripts/generate-action-schema.mjs +36 -0
- package/scripts/release-npm.mjs +68 -0
- package/scripts/run-tests.mjs +16 -0
- package/scripts/verify-registry.mjs +36 -0
- package/src/device_actions.js +1 -1
- package/src/device_bindings.js +3 -12
- package/src/device_session.js +56 -63
- package/src/evmux.js +4 -3
- package/tests/check-package.mjs +84 -0
- package/tests/device-checks.mjs +155 -0
- package/tests/fixtures/device-checks.py +317 -0
- package/tests/hardware-check.mjs +140 -0
- package/tests/test-cli-output.mjs +31 -0
- package/tests/test-cli-ports.mjs +50 -0
- package/tests/test-cli.mjs +98 -0
- package/tests/test-device-actions.mjs +381 -0
- package/tests/test-device-api.mjs +184 -0
- package/tests/test-device-execution.mjs +294 -0
- package/tests/test-device-files-api.mjs +202 -0
- package/tests/test-device-firmware.mjs +133 -0
- package/tests/test-device-functional-checks.mjs +98 -0
- package/tests/test-device-hardware-cli.mjs +103 -0
- package/tests/test-device-operations-api.mjs +210 -0
- package/tests/test-device-python-api.mjs +194 -0
- package/tests/test-device-v4-alignment.mjs +189 -0
- package/tests/test-file-binary.mjs +112 -0
- package/tests/test-firmware-flash.mjs +89 -0
- package/tests/test-network-discovery.mjs +153 -0
- package/tests/test-release-verification.mjs +58 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { parseArgs } from './hardware-check.mjs'
|
|
8
|
+
import { DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
|
|
9
|
+
|
|
10
|
+
function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {}) {
|
|
11
|
+
const files = new Map(), calls = [], listeners = new Map()
|
|
12
|
+
return {
|
|
13
|
+
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
|
+
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) },
|
|
19
|
+
}),
|
|
20
|
+
python: { async execute({ source }) {
|
|
21
|
+
calls.push(['execute', source])
|
|
22
|
+
const name = /\['run_check'\]\("([a-z_]+)"/.exec(source)?.[1]
|
|
23
|
+
if (!name) return { ok: true, output: '' }
|
|
24
|
+
const result = results[name] ?? { name, status: 'PASS', data: { width: 240, height: 320 } }
|
|
25
|
+
if (name === 'preview' && result.status === 'PASS' && !results.noPreview) listeners.get('preview')?.({ data: new Uint8Array([1]) })
|
|
26
|
+
return { ok: true, output: `unrelated output\n__EVDEVICECHECK__${JSON.stringify(result)}\n` }
|
|
27
|
+
} },
|
|
28
|
+
on(event, listener) { listeners.set(event, listener); return () => listeners.delete(event) },
|
|
29
|
+
async capture({ source }) {
|
|
30
|
+
calls.push(['capture', source])
|
|
31
|
+
if (source === 'screen' && screenFails) throw Object.assign(new Error('Not supported'), { code: 'UNSUPPORTED_OPERATION' })
|
|
32
|
+
return { data: new Uint8Array([255, 216, 0, 255, 217]), mimeType: 'image/jpeg', metadata: { width: 240, height: 320 } }
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('functional CLI preserves all legacy checks, validates options and honors explicit screenshot request', () => {
|
|
38
|
+
assert.deepEqual(parseArgs(['--tcp', 'test', '--checks', 'all']).checks, DEVICE_CHECKS)
|
|
39
|
+
assert.equal(parseArgs(['--tcp', 'test', '--checks', 'all']).timeout, 600000)
|
|
40
|
+
assert.equal(parseArgs(['--tcp', 'test', '--checks', 'all', '--timeout', '5000']).timeout, 5000)
|
|
41
|
+
assert.deepEqual(parseArgs(['--serial', 'test', '--screen-output', 'screen.jpg']).checks, ['display'])
|
|
42
|
+
for (const args of [['--checks', 'bogus'], ['--frames', '0'], ['--framesize', 'QVGA;print(1)'], ['--quality', '101'], ['--frame-timeout', '0'], ['--display-hold', '-1']]) assert.throws(() => parseArgs(['--tcp', 'test', ...args]))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('device check reports reject stale, duplicated and malformed markers', () => {
|
|
46
|
+
const valid = '__EVDEVICECHECK__{"name":"memory","status":"PASS","data":{"heapFree":1}}'
|
|
47
|
+
assert.equal(parseCheckResult(valid, 'memory').data.heapFree, 1)
|
|
48
|
+
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
|
+
|
|
51
|
+
test('functional checks stage their shared fixture once and consume preview via public events', async () => {
|
|
52
|
+
const device = devicePeer(), reports = []
|
|
53
|
+
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
|
+
assert.equal(device.files.size, 0)
|
|
56
|
+
assert.equal(device.listeners.size, 0)
|
|
57
|
+
assert.equal(reports.find(result => result.check === 'preview').data.received, 1)
|
|
58
|
+
assert.deepEqual(reports.at(-1), { check: 'summary', passed: 3, failed: 0, skipped: 0 })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('required IPA never silently skips; a failed check does not suppress later checks', async () => {
|
|
62
|
+
const device = devicePeer({ board: 'ESP32_P4X_EYE', results: { ipa_stats: { name: 'ipa_stats', status: 'SKIP', error: { message: 'ESP_ERR_NOT_SUPPORTED' } } } }), reports = []
|
|
63
|
+
await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'ipa_stats,identity']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
|
|
64
|
+
assert.equal(reports.find(result => result.check === 'ipa_stats').status, 'FAIL')
|
|
65
|
+
assert.equal(reports.find(result => result.check === 'identity').status, 'PASS')
|
|
66
|
+
assert.equal(device.files.size, 0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('missing LCD skips ordinary display tests but explicitly requested screenshot must fail', async () => {
|
|
70
|
+
const response = { name: 'display', status: 'SKIP', error: { message: 'ESP_ERR_NOT_SUPPORTED' } }
|
|
71
|
+
for (const explicit of [false, true]) {
|
|
72
|
+
const device = devicePeer({ results: { display: response } }), reports = []
|
|
73
|
+
const options = parseArgs(['--tcp', 'test', '--checks', 'display', ...(explicit ? ['--screen-output', 'screen.jpg'] : [])])
|
|
74
|
+
const run = runDeviceChecks(device, options, { nonce: 'abc123', report: value => reports.push(value) })
|
|
75
|
+
if (explicit) await assert.rejects(run, { code: 'DEVICE_CHECKS_FAILED' })
|
|
76
|
+
else await run
|
|
77
|
+
assert.equal(reports.find(result => result.check === 'display').status, explicit ? 'FAIL' : 'SKIP')
|
|
78
|
+
assert.ok(device.calls.some(call => call[0] === 'execute' && call[1].includes("['close_display']()")))
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('screen JPEG is captured while display fixture is alive and saved on host', async () => {
|
|
83
|
+
const directory = await mkdtemp(join(tmpdir(), 'esp-vision-screen-'))
|
|
84
|
+
const output = join(directory, 'screen.jpg'), device = devicePeer()
|
|
85
|
+
try {
|
|
86
|
+
await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'display', '--screen-output', output]), { nonce: 'abc123', report() {} })
|
|
87
|
+
assert.deepEqual(await readFile(output), Buffer.from([255, 216, 0, 255, 217]))
|
|
88
|
+
const captureAt = device.calls.findIndex(call => call[0] === 'capture' && call[1] === 'screen')
|
|
89
|
+
const closeAt = device.calls.findIndex(call => call[0] === 'execute' && call[1].includes("['close_display']()"))
|
|
90
|
+
assert.ok(captureAt > 0 && closeAt > captureAt)
|
|
91
|
+
assert.equal(device.files.size, 0)
|
|
92
|
+
} finally { await rm(directory, { recursive: true, force: true }) }
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('truncated JPEG and missing dimensions are refused before host artifact is written', async () => {
|
|
96
|
+
await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216]), mimeType: 'image/jpeg', metadata: { width: 1, height: 1 } }), { code: 'CAPTURE_INVALID' })
|
|
97
|
+
await assert.rejects(saveJpeg({ data: new Uint8Array([255, 216, 255, 217]), mimeType: 'image/jpeg' }), { code: 'CAPTURE_INVALID' })
|
|
98
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { createServer } from 'node:net'
|
|
5
|
+
import { once } from 'node:events'
|
|
6
|
+
import { checkHardware, parseArgs, serialTransport } from './hardware-check.mjs'
|
|
7
|
+
import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
|
|
8
|
+
import { EV_MUX_REQUIRED_FEATURES, EV_MUX_REQUIRED_CHANNELS, EV_MUX_DEFAULT_LIMITS } from '../src/device_session.js'
|
|
9
|
+
|
|
10
|
+
test('hardware CLI validates transport, timeout and never accepts credential URLs', () => {
|
|
11
|
+
assert.equal(parseArgs(['--tcp', 'localhost']).port, 3232)
|
|
12
|
+
assert.equal(parseArgs(['--serial', '/dev/test', '--role', 'usb']).role, 'usb')
|
|
13
|
+
assert.equal(parseArgs(['--tcp', '[::1]:1234', '--write']).host, '::1')
|
|
14
|
+
for (const args of [[], ['--tcp', 'a', '--serial', 'b'], ['--tcp', 'tcp://user:secret@host'], ['--tcp', 'a:65536'], ['--serial', 'a', '--timeout', 'NaN'], ['--tcp', 'a', '--role', 'usb']]) assert.throws(() => parseArgs(args))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('default hardware acceptance exercises real facade over a fake EV-MUX TCP peer', async () => {
|
|
18
|
+
const sessionId = 'a'.repeat(32), methods = [], sockets = new Set(), messages = []
|
|
19
|
+
const server = createServer(socket => {
|
|
20
|
+
sockets.add(socket)
|
|
21
|
+
socket.on('close', () => sockets.delete(socket))
|
|
22
|
+
const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
|
|
23
|
+
const text = new TextDecoder().decode(bytes)
|
|
24
|
+
if (metadata.channel === 'repl.stdin') {
|
|
25
|
+
const marker = /EV_HARDWARE_[a-z0-9]+/.exec(text)?.[0] ?? 'EVIDE_BOOT_OK'
|
|
26
|
+
socket.write(makeEvMuxFrame({ channel: 'repl.stdout', type: 'data', sessionId, vmEpoch: 1, payload: `${text}\r\n${marker}\r\n>>> ` }))
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
if (!metadata.channel.endsWith('.rpc')) return false
|
|
30
|
+
methods.push(metadata.method)
|
|
31
|
+
const request = JSON.parse(text)
|
|
32
|
+
let result = { ok: true }
|
|
33
|
+
if (metadata.method === 'hello') result = { ok: true, evMuxVersion: 4, deviceId: 'abcdef123456', hostname: 'esp-vision-test', board: 'TEST', firmware: { version: 'test' }, vmReady: true, session: { id: sessionId, vmEpoch: 1, leaseMs: 15000, keepaliveMs: 5000 } }
|
|
34
|
+
if (metadata.method === 'capabilities') result = { ok: true, protocol: { evMuxVersion: 4 }, features: EV_MUX_REQUIRED_FEATURES, channels: EV_MUX_REQUIRED_CHANNELS, limits: { ...EV_MUX_DEFAULT_LIMITS, txLargePayloadMax: 262144 }, session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 } }
|
|
35
|
+
socket.write(makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq, ...((metadata.method !== 'capabilities' && (metadata.method !== 'hello' || request.acquire)) ? { sessionId, vmEpoch: 1 } : {}), payload: JSON.stringify(result) }))
|
|
36
|
+
return false
|
|
37
|
+
} })
|
|
38
|
+
socket.on('data', bytes => parser.feedBytes(bytes))
|
|
39
|
+
})
|
|
40
|
+
server.listen(0, '127.0.0.1')
|
|
41
|
+
await once(server, 'listening')
|
|
42
|
+
try {
|
|
43
|
+
await checkHardware(parseArgs(['--tcp', `127.0.0.1:${server.address().port}`, '--timeout', '5000']), { nonce: 'abc123', report: value => messages.push(value) })
|
|
44
|
+
assert.deepEqual(messages.map(value => value.check), ['inspect', 'python'])
|
|
45
|
+
assert.ok(methods.includes('session.release'))
|
|
46
|
+
assert.ok(!methods.includes('script.write'))
|
|
47
|
+
assert.ok(!methods.includes('network.configure'))
|
|
48
|
+
} finally { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)) }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function fakeDevice({ mismatch = false, collision = false, writeFails = false } = {}) {
|
|
52
|
+
const calls = [], files = new Map(), path = '/evhc_abc123.bin'
|
|
53
|
+
if (collision) files.set(path, new Uint8Array([7]))
|
|
54
|
+
return {
|
|
55
|
+
calls, files,
|
|
56
|
+
async connect() { calls.push('connect') },
|
|
57
|
+
async inspect() { return { identity: { board: 'TEST' }, network: { password: 'NEVER_PRINT' } } },
|
|
58
|
+
python: { async execute() { return { ok: true, output: 'EV_HARDWARE_abc123\n' } } },
|
|
59
|
+
files: Object.assign(files, {
|
|
60
|
+
async list() { return [...files.keys()].map(path => ({ path })) },
|
|
61
|
+
async write({ path, data, overwrite }) { calls.push('write'); assert.equal(overwrite, false); files.set(`${path}.tmp`, data); if (writeFails) throw new Error('Write failed'); files.delete(`${path}.tmp`); files.set(path, data) },
|
|
62
|
+
async read({ path }) { return mismatch ? new Uint8Array([0]) : files.get(path) },
|
|
63
|
+
async remove({ path, signal }) { assert.equal(signal.aborted, false); calls.push('remove'); files.delete(path) },
|
|
64
|
+
}),
|
|
65
|
+
async disconnect() { calls.push('disconnect') },
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('write acceptance validates bytes and deletes only its owned file, even after partial failure', async () => {
|
|
70
|
+
for (const config of [{}, { mismatch: true }, { writeFails: true }]) {
|
|
71
|
+
const device = fakeDevice(config), reports = []
|
|
72
|
+
const run = checkHardware(parseArgs(['--tcp', 'localhost', '--write']), { device, nonce: 'abc123', report: value => reports.push(value) })
|
|
73
|
+
if (config.mismatch || config.writeFails) await assert.rejects(run)
|
|
74
|
+
else await run
|
|
75
|
+
assert.equal(device.files.size, 0)
|
|
76
|
+
assert.equal(device.calls.at(-1), 'disconnect')
|
|
77
|
+
assert.ok(device.calls.includes('remove'))
|
|
78
|
+
assert.ok(!JSON.stringify(reports).includes('NEVER_PRINT'))
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('write acceptance refuses an existing test path and preserves it', async () => {
|
|
83
|
+
const device = fakeDevice({ collision: true })
|
|
84
|
+
await assert.rejects(checkHardware(parseArgs(['--tcp', 'localhost', '--write']), { device, nonce: 'abc123', report() {} }), { code: 'TEST_PATH_EXISTS' })
|
|
85
|
+
assert.equal(device.files.size, 1)
|
|
86
|
+
assert.deepEqual(device.calls, ['connect', 'disconnect'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('cancellation before connection has no file side effects and still disconnects', async () => {
|
|
90
|
+
const device = fakeDevice(), controller = new AbortController()
|
|
91
|
+
controller.abort()
|
|
92
|
+
await assert.rejects(checkHardware(parseArgs(['--tcp', 'localhost']), { device, signal: controller.signal, report() {} }), { code: 'CANCELLED' })
|
|
93
|
+
assert.equal(device.files.size, 0)
|
|
94
|
+
assert.equal(device.calls.at(-1), 'disconnect')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('missing optional Python executable fails cleanly without opening a port', async () => {
|
|
98
|
+
const transport = serialTransport({ python: '/nonexistent-esp-vision-python', serial: '/not-a-device' })
|
|
99
|
+
const unsubscribe = transport.subscribe({ error() {}, close() {} })
|
|
100
|
+
try {
|
|
101
|
+
await assert.rejects(transport.connect({ signal: AbortSignal.timeout(1000) }), { code: 'SERIAL_ERROR' })
|
|
102
|
+
} finally { unsubscribe(); await transport.disconnect() }
|
|
103
|
+
})
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { createDeviceOperations } from '../src/device_operations.js'
|
|
5
|
+
|
|
6
|
+
function fixture({ enabled = true, features = ['network.background', 'network.configure', 'debug.capture_frame', 'device.control', 'storage.writer.lease'] } = {}) {
|
|
7
|
+
const calls = []
|
|
8
|
+
let operation = null
|
|
9
|
+
let sequence = 0
|
|
10
|
+
const state = {}
|
|
11
|
+
const session = {
|
|
12
|
+
identity: { deviceId: '123', hostname: 'esp-vision-test' }, capabilities: { features }, vmReady: true, vmEpoch: 1,
|
|
13
|
+
hasFeature: feature => features.includes(feature), snapshot: () => ({ state: 'ready' }),
|
|
14
|
+
async networkStatus(opts) { calls.push(['status', opts]); return { enabled, operation } },
|
|
15
|
+
async configureNetwork(credentials, opts) { calls.push(['configure', credentials, opts]); enabled = true; operation = { id: String(++sequence), state: 'succeeded' }; return { accepted: true, operationId: operation.id } },
|
|
16
|
+
async setNetworkEnabled(value, opts) { calls.push(['enable', value, opts]); enabled = value; operation = { id: String(++sequence), state: 'succeeded' }; return { accepted: true, operationId: operation.id } },
|
|
17
|
+
async forgetNetwork(opts) { calls.push(['forget', opts]); enabled = false; operation = { id: String(++sequence), state: 'succeeded' }; return { accepted: true, operationId: operation.id } },
|
|
18
|
+
async rpc(method, payload, opts) { calls.push(['rpc', method, payload, opts]); return { ok: true } },
|
|
19
|
+
async control(command, opts) { calls.push(['control', command, opts]); return { ok: true } },
|
|
20
|
+
async captureImage(method, payload, opts) { calls.push(['capture', method, payload, opts]); return { data: new Uint8Array([255, 216]), mimeType: 'image/jpeg', metadata: { seq: 1 } } },
|
|
21
|
+
}
|
|
22
|
+
return { session, state, calls, api: createDeviceOperations({ session, state }) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('inspect returns only requested fields and never invokes REPL', async () => {
|
|
26
|
+
const { api, state, calls, session } = fixture()
|
|
27
|
+
state.run = { path: '/app.py', completion: 'start', status: 'running', done: Promise.resolve() }
|
|
28
|
+
const result = await api.inspect({ fields: ['identity', 'runtime', 'storage'] })
|
|
29
|
+
assert.deepEqual(Object.keys(result), ['identity', 'runtime', 'storage'])
|
|
30
|
+
assert.equal(result.runtime.run.status, 'running')
|
|
31
|
+
assert.equal(result.runtime.run.done, undefined)
|
|
32
|
+
result.identity.hostname = 'changed'
|
|
33
|
+
assert.equal(session.identity.hostname, 'esp-vision-test')
|
|
34
|
+
assert.deepEqual(calls.map(call => call[2]?.volume), ['flash', 'sd'])
|
|
35
|
+
await assert.rejects(api.inspect({ fields: ['unknown'] }), /fields/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('credentials update preserves omitted enabled state after asynchronous operation completes', async () => {
|
|
39
|
+
const { api, calls, state } = fixture({ enabled: false })
|
|
40
|
+
const result = await api.configure({ network: { credentials: { ssid: 'test', password: '' } } })
|
|
41
|
+
assert.equal(result.network.enabled, false)
|
|
42
|
+
assert.deepEqual(result.completed.map(step => step.action), ['network.configure', 'network.enable'])
|
|
43
|
+
assert.deepEqual(calls.filter(call => call[0] === 'enable').map(call => call[1]), [false])
|
|
44
|
+
assert.equal(state.operation, null)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('credential clearing disables networking; enabling preserves credentials', async () => {
|
|
48
|
+
const { api, calls } = fixture()
|
|
49
|
+
const result = await api.configure({ network: { credentials: null } })
|
|
50
|
+
assert.equal(result.network.enabled, false)
|
|
51
|
+
assert.equal(calls.filter(call => call[0] === 'forget').length, 1)
|
|
52
|
+
await api.configure({ network: { enabled: true } })
|
|
53
|
+
assert.equal(calls.filter(call => call[0] === 'configure').length, 0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('configuration rejects unknown, contradictory and invalid inputs before mutation', async () => {
|
|
57
|
+
const { api, calls } = fixture()
|
|
58
|
+
for (const request of [
|
|
59
|
+
{ other: true }, { network: {} }, { network: { enabled: 'yes' } },
|
|
60
|
+
{ network: { credentials: null, enabled: true } },
|
|
61
|
+
{ network: { credentials: { ssid: '', password: '' } } },
|
|
62
|
+
{ network: { credentials: { ssid: 'a', password: '', hidden: true } } },
|
|
63
|
+
{ network: { credentials: { ssid: '你'.repeat(11), password: '' } } },
|
|
64
|
+
]) await assert.rejects(api.configure(request))
|
|
65
|
+
assert.equal(calls.length, 0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('configuration failure reports completed steps without pretending to roll back credentials', async () => {
|
|
69
|
+
const { api, session, state } = fixture({ enabled: false })
|
|
70
|
+
session.setNetworkEnabled = async () => { throw Object.assign(new Error('enable failed'), { code: 'OFFLINE' }) }
|
|
71
|
+
await assert.rejects(api.configure({ network: { credentials: { ssid: 'test', password: 'secret12' } } }), error => {
|
|
72
|
+
assert.equal(error.code, 'OFFLINE')
|
|
73
|
+
assert.deepEqual(error.completed.map(step => step.action), ['network.configure'])
|
|
74
|
+
assert.ok(!JSON.stringify(error).includes('secret12'))
|
|
75
|
+
return true
|
|
76
|
+
})
|
|
77
|
+
assert.equal(state.operation, null)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('configuration cancellation records accepted pending operation and releases local lock', async () => {
|
|
81
|
+
const { api, session, state } = fixture()
|
|
82
|
+
const controller = new AbortController()
|
|
83
|
+
session.setNetworkEnabled = async () => { controller.abort(new Error('cancelled')); return { accepted: true, operationId: 'pending' } }
|
|
84
|
+
await assert.rejects(api.configure({ network: { enabled: false }, signal: controller.signal }), error => {
|
|
85
|
+
assert.match(error.message, /cancelled/)
|
|
86
|
+
assert.equal(error.pending.operationId, 'pending')
|
|
87
|
+
assert.deepEqual(error.completed, [])
|
|
88
|
+
return true
|
|
89
|
+
})
|
|
90
|
+
assert.equal(state.operation, null)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('control maps explicit commands, validates mode and can interrupt Python but not file writes', async () => {
|
|
94
|
+
const { api, calls, state } = fixture()
|
|
95
|
+
state.operation = { kind: 'python.execute' }
|
|
96
|
+
await api.control({ action: 'stop' })
|
|
97
|
+
await api.control({ action: 'reset', mode: 'soft' })
|
|
98
|
+
await api.control({ action: 'reset', mode: 'hard' })
|
|
99
|
+
await api.control({ action: 'bootloader' })
|
|
100
|
+
assert.deepEqual(calls.map(call => call[0] === 'rpc' ? call[2].action : call[1]), ['stop', 'soft-reset', 'hard-reset', 'bootloader'])
|
|
101
|
+
state.operation = { kind: 'files.write' }
|
|
102
|
+
await assert.rejects(api.control({ action: 'reset', mode: 'hard' }), error => error.code === 'DEVICE_BUSY')
|
|
103
|
+
await assert.rejects(api.control({ action: 'reset' }), /mode/)
|
|
104
|
+
await assert.rejects(api.control({ action: 'stop', mode: 'hard' }), /mode/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('capture returns binary JPEG, forwards quality and never substitutes camera for unsupported screen', async () => {
|
|
108
|
+
const { api, calls, state } = fixture()
|
|
109
|
+
const image = await api.capture({ source: 'camera', format: 'jpeg', quality: 80 })
|
|
110
|
+
assert.deepEqual(image.data, new Uint8Array([255, 216]))
|
|
111
|
+
assert.equal(calls[0][1], 'debug.capture_frame')
|
|
112
|
+
assert.deepEqual(calls[0][2], { quality: 80 })
|
|
113
|
+
await assert.rejects(api.capture({ source: 'screen' }), error => error.code === 'UNSUPPORTED_OPERATION')
|
|
114
|
+
await assert.rejects(api.capture({ source: 'camera', quality: 101 }), /quality/)
|
|
115
|
+
await assert.rejects(api.capture({ source: 'camera', format: 'png' }), /jpeg/)
|
|
116
|
+
assert.equal(calls.length, 1)
|
|
117
|
+
assert.equal(state.operation, null)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('capture errors release operation lock and pre-abort performs no IO', async () => {
|
|
121
|
+
const { api, session, state, calls } = fixture()
|
|
122
|
+
session.captureImage = async () => { throw Object.assign(new Error('camera failed'), { code: 'CAPTURE_FAILED' }) }
|
|
123
|
+
await assert.rejects(api.capture({ source: 'camera' }), error => error.code === 'CAPTURE_FAILED')
|
|
124
|
+
assert.equal(state.operation, null)
|
|
125
|
+
const controller = new AbortController(); controller.abort()
|
|
126
|
+
await assert.rejects(api.capture({ source: 'camera', signal: controller.signal }), error => error.name === 'AbortError')
|
|
127
|
+
assert.equal(calls.length, 0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
test('configuration accepts firmware-supported PSKs and rejects invalid passwords locally', async () => {
|
|
132
|
+
const { api, calls } = fixture()
|
|
133
|
+
for (const password of ['short', '你'.repeat(8), 'g'.repeat(64)]) {
|
|
134
|
+
await assert.rejects(api.configure({ network: { credentials: { ssid: 'test', password } } }), /password/)
|
|
135
|
+
}
|
|
136
|
+
assert.equal(calls.length, 0)
|
|
137
|
+
await api.configure({ network: { credentials: { ssid: 'test', password: 'a'.repeat(64) } } })
|
|
138
|
+
assert.equal(calls.filter(call => call[0] === 'configure').length, 1)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('accepted asynchronous failure preserves operation identity and never applies later steps', async () => {
|
|
142
|
+
const { api, session, calls, state } = fixture({ enabled: false })
|
|
143
|
+
let polls = 0
|
|
144
|
+
session.networkStatus = async () => ++polls === 1 ? { enabled: false } : {
|
|
145
|
+
enabled: false, operation: { id: '1', state: 'failed', error: { code: 'AUTH_FAILED', message: 'Authentication failed' } },
|
|
146
|
+
}
|
|
147
|
+
await assert.rejects(api.configure({ network: { credentials: { ssid: 'test', password: 'password' }, enabled: true } }), error => {
|
|
148
|
+
assert.equal(error.code, 'AUTH_FAILED')
|
|
149
|
+
assert.deepEqual(error.completed, [])
|
|
150
|
+
assert.equal(error.pending.operationId, '1')
|
|
151
|
+
return true
|
|
152
|
+
})
|
|
153
|
+
assert.equal(calls.filter(call => call[0] === 'enable').length, 0)
|
|
154
|
+
assert.equal(state.operation, null)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('capture and configuration honor a busy device without transmitting requests', async () => {
|
|
158
|
+
const { api, state, calls } = fixture()
|
|
159
|
+
state.operation = { kind: 'files.write' }
|
|
160
|
+
await assert.rejects(api.capture({ source: 'camera' }), error => error.code === 'DEVICE_BUSY')
|
|
161
|
+
await assert.rejects(api.configure({ network: { enabled: false } }), error => error.code === 'DEVICE_BUSY')
|
|
162
|
+
assert.equal(calls.length, 0)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
test('control reserves the device until acknowledgement so file work cannot race a reset', async () => {
|
|
167
|
+
const { api, session, state } = fixture()
|
|
168
|
+
let release
|
|
169
|
+
session.control = () => new Promise(resolve => { release = resolve })
|
|
170
|
+
const resetting = api.control({ action: 'reset', mode: 'hard' })
|
|
171
|
+
assert.equal(state.operation.kind, 'control')
|
|
172
|
+
await assert.rejects(api.capture({ source: 'camera' }), error => error.code === 'DEVICE_BUSY')
|
|
173
|
+
release({ ok: true })
|
|
174
|
+
await resetting
|
|
175
|
+
assert.equal(state.operation, null)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('configuration snapshots credentials and enabled before waiting for device status', async () => {
|
|
179
|
+
const { api, session, calls } = fixture()
|
|
180
|
+
const original = session.networkStatus
|
|
181
|
+
let release
|
|
182
|
+
let first = true
|
|
183
|
+
session.networkStatus = opts => {
|
|
184
|
+
if (!first) return original(opts)
|
|
185
|
+
first = false
|
|
186
|
+
return new Promise(resolve => { release = () => original(opts).then(resolve) })
|
|
187
|
+
}
|
|
188
|
+
const request = { network: { credentials: { ssid: 'initial', password: '' }, enabled: true } }
|
|
189
|
+
const configuring = api.configure(request)
|
|
190
|
+
request.network.credentials.ssid = 'changed'
|
|
191
|
+
request.network.credentials.password = 'bad'
|
|
192
|
+
request.network.enabled = false
|
|
193
|
+
release()
|
|
194
|
+
const result = await configuring
|
|
195
|
+
assert.equal(result.network.enabled, true)
|
|
196
|
+
assert.deepEqual(calls.find(call => call[0] === 'configure')[1], { ssid: 'initial', password: '' })
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
test('malformed network acknowledgements cannot be reported as completed configuration', async () => {
|
|
201
|
+
const { api, session } = fixture()
|
|
202
|
+
for (const response of [{ ok: true }, { accepted: false, operationId: '1' }, { accepted: true }]) {
|
|
203
|
+
session.setNetworkEnabled = async () => response
|
|
204
|
+
await assert.rejects(api.configure({ network: { enabled: false } }), error => {
|
|
205
|
+
assert.equal(error.code, 'INVALID_RESPONSE')
|
|
206
|
+
assert.deepEqual(error.completed, [])
|
|
207
|
+
return true
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import { createPythonApi } from '../src/device_python.js'
|
|
5
|
+
import { createFilesApi } from '../src/device_files.js'
|
|
6
|
+
|
|
7
|
+
function fixture() {
|
|
8
|
+
const calls = [], state = {}
|
|
9
|
+
const session = {
|
|
10
|
+
repl: { writeStdin: async data => calls.push(['input', data]), removeFile: async path => calls.push(['remove', path]) },
|
|
11
|
+
executePython: async (source, options) => { calls.push(['source', source, options]); return '42\r\n' },
|
|
12
|
+
runScript: async (path, content, options) => { calls.push(['run', path, content, options]); return { ok: true, output: 'existing', outputTruncated: false } },
|
|
13
|
+
writeFile: async () => { throw new Error('Unexpected write') },
|
|
14
|
+
}
|
|
15
|
+
return { session, state, calls, python: createPythonApi({ session, state }) }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('source wait and path wait return the same result shape without rewriting path', async () => {
|
|
19
|
+
const { python, calls } = fixture()
|
|
20
|
+
assert.deepEqual(await python.execute({ source: 'print(42)' }), { ok: true, path: null, output: '42\n', outputTruncated: false })
|
|
21
|
+
assert.deepEqual(await python.execute({ path: '/existing.py' }), { ok: true, path: '/existing.py', output: 'existing', outputTruncated: false })
|
|
22
|
+
assert.deepEqual(calls[1].slice(0, 3), ['run', '/existing.py', undefined])
|
|
23
|
+
assert.equal(calls[1][3].inlineCompletion, true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('execution rejects ambiguous input, unsupported modes, unsafe paths and invalid deadlines before I/O', async () => {
|
|
27
|
+
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 }]) {
|
|
29
|
+
await assert.rejects(python.execute(request))
|
|
30
|
+
}
|
|
31
|
+
assert.equal(calls.length, 0)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('pre-aborted execution never starts Python', async () => {
|
|
35
|
+
const { python, calls } = fixture()
|
|
36
|
+
await assert.rejects(python.execute({ source: '', signal: AbortSignal.abort() }), { name: 'AbortError' })
|
|
37
|
+
assert.equal(calls.length, 0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('existing script start preserves the file and permits terminal input while rejecting parallel work', async () => {
|
|
41
|
+
const { python, calls, session, state } = fixture()
|
|
42
|
+
let finish
|
|
43
|
+
session.startScript = async (path, content, options) => {
|
|
44
|
+
assert.equal(options.inlineCompletion, true)
|
|
45
|
+
calls.push(['start', path, content])
|
|
46
|
+
return { done: new Promise(resolve => { finish = resolve }) }
|
|
47
|
+
}
|
|
48
|
+
const handle = await python.execute({ path: '/model-demo.py', completion: 'start' })
|
|
49
|
+
assert.deepEqual(calls[0], ['start', '/model-demo.py', undefined])
|
|
50
|
+
assert.equal(state.run.status, 'running')
|
|
51
|
+
await python.input('hello\r')
|
|
52
|
+
await assert.rejects(python.execute({ source: 'pass' }), { code: 'DEVICE_BUSY' })
|
|
53
|
+
const files = createFilesApi({ session, state })
|
|
54
|
+
await assert.rejects(files.write({ path: '/other', data: 'x' }), { code: 'DEVICE_BUSY' })
|
|
55
|
+
finish({ ok: true, output: '', outputTruncated: false })
|
|
56
|
+
await handle.done
|
|
57
|
+
assert.equal(state.run, null)
|
|
58
|
+
assert.equal(state.operation, null)
|
|
59
|
+
assert.ok(!calls.some(call => call[0] === 'remove'))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('started source uses a private temporary file and removes it only after completion', async () => {
|
|
63
|
+
const { python, calls, session } = fixture()
|
|
64
|
+
let finish
|
|
65
|
+
session.startScript = async (path, content, options) => {
|
|
66
|
+
assert.equal(options.inlineCompletion, true)
|
|
67
|
+
calls.push(['start', path, content])
|
|
68
|
+
return { done: new Promise(resolve => { finish = resolve }) }
|
|
69
|
+
}
|
|
70
|
+
const handle = await python.execute({ source: 'while True: pass', completion: 'start' })
|
|
71
|
+
assert.match(handle.path, /^\/\.__evcore_.*\.py$/)
|
|
72
|
+
assert.equal(calls.length, 1)
|
|
73
|
+
assert.equal(calls[0][2], 'while True: pass')
|
|
74
|
+
finish({ ok: true, output: 'done' })
|
|
75
|
+
assert.equal((await handle.done).output, 'done')
|
|
76
|
+
assert.deepEqual(calls.at(-1), ['remove', handle.path])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('execution failures release the operation and retain the original error', async () => {
|
|
80
|
+
const { python, session, state } = fixture()
|
|
81
|
+
const failure = Object.assign(new Error('disconnected'), { code: 'SESSION_REQUIRED' })
|
|
82
|
+
session.startScript = async () => { throw failure }
|
|
83
|
+
session.repl.removeFile = async () => { throw new Error('cleanup also failed') }
|
|
84
|
+
await assert.rejects(python.execute({ source: 'pass', completion: 'start' }), error => error === failure)
|
|
85
|
+
assert.equal(state.run, null)
|
|
86
|
+
assert.equal(state.operation, null)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('terminal input is excluded during startup and automated execution', async () => {
|
|
90
|
+
const { python, session } = fixture()
|
|
91
|
+
let finish
|
|
92
|
+
session.executePython = async () => new Promise(resolve => { finish = resolve })
|
|
93
|
+
const execution = python.execute({ source: 'pass' })
|
|
94
|
+
await assert.rejects(python.input('oops'), { code: 'DEVICE_BUSY' })
|
|
95
|
+
finish('')
|
|
96
|
+
await execution
|
|
97
|
+
await python.input(new Uint8Array([13]))
|
|
98
|
+
await assert.rejects(python.input({}), TypeError)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
test('source execution honors the same output bound as path execution', async () => {
|
|
103
|
+
const { python } = fixture()
|
|
104
|
+
assert.deepEqual(await python.execute({ source: 'print(42)', maxOutputChars: 1 }), { ok: true, path: null, output: '4', outputTruncated: true })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('interactive script input receives only user data, never the completion marker command', async () => {
|
|
108
|
+
const { DeviceSession } = await import('../src/device_session.js')
|
|
109
|
+
const { EvMuxParser, makeEvMuxFrame } = await import('../src/evmux.js')
|
|
110
|
+
const { EvRepl } = await import('../src/evrepl.js')
|
|
111
|
+
const input = [], methods = []
|
|
112
|
+
let session, waitingForInput = false
|
|
113
|
+
const stdout = payload => session.feedBytes(makeEvMuxFrame({
|
|
114
|
+
channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
|
|
115
|
+
}))
|
|
116
|
+
const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
|
|
117
|
+
const text = new TextDecoder().decode(bytes)
|
|
118
|
+
if (metadata.channel === 'repl.signal') return
|
|
119
|
+
if (metadata.channel === 'repl.stdin') {
|
|
120
|
+
if (waitingForInput) {
|
|
121
|
+
input.push(text)
|
|
122
|
+
waitingForInput = false
|
|
123
|
+
stdout(`${text}\r\nreceived:${text.trim()}\r\n${session.repl.scriptEndMarker}\r\n>>> `)
|
|
124
|
+
} else if (text.includes('/interactive.py')) {
|
|
125
|
+
waitingForInput = true
|
|
126
|
+
stdout(`${text}\r\nNAME?`)
|
|
127
|
+
} else stdout(`${text}\r\n>>> `)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
methods.push(metadata.method)
|
|
131
|
+
// Legacy script.run deliberately models input() consuming the next stdin line.
|
|
132
|
+
if (metadata.method === 'script.run') waitingForInput = true
|
|
133
|
+
session.feedBytes(makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq,
|
|
134
|
+
sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload: JSON.stringify({ ok: true }),
|
|
135
|
+
}))
|
|
136
|
+
} })
|
|
137
|
+
session = new DeviceSession({ transport: { async writeBytes(bytes) { parser.feedBytes(bytes) } }, EvMuxParser, makeEvMuxFrame, EvRepl })
|
|
138
|
+
session.state = 'ready'; session.sessionId = 'a'.repeat(32); session.vmEpoch = 1; session.vmReady = true
|
|
139
|
+
session.capabilities = { features: ['script.run', 'device.control'] }
|
|
140
|
+
try {
|
|
141
|
+
const python = createPythonApi({ session, state: {} })
|
|
142
|
+
const run = await python.execute({ path: '/interactive.py', completion: 'start', maxOutputChars: 1024 })
|
|
143
|
+
assert.deepEqual(input, [], 'Core must not pre-fill the application input with its private completion command')
|
|
144
|
+
assert.equal(waitingForInput, true)
|
|
145
|
+
await python.input('ev-api-check\r')
|
|
146
|
+
const result = await run.done
|
|
147
|
+
assert.deepEqual(input, ['ev-api-check\r'])
|
|
148
|
+
assert.match(result.output, /received:ev-api-check/)
|
|
149
|
+
assert.ok(!methods.includes('script.write'), 'Existing scripts must never be overwritten')
|
|
150
|
+
} finally { session.close() }
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('Python requests reject unknown parameters and malformed signals before interpreter I/O', async () => {
|
|
154
|
+
const { python, calls } = fixture()
|
|
155
|
+
for (const signal of [{}, true, 'signal', { aborted: false, addEventListener() {}, removeEventListener() {} }]) {
|
|
156
|
+
await assert.rejects(python.execute({ source: 'pass', signal }), TypeError)
|
|
157
|
+
}
|
|
158
|
+
await assert.rejects(python.execute({ source: 'pass', mode: 'wait' }), /Unknown Python execution parameter/)
|
|
159
|
+
await assert.rejects(python.execute({ source: 'pass', [Symbol('option')]: true }), /Unknown Python execution parameter/)
|
|
160
|
+
assert.equal(calls.length, 0)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('Python snapshots request getters and input byte buffers', async () => {
|
|
164
|
+
const { python, session } = fixture()
|
|
165
|
+
let reads = 0
|
|
166
|
+
await python.execute({ get source() { reads++; return reads === 1 ? 'safe' : 'changed' } })
|
|
167
|
+
assert.equal(reads, 1)
|
|
168
|
+
let release, observed
|
|
169
|
+
session.repl.writeStdin = async data => {
|
|
170
|
+
await new Promise(resolve => { release = resolve })
|
|
171
|
+
observed = Array.from(data)
|
|
172
|
+
}
|
|
173
|
+
const data = new Uint8Array([65])
|
|
174
|
+
const writing = python.input(data)
|
|
175
|
+
data[0] = 66
|
|
176
|
+
release()
|
|
177
|
+
await writing
|
|
178
|
+
assert.deepEqual(observed, [65])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('disconnect and cleanup errors retain execution failure and always release shared state', async () => {
|
|
182
|
+
const { python, session, state } = fixture()
|
|
183
|
+
const original = Object.assign(new Error('transport closed during execution'), { code: 'SESSION_REQUIRED' })
|
|
184
|
+
let rejectDone
|
|
185
|
+
session.startScript = async () => ({ done: new Promise((_resolve, reject) => { rejectDone = reject }) })
|
|
186
|
+
session.stopForRepl = async () => { throw new Error('cleanup transport closed') }
|
|
187
|
+
const run = await python.execute({ source: 'pass', completion: 'start' })
|
|
188
|
+
const failed = assert.rejects(run.done, error => error === original)
|
|
189
|
+
rejectDone(original)
|
|
190
|
+
await failed
|
|
191
|
+
assert.equal(state.operation, null)
|
|
192
|
+
assert.equal(state.run, null)
|
|
193
|
+
assert.equal(await python.execute({ source: '1' }).then(result => result.ok), true)
|
|
194
|
+
})
|