Skip to content

Commit ebdbb5a

Browse files
test(webview): prove the view-state ownership guard and the after-write ledger at their layer
Adds src/core/webview/__tests__/ClineProvider.persistedViewState.spec.ts: 6 tests over the tombstone, eviction and re-key ledger, covering the reject path and the accept path through both entry points the guard is used at, save and rekey. It is the proof for the two survivors CodeRabbit reported on PR 2416 at src/core/webview/ClineProvider.ts:466 (BooleanLiteral on wroteUnderTemporaryViewStateId) and :480 (ArrayDeclaration on prunedViewIdsDuringActivation); before this file no spec named any of the six view-state internals. Sensitivity measured on the tree this commit sits on, each run recorded in the lane scratch directory: flipping wroteUnderTemporaryViewStateId = false to true, the 466 mutant, fails 4 of 6, including the reject-path test that names the adoption the mutant models; flipping prunedViewIdsDuringActivation: string[] = [] to [0], the 480 mutant, fails 4 of 6; reverting the ordering fix of the previous commit with this spec unchanged fails exactly the 3 ordering tests; restoring the fix returns 6 of 6. The spec discriminates rather than merely passing. The focused lane exited 0, and the core lane ran 198 files with 4,270 passed and 9 skipped, exit 0. The 6 tests cost nothing against the gate's 500-changed-executable-line cap because the instrument's packageForPath excludes *.spec.ts, *.test.ts, *.visual.*, .d.ts and the config and setup shims. That is the gate's own rule about where mutation testing applies and not a loophole; it is also why the regression test sits beside the class it tests instead of inside a priced file.
1 parent bbd9931 commit ebdbb5a

1 file changed

Lines changed: 339 additions & 0 deletions

