@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,294 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import { DeviceSession } from '../src/device_session.js'
|
|
5
|
+
import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
|
|
6
|
+
import { EvRepl } from '../src/evrepl.js'
|
|
7
|
+
|
|
8
|
+
function executionFixture({ short = false, failWrite = false, failStop = false, stalledReset = false } = {}) {
|
|
9
|
+
const requests = []
|
|
10
|
+
let running = false
|
|
11
|
+
let sentinel = null
|
|
12
|
+
let terminal = ''
|
|
13
|
+
let session
|
|
14
|
+
let resetRequested = false
|
|
15
|
+
let resetPolls = 0
|
|
16
|
+
let uploaded = 0
|
|
17
|
+
const stdout = (payload) => session.feedBytes(makeEvMuxFrame({
|
|
18
|
+
channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
|
|
19
|
+
}))
|
|
20
|
+
const finish = () => {
|
|
21
|
+
running = false
|
|
22
|
+
if (sentinel) {
|
|
23
|
+
stdout(`${sentinel.line}${sentinel.marker}\r\n>>> `)
|
|
24
|
+
sentinel = null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const parser = new EvMuxParser({
|
|
28
|
+
filterFrame: (metadata, bytes) => {
|
|
29
|
+
const text = new TextDecoder().decode(bytes)
|
|
30
|
+
const payload = metadata.encoding === 'binary' ? bytes : metadata.channel.endsWith('.rpc') ? JSON.parse(text) : text
|
|
31
|
+
requests.push({ metadata, payload })
|
|
32
|
+
if (metadata.channel === 'repl.signal') return
|
|
33
|
+
if (metadata.channel === 'repl.stdin') {
|
|
34
|
+
if (text.includes('machine.soft_reset')) {
|
|
35
|
+
resetRequested = true
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const match = /print\("(~~EVSCRIPT-END:[^"]+)"\)/.exec(text)
|
|
39
|
+
if (match) {
|
|
40
|
+
sentinel = { line: text, marker: match[1] }
|
|
41
|
+
if (short) finish()
|
|
42
|
+
} else {
|
|
43
|
+
stdout(`${text}\r\n>>> `)
|
|
44
|
+
}
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
let response = { ok: true }
|
|
48
|
+
let responseEpoch = session.vmEpoch
|
|
49
|
+
if (metadata.method === 'session.keepalive' && resetRequested) {
|
|
50
|
+
resetPolls += 1
|
|
51
|
+
responseEpoch = stalledReset || resetPolls === 1 ? 1 : 2
|
|
52
|
+
response = { ok: true, sessionId: session.sessionId, vmEpoch: responseEpoch,
|
|
53
|
+
vmReady: stalledReset || resetPolls === 1 || resetPolls >= 3 }
|
|
54
|
+
}
|
|
55
|
+
if (metadata.method === 'file.write') {
|
|
56
|
+
if (payload instanceof Uint8Array) {
|
|
57
|
+
uploaded = new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint32(16, true) + payload.length - 20
|
|
58
|
+
response = { ok: true, writtenBytes: uploaded }
|
|
59
|
+
} else if (payload.action === 'begin') {
|
|
60
|
+
uploaded = 0; response = { ok: true, uploadId: payload.uploadId, chunkBytes: 16384 }
|
|
61
|
+
} else response = { ok: true, bytes: uploaded, complete: payload.action === 'commit' }
|
|
62
|
+
}
|
|
63
|
+
if (metadata.method === 'file.write' && failWrite) {
|
|
64
|
+
response = { ok: false, error: { code: 'WRITE_FAILED', message: 'simulated write failure' } }
|
|
65
|
+
} else if (metadata.method === 'script.run') {
|
|
66
|
+
running = true
|
|
67
|
+
stdout('streaming output\r\n')
|
|
68
|
+
} else if (metadata.method === 'device.control' && payload.action === 'stop' && failStop) {
|
|
69
|
+
response = { ok: false, error: { code: 'TX_BUSY', message: 'simulated stop failure' } }
|
|
70
|
+
}
|
|
71
|
+
session.feedBytes(makeEvMuxFrame({
|
|
72
|
+
channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq,
|
|
73
|
+
sessionId: session.sessionId, vmEpoch: responseEpoch, payload: JSON.stringify(response),
|
|
74
|
+
}))
|
|
75
|
+
if (metadata.method === 'device.control' && payload.action === 'stop' && !failStop) finish()
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
session = new DeviceSession({
|
|
79
|
+
transport: { async writeBytes(bytes) { parser.feedBytes(bytes) } },
|
|
80
|
+
EvMuxParser, makeEvMuxFrame, EvRepl, sleep: async () => {},
|
|
81
|
+
handlers: { text: (text) => { terminal += text } },
|
|
82
|
+
})
|
|
83
|
+
session.state = 'ready'
|
|
84
|
+
session.sessionId = 'a'.repeat(32)
|
|
85
|
+
session.vmEpoch = 1
|
|
86
|
+
session.vmReady = true
|
|
87
|
+
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 } }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
test('interactive execution survives the bounded-run deadline, streams output, and completes on shared stop', async (t) => {
|
|
93
|
+
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
94
|
+
const fixture = executionFixture()
|
|
95
|
+
t.after(() => fixture.session.close())
|
|
96
|
+
const run = await fixture.session.startScript('/main.py', 'while True: pass')
|
|
97
|
+
let completed = false
|
|
98
|
+
run.done.then(() => { completed = true })
|
|
99
|
+
t.mock.timers.tick(20000)
|
|
100
|
+
await Promise.resolve()
|
|
101
|
+
assert.equal(completed, false)
|
|
102
|
+
assert.equal(fixture.running, true)
|
|
103
|
+
assert.match(fixture.terminal, /streaming output/)
|
|
104
|
+
assert.equal(fixture.session.scriptRunSnapshot().output, '', 'interactive terminal output is not stored a second time')
|
|
105
|
+
const beforeDuplicate = fixture.requests.length
|
|
106
|
+
await assert.rejects(fixture.session.startScript('/other.py', 'pass'), /already running/)
|
|
107
|
+
assert.equal(fixture.requests.length, beforeDuplicate, 'duplicate start does not interrupt the existing run')
|
|
108
|
+
await fixture.session.control('stop')
|
|
109
|
+
assert.equal((await run.done).ok, true)
|
|
110
|
+
assert.equal(fixture.session.scriptRunSnapshot(), null)
|
|
111
|
+
assert.ok(!fixture.terminal.includes('~~EVSCRIPT-END'), 'shared completion markers never leak into terminal output')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('short scripts can complete while startScript is returning', async () => {
|
|
115
|
+
const fixture = executionFixture({ short: true })
|
|
116
|
+
const run = await fixture.session.startScript('/short.py', 'print(1)')
|
|
117
|
+
assert.equal((await run.done).path, '/short.py')
|
|
118
|
+
assert.equal(fixture.session.scriptRunSnapshot(), null)
|
|
119
|
+
fixture.session.close()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('parallel starts cannot overwrite the shared execution lifecycle during upload', async () => {
|
|
123
|
+
const fixture = executionFixture()
|
|
124
|
+
const starting = fixture.session.startScript('/first.py', 'pass')
|
|
125
|
+
await assert.rejects(fixture.session.startScript('/second.py', 'pass'), /already running/)
|
|
126
|
+
const first = await starting
|
|
127
|
+
assert.equal(fixture.requests.filter(({ metadata }) => metadata.method === 'script.run').length, 1)
|
|
128
|
+
fixture.finish()
|
|
129
|
+
await first.done
|
|
130
|
+
fixture.session.close()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('startup errors reject the start handle without leaving an active run', async () => {
|
|
134
|
+
const fixture = executionFixture({ failWrite: true })
|
|
135
|
+
await assert.rejects(fixture.session.startScript('/main.py', 'pass'), (error) => error.code === 'WRITE_FAILED')
|
|
136
|
+
assert.equal(fixture.session.scriptRunSnapshot(), null)
|
|
137
|
+
assert.ok(!fixture.requests.some(({ metadata }) => metadata.method === 'script.run'))
|
|
138
|
+
fixture.session.close()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('failed stop does not claim execution has finished', async () => {
|
|
142
|
+
const fixture = executionFixture({ failStop: true })
|
|
143
|
+
const run = await fixture.session.startScript('/main.py', 'while True: pass')
|
|
144
|
+
await assert.rejects(fixture.session.control('stop'), (error) => error.code === 'TX_BUSY')
|
|
145
|
+
assert.equal(fixture.running, true)
|
|
146
|
+
assert.notEqual(fixture.session.scriptRunSnapshot(), null)
|
|
147
|
+
fixture.finish()
|
|
148
|
+
await run.done
|
|
149
|
+
fixture.session.close()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('bounded Agent execution still times out and sends a shared interrupt', async () => {
|
|
153
|
+
const fixture = executionFixture()
|
|
154
|
+
let started
|
|
155
|
+
const starting = new Promise(resolve => { started = resolve })
|
|
156
|
+
const done = fixture.session.runScript('/trial.py', 'while True: pass', { timeout: 30, onStarted: started })
|
|
157
|
+
const rejected = assert.rejects(done, (error) => error.code === 'SCRIPT_TIMEOUT')
|
|
158
|
+
await starting
|
|
159
|
+
await rejected
|
|
160
|
+
assert.equal(fixture.session.scriptRunSnapshot(), null)
|
|
161
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
162
|
+
assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'))
|
|
163
|
+
fixture.session.close()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('VM reset and disconnect settle interactive completion and allow the next VM run', async () => {
|
|
167
|
+
const fixture = executionFixture()
|
|
168
|
+
const run = await fixture.session.startScript('/main.py', 'while True: pass')
|
|
169
|
+
const reset = assert.rejects(run.done, (error) => error.code === 'VM_EPOCH_CHANGED')
|
|
170
|
+
fixture.session.feedBytes(makeEvMuxFrame({
|
|
171
|
+
channel: 'user.rpc', type: 'event', method: 'hello', sessionId: fixture.session.sessionId, vmEpoch: 2,
|
|
172
|
+
payload: JSON.stringify({ ok: true, evMuxVersion: 4, vmEpoch: 2, vmReady: true, session: { id: fixture.session.sessionId, vmEpoch: 2 } }),
|
|
173
|
+
}))
|
|
174
|
+
await reset
|
|
175
|
+
const next = await fixture.session.startScript('/next.py', 'pass')
|
|
176
|
+
const lost = assert.rejects(next.done, /device disconnected/)
|
|
177
|
+
fixture.session.close(new Error('device disconnected'))
|
|
178
|
+
await lost
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('reset and bootloader controls use the shared RPC or framed REPL paths', async () => {
|
|
182
|
+
for (const operation of ['hard-reset', 'soft-reset', 'bootloader']) {
|
|
183
|
+
const fixture = executionFixture()
|
|
184
|
+
await fixture.session.control(operation)
|
|
185
|
+
if (operation === 'hard-reset') {
|
|
186
|
+
assert.deepEqual(fixture.requests.map(({ metadata, payload }) => [metadata.method, payload.action]), [['device.control', 'reset']])
|
|
187
|
+
} else {
|
|
188
|
+
assert.equal(fixture.requests[0].metadata.channel, 'repl.signal')
|
|
189
|
+
assert.equal(fixture.requests[1].metadata.channel, 'repl.stdin')
|
|
190
|
+
assert.match(fixture.requests[1].payload, operation === 'soft-reset' ? /machine\.soft_reset/ : /machine\.bootloader/)
|
|
191
|
+
}
|
|
192
|
+
fixture.session.close()
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('soft reset waits for a newer ready epoch and an actual REPL barrier', async () => {
|
|
197
|
+
const fixture = executionFixture()
|
|
198
|
+
try {
|
|
199
|
+
const result = await fixture.session.control('soft-reset')
|
|
200
|
+
assert.equal(result.acknowledged, true)
|
|
201
|
+
assert.equal(result.vmEpoch, 2)
|
|
202
|
+
const polls = fixture.requests.filter(({ metadata }) => metadata.method === 'session.keepalive')
|
|
203
|
+
assert.equal(polls.length, 3, 'old ready epoch and new unready epoch are insufficient')
|
|
204
|
+
const barrier = fixture.requests.at(-1)
|
|
205
|
+
assert.equal(barrier.metadata.channel, 'repl.stdin')
|
|
206
|
+
assert.equal(barrier.metadata.vmEpoch, 2)
|
|
207
|
+
assert.match(barrier.payload, /exec\("1"\)/)
|
|
208
|
+
} finally { fixture.session.close() }
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('soft reset times out without probing or running in the old epoch', async () => {
|
|
212
|
+
const fixture = executionFixture({ stalledReset: true })
|
|
213
|
+
try {
|
|
214
|
+
await assert.rejects(fixture.session.control('soft-reset', { timeout: 20 }), error => error.code === 'VM_RESET_TIMEOUT')
|
|
215
|
+
assert.equal(fixture.session.vmReady, false)
|
|
216
|
+
assert.equal(fixture.requests.filter(({ metadata }) => metadata.channel === 'repl.stdin').length, 1)
|
|
217
|
+
} finally { fixture.session.close() }
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('one lost keepalive acknowledgement retries within the same negotiated lease', async () => {
|
|
221
|
+
const f = executionFixture(), session = f.session
|
|
222
|
+
const scope = { sessionId: session.sessionId, generation: session.sessionGeneration, keepaliveGeneration: session.keepaliveGeneration }
|
|
223
|
+
let calls = 0
|
|
224
|
+
session.rpc = async (method, payload, options) => {
|
|
225
|
+
assert.equal(method, 'session.keepalive'); assert.deepEqual(payload, {}); assert.equal(options.timeout, 3000)
|
|
226
|
+
calls++
|
|
227
|
+
if (calls === 1) throw Object.assign(new Error('lost reply'), { code: 'TIMEOUT' })
|
|
228
|
+
return { ok: true, sessionId: scope.sessionId }
|
|
229
|
+
}
|
|
230
|
+
assert.equal((await session._keepaliveOnce(scope)).sessionId, scope.sessionId)
|
|
231
|
+
assert.equal(calls, 2)
|
|
232
|
+
session.close()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('keepalive retry fails closed after a second timeout and never crosses owners', async () => {
|
|
236
|
+
for (const changeOwner of [false, true]) {
|
|
237
|
+
const f = executionFixture(), session = f.session
|
|
238
|
+
const scope = { sessionId: session.sessionId, generation: session.sessionGeneration, keepaliveGeneration: session.keepaliveGeneration }
|
|
239
|
+
let calls = 0
|
|
240
|
+
session.rpc = async () => {
|
|
241
|
+
calls++
|
|
242
|
+
if (changeOwner) session.sessionId = 'f'.repeat(32)
|
|
243
|
+
throw Object.assign(new Error('no reply'), { code: 'TIMEOUT' })
|
|
244
|
+
}
|
|
245
|
+
await assert.rejects(session._keepaliveOnce(scope), { code: 'TIMEOUT' })
|
|
246
|
+
assert.equal(calls, changeOwner ? 1 : 2)
|
|
247
|
+
session.close()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('short leases and explicit owner errors do not retry keepalive', async () => {
|
|
252
|
+
for (const code of ['TIMEOUT', 'SESSION_REQUIRED']) {
|
|
253
|
+
const f = executionFixture(), session = f.session
|
|
254
|
+
session.leaseMs = 6000
|
|
255
|
+
const scope = { sessionId: session.sessionId, generation: session.sessionGeneration, keepaliveGeneration: session.keepaliveGeneration }
|
|
256
|
+
let calls = 0
|
|
257
|
+
session.rpc = async () => { calls++; throw Object.assign(new Error(code), { code }) }
|
|
258
|
+
await assert.rejects(session._keepaliveOnce(scope), { code })
|
|
259
|
+
assert.equal(calls, 1)
|
|
260
|
+
session.close()
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('soft reset total deadline also bounds a stalled REPL barrier', async () => {
|
|
265
|
+
const fixture = executionFixture()
|
|
266
|
+
fixture.session.repl.execPy = () => new Promise(() => {})
|
|
267
|
+
const started = Date.now()
|
|
268
|
+
try {
|
|
269
|
+
await assert.rejects(fixture.session.control('soft-reset', { timeout: 40 }), { code: 'VM_RESET_TIMEOUT' })
|
|
270
|
+
assert.ok(Date.now() - started < 500, 'must not inherit the separate five-second barrier budget')
|
|
271
|
+
} finally { fixture.session.close() }
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('soft reset cancellation reaches the REPL barrier', async () => {
|
|
275
|
+
const fixture = executionFixture()
|
|
276
|
+
const controller = new AbortController()
|
|
277
|
+
fixture.session.repl.execPy = (_source, { signal }) => {
|
|
278
|
+
assert.ok(signal)
|
|
279
|
+
queueMicrotask(() => controller.abort())
|
|
280
|
+
return new Promise(() => {})
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await assert.rejects(fixture.session.control('soft-reset', { signal: controller.signal }), { name: 'AbortError' })
|
|
284
|
+
} finally { fixture.session.close() }
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('soft reset deadline includes a stalled initial transport write', async () => {
|
|
288
|
+
const fixture = executionFixture()
|
|
289
|
+
fixture.session.transport.writeBytes = () => new Promise(() => {})
|
|
290
|
+
try {
|
|
291
|
+
await assert.rejects(fixture.session.control('soft-reset', { timeout: 30 }), { code: 'VM_RESET_TIMEOUT' })
|
|
292
|
+
assert.equal(fixture.requests.length, 0)
|
|
293
|
+
} finally { fixture.session.close() }
|
|
294
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import { createFilesApi } from '../src/device_files.js'
|
|
5
|
+
import { EvRepl, evReplExecInputBytes } from '../src/evrepl.js'
|
|
6
|
+
|
|
7
|
+
function fixture() {
|
|
8
|
+
const calls = [], state = {}
|
|
9
|
+
const session = {
|
|
10
|
+
repl: {
|
|
11
|
+
listFs: async (path, options) => {
|
|
12
|
+
calls.push(['listFs', path, options])
|
|
13
|
+
return [{ name: 'a', path: `${path === '/' ? '' : path}/a`, type: 'file', size: 10 }]
|
|
14
|
+
},
|
|
15
|
+
readFile: async (...args) => { calls.push(['read', ...args]); return new TextEncoder().encode('你好') },
|
|
16
|
+
removeFile: async (...args) => calls.push(['removeFile', ...args]),
|
|
17
|
+
removeDir: async (...args) => calls.push(['removeDir', ...args]),
|
|
18
|
+
execPy: async (...args) => { calls.push(['execPy', ...args]); return '__EVMK__ 0\n' },
|
|
19
|
+
},
|
|
20
|
+
writeFile: async (...args) => { calls.push(['write', ...args]); return { ok: true } },
|
|
21
|
+
}
|
|
22
|
+
return { files: createFilesApi({ session, state }), session, calls, state }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('file upload selects encoding and creation policy from public parameters', async () => {
|
|
26
|
+
const { files, calls } = fixture()
|
|
27
|
+
const onProgress = () => {}, signal = new AbortController().signal
|
|
28
|
+
await files.write({ path: '/model.bin', data: new Uint8Array([0, 255]), overwrite: false, onProgress, signal })
|
|
29
|
+
assert.deepEqual(calls[0], ['write', '/model.bin', new Uint8Array([0, 255]), { encoding: 'binary', mode: 'create', onProgress, signal }])
|
|
30
|
+
await files.write({ path: '/main.py', data: 'print(1)' })
|
|
31
|
+
assert.equal(calls[1][3].encoding, 'utf-8')
|
|
32
|
+
assert.equal(calls[1][3].mode, 'overwrite')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('file read is binary by default with explicit UTF-8 decoding and bounded options', async () => {
|
|
36
|
+
const { files, calls } = fixture()
|
|
37
|
+
assert.ok(await files.read({ path: '/model.bin' }) instanceof Uint8Array)
|
|
38
|
+
assert.equal(await files.read({ path: '/a.txt', encoding: 'utf-8', maxBytes: 512 }), '你好')
|
|
39
|
+
assert.equal(calls[1][2].maxBytes, 512)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('file listing passes scoped path and recursion without walking the entire device', async () => {
|
|
43
|
+
const { files, calls } = fixture()
|
|
44
|
+
assert.deepEqual(await files.list({ path: '/folder' }), [{ name: 'a', path: '/folder/a', type: 'file', size: 10 }])
|
|
45
|
+
assert.deepEqual(calls[0], ['listFs', '/folder', { recursive: false, signal: undefined }])
|
|
46
|
+
await files.list({ recursive: true })
|
|
47
|
+
assert.equal(calls[1][2].recursive, true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('REPL directory listing confines recursion to selected subtree and validates missing directories', async () => {
|
|
51
|
+
const repl = new EvRepl({ writeEvmux() {} })
|
|
52
|
+
const seen = []
|
|
53
|
+
repl.execPy = async source => {
|
|
54
|
+
const path = /p=("[^"\n]*")/.exec(source)[1]
|
|
55
|
+
seen.push(JSON.parse(path))
|
|
56
|
+
return '__EVLIST__' + JSON.stringify(seen.length === 1 ? [['deep', 0x4000, 0], ['a.bin', 0x8000, 12]] : [['b.bin', 0x8000, 20]])
|
|
57
|
+
}
|
|
58
|
+
assert.equal((await repl.listFs('/models')).length, 2)
|
|
59
|
+
assert.deepEqual(seen, ['/models'])
|
|
60
|
+
seen.length = 0
|
|
61
|
+
const entries = await repl.listFs('/models', { recursive: true })
|
|
62
|
+
assert.deepEqual(seen, ['/models', '/models/deep'])
|
|
63
|
+
assert.equal(entries[2].path, '/models/deep/b.bin')
|
|
64
|
+
repl.execPy = async () => '__EVLIST__2'
|
|
65
|
+
await assert.rejects(repl.listFs('/missing'), { code: 'NOT_FOUND', errno: 2 })
|
|
66
|
+
repl.execPy = async () => '__EVLIST__20'
|
|
67
|
+
await assert.rejects(repl.listFs('/models/a.bin'), { code: 'NOT_DIRECTORY' })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('recursive listing cancellation prevents traversal of the next directory', async () => {
|
|
71
|
+
const repl = new EvRepl({ writeEvmux() {} })
|
|
72
|
+
const controller = new AbortController()
|
|
73
|
+
let calls = 0
|
|
74
|
+
repl.execPy = async (_source, { signal }) => {
|
|
75
|
+
assert.equal(signal, controller.signal)
|
|
76
|
+
calls++
|
|
77
|
+
controller.abort()
|
|
78
|
+
return '__EVLIST__[["deep",16384,0]]'
|
|
79
|
+
}
|
|
80
|
+
await assert.rejects(repl.listFs('/models', { recursive: true, signal: controller.signal }), { name: 'AbortError' })
|
|
81
|
+
assert.equal(calls, 1)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('delete explicitly distinguishes file and directory and forwards cancellation', async () => {
|
|
85
|
+
const { files, calls } = fixture()
|
|
86
|
+
const signal = new AbortController().signal
|
|
87
|
+
await files.remove({ path: '/a', signal })
|
|
88
|
+
await files.remove({ path: '/b', type: 'directory', signal })
|
|
89
|
+
assert.deepEqual(calls, [['removeFile', '/a', { signal }], ['removeDir', '/b', { signal }]])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('directory creation honors recursive mode and surfaces filesystem errors', async () => {
|
|
93
|
+
const { files, calls, session } = fixture()
|
|
94
|
+
await files.mkdir({ path: '/a/b', recursive: true })
|
|
95
|
+
assert.equal(calls.length, 2)
|
|
96
|
+
assert.match(calls[0][1], /p="\/a"/)
|
|
97
|
+
assert.match(calls[1][1], /p="\/a\/b"/)
|
|
98
|
+
assert.match(calls[0][1], /os.stat\(p\)\[0\]&16384/)
|
|
99
|
+
session.repl.execPy = async () => '__EVMK__ 28'
|
|
100
|
+
await assert.rejects(files.mkdir({ path: '/a' }), { code: 'FILESYSTEM_ERROR', errno: 28 })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('rejects dangerous paths, unsupported data, mode confusion and pre-cancellation before writes', async () => {
|
|
104
|
+
const { files, calls } = fixture()
|
|
105
|
+
for (const path of ['relative', '/', '/a/../b', '/a//b', '/a\0b', '/a"b', '/a\\b', '/' + '中'.repeat(42)]) {
|
|
106
|
+
await assert.rejects(files.write({ path, data: 'x' }), { code: 'INVALID_PATH' })
|
|
107
|
+
}
|
|
108
|
+
for (const data of [null, {}, [], 12]) await assert.rejects(files.write({ path: '/a', data }), TypeError)
|
|
109
|
+
await assert.rejects(files.write({ path: '/a', data: '', overwrite: 'yes' }), TypeError)
|
|
110
|
+
await assert.rejects(files.write({ path: '/a', data: '', runAfterWrite: true }), TypeError)
|
|
111
|
+
await assert.rejects(files.write({ path: '/a', data: '', signal: AbortSignal.abort() }), { name: 'AbortError' })
|
|
112
|
+
assert.equal(calls.length, 0)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('shared lock excludes concurrent filesystem work and releases after failure', async () => {
|
|
116
|
+
const { files, session, state } = fixture()
|
|
117
|
+
let fail
|
|
118
|
+
session.writeFile = async () => new Promise((_resolve, reject) => { fail = reject })
|
|
119
|
+
const writing = files.write({ path: '/a', data: 'x' })
|
|
120
|
+
await assert.rejects(files.read({ path: '/a' }), { code: 'DEVICE_BUSY' })
|
|
121
|
+
fail(new Error('disk full'))
|
|
122
|
+
await assert.rejects(writing, /disk full/)
|
|
123
|
+
assert.equal(state.operation, null)
|
|
124
|
+
await files.read({ path: '/a' })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('every file operation rejects unknown fields and invalid signals before device I/O', async () => {
|
|
128
|
+
const { files, calls } = fixture()
|
|
129
|
+
for (const [method, options] of [
|
|
130
|
+
['list', { path: '/' }], ['read', { path: '/a' }], ['write', { path: '/a', data: 'x' }],
|
|
131
|
+
['remove', { path: '/a' }], ['mkdir', { path: '/a' }],
|
|
132
|
+
]) {
|
|
133
|
+
await assert.rejects(files[method]({ ...options, typo: true }), /Unknown files\./)
|
|
134
|
+
await assert.rejects(files[method]({ ...options, signal: {} }), TypeError)
|
|
135
|
+
}
|
|
136
|
+
for (const maxBytes of [-1, 0.5, '1', 16 * 1024 * 1024 + 1]) await assert.rejects(files.read({ path: '/a', maxBytes }), RangeError)
|
|
137
|
+
assert.equal(calls.length, 0)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('file paths and caller byte buffers are snapshotted before asynchronous writing', async () => {
|
|
141
|
+
const { files, session } = fixture()
|
|
142
|
+
let release, observed, reads = 0
|
|
143
|
+
session.writeFile = async (path, data) => {
|
|
144
|
+
await new Promise(resolve => { release = resolve })
|
|
145
|
+
observed = { path, bytes: Array.from(data) }
|
|
146
|
+
}
|
|
147
|
+
// Buffer.slice() shares backing memory; the API must copy all Uint8Array subclasses.
|
|
148
|
+
const data = Buffer.from([0, 127, 255])
|
|
149
|
+
const request = { get path() { reads++; return reads === 1 ? '/safe.bin' : '/other.bin' }, data }
|
|
150
|
+
const writing = files.write(request)
|
|
151
|
+
data.fill(1)
|
|
152
|
+
release()
|
|
153
|
+
await writing
|
|
154
|
+
assert.equal(reads, 1)
|
|
155
|
+
assert.deepEqual(observed, { path: '/safe.bin', bytes: [0, 127, 255] })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
test('recursive mkdir keeps longest legal Unicode paths inside the real REPL input budget', async () => {
|
|
160
|
+
const { files, session } = fixture()
|
|
161
|
+
const commands = []
|
|
162
|
+
session.repl.execPy = async source => {
|
|
163
|
+
commands.push(source)
|
|
164
|
+
assert.ok(evReplExecInputBytes(source) <= 511, `REPL input is ${evReplExecInputBytes(source)} bytes`)
|
|
165
|
+
return '__EVMK__ 0'
|
|
166
|
+
}
|
|
167
|
+
await files.mkdir({ path: '/' + '中'.repeat(40), recursive: true })
|
|
168
|
+
await files.mkdir({ path: '/' + 'a'.repeat(59) + '/' + '中'.repeat(20), recursive: true })
|
|
169
|
+
assert.equal(commands.length, 3)
|
|
170
|
+
await files.mkdir({ path: '/' + 'é'.repeat(61), recursive: true })
|
|
171
|
+
assert.equal(commands.length, 5, 'two-byte Unicode paths split assignment from the fixed mkdir command')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('recursive mkdir checks existing entry type and preserves errors for files', async () => {
|
|
175
|
+
const { files, session } = fixture()
|
|
176
|
+
const commands = []
|
|
177
|
+
session.repl.execPy = async source => { commands.push(source); return '__EVMK__ 0' }
|
|
178
|
+
await files.mkdir({ path: '/existing/child', recursive: true })
|
|
179
|
+
assert.match(commands[0], /if r==17:/)
|
|
180
|
+
assert.match(commands[0], /0 if os.stat\(p\)\[0\]&16384 else 20/)
|
|
181
|
+
session.repl.execPy = async () => '__EVMK__ 20'
|
|
182
|
+
await assert.rejects(files.mkdir({ path: '/existing-file/child', recursive: true }), { code: 'FILESYSTEM_ERROR', errno: 20 })
|
|
183
|
+
session.repl.execPy = async source => {
|
|
184
|
+
assert.ok(!source.includes('if r==17:'))
|
|
185
|
+
return '__EVMK__ 17'
|
|
186
|
+
}
|
|
187
|
+
await assert.rejects(files.mkdir({ path: '/already-exists' }), { code: 'FILESYSTEM_ERROR', errno: 17 })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('recursive mkdir cancellation prevents creating the next path segment', async () => {
|
|
191
|
+
const { files, session } = fixture()
|
|
192
|
+
const controller = new AbortController()
|
|
193
|
+
let calls = 0
|
|
194
|
+
session.repl.execPy = async (_source, { signal }) => {
|
|
195
|
+
assert.equal(signal, controller.signal)
|
|
196
|
+
calls++
|
|
197
|
+
controller.abort()
|
|
198
|
+
return '__EVMK__ 0'
|
|
199
|
+
}
|
|
200
|
+
await assert.rejects(files.mkdir({ path: '/parent/child', recursive: true, signal: controller.signal }), { name: 'AbortError' })
|
|
201
|
+
assert.equal(calls, 1)
|
|
202
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/* SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
|
|
2
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
*/
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { createDevice } from '../src/index.js'
|
|
7
|
+
import { firmwareSha256 } from '../src/firmware_flash.js'
|
|
8
|
+
|
|
9
|
+
async function fixture() {
|
|
10
|
+
const data = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5]), calls = []
|
|
11
|
+
const release = { board: 'BOARD', version: '2026.10.15', image: { evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, offset: 65536, bytes: 4, partitionSize: 4096, sha256: await firmwareSha256(data), partitionTable: { offset: 32768, bytes: 2, sha256: await firmwareSha256(table) }, url: 'https://example.test/app.bin' } }
|
|
12
|
+
const stub = { async readFlash(offset) { return offset === 32768 ? table : data }, async readRegister() { return 0 }, async flashData() { calls.push('write') } }
|
|
13
|
+
const loader = { chipFamily: 0x3280, chipRevision: 302, async initialize() { calls.push('initialize') }, async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } }, async runStub() { return stub }, async hardReset() { calls.push('reset') }, async disconnect() { calls.push('close') } }
|
|
14
|
+
const device = createDevice(), request = { release, board: 'BOARD', data, async openLoader() { calls.push('open'); return loader } }
|
|
15
|
+
return { device, release, request, data, calls, loader, stub }
|
|
16
|
+
}
|
|
17
|
+
test('firmware check works offline and preserves catalog order for calendar/git versions', async () => {
|
|
18
|
+
const f = await fixture(), identity = { board: 'BOARD', firmwareVersion: '2026.08.27-18-gabc' }
|
|
19
|
+
const catalog = { schemaVersion: 1, releases: [f.release, { ...f.release, version: '0.9' }] }
|
|
20
|
+
const result = await f.device.firmware.check({ catalog, identity })
|
|
21
|
+
assert.equal(result.available, true); assert.equal(result.release.version, '2026.10.15')
|
|
22
|
+
result.release.image.sha256 = 'x'; assert.notEqual(catalog.releases[0].image.sha256, 'x')
|
|
23
|
+
assert.deepEqual(f.calls, [])
|
|
24
|
+
})
|
|
25
|
+
test('firmware check distinguishes current firmware and unavailable board', async () => {
|
|
26
|
+
const f = await fixture(), catalog = { schemaVersion: 1, releases: [f.release] }
|
|
27
|
+
const current = await f.device.firmware.check({ catalog, identity: { board: 'BOARD', firmwareVersion: f.release.version } })
|
|
28
|
+
assert.equal(current.available, false); assert.ok(current.release)
|
|
29
|
+
const missing = await f.device.firmware.check({ catalog, identity: { board: 'OTHER', firmwareVersion: 'v1' } })
|
|
30
|
+
assert.equal(missing.available, false); assert.equal(missing.release, null)
|
|
31
|
+
})
|
|
32
|
+
test('firmware catalog download propagates cancellation and HTTP errors without connecting', async () => {
|
|
33
|
+
const f = await fixture(), controller = new AbortController()
|
|
34
|
+
const request = { catalog: 'https://example.test/releases.json', identity: { board: 'BOARD', firmwareVersion: 'v1' }, signal: controller.signal,
|
|
35
|
+
async fetch(url, options) { assert.equal(options.signal, controller.signal); assert.equal(options.cache, 'no-store'); return { ok: false, status: 503 } } }
|
|
36
|
+
await assert.rejects(f.device.firmware.check(request), { code: 'FIRMWARE_DOWNLOAD_FAILED' })
|
|
37
|
+
controller.abort(); await assert.rejects(f.device.firmware.check(request), { name: 'AbortError' })
|
|
38
|
+
assert.deepEqual(f.calls, [])
|
|
39
|
+
})
|
|
40
|
+
test('invalid catalog, wrong protocol and unknown arguments are rejected', async () => {
|
|
41
|
+
const f = await fixture(), identity = { board: 'BOARD', firmwareVersion: 'v1' }
|
|
42
|
+
for (const catalog of [{}, { schemaVersion: 1, releases: [{ ...f.release, image: { ...f.release.image, evMuxVersion: 3 } }] }]) await assert.rejects(f.device.firmware.check({ catalog, identity }), TypeError)
|
|
43
|
+
await assert.rejects(f.device.firmware.check({ catalog: 'http://example.test/releases.json', identity }), /HTTPS/)
|
|
44
|
+
await assert.rejects(f.device.firmware.check({ identity, typo: true }), /Unknown/)
|
|
45
|
+
})
|
|
46
|
+
test('firmware update verifies image and physical board before opening ROM', async () => {
|
|
47
|
+
const f = await fixture()
|
|
48
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, board: 'OTHER' }), /board/)
|
|
49
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, data: new Uint8Array(4) }), /SHA-256/)
|
|
50
|
+
assert.deepEqual(f.calls, [])
|
|
51
|
+
})
|
|
52
|
+
test('firmware update uses application-only verified ROM flow and returns explicit reconnect state', async () => {
|
|
53
|
+
const f = await fixture(), result = await f.device.firmware.update(f.request)
|
|
54
|
+
assert.deepEqual(result, { verified: true, needsReset: false, board: 'BOARD', version: f.release.version, reconnected: false })
|
|
55
|
+
assert.deepEqual(f.calls, ['open', 'initialize', 'write', 'reset', 'close'])
|
|
56
|
+
})
|
|
57
|
+
test('firmware update downloads raw application image through injected fetch', async () => {
|
|
58
|
+
const f = await fixture()
|
|
59
|
+
await f.device.firmware.update({ ...f.request, data: undefined, async fetch(url) { assert.equal(url, f.release.image.url); return { ok: true, async arrayBuffer() { return f.data.buffer } } } })
|
|
60
|
+
assert.ok(f.calls.includes('write'))
|
|
61
|
+
})
|
|
62
|
+
test('cancel after loader acquisition closes port without writing', async () => {
|
|
63
|
+
const f = await fixture(), controller = new AbortController()
|
|
64
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, signal: controller.signal, async openLoader() { controller.abort(); return f.loader } }), { name: 'AbortError' })
|
|
65
|
+
assert.deepEqual(f.calls, ['close'])
|
|
66
|
+
})
|
|
67
|
+
test('active update excludes connect and concurrent update; failures release the reservation', async () => {
|
|
68
|
+
const f = await fixture(); let unblock
|
|
69
|
+
const waiting = new Promise(resolve => { unblock = resolve })
|
|
70
|
+
const active = f.device.firmware.update({ ...f.request, async openLoader() { await waiting; throw new Error('port unavailable') } })
|
|
71
|
+
await assert.rejects(f.device.connect(), { code: 'DEVICE_BUSY' })
|
|
72
|
+
await assert.rejects(f.device.firmware.update(f.request), { code: 'DEVICE_BUSY' })
|
|
73
|
+
unblock(); await assert.rejects(active, /port unavailable/)
|
|
74
|
+
assert.equal((await f.device.firmware.update(f.request)).verified, true)
|
|
75
|
+
})
|
|
76
|
+
test('failed readback never reports verified or resets the device', async () => {
|
|
77
|
+
const f = await fixture(); f.stub.readFlash = async offset => offset === 32768 ? new Uint8Array([4, 5]) : new Uint8Array(4)
|
|
78
|
+
await assert.rejects(f.device.firmware.update(f.request), /verification failed/)
|
|
79
|
+
assert.ok(!f.calls.includes('reset')); assert.equal(f.calls.at(-1), 'close')
|
|
80
|
+
})
|
|
81
|
+
test('reset failure returns verified with explicit manual reset requirement', async () => {
|
|
82
|
+
const f = await fixture(); f.loader.hardReset = async () => { throw new Error('reset unavailable') }
|
|
83
|
+
assert.equal((await f.device.firmware.update(f.request)).needsReset, true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('firmware check lists all board versions and selects an explicit older version', async () => {
|
|
87
|
+
const f = await fixture(), older = { ...f.release, version: '2026.08.27' }
|
|
88
|
+
const request = { catalog: { schemaVersion: 1, releases: [f.release, { ...f.release, board: 'OTHER' }, older] }, identity: { board: 'BOARD', firmwareVersion: f.release.version } }
|
|
89
|
+
const history = await f.device.firmware.check(request)
|
|
90
|
+
assert.deepEqual(history.releases.map(item => item.version), [f.release.version, older.version])
|
|
91
|
+
assert.equal(history.available, false)
|
|
92
|
+
const selected = await f.device.firmware.check({ ...request, version: older.version })
|
|
93
|
+
assert.equal(selected.available, true); assert.equal(selected.release.version, older.version)
|
|
94
|
+
assert.equal((await f.device.firmware.update({ ...f.request, release: selected.release })).version, older.version)
|
|
95
|
+
})
|
|
96
|
+
test('an unavailable or ambiguous requested version never falls back to the latest build', async () => {
|
|
97
|
+
const f = await fixture(), request = { catalog: { schemaVersion: 1, releases: [f.release] }, identity: { board: 'BOARD', firmwareVersion: 'old' } }
|
|
98
|
+
await assert.rejects(f.device.firmware.check({ ...request, version: 'missing' }), { code: 'FIRMWARE_VERSION_UNAVAILABLE' })
|
|
99
|
+
for (const version of ['', 4, null]) await assert.rejects(f.device.firmware.check({ ...request, version }), /version must/)
|
|
100
|
+
request.catalog.releases.push({ ...f.release })
|
|
101
|
+
await assert.rejects(f.device.firmware.check(request), /duplicate versions/)
|
|
102
|
+
assert.deepEqual(f.calls, [])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('default update check reads official dl config and lists legacy releases without enabling unqualified writes', async () => {
|
|
106
|
+
const f = await fixture(), urls = []
|
|
107
|
+
const result = await f.device.firmware.check({ identity: { board: 'BOARD', firmwareVersion: 'old' }, async fetch(url) {
|
|
108
|
+
urls.push(url)
|
|
109
|
+
if (url.endsWith('config.toml')) return { ok: true, text: async () => 'firmware_images_url = "https://dl.espressif.com/AE/esp-vision/bin/"\n[esp-vision.image]\nBOARD = "latest.bin"\n[website]\nrelease = "new"\nreleases = ["new", "old"]\nbinaryNameTemplate = "esp-vision-{board}-{tag}.bin"\n' }
|
|
110
|
+
if (url.endsWith('/new.json')) return { ok: true, json: async () => ({ ...f.release, version: 'new', image: { ...f.release.image, url: 'https://dl.espressif.com/AE/esp-vision/bin/new-app.bin' } }) }
|
|
111
|
+
return { ok: false, status: 404 }
|
|
112
|
+
} })
|
|
113
|
+
assert.equal(result.available, true); assert.equal(result.release.version, 'new')
|
|
114
|
+
assert.equal(result.releases.length, 2); assert.equal(result.releases[1].compatible, false)
|
|
115
|
+
assert.equal(urls[0], 'https://dl.espressif.com/AE/esp-vision/config.toml')
|
|
116
|
+
})
|
|
117
|
+
test('unqualified historical version remains visible but is never selected for flashing', async () => {
|
|
118
|
+
const f = await fixture(), request = { identity: { board: 'BOARD', firmwareVersion: 'x' }, catalog: { schemaVersion: 1, releases: [{ board: 'BOARD', version: 'old', compatible: false, reason: 'UPDATE_METADATA_UNAVAILABLE' }] } }
|
|
119
|
+
const result = await f.device.firmware.check(request)
|
|
120
|
+
assert.equal(result.available, false); assert.equal(result.release, null); assert.equal(result.releases.length, 1)
|
|
121
|
+
await assert.rejects(f.device.firmware.check({ ...request, version: 'old' }), { code: 'FIRMWARE_INCOMPATIBLE' })
|
|
122
|
+
await assert.rejects(f.device.firmware.update({ ...f.request, release: result.releases[0] }), /metadata/)
|
|
123
|
+
assert.deepEqual(f.calls, [])
|
|
124
|
+
})
|
|
125
|
+
test('approved silicon revision interval is checked before any write', async () => {
|
|
126
|
+
const f = await fixture(); delete f.release.image.chipRevision
|
|
127
|
+
f.release.image.chipRevisionMin = 301; f.release.image.chipRevisionMax = 399
|
|
128
|
+
assert.equal((await f.device.firmware.update(f.request)).verified, true)
|
|
129
|
+
const bad = await fixture(); delete bad.release.image.chipRevision
|
|
130
|
+
bad.release.image.chipRevisionMin = 303; bad.release.image.chipRevisionMax = 399
|
|
131
|
+
await assert.rejects(bad.device.firmware.update(bad.request), /revision/)
|
|
132
|
+
assert.ok(!bad.calls.includes('write'))
|
|
133
|
+
})
|