Kaynağa Gözat

test(client): preserve migrated fixture semantics

imccyu 2 hafta önce
ebeveyn
işleme
e179c4ae14

+ 44 - 0
apps/web/tests/assembled-remote.spec.ts

@@ -0,0 +1,44 @@
+/** Stateful behavior of the app-owned RemoteMock scenario. */
+
+import { expect, it } from 'vitest'
+import { createAssembledRemote } from './assembled-remote.ts'
+
+it('advances turns and updates the Session summary after accepted prompts', async () => {
+  const { mock } = createAssembledRemote()
+  await mock.dispatch('session/create', [{ request: { cwd: '/work' } }])
+  await mock.dispatch('session/prompt', [{
+    request: { sessionId: 'fx-1', requestId: 'request-1', mode: 'queue', content: [] },
+  }])
+  await mock.dispatch('session/prompt', [{
+    request: { sessionId: 'fx-1', requestId: 'request-2', mode: 'queue', content: [] },
+  }])
+
+  const listed = await mock.dispatch('session/list', []) as {
+    readonly ok: true
+    readonly value: {
+      readonly items: readonly { readonly sessionId: string; readonly blank: boolean; readonly running: boolean }[]
+    }
+  }
+  expect(listed.ok).toBe(true)
+  expect(listed.value.items.find(item => item.sessionId === 'fx-1')).toMatchObject({
+    blank: false,
+    running: true,
+  })
+
+  const iterator = mock.open('session/follow', [{
+    request: { address: { kind: 'session', sessionId: 'fx-1' } },
+  }], new AbortController().signal)[Symbol.asyncIterator]()
+  await expect(iterator.next()).resolves.toMatchObject({
+    done: false,
+    value: {
+      records: [
+        { event: { type: 'turn/start', data: { turn: 0 } } },
+        { event: { type: 'user/message' } },
+        { event: { type: 'turn/start', data: { turn: 1 } } },
+        { event: { type: 'user/message' } },
+      ],
+    },
+  })
+  await iterator.return?.()
+  mock.assertNoUnmatched()
+})

+ 32 - 11
apps/web/tests/assembled-remote.ts

@@ -1,4 +1,8 @@
-/** RemoteMock scenario for built-client tests that do not own a Host. */
+/**
+ * RemoteMock scenario for built-client tests that do not own a Host.
+ * The adjacent JSON is maintained with this module when Remote responses or
+ * the current Session header version change.
+ */
 
 import { readFileSync } from 'node:fs'
 import { randomUUID } from 'node:crypto'
@@ -107,10 +111,23 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
     'fx-alpha',
     structuredClone(fixture.follow.records) as EventRecord[],
   ]])
+  const nextTurns = new Map([...records].map(([sessionId, sessionRecords]) => {
+    let next = 0
+    for (const { event } of sessionRecords) {
+      if (event.type !== 'turn/start' || !isRecord(event.data)) continue
+      const turn = event.data['turn']
+      if (typeof turn === 'number') next = Math.max(next, turn + 1)
+    }
+    return [sessionId, next] as const
+  }))
   const attachments = new Map<string, unknown>([['fixture:image', structuredClone(fixture.attachment)]])
+  const blankSessionProjections = fixture.control.value.projections['fx-gamma']
+  if (blankSessionProjections === undefined) {
+    throw new Error('assembled fixture: blank Session projections missing')
+  }
   let nextSession = 1
 
-  const mock = RemoteMock.create({ host: { home: '/home/fixture' } }).load(remoteDefaultResponses)
+  const mock = RemoteMock.create().load(remoteDefaultResponses)
   mock.load({
     unary: {
       'settings/describe': structuredClone(fixture.settingsDescribe),
@@ -174,10 +191,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
       cursor: sessionRecords.at(-1)?.event.seq ?? -1,
       records: structuredClone(sessionRecords),
       hasMore: false,
-      projections: structuredClone(fixture.control.value.projections['fx-gamma'] ?? {
-        asOfSeq: -1,
-        values: {},
-      }),
+      projections: structuredClone(blankSessionProjections),
       ...isAssistantStreamRequested(request) ? { assistantStream: { revision: 0 } } : {},
     })
   })
@@ -220,13 +234,11 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
       running: false,
       blank: true,
       cwd,
-      projections: structuredClone(fixture.control.value.projections['fx-gamma'] ?? {
-        asOfSeq: -1,
-        values: {},
-      }),
+      projections: structuredClone(blankSessionProjections),
     }
     sessions.push(summary)
     records.set(sessionId, [])
