verify-mobile-pair.test.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. /** Optional two-checkout acceptance: real mobile core + real BFF over local HTTP.
  2. * Hermes is a deterministic loopback fixture. No production service is used. */
  3. import test from 'node:test'
  4. import assert from 'node:assert/strict'
  5. import { createServer, type Server } from 'node:http'
  6. import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
  7. import { tmpdir } from 'node:os'
  8. import { join, resolve } from 'node:path'
  9. import { pathToFileURL } from 'node:url'
  10. import { randomUUID } from 'node:crypto'
  11. import { DatabaseSync } from 'node:sqlite'
  12. import { WebSocketServer } from 'ws'
  13. import { createApplication, type ApplicationRuntime } from '../src/server/app.js'
  14. import type { ServerConfig } from '../src/server/config.js'
  15. const root = resolve(import.meta.dirname, '..')
  16. const mobile = resolve(process.env.YAOYAO_MOBILE_CHECKOUT ?? join(root, '../yaoyao-mobile'))
  17. const { YaoyaoAPI } = await import(pathToFileURL(join(mobile, 'apps/mobile/src/core/api.ts')).href)
  18. const { OrdinaryStore } = await import(pathToFileURL(join(mobile, 'apps/mobile/src/core/ordinary.ts')).href)
  19. const { createOrdinaryCache } = await import(pathToFileURL(join(mobile, 'apps/mobile/src/core/ordinary-cache.ts')).href)
  20. 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}`)))
  21. const close = (server: Server) => new Promise<void>(done => { server.close(() => done()); server.closeAllConnections() })
  22. function sqlite(db: DatabaseSync) {
  23. return { async execAsync(sql: string) { db.exec(sql) }, async runAsync(sql: string, ...args: any[]) { return db.prepare(sql).run(...args) },
  24. 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) } }
  25. }
  26. test('mobile 1.5 core recovers durable attachments across BFF restart, never repeats admission, and loses its transcript stream on logout', { timeout: 45000 }, async () => {
  27. const home = mkdtempSync(join(tmpdir(), 'yaoyao-mobile-pair-')), cookies = new Map<string, string>()
  28. const commands: Array<{ method: string; params: any }> = []
  29. let runtime: ApplicationRuntime | undefined, web: Server | undefined, store: any, api: any, db: DatabaseSync | undefined
  30. let attached = false, simulateDisconnect = true
  31. const hermes = createServer((req, res) => {
  32. res.setHeader('Content-Type', 'application/json')
  33. const path = req.url?.split('?')[0]
  34. res.end(JSON.stringify(path === '/api/status' ? { auth_required: true, dashboard_running: true }
  35. : path === '/api/auth/ws-ticket' ? { ticket: 'fixture' }
  36. : path === '/api/profiles' ? { profiles: [{ name: 'default', is_default: true }] }
  37. : path?.endsWith('/messages') ? { messages: [], pagination: { total: 0, returned: 0 } }
  38. : path === '/api/sessions' || path === '/api/profiles/sessions' ? { sessions: [] }
  39. : path === '/api/config' ? { config: { terminal: { cwd: '' } } } : { ok: true }))
  40. })
  41. const upstream = await listen(hermes), ws = new WebSocketServer({ server: hermes })
  42. ws.on('connection', socket => {
  43. socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'fixture' } } }))
  44. socket.on('message', raw => {
  45. const f = JSON.parse(String(raw)); commands.push(f)
  46. const result = ['session.create', 'session.resume'].includes(f.method) ? { session_id: 'runtime-fixture', stored_session_id: 'stored-fixture', running: false, info: { profile_name: 'default' } }
  47. : f.method === 'file.attach' ? { ref_text: '@file:/fixture/upload.txt', path: '/fixture/upload.txt' }
  48. : f.method === 'session.events.since' ? { epoch: 'fixture', events: [] }
  49. : f.method === 'session.active_list' ? { sessions: [{ id: 'runtime-fixture', status: 'idle' }] } : { status: 'accepted', ok: true }
  50. socket.send(JSON.stringify({ id: f.id, result }))
  51. })
  52. })
  53. for (const folder of ['media', 'attachments', 'images']) mkdirSync(join(home, folder))
  54. const config: ServerConfig = { home, host: '127.0.0.1', port: 0, upstream: new URL(upstream), allowedHosts: new Set(),
  55. mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'fixture',
  56. production: true, allowInsecureLan: false, insecureLan: false, superviseDashboard: false, localVmHost: 'runner' }
  57. const fetcher = async (url: string, options?: RequestInit) => {
  58. const headers = new Headers(options?.headers)
  59. // This Node fixture has no native Cookie/URLSession jar. Avoid reusing its
  60. // global fetch pool across a deliberately destroyed/rebound test listener.
  61. headers.set('Connection', 'close')
  62. headers.set('Cookie', [...cookies].map(([k, v]) => `${k}=${v}`).join('; '))
  63. if (attached && simulateDisconnect && decodeURIComponent(new URL(url).pathname).startsWith('/api/realtime/commands/ios:prompt:')) {
  64. simulateDisconnect = false; throw new Error('Fixture disconnect before prompt admission')
  65. }
  66. const response = await fetch(url, { ...options, headers })
  67. for (const cookie of response.headers.getSetCookie()) {
  68. const pair = cookie.split(';')[0]!, at = pair.indexOf('='); cookies.set(pair.slice(0, at), pair.slice(at + 1))
  69. }
  70. if (options?.method === 'POST' && new URL(url).pathname.endsWith('/commands') && JSON.parse(String(options.body)).method === 'file.attach' && response.ok) attached = true
  71. return response
  72. }
  73. const openCache = async () => {
  74. db = new DatabaseSync(join(home, 'mobile.sqlite3'))
  75. db.exec('CREATE TABLE IF NOT EXISTS account_documents(scope TEXT PRIMARY KEY,payload TEXT NOT NULL)')
  76. return createOrdinaryCache(sqlite(db))
  77. }
  78. try {
  79. runtime = createApplication({ config, deferBackground: true }); web = createServer(runtime.app.callback())
  80. const origin = await listen(web); config.port = Number(new URL(origin).port)
  81. api = new YaoyaoAPI(origin, fetcher); await api.bootstrap()
  82. const setup = await api.request('/api/app/setup', 'POST', { username: 'pair-fixture', password: 'pair-fixture-password' })
  83. api.csrf = setup.csrfToken
  84. const owner = setup.user.id
  85. assert.ok((await api.request('/api/realtime/capabilities')).features.includes('ordinary-chat-transcript-v2'))
  86. store = new OrdinaryStore(api, await openCache(), 'pair-account', randomUUID, async () => 'Zml4dHVyZQ==')
  87. await store.start(); await store.newSession('default')
  88. await store.setAttachments([{ id: 'file', name: 'fixture.txt', mimeType: 'text/plain', uri: 'file:///fixture.txt' }])
  89. await store.send('paired attachment')
  90. const pending = store.getSnapshot().data.pending[0]
  91. assert.ok(pending, 'the interrupted send must remain in the durable mobile cache')
  92. 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) }))
  93. assert.ok(!pending.attemptedCommands.includes(`ios:prompt:${pending.id}`))
  94. assert.equal(commands.filter(f => f.method === 'file.attach').length, 1)
  95. assert.equal(commands.filter(f => f.method === 'prompt.submit').length, 0)
  96. await store.close(); await api.close(); db!.close(); db = undefined
  97. runtime.close(); runtime = undefined; await close(web); web = undefined
  98. runtime = createApplication({ config, deferBackground: true }); web = createServer(runtime.app.callback()); await listen(web, config.port)
  99. api = new YaoyaoAPI(origin, fetcher); await api.bootstrap()
  100. store = new OrdinaryStore(api, await openCache(), 'pair-account', randomUUID, async () => 'Zml4dHVyZQ==')
  101. await store.start(); await store.retry(pending.id); await store.retry(pending.id)
  102. assert.equal(commands.filter(f => f.method === 'file.attach').length, 1, 'restart must reuse the persisted attachment reference')
  103. const prompts = commands.filter(f => f.method === 'prompt.submit')
  104. 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) }))
  105. assert.match(prompts[0]!.params.text, /@file:\/fixture\/upload\.txt/)
  106. const snapshot = await api.request('/api/app/chat/sessions/stored-fixture/snapshot?profile=default')
  107. assert.equal(snapshot.protocol, 'ordinary-chat-transcript-v2')
  108. assert.ok(snapshot.messages.some((m: any) => m.client_message_id === pending.id))
  109. let ready!: () => void
  110. const opened = new Promise<void>(done => { ready = done }), frames: any[] = []
  111. const stream = api.events('/api/app/chat/sessions/stored-fixture/events?profile=default', `${snapshot.epoch}:${snapshot.cursor}`, async (frame: any) => {
  112. frames.push(frame); if (frame.event === 'ready') ready()
  113. }).catch((error: Error) => error)
  114. await opened
  115. await api.request('/api/app/logout', 'POST', {})
  116. runtime.chatCache!.store.recordCommand(owner, 'default', 'stored-fixture', 'prompt.submit', { text: 'must-not-leak-after-logout', _delivery_id: 'late-fixture' })
  117. let timeout: ReturnType<typeof setTimeout> | undefined
  118. try { await Promise.race([stream, new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error('Logged-out transcript stream did not close')), 1500) })]) }
  119. finally { clearTimeout(timeout) }
  120. assert.ok(!JSON.stringify(frames).includes('must-not-leak-after-logout'))
  121. console.log('PAIR_OK: transcript-v2 handshake; real HTTP/SSE; SQLite reopen; BFF restart; attachment once; prompt once; canonical echo; logout closes stream')
  122. } finally {
  123. await store?.close().catch(() => {}); await api?.close().catch(() => {}); db?.close(); runtime?.close()
  124. if (web) await close(web)
  125. for (const socket of ws.clients) socket.terminate()
  126. await new Promise<void>(done => ws.close(() => done())); await close(hermes)
  127. rmSync(home, { recursive: true, force: true })
  128. }
  129. })