feat(fixtures): SSE patch + pagination scenario fixtures (K7)

Two new golden-fixture suites, expectations machine-generated from the web
implementation (same source-of-truth principle as the chat suite):

- shared/fixtures/sse/ (12 cases): session-updated versioned-patch
  application via applySessionDetailPatch — strict version gates for
  metadata/agentState/todos/teamState, out-of-order arrival, teamState:null
  clear, max-monotonic updatedAt, flat last-write-wins fields, sub-minute
  activeAt keep-alive drop, scratchlistUpdatedAt trigger, and the pinned
  actual behavior that activeTurnStartedAt is NOT applied by the patch path.
  Inputs are stored schema-normalized and validated against SessionSchema /
  strict SessionPatchSchema at generation time; per-patch applied/unchanged
  verdicts are part of the contract.

- shared/fixtures/pagination/ (11 cases): op scripts driving the real
  message-window store with a scripted ApiClient — latest page + SSE ingest,
  before-cursor older pages, epoch-mismatch reset (window discard + recorded
  internal latest request), reset:true replace preserving optimistic rows,
  localId echo reconciliation, messages-consumed invokedAt stamping (no
  cursor advance), message-cancelled removal, cancel-too-late invoked-row
  ingest, hidden-row cursor advance, 400-row trim preserving queued rows,
  and queued-state gap recovery. Documents pin the exact requests the store
  issued, older-load outcomes, reconcile candidates, and a minimal window
  projection (ids/order, queued/optimistic flags, hasMore, epoch, viewMode,
  compound cursors).

Generator: web/scripts/fixtures/{sse,pagination}/ extend the K5 framework
(canonical serialization reused; suites pruned of stale files). Web
self-conformance tests replay every fixture against the real implementation
in bun run test:web. README documents schemas, projections, replay
contracts, and native consumption for both suites.

