Files
openchamber/packages/ui/src/sync/bootstrap.test.ts
T
Leonidandbashrusakh c1f19b1158 test(sync): preserve question bootstrap and reducer invariants (#3439)
* test(sync): preserve question bootstrap and reducer invariants

Port the main-independent test assets from the superseded hybrid V2
question PRs (#3266/#3267/#3288) onto current main's V1-only
architecture:

- listPendingQuestions: unscoped + per-directory fan-out, merge with
  id-dedupe (first wins), failure throws instead of empty success,
  malformed-item skip, true-empty success (client.questions.test.ts)
- question reducer invariants: idempotent upsert-by-id, replace-not-
  first-wins, removal by session/request pair, duplicate-terminal no-op,
  late-asked re-registration (no tombstone)
- bootstrap deferred-phase question merge: signature-based replace and
  disappearance-deletion, in-flight-change preservation (stale guard),
  retry-then-merge on transient failure

Tests only: zero production changes, zero V2 SDK calls, zero fallback
logic, zero raw V2 event aliases.

* test(questions): clarify V1 merge id-filter test title

---------

Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
2026-09-09 17:44:11 +03:00

251 lines
9.0 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import type { OpencodeClient, Project, QuestionRequest } from "@opencode-ai/sdk/v2/client"
import { bootstrapDirectory } from "./bootstrap"
import { INITIAL_STATE, type State } from "./types"
const createSdk = (options?: {
commandList?: () => Promise<{ data: unknown[] }>
sessionStatus?: () => Promise<{ data: State['session_status'] }>
questionList?: () => Promise<{ data?: unknown[]; error?: unknown; response?: { status?: number } }>
}) => ({
project: { current: async () => ({ data: { id: "project-a" } }) },
config: { get: async () => ({ data: {} }) },
path: { get: async () => ({ data: { state: "", config: "", worktree: "/repo", directory: "/repo", home: "/home" } }) },
session: { status: options?.sessionStatus ?? (async () => ({ data: {} })) },
command: { list: options?.commandList ?? (async () => ({ data: [] })) },
mcp: { status: async () => ({ data: {} }) },
lsp: { status: async () => ({ data: [] }) },
vcs: { get: async () => ({ data: { branch: "main" } }) },
question: { list: options?.questionList ?? (async () => ({ data: [] })) },
permission: { list: async () => ({ data: [] }) },
}) as unknown as OpencodeClient
const createState = (): State => ({
...INITIAL_STATE,
message: {},
part: {},
})
const project = { id: "project-a", worktree: "/repo" } as Project
describe("bootstrapDirectory", () => {
test("prioritizes session loading without waiting for deferred fields", async () => {
let state = createState()
let deferredStarted = false
let resolveDeferred!: () => void
const deferred = new Promise<{ data: unknown[] }>((resolve) => {
resolveDeferred = () => resolve({ data: [] })
})
let resolveSessions!: () => void
const sessions = new Promise<void>((resolve) => {
resolveSessions = resolve
})
let settled = false
const sdk = createSdk({
commandList: async () => {
deferredStarted = true
return deferred
},
})
const bootstrapping = bootstrapDirectory({
directory: "/repo",
sdk,
getState: () => state,
set: (patch) => {
state = { ...state, ...patch }
},
global: { config: {}, projects: [project] },
loadSessions: () => sessions,
}).then((result) => {
settled = true
return result
})
await Promise.resolve()
await Promise.resolve()
expect(settled).toBe(false)
expect(deferredStarted).toBe(false)
resolveSessions()
expect(await bootstrapping).toBe("complete")
expect(state.status).toBe("complete")
expect(state.sessionStatusReady).toBe(true)
expect(deferredStarted).toBe(false)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(deferredStarted).toBe(true)
resolveDeferred()
})
test("reports session-list failure without clearing existing state", async () => {
let state = { ...createState(), session: [{ id: "cached" }] as State["session"] }
const result = await bootstrapDirectory({
directory: "/repo",
sdk: createSdk(),
getState: () => state,
set: (patch) => {
state = { ...state, ...patch }
},
global: { config: {}, projects: [project] },
loadSessions: async () => {
throw new Error("unavailable")
},
})
expect(result).toBe("failed")
expect(state.session.map((session) => session.id)).toEqual(["cached"])
})
test("rejects stale work before committing", async () => {
const state = createState()
let commits = 0
const result = await bootstrapDirectory({
directory: "/repo",
sdk: createSdk(),
getState: () => state,
set: () => {
commits += 1
},
isStale: () => true,
global: { config: {}, projects: [project] },
loadSessions: async () => undefined,
})
expect(result).toBe("stale")
expect(commits).toBe(0)
})
test("a failed status request cannot grant idle authority even when bootstrap completes", async () => {
let state = createState()
const result = await bootstrapDirectory({
directory: '/repo',
sdk: createSdk({ sessionStatus: async () => { throw new Error('status unavailable') } }),
getState: () => state,
set: (patch) => { state = { ...state, ...patch } },
global: { config: {}, projects: [project] },
loadSessions: async () => undefined,
})
expect(result).toBe('complete')
expect(state.sessionStatusReady).toBe(undefined)
})
test("deferred phase merges fetched questions by session, replacing the pre-fetch record", async () => {
let state = createState()
const preExisting: QuestionRequest = { id: "que_1", sessionID: "ses_1", questions: [] }
const fetched: QuestionRequest[] = [
{ id: "que_2", sessionID: "ses_1", questions: [] },
{
id: "que_1",
sessionID: "ses_1",
questions: [{ question: "updated?", header: "Build", options: [{ label: "Yes", description: "Go" }] }],
},
]
state = { ...state, question: { ses_1: [preExisting] } }
const sdk = createSdk({ questionList: async () => ({ data: fetched }) })
await bootstrapDirectory({
directory: "/repo",
sdk,
getState: () => state,
set: (patch) => { state = { ...state, ...patch } },
global: { config: {}, projects: [project] },
loadSessions: async () => undefined,
})
// Deferred phase runs on a setTimeout(0); give it a tick.
await new Promise((resolve) => setTimeout(resolve, 20))
// The fetched (sorted) records replace the pre-fetch snapshot entirely.
expect(state.question["ses_1"]?.map((q) => q.id)).toEqual(["que_1", "que_2"])
expect(state.question["ses_1"]?.[0]?.questions).toEqual(fetched[1].questions)
})
test("deferred phase deletes a session's questions when they disappear and the signature is unchanged", async () => {
let state = createState()
const que1: QuestionRequest = { id: "que_1", sessionID: "ses_1", questions: [] }
const que3: QuestionRequest = { id: "que_3", sessionID: "ses_2", questions: [] }
state = {
...state,
question: {
ses_1: [que1],
ses_2: [que3],
},
}
const sdk = createSdk({ questionList: async () => ({ data: [] }) })
await bootstrapDirectory({
directory: "/repo",
sdk,
getState: () => state,
set: (patch) => { state = { ...state, ...patch } },
global: { config: {}, projects: [project] },
loadSessions: async () => undefined,
})
await new Promise((resolve) => setTimeout(resolve, 20))
// Both sessions vanished from the fetched list and nothing changed in
// between, so the signature guard allows the delete.
expect(state.question).toEqual({})
})
test("deferred phase preserves in-flight question changes when the signature changed (stale guard)", async () => {
let state = createState()
const que1: QuestionRequest = { id: "que_1", sessionID: "ses_1", questions: [] }
const que2: QuestionRequest = { id: "que_2", sessionID: "ses_1", questions: [] }
state = { ...state, question: { ses_1: [que1] } }
const sdk = createSdk({
questionList: async () => {
// Simulate an event landing while the deferred fetch is in flight:
// the session gains a second question before the fetch resolves.
state = { ...state, question: { ...state.question, ses_1: [que1, que2] } }
return { data: [] }
},
})
await bootstrapDirectory({
directory: "/repo",
sdk,
getState: () => state,
set: (patch) => { state = { ...state, ...patch } },
global: { config: {}, projects: [project] },
loadSessions: async () => undefined,
})
await new Promise((resolve) => setTimeout(resolve, 20))
// The fetched list is empty, but the in-flight change altered the
// signature, so the disappearance must NOT be treated as authoritative.
expect(state.question["ses_1"]?.map((q) => q.id)).toEqual(["que_1", "que_2"])
})
test("deferred phase retries a transient question.list failure and still merges", async () => {
let state = createState()
const que1: QuestionRequest = { id: "que_1", sessionID: "ses_1", questions: [] }
let calls = 0
let resolveSecondCall!: () => void
const secondCall = new Promise<void>((resolve) => { resolveSecondCall = resolve })
const sdk = createSdk({
questionList: async () => {
calls += 1
if (calls === 1) {
return { error: { name: "ServerError", data: { message: "boom" } }, response: new Response(null, { status: 500 }) }
}
resolveSecondCall()
return { data: [que1] }
},
})
await bootstrapDirectory({
directory: "/repo",
sdk,
getState: () => state,
set: (patch) => { state = { ...state, ...patch } },
global: { config: {}, projects: [project] },
loadSessions: async () => undefined,
})
// retry() backs off 500ms before the second attempt; wait for it.
await secondCall
await new Promise((resolve) => setTimeout(resolve, 0))
expect(calls).toBeGreaterThanOrEqual(2)
expect(state.question["ses_1"]?.map((q) => q.id)).toEqual(["que_1"])
})
})