Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/code-qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ jobs:
run: pnpm lint
- name: Check types
run: pnpm check-types
- name: Test restart artifact sanitization
run: pnpm --filter @roo-code/vscode-e2e test:restart-artifacts
- name: Validate Code QA workflow
run: pnpm test:code-qa-ci
- name: Validate Solheim provider smoke workflow
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ jobs:
if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true'
run: TEST_FILE=restart-persistence.test USE_MOCK=true xvfb-run -a pnpm --filter @roo-code/vscode-e2e exec node ./out/runTest.js

- name: Upload restart-persistence failure artifacts
# Publish only generated metadata. Raw histories and logs are excluded.
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: restart-persistence-failure
retention-days: 7
path: apps/vscode-e2e/test-failures/**/diagnostics.json
if-no-files-found: ignore

- name: Explain skipped mocked E2E pass marker
if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' && steps.vscode-fallback.outputs.used == 'true'
run: echo "Skipping mocked E2E pass marker because tests ran against a stale cached VS Code binary (VS Code download endpoints unreachable)."
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ AGENTS.local.md
.vscode-test/
apps/vscode-e2e/playwright-report-electron/
apps/vscode-e2e/test-results-electron/
apps/vscode-e2e/test-failures/

# Docs
docs/_site/
Expand Down
1 change: 1 addition & 0 deletions apps/vscode-e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"format": "prettier --write src",
"test:ci": "pnpm -w bundle && pnpm --filter @roo-code/vscode-webview build && pnpm test:run",
"test:ci:mock": "pnpm -w bundle && pnpm --filter @roo-code/vscode-webview build && USE_MOCK=true pnpm test:run",
"test:restart-artifacts": "tsc -p tsconfig.restart-artifacts.json && node ./out/restart/scenarioWorkspace.test.js",
"test:record": "AIMOCK_RECORD=true pnpm test:ci",
"test:run": "rimraf out && tsc -p tsconfig.json && dotenv -e .env.local -- node ./out/runTest.js",
"themes:build": "pnpm -w bundle && pnpm --filter @roo-code/vscode-webview build && rimraf out && tsc -p tsconfig.json",
Expand Down
186 changes: 186 additions & 0 deletions apps/vscode-e2e/src/restart/scenarioWorkspace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import assert from "node:assert/strict"
import fs from "node:fs/promises"
import path from "node:path"
import test, { type TestContext } from "node:test"

import {
createScenarioWorkspace,
preserveScenarioWorkspace,
preserveScenarioWorkspaceForRunner,
removeScenarioWorkspace,
} from "./scenarioWorkspace"

async function scenario(t: TestContext) {
const workspace = await createScenarioWorkspace()
const destination = path.resolve(__dirname, "../../test-failures", path.basename(workspace.root))
t.after(async () => {
await removeScenarioWorkspace(workspace)
await fs.rm(destination, { recursive: true, force: true })
})
return { workspace, destination }
}

void test("publishes only metadata, including when raw copies would fail", async (t) => {
const { workspace, destination } = await scenario(t)
const secret = "private-prompt-and-api-key"
const tasks = path.join(workspace.userData, "User/globalStorage/zoocodeorganization.zoo-code/tasks", secret)
const logs = path.join(workspace.userData, "logs")
await fs.mkdir(tasks, { recursive: true })
await fs.mkdir(logs, { recursive: true })
for (const name of ["ui_messages.json", "api_conversation_history.json", ".ui_messages.json.bak_secret.tmp"]) {
await fs.writeFile(path.join(tasks, name), JSON.stringify({ text: secret }))
}
await fs.writeFile(path.join(logs, `${secret}.log`), secret)
await fs.writeFile(
path.join(workspace.results, "01-create.json"),
JSON.stringify({
version: 1,
phase: "create",
status: "failed",
values: { secret },
error: { message: secret, stack: secret },
}),
)
const copy = t.mock.method(fs, "cp", () => {
throw new Error("Raw copy must never run")
})
assert.equal(await preserveScenarioWorkspace(workspace), destination)
assert.equal(copy.mock.callCount(), 0)
assert.deepEqual(await fs.readdir(destination), ["diagnostics.json"])
const manifest = await fs.readFile(path.join(destination, "diagnostics.json"), "utf8")
assert(!manifest.includes(secret))
assert(!manifest.includes(workspace.root))
assert(manifest.includes('"sha256"'))
assert(manifest.includes('"status": "failed"'))
assert(manifest.includes('"ui_messages.json"'))
await assert.rejects(fs.access(workspace.root), { code: "ENOENT" })
})