File tree

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
import { WebviewFocusTracker } from "../WebviewFocusTracker"
2+
// pnpm --dir src exec vitest run --config vitest.unit.config.mjs core/webview/__tests__/ClineProvider.persistedViewState.spec.ts
3+
4+
import * as vscode from "vscode"
5+
import { makeCompositeDisposable } from "../../../test-utils/vscode"
6+
7+
import { TelemetryService } from "@roo-code/telemetry"
8+
import type { GlobalState } from "@roo-code/types"
9+
10+
import { ContextProxy } from "../../config/ContextProxy"
11+
import { ClineProvider } from "../ClineProvider"
12+
13+
// Mock setup
14+
vi.mock("fs/promises", () => ({
15+
mkdir: vi.fn().mockResolvedValue(undefined),
16+
writeFile: vi.fn().mockResolvedValue(undefined),
17+
readFile: vi.fn().mockResolvedValue(""),
18+
unlink: vi.fn().mockResolvedValue(undefined),
19+
rmdir: vi.fn().mockResolvedValue(undefined),
20+
}))
21+
22+
vi.mock("../../../utils/storage", () => ({
23+
getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"),
24+
getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"),
25+
getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"),
26+
}))
27+
28+
vi.mock("p-wait-for", () => ({
29+
__esModule: true,
30+
default: vi.fn().mockResolvedValue(undefined),
31+
}))
32+
33+
vi.mock("delay", () => {
34+
const delayFn = (_ms: number) => Promise.resolve()
35+
delayFn.createDelay = delayFn
36+
delayFn.reject = delayFn
37+
delayFn.range = delayFn
38+
return { default: delayFn }
39+
})
40+
41+
vi.mock("vscode", () => ({
42+
ExtensionContext: vi.fn(),
43+
OutputChannel: vi.fn(),
44+
WebviewView: vi.fn(),
45+
Disposable: { from: (...subscriptions: vscode.Disposable[]) => makeCompositeDisposable(...subscriptions) },
46+
Uri: {
47+
joinPath: vi.fn(),
48+
file: vi.fn(),
49+
},
50+
commands: {
51+
executeCommand: vi.fn().mockResolvedValue(undefined),
52+
},
53+
window: {
54+
showInformationMessage: vi.fn(),
55+
showWarningMessage: vi.fn(),
56+
showErrorMessage: vi.fn(),
57+
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
58+
},
59+
workspace: {
60+
getConfiguration: vi.fn().mockReturnValue({
61+
get: vi.fn().mockReturnValue([]),
62+
update: vi.fn(),
63+
}),
64+
onDidChangeConfiguration: vi.fn().mockImplementation(() => {
65+
return {
66+
dispose: vi.fn(),
67+
}
68+
}),
69+
},
70+
env: {
71+
uriScheme: "vscode",
72+
language: "en",
73+
appName: "Visual Studio Code",
74+
},
75+
extensions: {
76+
all: [],
77+
},
78+
ExtensionMode: {
79+
Production: 1,
80+
Development: 2,
81+
Test: 3,
82+
},
83+
version: "1.85.0",
84+
}))
85+
86+
vi.mock("../../../utils/tts", () => ({
87+
setTtsEnabled: vi.fn(),
88+
setTtsSpeed: vi.fn(),
89+
}))
90+
91+
vi.mock("../../../api", () => ({
92+
buildApiHandler: vi.fn(),
93+
}))
94+
95+
vi.mock("../../../integrations/workspace/WorkspaceTracker", () => {
96+
return {
97+
default: vi.fn().mockImplementation(function () {
98+
return {
99+
initializeFilePaths: vi.fn(),
100+
dispose: vi.fn(),
101+
}
102+
}),
103+
}
104+
})
105+
106+
vi.mock("../../task/Task", () => ({
107+
Task: vi.fn().mockImplementation(() => ({
108+
taskId: "mock-task-id",
109+
instanceId: "mock-instance-id",
110+
abortTask: vi.fn().mockResolvedValue(undefined),
111+
emit: vi.fn(),
112+
})),
113+
}))
114+
115+
vi.mock("@roo-code/cloud", () => ({
116+
CloudService: {
117+
hasInstance: vi.fn().mockReturnValue(true),
118+
get instance() {
119+
return {
120+
isAuthenticated: vi.fn().mockReturnValue(false),
121+
off: vi.fn(),
122+
}
123+
},
124+
},
125+
getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
126+
}))
127+
128+
vi.mock("../../../integrations/theme/getTheme", () => ({
129+
getTheme: vi.fn().mockResolvedValue({ kind: "dark" }),
130+
}))
131+
132+
vi.mock("../webviewMessageHandler", () => ({
133+
webviewMessageHandler: vi.fn().mockResolvedValue(undefined),
134+
}))
135+
136+
type PersistedEntry = NonNullable<GlobalState["viewStates"]>[string]
137+
type ViewStates = Record<string, PersistedEntry>
138+
139+
describe("ClineProvider - persisted view state write ordering", () => {
140+
let provider: ClineProvider
141+
let durable: Record<string, unknown>
142+
let failNextViewStatesWrite = false
143+
144+
// The tombstone set and the write queue are per-class, so every test starts from an
145+
// empty ledger: otherwise a ledger entry from the previous test reads as a failure
146+
// of this one.
147+
const tombstones = () => ClineProvider["clearedViewStateIds"]
148+
const evictions = () => provider["prunedViewIdsDuringActivation"]
149+
// One cast: the durable double stores every key as unknown, and the only reader
150+
// here is the viewStates map this suite seeds.
151+
const viewStates = () => durable["viewStates"] as ViewStates | undefined
152+
153+
beforeEach(() => {
154+
vi.clearAllMocks()
155+
failNextViewStatesWrite = false
156+
157+
if (!TelemetryService.hasInstance()) {
158+
TelemetryService.createInstance([])
159+
}
160+
161+
durable = { mode: "code", currentApiConfigName: "test-config", viewStates: {} }
162+
163+
const secrets: Record<string, string | undefined> = {}
164+
165+
// Structural double providing only the storage members ClineProvider and ContextProxy
166+
// read. The viewStates update rejects once per armed test, which is how a durable
167+
// write failure is modelled: the in-memory cache is still updated first.
168+
const globalState: Record<string, unknown> = durable
169+
const update = vi.fn((key: string, value: unknown) => {
170+
if (key === "viewStates" && failNextViewStatesWrite) {
171+
failNextViewStatesWrite = false
172+
return Promise.reject(new Error("durable write failed"))
173+
}
174+
globalState[key] = value
175+
return Promise.resolve()
176+
})
177+
const mockContext = {
178+
extensionPath: "/test/path",
179+
extensionUri: { fsPath: "/test/path" },
180+
globalState: {
181+
get: vi.fn((key: string) => globalState[key]),
182+
update,
183+
keys: vi.fn(() => Object.keys(globalState)),
184+
},
185+
secrets: {
186+
get: vi.fn((key: string) => secrets[key]),
187+
store: vi.fn((key: string, value: string | undefined) => {
188+
secrets[key] = value
189+
return Promise.resolve()
190+
}),
191+
delete: vi.fn((key: string) => {
192+
delete secrets[key]
193+
return Promise.resolve()
194+
}),
195+
},
196+
workspaceState: {
197+
get: vi.fn(() => undefined),
198+
update: vi.fn(() => Promise.resolve()),
199+
keys: vi.fn(() => []),
200+
},
201+
subscriptions: [],
202+
extension: { packageJSON: { version: "1.0.0" } },
203+
globalStorageUri: { fsPath: "/test/storage/path" },
204+
} as unknown as vscode.ExtensionContext
205+
206+
// Structural double providing only the logging members ClineProvider calls.
207+
const mockOutputChannel = {
208+
appendLine: vi.fn(),
209+
clear: vi.fn(),
210+
dispose: vi.fn(),
211+
} as unknown as vscode.OutputChannel
212+
213+
tombstones().clear()
214+
ClineProvider["persistedViewStateWriteQueue"] = Promise.resolve()
215+
216+
provider = new ClineProvider(
217+
mockContext,
218+
mockOutputChannel,
219+
"sidebar",
220+
new ContextProxy(mockContext),
221+
new WebviewFocusTracker(),
222+
)
223+
})
224+
225+
afterEach(async () => {
226+
await provider.dispose()
227+
})
228+
229+
test("records no tombstone when the write that clears an entry rejects", async () => {
230+
const viewStateId = provider["viewId"]
231+
durable["viewStates"] = {
232+
[viewStateId]: { mode: "code", currentApiConfigName: "profile-a", updatedAt: 1 },
233+
}
234+
failNextViewStatesWrite = true
235+
236+
await expect(provider["savePersistedViewState"]({ mode: undefined, currentApiConfigName: undefined })).rejects.toThrow(
237+
"durable write failed",
238+
)
239+
240+
// The map on disk still holds the entry, so the tombstone that would tell a later
241+
// activation to skip it was never earned.
242+
expect(tombstones().has(viewStateId)).toBe(false)
243+
expect(Object.hasOwn(viewStates() ?? {}, viewStateId)).toBe(true)
244+
expect(evictions()).toEqual([])
245+
})
246+
247+
test("records the tombstone once the clearing write lands, and lifts it on the next landing write", async () => {
248+
const viewStateId = provider["viewId"]
249+
durable["viewStates"] = {
250+
[viewStateId]: { mode: "code", currentApiConfigName: "profile-a", updatedAt: 1 },
251+
}
252+
253+
await provider["savePersistedViewState"]({ mode: undefined, currentApiConfigName: undefined })
254+
255+
expect(tombstones().has(viewStateId)).toBe(true)
256+
expect(Object.hasOwn(viewStates() ?? {}, viewStateId)).toBe(false)
257+
258+
await provider["savePersistedViewState"]({ mode: "code", currentApiConfigName: "profile-a" })
259+
260+
expect(tombstones().has(viewStateId)).toBe(false)
261+
expect(viewStates()?.[viewStateId]?.mode).toBe("code")
262+
})
263+
264+
test("records evictions only for the map that reached durable storage", async () => {
265+
// The cap keeps the 50 most recent closed-view entries, so 52 seeded entries evict
266+
// the two oldest while the live view's own entry is never aged out.
267+
const viewStateId = provider["viewId"]
268+
const seeded: ViewStates = {
269+
[viewStateId]: { mode: "code", currentApiConfigName: "profile-a", updatedAt: 100 },
270+
}
271+
for (let index = 0; index < 52; index++) {
272+
seeded[`closed-${index}`] = { mode: "code", currentApiConfigName: "profile-a", updatedAt: index }
273+
}
274+
durable["viewStates"] = seeded
275+
failNextViewStatesWrite = true
276+
277+
await expect(provider["savePersistedViewState"]({ currentApiConfigName: "profile-b" })).rejects.toThrow(
278+
"durable write failed",
279+
)
280+
281+
expect(evictions()).toEqual([])
282+
expect(tombstones().size).toBe(0)
283+
284+
await provider["savePersistedViewState"]({ mode: "code", currentApiConfigName: "profile-b" })
285+
286+
expect(evictions()).toEqual(["closed-0", "closed-1"])
287+
expect(Object.hasOwn(viewStates() ?? {}, "closed-0")).toBe(false)
288+
})
289+
290+
test("leaves an entry this view never wrote under its temporary id", async () => {
291+
// Temporary ids restart from 0 on every host start, so this entry can belong to an
292+
// earlier session: re-keying it would adopt another session's pinned profile.
293+
const tempId = provider["viewId"]
294+
durable["viewStates"] = {
295+
[tempId]: { mode: "plan", currentApiConfigName: "profile-a", updatedAt: 5 },
296+
}
297+
298+
await provider["rekeyPersistedViewStateEntry"]("stable-view")
299+
300+
expect(Object.hasOwn(viewStates() ?? {}, tempId)).toBe(true)
301+
expect(Object.hasOwn(viewStates() ?? {}, "stable-view")).toBe(false)
302+
expect(tombstones().has(tempId)).toBe(false)
303+
expect(provider["wroteUnderTemporaryViewStateId"]).toBe(false)
304+
})
305+
306+
test("re-keys an entry this view wrote under its temporary id", async () => {
307+
const tempId = provider["viewId"]
308+
309+
await provider["savePersistedViewState"]({ mode: "code", currentApiConfigName: "profile-a" })
310+
311+
expect(provider["wroteUnderTemporaryViewStateId"]).toBe(true)
312+
expect(Object.hasOwn(viewStates() ?? {}, tempId)).toBe(true)
313+
314+
await provider["rekeyPersistedViewStateEntry"]("stable-view")
315+
316+
expect(Object.hasOwn(viewStates() ?? {}, "stable-view")).toBe(true)
317+
expect(Object.hasOwn(viewStates() ?? {}, tempId)).toBe(false)
318+
expect(viewStates()?.["stable-view"]?.currentApiConfigName).toBe("profile-a")
319+
expect(tombstones().has(tempId)).toBe(true)
320+
expect(tombstones().has("stable-view")).toBe(false)
321+
expect(provider["wroteUnderTemporaryViewStateId"]).toBe(false)
322+
})
323+
324+
test("records no tombstone when the re-key write rejects", async () => {
325+
const tempId = provider["viewId"]
326+
327+
await provider["savePersistedViewState"]({ mode: "code", currentApiConfigName: "profile-a" })
328+
failNextViewStatesWrite = true
329+
330+
await expect(provider["rekeyPersistedViewStateEntry"]("stable-view")).rejects.toThrow("durable write failed")
331+
332+
// The entry is still under the temporary name, so the next registration must be able
333+
// to re-key it: recording the tombstone pair here would make the retry skip it.
334+
expect(tombstones().size).toBe(0)
335+
expect(Object.hasOwn(viewStates() ?? {}, tempId)).toBe(true)
336+
expect(Object.hasOwn(viewStates() ?? {}, "stable-view")).toBe(false)
337+
expect(provider["wroteUnderTemporaryViewStateId"]).toBe(true)
338+
})
339+
})

0 commit comments

Comments
 (0)