#!/usr/bin/env node
/**
* Nonvisual release story smoke for a launched PearBrowser process.
*
* This complements, but does not replace, the manual GUI checklist. It proves
* the production runtime can browse the release homepage through its local
* proxy and can load the live release catalogues with the expected featured
* rows. It never launches third-party apps or approves Pear trust prompts.
*
* With --local-stories it also performs local-only search, naming, bookmark,
* and session round-trips, cleaning up the temporary bookmark/petname and
* restoring the previous naming flag/session where possible. It still does not
* launch third-party apps, approve trust, or publish a test site.
*
* With --site-story it creates, publishes, verifies, and deletes a temporary
* PearBrowser site. That path intentionally exercises publishing and HiveRelay
* pin/unseed cleanup, so it is opt-in and separate from --local-stories.
*
* With --desktop-gui-stories it expands the same evidence path across the
* automatable blank desktop GUI rows from the release evidence log. It implies
* --local-stories, adds browse reload/site-info proof, catalogue search/action
* proof, safe catalogue-row app opening through Browse, startup source-contract
* checks, Nostr trusted-contact reducer proof, and release-evidence row
* suggestions in the JSON output.
*/
import { EventEmitter } from 'node:events'
import { createRequire } from 'node:module'
import { readFile } from 'node:fs/promises'
import http from 'node:http'
import net from 'node:net'
import { randomBytes } from 'node:crypto'
const require = createRequire(import.meta.url)
const C = require('../backend/constants.js')
const { catalogAppSearchText } = require('../backend/catalog-safety.cjs')
const DEFAULT_HOST = '127.0.0.1'
const DEFAULT_PORT_BASE = 9876
const DEFAULT_PORT_COUNT = 5
const DEFAULT_TIMEOUT_MS = 30_000
const DEFAULT_CONNECT_MS = 1_500
const DEFAULT_REQUEST_MS = 20_000
const DEFAULT_FETCH_MS = 20_000
const DEFAULT_HOMEPAGE_URL = 'hyper://03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f/'
const PEERIT_URL = 'hyper://ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4/'
const P2PBUILDERS_URL = 'hyper://ac1977a75cc84b46af0af8bb559cd4ebbe10507eb0f51d863e289d09635f6d74/'
const DEFAULT_CATALOGS = [
'f5fb7500bccd60a976d2b1d24246108f4444a210b9ca591533114dffc089934d',
'5d961fdc2f56215463e5d4656dd4a3f22bb5e15b93f9bfc8439a63a18f974d75'
]
const REQUIRED_FEATURED = ['Keet', 'PearPass', 'anonGPT', 'Paste', 'Peercord']
const CATALOG_SEARCH_TERMS = ['peercord', 'peerit', 'keet', 'paste']
const PEERCORD_MIGRATION_ID = 'wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy'
const RELEASE_EVIDENCE_SECTION = 'Desktop GUI And User Stories'
function parseArgs (argv) {
const args = {
host: DEFAULT_HOST,
portBase: DEFAULT_PORT_BASE,
portCount: DEFAULT_PORT_COUNT,
timeout: DEFAULT_TIMEOUT_MS,
connectTimeout: DEFAULT_CONNECT_MS,
requestTimeout: DEFAULT_REQUEST_MS,
fetchTimeout: DEFAULT_FETCH_MS,
diagnosticToken: process.env.PEARBROWSER_RPC_DIAGNOSTIC_TOKEN || '',
homepageUrl: DEFAULT_HOMEPAGE_URL,
catalogs: [...DEFAULT_CATALOGS],
localStories: false,
siteStory: false,
desktopGuiStories: false,
json: false
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--host') args.host = argv[++i] || args.host
else if (arg === '--port-base') args.portBase = parsePositiveInt(argv[++i], '--port-base')
else if (arg === '--port-count') args.portCount = parsePositiveInt(argv[++i], '--port-count')
else if (arg === '--timeout') args.timeout = parseDuration(argv[++i], '--timeout')
else if (arg === '--connect-timeout') args.connectTimeout = parseDuration(argv[++i], '--connect-timeout')
else if (arg === '--request-timeout') args.requestTimeout = parseDuration(argv[++i], '--request-timeout')
else if (arg === '--fetch-timeout') args.fetchTimeout = parseDuration(argv[++i], '--fetch-timeout')
else if (arg === '--diagnostic-token') args.diagnosticToken = String(argv[++i] || '').trim()
else if (arg === '--homepage-url') args.homepageUrl = parseHyperUrl(argv[++i], '--homepage-url')
else if (arg === '--catalog') args.catalogs.push(parseHexKey(argv[++i], '--catalog'))
else if (arg === '--only-catalog') args.catalogs = [parseHexKey(argv[++i], '--only-catalog')]
else if (arg === '--local-stories') args.localStories = true
else if (arg === '--site-story') args.siteStory = true
else if (arg === '--desktop-gui-stories') {
args.desktopGuiStories = true
args.localStories = true
}
else if (arg === '--json') args.json = true
else if (arg === '-h' || arg === '--help') usage(0)
else usage(2, `unknown option: ${arg}`)
}
return args
}
function usage (code, msg = '') {
if (msg) console.error('error:', msg)
console.error('usage: node scripts/release-rpc-story-smoke.mjs [--timeout 30000] [--port-base 9876] [--diagnostic-token <token>] [--catalog <64-hex>] [--homepage-url hyper://...] [--local-stories] [--site-story] [--desktop-gui-stories] [--json]')
process.exit(code)
}
function parseDuration (value, label) {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) usage(2, `${label} must be a positive number of milliseconds`)
return Math.floor(n)
}
function parsePositiveInt (value, label) {
const n = Number(value)
if (!Number.isInteger(n) || n <= 0) usage(2, `${label} must be a positive integer`)
return n
}
function parseHexKey (value, label) {
const key = String(value || '').trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(key)) usage(2, `${label} must be a 64-hex key`)
return key
}
function parseHyperUrl (value, label) {
const url = String(value || '').trim()
if (!/^hyper:\/\/[0-9a-f]{64}(?:\/|$)/i.test(url)) usage(2, `${label} must be a hyper:// URL with a 64-hex drive key`)
return url
}
function frame (msg) {
const json = JSON.stringify(msg)
return json.length.toString(16).padStart(8, '0') + json
}
function parseFrames (state, data) {
state.buffer += Buffer.isBuffer(data) ? data.toString('utf8') : String(data)
const out = []
while (state.buffer.length >= 8) {
const len = parseInt(state.buffer.slice(0, 8), 16)
if (!Number.isFinite(len) || len <= 0 || len > 10_000_000) {
throw new Error(`invalid RPC frame length: ${state.buffer.slice(0, 8)}`)
}
if (state.buffer.length < 8 + len) break
const json = state.buffer.slice(8, 8 + len)
state.buffer = state.buffer.slice(8 + len)
out.push(JSON.parse(json))
}
return out
}
class RawWebSocket extends EventEmitter {
constructor (socket, initial = Buffer.alloc(0)) {
super()
this.socket = socket
this.buffer = initial
this.closed = false
socket.on('data', (chunk) => this._onData(chunk))
socket.on('close', () => {
if (this.closed) return
this.closed = true
this.emit('close')
})
socket.on('error', (err) => this.emit('error', err))
if (initial.length) queueMicrotask(() => this._drain())
}
send (text) {
if (this.closed) return
const payload = Buffer.from(String(text))
const mask = randomBytes(4)
let header
if (payload.length < 126) {
header = Buffer.alloc(2)
header[0] = 0x81
header[1] = 0x80 | payload.length
} else if (payload.length <= 0xffff) {
header = Buffer.alloc(4)
header[0] = 0x81
header[1] = 0x80 | 126
header.writeUInt16BE(payload.length, 2)
} else {
header = Buffer.alloc(10)
header[0] = 0x81
header[1] = 0x80 | 127
header.writeBigUInt64BE(BigInt(payload.length), 2)
}
const masked = Buffer.alloc(payload.length)
for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4]
this.socket.write(Buffer.concat([header, mask, masked]))
}
close () {
this.closed = true
try { this.socket.end() } catch {}
}
_onData (chunk) {
this.buffer = Buffer.concat([this.buffer, chunk])
this._drain()
}
_drain () {
while (this.buffer.length >= 2) {
const b0 = this.buffer[0]
const b1 = this.buffer[1]
const opcode = b0 & 0x0f
const masked = (b1 & 0x80) !== 0
let len = b1 & 0x7f
let offset = 2
if (len === 126) {
if (this.buffer.length < offset + 2) return
len = this.buffer.readUInt16BE(offset)
offset += 2
} else if (len === 127) {
if (this.buffer.length < offset + 8) return
len = Number(this.buffer.readBigUInt64BE(offset))
offset += 8
}
const maskOffset = offset
if (masked) offset += 4
if (this.buffer.length < offset + len) return
let payload = this.buffer.subarray(offset, offset + len)
if (masked) {
const mask = this.buffer.subarray(maskOffset, maskOffset + 4)
const unmasked = Buffer.alloc(payload.length)
for (let i = 0; i < payload.length; i++) unmasked[i] = payload[i] ^ mask[i % 4]
payload = unmasked
}
this.buffer = this.buffer.subarray(offset + len)
if (opcode === 0x8) {
this.close()
this.emit('close')
return
}
if (opcode === 0x1 || opcode === 0x2) this.emit('message', payload)
}
}
}
async function connect (url, timeout) {
return await new Promise((resolve, reject) => {
const u = new URL(url)
const socket = net.createConnection({ host: u.hostname, port: Number(u.port) })
const key = randomBytes(16).toString('base64')
let settled = false
let buffer = Buffer.alloc(0)
const timer = setTimeout(() => done(new Error(`connect timeout: ${url}`)), timeout)
function done (err, ws = null) {
if (settled) return
settled = true
clearTimeout(timer)
socket.removeListener('connect', onConnect)
socket.removeListener('data', onData)
socket.removeListener('error', onError)
socket.removeListener('close', onClose)
if (err) {
try { socket.destroy() } catch {}
reject(err)
} else {
resolve(ws)
}
}
function onConnect () {
const path = (u.pathname || '/') + (u.search || '')
socket.write([
`GET ${path} HTTP/1.1`,
`Host: ${u.hostname}:${u.port}`,
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Key: ${key}`,
'Sec-WebSocket-Version: 13',
'',
''
].join('\r\n'))
}
function onData (chunk) {
buffer = Buffer.concat([buffer, chunk])
const end = buffer.indexOf('\r\n\r\n')
if (end === -1) return
const head = buffer.subarray(0, end).toString('latin1')
const rest = buffer.subarray(end + 4)
if (!/^HTTP\/1\.1 101\b/i.test(head)) {
done(new Error(`websocket handshake rejected: ${head.split('\r\n')[0] || 'no status'}`))
return
}
done(null, new RawWebSocket(socket, rest))
}
function onError () { done(new Error(`connect failed: ${url}`)) }
function onClose () { done(new Error(`socket closed before handshake: ${url}`)) }
socket.once('connect', onConnect)
socket.on('data', onData)
socket.once('error', onError)
socket.once('close', onClose)
})
}
function requestRpc (ws, cmd, data, timeout) {
const id = 910_000_000 + Math.floor(Math.random() * 10_000_000)
const state = { buffer: '' }
return new Promise((resolve, reject) => {
let settled = false
const timer = setTimeout(() => done(new Error(`RPC timeout: cmd ${cmd}`)), timeout)
function done (err, value) {
if (settled) return
settled = true
clearTimeout(timer)
ws.off('message', onMessage)
ws.off('close', onClose)
ws.off('error', onError)
err ? reject(err) : resolve(value)
}
function onClose () { done(new Error(`diagnostic socket closed before cmd ${cmd} reply`)) }
function onError () { done(new Error(`diagnostic socket error before cmd ${cmd} reply`)) }
function onMessage (chunk) {
let messages
try {
messages = parseFrames(state, chunk)
} catch (err) {
done(err)
return
}
for (const msg of messages) {
if (msg?.event === 'backend-boot-failed') {
done(new Error(`backend boot failed: ${msg.data?.message || 'unknown error'}`))
return
}
if (msg?.id !== id) continue
if (msg.error) done(new Error(msg.error))
else done(null, msg.result)
return
}
}
ws.on('message', onMessage)
ws.on('close', onClose)
ws.on('error', onError)
ws.send(frame({ id, cmd, data: data || {} }))
})
}
function validateStatus (status) {
const errors = []
if (!status || typeof status !== 'object') return ['status reply is not an object']
if (status.dhtConnected !== true) errors.push('DHT is not connected')
if (!Number.isInteger(status.proxyPort) || status.proxyPort <= 0) errors.push('HTTP proxy is not ready')
if (!Number.isInteger(status.hiveRelays) || status.hiveRelays < 1) errors.push('no HiveRelay connections reported')
if (!Number.isFinite(status.storageUsed) || status.storageUsed < 0) errors.push('storageUsed is invalid')
if (!Number.isFinite(status.storageLimit) || status.storageLimit <= 0) errors.push('storageLimit is invalid')
return errors
}
async function connectReady (args) {
const deadline = Date.now() + args.timeout
let last = null
while (Date.now() < deadline) {
for (let i = 0; i < args.portCount; i++) {
const port = args.portBase + i
const remain = Math.max(1, deadline - Date.now())
const publicUrl = `ws://${args.host}:${port}/status-smoke`
const url = `${publicUrl}${args.diagnosticToken ? `?token=${encodeURIComponent(args.diagnosticToken)}` : ''}`
let ws = null
try {
ws = await connect(url, Math.min(args.connectTimeout, remain))
const status = await requestRpc(ws, C.CMD_GET_STATUS, {}, Math.min(args.requestTimeout, remain))
const errors = validateStatus(status)
if (errors.length === 0) return { ws, port, url, status }
last = { port, url: publicUrl, status, errors }
ws.close()
} catch (err) {
last = { port, url: publicUrl, error: err.message }
try { ws?.close() } catch {}
}
}
await sleep(500)
}
const error = new Error('no ready diagnostic RPC socket answered')
error.last = last
throw error
}
function sleep (ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function fetchLocalUrl (localUrl, timeout) {
const u = new URL(localUrl)
if (!['127.0.0.1', 'localhost'].includes(u.hostname)) {
throw new Error(`refusing to fetch non-local proxy URL: ${localUrl}`)
}
return await new Promise((resolve, reject) => {
let settled = false
const req = http.get({
hostname: u.hostname,
port: u.port,
path: `${u.pathname}${u.search || ''}`,
timeout
}, (res) => {
const chunks = []
res.on('data', (chunk) => chunks.push(chunk))
res.on('end', () => {
done(null, {
statusCode: res.statusCode,
headers: res.headers,
body: Buffer.concat(chunks).toString('utf8')
})
})
})
req.on('timeout', () => done(new Error(`HTTP fetch timeout: ${localUrl}`)))
req.on('error', done)
function done (err, value) {
if (settled) return
settled = true
try { req.destroy() } catch {}
err ? reject(err) : resolve(value)
}
})
}
function titleOf (html) {
const match = String(html || '').match(/<title[^>]*>([\s\S]*?)<\/title>/i)
return match ? match[1].replace(/\s+/g, ' ').trim() : ''
}
function assertHomepage (nav, fetched, expectedUrl) {
if (!nav || typeof nav.localUrl !== 'string') throw new Error('CMD_NAVIGATE did not return a localUrl')
const expectedKey = new URL(expectedUrl).hostname.toLowerCase()
if (nav.key !== expectedKey) throw new Error(`homepage key mismatch: expected ${expectedKey}, got ${nav.key || '(none)'}`)
if (!Number.isInteger(fetched.statusCode) || fetched.statusCode < 200 || fetched.statusCode >= 300) {
throw new Error(`homepage proxy fetch returned HTTP ${fetched.statusCode}`)
}
if (fetched.body.length < 1000) throw new Error(`homepage proxy fetch is unexpectedly small (${fetched.body.length} bytes)`)
if (!/PearBrowser|Pear Browser/i.test(fetched.body)) throw new Error('homepage fetch did not contain PearBrowser text')
}
function assertCatalogues (catalogResult) {
const apps = Array.isArray(catalogResult?.apps) ? catalogResult.apps : []
const catalogs = Array.isArray(catalogResult?.catalogs) ? catalogResult.catalogs : []
if (catalogs.length < 1) throw new Error('no loaded catalogues reported')
if (apps.length < REQUIRED_FEATURED.length) throw new Error(`catalogue returned too few apps: ${apps.length}`)
const byName = new Map(apps.map((app) => [String(app?.name || '').toLowerCase(), app]))
const featured = []
for (const name of REQUIRED_FEATURED) {
const app = byName.get(name.toLowerCase())
if (!app) throw new Error(`featured app missing from catalogue: ${name}`)
featured.push({ name: app.name, type: app.type || 'standalone', link: app.link || null, driveKey: app.driveKey || null })
}
const peercord = byName.get('peercord')
if (!peercord) throw new Error('Peercord missing from catalogue')
if (peercord.legacyMigrationId !== PEERCORD_MIGRATION_ID) throw new Error(`Peercord migration id mismatch: ${peercord.legacyMigrationId || '(missing)'}`)
if (peercord.nativeDelivery?.status !== 'migration-required') throw new Error('Peercord must require a verified native v3 package')
if (peercord.link || peercord.driveKey) throw new Error('Peercord must not expose a remote executable or browsable content target')
return {
catalogs: catalogs.length,
apps: apps.length,
featured,
peercord: {
nativeDelivery: peercord.nativeDelivery,
legacyMigrationId: peercord.legacyMigrationId,
sourceUrl: peercord.sourceUrl || null,
license: peercord.license || null,
runMode: 'migration-required'
}
}
}
function makeToken () {
return `releaseprobe${Date.now().toString(36)}${randomBytes(3).toString('hex')}`
}
async function runSearchStory (ws, args, token) {
const path = `/release-smoke/${token}.html`
const title = `Release Smoke Search ${token}`
const body = `PearBrowser release smoke local search probe ${token}`
const driveKey = new URL(args.homepageUrl).hostname.toLowerCase()
const before = await requestRpc(ws, C.CMD_USERDATA_GET_SETTINGS, {}, args.requestTimeout)
const previousSettings = before?.settings && typeof before.settings === 'object' ? before.settings : {}
const previousSearchIndex = previousSettings.searchIndexEnabled === true
try {
// Search indexing is privacy-first and defaults OFF. The release probe opts
// in only for this local round-trip, then restores the user's prior choice.
if (!previousSearchIndex) {
await requestRpc(ws, C.CMD_USERDATA_SET_SETTINGS, { updates: { searchIndexEnabled: true } }, args.requestTimeout)
}
const indexed = await requestRpc(ws, C.CMD_SEARCH_INDEX, {
driveKey,
path,
title,
body,
publishedAt: Date.now()
}, args.requestTimeout)
if (!indexed?.ok || typeof indexed.docId !== 'string') {
throw new Error('search story failed to index a local document')
}
const found = await requestRpc(ws, C.CMD_SEARCH, { query: token, limit: 5 }, args.requestTimeout)
const results = Array.isArray(found?.results) ? found.results : []
const hit = results.find((row) => row?.docId === indexed.docId || row?.path === path)
if (found?.phase !== 'first-paint') throw new Error(`search story phase mismatch: ${found?.phase || '(missing)'}`)
if (found?.federating !== false) throw new Error('search story unexpectedly entered federated mode')
if (!hit) throw new Error('search story did not return the indexed local document')
return {
token,
docId: indexed.docId,
phase: found.phase,
federating: found.federating,
results: results.length,
hitTitle: hit.title || null,
restoredSearchIndexSetting: !previousSearchIndex
}
} finally {
if (!previousSearchIndex) {
try { await requestRpc(ws, C.CMD_USERDATA_SET_SETTINGS, { updates: { searchIndexEnabled: false } }, args.requestTimeout) } catch {}
}
}
}
async function runNamingStory (ws, args, token) {
const before = await requestRpc(ws, C.CMD_USERDATA_GET_SETTINGS, {}, args.requestTimeout)
const previousSettings = before?.settings && typeof before.settings === 'object' ? before.settings : {}
const previousNaming = previousSettings.experimentalNaming === true
const petname = `smoke${token.slice(-10)}`
let petnameCreated = false
try {
if (!previousNaming) {
await requestRpc(ws, C.CMD_USERDATA_SET_SETTINGS, { updates: { experimentalNaming: true } }, args.requestTimeout)
}
const curated = await requestRpc(ws, C.CMD_NAME_RESOLVE, { name: 'peerit' }, args.requestTimeout)
if (curated?.enabled === false) throw new Error('naming story did not enable naming')
if (curated?.resolved?.provenance !== 'curated') {
throw new Error(`naming story expected curated peerit alias, got ${curated?.resolved?.provenance || '(missing)'}`)
}
if (!String(curated.resolved.link || '').startsWith('hyper://')) throw new Error('naming story peerit alias did not resolve to a hyper:// link')
await requestRpc(ws, C.CMD_NAME_PETNAME_SET, {
name: petname,
link: args.homepageUrl,
label: 'Release Smoke Home'
}, args.requestTimeout)
petnameCreated = true
const pet = await requestRpc(ws, C.CMD_NAME_RESOLVE, { name: petname }, args.requestTimeout)
if (pet?.resolved?.provenance !== 'petname') throw new Error('naming story petname did not resolve as petname provenance')
if (pet.resolved.link !== args.homepageUrl) throw new Error('naming story petname link mismatch')
return {
curated: { name: curated.resolved.name, provenance: curated.resolved.provenance, link: curated.resolved.link },
petname: { name: petname, provenance: pet.resolved.provenance, link: pet.resolved.link },
restoredExperimentalNaming: !previousNaming
}
} finally {
if (petnameCreated) {
try { await requestRpc(ws, C.CMD_NAME_PETNAME_REMOVE, { name: petname }, args.requestTimeout) } catch {}
}
if (!previousNaming) {
try { await requestRpc(ws, C.CMD_USERDATA_SET_SETTINGS, { updates: { experimentalNaming: false } }, args.requestTimeout) } catch {}
}
}
}
async function runLibraryStory (ws, args, token, options = {}) {
const bookmarkUrl = `${args.homepageUrl}?release-smoke=${token}`
const bookmarkTitle = `Release Smoke Bookmark ${token}`
const beforeSession = await requestRpc(ws, C.CMD_USERDATA_GET_SESSION, {}, args.requestTimeout)
const previousSession = beforeSession?.session ?? null
let bookmarkCreated = false
let sessionTouched = false
let diagnosticReconnectRoundTrip = false
try {
const add = await requestRpc(ws, C.CMD_USERDATA_ADD_BOOKMARK, { url: bookmarkUrl, title: bookmarkTitle }, args.requestTimeout)
if (add?.bookmark?.url !== bookmarkUrl) throw new Error('library story bookmark add failed')
bookmarkCreated = true
const listed = await requestRpc(ws, C.CMD_USERDATA_LIST_BOOKMARKS, {}, args.requestTimeout)
const bookmarks = Array.isArray(listed?.bookmarks) ? listed.bookmarks : []
if (!bookmarks.some((b) => b?.url === bookmarkUrl && b?.title === bookmarkTitle)) {
throw new Error('library story bookmark was not listed after add')
}
const smokeSession = {
releaseSmoke: token,
activeTabId: 'release-smoke',
tabs: [{ id: 'release-smoke', url: args.homepageUrl, title: 'Release Smoke Home' }]
}
await requestRpc(ws, C.CMD_USERDATA_SAVE_SESSION, { state: smokeSession }, args.requestTimeout)
sessionTouched = true
const afterSession = await requestRpc(ws, C.CMD_USERDATA_GET_SESSION, {}, args.requestTimeout)
if (afterSession?.session?.releaseSmoke !== token) throw new Error('library story session round-trip failed')
if (options.reconnectUrl) {
const second = await connect(options.reconnectUrl, args.connectTimeout)
try {
const listedAgain = await requestRpc(second, C.CMD_USERDATA_LIST_BOOKMARKS, {}, args.requestTimeout)
const bookmarksAgain = Array.isArray(listedAgain?.bookmarks) ? listedAgain.bookmarks : []
if (!bookmarksAgain.some((b) => b?.url === bookmarkUrl && b?.title === bookmarkTitle)) {
throw new Error('library story bookmark was not persisted across diagnostic reconnect')
}
const sessionAgain = await requestRpc(second, C.CMD_USERDATA_GET_SESSION, {}, args.requestTimeout)
if (sessionAgain?.session?.releaseSmoke !== token) {
throw new Error('library story session was not persisted across diagnostic reconnect')
}
diagnosticReconnectRoundTrip = true
} finally {
try { second.close() } catch {}
}
}
await requestRpc(ws, C.CMD_USERDATA_REMOVE_BOOKMARK, { url: bookmarkUrl }, args.requestTimeout)
bookmarkCreated = false
const afterRemove = await requestRpc(ws, C.CMD_USERDATA_LIST_BOOKMARKS, {}, args.requestTimeout)
const remaining = Array.isArray(afterRemove?.bookmarks) ? afterRemove.bookmarks : []
if (remaining.some((b) => b?.url === bookmarkUrl)) throw new Error('library story bookmark cleanup failed')
return {
bookmarkRoundTrip: true,
sessionRoundTrip: true,
diagnosticReconnectRoundTrip,
restoredPreviousSession: previousSession !== null
}
} finally {
if (bookmarkCreated) {
try { await requestRpc(ws, C.CMD_USERDATA_REMOVE_BOOKMARK, { url: bookmarkUrl }, args.requestTimeout) } catch {}
}
if (sessionTouched) {
try { await requestRpc(ws, C.CMD_USERDATA_SAVE_SESSION, { state: previousSession || {} }, args.requestTimeout) } catch {}
}
}
}
async function runLocalStories (ws, args, options = {}) {
const token = makeToken()
const search = await runSearchStory(ws, args, token)
const naming = await runNamingStory(ws, args, token)
const library = await runLibraryStory(ws, args, token, options)
return { token, search, naming, library }
}
async function runSiteStory (ws, args) {
const token = makeToken()
const siteName = `Release Smoke ${token.slice(-8)}`
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${siteName}</title>
</head>
<body>
<main>
<h1>${siteName}</h1>
<p id="release-smoke-token">${token}</p>
</main>
</body>
</html>`
let siteId = null
let publish = null
try {
const created = await requestRpc(ws, C.CMD_CREATE_SITE, { name: siteName }, args.requestTimeout)
siteId = created?.siteId
if (!siteId || !/^[0-9a-f]{16}$/i.test(siteId)) throw new Error('site story did not create a valid site id')
if (!/^[0-9a-f]{64}$/i.test(created?.keyHex || '')) throw new Error('site story did not create a valid site key')
const updated = await requestRpc(ws, C.CMD_UPDATE_SITE, {
siteId,
files: [
{ path: '/index.html', content: html },
{ path: '/style.css', content: 'body { font-family: sans-serif; }' }
]
}, args.requestTimeout)
if (!updated || (updated.updated !== 2 && updated.siteId !== siteId)) throw new Error('site story update failed')
publish = await requestRpc(ws, C.CMD_PUBLISH_SITE, { siteId }, Math.max(args.requestTimeout, 70_000))
if (publish?.siteId !== siteId) throw new Error('site story publish returned the wrong site id')
if (!/^hyper:\/\/[0-9a-f]{64}$/i.test(publish?.url || '')) throw new Error(`site story publish returned invalid URL: ${publish?.url || '(missing)'}`)
const sites = await requestRpc(ws, C.CMD_LIST_SITES, {}, args.requestTimeout)
const listed = Array.isArray(sites) ? sites : []
const row = listed.find((site) => site?.siteId === siteId)
if (!row || row.published !== true) throw new Error('site story published site was not listed as published')
const nav = await requestRpc(ws, C.CMD_NAVIGATE, { url: publish.url + '/' }, args.requestTimeout)
const fetched = await fetchLocalUrl(nav.localUrl, args.fetchTimeout)
if (!Number.isInteger(fetched.statusCode) || fetched.statusCode < 200 || fetched.statusCode >= 300) {
throw new Error(`site story fetch returned HTTP ${fetched.statusCode}`)
}
if (!fetched.body.includes(token)) throw new Error('site story fetched page did not contain the release token')
const deleted = await requestRpc(ws, C.CMD_DELETE_SITE, { siteId }, Math.max(args.requestTimeout, 30_000))
siteId = null
if (deleted?.ok !== true) throw new Error('site story cleanup did not delete the site')
return {
token,
siteId: publish.siteId,
keyHex: publish.keyHex || null,
url: publish.url,
localUrl: nav.localUrl,
statusCode: fetched.statusCode,
bytes: Buffer.byteLength(fetched.body),
pin: publish.pin || null,
cleanup: {
deleted: true,
unseed: deleted.unseed || null
}
}
} finally {
if (siteId) {
try { await requestRpc(ws, C.CMD_DELETE_SITE, { siteId }, Math.max(args.requestTimeout, 30_000)) } catch {}
}
}
}
async function runDesktopGuiStories (ws, args, context) {
const browse = await runBrowseStory(ws, args, context.homepage)
const catalogue = runCatalogueStory(context.catalogResult)
const latestAppWithoutDownload = await runSafeCatalogueAppOpenStory(ws, args, context.catalogResult)
const featuredAppRegression = runFeaturedAppRegressionStory(context.catalogResult, latestAppWithoutDownload)
const freshLaunch = await runFreshLaunchSourceContract()
const nostrTrustedContact = await runNostrTrustedContactStory()
const librarySession = context.localStories?.library
? {
bookmarkRoundTrip: context.localStories.library.bookmarkRoundTrip === true,
sessionRoundTrip: context.localStories.library.sessionRoundTrip === true,
diagnosticReconnectRoundTrip: context.localStories.library.diagnosticReconnectRoundTrip === true,
restoredPreviousSession: context.localStories.library.restoredPreviousSession === true
}
: null
if (!librarySession?.bookmarkRoundTrip || !librarySession?.sessionRoundTrip || !librarySession?.diagnosticReconnectRoundTrip) {
throw new Error('desktop GUI story mode requires library bookmark/session round-trip with diagnostic reconnect proof')
}
return {
browse,
freshLaunch,
catalogue,
latestAppWithoutDownload,
featuredAppRegression,
nostrTrustedContact,
librarySession
}
}
async function runBrowseStory (ws, args, homepage) {
const nav = homepage?.nav
const fetched = homepage?.fetched
assertHomepage(nav, fetched, args.homepageUrl)
const reloaded = await fetchLocalUrl(nav.localUrl, args.fetchTimeout)
assertHomepage(nav, reloaded, args.homepageUrl)
const info = await requestRpc(ws, C.CMD_GET_DRIVE_INFO, { url: args.homepageUrl }, args.requestTimeout)
const keyHex = new URL(args.homepageUrl).hostname.toLowerCase()
if (info?.keyHex !== keyHex) throw new Error(`browse story drive-info key mismatch: ${info?.keyHex || '(missing)'}`)
if (typeof info.discoveryKey !== 'string' || !/^[0-9a-f]{64}$/i.test(info.discoveryKey)) {
throw new Error('browse story drive-info missing discovery key')
}
return {
url: args.homepageUrl,
localUrl: nav.localUrl,
statusCode: fetched.statusCode,
reloadStatusCode: reloaded.statusCode,
bytes: Buffer.byteLength(fetched.body),
reloadBytes: Buffer.byteLength(reloaded.body),
title: titleOf(fetched.body),
driveInfo: {
keyHex: info.keyHex,
version: Number.isFinite(info.version) ? info.version : null,
discoveryKey: info.discoveryKey,
peerCount: Number.isFinite(info.peerCount) ? info.peerCount : 0,
byteLength: Number.isFinite(info.byteLength) ? info.byteLength : 0,
relay: info.relay || null
}
}
}
function runCatalogueStory (catalogResult) {
const apps = Array.isArray(catalogResult?.apps) ? catalogResult.apps : []
const catalogs = Array.isArray(catalogResult?.catalogs) ? catalogResult.catalogs : []
const searches = {}
for (const term of CATALOG_SEARCH_TERMS) {
const hits = apps.filter((app) => app?.link && catalogAppSearchText(app).includes(term))
if (hits.length === 0) throw new Error(`catalogue story search returned no results for ${term}`)
const exact = hits.find((app) => sameNameOrId(app, term)) || hits[0]
searches[term] = {
results: hits.length,
top: exact.name || exact.id || null,
action: launchActionForApp(exact)
}
}
return {
catalogs: catalogs.map((catalog) => ({
key: catalog.key || null,
name: catalog.name || null,
count: Number.isFinite(catalog.count) ? catalog.count : 0,
source: catalog.source || null
})),
totalCatalogs: catalogs.length,
totalApps: apps.length,
runnableApps: apps.filter((app) => app?.link).length,
featured: REQUIRED_FEATURED.map((name) => {
const app = findAppByName(apps, name)
if (!app) throw new Error(`catalogue story featured row missing: ${name}`)
return {
name: app.name,
type: app.type || 'standalone',
link: app.link || null,
driveKey: app.driveKey || null,
action: launchActionForApp(app),
catalogName: app.catalogName || null
}
}),
searches
}
}
async function runSafeCatalogueAppOpenStory (ws, args, catalogResult) {
const apps = Array.isArray(catalogResult?.apps) ? catalogResult.apps : []
const candidates = apps
.filter((app) => app && app.driveKey && isHyperDriveKey(app.driveKey))
.filter((app) => {
const categories = Array.isArray(app.categories) ? app.categories.map((c) => String(c).toLowerCase()) : []
return categories.includes('featured') || sameNameOrId(app, 'peerit') || sameNameOrId(app, 'p2pbuilders')
})
.sort((a, b) => publishedTime(b) - publishedTime(a))
const app = candidates.find((candidate) => sameNameOrId(candidate, 'peerit')) || candidates[0]
if (!app) throw new Error('latest app story could not find a safe catalogue row with a Hyperdrive target')
const url = hyperUrlForApp(app)
const nav = await requestRpc(ws, C.CMD_NAVIGATE, { url }, args.requestTimeout)
const fetched = await fetchLocalUrl(nav.localUrl, args.fetchTimeout)
assertFetchedPage(`${app.name || app.id} catalogue row`, fetched)
const info = await requestRpc(ws, C.CMD_GET_DRIVE_INFO, { url }, args.requestTimeout)
if (info?.keyHex !== app.driveKey.toLowerCase()) throw new Error('latest app story drive-info key mismatch')
return {
name: app.name || app.id || app.driveKey,
id: app.id || null,
url,
localUrl: nav.localUrl,
statusCode: fetched.statusCode,
bytes: Buffer.byteLength(fetched.body),
title: titleOf(fetched.body),
driveKey: app.driveKey.toLowerCase(),
type: app.type || null,
catalogName: app.catalogName || null,
publishedAt: app.publishedAt || null,
action: 'open-in-browse',
driveInfo: {
version: Number.isFinite(info.version) ? info.version : null,
peerCount: Number.isFinite(info.peerCount) ? info.peerCount : 0,
byteLength: Number.isFinite(info.byteLength) ? info.byteLength : 0
}
}
}
function runFeaturedAppRegressionStory (catalogResult, safeOpen) {
const apps = Array.isArray(catalogResult?.apps) ? catalogResult.apps : []
const migrationRequired = REQUIRED_FEATURED
.map((name) => findAppByName(apps, name))
.filter(Boolean)
.filter((app) => app.nativeDelivery?.status === 'migration-required')
.map((app) => ({
name: app.name || app.id,
legacyMigrationId: app.legacyMigrationId || null,
nativeDelivery: app.nativeDelivery,
action: launchActionForApp(app)
}))
if (!migrationRequired.some((app) => sameNameOrId(app, 'keet'))) {
throw new Error('featured app regression story could not verify Keet migration state')
}
for (const app of migrationRequired) {
if (app.action.primary !== 'migration-required' || !app.legacyMigrationId) {
throw new Error(`featured app regression migration state mismatch for ${app.name}`)
}
}
return {
safeOpenedFeaturedApp: {
name: safeOpen.name,
url: safeOpen.url,
statusCode: safeOpen.statusCode,
bytes: safeOpen.bytes,
action: safeOpen.action
},
migrationRequired,
automationScope: 'safe featured Hyperdrive open plus non-executable native migration-state validation; no third-party code execution'
}
}
async function runFreshLaunchSourceContract () {
const shell = await readFile(new URL('../ui/shell.js', import.meta.url), 'utf8')
const startupBlock = shell.match(/const STARTUP_TABS = \[([\s\S]*?)\]/)?.[1] || ''
if (!startupBlock) throw new Error('fresh-launch story could not find STARTUP_TABS')
const defaultIdx = startupBlock.indexOf('DEFAULT_URL')
const buildersIdx = startupBlock.indexOf('P2PBUILDERS_URL')
const peeritIdx = startupBlock.indexOf('PEERIT_URL')
if (defaultIdx === -1 || buildersIdx === -1 || peeritIdx === -1) {
throw new Error('fresh-launch story startup tabs are missing the landing, P2P Builders, or peerit tab')
}
if (!(defaultIdx < buildersIdx && buildersIdx < peeritIdx)) {
throw new Error('fresh-launch story startup tabs are not in the expected order')
}
if (!/restoreStartupTabs\(savedTabs, STARTUP_TABS\)/.test(shell)) {
throw new Error('fresh-launch story session restore does not preserve startup defaults')
}
if (!/a\.driveKey === PEERIT_DRIVE_KEY \? 0/.test(shell)) {
throw new Error('fresh-launch story Sites discovery does not pin peerit first')
}
return {
frontTab: DEFAULT_HOMEPAGE_URL,
startupTabs: [DEFAULT_HOMEPAGE_URL, P2PBUILDERS_URL, PEERIT_URL],
peeritUrl: PEERIT_URL,
restoreKeepsStartupDefaults: true,
sitesDiscoveryPinsPeeritFirst: true,
proof: 'ui/shell.js STARTUP_TABS, restoreStartupTabs, and Sites rank contract'
}
}
async function runNostrTrustedContactStory () {
const crypto = require('hypercore-crypto')
const b4a = require('b4a')
const secp = require('../backend/secp256k1-bundle.cjs')
const nb = require('../backend/nostr-bind.cjs')
const { FederatedNostrFeed } = require('../backend/federated-nostr-feed.cjs')
const token = makeToken()
const hex = (buf) => b4a.toString(buf, 'hex')
const rootSigner = (keyPair) => (msg) => hex(crypto.sign(b4a.from(msg, 'utf-8'), keyPair.secretKey))
const nostrSigner = (skHex) => (msg32Hex) => secp.schnorrSign(msg32Hex, skHex)
const nostrPubkey = (skHex) => secp.schnorrGetPublicKey(skHex)
const makeEvent = (skHex, content) => secp.nip01Sign({
pubkey: nostrPubkey(skHex),
created_at: 1700000000,
kind: 1,
tags: [],
content
}, skHex)
const trustedRoot = crypto.keyPair()
const revokedRoot = crypto.keyPair()
const trustedRootHex = hex(trustedRoot.publicKey)
const revokedRootHex = hex(revokedRoot.publicKey)
const trustedSk = '11'.repeat(32)
const revokedSk = '22'.repeat(32)
const forgedSk = '33'.repeat(32)
const trustedBind = nb.makeNostrBind({
rootPubkey: trustedRootHex,
nostrPubkey: nostrPubkey(trustedSk),
epoch: 1
}, rootSigner(trustedRoot), nostrSigner(trustedSk))
const revokedBind = nb.makeNostrBind({
rootPubkey: revokedRootHex,
nostrPubkey: nostrPubkey(revokedSk),
epoch: 1
}, rootSigner(revokedRoot), nostrSigner(revokedSk))
const revokedRecord = nb.makeNostrRevoke({
rootPubkey: revokedRootHex,
nostrPubkey: nostrPubkey(revokedSk),
epoch: 1
}, rootSigner(revokedRoot))
const trustedEventKey = 'aa'.repeat(32)
const revokedEventKey = 'bb'.repeat(32)
const bindings = new Map([
[trustedRootHex.toLowerCase(), { nostrEventKey: trustedEventKey, nostrBind: trustedBind, nostrRevocations: [] }],
[revokedRootHex.toLowerCase(), { nostrEventKey: revokedEventKey, nostrBind: revokedBind, nostrRevocations: [revokedRecord] }]
])
const trustedContent = `trusted contact ${token}`
const revokedContent = `revoked contact ${token}`
const forgedContent = `forged contact ${token}`
const eventStores = new Map([
[trustedEventKey, [makeEvent(trustedSk, trustedContent), makeEvent(forgedSk, forgedContent)]],
[revokedEventKey, [makeEvent(revokedSk, revokedContent)]]
])
const feed = new FederatedNostrFeed({
listContacts: async () => [
{ pubkey: trustedRootHex, displayName: 'Trusted Alice', verifiedAt: 1, bindingKey: '01'.repeat(32) },
{ pubkey: revokedRootHex, displayName: 'Revoked Bob', verifiedAt: 1, bindingKey: '02'.repeat(32) }
],
resolveBinding: async ({ contactPubkey }) => bindings.get(String(contactPubkey || '').toLowerCase()) || null,
openEventStore: async (eventKey) => ({ listEvents: async () => eventStores.get(eventKey) || [] }),
now: () => 1700000000,
stepTimeoutMs: 100
})
const result = await feed.eventsWithDiagnostics()
const visible = Array.isArray(result.events) ? result.events : []
if (visible.length !== 1 || visible[0].content !== trustedContent || visible[0]._via !== 'Trusted Alice') {
throw new Error('Nostr trusted-contact story did not expose only the attested contact event')
}
const hidden = result.hidden || {}
if (!Number.isFinite(hidden.quarantined) || hidden.quarantined < 2) {
throw new Error('Nostr trusted-contact story did not quarantine revoked/forged events')
}
return {
token,
visibleEvents: visible.length,
trustedVia: visible[0]._via,
hidden: {
contactsEligible: hidden.contactsEligible || 0,
bindingUntrusted: hidden.bindingUntrusted || 0,
quarantined: hidden.quarantined || 0,
dropped: hidden.dropped || 0,
byReason: hidden.byReason || {}
}
}
}
function findAppByName (apps, name) {
return apps.find((app) => sameNameOrId(app, name))
}
function sameNameOrId (app, value) {
const target = String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '')
const names = [app?.name, app?.id].map((item) => String(item || '').toLowerCase().replace(/[^a-z0-9]/g, ''))
return names.includes(target)
}
function launchActionForApp (app) {
const link = String(app?.link || '').trim()
const driveKey = String(app?.driveKey || '').trim().toLowerCase()
const hasDrive = isHyperDriveKey(driveKey)
if (app?.nativeDelivery?.status === 'migration-required') {
return {
primary: 'migration-required',
openPage: false,
runInTab: false,
reason: 'verified native v3 package required'
}
}
if (/^hyper:\/\//i.test(link) || hasDrive) {
return {
primary: 'open-in-browse',
openPage: true,
runInTab: false,
reason: 'Hyperdrive page opens through Browse'
}
}
return {
primary: 'unsupported',
openPage: false,
runInTab: false,
reason: 'no launchable link or drive key'
}
}
function hyperUrlForApp (app) {
const link = String(app?.link || '').trim()
if (/^hyper:\/\/[0-9a-f]{64}(?:\/|$)/i.test(link)) return link.endsWith('/') ? link : `${link}/`
const key = String(app?.driveKey || '').trim().toLowerCase()
if (!isHyperDriveKey(key)) throw new Error(`app ${app?.name || app?.id || '(unknown)'} has no Hyperdrive URL`)
return `hyper://${key}/`
}
function isHyperDriveKey (value) {
return /^[0-9a-f]{64}$/i.test(String(value || ''))
}
function publishedTime (app) {
const value = app?.publishedAt
if (Number.isFinite(value)) return value
const t = Date.parse(String(value || ''))
return Number.isFinite(t) ? t : 0
}
function assertFetchedPage (label, fetched) {
if (!Number.isInteger(fetched?.statusCode) || fetched.statusCode < 200 || fetched.statusCode >= 300) {
throw new Error(`${label} fetch returned HTTP ${fetched?.statusCode}`)
}
if (Buffer.byteLength(String(fetched.body || '')) < 100) {
throw new Error(`${label} fetch returned an unexpectedly small body`)
}
}
function buildReleaseEvidence (result) {
const rows = []
const desktop = result.desktopGuiStories || null
const add = (gate, evidence) => {
if (!evidence) return
rows.push({
section: RELEASE_EVIDENCE_SECTION,
gate,
result: 'PASS',
evidence: evidenceCell(evidence)
})
}
if (desktop?.browse) {
add('Browse story', `release RPC desktop-gui smoke: ${shortHyper(desktop.browse.url)} fetched HTTP ${desktop.browse.statusCode} ${desktop.browse.bytes} bytes, reload HTTP ${desktop.browse.reloadStatusCode}, site info key ${shortKey(desktop.browse.driveInfo.keyHex)} version ${desktop.browse.driveInfo.version}`)
}
if (desktop?.freshLaunch) {
add('Fresh-launch landing story', `release RPC desktop-gui smoke source contract: front tab ${shortHyper(desktop.freshLaunch.frontTab)}, startup tabs include P2P Builders and peerit, restoreStartupTabs keeps defaults, Sites discovery pins peerit first`)
}
if (desktop?.catalogue) {
add('Catalogue story', `release RPC desktop-gui smoke: ${desktop.catalogue.totalCatalogs} catalogues, ${desktop.catalogue.totalApps} apps, featured ${desktop.catalogue.featured.map((app) => app.name).join(', ')}, searches ${CATALOG_SEARCH_TERMS.join(', ')} all returned launchable rows`)
}
if (desktop?.latestAppWithoutDownload) {
add('Latest-app-without-download story', `release RPC desktop-gui smoke: opened ${desktop.latestAppWithoutDownload.name} from catalogue row via Browse at ${shortHyper(desktop.latestAppWithoutDownload.url)}, HTTP ${desktop.latestAppWithoutDownload.statusCode} ${desktop.latestAppWithoutDownload.bytes} bytes, no project page download or manual update`)
}
if (desktop?.featuredAppRegression) {
add('Existing featured app regression', `release RPC desktop-gui smoke: safe featured app ${desktop.featuredAppRegression.safeOpenedFeaturedApp.name} opened via Browse HTTP ${desktop.featuredAppRegression.safeOpenedFeaturedApp.statusCode}; legacy native records ${desktop.featuredAppRegression.migrationRequired.map((app) => app.name).join(', ')} require verified packages and no code execution was automated`)
}
if (desktop?.nostrTrustedContact) {
add('Nostr trusted-contact story', `release RPC desktop-gui smoke: Nostr trust proof exposed ${desktop.nostrTrustedContact.visibleEvents} attested contact event via ${desktop.nostrTrustedContact.trustedVia} and quarantined ${desktop.nostrTrustedContact.hidden.quarantined} revoked or forged event(s)`)
}
if (desktop?.librarySession) {
add('Library/session story', `release RPC desktop-gui smoke: bookmark and session round-tripped through user-data Hyperbee and persisted across diagnostic reconnect before cleanup; previous session restored=${desktop.librarySession.restoredPreviousSession}`)
}
if (result.localStories?.search) {
add('Search story', `release RPC local-story smoke: indexed token ${result.localStories.token}, returned doc ${result.localStories.search.docId} as ${result.localStories.search.phase} with federating=${result.localStories.search.federating}`)
}
if (result.localStories?.naming) {
add('Naming story', `release RPC local-story smoke: curated ${result.localStories.naming.curated.name} provenance ${result.localStories.naming.curated.provenance}, temporary petname ${result.localStories.naming.petname.name} provenance ${result.localStories.naming.petname.provenance}, naming flag restored=${result.localStories.naming.restoredExperimentalNaming}`)
}
if (result.siteStory) {
add('Site publishing story', `release RPC site-story smoke: published ${shortHyper(result.siteStory.url)}, fetched HTTP ${result.siteStory.statusCode} ${result.siteStory.bytes} bytes, deleted site and requested unseed cleanup`)
}
return {
kind: 'pearbrowser-release-rpc-story-smoke-evidence',
generatedAt: new Date().toISOString(),
rows
}
}
function evidenceCell (value) {
return String(value || '').replace(/\|/g, '/').replace(/\s+/g, ' ').trim()
}
function shortKey (key) {
const s = String(key || '')
return s.length > 16 ? `${s.slice(0, 8)}...${s.slice(-6)}` : s
}
function shortHyper (url) {
try {
const parsed = new URL(String(url || ''))
if (parsed.protocol === 'hyper:') return `hyper://${shortKey(parsed.hostname)}/`
} catch {}
return String(url || '')
}
async function run (args) {
const conn = await connectReady(args)
const ws = conn.ws
try {
const nav = await requestRpc(ws, C.CMD_NAVIGATE, { url: args.homepageUrl }, args.requestTimeout)
const fetched = await fetchLocalUrl(nav.localUrl, args.fetchTimeout)
assertHomepage(nav, fetched, args.homepageUrl)
const loaded = []
for (const keyHex of args.catalogs) {
const data = await requestRpc(ws, C.CMD_LOAD_CATALOG_BEE, { keyHex }, args.requestTimeout)
loaded.push({ keyHex, name: data?.name || null, apps: Array.isArray(data?.apps) ? data.apps.length : 0 })
}
const catalogResult = await requestRpc(ws, C.CMD_GET_CATALOG_APPS, {}, args.requestTimeout)
const catalog = assertCatalogues(catalogResult)
const localStories = args.localStories
? await runLocalStories(ws, args, { reconnectUrl: args.desktopGuiStories ? conn.url : null })
: null
const siteStory = args.siteStory ? await runSiteStory(ws, args) : null
const result = {
ok: true,
port: conn.port,
status: conn.status,
homepage: {
url: args.homepageUrl,
localUrl: nav.localUrl,
statusCode: fetched.statusCode,
bytes: Buffer.byteLength(fetched.body),
title: titleOf(fetched.body)
},
loadedCatalogues: loaded,
catalog,
localStories,
siteStory,
desktopGuiStories: null,
releaseEvidence: null
}
if (args.desktopGuiStories) {
result.desktopGuiStories = await runDesktopGuiStories(ws, args, {
homepage: { nav, fetched },
catalogResult,
localStories
})
}
result.releaseEvidence = buildReleaseEvidence(result)
return result
} finally {
try { ws.close() } catch {}
}
}
function printHuman (result) {
console.log('Release RPC story smoke passed')
console.log(` rpcPort: ${result.port}`)
console.log(` proxyPort: ${result.status.proxyPort}`)
console.log(` homepage: HTTP ${result.homepage.statusCode}, ${result.homepage.bytes} bytes${result.homepage.title ? `, "${result.homepage.title}"` : ''}`)
console.log(` catalogues: ${result.catalog.catalogs} loaded, ${result.catalog.apps} aggregated apps`)
console.log(` featured: ${result.catalog.featured.map((app) => app.name).join(', ')}`)
console.log(` Peercord: ${result.catalog.peercord.runMode}, ${result.catalog.peercord.legacyMigrationId}`)
if (result.localStories) {
console.log(` local search: ${result.localStories.search.results} result(s), doc ${result.localStories.search.docId}`)
console.log(` naming: ${result.localStories.naming.curated.name} curated + ${result.localStories.naming.petname.name} petname`)
console.log(` library: bookmark/session round-trip passed`)
}
if (result.siteStory) {
console.log(` site publish: ${result.siteStory.url}, HTTP ${result.siteStory.statusCode}, cleanup deleted`)
}
if (result.desktopGuiStories) {
console.log(` desktop GUI stories: browse reload, fresh-launch contract, catalogue search, safe app open, Nostr trust, library reconnect passed`)
console.log(` release evidence rows: ${result.releaseEvidence?.rows?.length || 0}`)
}
}
async function main () {
const args = parseArgs(process.argv.slice(2))
try {
const result = await run(args)
if (args.json) console.log(JSON.stringify(result))
else printHuman(result)
} catch (err) {
const output = { ok: false, error: err.message, last: err.last || null }
if (args.json) console.log(JSON.stringify(output))
else {
console.error('Release RPC story smoke failed')
console.error(` ${err.message}`)
if (err.last) console.error(` last: ${JSON.stringify(err.last)}`)
}
process.exit(1)
}
}
main().catch((err) => {
console.error(err.stack || err.message)
process.exit(1)
})