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
137 changes: 137 additions & 0 deletions apps/vscode-e2e/src/restart/scenarioWorkspace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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, 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" })
})

void test("cleans the workspace even when writing the diagnostic artifact fails", async (t) => {
const { workspace } = await scenario(t)
t.mock.method(fs, "writeFile", async () => {
throw Object.assign(new Error("disk failed"), { code: "EIO" })
})
await assert.rejects(preserveScenarioWorkspace(workspace), { code: "EIO" })
await assert.rejects(fs.access(workspace.root), { code: "ENOENT" })
})
189 changes: 189 additions & 0 deletions apps/vscode-e2e/src/restart/scenarioWorkspace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as os from "os"
import * as path from "path"
import * as fs from "fs/promises"
import { createHash } from "crypto"

import { readPhaseResult } from "./phaseProtocol"

const SCENARIO_ROOT_PREFIX = "roo-vscode-e2e-restart-"

Expand Down Expand Up @@ -56,3 +59,189 @@ export async function removeScenarioWorkspace(scenarioWorkspace: ScenarioWorkspa

await fs.rm(root, { recursive: true, force: true })
}

const FAILURE_ARTIFACTS_DIR = "test-failures"

type FileDiagnostic = {
pathHash: string
name?: string
kind: "file" | "directory" | "symlink" | "error"
directoryHash: string
class: "backup" | "new-temp" | "lock" | "other"
target?: string
size?: number
sha256?: string
code?: string
}

function hash(value: string | Buffer): string {
return createHash("sha256").update(value).digest("hex")
}

function diagnosticCode(error: unknown): string {
const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined
return typeof code === "string" && ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "EBUSY", "EIO"].includes(code)
? code
: "IO_ERROR"
}

type TraceDiagnostic = { stage: string; pid: number; ts: string }

const knownFiles = ["ui_messages.json", "api_conversation_history.json", "history_item.json"]

function classifyFile(name: string): Pick<FileDiagnostic, "class" | "target"> {
const leftover = /^\.([^/\\]+)\.(bak|new)_[^/\\]+\.tmp$/.exec(name)
if (leftover)
return {
class: leftover[2] === "bak" ? "backup" : "new-temp",
target: knownFiles.includes(leftover[1] ?? "") ? leftover[1] : undefined,
}
const lock = /^([^/\\]+)\.lock$/.exec(name)
if (lock) return { class: "lock", target: knownFiles.includes(lock[1] ?? "") ? lock[1] : undefined }
return { class: "other" }
}

function traceDiagnostics(data: Buffer): TraceDiagnostic[] {
const traces: TraceDiagnostic[] = []
// Unknown stages stay private until their publication is reviewed.
const stages = new Set([
"json-recovery-failed",
"ui-messages-recovery",
"ui-messages-write-start",
"ui-messages-backup-rename",
"ui-messages-replacement-rename",
"ui-messages-write-complete",
"ui-messages-marker-check",
"shutdown",
])
const marker = "[persistence-trace] "
for (const line of data.toString("utf8").split(/\r?\n/)) {
const start = line.indexOf(marker)
if (start < 0) continue
try {
const value: unknown = JSON.parse(line.slice(start + marker.length))
if (!value || typeof value !== "object" || !("stage" in value) || !("pid" in value) || !("ts" in value))
continue
const { stage, pid, ts } = value
if (
typeof stage !== "string" ||
!stages.has(stage) ||
typeof pid !== "number" ||
!Number.isSafeInteger(pid) ||
pid <= 0
)
continue
if (
typeof ts !== "string" ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(ts) ||
!Number.isFinite(Date.parse(ts))
)
continue
traces.push({ stage, pid, ts })
} catch {
// A malformed or truncated log line must not expose its raw contents.
}
}
return traces
}

async function fileDiagnostics(
directory: string,
relative = "",
traces?: TraceDiagnostic[],
): Promise<FileDiagnostic[]> {
const files: FileDiagnostic[] = []
for (const entry of await fs.readdir(path.join(directory, relative), { withFileTypes: true })) {
const child = path.join(relative, entry.name)
const identity = { pathHash: hash(child), directoryHash: hash(relative), ...classifyFile(entry.name) }
try {
if (entry.isSymbolicLink()) files.push({ ...identity, kind: "symlink" })
else if (entry.isDirectory()) {
if (identity.class === "lock") files.push({ ...identity, kind: "directory" })
files.push(...(await fileDiagnostics(directory, child, traces)))
} else if (entry.isFile()) {
const data = await fs.readFile(path.join(directory, child))
if (traces) traces.push(...traceDiagnostics(data))
// Arbitrary filenames can also contain private data. Publish only known names.
const name = knownFiles.includes(entry.name) ? entry.name : undefined
files.push({ ...identity, name, kind: "file", size: data.length, sha256: hash(data) })
}
} catch (error) {
files.push({ ...identity, kind: "error", code: diagnosticCode(error) })
}
}
return files
}

/** Publish metadata only. Never copy task contents, phase errors, or raw logs. */
export async function preserveScenarioWorkspace(scenarioWorkspace: ScenarioWorkspace): Promise<string> {
const destinationRoot = path.resolve(
__dirname,
"../../",
FAILURE_ARTIFACTS_DIR,
path.basename(scenarioWorkspace.root),
)
try {
await fs.rm(destinationRoot, { recursive: true, force: true })
await fs.mkdir(destinationRoot, { recursive: true })
const selections = [
{ label: "phase-results", source: scenarioWorkspace.results },
{ label: "vscode-logs", source: path.join(scenarioWorkspace.userData, "logs") },
{
label: "tasks",
source: path.join(
scenarioWorkspace.userData,
"User",
"globalStorage",
"zoocodeorganization.zoo-code",
"tasks",
),
},
]
const sources = []
const traces: TraceDiagnostic[] = []
for (const selection of selections) {
try {
sources.push({
label: selection.label,
status: "collected",
files: await fileDiagnostics(
selection.source,
"",
selection.label === "vscode-logs" ? traces : undefined,
),
})
} catch (error) {
const code = diagnosticCode(error)
sources.push({ label: selection.label, status: code === "ENOENT" ? "missing" : "error", code })
}
}
const phases = []
for (const phase of ["create", "verify"] as const) {
try {
const result = await readPhaseResult(scenarioWorkspace.results, phase)
phases.push({ phase, status: result.status })
} catch (error) {
phases.push({ phase, code: diagnosticCode(error) })
}
}
await fs.writeFile(
path.join(destinationRoot, "diagnostics.json"),
JSON.stringify(
{
sources,
phases,
traces: [...new Map(traces.map((event) => [JSON.stringify(event), event])).values()].sort((a, b) =>
a.ts.localeCompare(b.ts),
),
},
null,
2,
),
)
return destinationRoot
} finally {
// Collection or output errors must not leave the temporary workspace behind.
await removeScenarioWorkspace(scenarioWorkspace)
}
}
6 changes: 6 additions & 0 deletions apps/vscode-e2e/src/restart/vscodeCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ function phaseEnvironment(options: RestartCoordinatorOptions, phase: RestartPhas
E2E_PHASE: phase,
E2E_SCENARIO: options.scenario,
E2E_RESULTS_DIR: options.workspace.results,
E2E_GLOBAL_STORAGE_PATH: path.join(
options.workspace.userData,
"User",
"globalStorage",
"zoocodeorganization.zoo-code",
),
}
}

Expand Down
Loading
Loading