No time injection needed: the store's only Date.now() reads gate
notification throttling, which never reaches persisted or projected state.
This commit is contained in:
weishu
2026-08-17 12:06:57 +08:00
parent 81146e68c5
commit e1bfdd6265
36 changed files with 14424 additions and 71 deletions
@@ -0,0 +1,70 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { clearMessageWindow } from '@/lib/message-window-store'
import { FIXTURE_VERSION } from '../../scripts/fixtures/fixtureTypes'
import {
extractOpExpectations,
paginationFixtureSessionId,
runPaginationScript
} from '../../scripts/fixtures/pagination/runner'
import type { PaginationFixtureDocument } from '../../scripts/fixtures/pagination/types'
import { toCanonicalJson } from '../../scripts/fixtures/serialize'
// Self-conformance for the pagination fixtures in shared/fixtures/pagination:
// replaying every stored op script through the REAL message-window store must
// reproduce the stored request log, older-load outcomes, reconcile candidates
// and final window projection, and every file must be canonically serialized.
// A failure here means the web store (or the projection) drifted — rerun
// `bun run gen:fixtures` from the repo root and commit the diff, which is what
// flips the native iOS/Android conformance suites red.
const fixturesDir = resolve(process.cwd(), '../shared/fixtures')
const paginationDir = join(fixturesDir, 'pagination')
const fixtureFiles = readdirSync(paginationDir).filter((name) => name.endsWith('.json')).sort()
function canonical(value: unknown): unknown {
return JSON.parse(toCanonicalJson(value))
}
afterEach(() => {
for (const file of fixtureFiles) {
clearMessageWindow(paginationFixtureSessionId(file.slice(0, -'.json'.length)))
}
sessionStorage.clear()
})
describe('shared/fixtures/pagination golden fixtures', () => {
it('has generated fixtures on disk', () => {
expect(fixtureFiles.length).toBeGreaterThan(0)
})
it('VERSION matches the generator fixtureVersion', () => {
expect(readFileSync(join(fixturesDir, 'VERSION'), 'utf8')).toBe(`${FIXTURE_VERSION}\n`)
})
for (const file of fixtureFiles) {
describe(file, () => {
const raw = readFileSync(join(paginationDir, file), 'utf8')
const document = JSON.parse(raw) as PaginationFixtureDocument
it('is canonically serialized (sorted keys, 4-space indent, trailing LF)', () => {
expect(toCanonicalJson(document)).toBe(raw)
})
it('carries the current fixtureVersion and a name matching its file', () => {
expect(document.fixtureVersion).toBe(FIXTURE_VERSION)
expect(`${document.name}.json`).toBe(file)
})
it('replaying the ops against the real store matches the stored expectations', async () => {
const { observations, expectedState } = await runPaginationScript(
paginationFixtureSessionId(document.name),
document.ops
)
expect(canonical(observations)).toEqual(canonical(extractOpExpectations(document.ops)))
expect(canonical(expectedState)).toEqual(canonical(document.expectedState))
})
})
}
})
+71
View File
@@ -0,0 +1,71 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { SessionPatchSchema, SessionSchema } from '@hapi/protocol/schemas'
import { FIXTURE_VERSION } from '../../scripts/fixtures/fixtureTypes'
import { toCanonicalJson } from '../../scripts/fixtures/serialize'
import { runSessionPatchScript } from '../../scripts/fixtures/sse/apply'
import type { SseFixtureDocument } from '../../scripts/fixtures/sse/types'
// Self-conformance for the SSE session-patch fixtures in shared/fixtures/sse:
// the stored expectations must equal a fresh applySessionDetailPatch fold over
// the stored initialSession + patches, every input must round-trip through the
// wire schemas unchanged, and every file must be canonically serialized. A
// failure here means the web patch rules (or the schemas) drifted — rerun
// `bun run gen:fixtures` from the repo root and commit the diff, which is what
// flips the native iOS/Android conformance suites red.
const fixturesDir = resolve(process.cwd(), '../shared/fixtures')
const sseDir = join(fixturesDir, 'sse')
const fixtureFiles = readdirSync(sseDir).filter((name) => name.endsWith('.json')).sort()
function canonical(value: unknown): unknown {
return JSON.parse(toCanonicalJson(value))
}
describe('shared/fixtures/sse golden fixtures', () => {
it('has generated fixtures on disk', () => {
expect(fixtureFiles.length).toBeGreaterThan(0)
})
it('VERSION matches the generator fixtureVersion', () => {
expect(readFileSync(join(fixturesDir, 'VERSION'), 'utf8')).toBe(`${FIXTURE_VERSION}\n`)
})
for (const file of fixtureFiles) {
describe(file, () => {
const raw = readFileSync(join(sseDir, file), 'utf8')
const document = JSON.parse(raw) as SseFixtureDocument
it('is canonically serialized (sorted keys, 4-space indent, trailing LF)', () => {
expect(toCanonicalJson(document)).toBe(raw)
})
it('carries the current fixtureVersion and a name matching its file', () => {
expect(document.fixtureVersion).toBe(FIXTURE_VERSION)
expect(`${document.name}.json`).toBe(file)
})
it('stores schema-normalized wire inputs (SessionSchema / strict SessionPatchSchema)', () => {
// Parsing the stored form must be a no-op: natives can decode
// the document verbatim, and a wire-schema change that would
// alter parsing shows up as a diff here.
expect(canonical(SessionSchema.parse(document.initialSession)))
.toEqual(canonical(document.initialSession))
expect(document.patches.length).toBeGreaterThan(0)
for (const patch of document.patches) {
expect(canonical(SessionPatchSchema.parse(patch))).toEqual(canonical(patch))
}
})
it('expected outcome matches a fresh applySessionDetailPatch fold over the inputs', () => {
const { expectedPatchResults, expectedSession } = runSessionPatchScript(
document.initialSession,
document.patches
)
expect(expectedPatchResults).toEqual(document.expectedPatchResults)
expect(canonical(expectedSession)).toEqual(canonical(document.expectedSession))
})
})
}
})