fix: support resumable Cloud uploads in the public demo

This commit is contained in:
archipelago
2026-10-05 14:41:28 -04:00
parent 5aa74d0513
commit b8266c2872
6 changed files with 367 additions and 27 deletions
+26
View File
@@ -559,3 +559,29 @@ existing-signer verification. Dev serving is verified byte-for-byte with SHA256
The operator reports “works, we can proceed” after the phone save/open/cancel
check. This physical companion download gate is **accepted (2026-10-05)**. The APK is staged for fleet OTA/ISO and official/demo downloads;
only the dev-box test download is updated now. No fleet/public release is claimed.
### Demo upload compatibility — 2026-10-05
The prior demo backend lacked TUS, returned folder metadata for completed files,
and only accepted JSON-body renames. Added the actual Cloud upload protocol with
per-visitor sessions, reserved-byte quota, committed offsets, checksum metadata,
query-based rename and partial cancellation. Literal encoded filenames survive
unchanged. Deleting seeded files cannot subtract uncharged bytes from the quota;
deleting a folder releases its pending upload reservations.
Four real HTTP scenarios run the production `resumableUpload` client against an
isolated demo process and pass: lost chunk/save responses with exact5MiB+123bytes,
visitor isolation, zero-byte/replacement/cancellation, simultaneous quota, stale
offsets/oversized chunks and a real interrupted TCP request. The first run's10s
cold-start limit failed before startup; the bounded60s run passes. Evidence:
`/tmp/archy-190-demo-upload-final-2.log`.
The existing mesh/federation parity harness initially discarded demo cookies,
creating a new visitor on every request. It now retains its session and always
runs demo-only on loopback, never against the live container runtime. It also
found two newly missing radio configuration handlers. Demo now explicitly reports
hardware unavailable and refuses to claim an applied radio configuration. The
full parity run passes, including those assertions:
`/tmp/archy-190-demo-rpc-parity-final-2.log`. Both checks are release harness stages.
These changes are locally qualified; the public demo remains unchanged pending
final release, fresh AIUI packaging and deployed browser acceptance.
+86
View File
@@ -0,0 +1,86 @@
// In-memory File Browser TUS subset used by the public demo. Each visitor's
// reservations and bytes belong to their existing isolated, expiring session.
export function installDemoUploads(app, { currentStore, quota, normalize, parent, base, type }) {
const route = '/app/filebrowser/api/tus/*'
const number = value => typeof value === 'string' && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null
const locate = req => normalize(req.params[0] || '')
const headers = (res, upload) => res.set({
'Tus-Resumable': '1.0.0', 'Upload-Offset': String(upload.offset),
'Upload-Length': String(upload.length), 'Cache-Control': 'no-store',
})
const complete = (files, name, upload) => {
const data = Buffer.concat(upload.chunks, upload.length)
files.contents[name] = data
files.tree[parent(name)].push({ name: base(name), path: name, size: data.length,
modified: new Date().toISOString(), isDir: false, type: type(base(name)) })
files.bytes += data.length
files.reserved -= upload.length
files.uploaded.add(name)
files.uploads.delete(name)
}
app.head(route, (req, res) => {
const upload = currentStore().files.uploads.get(locate(req))
if (!upload) return res.sendStatus(404)
headers(res, upload).status(200).end()
})
app.post(route, (req, res) => {
const name = locate(req)
const files = currentStore().files
const length = number(req.get('Upload-Length'))
if (req.get('Tus-Resumable') !== '1.0.0' || length === null || name === '/' ||
name.split('/').some(p => p === '.' || p === '..' || p.includes('\0'))) return res.sendStatus(400)
if (!files.tree[parent(name)]) return res.sendStatus(404)
if (files.uploads.has(name) || files.tree[parent(name)].some(e => e.path === name)) return res.sendStatus(409)
// Reserve the whole declared size, so concurrent uploads cannot evade quota.
// Bound empty/abandoned session entries independently of their byte length.
if (files.bytes + files.reserved + length > quota || files.uploads.size >= 64) return res.sendStatus(507)
const upload = { length, offset: 0, chunks: [], writing: false }
files.uploads.set(name, upload)
files.reserved += length
if (!length) complete(files, name, upload)
headers(res, upload).location(req.path).status(201).end()
})
app.patch(route, async (req, res) => {
const name = locate(req)
const files = currentStore().files
const upload = files.uploads.get(name)
if (!upload) return res.sendStatus(404)
if (req.get('Tus-Resumable') !== '1.0.0') return res.sendStatus(412)
if (req.get('Content-Type') !== 'application/offset+octet-stream') return res.sendStatus(415)
if (upload.writing || number(req.get('Upload-Offset')) !== upload.offset) return headers(res, upload).status(409).end()
upload.writing = true
const chunks = []
let size = 0
try {
for await (const chunk of req) {
size += chunk.length
if (size > 2 * 1024 * 1024 || size > upload.length - upload.offset) {
res.status(413).end()
return
}
chunks.push(chunk)
}
// A simultaneous cancellation must never resurrect its staging file.
if (files.uploads.get(name) !== upload) return res.sendStatus(404)
upload.chunks.push(...chunks)
upload.offset += size
if (upload.offset === upload.length) complete(files, name, upload)
headers(res, upload).status(204).end()
} catch {
// An interrupted chunk does not advance the committed offset. HEAD tells
// the reconnecting client exactly where to resume.
if (!res.headersSent && !req.destroyed) res.sendStatus(400)
} finally {
upload.writing = false
}
})
app.delete(route, (req, res) => {
const files = currentStore().files
const name = locate(req)
const upload = files.uploads.get(name)
if (!upload) return res.sendStatus(404)
files.reserved -= upload.length
files.uploads.delete(name)
res.status(204).end()
})
}
+78 -23
View File
@@ -19,6 +19,7 @@ import { fileURLToPath } from 'url'
import Docker from 'dockerode'
import { AsyncLocalStorage } from 'node:async_hooks'
import crypto from 'crypto'
import { installDemoUploads } from './demo-uploads.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -209,7 +210,8 @@ const corsOptions = {
app.use(cors(corsOptions))
// Skip JSON body parsing for filebrowser upload routes (binary file bodies)
app.use((req, res, next) => {
if (req.path.startsWith('/app/filebrowser/api/resources') && req.method === 'POST') {
if ((req.path.startsWith('/app/filebrowser/api/resources') && req.method === 'POST') ||
(req.path.startsWith('/app/filebrowser/api/tus') && req.method === 'PATCH')) {
return next()
}
express.json({ limit: '50mb' })(req, res, next)
@@ -3246,6 +3248,19 @@ app.post('/rpc/v1', (req, res) => {
// =====================================================================
// Mesh Networking (LoRa radio via Meshcore)
// =====================================================================
case 'mesh.rnode-config': {
return res.json({ result: {
settings: { enabled: true, port: null, frequency: 869525000,
bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17,
airtime_limit_short: null, airtime_limit_long: null },
live: null, live_error: 'Radio hardware is unavailable in this demo.',
} })
}
case 'mesh.rnode-config-apply': {
// Never make a hardware claim or execute a radio command in the demo.
return res.json({ result: { applied: false, confirmed: false, live: null,
message: 'Radio settings cannot be applied in this demo. Connect to your own node to configure its radio.' } })
}
case 'mesh.status': {
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
// Stateful enable/disable so the dev UI can demo the global
@@ -5242,7 +5257,8 @@ const FB_QUOTA_BYTES = Number(process.env.DEMO_FILE_QUOTA_BYTES) || 50 * 1024 *
function fbNormalize(raw) {
// → leading slash, no trailing slash (root stays '/')
const p = '/' + decodeURIComponent(raw || '').split('/').filter(Boolean).join('/')
// Express already decodes path parameters. Preserve literal %, + and ? names.
const p = '/' + (raw || '').split('/').filter(Boolean).join('/')
return p === '/' ? '/' : p.replace(/\/+$/, '')
}
function fbParent(p) {
@@ -5275,11 +5291,18 @@ function fbListResponse(res, items) {
})
}
installDemoUploads(app, { currentStore, quota: FB_QUOTA_BYTES, normalize: fbNormalize, parent: fbParent, base: fbBase, type: fbType })
// FileBrowser list resources (root: /api/resources or /api/resources/)
app.get(['/app/filebrowser/api/resources', '/app/filebrowser/api/resources/*'], (req, res) => {
const dir = fbNormalize(req.params[0] || '')
const items = currentStore().files.tree[dir] || []
fbListResponse(res, items)
const { tree, contents } = currentStore().files
if (tree[dir]) return fbListResponse(res, tree[dir])
const entry = (tree[fbParent(dir)] || []).find(e => e.path === dir)
if (!entry) return res.sendStatus(404)
const checksums = req.query.checksum === 'sha256' && contents[dir] !== undefined
? { sha256: crypto.createHash('sha256').update(contents[dir]).digest('hex') } : undefined
res.json({ ...entry, checksums })
})
// FileBrowser POST = upload a file OR create a folder (trailing slash ⇒ folder)
@@ -5301,33 +5324,38 @@ app.post('/app/filebrowser/api/resources/*', (req, res) => {
return res.sendStatus(200)
}
// File upload — collect body with a quota guard.
// Reserve incoming bytes immediately, including concurrent legacy/TUS writes.
const chunks = []
let size = 0
let aborted = false
const release = () => { store.files.reserved -= size; size = 0 }
req.on('data', (c) => {
size += c.length
if (store.files.bytes + size > FB_QUOTA_BYTES) {
if (aborted) return
if (store.files.bytes + store.files.reserved + c.length > FB_QUOTA_BYTES) {
aborted = true
req.destroy()
release()
res.status(507).send('Demo storage quota exceeded')
return
}
size += c.length
store.files.reserved += c.length
chunks.push(c)
})
req.on('end', () => {
if (aborted) return res.status(507).send('Demo storage quota exceeded (50 MB)')
if (aborted) return
const buf = Buffer.concat(chunks)
// Replace existing entry of the same name (override=true).
const existing = tree[parent].find(e => e.name === name && !e.isDir)
if (existing) store.files.bytes -= existing.size
tree[parent] = tree[parent].filter(e => !(e.name === name && !e.isDir))
const type = fbType(name)
tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type })
contents[full] = type === 'text' ? buf.toString('utf-8') : buf
release()
if (!tree[parent] || store.files.uploads.has(full) || tree[parent].some(e => e.name === name && e.isDir)) return res.sendStatus(409)
if (store.files.uploaded.has(full)) store.files.bytes -= Buffer.byteLength(contents[full])
tree[parent] = tree[parent].filter(e => e.name !== name)
tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type: fbType(name) })
contents[full] = buf
store.files.bytes += buf.length
store.files.uploaded.add(full)
res.sendStatus(200)
})
req.on('error', () => { if (!res.headersSent) res.sendStatus(400) })
req.on('aborted', () => { aborted = true; release() })
req.on('error', () => { aborted = true; release(); if (!res.headersSent) res.sendStatus(400) })
})
// FileBrowser delete (file or folder + its subtree)
@@ -5337,10 +5365,16 @@ app.delete('/app/filebrowser/api/resources/*', (req, res) => {
const full = fbNormalize(req.params[0] || '')
const parent = fbParent(full)
if (tree[parent]) {
const entry = tree[parent].find(e => e.path === full)
if (entry && !entry.isDir) store.files.bytes -= entry.size || 0
if (store.files.uploaded.delete(full)) store.files.bytes -= Buffer.byteLength(contents[full])
tree[parent] = tree[parent].filter(e => e.path !== full)
}
// Cancel unfinished sessions in the deleted subtree too.
for (const [name, upload] of store.files.uploads) {
if (name === full || name.startsWith(full + '/')) {
store.files.reserved -= upload.length
store.files.uploads.delete(name)
}
}
// Recursively drop a directory's children.
if (tree[full]) {
const stack = [full]
@@ -5348,7 +5382,10 @@ app.delete('/app/filebrowser/api/resources/*', (req, res) => {
const d = stack.pop()
for (const e of tree[d] || []) {
if (e.isDir) stack.push(e.path)
else { store.files.bytes -= e.size || 0; delete contents[e.path] }
else {
if (store.files.uploaded.delete(e.path)) store.files.bytes -= Buffer.byteLength(contents[e.path])
delete contents[e.path]
}
}
delete tree[d]
}
@@ -5362,11 +5399,29 @@ app.patch('/app/filebrowser/api/resources/*', (req, res) => {
const store = currentStore()
const { tree, contents } = store.files
const full = fbNormalize(req.params[0] || '')
const dest = fbNormalize((req.body && req.body.destination) || '')
let destination = req.body?.destination || ''
if (req.query.action === 'rename') {
try { destination = decodeURIComponent(String(req.query.destination || '')) }
catch { return res.sendStatus(400) }
}
const dest = fbNormalize(destination)
if (!dest || dest === '/') return res.sendStatus(400)
const parent = fbParent(full)
const entry = (tree[parent] || []).find(e => e.path === full)
if (!entry) return res.sendStatus(404)
const newParent = fbParent(dest)
if (!tree[newParent]) return res.sendStatus(404)
if (dest === full) return res.sendStatus(200)
const replaced = tree[newParent].find(e => e.path === dest)
if (replaced && (replaced.isDir || req.query.override !== 'true')) return res.sendStatus(409)
if (store.files.uploaded.has(dest)) {
store.files.bytes -= Buffer.byteLength(contents[dest])
store.files.uploaded.delete(dest)
}
tree[parent] = tree[parent].filter(e => e !== entry)
tree[newParent] = tree[newParent].filter(e => e.path !== dest)
tree[newParent].push(entry)
if (store.files.uploaded.delete(full)) store.files.uploaded.add(dest)
const newName = fbBase(dest)
entry.name = newName
entry.path = dest
@@ -6071,7 +6126,7 @@ function makeSessionStore() {
userState: seedUserState(),
mockState: seedMockState(),
bitcoinRelayMockState: structuredClone(SEED_BTCRELAY),
files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0 },
files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0, reserved: 0, uploads: new Map(), uploaded: new Set() },
// Mesh chat mutable state: messages sent this session + attachment bytes
// (cid → {mime, filename, b64, thumb_b64}). Per-session so one demo
// visitor's uploads are never visible to another.
@@ -6348,7 +6403,7 @@ async function hydrateRealTestnetTxids() {
} catch { /* offline — keep mock hashes */ }
}
server.listen(PORT, '0.0.0.0', async () => {
server.listen(PORT, process.env.MOCK_BACKEND_HOST || '0.0.0.0', async () => {
const runtime = await isContainerRuntimeAvailable()
// Initialize package data from Docker
+159
View File
@@ -0,0 +1,159 @@
// Exercise the actual browser upload protocol against an isolated demo server.
import assert from 'node:assert/strict'
import { after, before, test } from 'node:test'
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import { readFile } from 'node:fs/promises'
import { File } from 'node:buffer'
import { createHash, randomBytes } from 'node:crypto'
import ts from 'typescript'
import http from 'node:http'
const root = new URL('../', import.meta.url)
const source = await readFile(new URL('src/api/resumable-upload.ts', root), 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } }).outputText
const { resumableUpload } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
globalThis.window = new EventTarget()
globalThis.document = new EventTarget()
document.visibilityState = 'visible'
let child, origin, output = ''
before(async () => {
const server = createServer()
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
await new Promise(resolve => server.close(resolve))
origin = `http://127.0.0.1:${port}`
child = spawn(process.execPath, ['mock-backend.js'], { cwd: root, env: {
...process.env, DEMO: '1', MOCK_BACKEND_HOST: '127.0.0.1',
MOCK_BACKEND_PORT: String(port), DEMO_FILE_QUOTA_BYTES: String(8 * 1024 * 1024),
}, stdio: ['ignore', 'pipe', 'pipe'] })
child.stdout.on('data', data => { output = (output + data).slice(-10000) })
child.stderr.on('data', data => { output = (output + data).slice(-10000) })
for (let n = 0; n < 600; n++) {
try { if ((await fetch(`${origin}/health`)).ok) return } catch {}
if (child.exitCode !== null) break
await new Promise(resolve => setTimeout(resolve, 100))
}
throw new Error(`Demo failed to start: ${output}`)
})
after(async () => {
if (child && child.exitCode === null) {
const exited = new Promise(resolve => child.once('exit', resolve))
child.kill('SIGTERM')
await exited
}
})
async function visitor() {
const response = await fetch(`${origin}/health`)
const cookie = response.headers.getSetCookie().find(v => v.startsWith('demo_sid='))?.split(';')[0]
assert.ok(cookie, 'a new visitor must receive an isolated session')
return (url, init = {}) => fetch(url.startsWith('http') ? url : origin + url, {
...init, headers: { ...init.headers, Cookie: cookie },
})
}
const prefix = '/app/filebrowser/api'
const path = p => p.split('/').map(encodeURIComponent).join('/')
const begin = (client, name, size) => client(`${prefix}/tus${path(name)}`, {
method: 'POST', headers: { 'Upload-Length': String(size), 'Tus-Resumable': '1.0.0' },
})
const patch = (client, name, offset, body) => client(`${prefix}/tus${path(name)}`, {
method: 'PATCH', headers: { 'Upload-Offset': String(offset), 'Tus-Resumable': '1.0.0',
'Content-Type': 'application/offset+octet-stream' }, body,
})
test('real client resumes lost chunk/rename replies; exact bytes, encoded name and visitor isolation', async () => {
const alice = await visitor(), bob = await visitor()
const data = randomBytes(5 * 1024 * 1024 + 123)
const name = 'literal %2F + ? # é.bin'
let droppedChunk = false, droppedRename = false, last = 0
const transport = async (url, init) => {
const response = await alice(url, init)
if (init?.method === 'PATCH' && url.includes('/tus/') && !droppedChunk) {
assert.equal(response.status, 204)
droppedChunk = true
throw new TypeError('simulated lost chunk reply')
}
if (init?.method === 'PATCH' && url.includes('action=rename') && !droppedRename) {
assert.equal(response.status, 200)
droppedRename = true
throw new TypeError('simulated lost rename reply')
}
return response
}
await resumableUpload(`${origin}/app/filebrowser`, '/Documents', new File([data], name), transport,
{ signal: new AbortController().signal, onProgress: value => { last = value } })
assert.ok(droppedChunk && droppedRename)
assert.equal(last, data.length)
const url = `${prefix}/resources${path(`/Documents/${name}`)}?checksum=sha256`
const saved = await (await alice(url)).json()
assert.equal(saved.size, data.length)
assert.equal(saved.checksums.sha256, createHash('sha256').update(data).digest('hex'))
assert.deepEqual(Buffer.from(await (await alice(`${prefix}/raw${path(`/Documents/${name}`)}`)).arrayBuffer()), data)
assert.equal((await bob(url)).status, 404)
assert.equal((await bob(`${prefix}/raw${path(`/Documents/${name}`)}`)).status, 404)
const listed = await (await alice(`${prefix}/resources/Documents`)).json()
assert.ok(listed.items.some(e => e.name === name))
assert.ok(!listed.items.some(e => e.name.startsWith('.archy-upload-')))
})
test('zero bytes, replacement and cancellation leave only completed destinations', async () => {
const client = await visitor()
for (const bytes of [Buffer.from('old'), Buffer.alloc(0), Buffer.from('new')]) {
await resumableUpload(`${origin}/app/filebrowser`, '/Documents', new File([bytes], 'replace.bin'), client,
{ signal: new AbortController().signal, onProgress() {} })
assert.deepEqual(Buffer.from(await (await client(`${prefix}/raw/Documents/replace.bin`)).arrayBuffer()), bytes)
}
const controller = new AbortController()
await assert.rejects(resumableUpload(`${origin}/app/filebrowser`, '/Documents',
new File([randomBytes(3 * 1024 * 1024)], 'cancel.bin'), client, {
signal: controller.signal, onProgress(n) { if (n > 0) controller.abort() },
}), { name: 'AbortError' })
const listed = await (await client(`${prefix}/resources/Documents`)).json()
assert.ok(!listed.items.some(e => e.name.startsWith('.archy-upload-') || e.name === 'cancel.bin'))
assert.equal((await begin(client, '/Documents/quota.part', 7 * 1024 * 1024)).status, 201)
})
test('quota includes simultaneous reservations; stale offsets and oversized chunks preserve committed bytes', async () => {
const client = await visitor()
// Deleting a seeded file must not make the byte counter negative.
assert.equal((await client(`${prefix}/resources/Documents/bitcoin-whitepaper-notes.md`, { method: 'DELETE' })).status, 200)
assert.equal((await begin(client, '/Documents/a.part', 6 * 1024 * 1024)).status, 201)
assert.equal((await begin(client, '/Documents/b.part', 3 * 1024 * 1024)).status, 507)
assert.equal((await patch(client, '/Documents/a.part', 1, Buffer.from('bad'))).status, 409)
assert.equal((await patch(client, '/Documents/a.part', 0, Buffer.alloc(2 * 1024 * 1024 + 1))).status, 413)
assert.equal((await client(`${prefix}/tus/Documents/a.part`, { method: 'HEAD' })).headers.get('Upload-Offset'), '0')
assert.equal((await patch(client, '/Documents/a.part', 0, Buffer.from('abc'))).status, 204)
assert.equal((await patch(client, '/Documents/a.part', 0, Buffer.from('abc'))).status, 409)
assert.equal((await client(`${prefix}/tus/Documents/a.part`, { method: 'HEAD' })).headers.get('Upload-Offset'), '3')
assert.equal((await client(`${prefix}/tus/Documents/a.part`, { method: 'DELETE' })).status, 204)
assert.equal((await begin(client, '/Documents/b.part', 8 * 1024 * 1024)).status, 201)
})
test('interrupted chunk resumes from committed offset; deleting a folder releases pending reservations', async () => {
const response = await fetch(`${origin}/health`)
const cookie = response.headers.getSetCookie().find(v => v.startsWith('demo_sid='))?.split(';')[0]
const client = (url, init = {}) => fetch(origin + url, { ...init, headers: { ...init.headers, Cookie: cookie } })
await client(`${prefix}/resources/Temporary/`, { method: 'POST' })
assert.equal((await begin(client, '/Temporary/a.part', 6)).status, 201)
const request = http.request(`${origin}${prefix}/tus/Temporary/a.part`, { method: 'PATCH', headers: {
Cookie: cookie, 'Upload-Offset': '0', 'Tus-Resumable': '1.0.0',
'Content-Type': 'application/offset+octet-stream', 'Content-Length': '6',
} })
request.on('error', () => {})
request.write('abc')
// HEAD can run while the chunk is incomplete, but must not report those bytes
// committed. Abort the real TCP request, then resume the exact same offset.
await new Promise(resolve => setTimeout(resolve, 100))
assert.equal((await client(`${prefix}/tus/Temporary/a.part`, { method: 'HEAD' })).headers.get('Upload-Offset'), '0')
const closed = new Promise(resolve => request.once('close', resolve))
request.destroy()
await closed
let result
for (let n = 0; n < 20; n++) {
result = await patch(client, '/Temporary/a.part', 0, Buffer.from('abcdef'))
if (result.status !== 409) break
await new Promise(resolve => setTimeout(resolve, 25))
}
assert.equal(result.status, 204)
assert.equal(await (await client(`${prefix}/raw/Temporary/a.part`)).text(), 'abcdef')
assert.equal((await begin(client, '/Temporary/b.part', 7 * 1024 * 1024)).status, 201)
await client(`${prefix}/resources/Temporary`, { method: 'DELETE' })
assert.equal((await client(`${prefix}/tus/Temporary/b.part`, { method: 'HEAD' })).status, 404)
assert.equal((await begin(client, '/Documents/c.part', 8 * 1024 * 1024)).status, 201)
})
+15 -4
View File
@@ -96,7 +96,8 @@ console.log(`LIVE: booting mock-backend.js on :${port}`)
const child = spawn(process.execPath, [MOCK], {
cwd: UI_ROOT,
env: { ...process.env, MOCK_BACKEND_PORT: String(port) },
// Never permit this regression harness to contact the live container runtime.
env: { ...process.env, DEMO: '1', MOCK_BACKEND_HOST: '127.0.0.1', MOCK_BACKEND_PORT: String(port) },
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.resume()
@@ -108,12 +109,15 @@ child.on('exit', () => {
})
const base = `http://127.0.0.1:${port}`
let cookie = ''
const rpc = async (method, params) => {
const resp = await fetch(`${base}/rpc/v1`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json', Cookie: cookie },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
})
const session = resp.headers.getSetCookie().find(value => value.startsWith('demo_sid='))
if (session) cookie = session.split(';')[0]
if (!resp.ok) throw new Error(`${method} → HTTP ${resp.status}`)
const body = await resp.json()
if (body.error) throw new Error(`${method} → RPC error ${body.error.code}: ${body.error.message}`)
@@ -122,19 +126,26 @@ const rpc = async (method, params) => {
try {
// Poll until the server answers (bounded).
const deadline = Date.now() + 10000
const deadline = Date.now() + 60000
for (;;) {
if (childExited) throw new Error('mock-backend exited before becoming ready')
try {
await rpc('server.echo', { message: 'parity-probe' })
break
} catch {
if (Date.now() > deadline) throw new Error('mock-backend did not become ready within 10s')
if (Date.now() > deadline) throw new Error('mock-backend did not become ready within 60s')
await new Promise((r) => setTimeout(r, 200))
}
}
pass('mock-backend answered on the ephemeral port')
const radio = await rpc('mesh.rnode-config')
if (radio.live === null && radio.live_error.includes('demo')) pass('radio settings report unavailable demo hardware')
else fail('demo radio status implies real hardware')
const applied = await rpc('mesh.rnode-config-apply', { settings: radio.settings })
if (applied.applied === false && applied.confirmed === false && applied.message.includes('demo')) pass('demo never claims a radio configuration was applied')
else fail('demo radio apply claims hardware success')
// 1. Peer aliasing round-trip (the FED-04 headline: renaming a peer sticks).
const peers = await rpc('mesh.contacts-list')
const target = peers.contacts.find((c) => c.pubkey)
+3
View File
@@ -72,6 +72,9 @@ summary() {
stage "git-diff-check" git diff --check
stage "mirror-gate-regression" python3 scripts/tests/test_git_mirrors.py
stage "iso-boot-runner-regression" python3 scripts/tests/test_iso_qemu_runner.py
stage "demo-rpc-parity" timeout 120 node neode-ui/scripts/mock-rpc-parity.mjs
stage "demo-resumable-uploads" timeout 120 node --test neode-ui/scripts/demo-upload-test.mjs
stage "companion-signature-regression" python3 scripts/tests/test_companion_apk_verification.py
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
stage "manifest-shell" python3 scripts/check-manifest-shell.py