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>
This commit is contained in:
committed by
GitHub
co-authored by
bashrusakh
parent
5c127a1e3c
commit
c1f19b1158
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
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'] }> }) => ({
|
||||
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" } }) },
|
||||
@@ -12,7 +16,7 @@ const createSdk = (options?: { commandList?: () => Promise<{ data: unknown[] }>;
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
lsp: { status: async () => ({ data: [] }) },
|
||||
vcs: { get: async () => ({ data: { branch: "main" } }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
question: { list: options?.questionList ?? (async () => ({ data: [] })) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
}) as unknown as OpencodeClient
|
||||
|
||||
@@ -123,4 +127,124 @@ describe("bootstrapDirectory", () => {
|
||||
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"])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user