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
+292
-49
@@ -81,11 +81,61 @@ if (DEMO) {
|
||||
}
|
||||
|
||||
const app = express()
|
||||
const PORT = 5959
|
||||
// MOCK_BACKEND_PORT lets a harness (scripts/mock-rpc-parity.mjs) bind an
|
||||
// ephemeral port instead of colliding with a running dev preview on 5959.
|
||||
const PORT = Number(process.env.MOCK_BACKEND_PORT) || 5959
|
||||
|
||||
// Dev mode from environment (setup, onboarding, existing, boot, or default)
|
||||
const DEV_MODE = process.env.VITE_DEV_MODE || 'default'
|
||||
|
||||
// The demo's mesh peer list, shared by mesh.peers and mesh.contacts-list so
|
||||
// the two can never disagree about who is on the mesh (the daemon merges
|
||||
// contacts over the same peer map for exactly this reason).
|
||||
const DEMO_MESH_PEERS = [
|
||||
{
|
||||
contact_id: 1,
|
||||
advert_name: 'archy-198',
|
||||
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
|
||||
pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
||||
rssi: -67,
|
||||
snr: 9.5,
|
||||
hops: 0,
|
||||
},
|
||||
{
|
||||
contact_id: 2,
|
||||
advert_name: 'satoshi-relay',
|
||||
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
|
||||
pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
|
||||
rssi: -82,
|
||||
snr: 4.2,
|
||||
hops: 1,
|
||||
},
|
||||
// No pubkey_hex — mesh.contacts-list omits this peer, matching the daemon's
|
||||
// `if let Some(pk) = peer.pubkey_hex` guard.
|
||||
{
|
||||
contact_id: 3,
|
||||
advert_name: 'mountain-node',
|
||||
did: null,
|
||||
pubkey_hex: null,
|
||||
rssi: -95,
|
||||
snr: 1.8,
|
||||
hops: 2,
|
||||
},
|
||||
{
|
||||
contact_id: 4,
|
||||
advert_name: 'bunker-alpha',
|
||||
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
|
||||
pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
|
||||
rssi: -74,
|
||||
snr: 7.1,
|
||||
hops: 0,
|
||||
},
|
||||
]
|
||||
|
||||
// Ages the peer list's last_heard relative to now, as mesh.peers has always
|
||||
// reported it (the seed values are offsets in ms).
|
||||
const DEMO_PEER_LAST_HEARD_MS = { 1: 30000, 2: 120000, 3: 600000, 4: 45000 }
|
||||
|
||||
// Boot mode: simulate server startup delay
|
||||
let BOOT_START_TIME = Date.now()
|
||||
const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis)
|
||||
@@ -228,6 +278,15 @@ function seedMockState() {
|
||||
return {
|
||||
analyticsEnabled: false,
|
||||
nodeVisibility: 'discoverable',
|
||||
// Mesh assistant config — mirrors the daemon's AssistantConfig defaults
|
||||
// (assistant.rs). Disabled by default, trusted-only, no model override.
|
||||
assistantConfig: {
|
||||
enabled: false,
|
||||
model: null,
|
||||
trusted_only: true,
|
||||
backend: 'ollama',
|
||||
allowed_contacts: [],
|
||||
},
|
||||
// Discovery starts OFF, matching the production default — enabling it in
|
||||
// the UI walks through the presence-signing overlay.
|
||||
nostrDiscovery: false,
|
||||
@@ -243,6 +302,21 @@ function seedMockState() {
|
||||
state: 'pending',
|
||||
outbound: false,
|
||||
},
|
||||
// An outbound request still awaiting an answer, so the demo can exercise
|
||||
// federation.cancel-request — the daemon only permits cancelling an
|
||||
// outbound request in 'sent' state, so without one the demo's cancel
|
||||
// button could only ever produce an error.
|
||||
{
|
||||
id: 'preq-demo-out-1',
|
||||
from_nostr_pubkey: '3c8f1a5d9e2b7c4a6f0d3b8e1a5c9f2d7b4e6a0c3f8d1b5e9a2c7f4d0b6e3a8c',
|
||||
from_nostr_npub: 'npub1demo0outbound0request0abcd',
|
||||
from_did: 'did:key:z6MkqR3nT8yVb2xLc7fPu4dWe6gJa9mHzN1oKiX5vYtA3sqp',
|
||||
from_name: 'harbor-node',
|
||||
message: 'Requested peering — waiting on their approval.',
|
||||
received_at: new Date(Date.now() - 20 * 60000).toISOString(),
|
||||
state: 'sent',
|
||||
outbound: true,
|
||||
},
|
||||
],
|
||||
tollgateEnabled: true,
|
||||
tollgatePrice: 21,
|
||||
@@ -3163,53 +3237,13 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'mesh.peers': {
|
||||
return res.json({
|
||||
result: {
|
||||
peers: [
|
||||
{
|
||||
contact_id: 1,
|
||||
advert_name: 'archy-198',
|
||||
did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
|
||||
pubkey_hex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
||||
rssi: -67,
|
||||
snr: 9.5,
|
||||
last_heard: new Date(Date.now() - 30000).toISOString(),
|
||||
hops: 0,
|
||||
},
|
||||
{
|
||||
contact_id: 2,
|
||||
advert_name: 'satoshi-relay',
|
||||
did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH',
|
||||
pubkey_hex: 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5',
|
||||
rssi: -82,
|
||||
snr: 4.2,
|
||||
last_heard: new Date(Date.now() - 120000).toISOString(),
|
||||
hops: 1,
|
||||
},
|
||||
{
|
||||
contact_id: 3,
|
||||
advert_name: 'mountain-node',
|
||||
did: null,
|
||||
pubkey_hex: null,
|
||||
rssi: -95,
|
||||
snr: 1.8,
|
||||
last_heard: new Date(Date.now() - 600000).toISOString(),
|
||||
hops: 2,
|
||||
},
|
||||
{
|
||||
contact_id: 4,
|
||||
advert_name: 'bunker-alpha',
|
||||
did: 'did:key:z6MkrHKPxJP6tvCvXMaJKZd3rRA2Y44tyftVhR8FDCMKGFjb',
|
||||
pubkey_hex: 'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
|
||||
rssi: -74,
|
||||
snr: 7.1,
|
||||
last_heard: new Date(Date.now() - 45000).toISOString(),
|
||||
hops: 0,
|
||||
},
|
||||
],
|
||||
count: 4,
|
||||
},
|
||||
})
|
||||
// Built from the shared DEMO_MESH_PEERS list so mesh.contacts-list
|
||||
// cannot drift from it; last_heard is aged relative to now.
|
||||
const peers = DEMO_MESH_PEERS.map((p) => ({
|
||||
...p,
|
||||
last_heard: new Date(Date.now() - (DEMO_PEER_LAST_HEARD_MS[p.contact_id] || 0)).toISOString(),
|
||||
}))
|
||||
return res.json({ result: { peers, count: peers.length } })
|
||||
}
|
||||
|
||||
case 'mesh.messages': {
|
||||
@@ -4440,6 +4474,53 @@ app.post('/rpc/v1', (req, res) => {
|
||||
if (r) r.state = 'rejected'
|
||||
return res.json({ result: { rejected: true, id: params?.id || '' } })
|
||||
}
|
||||
// Mirrors federation/handlers.rs handle_federation_cancel_request: only
|
||||
// an OUTBOUND request still in 'sent' state may be cancelled, the row is
|
||||
// then dropped locally, and `notify` defaults to TRUE (the daemon calls
|
||||
// cancelling without notifying a footgun — the peer would keep showing
|
||||
// an unanswerable request).
|
||||
case 'federation.cancel-request': {
|
||||
const id = params?.id
|
||||
if (!id) {
|
||||
return res.json({ error: { code: -32602, message: 'Missing id' } })
|
||||
}
|
||||
const notify = params?.notify === undefined ? true : !!params.notify
|
||||
const req = mockState.pendingPeerRequests.find((x) => x.id === id)
|
||||
if (!req) {
|
||||
return res.json({
|
||||
error: { code: -32603, message: `Pending request not found: ${id}` },
|
||||
})
|
||||
}
|
||||
if (!req.outbound || req.state !== 'sent') {
|
||||
return res.json({
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Can only cancel outbound requests in Sent state (outbound=${!!req.outbound}, state=${req.state})`,
|
||||
},
|
||||
})
|
||||
}
|
||||
mockState.pendingPeerRequests = mockState.pendingPeerRequests.filter((x) => x.id !== id)
|
||||
return res.json({ result: { cancelled: true, id, notified: notify } })
|
||||
}
|
||||
// Mirrors handle_federation_notify_did_change: fans the rotation proof
|
||||
// out to every known node and reports per-peer results. The demo has no
|
||||
// real peers to reach, so every known node reports ok over its recorded
|
||||
// transport — the shape the UI renders, not a fabricated failure.
|
||||
case 'federation.notify-did-change': {
|
||||
for (const key of ['old_did', 'new_did', 'proof_signature', 'proof_message']) {
|
||||
if (!params?.[key]) {
|
||||
return res.json({ error: { code: -32602, message: `Missing '${key}'` } })
|
||||
}
|
||||
}
|
||||
const results = sessionFederationNodes().map((n) => ({
|
||||
did: n.did,
|
||||
status: 'ok',
|
||||
transport: n.transport || 'tor',
|
||||
}))
|
||||
return res.json({
|
||||
result: { notified: results.length, failed: 0, results },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Mesh chat polish + flash flow (v1.7.117/118 demo coverage) ──────
|
||||
|
||||
@@ -4609,7 +4690,166 @@ app.post('/rpc/v1', (req, res) => {
|
||||
return res.json({ result: { cancelled: true } })
|
||||
}
|
||||
|
||||
// ── Contacts (peer aliasing) ────────────────────────────────────────
|
||||
// Mirrors typed_messages.rs handle_mesh_contacts_list /
|
||||
// handle_mesh_contacts_save: contact entries are keyed by pubkey_hex and
|
||||
// merged OVER the peer list, so a peer with no entry still appears with
|
||||
// null alias/notes. Peers without a pubkey_hex are omitted, exactly as
|
||||
// the daemon omits them (it only pushes when peer.pubkey_hex is Some).
|
||||
case 'mesh.contacts-list': {
|
||||
const contacts = currentStore().mesh.contacts
|
||||
const out = DEMO_MESH_PEERS.filter((p) => p.pubkey_hex).map((p) => {
|
||||
const entry = contacts[p.pubkey_hex] || {}
|
||||
return {
|
||||
pubkey: p.pubkey_hex,
|
||||
contact_id: p.contact_id,
|
||||
name: p.advert_name,
|
||||
alias: entry.alias ?? null,
|
||||
notes: entry.notes ?? null,
|
||||
pinned: entry.pinned ?? false,
|
||||
blocked: entry.blocked ?? false,
|
||||
}
|
||||
})
|
||||
return res.json({ result: { contacts: out } })
|
||||
}
|
||||
|
||||
// Upsert semantics match the daemon's: a field absent from params leaves
|
||||
// the stored value alone (it only assigns when the key is present), so
|
||||
// saving an alias never clears existing notes.
|
||||
case 'mesh.contacts-save': {
|
||||
const pubkey = params?.pubkey
|
||||
if (!pubkey) {
|
||||
return res.json({ error: { code: -32602, message: 'Missing pubkey' } })
|
||||
}
|
||||
const contacts = currentStore().mesh.contacts
|
||||
const entry = contacts[pubkey] || { alias: null, notes: null, pinned: false, blocked: false }
|
||||
if (params.alias !== undefined) entry.alias = params.alias
|
||||
if (params.notes !== undefined) entry.notes = params.notes
|
||||
if (params.pinned !== undefined) entry.pinned = !!params.pinned
|
||||
contacts[pubkey] = entry
|
||||
return res.json({
|
||||
result: {
|
||||
saved: true,
|
||||
pubkey,
|
||||
alias: entry.alias ?? null,
|
||||
notes: entry.notes ?? null,
|
||||
pinned: entry.pinned ?? false,
|
||||
blocked: entry.blocked ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors status.rs handle_mesh_clear_all, which deletes the mesh state
|
||||
// files and clears in-memory state, returning { status: "cleared" }.
|
||||
// Here that means dropping this session's sent messages and blobs.
|
||||
case 'mesh.clear-all': {
|
||||
const meshStore = currentStore().mesh
|
||||
meshStore.dynamic.length = 0
|
||||
meshStore.blobs = {}
|
||||
return res.json({ result: { status: 'cleared' } })
|
||||
}
|
||||
|
||||
// ── Scheduled messages ──────────────────────────────────────────────
|
||||
// Mirrors assistant.rs handle_mesh_schedule_message / list / cancel and
|
||||
// scheduler.rs ScheduledMessage { id, contact_id, channel, body,
|
||||
// fire_at, attempts }. The daemon requires body + fire_at and one of
|
||||
// contact_id / channel, and errors otherwise — so does this.
|
||||
case 'mesh.schedule-message': {
|
||||
const body = params?.body
|
||||
const fireAt = params?.fire_at
|
||||
if (typeof body !== 'string') {
|
||||
return res.json({ error: { code: -32602, message: 'body is required' } })
|
||||
}
|
||||
if (typeof fireAt !== 'number') {
|
||||
return res.json({ error: { code: -32602, message: 'fire_at (unix seconds) is required' } })
|
||||
}
|
||||
const contactId = params?.contact_id
|
||||
const channel = params?.channel
|
||||
if (contactId === undefined && channel === undefined) {
|
||||
return res.json({
|
||||
error: { code: -32602, message: 'either contact_id or channel is required' },
|
||||
})
|
||||
}
|
||||
const meshStore = currentStore().mesh
|
||||
const entry = {
|
||||
id: meshStore.nextScheduledId++,
|
||||
contact_id: contactId ?? null,
|
||||
channel: channel ?? null,
|
||||
body,
|
||||
fire_at: fireAt,
|
||||
attempts: 0,
|
||||
}
|
||||
meshStore.scheduled.push(entry)
|
||||
return res.json({ result: entry })
|
||||
}
|
||||
|
||||
// Sorted by fire time, as the daemon's scheduler.list() returns them.
|
||||
case 'mesh.list-scheduled': {
|
||||
const messages = [...currentStore().mesh.scheduled].sort((a, b) => a.fire_at - b.fire_at)
|
||||
return res.json({ result: { messages } })
|
||||
}
|
||||
|
||||
case 'mesh.cancel-scheduled': {
|
||||
const id = params?.id
|
||||
if (typeof id !== 'number') {
|
||||
return res.json({ error: { code: -32602, message: 'id is required' } })
|
||||
}
|
||||
const meshStore = currentStore().mesh
|
||||
const before = meshStore.scheduled.length
|
||||
meshStore.scheduled = meshStore.scheduled.filter((m) => m.id !== id)
|
||||
return res.json({ result: { cancelled: meshStore.scheduled.length < before } })
|
||||
}
|
||||
|
||||
// ── Mesh assistant ──────────────────────────────────────────────────
|
||||
// Mirrors assistant.rs handle_mesh_assistant_status: the demo reports no
|
||||
// Ollama and no Claude key, which is the honest answer for a browser
|
||||
// demo with no local model — the UI's "not detected" path is what a
|
||||
// visitor should see, not a fabricated model list.
|
||||
case 'mesh.assistant-status': {
|
||||
const cfg = mockState.assistantConfig
|
||||
return res.json({
|
||||
result: {
|
||||
enabled: cfg.enabled,
|
||||
model: cfg.model,
|
||||
trusted_only: cfg.trusted_only,
|
||||
backend: cfg.backend,
|
||||
allowed_contacts: cfg.allowed_contacts,
|
||||
default_model: 'llama3.2:3b',
|
||||
ollama_detected: false,
|
||||
claude_available: false,
|
||||
models: [],
|
||||
denied_askers: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors handle_mesh_assistant_configure's key-presence semantics:
|
||||
// model present+string sets, present+null clears to default, absent
|
||||
// leaves; allowed_contacts present+array replaces the allowlist.
|
||||
case 'mesh.assistant-configure': {
|
||||
const cfg = mockState.assistantConfig
|
||||
if (params?.enabled !== undefined) cfg.enabled = !!params.enabled
|
||||
if (params?.trusted_only !== undefined) cfg.trusted_only = !!params.trusted_only
|
||||
if (params?.backend !== undefined) cfg.backend = params.backend
|
||||
if (params && 'model' in params) cfg.model = params.model
|
||||
if (Array.isArray(params?.allowed_contacts)) cfg.allowed_contacts = params.allowed_contacts
|
||||
return res.json({
|
||||
result: {
|
||||
enabled: cfg.enabled,
|
||||
model: cfg.model,
|
||||
trusted_only: cfg.trusted_only,
|
||||
backend: cfg.backend,
|
||||
allowed_contacts: cfg.allowed_contacts,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Chat actions the demo only needs to acknowledge.
|
||||
//
|
||||
// mesh.refresh and mesh.reboot-radio stay bare acknowledgements ON
|
||||
// PURPOSE: the daemon's handlers have no message-store effect either
|
||||
// (refresh re-polls the radio, reboot-radio power-cycles it), so giving
|
||||
// them demo-side state would be divergence, not parity. Do not "fix".
|
||||
case 'mesh.send-reaction':
|
||||
case 'mesh.send-reply':
|
||||
case 'mesh.send-read-receipt':
|
||||
@@ -5634,7 +5874,10 @@ function makeSessionStore() {
|
||||
// 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.
|
||||
mesh: { dynamic: [], blobs: {} },
|
||||
// contacts: pubkey_hex → { alias, notes, pinned, blocked }, mirroring the
|
||||
// daemon's state.contacts map. scheduled: queued messages awaiting their
|
||||
// fire_at, mirroring svc.scheduler's list.
|
||||
mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1 },
|
||||
sockets: new Set(),
|
||||
lastSeen: Date.now(),
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"stop": "./stop-dev.sh",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:mock-parity": "node scripts/mock-rpc-parity.mjs",
|
||||
"dev": "vite",
|
||||
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
|
||||
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
|
||||
|
||||
@@ -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