@esp-vision/device-core 0.1.1 → 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 +10 -0
- package/README.md +4 -4
- package/package.json +1 -1
- package/schemas/device-actions-v1.json +2 -2
- package/scripts/release-npm.mjs +4 -3
- 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/test-device-execution.mjs +13 -3
- package/tests/test-file-binary.mjs +112 -0
- package/tests/test-release-verification.mjs +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- File and large-script uploads use one reliable binary `file.write` engine with 16 KiB blocks, integrity validation and cancellation cleanup; no Base64/JSON or REPL upload fallback. Public `files.write` and Python API signatures stay unchanged. Uploads require matching firmware capability `file.write.binary`; older v4 devices can still be inspected but must update firmware before upload.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Verify newly published tarballs using bounded uncached registry reads instead of a single immediate npm view; retry transient metadata propagation/network failures without repeating publication, while failing immediately on access denial or integrity mismatch.
|
|
12
|
+
|
|
3
13
|
## 0.1.1
|
|
4
14
|
|
|
5
15
|
### Fixed
|
package/README.md
CHANGED
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
| Visibility | Public; installation needs no npm login |
|
|
20
20
|
| Source location | `host/device-core` in the ESP-VISION firmware repository |
|
|
21
21
|
|
|
22
|
-
The `/node` export and bundled checks
|
|
22
|
+
The `/node` export and bundled checks were introduced in 0.1.1. This source prepares 0.2.0 with reliable binary uploads; confirm registry publication before installing it. CI authenticates with the protected `NPM_PUBLISH_TOKEN` and verifies that `npm whoami` returns `esp-vision` before publishing. Tokens are not part of this package or its documentation.
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
npm install --save-exact @esp-vision/device-core@0.
|
|
25
|
+
npm install --save-exact @esp-vision/device-core@0.2.0 --registry=https://registry.npmjs.org/
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
EV-MUX v4 firmware is required. A v3 hello is rejected with `VERSION_MISMATCH`; applications should ask the user to update firmware. Core has no runtime dependency on the IDE, Agent, DOM or Node serial libraries. The host supplies the physical byte transport and discovery adapter; Core owns protocol parsing, session negotiation, REPL coordination and device operations.
|
|
@@ -41,7 +41,7 @@ Documentation source lives in this repository under `docs/en/device-core/` and `
|
|
|
41
41
|
The npm package installs `esp-vision-device`. `inspect` combines passive wired/LAN enumeration and targeted queries. By default it lists USB serial candidates; `--all-ports` also includes native UART/Bluetooth ports. Explicit `--port` bypasses discovery filtering; `configure`, `control`, and `firmware check/update` cover management. See the [CLI guide](https://docs.espressif.com/projects/esp-vision/en/latest/esp32p4/device-core/cli.html) ([中文](https://docs.espressif.com/projects/esp-vision/zh_CN/latest/esp32p4/device-core/cli.html)).
|
|
42
42
|
|
|
43
43
|
```sh
|
|
44
|
-
npm install -g @esp-vision/device-core@0.
|
|
44
|
+
npm install -g @esp-vision/device-core@0.2.0
|
|
45
45
|
esp-vision-device inspect
|
|
46
46
|
esp-vision-device inspect --all-ports
|
|
47
47
|
esp-vision-device inspect --interface usj,usb --probe --json
|
|
@@ -220,7 +220,7 @@ const image = await device.capture({ source: 'camera', format: 'jpeg', timeout:
|
|
|
220
220
|
|
|
221
221
|
Paths are absolute device paths. `files.list({ path: '/', recursive: false })` returns entries `{ name, path, type, size }`. `files.read({ path })` returns a `Uint8Array`; use `encoding: 'utf-8'` to return text, and `maxBytes` to bound a read. Writes default to `overwrite: true`; set it to `false` to require a new destination. Uploading and executing are separate operations; create an empty file with `files.write({ path, data: new Uint8Array() })` rather than a second touch API. Remove an empty directory with `files.remove({ path: '/models', type: 'directory' })`; there is no implicit recursive deletion. `files.mkdir({ path, recursive: true })` creates missing parents.
|
|
222
222
|
|
|
223
|
-
**
|
|
223
|
+
**Binary uploads (SDK 0.2.0):** text, model and image uploads share reliable raw binary `file.write` frames with 16 KiB blocks, continuous native VFS writes and SHA-256 commit. Matching firmware must advertise `file.write.binary`; missing support rejects with a firmware-upgrade message, without a slow upload fallback. File progress is not a durable commit. FAT replacement preserves an old-file backup until ordinary publication succeeds, with rollback on ordinary rename failure; it is not a power-loss-atomic journal. The filesystem must be mounted and allow writing, with room for the temporary upload and any existing destination file. File reads and directory operations currently use the REPL.
|
|
224
224
|
|
|
225
225
|
`capture({ source: 'screen', format: 'jpeg' })` uses the dedicated `debug.capture_screen` RPC to encode the LCD's last submitted RGB565 framebuffer, including the display's scaling and letterboxing. It does not read the panel electronics or capture the camera. Camera capture uses `debug.capture_frame`. Both return binary JPEG responses correlated to the request; optional `quality` is an integer from 1 to 100. Missing firmware capabilities reject with `UNSUPPORTED_OPERATION`; an uninitialized display returns `DISPLAY_NOT_READY`, and a board without an LCD returns `NOT_SUPPORTED`. Preview is a separate real-time stream and may drop frames under congestion.
|
|
226
226
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"title": "ESP Vision Device Actions",
|
|
4
|
-
"deviceCoreVersion": "0.
|
|
4
|
+
"deviceCoreVersion": "0.2.0",
|
|
5
5
|
"schemaVersion": 1,
|
|
6
6
|
"actions": [
|
|
7
7
|
{
|
|
@@ -169,7 +169,7 @@
|
|
|
169
169
|
},
|
|
170
170
|
{
|
|
171
171
|
"name": "python.execute",
|
|
172
|
-
"description": "Execute MicroPython through the shared interpreter using bounded REPL or
|
|
172
|
+
"description": "Execute MicroPython through the shared interpreter using bounded REPL or binary file staging.",
|
|
173
173
|
"inputSchema": {
|
|
174
174
|
"type": "object",
|
|
175
175
|
"properties": {
|
package/scripts/release-npm.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 ESP-VISION-IDE
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import assert from 'node:assert/strict'
|
|
4
|
+
import { verifyPublishedIntegrity } from './verify-registry.mjs'
|
|
4
5
|
import { spawnSync } from 'node:child_process'
|
|
5
6
|
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
|
|
6
7
|
import { tmpdir } from 'node:os'
|
|
@@ -48,7 +49,7 @@ try {
|
|
|
48
49
|
console.log(`Npm publisher: ${publisher}`)
|
|
49
50
|
const url = new URL(encodeURIComponent(manifest.name), registry)
|
|
50
51
|
url.pathname += '/' + encodeURIComponent(manifest.version)
|
|
51
|
-
const response = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
|
52
|
+
const response = await fetch(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(15000) })
|
|
52
53
|
if (response.ok) {
|
|
53
54
|
const existing = await response.json()
|
|
54
55
|
assert.equal(existing.dist?.integrity, packed.integrity,
|
|
@@ -57,8 +58,8 @@ try {
|
|
|
57
58
|
} else {
|
|
58
59
|
assert.equal(response.status, 404, 'Registry lookup failed; do not attempt publication')
|
|
59
60
|
npm(['publish', join(temporary, packed.filename), '--ignore-scripts', '--access', 'public', '--registry', registry])
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
console.log('npm publish accepted; verifying registry metadata')
|
|
62
|
+
await verifyPublishedIntegrity(url, packed.integrity)
|
|
62
63
|
console.log(`Published and verified ${packed.name}@${packed.version}`)
|
|
63
64
|
}
|
|
64
65
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
|
|
5
|
+
// Publication is one write. Only reads are retried while registry metadata propagates.
|
|
6
|
+
export async function verifyPublishedIntegrity(url, integrity, {
|
|
7
|
+
fetcher = globalThis.fetch,
|
|
8
|
+
wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
|
|
9
|
+
report = message => console.log(message),
|
|
10
|
+
} = {}) {
|
|
11
|
+
const attempts = 6
|
|
12
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
13
|
+
let response, transient
|
|
14
|
+
try {
|
|
15
|
+
response = await fetcher(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(15000) })
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (!(error instanceof TypeError) && !['TimeoutError', 'AbortError'].includes(error.name)) throw error
|
|
18
|
+
transient = 'network timeout or unavailable'
|
|
19
|
+
}
|
|
20
|
+
if (response?.ok) {
|
|
21
|
+
const metadata = await response.json()
|
|
22
|
+
assert.equal(metadata.dist?.integrity, integrity, 'Published tarball integrity mismatch')
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
if (response) {
|
|
26
|
+
const retryable = response.status === 404 || response.status === 429 || response.status >= 500 && response.status <= 599
|
|
27
|
+
await response.body?.cancel()
|
|
28
|
+
assert.ok(retryable, `Registry verification failed: HTTP ${response.status}`)
|
|
29
|
+
transient = `HTTP ${response.status}`
|
|
30
|
+
}
|
|
31
|
+
assert.ok(attempt + 1 < attempts, `Published metadata verification unavailable after ${attempts} reads (${transient}); retry the CI job, not publication manually`)
|
|
32
|
+
const delay = 1000 * 2 ** attempt
|
|
33
|
+
report(`Registry verification ${transient}; retrying read ${attempt + 2}/${attempts} in ${delay}ms`)
|
|
34
|
+
await wait(delay)
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/device_actions.js
CHANGED
|
@@ -74,7 +74,7 @@ export const DEVICE_ACTION_DESCRIPTORS = Object.freeze([
|
|
|
74
74
|
includeFiles: { type: 'boolean', default: true },
|
|
75
75
|
logLimit: { type: 'integer', minimum: 0, maximum: 500, default: 100 },
|
|
76
76
|
}), { readOnly: true, concurrency: 'shared-read' }),
|
|
77
|
-
action('python.execute', 'Execute MicroPython through the shared interpreter using bounded REPL or
|
|
77
|
+
action('python.execute', 'Execute MicroPython through the shared interpreter using bounded REPL or binary file staging.', objectSchema({
|
|
78
78
|
code: { type: 'string' },
|
|
79
79
|
timeoutMs: { type: 'integer', minimum: 100, maximum: 120000, default: 8000 },
|
|
80
80
|
}, ['code']), { destructive: true, openWorld: true }),
|
package/src/device_bindings.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { EV_REPL_FILE_CHUNK_BYTES } from './evrepl.js'
|
|
7
6
|
|
|
8
7
|
export const EPHEMERAL_PYTHON_PATH = '/.espvision_agent_run.py'
|
|
9
8
|
|
|
@@ -306,17 +305,9 @@ export function bindSessionDeviceActions(registry, {
|
|
|
306
305
|
registry.bind('deviceFile.write', async (input, { session, signal }) => {
|
|
307
306
|
const path = validateDevicePath(input.path)
|
|
308
307
|
const bytes = input.encoding === 'base64' ? base64ToBytes(input.content) : null
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
} else {
|
|
313
|
-
await session.repl.writeFile(path, bytes, EV_REPL_FILE_CHUNK_BYTES, false, { signal })
|
|
314
|
-
}
|
|
315
|
-
} else if (session.hasFeature('script.write')) {
|
|
316
|
-
await session.writeScript(path, input.content, { runAfterWrite: input.runAfterWrite, signal })
|
|
317
|
-
} else {
|
|
318
|
-
await session.repl.writeFile(path, input.content, EV_REPL_FILE_CHUNK_BYTES, false, { signal })
|
|
319
|
-
}
|
|
308
|
+
await session.writeFile(path, bytes ?? input.content, {
|
|
309
|
+
encoding: bytes ? 'binary' : 'utf-8', runAfterWrite: input.runAfterWrite, signal,
|
|
310
|
+
})
|
|
320
311
|
return { ok: true, path, bytes: bytes?.length ?? new TextEncoder().encode(input.content).length }
|
|
321
312
|
})
|
|
322
313
|
registry.bind('deviceFile.remove', async (input, { session, signal }) => {
|
package/src/device_session.js
CHANGED
|
@@ -47,6 +47,7 @@ const EV_MUX_USER_RPC_METHODS = new Set([
|
|
|
47
47
|
'capabilities',
|
|
48
48
|
'session.keepalive',
|
|
49
49
|
'session.release',
|
|
50
|
+
'file.write',
|
|
50
51
|
'script.write',
|
|
51
52
|
'script.run',
|
|
52
53
|
'device.control',
|
|
@@ -146,7 +147,7 @@ function isNetworkAction(method, payload) {
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
function isVmRpc(method, payload) {
|
|
149
|
-
if (method === 'script.write' || method === 'script.run' || method === 'debug.capture_frame' || method === 'debug.capture_screen') return true
|
|
150
|
+
if (method === 'file.write' || method === 'script.write' || method === 'script.run' || method === 'debug.capture_frame' || method === 'debug.capture_screen') return true
|
|
150
151
|
if (method === 'debug.info') return payload?.scope !== 'transport.stats'
|
|
151
152
|
return false
|
|
152
153
|
}
|
|
@@ -155,30 +156,6 @@ function payloadBytes(payload) {
|
|
|
155
156
|
return payload instanceof Uint8Array ? payload : utf8Bytes(payload)
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
function maxScriptChunkBytes({ path, encoding, mode, runAfterWrite, rxPayloadMax, totalBytes }) {
|
|
159
|
-
let low = 1
|
|
160
|
-
let high = Math.max(1, Math.min(EV_MUX_SCRIPT_CHUNK_BYTES, rxPayloadMax))
|
|
161
|
-
while (low < high) {
|
|
162
|
-
const candidate = Math.ceil((low + high) / 2)
|
|
163
|
-
const payload = {
|
|
164
|
-
path, encoding, mode, offset: Math.max(0, totalBytes - 1), totalBytes,
|
|
165
|
-
contentBase64: 'A'.repeat(Math.ceil(candidate / 3) * 4), runAfterWrite,
|
|
166
|
-
}
|
|
167
|
-
if (utf8Bytes(JSON.stringify(payload)).length <= rxPayloadMax) low = candidate
|
|
168
|
-
else high = candidate - 1
|
|
169
|
-
}
|
|
170
|
-
return low
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function bytesToBase64(bytes) {
|
|
174
|
-
let binary = ''
|
|
175
|
-
const chunkSize = 0x8000
|
|
176
|
-
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
177
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize))
|
|
178
|
-
}
|
|
179
|
-
return btoa(binary)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
159
|
function defaultSleep(ms) {
|
|
183
160
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
184
161
|
}
|
|
@@ -262,6 +239,11 @@ export function validateEvmuxCapabilities(payload) {
|
|
|
262
239
|
}
|
|
263
240
|
|
|
264
241
|
for (const key of Object.keys(EV_MUX_DEFAULT_LIMITS)) validateLimit(payload.limits, key)
|
|
242
|
+
if (payload.features.includes('file.write.binary') && (!Number.isSafeInteger(payload.limits.fileWriteChunkMax)
|
|
243
|
+
|| payload.limits.fileWriteChunkMax < 4096 || payload.limits.fileWriteChunkMax > 16384
|
|
244
|
+
|| payload.limits.fileWriteChunkMax % 4096 !== 0)) {
|
|
245
|
+
throw new Error('EV-MUX v4 capabilities are missing a valid limits.fileWriteChunkMax')
|
|
246
|
+
}
|
|
265
247
|
if (payload.session?.policy !== 'firstCome'
|
|
266
248
|
|| !Number.isSafeInteger(payload.session.leaseMs)
|
|
267
249
|
|| !Number.isSafeInteger(payload.session.keepaliveMs)
|
|
@@ -622,6 +604,7 @@ export class DeviceSession {
|
|
|
622
604
|
redactSsid = true,
|
|
623
605
|
forceNetwork = false,
|
|
624
606
|
} = {}) {
|
|
607
|
+
if (payload instanceof Uint8Array) return { binaryBytes: payload.byteLength }
|
|
625
608
|
if (!forceNetwork && !isNetworkAction(method, payload) && !isNetworkAction(method, requestPayload)) return payload
|
|
626
609
|
const secrets = [requestPayload?.ssid, requestPayload?.password]
|
|
627
610
|
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
@@ -687,6 +670,7 @@ export class DeviceSession {
|
|
|
687
670
|
seq: requestedSeq = null,
|
|
688
671
|
signal = null,
|
|
689
672
|
writeTimeout = null,
|
|
673
|
+
encoding = undefined,
|
|
690
674
|
} = {}) {
|
|
691
675
|
if (this.state === 'disconnected') {
|
|
692
676
|
throw new Error('EV-MUX is not connected')
|
|
@@ -715,7 +699,9 @@ export class DeviceSession {
|
|
|
715
699
|
&& (payloadData.length !== 1 || payloadData[0] !== 0x03)) {
|
|
716
700
|
throw new Error('EV-MUX repl.signal only accepts the single-byte Ctrl-C signal 0x03')
|
|
717
701
|
}
|
|
718
|
-
|
|
702
|
+
const payloadLimit = method === 'file.write' && encoding === 'binary' && this.hasFeature('file.write.binary')
|
|
703
|
+
? this.limits.fileWriteChunkMax + 20 : this.limits.rxPayloadMax
|
|
704
|
+
if (this.capabilities && payloadData.length > payloadLimit) {
|
|
719
705
|
throw new Error(`EV-MUX payload is ${payloadData.length} bytes; device limit is ${this.limits.rxPayloadMax}`)
|
|
720
706
|
}
|
|
721
707
|
if ((channel === 'repl.stdin' || channel === 'repl.signal') && payloadData.length > this.limits.replInputMax) {
|
|
@@ -727,7 +713,7 @@ export class DeviceSession {
|
|
|
727
713
|
seq,
|
|
728
714
|
channel,
|
|
729
715
|
type: frameType,
|
|
730
|
-
encoding: channel.endsWith('.rpc') ? 'json' : 'text',
|
|
716
|
+
encoding: encoding ?? (channel.endsWith('.rpc') ? 'json' : 'text'),
|
|
731
717
|
...(method ? { method } : {}),
|
|
732
718
|
...(needsAuthorization ? { sessionId: this.sessionId } : {}),
|
|
733
719
|
...(usesVm && needsAuthorization ? { vmEpoch: this.vmEpoch } : {}),
|
|
@@ -738,6 +724,7 @@ export class DeviceSession {
|
|
|
738
724
|
const frame = this.makeEvMuxFrame({
|
|
739
725
|
channel,
|
|
740
726
|
type: frameType,
|
|
727
|
+
encoding,
|
|
741
728
|
method,
|
|
742
729
|
payload,
|
|
743
730
|
seq,
|
|
@@ -764,6 +751,7 @@ export class DeviceSession {
|
|
|
764
751
|
throw new Error('EV-MUX is not connected')
|
|
765
752
|
}
|
|
766
753
|
if (signal?.aborted) throw abortError(signal)
|
|
754
|
+
if (payload instanceof Uint8Array && method !== 'file.write') throw new TypeError('Raw binary requests are only supported for file.write')
|
|
767
755
|
if (method === 'script.write' || method === 'script.run') validateRpcPath(payload?.path)
|
|
768
756
|
if (method === 'debug.info' && (payload?.scope === 'fs.list' || payload?.scope === 'fs.read')) {
|
|
769
757
|
validateRpcPath(payload.path ?? (payload.scope === 'fs.list' ? '/' : undefined))
|
|
@@ -816,7 +804,8 @@ export class DeviceSession {
|
|
|
816
804
|
this.handlers.rpcRequest?.({ channel: rpcChannel, method, seq, timeout, payload: safePayload })
|
|
817
805
|
this._diagnostic('rpc.request', { channel: rpcChannel, method, seq })
|
|
818
806
|
try {
|
|
819
|
-
await this.writeFrame(rpcChannel, 'req', method, JSON.stringify(payload), {
|
|
807
|
+
await this.writeFrame(rpcChannel, 'req', method, payload instanceof Uint8Array ? payload : JSON.stringify(payload), {
|
|
808
|
+
encoding: payload instanceof Uint8Array ? 'binary' : undefined,
|
|
820
809
|
vmRequest,
|
|
821
810
|
seq,
|
|
822
811
|
signal,
|
|
@@ -1089,41 +1078,45 @@ export class DeviceSession {
|
|
|
1089
1078
|
: content instanceof Uint8Array
|
|
1090
1079
|
? content
|
|
1091
1080
|
: new Uint8Array(content)
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1081
|
+
if (!this.hasFeature('file.write.binary')) {
|
|
1082
|
+
throw Object.assign(new Error('File upload requires firmware with file.write.binary; update the device firmware'), { code: 'UNSUPPORTED_OPERATION' })
|
|
1083
|
+
}
|
|
1084
|
+
const limit = this.limits.fileWriteChunkMax
|
|
1085
|
+
if (!Number.isSafeInteger(limit) || limit < 4096 || limit > 16384 || limit % 4096 !== 0 || bytes.length > 0xffffffff) {
|
|
1086
|
+
throw new Error('Invalid negotiated binary file transfer limit or file size')
|
|
1087
|
+
}
|
|
1088
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes))
|
|
1089
|
+
if (signal?.aborted) throw abortError(signal)
|
|
1090
|
+
const sha256 = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')
|
|
1091
|
+
const uploadId = randomHex()
|
|
1092
|
+
let committed = false
|
|
1093
|
+
try {
|
|
1094
|
+
const begin = await this.rpc('file.write', { action: 'begin', path, mode, totalBytes: bytes.length, sha256, uploadId }, { timeout: 8000, signal })
|
|
1095
|
+
if (begin.uploadId !== uploadId || begin.chunkBytes !== limit) throw new Error('Invalid binary file transfer acknowledgement')
|
|
1096
|
+
const token = Uint8Array.from(begin.uploadId.match(/../g), byte => parseInt(byte, 16))
|
|
1097
|
+
for (let offset = 0; offset < bytes.length; offset += limit) {
|
|
1098
1098
|
if (signal?.aborted) throw abortError(signal)
|
|
1099
|
-
const chunk = bytes.subarray(offset, Math.min(offset +
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
contentBase64: bytesToBase64(chunk),
|
|
1107
|
-
runAfterWrite: runAfterWrite && offset + chunk.length >= bytes.length,
|
|
1108
|
-
}, { timeout: 8000, signal })
|
|
1099
|
+
const chunk = bytes.subarray(offset, Math.min(offset + limit, bytes.length))
|
|
1100
|
+
const payload = new Uint8Array(20 + chunk.length)
|
|
1101
|
+
payload.set(token)
|
|
1102
|
+
new DataView(payload.buffer).setUint32(16, offset, true)
|
|
1103
|
+
payload.set(chunk, 20)
|
|
1104
|
+
const response = await this.rpc('file.write', payload, { timeout: 8000, signal })
|
|
1105
|
+
if (response.writtenBytes !== offset + chunk.length) throw new Error('Incomplete binary file transfer acknowledgement')
|
|
1109
1106
|
onProgress?.({ writtenBytes: offset + chunk.length, totalBytes: bytes.length })
|
|
1110
1107
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
path,
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
throw new Error(`script.write payload exceeds the device RX limit ${this.limits.rxPayloadMax}; firmware must support script.write.chunked.`)
|
|
1108
|
+
const result = await this.rpc('file.write', { action: 'commit', uploadId: begin.uploadId }, { timeout: 8000, signal })
|
|
1109
|
+
if (result.complete !== true || result.bytes !== bytes.length) throw new Error('Incomplete binary file commit acknowledgement')
|
|
1110
|
+
committed = true
|
|
1111
|
+
if (bytes.length === 0) onProgress?.({ writtenBytes: 0, totalBytes: 0 })
|
|
1112
|
+
if (runAfterWrite) await this.rpc('script.run', { path }, { timeout: 8000, signal })
|
|
1113
|
+
return { ...result, path, runAfterWrite }
|
|
1114
|
+
} finally {
|
|
1115
|
+
if (!committed) {
|
|
1116
|
+
// Cancellation does not cancel cleanup, and cleanup cannot mask the primary error.
|
|
1117
|
+
await this.rpc('file.write', { action: 'abort', uploadId }, { timeout: 1500 }).catch(() => {})
|
|
1118
|
+
}
|
|
1123
1119
|
}
|
|
1124
|
-
const response = await this.rpc('script.write', payload, { timeout: 8000, signal })
|
|
1125
|
-
onProgress?.({ writtenBytes: bytes.length, totalBytes: bytes.length })
|
|
1126
|
-
return response
|
|
1127
1120
|
}
|
|
1128
1121
|
|
|
1129
1122
|
async writeScript(path, content, { runAfterWrite = false, signal = null } = {}) {
|
|
@@ -1217,10 +1210,10 @@ export class DeviceSession {
|
|
|
1217
1210
|
if (commandBytes <= inputLimit) {
|
|
1218
1211
|
return this.repl.execPy(source, { timeout, signal })
|
|
1219
1212
|
}
|
|
1220
|
-
if (!this.hasFeature('
|
|
1213
|
+
if (!this.hasFeature('file.write.binary') || !this.hasFeature('script.run')) {
|
|
1221
1214
|
throw new Error(
|
|
1222
1215
|
`Python source requires ${commandBytes} REPL bytes, above the firmware limit ${inputLimit}; ` +
|
|
1223
|
-
'upgrade firmware with
|
|
1216
|
+
'upgrade firmware with file.write.binary and script.run support.',
|
|
1224
1217
|
)
|
|
1225
1218
|
}
|
|
1226
1219
|
|
|
@@ -1365,8 +1358,8 @@ export class DeviceSession {
|
|
|
1365
1358
|
if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
|
|
1366
1359
|
throw new RangeError('Script timeout must be a positive number or null for an interactive run')
|
|
1367
1360
|
}
|
|
1368
|
-
if ((content !== undefined && !this.hasFeature('
|
|
1369
|
-
throw new Error('Running scripts requires script.run and uploading source also requires
|
|
1361
|
+
if ((content !== undefined && !this.hasFeature('file.write.binary')) || !this.hasFeature('script.run')) {
|
|
1362
|
+
throw new Error('Running scripts requires script.run and uploading source also requires file.write.binary')
|
|
1370
1363
|
}
|
|
1371
1364
|
if (this.runCapture || this.runStarting) {
|
|
1372
1365
|
throw new Error('A script is already running')
|
package/src/evmux.js
CHANGED
|
@@ -389,14 +389,14 @@ export class EvMuxParser {
|
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
export function makeEvMuxFrame({ channel, type = 'data', method = null, payload = '', seq = 0, sid = 'web-ide', sessionId = undefined, vmEpoch = undefined, contentType = undefined, width = undefined, height = undefined, route = undefined }) {
|
|
392
|
+
export function makeEvMuxFrame({ channel, type = 'data', method = null, payload = '', seq = 0, sid = 'web-ide', sessionId = undefined, vmEpoch = undefined, contentType = undefined, width = undefined, height = undefined, route = undefined, encoding = undefined }) {
|
|
393
393
|
const payloadBytes = payload instanceof Uint8Array ? payload : new TextEncoder().encode(payload)
|
|
394
394
|
const metadata = {
|
|
395
395
|
sid,
|
|
396
396
|
seq,
|
|
397
397
|
channel,
|
|
398
398
|
type,
|
|
399
|
-
encoding: channel.endsWith('.rpc') ? 'json' : 'text',
|
|
399
|
+
encoding: encoding ?? (channel.endsWith('.rpc') ? 'json' : 'text'),
|
|
400
400
|
...(sessionId ? { sessionId } : {}),
|
|
401
401
|
...(vmEpoch !== undefined ? { vmEpoch } : {}),
|
|
402
402
|
...(contentType ? { contentType } : {}),
|
|
@@ -408,7 +408,8 @@ export function makeEvMuxFrame({ channel, type = 'data', method = null, payload
|
|
|
408
408
|
metadata.method = method
|
|
409
409
|
}
|
|
410
410
|
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata))
|
|
411
|
-
const
|
|
411
|
+
const checksum = encoding === 'binary' ? crc32(payloadBytes).toString(16).padStart(8, '0') : '00000000'
|
|
412
|
+
const header = new TextEncoder().encode(`\x1eEVMUX/1 h=${metadataBytes.length} p=${payloadBytes.length} c=${checksum}\r\n`)
|
|
412
413
|
const out = new Uint8Array(header.length + metadataBytes.length + payloadBytes.length + 1)
|
|
413
414
|
out.set(header, 0)
|
|
414
415
|
out.set(metadataBytes, header.length)
|
|
@@ -13,6 +13,7 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
13
13
|
let session
|
|
14
14
|
let resetRequested = false
|
|
15
15
|
let resetPolls = 0
|
|
16
|
+
let uploaded = 0
|
|
16
17
|
const stdout = (payload) => session.feedBytes(makeEvMuxFrame({
|
|
17
18
|
channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
|
|
18
19
|
}))
|
|
@@ -26,7 +27,7 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
26
27
|
const parser = new EvMuxParser({
|
|
27
28
|
filterFrame: (metadata, bytes) => {
|
|
28
29
|
const text = new TextDecoder().decode(bytes)
|
|
29
|
-
const payload = metadata.channel.endsWith('.rpc') ? JSON.parse(text) : text
|
|
30
|
+
const payload = metadata.encoding === 'binary' ? bytes : metadata.channel.endsWith('.rpc') ? JSON.parse(text) : text
|
|
30
31
|
requests.push({ metadata, payload })
|
|
31
32
|
if (metadata.channel === 'repl.signal') return
|
|
32
33
|
if (metadata.channel === 'repl.stdin') {
|
|
@@ -51,7 +52,15 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
51
52
|
response = { ok: true, sessionId: session.sessionId, vmEpoch: responseEpoch,
|
|
52
53
|
vmReady: stalledReset || resetPolls === 1 || resetPolls >= 3 }
|
|
53
54
|
}
|
|
54
|
-
if (metadata.method === '
|
|
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) {
|
|
55
64
|
response = { ok: false, error: { code: 'WRITE_FAILED', message: 'simulated write failure' } }
|
|
56
65
|
} else if (metadata.method === 'script.run') {
|
|
57
66
|
running = true
|
|
@@ -75,7 +84,8 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
|
|
|
75
84
|
session.sessionId = 'a'.repeat(32)
|
|
76
85
|
session.vmEpoch = 1
|
|
77
86
|
session.vmReady = true
|
|
78
|
-
session.
|
|
87
|
+
session.limits.fileWriteChunkMax = 16384
|
|
88
|
+
session.capabilities = { features: ['file.write.binary', 'script.run', 'device.control'] }
|
|
79
89
|
return { session, requests, finish, get terminal() { return terminal }, get running() { return running } }
|
|
80
90
|
}
|
|
81
91
|
|
|
@@ -0,0 +1,112 @@
|
|
|
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 { test } from 'node:test'
|
|
5
|
+
import { DeviceSession } from '../src/device_session.js'
|
|
6
|
+
import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
|
|
7
|
+
import { EvRepl } from '../src/evrepl.js'
|
|
8
|
+
|
|
9
|
+
function fixture(options = {}) {
|
|
10
|
+
const requests = [], chunks = []; let session, began, finished
|
|
11
|
+
let token
|
|
12
|
+
const peer = new EvMuxParser({ filterFrame(metadata, bytes) {
|
|
13
|
+
const payload = metadata.encoding === 'binary' ? bytes.slice() : JSON.parse(new TextDecoder().decode(bytes))
|
|
14
|
+
requests.push({ metadata, payload })
|
|
15
|
+
let reply = { ok: true }
|
|
16
|
+
if (payload instanceof Uint8Array) {
|
|
17
|
+
assert.equal(metadata.method, 'file.write'); assert.equal(metadata.encoding, 'binary')
|
|
18
|
+
assert.equal(metadata.vmEpoch, 7); assert.equal(metadata.sessionId, 'a'.repeat(32))
|
|
19
|
+
assert.equal(Buffer.from(payload.slice(0, 16)).toString('hex'), token)
|
|
20
|
+
const offset = new DataView(payload.buffer).getUint32(16, true)
|
|
21
|
+
assert.equal(offset, chunks.reduce((sum, chunk) => sum + chunk.length, 0))
|
|
22
|
+
chunks.push(payload.slice(20)); reply.writtenBytes = offset + payload.length - 20
|
|
23
|
+
if (options.badAck) reply.writtenBytes--
|
|
24
|
+
if (options.abort) options.abort.abort()
|
|
25
|
+
} else if (payload.action === 'begin') {
|
|
26
|
+
began = payload; token = payload.uploadId; reply.uploadId = token; reply.chunkBytes = 16384
|
|
27
|
+
if (options.abortBegin) options.abortBegin.abort()
|
|
28
|
+
if (options.failBegin) { options.failBegin = false; throw new Error('begin response lost') }
|
|
29
|
+
} else if (payload.action === 'commit') {
|
|
30
|
+
finished = true; reply.bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0); reply.complete = true
|
|
31
|
+
if (options.badCommit) reply.bytes++
|
|
32
|
+
if (options.failCommit) reply = { ok: false, error: { code: 'HASH_MISMATCH' } }
|
|
33
|
+
}
|
|
34
|
+
session.feedBytes(makeEvMuxFrame({ channel: 'user.rpc', type: 'rsp', method: metadata.method, seq: metadata.seq,
|
|
35
|
+
sessionId: 'a'.repeat(32), vmEpoch: 7, payload: JSON.stringify(reply) }))
|
|
36
|
+
return false
|
|
37
|
+
} })
|
|
38
|
+
session = new DeviceSession({ transport: { async writeBytes(bytes) { peer.feedBytes(bytes) } }, EvMuxParser, makeEvMuxFrame, EvRepl })
|
|
39
|
+
Object.assign(session, { state: 'ready', sessionId: 'a'.repeat(32), vmEpoch: 7, vmReady: true,
|
|
40
|
+
capabilities: { features: ['file.write.binary', 'script.run'] } })
|
|
41
|
+
session.limits.fileWriteChunkMax = 16384
|
|
42
|
+
return { session, requests, chunks, get began() { return began }, get finished() { return finished } }
|
|
43
|
+
}
|
|
44
|
+
for (const size of [0, 1, 4096, 16384, 16385, 2 * 1024 * 1024]) {
|
|
45
|
+
test(`binary upload ${size} bytes is bounded, exact, hashed and committed`, async () => {
|
|
46
|
+
const f = fixture(), data = Uint8Array.from({ length: size }, (_, i) => i & 255), progress = []
|
|
47
|
+
try {
|
|
48
|
+
const result = await f.session.writeFile('/model.bin', data, { encoding: 'binary', onProgress: value => progress.push(value.writtenBytes) })
|
|
49
|
+
assert.equal(result.complete, true); assert.equal(result.bytes, size)
|
|
50
|
+
assert.deepEqual(Buffer.concat(f.chunks), Buffer.from(data)); assert.equal(f.finished, true)
|
|
51
|
+
assert.equal(f.began.sha256, Buffer.from(await crypto.subtle.digest('SHA-256', data)).toString('hex'))
|
|
52
|
+
assert.ok(f.chunks.every(chunk => chunk.length <= 16384)); assert.equal(f.chunks.length, Math.ceil(size / 16384))
|
|
53
|
+
assert.equal(progress.at(-1), size)
|
|
54
|
+
assert.ok(!f.requests.some(request => request.metadata.method === 'script.write'))
|
|
55
|
+
} finally { f.session.close() }
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
test('Python upload uses the same byte engine and requests execution only after commit', async () => {
|
|
59
|
+
const f = fixture()
|
|
60
|
+
try {
|
|
61
|
+
await f.session.writeScript('/script.py', "print('中文')", { runAfterWrite: true })
|
|
62
|
+
assert.deepEqual(Buffer.concat(f.chunks), Buffer.from("print('中文')"))
|
|
63
|
+
assert.equal(f.requests.at(-1).metadata.method, 'script.run')
|
|
64
|
+
assert.equal(f.requests.at(-2).payload.action, 'commit')
|
|
65
|
+
} finally { f.session.close() }
|
|
66
|
+
})
|
|
67
|
+
for (const setting of ['badAck', 'badCommit', 'failCommit']) {
|
|
68
|
+
test(`${setting} triggers abort cleanup without publishing success`, async () => {
|
|
69
|
+
const f = fixture({ [setting]: true })
|
|
70
|
+
try {
|
|
71
|
+
await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100)))
|
|
72
|
+
assert.equal(f.requests.at(-1).payload.action, 'abort')
|
|
73
|
+
} finally { f.session.close() }
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
test('cancellation after begin still sends an independent abort', async () => {
|
|
77
|
+
const controller = new AbortController(), f = fixture({ abort: controller })
|
|
78
|
+
try {
|
|
79
|
+
await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(32768), { signal: controller.signal }), { name: 'AbortError' })
|
|
80
|
+
assert.equal(f.requests.at(-1).payload.action, 'abort')
|
|
81
|
+
} finally { f.session.close() }
|
|
82
|
+
})
|
|
83
|
+
test('missing binary capability rejects without falling back to script.write', async () => {
|
|
84
|
+
const f = fixture(); f.session.capabilities.features = ['script.write']
|
|
85
|
+
try { await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(10)), { code: 'UNSUPPORTED_OPERATION' }); assert.equal(f.requests.length, 0) }
|
|
86
|
+
finally { f.session.close() }
|
|
87
|
+
})
|
|
88
|
+
test('corrupted binary frames fail CRC before dispatch', () => {
|
|
89
|
+
let calls = 0
|
|
90
|
+
const parser = new EvMuxParser({ filterFrame() { calls++; return false } })
|
|
91
|
+
const bytes = makeEvMuxFrame({ channel: 'user.rpc', type: 'req', method: 'file.write', encoding: 'binary', payload: new Uint8Array([0, 1, 0x1e, 0x1f, 255]) })
|
|
92
|
+
parser.feedBytes(bytes); assert.equal(calls, 1)
|
|
93
|
+
bytes[bytes.length - 2] ^= 1; parser.feedBytes(bytes); assert.equal(calls, 1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('cancellation during begin still aborts the client-known upload ID', async () => {
|
|
97
|
+
const controller = new AbortController(), f = fixture({ abortBegin: controller })
|
|
98
|
+
try {
|
|
99
|
+
await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100), { signal: controller.signal }), { name: 'AbortError' })
|
|
100
|
+
assert.equal(f.requests.at(-1).payload.action, 'abort')
|
|
101
|
+
assert.equal(f.requests.at(-1).payload.uploadId, f.began.uploadId)
|
|
102
|
+
assert.equal(f.chunks.length, 0)
|
|
103
|
+
} finally { f.session.close() }
|
|
104
|
+
})
|
|
105
|
+
test('a lost begin response still allows abort without knowing a server-generated ID', async () => {
|
|
106
|
+
const f = fixture({ failBegin: true })
|
|
107
|
+
try {
|
|
108
|
+
await assert.rejects(f.session.writeFile('/x.bin', new Uint8Array(100)), /begin response lost/)
|
|
109
|
+
assert.equal(f.requests.at(-1).payload.action, 'abort')
|
|
110
|
+
assert.equal(f.requests.at(-1).payload.uploadId, f.began.uploadId)
|
|
111
|
+
} finally { f.session.close() }
|
|
112
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { test } from 'node:test'
|
|
5
|
+
import { verifyPublishedIntegrity } from '../scripts/verify-registry.mjs'
|
|
6
|
+
|
|
7
|
+
const integrity = 'sha512-test-content'
|
|
8
|
+
const url = 'https://registry.npmjs.org/%40esp-vision%2Fdevice-core/0.2.0'
|
|
9
|
+
const published = value => new Response(JSON.stringify({ dist: { integrity: value } }))
|
|
10
|
+
function fixture(replies) {
|
|
11
|
+
const calls = [], waits = [], reports = []
|
|
12
|
+
return { calls, waits, reports, options: {
|
|
13
|
+
async fetcher(address, options) {
|
|
14
|
+
calls.push({ address, options })
|
|
15
|
+
const reply = replies.shift()
|
|
16
|
+
if (reply instanceof Error) throw reply
|
|
17
|
+
assert(reply, 'Unexpected verification request')
|
|
18
|
+
return reply
|
|
19
|
+
},
|
|
20
|
+
wait: async milliseconds => { waits.push(milliseconds) },
|
|
21
|
+
report: message => reports.push(message),
|
|
22
|
+
} }
|
|
23
|
+
}
|
|
24
|
+
test('new npm version becomes visible after stale 404 metadata; only uncached reads repeat', async () => {
|
|
25
|
+
const f = fixture([new Response('', { status: 404 }), published(integrity)])
|
|
26
|
+
await verifyPublishedIntegrity(url, integrity, f.options)
|
|
27
|
+
assert.equal(f.calls.length, 2); assert.deepEqual(f.waits, [1000])
|
|
28
|
+
for (const call of f.calls) {
|
|
29
|
+
assert.equal(call.address, url); assert.equal(call.options.cache, 'no-store')
|
|
30
|
+
assert.equal(call.options.headers['Cache-Control'], 'no-cache')
|
|
31
|
+
assert.equal(call.options.method ?? 'GET', 'GET')
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
test('verification retries rate limits, temporary registry errors and network failures', async () => {
|
|
35
|
+
const f = fixture([new Response('', { status: 429 }), new Response('', { status: 503 }), new TypeError('network unavailable'), published(integrity)])
|
|
36
|
+
await verifyPublishedIntegrity(url, integrity, f.options)
|
|
37
|
+
assert.deepEqual(f.waits, [1000, 2000, 4000])
|
|
38
|
+
})
|
|
39
|
+
for (const status of [400, 401, 403]) test(`registry HTTP ${status} fails without retries`, async () => {
|
|
40
|
+
const f = fixture([new Response('', { status })])
|
|
41
|
+
await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), new RegExp(`HTTP ${status}`))
|
|
42
|
+
assert.equal(f.calls.length, 1); assert.deepEqual(f.waits, [])
|
|
43
|
+
})
|
|
44
|
+
test('published integrity mismatch fails immediately', async () => {
|
|
45
|
+
const f = fixture([published('sha512-other-content')])
|
|
46
|
+
await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /integrity mismatch/)
|
|
47
|
+
assert.equal(f.calls.length, 1); assert.deepEqual(f.waits, [])
|
|
48
|
+
})
|
|
49
|
+
test('missing published integrity is not accepted', async () => {
|
|
50
|
+
const f = fixture([new Response('{}')])
|
|
51
|
+
await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /integrity mismatch/)
|
|
52
|
+
assert.deepEqual(f.waits, [])
|
|
53
|
+
})
|
|
54
|
+
test('persistent propagation failures stop after six reads with bounded backoff', async () => {
|
|
55
|
+
const f = fixture(Array.from({ length: 6 }, () => new Response('', { status: 404 })))
|
|
56
|
+
await assert.rejects(verifyPublishedIntegrity(url, integrity, f.options), /after 6 reads/)
|
|
57
|
+
assert.equal(f.calls.length, 6); assert.deepEqual(f.waits, [1000, 2000, 4000, 8000, 16000])
|
|
58
|
+
})
|