| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131 |
- /** 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<string>(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<void>(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<string, string>()
- 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<void>(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<typeof setTimeout> | 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<void>(done => ws.close(() => done())); await close(hermes)
- rmSync(home, { recursive: true, force: true })
- }
- })
|