/** Optional two-checkout acceptance: real mobile core + real BFF over local HTTP. * Hermes is a deterministic loopback fixture. No production service is used. */ import test from 'node:test' import assert from 'node:assert/strict' import { createServer, type Server } from 'node:http' import { mkdtempSync, mkdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' import { WebSocketServer } from 'ws' import { createApplication, type ApplicationRuntime } from '../src/server/app.js' import type { ServerConfig } from '../src/server/config.js' const root = resolve(import.meta.dirname, '..') const mobile = resolve(process.env.YAOYAO_MOBILE_CHECKOUT ?? join(root, '../yaoyao-mobile')) const { YaoyaoAPI } = await import(pathToFileURL(join(mobile, 'apps/mobile/src/core/api.ts')).href) const { OrdinaryStore } = await import(pathToFileURL(join(mobile, 'apps/mobile/src/core/ordinary.ts')).href) const { createOrdinaryCache } = await import(pathToFileURL(join(mobile, 'apps/mobile/src/core/ordinary-cache.ts')).href) const listen = (server: Server, port = 0) => new Promise(done => server.listen(port, '127.0.0.1', () => done(`http://127.0.0.1:${(server.address() as { port: number }).port}`))) const close = (server: Server) => new Promise(done => { server.close(() => done()); server.closeAllConnections() }) function sqlite(db: DatabaseSync) { return { async execAsync(sql: string) { db.exec(sql) }, async runAsync(sql: string, ...args: any[]) { return db.prepare(sql).run(...args) }, async getFirstAsync(sql: string, ...args: any[]) { return db.prepare(sql).get(...args) ?? null }, async getAllAsync(sql: string, ...args: any[]) { return db.prepare(sql).all(...args) } } } test('mobile 1.5 core recovers durable attachments across BFF restart, never repeats admission, and loses its transcript stream on logout', { timeout: 45000 }, async () => { const home = mkdtempSync(join(tmpdir(), 'yaoyao-mobile-pair-')), cookies = new Map() const commands: Array<{ method: string; params: any }> = [] let runtime: ApplicationRuntime | undefined, web: Server | undefined, store: any, api: any, db: DatabaseSync | undefined let attached = false, simulateDisconnect = true const hermes = createServer((req, res) => { res.setHeader('Content-Type', 'application/json') const path = req.url?.split('?')[0] res.end(JSON.stringify(path === '/api/status' ? { auth_required: true, dashboard_running: true } : path === '/api/auth/ws-ticket' ? { ticket: 'fixture' } : path === '/api/profiles' ? { profiles: [{ name: 'default', is_default: true }] } : path?.endsWith('/messages') ? { messages: [], pagination: { total: 0, returned: 0 } } : path === '/api/sessions' || path === '/api/profiles/sessions' ? { sessions: [] } : path === '/api/config' ? { config: { terminal: { cwd: '' } } } : { ok: true })) }) const upstream = await listen(hermes), ws = new WebSocketServer({ server: hermes }) ws.on('connection', socket => { socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'fixture' } } })) socket.on('message', raw => { const f = JSON.parse(String(raw)); commands.push(f) const result = ['session.create', 'session.resume'].includes(f.method) ? { session_id: 'runtime-fixture', stored_session_id: 'stored-fixture', running: false, info: { profile_name: 'default' } } : f.method === 'file.attach' ? { ref_text: '@file:/fixture/upload.txt', path: '/fixture/upload.txt' } : f.method === 'session.events.since' ? { epoch: 'fixture', events: [] } : f.method === 'session.active_list' ? { sessions: [{ id: 'runtime-fixture', status: 'idle' }] } : { status: 'accepted', ok: true } socket.send(JSON.stringify({ id: f.id, result })) }) }) for (const folder of ['media', 'attachments', 'images']) mkdirSync(join(home, folder)) const config: ServerConfig = { home, host: '127.0.0.1', port: 0, upstream: new URL(upstream), allowedHosts: new Set(), mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'fixture', production: true, allowInsecureLan: false, insecureLan: false, superviseDashboard: false, localVmHost: 'runner' } const fetcher = async (url: string, options?: RequestInit) => { const headers = new Headers(options?.headers) // This Node fixture has no native Cookie/URLSession jar. Avoid reusing its // global fetch pool across a deliberately destroyed/rebound test listener. headers.set('Connection', 'close') headers.set('Cookie', [...cookies].map(([k, v]) => `${k}=${v}`).join('; ')) if (attached && simulateDisconnect && decodeURIComponent(new URL(url).pathname).startsWith('/api/realtime/commands/ios:prompt:')) { simulateDisconnect = false; throw new Error('Fixture disconnect before prompt admission') } const response = await fetch(url, { ...options, headers }) for (const cookie of response.headers.getSetCookie()) { const pair = cookie.split(';')[0]!, at = pair.indexOf('='); cookies.set(pair.slice(0, at), pair.slice(at + 1)) } if (options?.method === 'POST' && new URL(url).pathname.endsWith('/commands') && JSON.parse(String(options.body)).method === 'file.attach' && response.ok) attached = true return response } const openCache = async () => { db = new DatabaseSync(join(home, 'mobile.sqlite3')) db.exec('CREATE TABLE IF NOT EXISTS account_documents(scope TEXT PRIMARY KEY,payload TEXT NOT NULL)') return createOrdinaryCache(sqlite(db)) } try { runtime = createApplication({ config, deferBackground: true }); web = createServer(runtime.app.callback()) const origin = await listen(web); config.port = Number(new URL(origin).port) api = new YaoyaoAPI(origin, fetcher); await api.bootstrap() const setup = await api.request('/api/app/setup', 'POST', { username: 'pair-fixture', password: 'pair-fixture-password' }) api.csrf = setup.csrfToken const owner = setup.user.id assert.ok((await api.request('/api/realtime/capabilities')).features.includes('ordinary-chat-transcript-v2')) store = new OrdinaryStore(api, await openCache(), 'pair-account', randomUUID, async () => 'Zml4dHVyZQ==') await store.start(); await store.newSession('default') await store.setAttachments([{ id: 'file', name: 'fixture.txt', mimeType: 'text/plain', uri: 'file:///fixture.txt' }]) await store.send('paired attachment') const pending = store.getSnapshot().data.pending[0] assert.ok(pending, 'the interrupted send must remain in the durable mobile cache') assert.ok(pending.attemptedCommands.includes(`ios:attachment:${pending.id}:file`), JSON.stringify({ state: pending.state, error: pending.error, attempted: pending.attemptedCommands, methods: commands.map(f => f.method) })) assert.ok(!pending.attemptedCommands.includes(`ios:prompt:${pending.id}`)) assert.equal(commands.filter(f => f.method === 'file.attach').length, 1) assert.equal(commands.filter(f => f.method === 'prompt.submit').length, 0) await store.close(); await api.close(); db!.close(); db = undefined runtime.close(); runtime = undefined; await close(web); web = undefined runtime = createApplication({ config, deferBackground: true }); web = createServer(runtime.app.callback()); await listen(web, config.port) api = new YaoyaoAPI(origin, fetcher); await api.bootstrap() store = new OrdinaryStore(api, await openCache(), 'pair-account', randomUUID, async () => 'Zml4dHVyZQ==') await store.start(); await store.retry(pending.id); await store.retry(pending.id) assert.equal(commands.filter(f => f.method === 'file.attach').length, 1, 'restart must reuse the persisted attachment reference') const prompts = commands.filter(f => f.method === 'prompt.submit') assert.equal(prompts.length, 1, JSON.stringify({ expectation: 'retry must execute the never-admitted prompt once', pending: store.getSnapshot().data.pending.map((p: any) => ({ state: p.state, error: p.error, attempted: p.attemptedCommands })), methods: commands.map(f => f.method) })) assert.match(prompts[0]!.params.text, /@file:\/fixture\/upload\.txt/) const snapshot = await api.request('/api/app/chat/sessions/stored-fixture/snapshot?profile=default') assert.equal(snapshot.protocol, 'ordinary-chat-transcript-v2') assert.ok(snapshot.messages.some((m: any) => m.client_message_id === pending.id)) let ready!: () => void const opened = new Promise(done => { ready = done }), frames: any[] = [] const stream = api.events('/api/app/chat/sessions/stored-fixture/events?profile=default', `${snapshot.epoch}:${snapshot.cursor}`, async (frame: any) => { frames.push(frame); if (frame.event === 'ready') ready() }).catch((error: Error) => error) await opened await api.request('/api/app/logout', 'POST', {}) runtime.chatCache!.store.recordCommand(owner, 'default', 'stored-fixture', 'prompt.submit', { text: 'must-not-leak-after-logout', _delivery_id: 'late-fixture' }) let timeout: ReturnType | undefined try { await Promise.race([stream, new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error('Logged-out transcript stream did not close')), 1500) })]) } finally { clearTimeout(timeout) } assert.ok(!JSON.stringify(frames).includes('must-not-leak-after-logout')) console.log('PAIR_OK: transcript-v2 handshake; real HTTP/SSE; SQLite reopen; BFF restart; attachment once; prompt once; canonical echo; logout closes stream') } finally { await store?.close().catch(() => {}); await api?.close().catch(() => {}); db?.close(); runtime?.close() if (web) await close(web) for (const socket of ws.clients) socket.terminate() await new Promise(done => ws.close(() => done())); await close(hermes) rmSync(home, { recursive: true, force: true }) } })