void test("classifies leftovers and publishes only validated trace fields", async (t) => {
const { workspace, destination } = await scenario(t)
const secret = "private-task-secret"
const tasks = path.join(workspace.userData, "User/globalStorage/zoocodeorganization.zoo-code/tasks", secret)
const logs = path.join(workspace.userData, "logs")
await fs.mkdir(tasks, { recursive: true })
await fs.mkdir(logs, { recursive: true })
for (const name of [
".ui_messages.json.bak_private.tmp",
".api_conversation_history.json.new_private.tmp",
"other.json",
]) {
await fs.writeFile(path.join(tasks, name), secret)
}
await fs.mkdir(path.join(tasks, "history_item.json.lock"))
const first = { stage: "ui-messages-write-start", pid: 101, ts: "2026-10-24T12:00:00.000Z" }
const second = { stage: "shutdown", pid: 202, ts: "2026-10-24T12:00:01.000Z" }
const line = (event: object) => `[prefix] [persistence-trace] ${JSON.stringify(event)}`
await fs.writeFile(
path.join(logs, "extension.log"),
[
line({ ...second, taskId: secret, filePath: secret, detail: secret, result: secret }),
line({ ...first, taskId: secret, filePath: secret, detail: secret }),
line(first), // Console and output-channel copies must not duplicate events.
line({ ...first, stage: secret }),
line({ ...first, pid: secret }),
line({ ...first, ts: secret }),
"[persistence-trace] truncated {",
].join("\n"),
)
await preserveScenarioWorkspace(workspace)
const text = await fs.readFile(path.join(destination, "diagnostics.json"), "utf8")
assert(!text.includes(secret))
assert(!text.includes(".bak_private"))
const diagnostic: {
sources: Array<{
label: string
files?: Array<{ class: string; kind: string; directoryHash: string; target?: string; name?: string }>
}>
traces: unknown
} = JSON.parse(text)
assert.deepEqual(diagnostic.traces, [first, second])
const files = diagnostic.sources.find((source) => source.label === "tasks")?.files
assert(files)
assert.deepEqual(files.map((file) => file.class).sort(), ["backup", "lock", "new-temp", "other"])
assert.equal(new Set(files.map((file) => file.directoryHash)).size, 1)
assert.equal(files.find((file) => file.class === "backup")?.target, "ui_messages.json")
assert.equal(files.find((file) => file.class === "lock")?.kind, "directory")
assert(!files.some((file) => file.name === "ui_messages.json"))
})

void test("skips absent optional directories", async (t) => {
const { workspace, destination } = await scenario(t)
await preserveScenarioWorkspace(workspace)
const manifest = await fs.readFile(path.join(destination, "diagnostics.json"), "utf8")
assert.equal((manifest.match(/"status": "missing"/g) ?? []).length, 2)
await assert.rejects(fs.access(workspace.root), { code: "ENOENT" })
})

void test("records collection failures without copying raw files and still cleans the workspace", async (t) => {
const { workspace, destination } = await scenario(t)
await fs.writeFile(path.join(workspace.results, "01-create.json"), "private phase contents")
const read = fs.readFile
t.mock.method(fs, "readFile", (async (...args: Parameters<typeof fs.readFile>) => {
if (String(args[0]).endsWith("01-create.json"))
throw Object.assign(new Error("sensitive detail"), { code: "EIO" })
return read(...args)
}) as typeof fs.readFile)
await preserveScenarioWorkspace(workspace)
const manifest = await read(path.join(destination, "diagnostics.json"), "utf8")
assert(manifest.includes('"code": "EIO"'))
assert(manifest.includes('"kind": "error"'))
assert(!manifest.includes("sensitive detail"))
await assert.rejects(fs.access(workspace.root), { code: "ENOENT" })
})

const preservationEntries = [
{ name: "retryable preservation", preserve: preserveScenarioWorkspace, keepsSource: true },
{ name: "runner cleanup", preserve: preserveScenarioWorkspaceForRunner, keepsSource: false },
]

for (const entry of preservationEntries) {
for (const failure of ["partial write", "rename"] as const) {
for (const existing of [false, true]) {
void test(`${entry.name}: failed ${failure}, previous artifact: ${existing}`, async (t) => {
const { workspace, destination } = await scenario(t)
await fs.mkdir(destination, { recursive: true })
const artifact = path.join(destination, "diagnostics.json")
const previous = JSON.stringify({ previous: true })
if (existing) await fs.writeFile(artifact, previous)
const write = fs.writeFile
if (failure === "partial write") {
t.mock.method(fs, "writeFile", async (file: Parameters<typeof fs.writeFile>[0]) => {
await write(file, '{"partial":')
throw Object.assign(new Error("disk failed"), { code: "EIO" })
})
} else {
t.mock.method(fs, "rename", async () => {
throw Object.assign(new Error("rename failed"), { code: "EIO" })
})
}
await assert.rejects(entry.preserve(workspace), { code: "EIO" })
if (entry.keepsSource) await fs.access(workspace.root)
else await assert.rejects(fs.access(workspace.root), { code: "ENOENT" })
if (existing) assert.equal(await fs.readFile(artifact, "utf8"), previous)
else await assert.rejects(fs.access(artifact), { code: "ENOENT" })
assert.deepEqual(await fs.readdir(destination), existing ? ["diagnostics.json"] : [])
})
}
}
}

for (const entry of preservationEntries) {
void test(`${entry.name}: publishes complete diagnostics before removing the source workspace`, async (t) => {
const { workspace, destination } = await scenario(t)
const rename = fs.rename
t.mock.method(fs, "rename", async (source: string, target: string) => {
assert.equal(target, path.join(destination, "diagnostics.json"))
await fs.access(workspace.root)
await assert.rejects(fs.access(target), { code: "ENOENT" })
assert(JSON.parse(await fs.readFile(source, "utf8")).sources)
await rename(source, target)
})
await entry.preserve(workspace)
assert.deepEqual(await fs.readdir(destination), ["diagnostics.json"])
await assert.rejects(fs.access(workspace.root), { code: "ENOENT" })
})
}
Loading
Loading