@esp-vision/device-core 0.5.1 → 0.6.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 +25 -0
- package/NOTICE +8 -0
- package/README.md +24 -7
- package/package.json +4 -3
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/release-npm.mjs +16 -0
- package/scripts/version-policy.mjs +71 -0
- package/src/browser.js +6 -2
- package/src/browser_rom.js +208 -4
- package/src/browser_rom_compat.js +86 -59
- package/src/browser_serial.js +130 -0
- package/src/device.js +5 -4
- package/src/device_bindings.js +5 -4
- package/src/device_capture.js +11 -0
- package/src/device_operations.js +3 -1
- package/src/device_session.js +263 -10
- package/src/evmux.js +43 -4
- package/src/firmware_flash.js +1 -1
- package/src/jpeg_validate.js +132 -0
- package/src/node_inventory.js +1 -1
- package/src/node_rom.js +76 -27
- package/src/node_rom.py +68 -2
- package/src/node_rom_port.js +211 -0
- package/src/node_serial.py +47 -1
- package/src/node_transport.js +76 -10
- package/src/rom_md5.js +30 -0
- package/tests/check-browser.mjs +76 -123
- package/tests/check-package.mjs +1 -1
- package/tests/device-checks.mjs +6 -2
- package/tests/fixtures/device-checks.py +11 -1
- package/tests/jpeg-fixtures.mjs +39 -0
- package/tests/test-browser-rom.mjs +204 -67
- package/tests/test-browser-serial.mjs +143 -0
- package/tests/test-device-api.mjs +40 -7
- package/tests/test-device-functional-checks.mjs +12 -2
- package/tests/test-device-operations-api.mjs +4 -3
- package/tests/test-device-reset.mjs +292 -0
- package/tests/test-firmware-flash.mjs +1 -1
- package/tests/test-jpeg-validation.mjs +233 -0
- package/tests/test-node-rom-port.mjs +305 -0
- package/tests/test-node-rom.mjs +160 -0
- package/tests/test-node-serial.mjs +206 -0
- package/tests/test-npm-release-policy.mjs +62 -0
- package/tests/test-observed-capture.mjs +131 -0
- package/tests/test-uart-baud.mjs +180 -0
- package/tests/test-version-policy.mjs +37 -0
|
@@ -102,3 +102,209 @@ test('non-POSIX serial backends do not receive the unsupported exclusive option'
|
|
|
102
102
|
})
|
|
103
103
|
assert.equal(result.status, 0, result.stderr || result.error?.message || 'Python fixture failed')
|
|
104
104
|
})
|
|
105
|
+
|
|
106
|
+
test('Node live retune drains without reopening and binary payload cannot become a bridge command', { skip: !python }, async t => {
|
|
107
|
+
await mkdir(tempBase, { recursive: true })
|
|
108
|
+
const root = await mkdtemp(join(tempBase, 'serial-retune-'))
|
|
109
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
110
|
+
const stateFile = join(root, 'state.jsonl')
|
|
111
|
+
await writeFile(join(root, 'serial.py'), `
|
|
112
|
+
import json, queue
|
|
113
|
+
|
|
114
|
+
class SerialException(Exception):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def record(event, **fields):
|
|
118
|
+
with open(${JSON.stringify(stateFile)}, 'a', encoding='utf-8') as stream:
|
|
119
|
+
stream.write(json.dumps(dict(event=event, **fields)) + '\\n')
|
|
120
|
+
|
|
121
|
+
class Serial:
|
|
122
|
+
def __init__(self, *args, **kwargs):
|
|
123
|
+
self.queue = queue.Queue()
|
|
124
|
+
self._baudrate = kwargs['baudrate']
|
|
125
|
+
@property
|
|
126
|
+
def baudrate(self):
|
|
127
|
+
return self._baudrate
|
|
128
|
+
@baudrate.setter
|
|
129
|
+
def baudrate(self, value):
|
|
130
|
+
record('baud', value=value)
|
|
131
|
+
if value == 1500000:
|
|
132
|
+
raise ValueError('fixture unsupported baud')
|
|
133
|
+
self._baudrate = value
|
|
134
|
+
def open(self):
|
|
135
|
+
record('open', baud=self.baudrate, rts=self.rts, dtr=self.dtr)
|
|
136
|
+
def read(self, size):
|
|
137
|
+
try:
|
|
138
|
+
return self.queue.get(timeout=0.01)
|
|
139
|
+
except queue.Empty:
|
|
140
|
+
return b''
|
|
141
|
+
def write(self, data):
|
|
142
|
+
record('write', data=data.hex())
|
|
143
|
+
self.queue.put(data)
|
|
144
|
+
return len(data)
|
|
145
|
+
def flush(self):
|
|
146
|
+
record('drain')
|
|
147
|
+
def close(self):
|
|
148
|
+
record('close')
|
|
149
|
+
`)
|
|
150
|
+
const previous = process.env.PYTHONPATH
|
|
151
|
+
process.env.PYTHONPATH = [root, previous].filter(Boolean).join(delimiter)
|
|
152
|
+
t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
|
|
153
|
+
const transport = serialTransport({ python, serial: '/dev/fixture' }), received = []
|
|
154
|
+
let finish
|
|
155
|
+
const echoed = new Promise(resolveEcho => { finish = resolveEcho })
|
|
156
|
+
const data = new Uint8Array([0, 1, 255, ...new TextEncoder().encode('\n{"id":99,"baudRate":1}\n'), 0])
|
|
157
|
+
transport.subscribe({ data(bytes) { received.push(...bytes); if (received.length === data.length) finish() } })
|
|
158
|
+
t.after(() => transport.disconnect())
|
|
159
|
+
await transport.connect({ signal: new AbortController().signal })
|
|
160
|
+
await transport.setBaudRate(2000000)
|
|
161
|
+
await transport.writeBytes(data)
|
|
162
|
+
await Promise.race([echoed, new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error('Binary echo timed out')), 2000); timer.unref() })])
|
|
163
|
+
assert.deepEqual(new Uint8Array(received), data)
|
|
164
|
+
await assert.rejects(transport.setBaudRate(1500000), { code: 'SERIAL_BAUD_UNSUPPORTED' })
|
|
165
|
+
await transport.setBaudRate(115200)
|
|
166
|
+
assert.equal(transport.getDiagnostics().baudRate, 115200)
|
|
167
|
+
const state = (await readFile(stateFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line))
|
|
168
|
+
assert.equal(state.filter(event => event.event === 'open').length, 1)
|
|
169
|
+
assert.deepEqual(state.filter(event => event.event === 'baud').map(event => event.value), [2000000, 1500000, 115200])
|
|
170
|
+
assert.equal(state.filter(event => event.event === 'drain').length, 3)
|
|
171
|
+
assert.deepEqual(state.find(event => event.event === 'open'), { event: 'open', baud: 115200, rts: false, dtr: true })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
async function drainFixture(t, flushPlan = ['gate']) {
|
|
175
|
+
await mkdir(tempBase, { recursive: true })
|
|
176
|
+
const root = await mkdtemp(join(tempBase, 'serial-drain-'))
|
|
177
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
178
|
+
const stateFile = join(root, 'state.jsonl'), gateFile = join(root, 'flush-gate')
|
|
179
|
+
await writeFile(join(root, 'serial.py'), `
|
|
180
|
+
import json, os, sys, time
|
|
181
|
+
|
|
182
|
+
PLAN = json.loads(${JSON.stringify(JSON.stringify(flushPlan))})
|
|
183
|
+
|
|
184
|
+
class SerialException(Exception):
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
def record(event, **fields):
|
|
188
|
+
with open(${JSON.stringify(stateFile)}, 'a', encoding='utf-8') as stream:
|
|
189
|
+
stream.write(json.dumps(dict(event=event, **fields)) + '\\n')
|
|
190
|
+
|
|
191
|
+
class Serial:
|
|
192
|
+
def __init__(self, *args, **kwargs):
|
|
193
|
+
self.baudrate = kwargs['baudrate']
|
|
194
|
+
self.flush_count = 0
|
|
195
|
+
def open(self):
|
|
196
|
+
record('open', baud=self.baudrate)
|
|
197
|
+
def read(self, size):
|
|
198
|
+
time.sleep(0.01)
|
|
199
|
+
return b''
|
|
200
|
+
def write(self, data):
|
|
201
|
+
# Exercise the production bridge's partial serial-write loop.
|
|
202
|
+
written = min(3, len(data))
|
|
203
|
+
record('write', data=data[:written].hex())
|
|
204
|
+
return written
|
|
205
|
+
def flush(self):
|
|
206
|
+
self.flush_count += 1
|
|
207
|
+
count = self.flush_count
|
|
208
|
+
mode = PLAN[min(count - 1, len(PLAN) - 1)]
|
|
209
|
+
record('drain_start', count=count, baud=self.baudrate)
|
|
210
|
+
if mode == 'error':
|
|
211
|
+
raise SerialException('fixture drain failure: private port details')
|
|
212
|
+
if mode == 'exit':
|
|
213
|
+
raise SystemExit(2)
|
|
214
|
+
if mode == 'wrong_ack':
|
|
215
|
+
print(json.dumps({'id': 1, 'ok': True, 'baudRate': self.baudrate}), file=sys.stderr, flush=True)
|
|
216
|
+
elif mode == 'gate':
|
|
217
|
+
while not os.path.exists(${JSON.stringify(gateFile)}):
|
|
218
|
+
time.sleep(0.005)
|
|
219
|
+
else:
|
|
220
|
+
time.sleep(mode)
|
|
221
|
+
record('drain_end', count=count, baud=self.baudrate)
|
|
222
|
+
def close(self):
|
|
223
|
+
record('close')
|
|
224
|
+
`)
|
|
225
|
+
const previous = process.env.PYTHONPATH
|
|
226
|
+
process.env.PYTHONPATH = [root, previous].filter(Boolean).join(delimiter)
|
|
227
|
+
t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
|
|
228
|
+
const transport = serialTransport({ python, serial: '/dev/fixture' })
|
|
229
|
+
t.after(() => transport.disconnect())
|
|
230
|
+
await transport.connect({ signal: new AbortController().signal })
|
|
231
|
+
const state = async () => (await readFile(stateFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line))
|
|
232
|
+
const waitFor = async (event, count = 1) => {
|
|
233
|
+
const deadline = Date.now() + 3000
|
|
234
|
+
while (Date.now() < deadline) {
|
|
235
|
+
const records = await state()
|
|
236
|
+
if (records.filter(record => record.event === event).length >= count) return records
|
|
237
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 5))
|
|
238
|
+
}
|
|
239
|
+
throw new Error(`Serial fixture did not reach ${event}`)
|
|
240
|
+
}
|
|
241
|
+
return { transport, state, waitFor, unblock: () => writeFile(gateFile, '') }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
test('Node drain waits for real Python port.flush after all partial byte writes without changing baud', { skip: !python }, async t => {
|
|
245
|
+
const f = await drainFixture(t)
|
|
246
|
+
const bytes = new TextEncoder().encode('import machine; machine.bootloader()\r\n')
|
|
247
|
+
await f.transport.writeBytes(bytes)
|
|
248
|
+
let completed = false
|
|
249
|
+
const draining = f.transport.drain().then(() => { completed = true })
|
|
250
|
+
const before = await f.waitFor('drain_start')
|
|
251
|
+
assert.equal(completed, false, 'accepting the child stdin record is not a drain ACK')
|
|
252
|
+
assert.deepEqual(Buffer.from(before.filter(record => record.event === 'write').map(record => record.data).join(''), 'hex'), Buffer.from(bytes))
|
|
253
|
+
assert.equal(before.some(record => record.event === 'drain_end'), false)
|
|
254
|
+
await f.unblock()
|
|
255
|
+
await draining
|
|
256
|
+
assert.equal(f.transport.getDiagnostics().baudRate, 115200)
|
|
257
|
+
await f.transport.disconnect()
|
|
258
|
+
const after = await f.state()
|
|
259
|
+
assert.deepEqual(after.filter(record => ['open', 'drain_start', 'drain_end', 'close'].includes(record.event)).map(record => record.event), ['open', 'drain_start', 'drain_end', 'close'])
|
|
260
|
+
assert.deepEqual(after.filter(record => record.event.startsWith('drain')).map(record => record.baud), [115200, 115200])
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('Node drain distinguishes flush rejection and a baud-shaped ACK from successful drain', { skip: !python }, async t => {
|
|
264
|
+
const f = await drainFixture(t, ['error', 0])
|
|
265
|
+
await assert.rejects(f.transport.drain(), { code: 'SERIAL_DRAIN_FAILED', message: 'Serial adapter could not drain pending output' })
|
|
266
|
+
await f.transport.drain()
|
|
267
|
+
assert.equal(f.transport.getDiagnostics().baudRate, 115200)
|
|
268
|
+
await f.transport.disconnect()
|
|
269
|
+
const wrong = await drainFixture(t, ['wrong_ack'])
|
|
270
|
+
await assert.rejects(wrong.transport.drain(), { code: 'SERIAL_DRAIN_FAILED' })
|
|
271
|
+
assert.equal(wrong.transport.getDiagnostics().baudRate, 115200)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('Node drain preserves pre-cancellation and ignores a cancelled request late ACK', { skip: !python }, async t => {
|
|
275
|
+
const f = await drainFixture(t, ['gate', 0.05])
|
|
276
|
+
const before = new AbortController(), reason = Object.assign(new Error('cancel drain'), { code: 'CANCELLED' })
|
|
277
|
+
before.abort(reason)
|
|
278
|
+
await assert.rejects(f.transport.drain({ signal: before.signal }), error => error === reason)
|
|
279
|
+
assert.equal((await f.state()).some(record => record.event === 'drain_start'), false)
|
|
280
|
+
const controller = new AbortController()
|
|
281
|
+
const pending = f.transport.drain({ signal: controller.signal })
|
|
282
|
+
const rejected = assert.rejects(pending, error => error === reason)
|
|
283
|
+
await f.waitFor('drain_start')
|
|
284
|
+
controller.abort(reason)
|
|
285
|
+
await rejected
|
|
286
|
+
const next = f.transport.drain()
|
|
287
|
+
await f.unblock()
|
|
288
|
+
await next
|
|
289
|
+
assert.equal((await f.state()).filter(record => record.event === 'drain_end').length, 2, 'the previous ID must not resolve the next request')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('Node drain timeout remains distinct and its late ACK cannot resolve the next drain', { skip: !python }, async t => {
|
|
293
|
+
const f = await drainFixture(t, ['gate', 0.05])
|
|
294
|
+
await assert.rejects(f.transport.drain(), { code: 'SERIAL_DRAIN_TIMEOUT', message: 'Serial drain timed out' })
|
|
295
|
+
const next = f.transport.drain()
|
|
296
|
+
await f.unblock()
|
|
297
|
+
await next
|
|
298
|
+
assert.equal((await f.state()).filter(record => record.event === 'drain_end').length, 2)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('Node drain rejects promptly on explicit disconnect or Python bridge exit', { skip: !python }, async t => {
|
|
302
|
+
const f = await drainFixture(t)
|
|
303
|
+
const pending = f.transport.drain()
|
|
304
|
+
const rejected = assert.rejects(pending, { code: 'SERIAL_CLOSED' })
|
|
305
|
+
await f.waitFor('drain_start')
|
|
306
|
+
await f.transport.disconnect()
|
|
307
|
+
await rejected
|
|
308
|
+
const exited = await drainFixture(t, ['exit'])
|
|
309
|
+
await assert.rejects(exited.transport.drain(), { code: 'SERIAL_CLOSED' })
|
|
310
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { spawnSync } from 'node:child_process'
|
|
5
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join, resolve } from 'node:path'
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
9
|
+
import { test } from 'node:test'
|
|
10
|
+
|
|
11
|
+
const source = fileURLToPath(new URL('../scripts/', import.meta.url))
|
|
12
|
+
async function fixture(t, published, version = '1.2.3', type = 'patch') {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'core-release-policy-'))
|
|
14
|
+
t.after(() => rm(root, { recursive: true, force: true }))
|
|
15
|
+
const scripts = join(root, 'scripts'), log = join(root, 'calls')
|
|
16
|
+
await mkdir(scripts)
|
|
17
|
+
await copyFile(join(source, 'release-npm.mjs'), join(scripts, 'release-npm.mjs'))
|
|
18
|
+
await copyFile(join(source, 'verify-registry.mjs'), join(scripts, 'verify-registry.mjs'))
|
|
19
|
+
await copyFile(join(source, 'version-policy.mjs'), join(scripts, 'version-policy.mjs'))
|
|
20
|
+
await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@esp-vision/device-core', version, espVisionReleaseType: type, license: 'Apache-2.0', publishConfig: { access: 'public', registry: 'https://registry.npmjs.org/' } }))
|
|
21
|
+
const loader = join(root, 'registry.mjs'), npm = join(root, 'npm.cjs')
|
|
22
|
+
// Invoke the fixture with Node on every platform, without shell/PATH resolution.
|
|
23
|
+
await writeFile(loader, `import childProcess from 'node:child_process';
|
|
24
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
25
|
+
const spawnSync = childProcess.spawnSync;
|
|
26
|
+
childProcess.spawnSync = (command, ...args) => command === 'npm'
|
|
27
|
+
? spawnSync(process.execPath, [${JSON.stringify(npm)}, ...args[0]], args[1])
|
|
28
|
+
: spawnSync(command, ...args);
|
|
29
|
+
syncBuiltinESMExports();
|
|
30
|
+
let reads = 0; globalThis.fetch = async () => { reads++; const found = ${published} || reads === 2 || reads >= 4; return {ok:found,status:found?200:404,json:async()=>reads===2?({versions:{'1.2.2':{}}}):({dist:{integrity:'sha512-fixture'}})} }`)
|
|
31
|
+
await writeFile(npm, `const fs = require('node:fs'); const args = process.argv.slice(2); fs.appendFileSync(process.env.CALL_LOG, args[0] + '\\n');
|
|
32
|
+
if (args[0] === 'whoami') process.stdout.write('esp-vision');
|
|
33
|
+
else if (args[0] === 'pack') process.stdout.write(JSON.stringify([{name:'@esp-vision/device-core',version:'1.2.3',filename:'core.tgz',integrity:'sha512-fixture',files:[{path:'LICENSE'},{path:'NOTICE'}]}]));
|
|
34
|
+
else if (args[0] !== 'publish') process.exit(2);
|
|
35
|
+
`)
|
|
36
|
+
const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root, CALL_LOG: log,
|
|
37
|
+
NODE_OPTIONS: `--import ${pathToFileURL(loader).href}`, NPM_PUBLISH_TOKEN: 'fixture', CI_PIPELINE_SOURCE: 'push',
|
|
38
|
+
CI_DEFAULT_BRANCH: 'master', CI_COMMIT_BRANCH: 'master', CI_COMMIT_REF_PROTECTED: 'true' }
|
|
39
|
+
const result = spawnSync(process.execPath, [resolve(scripts, 'release-npm.mjs')], { cwd: root, env, encoding: 'utf8' })
|
|
40
|
+
return { result, calls: await readFile(log, 'utf8').catch(() => '') }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('an existing Core version is skipped without npm writes', async t => {
|
|
44
|
+
const { result, calls } = await fixture(t, true)
|
|
45
|
+
assert.equal(result.status, 0, result.stderr)
|
|
46
|
+
assert.match(result.stdout, /already exists; no new version to publish/)
|
|
47
|
+
assert.equal(calls, '')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a new Core version is published once and its registry integrity verified', async t => {
|
|
51
|
+
const { result, calls } = await fixture(t, false)
|
|
52
|
+
assert.equal(result.status, 0, result.stderr)
|
|
53
|
+
assert.match(result.stdout, /Published and verified/)
|
|
54
|
+
assert.deepEqual(calls.trim().split('\n'), ['pack', 'whoami', 'publish'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a misclassified Core version is rejected before packaging or publication', async t => {
|
|
58
|
+
const { result, calls } = await fixture(t, false, '1.3.0', 'patch')
|
|
59
|
+
assert.notEqual(result.status, 0)
|
|
60
|
+
assert.match(result.stderr, /A patch release after 1.2.2 must be 1.2.3/)
|
|
61
|
+
assert.equal(calls, '')
|
|
62
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
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 { createDeviceOperations } from '../src/device_operations.js'
|
|
7
|
+
import { DeviceActionRegistry } from '../src/device_actions.js'
|
|
8
|
+
import { bindSessionDeviceActions } from '../src/device_bindings.js'
|
|
9
|
+
import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
|
|
10
|
+
import { EvRepl } from '../src/evrepl.js'
|
|
11
|
+
import { jpegFixture } from './jpeg-fixtures.mjs'
|
|
12
|
+
|
|
13
|
+
const safeFeature = 'debug.capture_frame.observed.v1'
|
|
14
|
+
function fixture({ features = ['debug.capture_frame', safeFeature, 'debug.capture_screen', 'device.control'], cameraError = null, pending = false } = {}) {
|
|
15
|
+
let session
|
|
16
|
+
const requests = [], id = 'a'.repeat(32), state = {}
|
|
17
|
+
const jpeg = jpegFixture('small').data
|
|
18
|
+
const replyImage = (request, { sessionId = request.sessionId, vmEpoch = request.vmEpoch } = {}) => session.feedBytes(makeEvMuxFrame({
|
|
19
|
+
channel: request.channel, type: 'rsp', method: request.method, seq: request.seq, sessionId, vmEpoch,
|
|
20
|
+
contentType: 'image/jpeg', encoding: 'binary', width: 4, height: 3, payload: jpeg,
|
|
21
|
+
}))
|
|
22
|
+
const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
|
|
23
|
+
const payload = JSON.parse(new TextDecoder().decode(bytes))
|
|
24
|
+
requests.push({ ...metadata, payload })
|
|
25
|
+
if (metadata.method === 'debug.capture_frame' && pending) return false
|
|
26
|
+
if (metadata.method === 'debug.capture_screen' || (metadata.method === 'debug.capture_frame' && !cameraError)) replyImage(metadata)
|
|
27
|
+
else session.feedBytes(makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq,
|
|
28
|
+
sessionId: metadata.sessionId, vmEpoch: metadata.vmEpoch ?? session.vmEpoch,
|
|
29
|
+
payload: JSON.stringify(cameraError && metadata.method === 'debug.capture_frame'
|
|
30
|
+
? { ok: false, error: { code: cameraError, message: 'No safe camera observation is available' } } : { ok: true }) }))
|
|
31
|
+
return false
|
|
32
|
+
} })
|
|
33
|
+
session = new DeviceSession({ transport: { async writeBytes(bytes) { parser.feedBytes(bytes) } }, EvMuxParser, makeEvMuxFrame, EvRepl })
|
|
34
|
+
session.state = 'ready'
|
|
35
|
+
session.sessionId = id
|
|
36
|
+
session.vmEpoch = 1
|
|
37
|
+
session.vmReady = true
|
|
38
|
+
session.capabilities = { features }
|
|
39
|
+
return { session, requests, replyImage, state, jpeg, api: createDeviceOperations({ session, state }) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('old unsafe camera firmware is rejected before public, RPC, raw-frame or action requests are sent', async t => {
|
|
43
|
+
for (const features of [[], ['debug.capture_frame'], [safeFeature]]) {
|
|
44
|
+
const f = fixture({ features }); t.after(() => f.session.close())
|
|
45
|
+
const denied = error => error.code === 'UNSUPPORTED_OPERATION' && /update.*firmware/i.test(error.message)
|
|
46
|
+
await assert.rejects(f.api.capture({ source: 'camera' }), denied)
|
|
47
|
+
await assert.rejects(f.session.captureImage('debug.capture_frame'), denied)
|
|
48
|
+
await assert.rejects(f.session.rpc('debug.capture_frame'), denied)
|
|
49
|
+
await assert.rejects(f.session.writeFrame('debug.rpc', 'req', 'debug.capture_frame', '{}'), denied)
|
|
50
|
+
const registry = new DeviceActionRegistry()
|
|
51
|
+
bindSessionDeviceActions(registry)
|
|
52
|
+
await assert.rejects(registry.handler('frame.capture')({ timeoutMs: 1000 }, { session: f.session }), denied)
|
|
53
|
+
assert.deepEqual(f.requests, [])
|
|
54
|
+
assert.equal(f.session.pendingRpc.size, 0)
|
|
55
|
+
assert.equal(f.state.operation, undefined)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('negotiated camera observation returns correlated JPEG bytes and forwards quality', async t => {
|
|
60
|
+
const f = fixture(); t.after(() => f.session.close())
|
|
61
|
+
const image = await f.api.capture({ source: 'camera', quality: 73 })
|
|
62
|
+
assert.deepEqual(image.data, f.jpeg)
|
|
63
|
+
assert.equal(image.mimeType, 'image/jpeg')
|
|
64
|
+
assert.equal(image.metadata.width, 4)
|
|
65
|
+
assert.equal(image.metadata.height, 3)
|
|
66
|
+
assert.equal(f.requests.length, 1)
|
|
67
|
+
assert.equal(f.requests[0].method, 'debug.capture_frame')
|
|
68
|
+
assert.deepEqual(f.requests[0].payload, { quality: 73 })
|
|
69
|
+
assert.equal(f.state.operation, null)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('no published camera frame and observer allocation failures retain explicit firmware errors', async t => {
|
|
73
|
+
for (const cameraError of ['CAMERA_FRAME_NOT_READY', 'NO_MEM']) {
|
|
74
|
+
const f = fixture({ cameraError }); t.after(() => f.session.close())
|
|
75
|
+
await assert.rejects(f.api.capture({ source: 'camera' }), { code: cameraError })
|
|
76
|
+
assert.equal(f.requests.length, 1, 'no sensor initialization, retry or capture fallback is synthesized')
|
|
77
|
+
assert.equal(f.session.lastDebugImage, null)
|
|
78
|
+
assert.equal(f.state.operation, null)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('screen capture retains its independent capability without a camera observation feature', async t => {
|
|
83
|
+
const f = fixture({ features: ['debug.capture_frame', 'debug.capture_screen'] }); t.after(() => f.session.close())
|
|
84
|
+
assert.deepEqual((await f.api.capture({ source: 'screen' })).data, f.jpeg)
|
|
85
|
+
await assert.rejects(f.api.capture({ source: 'camera' }), { code: 'UNSUPPORTED_OPERATION' })
|
|
86
|
+
assert.deepEqual(f.requests.map(request => request.method), ['debug.capture_screen'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('camera cancellation and timeout release the operation and ignore stale binary responses', async t => {
|
|
90
|
+
const f = fixture({ pending: true }); t.after(() => f.session.close())
|
|
91
|
+
const controller = new AbortController()
|
|
92
|
+
const capture = f.api.capture({ source: 'camera', signal: controller.signal })
|
|
93
|
+
const cancelled = assert.rejects(capture, { name: 'AbortError' })
|
|
94
|
+
await new Promise(setImmediate)
|
|
95
|
+
controller.abort()
|
|
96
|
+
await cancelled
|
|
97
|
+
assert.equal(f.state.operation, null)
|
|
98
|
+
assert.equal(f.session.pendingRpc.size, 0)
|
|
99
|
+
f.replyImage(f.requests[0])
|
|
100
|
+
assert.deepEqual((await f.api.capture({ source: 'screen' })).data, f.jpeg)
|
|
101
|
+
await assert.rejects(f.api.capture({ source: 'camera', timeout: 20 }), { code: 'TIMEOUT' })
|
|
102
|
+
assert.equal(f.state.operation, null)
|
|
103
|
+
assert.equal(f.session.pendingRpc.size, 0)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('soft-reset epochs invalidate a pending camera observation and reject the old image', async t => {
|
|
107
|
+
const f = fixture({ pending: true }); t.after(() => f.session.close())
|
|
108
|
+
const before = f.api.capture({ source: 'camera' })
|
|
109
|
+
const invalidated = assert.rejects(before, { code: 'VM_EPOCH_CHANGED' })
|
|
110
|
+
await new Promise(setImmediate)
|
|
111
|
+
f.session.feedBytes(makeEvMuxFrame({ channel: 'user.rpc', type: 'event', method: 'hello', sessionId: f.session.sessionId, vmEpoch: 2,
|
|
112
|
+
payload: JSON.stringify({ evMuxVersion: 4, session: { id: f.session.sessionId, vmEpoch: 2 }, vmEpoch: 2, vmReady: true }) }))
|
|
113
|
+
await invalidated
|
|
114
|
+
f.replyImage(f.requests[0])
|
|
115
|
+
assert.equal(f.session.vmEpoch, 2)
|
|
116
|
+
assert.deepEqual((await f.api.capture({ source: 'screen' })).data, f.jpeg)
|
|
117
|
+
assert.equal(f.requests.at(-1).vmEpoch, 2)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('acknowledged hard reset discards a pending safe camera response and its old session', async t => {
|
|
121
|
+
const f = fixture({ pending: true }); t.after(() => f.session.close())
|
|
122
|
+
const capture = f.session.captureImage('debug.capture_frame')
|
|
123
|
+
const invalidated = assert.rejects(capture, { code: 'DEVICE_RESET' })
|
|
124
|
+
await new Promise(setImmediate)
|
|
125
|
+
await f.session.control('hard-reset')
|
|
126
|
+
await invalidated
|
|
127
|
+
f.replyImage(f.requests[0])
|
|
128
|
+
assert.equal(f.session.state, 'disconnected')
|
|
129
|
+
assert.equal(f.session.sessionId, null)
|
|
130
|
+
assert.equal(f.session.pendingRpc.size, 0)
|
|
131
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
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 { createDevice } from '../src/index.js'
|
|
6
|
+
import { DeviceSession, UART_BAUD_CANDIDATES, EV_MUX_DEFAULT_LIMITS, EV_MUX_REQUIRED_CHANNELS, EV_MUX_REQUIRED_FEATURES } from '../src/device_session.js'
|
|
7
|
+
import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
|
|
8
|
+
import { EvRepl } from '../src/evrepl.js'
|
|
9
|
+
|
|
10
|
+
function peer({ owner = 'uart', support = true, rejectSpeeds = [], corruptSpeeds = [], corruptFrames = [], silentSpeeds = [], dropCommit = false, lostPrepare = false, lostCommitAndRelease = false, resetOnChange = false } = {}) {
|
|
11
|
+
let listener, session, hostBaud = 115200, deviceBaud = 115200, active = false, token, commitDrops = 0
|
|
12
|
+
const id = 'a'.repeat(32), requests = [], changed = [], diagnostics = [], sleeps = []
|
|
13
|
+
const caps = { ok: true, protocol: { evMuxVersion: 4 }, features: EV_MUX_REQUIRED_FEATURES,
|
|
14
|
+
channels: EV_MUX_REQUIRED_CHANNELS, limits: { ...EV_MUX_DEFAULT_LIMITS, txLargePayloadMax: 262144 },
|
|
15
|
+
session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 },
|
|
16
|
+
...(support ? { uartBaud: { baseline: 115200, current: 115200, candidates: UART_BAUD_CANDIDATES, probeBytes: 512, timeoutMs: 5000, switchDelayMs: 100 } } : {}),
|
|
17
|
+
}
|
|
18
|
+
const reset = () => { active = false; deviceBaud = 115200; token = null }
|
|
19
|
+
const reader = new EvMuxParser({ filterFrame(metadata, bytes) {
|
|
20
|
+
if (hostBaud !== deviceBaud) return false
|
|
21
|
+
const payload = JSON.parse(new TextDecoder().decode(bytes))
|
|
22
|
+
requests.push({ method: metadata.method, payload, baudRate: hostBaud, state: session?.state })
|
|
23
|
+
let result = { ok: true }, acquired = false
|
|
24
|
+
if (metadata.method === 'hello') {
|
|
25
|
+
if (payload.acquire) { active = true; acquired = true }
|
|
26
|
+
result = { ok: true, evMuxVersion: 4, deviceId: 'test', hostname: 'test', vmReady: false,
|
|
27
|
+
session: active ? { id, owner, vmEpoch: 1, leaseMs: 15000, keepaliveMs: 5000 } : null }
|
|
28
|
+
} else if (metadata.method === 'capabilities') result = caps
|
|
29
|
+
else if (metadata.method === 'transport.uart.prepare') {
|
|
30
|
+
token = payload.token
|
|
31
|
+
if (lostPrepare && payload.baudRate === 2000000) { deviceBaud = payload.baudRate; return false }
|
|
32
|
+
result = { ok: true, ...payload, timeoutMs: 5000, switchDelayMs: 100 }
|
|
33
|
+
} else if (metadata.method === 'transport.uart.probe') {
|
|
34
|
+
if (silentSpeeds.includes(hostBaud)) return false
|
|
35
|
+
result = { ok: true, token, data: corruptSpeeds.includes(hostBaud) ? payload.data + 'x' : payload.data }
|
|
36
|
+
} else if (metadata.method === 'transport.uart.commit') {
|
|
37
|
+
if (lostCommitAndRelease && hostBaud === 2000000) return false
|
|
38
|
+
if (dropCommit && commitDrops++ === 0) return false
|
|
39
|
+
result = { ok: true, token, baudRate: deviceBaud }
|
|
40
|
+
}
|
|
41
|
+
if (metadata.method === 'session.release' && lostCommitAndRelease && hostBaud === 2000000) return false
|
|
42
|
+
const response = makeEvMuxFrame({ channel: metadata.channel, type: 'rsp', method: metadata.method, seq: metadata.seq,
|
|
43
|
+
...((acquired || !['hello', 'capabilities'].includes(metadata.method)) ? { sessionId: id, vmEpoch: 1 } : {}), payload: JSON.stringify(result) })
|
|
44
|
+
if (metadata.method === 'transport.uart.probe' && corruptFrames.includes(hostBaud)) response[response.length - 2] ^= 1
|
|
45
|
+
listener.data(response)
|
|
46
|
+
if (metadata.method === 'transport.uart.prepare') deviceBaud = payload.baudRate
|
|
47
|
+
if (metadata.method === 'session.release') reset()
|
|
48
|
+
return false
|
|
49
|
+
} })
|
|
50
|
+
const transport = {
|
|
51
|
+
subscribe(value) { listener = value; return () => { listener = null } },
|
|
52
|
+
async connect() { hostBaud = 115200 },
|
|
53
|
+
async disconnect() {},
|
|
54
|
+
async writeBytes(bytes) { reader.feedBytes(bytes) },
|
|
55
|
+
async setBaudRate(speed) {
|
|
56
|
+
changed.push(speed)
|
|
57
|
+
if (rejectSpeeds.includes(speed)) throw Object.assign(new Error('unsupported speed'), { code: 'SERIAL_BAUD_UNSUPPORTED' })
|
|
58
|
+
hostBaud = speed
|
|
59
|
+
if (resetOnChange && speed !== 115200) reset()
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
const sleep = async ms => { sleeps.push(ms); if (ms >= 1000) reset() }
|
|
63
|
+
session = new DeviceSession({ transport, EvMuxParser, makeEvMuxFrame, EvRepl, sleep, handlers: { diagnostic: event => diagnostics.push(event) } })
|
|
64
|
+
listener = { data: bytes => session.feedBytes(bytes) }
|
|
65
|
+
return { transport, session, requests, changed, diagnostics, sleeps }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('runtime tests 2 Mbps first with varied bidirectional probes and stops after success', async () => {
|
|
69
|
+
const p = peer()
|
|
70
|
+
try {
|
|
71
|
+
await p.session.negotiate({ drainMs: 0, probeRepl: false })
|
|
72
|
+
assert.deepEqual(p.changed, [2000000])
|
|
73
|
+
assert.deepEqual(p.requests.filter(x => x.method === 'transport.uart.probe').map(x => x.payload.data.length), [32, 32, 128, 128, 512, 512])
|
|
74
|
+
assert.equal(p.requests.filter(x => x.method === 'transport.uart.commit').length, 1)
|
|
75
|
+
assert.equal(p.session.capabilities.uartBaud.current, 2000000)
|
|
76
|
+
assert.ok(p.requests.every(x => x.state !== 'ready'), 'no request or event can observe ready before verification')
|
|
77
|
+
assert.equal(p.session.state, 'ready')
|
|
78
|
+
} finally { p.session.close() }
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('corrupt echoes and adapter rejection recover at baseline and select first passing lower speed', async () => {
|
|
82
|
+
const p = peer({ corruptSpeeds: [2000000], rejectSpeeds: [1500000] })
|
|
83
|
+
try {
|
|
84
|
+
await p.session.negotiate({ drainMs: 0, probeRepl: false })
|
|
85
|
+
assert.deepEqual(p.changed, [2000000, 115200, 1500000, 115200, 921600])
|
|
86
|
+
assert.deepEqual(p.requests.filter(x => x.method === 'transport.uart.prepare').map(x => x.payload.baudRate), [2000000, 1500000, 921600])
|
|
87
|
+
assert.equal(p.requests.filter(x => x.method === 'hello' && x.payload.acquire).length, 3)
|
|
88
|
+
assert.equal(p.session.capabilities.uartBaud.current, 921600)
|
|
89
|
+
} finally { p.session.close() }
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('all high speeds can fail while retaining a fully verified 115200 connection', async () => {
|
|
93
|
+
const p = peer({ corruptSpeeds: UART_BAUD_CANDIDATES.slice(0, -1) })
|
|
94
|
+
try {
|
|
95
|
+
await p.session.negotiate({ drainMs: 0, probeRepl: false })
|
|
96
|
+
assert.deepEqual(p.requests.filter(x => x.method === 'transport.uart.prepare').map(x => x.payload.baudRate), UART_BAUD_CANDIDATES)
|
|
97
|
+
assert.equal(p.session.capabilities.uartBaud.current, 115200)
|
|
98
|
+
assert.equal(p.session.state, 'ready')
|
|
99
|
+
} finally { p.session.close() }
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('a timed out new-rate probe and a reopen-triggered reset both recover before the next candidate', async () => {
|
|
103
|
+
for (const options of [{ silentSpeeds: [2000000] }, { corruptFrames: [2000000] }, { resetOnChange: true }]) {
|
|
104
|
+
const p = peer(options)
|
|
105
|
+
try {
|
|
106
|
+
await p.session.negotiate({ drainMs: 0, probeRepl: false })
|
|
107
|
+
assert.equal(p.session.capabilities.uartBaud.current, options.resetOnChange ? 115200 : 1500000)
|
|
108
|
+
assert.ok(p.sleeps.some(ms => ms >= (options.resetOnChange ? 1000 : 150)), 'wait for release completion or independent rollback before reacquiring')
|
|
109
|
+
} finally { p.session.close() }
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('a lost commit acknowledgement retries the idempotent token without downgrading', async () => {
|
|
114
|
+
const p = peer({ dropCommit: true })
|
|
115
|
+
try {
|
|
116
|
+
await p.session.negotiate({ drainMs: 0, probeRepl: false })
|
|
117
|
+
assert.deepEqual(p.changed, [2000000])
|
|
118
|
+
const commits = p.requests.filter(x => x.method === 'transport.uart.commit')
|
|
119
|
+
assert.equal(commits.length, 2)
|
|
120
|
+
assert.equal(commits[0].payload.token, commits[1].payload.token)
|
|
121
|
+
} finally { p.session.close() }
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('lost prepare and committed-session ACKs wait full rollback bounds after the last failed release', async () => {
|
|
125
|
+
for (const options of [{ lostPrepare: true }, { lostCommitAndRelease: true }]) {
|
|
126
|
+
const p = peer(options)
|
|
127
|
+
try {
|
|
128
|
+
await p.session.negotiate({ drainMs: 0, probeRepl: false })
|
|
129
|
+
assert.ok(p.sleeps.includes(options.lostPrepare ? 5250 : 15250), 'prepare delay and lease renewal must not shorten the recovery window')
|
|
130
|
+
assert.equal(p.session.capabilities.uartBaud.current, 1500000)
|
|
131
|
+
} finally { p.session.close() }
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('a timed out adapter retune receives cancellation before baseline recovery', async () => {
|
|
136
|
+
const p = peer(), original = p.transport.setBaudRate
|
|
137
|
+
let aborted = false
|
|
138
|
+
p.transport.setBaudRate = (speed, options) => speed !== 2000000 ? original(speed, options) : new Promise((_, reject) => {
|
|
139
|
+
options.signal.addEventListener('abort', () => { aborted = true; reject(options.signal.reason) }, { once: true })
|
|
140
|
+
})
|
|
141
|
+
try {
|
|
142
|
+
await p.session.negotiate({ drainMs: 0, probeRepl: false })
|
|
143
|
+
assert.equal(aborted, true)
|
|
144
|
+
assert.equal(p.session.capabilities.uartBaud.current, 1500000)
|
|
145
|
+
} finally { p.session.close() }
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('USB owners, old firmware and transports lacking a retune method keep baseline', async () => {
|
|
149
|
+
for (const options of [{ owner: 'usb' }, { owner: 'usj' }, { owner: 'tcp' }, { support: false }, { noAdapter: true }]) {
|
|
150
|
+
const p = peer(options)
|
|
151
|
+
if (options.noAdapter) delete p.transport.setBaudRate
|
|
152
|
+
try {
|
|
153
|
+
await p.session.negotiate({ drainMs: 0, probeRepl: false })
|
|
154
|
+
assert.deepEqual(p.changed, [])
|
|
155
|
+
assert.ok(!p.requests.some(x => x.method.startsWith('transport.uart.')))
|
|
156
|
+
} finally { p.session.close() }
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('public connect publishes ready only after runtime speed selection', async () => {
|
|
161
|
+
const p = peer(), events = []
|
|
162
|
+
const device = createDevice({ transport: p.transport })
|
|
163
|
+
device.on('state', event => events.push(event.state))
|
|
164
|
+
device.on('diagnostic', event => { if (event.event === 'uart.baud.selected') events.push('selected') })
|
|
165
|
+
try {
|
|
166
|
+
await device.connect({ probeRepl: false })
|
|
167
|
+
assert.ok(events.indexOf('selected') >= 0)
|
|
168
|
+
assert.ok(events.indexOf('ready') > events.indexOf('selected'))
|
|
169
|
+
} finally { await device.disconnect() }
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('public passive identity probes can explicitly disable retuning', async () => {
|
|
173
|
+
const p = peer(), device = createDevice({ transport: p.transport })
|
|
174
|
+
for (const autoBaud of [null, 'false', 0]) await assert.rejects(device.connect({ autoBaud }), { name: 'TypeError' })
|
|
175
|
+
try {
|
|
176
|
+
await device.connect({ probeRepl: false, autoBaud: false })
|
|
177
|
+
assert.deepEqual(p.changed, [])
|
|
178
|
+
assert.ok(!p.requests.some(x => x.method.startsWith('transport.uart.')))
|
|
179
|
+
} finally { await device.disconnect() }
|
|
180
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
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 { cliBundlesCore, declaredCoreSupports, highestStableVersion, inMinorRange, minorRange, validateVersionStep } from '../scripts/version-policy.mjs'
|
|
6
|
+
|
|
7
|
+
test('minor dependency ranges accept patch updates and reject adjacent minor or major versions', () => {
|
|
8
|
+
for (const version of ['0.5.0', '0.5.1', '0.5.99']) assert.equal(inMinorRange('~0.5.0', version), true)
|
|
9
|
+
for (const version of ['0.4.9', '0.6.0', '1.5.0']) assert.equal(inMinorRange('~0.5.0', version), false)
|
|
10
|
+
for (const value of ['^0.5.0', '0.5.1', '~0.5.1', '*', '~0.05.0']) assert.throws(() => minorRange(value))
|
|
11
|
+
assert.equal(declaredCoreSupports('0.5.1', '0.5.1'), true)
|
|
12
|
+
assert.equal(declaredCoreSupports('0.5.1', '0.5.2'), false)
|
|
13
|
+
assert.equal(declaredCoreSupports('~0.5.0', '0.5.2'), true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('only the next patch, minor or major version matches its declared release type', () => {
|
|
17
|
+
for (const [kind, next] of [['patch', '0.5.2'], ['minor', '0.6.0'], ['major', '1.0.0']]) {
|
|
18
|
+
assert.doesNotThrow(() => validateVersionStep('0.5.1', next, kind))
|
|
19
|
+
}
|
|
20
|
+
for (const [kind, next] of [['patch', '0.6.0'], ['patch', '0.5.3'], ['minor', '0.5.2'], ['minor', '0.7.0'], ['major', '0.6.0'], ['major', '1.1.0'], ['feature', '0.6.0']]) {
|
|
21
|
+
assert.throws(() => validateVersionStep('0.5.1', next, kind))
|
|
22
|
+
}
|
|
23
|
+
assert.throws(() => validateVersionStep('0.5.1', '0.5.2-rc.1', 'patch'))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('registry baseline is the numerically highest stable published version', () => {
|
|
27
|
+
assert.equal(highestStableVersion(['0.9.9', '0.10.1', '0.10.0', '0.11.0-beta.1', '0.0.0-stage', '1.0.0']), '1.0.0')
|
|
28
|
+
assert.equal(highestStableVersion(['0.0.0-stage']), undefined)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('a bundled CLI identifies the actual Core patch even when its range allows others', () => {
|
|
32
|
+
const cli = { devDependencies: { '@esp-vision/device-core': '~0.5.0' }, espVision: { resolved: { deviceCore: '0.5.1' } } }
|
|
33
|
+
assert.equal(cliBundlesCore(cli, '0.5.1'), true)
|
|
34
|
+
assert.equal(cliBundlesCore(cli, '0.5.2'), false)
|
|
35
|
+
assert.equal(cliBundlesCore({ devDependencies: { '@esp-vision/device-core': '0.5.1' } }, '0.5.1'), true)
|
|
36
|
+
assert.equal(cliBundlesCore({ devDependencies: { '@esp-vision/device-core': '~0.5.0' } }, '0.5.1'), false)
|
|
37
|
+
})
|