+    nextTurns.set(sessionId, 0)
     if (workspace !== undefined && !workspace.sessionIds.includes(sessionId)) {
       workspace.sessionIds = [sessionId, ...workspace.sessionIds]
       workspace.updatedAt = new Date().toISOString()
@@ -265,6 +277,8 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
     const sessionId = recordString(request, 'sessionId')
     const requestId = recordString(request, 'requestId')
     const sessionRecords = records.get(sessionId) ?? []
+    const summary = sessions.find(candidate => candidate.sessionId === sessionId)
+    if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`)
     const content = recordArray(request, 'content').map((part) => {
       if (!isRecord(part) || part.type !== 'image') return part
       const attachmentId = `assembled:${randomUUID()}`
@@ -282,7 +296,14 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
       attachments.set(attachmentId, ok({ attachment, data }))
       return { type: 'image', attachment }
     })
-    const turn = sessionRecords.length
+    const turn = nextTurns.get(sessionId) ?? 0
+    nextTurns.set(sessionId, turn + 1)
+    summary.updatedAt = Date.now()
+    summary.blank = false
+    if (!summary.running) {
+      summary.running = true
+      mock.streams.push('$events', { type: 'emit', event: 'api-session/status', args: [sessionId, true] })
+    }
     const turnEvent = eventOf(sessionRecords.length, 'turn/start', { turn })
     const userEvent = eventOf(sessionRecords.length + 1, 'user/message', {
       content,

+ 6 - 4
packages/api/session-controller/tests/manager.client.spec.ts

@@ -21,9 +21,11 @@ import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client
 
 const S1 = 'fk-m1' as SessionId
 const S2 = 'fk-m2' as SessionId
-/** A SessionManager's Remote methods use the same native mocks as an assembled client. */
+/** Gateway Client cone used by the subagent-catalog and connected-generation cases. */
 const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
 const it = createClientTest({ roster: API_ROSTER })
+/** The first client boot pays the cold module transform of the api cone. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
 
 type SummaryOver = Partial<{
   updatedAt: number
@@ -229,7 +231,7 @@ describe('search', () => {
         hasMore: true,
       },
     })
-    expect(remote.session.search).toHaveBeenCalledWith({ query: 'exact phrase' }, signal)
+    expect(remote.session.search).toHaveBeenCalledExactlyOnceWith({ query: 'exact phrase' }, signal)
   })
 
   it('preserves business errors and propagates a non-Remote throw', async ({ mock, remote }) => {
@@ -345,7 +347,7 @@ describe('subagent catalogs', () => {
         address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
       },
     })
-  })
+  }, COLD_BOOT_TIMEOUT_MS)
 
   it('refetches debounced membership only while the parent catalog is open', async ({ mock, remote }) => {
     vi.useFakeTimers()
@@ -632,7 +634,7 @@ describe('remaining branches', () => {
     remote.session.create.mockResolvedValue(ok({ sessionId: S1 }))
     const manager = makeManager(mock, remote)
     await manager.create({ cwd: '/tmp/w', sessionId: S1 })
-    expect(remote.session.create).toHaveBeenCalledWith({ cwd: '/tmp/w', sessionId: S1 })
+    expect(remote.session.create).toHaveBeenCalledExactlyOnceWith({ cwd: '/tmp/w', sessionId: S1 })
     expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
     await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
     expect(manager.getListSnapshot().items).toHaveLength(1)

+ 3 - 1
packages/api/session-controller/tests/projection-store.client.spec.ts

@@ -33,6 +33,8 @@ const SID = 'fk-s1' as SessionId
 /** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */
 const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
 const it = createClientTest({ roster: API_ROSTER })
+/** The first client boot pays the cold module transform of the api cone. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
 
 function makeManager(mock: RemoteMock, remote: ClientTestFixtures['remote']): SessionManager {
   mock.load(sessionWorld)
@@ -124,7 +126,7 @@ describe('Session tail-page seeding', () => {
     } as never)))
     await session.open()
     expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
-  })
+  }, COLD_BOOT_TIMEOUT_MS)
 
   it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async ({ mock, start }) => {
     const session = await sessionBench(mock, start, SID)

+ 7 - 5
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -25,6 +25,8 @@ import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client
 const sid = (s: string): SessionId => s as SessionId
 /** ClientSessions uses the Gateway client for stream supervision and the native Remote mocks for responses. */
 const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
+/** The first client boot pays the cold module transform of the api cone. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
 
 interface Bench {
   ctx: Context
@@ -96,7 +98,7 @@ describe('list store projection', () => {
       displayTitle: 's2', parentId: 's1', origin: 'subagent', running: true,
     })
     expect(state.byId[sid('s2')]?.title).toBeUndefined()
-  })
+  }, COLD_BOOT_TIMEOUT_MS)
 
   it('reprojects a blank session from the generic agent-preset projection', async ({ bench }) => {
     const b = bench()
@@ -920,7 +922,7 @@ describe('create', () => {
     const b = bench()
     b.mock.remote.session.create.mockResolvedValue(ok({ sessionId: sid('fresh') }))
     await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
-    expect(b.mock.remote.session.create).toHaveBeenCalledWith({ cwd: '/w', sessionId: 'fresh' })
+    expect(b.mock.remote.session.create).toHaveBeenCalledExactlyOnceWith({ cwd: '/w', sessionId: 'fresh' })
     b.mock.remote.session.create.mockResolvedValue(err(new RemoteError('gateway/internal', '爆了', {})))
     const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
     expect(failure).toBeInstanceOf(SessionCreateError)
@@ -985,8 +987,8 @@ describe('fork', () => {
       sessionId: sid('source'), atSeq: 7, increaseTitle: true,
     })).resolves.toBe('child')
 
-    expect(b.mock.remote.session.fork).toHaveBeenCalledWith({ sessionId: 'source', atSeq: 7 })
-    expect(b.mock.remote.session.rename).toHaveBeenCalledWith({ sessionId: 'child', title: childTitle })
+    expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: 'source', atSeq: 7 })
+    expect(b.mock.remote.session.rename).toHaveBeenCalledExactlyOnceWith({ sessionId: 'child', title: childTitle })
     await Promise.resolve()
     expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
       title: childTitle,
@@ -1003,7 +1005,7 @@ describe('fork', () => {
     // The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
     await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
 
-    expect(b.mock.remote.session.fork).toHaveBeenCalledWith({ sessionId: 'source', atSeq: 41 })
+    expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: 'source', atSeq: 41 })
   })
 
   it('does not rename without the title policy or a durable source title', async ({ bench }) => {