feat(01-02): demo answers every mesh/federation RPC the UI calls (FED-04)
Demo images / Build & push demo images (push) Successful in 4m14s
Demo images / Build & push demo images (push) Successful in 4m14s
Ten methods the UI calls had no case at all in mock-backend.js, so the demo
answered them with "Method not found" and the frontend swallowed it in a
try/catch — peer renaming, scheduling, clear-all, the assistant panel and two
federation actions were all silently inert on the demo.
Each new handler mirrors its daemon counterpart and cites the Rust source it
mirrors, per the house convention above mesh.transport-advice:
mesh.contacts-list/-save typed_messages.rs (contacts merged over peers by
pubkey_hex; absent params leave stored fields)
mesh.clear-all status.rs ({ status: "cleared" })
mesh.schedule/list/cancel assistant.rs + scheduler.rs ScheduledMessage
mesh.assistant-status/-configure assistant.rs (key-presence semantics)
federation.cancel-request handlers.rs (outbound+sent only, notify defaults
true) — plus an outbound seed request, since
without one the demo's cancel path was
unexercisable
federation.notify-did-change handlers.rs ({ notified, failed, results })
mesh.peers and mesh.contacts-list now share one DEMO_MESH_PEERS list so they
cannot disagree about who is on the mesh, and the peer with no pubkey_hex is
omitted from contacts exactly as the daemon omits it.
scripts/mock-rpc-parity.mjs cross-references UI call sites against mock cases
and then drives a live scripted RPC sequence against an ephemeral-port
instance (MOCK_BACKEND_PORT). It matches only `method: '<x>'`, NOT bare string
literals: Mesh.vue and Federation.vue use the same dotted names as
resource-cache keys, and matching those would report permanent phantom gaps.
Fail-first proof: disabling the mesh.clear-all case makes the harness exit 1
naming the gap; restored, it exits 0 twice in a row with no stray listener.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
579398981f
commit
b8979f3687
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
// Demo/real RPC parity harness (FED-04).
|
||||
//
|
||||
// STATIC stage: cross-references every mesh.*/federation.* RPC method the UI
|
||||
// actually calls against the cases mock-backend.js implements, and reports any
|
||||
// the demo would answer with "Method not found".
|
||||
//
|
||||
// LIVE stage: boots mock-backend.js on an ephemeral port and drives a scripted
|
||||
// JSON-RPC sequence, asserting the handlers mutate demo state rather than
|
||||
// returning bare acknowledgements.
|
||||
//
|
||||
// Exits non-zero on any failure. There are deliberately no `|| fallback`
|
||||
// escapes anywhere in here: a failed spawn, a failed fetch or a missing field
|
||||
// must fail the run, never produce a green run that measured nothing.
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join, dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const UI_ROOT = resolve(HERE, '..')
|
||||
const MOCK = join(UI_ROOT, 'mock-backend.js')
|
||||
const SRC = join(UI_ROOT, 'src')
|
||||
|
||||
// Methods the UI calls that the mock is knowingly allowed to be missing.
|
||||
// Task 2 emptied this: any gap now fails the run.
|
||||
const KNOWN_GAPS = []
|
||||
|
||||
let failures = 0
|
||||
const fail = (msg) => {
|
||||
console.error(` ✗ ${msg}`)
|
||||
failures++
|
||||
}
|
||||
const pass = (msg) => console.log(` ✓ ${msg}`)
|
||||
|
||||
// ── STATIC ──────────────────────────────────────────────────────────────────
|
||||
// Only `method: 'mesh.x'` / `method: "mesh.x"` counts as a call site. A bare
|
||||
// string literal is NOT enough: Mesh.vue and Federation.vue use the same
|
||||
// dotted names as resource-cache keys (`key: 'mesh.self-did'`,
|
||||
// `key: 'federation.nodes'`), which are not RPC methods at all. Matching those
|
||||
// would report permanent phantom gaps and make this harness unfailable.
|
||||
const CALL_RE = /method:\s*['"]((?:mesh|federation)\.[a-z0-9-]+)['"]/g
|
||||
const CASE_RE = /case\s*['"]((?:mesh|federation)\.[a-z0-9-]+)['"]/g
|
||||
|
||||
function walk(dir) {
|
||||
const out = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
const st = statSync(p)
|
||||
if (st.isDirectory()) out.push(...walk(p))
|
||||
else if (/\.(ts|js|vue)$/.test(name)) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function collect(re, text, into) {
|
||||
let m
|
||||
while ((m = re.exec(text)) !== null) into.add(m[1])
|
||||
}
|
||||
|
||||
console.log('STATIC: cross-referencing UI call sites against mock handlers')
|
||||
const called = new Set()
|
||||
for (const file of walk(SRC)) collect(new RegExp(CALL_RE), readFileSync(file, 'utf8'), called)
|
||||
|
||||
const handled = new Set()
|
||||
collect(new RegExp(CASE_RE), readFileSync(MOCK, 'utf8'), handled)
|
||||
|
||||
const missing = [...called].filter((m) => !handled.has(m)).sort()
|
||||
const unexpected = missing.filter((m) => !KNOWN_GAPS.includes(m))
|
||||
|
||||
console.log(` UI calls ${called.size} mesh/federation methods; mock implements ${handled.size}`)
|
||||
if (missing.length === 0) {
|
||||
pass('every UI-called mesh/federation method has a mock handler')
|
||||
} else {
|
||||
for (const m of unexpected) fail(`no mock handler for ${m}`)
|
||||
for (const m of missing.filter((x) => KNOWN_GAPS.includes(x))) {
|
||||
console.log(` … known gap (allowed): ${m}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── LIVE ────────────────────────────────────────────────────────────────────
|
||||
const freePort = () =>
|
||||
new Promise((res, rej) => {
|
||||
const srv = createServer()
|
||||
srv.on('error', rej)
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const { port } = srv.address()
|
||||
srv.close(() => res(port))
|
||||
})
|
||||
})
|
||||
|
||||
const port = await freePort()
|
||||
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) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
child.stdout.resume()
|
||||
child.stderr.resume()
|
||||
|
||||
let childExited = false
|
||||
child.on('exit', () => {
|
||||
childExited = true
|
||||
})
|
||||
|
||||
const base = `http://127.0.0.1:${port}`
|
||||
const rpc = async (method, params) => {
|
||||
const resp = await fetch(`${base}/rpc/v1`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
})
|
||||
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}`)
|
||||
return body.result
|
||||
}
|
||||
|
||||
try {
|
||||
// Poll until the server answers (bounded).
|
||||
const deadline = Date.now() + 10000
|
||||
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')
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
}
|
||||
pass('mock-backend answered on the ephemeral port')
|
||||
|
||||
// 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)
|
||||
if (!target) throw new Error('mesh.contacts-list returned no contact with a pubkey')
|
||||
if (target.alias !== null) fail(`expected a fresh contact to have a null alias, got ${target.alias}`)
|
||||
|
||||
await rpc('mesh.contacts-save', { pubkey: target.pubkey, alias: 'Base Camp' })
|
||||
const after = await rpc('mesh.contacts-list')
|
||||
const renamed = after.contacts.find((c) => c.pubkey === target.pubkey)
|
||||
if (renamed?.alias === 'Base Camp') pass('mesh.contacts-save → contacts-list round-trips the alias')
|
||||
else fail(`alias did not persist: expected 'Base Camp', got ${JSON.stringify(renamed?.alias)}`)
|
||||
|
||||
// The daemon omits peers with no pubkey_hex; so must the demo.
|
||||
if (after.contacts.every((c) => c.pubkey)) pass('contacts-list omits peers without a pubkey, as the daemon does')
|
||||
else fail('contacts-list returned a contact with no pubkey')
|
||||
|
||||
// 2. Scheduling: schedule → list → cancel → list.
|
||||
const fireAt = Math.floor(Date.now() / 1000) + 3600
|
||||
const scheduled = await rpc('mesh.schedule-message', {
|
||||
body: 'mesh parity check',
|
||||
fire_at: fireAt,
|
||||
contact_id: 1,
|
||||
})
|
||||
if (typeof scheduled.id !== 'number') fail('mesh.schedule-message did not return a numeric id')
|
||||
|
||||
const listed = await rpc('mesh.list-scheduled')
|
||||
if (listed.messages.some((m) => m.id === scheduled.id)) pass('mesh.schedule-message → list-scheduled shows the entry')
|
||||
else fail('scheduled message missing from mesh.list-scheduled')
|
||||
|
||||
const cancelled = await rpc('mesh.cancel-scheduled', { id: scheduled.id })
|
||||
if (cancelled.cancelled !== true) fail('mesh.cancel-scheduled did not report a cancellation')
|
||||
const afterCancel = await rpc('mesh.list-scheduled')
|
||||
if (!afterCancel.messages.some((m) => m.id === scheduled.id)) pass('mesh.cancel-scheduled removed the entry')
|
||||
else fail('cancelled message still present in mesh.list-scheduled')
|
||||
|
||||
// Cancelling an unknown id must report false, not throw or lie.
|
||||
const ghost = await rpc('mesh.cancel-scheduled', { id: 999999 })
|
||||
if (ghost.cancelled === false) pass('cancel-scheduled reports false for an unknown id')
|
||||
else fail(`cancel-scheduled lied about an unknown id: ${JSON.stringify(ghost)}`)
|
||||
|
||||
// 3. clear-all empties this session's sent messages.
|
||||
await rpc('mesh.send-content-inline', {
|
||||
contact_id: 1,
|
||||
mime: 'text/plain',
|
||||
filename: 'parity.txt',
|
||||
bytes_b64: Buffer.from('parity').toString('base64'),
|
||||
})
|
||||
const withMsg = await rpc('mesh.messages', { limit: 500 })
|
||||
const cleared = await rpc('mesh.clear-all')
|
||||
if (cleared.status !== 'cleared') fail(`mesh.clear-all returned ${JSON.stringify(cleared)}`)
|
||||
const afterClear = await rpc('mesh.messages', { limit: 500 })
|
||||
if (afterClear.count < withMsg.count) pass('mesh.clear-all dropped this session\'s messages')
|
||||
else fail(`mesh.clear-all did not reduce the message count (${withMsg.count} → ${afterClear.count})`)
|
||||
|
||||
// 4. Assistant config round-trip.
|
||||
const status = await rpc('mesh.assistant-status')
|
||||
for (const k of ['enabled', 'model', 'trusted_only', 'backend', 'allowed_contacts', 'default_model', 'ollama_detected', 'claude_available', 'models', 'denied_askers']) {
|
||||
if (!(k in status)) fail(`mesh.assistant-status missing field '${k}'`)
|
||||
}
|
||||
const configured = await rpc('mesh.assistant-configure', { enabled: true, model: 'llama3.2:3b' })
|
||||
if (configured.enabled === true && configured.model === 'llama3.2:3b') pass('mesh.assistant-configure applies and echoes settings')
|
||||
else fail(`assistant-configure did not apply: ${JSON.stringify(configured)}`)
|
||||
const restatus = await rpc('mesh.assistant-status')
|
||||
if (restatus.enabled === true) pass('assistant config persists across calls')
|
||||
else fail('assistant config did not persist')
|
||||
|
||||
// 5. federation.cancel-request removes the outbound request.
|
||||
const pending = await rpc('federation.list-pending-requests')
|
||||
const outbound = pending.requests.find((r) => r.outbound && r.state === 'sent')
|
||||
if (!outbound) {
|
||||
fail('no outbound/sent request in the demo seed — cancel-request is unexercisable')
|
||||
} else {
|
||||
const res = await rpc('federation.cancel-request', { id: outbound.id })
|
||||
if (res.cancelled !== true || res.notified !== true) fail(`cancel-request returned ${JSON.stringify(res)}`)
|
||||
const post = await rpc('federation.list-pending-requests')
|
||||
if (!post.requests.some((r) => r.id === outbound.id)) pass('federation.cancel-request removed the request')
|
||||
else fail('cancelled request still listed as pending')
|
||||
}
|
||||
|
||||
// 6. federation.notify-did-change reports per-peer results.
|
||||
const notified = await rpc('federation.notify-did-change', {
|
||||
old_did: 'did:key:zOld',
|
||||
new_did: 'did:key:zNew',
|
||||
proof_signature: 'demo-sig',
|
||||
proof_message: 'demo-msg',
|
||||
})
|
||||
if (Array.isArray(notified.results) && notified.notified === notified.results.length) {
|
||||
pass('federation.notify-did-change reports one result per node')
|
||||
} else {
|
||||
fail(`notify-did-change shape wrong: ${JSON.stringify(notified)}`)
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err.message)
|
||||
} finally {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\nFAILED: ${failures} parity problem(s)`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\nOK: demo/real mesh + federation RPC parity holds')
|
||||
Reference in New Issue
Block a user