Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ jobs:
if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true'
run: xvfb-run -a pnpm --filter @roo-code/vscode-e2e test:ci:mock

- name: Run mocked restart-persistence E2E test
# Reuse the runner built by the full mocked suite; this direct invocation avoids
# test:run's dotenv loading and does not repeat the bundle or webview build.
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: 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
18 changes: 18 additions & 0 deletions apps/vscode-e2e/fixtures/restart-persistence.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"fixtures": [
{
"match": {
"userMessage": "RESTART_PERSISTENCE_SMOKE"
},
"response": {
"toolCalls": [
{
"name": "attempt_completion",
"arguments": "{\"result\":\"RESTART_PERSISTENCE_MARKER\"}",
"id": "call_restart_persistence_done"
}
]
}
}
]
}
107 changes: 107 additions & 0 deletions apps/vscode-e2e/src/restart/phaseProtocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import * as path from "path"
import * as fs from "fs/promises"

import { createWriteStream } from "fs"

export const PHASE_RESULT_VERSION = 1 as const

export type RestartPhase = "create" | "verify"
export type PhaseStatus = "passed" | "failed"

export type PhaseError = {
message: string
stack?: string
}

export type PhaseResult = {
version: typeof PHASE_RESULT_VERSION
phase: RestartPhase
status: PhaseStatus
values?: Record<string, string>
error?: PhaseError
}

const phaseResultNames: Record<RestartPhase, string> = {
create: "01-create.json",
verify: "02-verify.json",
}

export function getPhaseResultPath(resultsDir: string, phase: RestartPhase): string {
const resolvedResultsDir = path.resolve(resultsDir)
const resultPath = path.resolve(resolvedResultsDir, phaseResultNames[phase])
if (path.dirname(resultPath) !== resolvedResultsDir) {
throw new Error(`Phase result path escaped the results directory: ${phase}`)
}
return resultPath
}

export function serializePhaseError(error: unknown): PhaseError {
if (error instanceof Error) {
return {
message: error.message.slice(0, 2_000),
...(error.stack && { stack: error.stack.slice(0, 4_000) }),
}
}

return { message: String(error).slice(0, 2_000) }
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

export function validatePhaseResult(value: unknown): asserts value is PhaseResult {
if (!isRecord(value) || value.version !== PHASE_RESULT_VERSION) {
throw new Error("Invalid phase result version")
}
if (value.phase !== "create" && value.phase !== "verify") {
throw new Error("Invalid phase result phase")
}
if (value.status !== "passed" && value.status !== "failed") {
throw new Error("Invalid phase result status")
}
if (value.values !== undefined) {
if (!isRecord(value.values) || Object.values(value.values).some((entry) => typeof entry !== "string")) {
throw new Error("Phase result values must be string-valued")
}
}
if (value.error !== undefined) {
if (!isRecord(value.error) || typeof value.error.message !== "string") {
throw new Error("Invalid phase result error")
}
if (value.error.stack !== undefined && typeof value.error.stack !== "string") {
throw new Error("Invalid phase result error stack")
}
}
}

export async function writePhaseResult(resultsDir: string, result: PhaseResult): Promise<void> {
validatePhaseResult(result)
const targetPath = getPhaseResultPath(resultsDir, result.phase)
const temporaryPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`
try {
await writeJsonAtomically(temporaryPath, result)
await fs.rename(temporaryPath, targetPath)
} finally {
await fs.rm(temporaryPath, { force: true })
}
}

async function writeJsonAtomically(filePath: string, value: PhaseResult): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true })
await new Promise<void>((resolve, reject) => {
const stream = createWriteStream(filePath, { encoding: "utf8" })
stream.once("error", reject)
stream.once("finish", resolve)
stream.end(JSON.stringify(value))
})
}

export async function readPhaseResult(resultsDir: string, phase: RestartPhase): Promise<PhaseResult> {
const result = JSON.parse(await fs.readFile(getPhaseResultPath(resultsDir, phase), "utf8")) as unknown
validatePhaseResult(result)
if (result.phase !== phase) {
throw new Error(`Phase result does not match requested phase: ${phase}`)
}
return result
}
58 changes: 58 additions & 0 deletions apps/vscode-e2e/src/restart/scenarioWorkspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import * as os from "os"
import * as path from "path"
import * as fs from "fs/promises"

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

export type ScenarioWorkspace = {
root: string
workspace: string
userData: string
extensions: string
results: string
}

function childPath(root: string, name: string): string {
const resolvedRoot = path.resolve(root)
const resolvedChild = path.resolve(resolvedRoot, name)
if (path.dirname(resolvedChild) !== resolvedRoot) {
throw new Error(`Scenario path escaped its root: ${name}`)
}
return resolvedChild
}

export async function createScenarioWorkspace(): Promise<ScenarioWorkspace> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), SCENARIO_ROOT_PREFIX))
const scenarioWorkspace: ScenarioWorkspace = {
root,
workspace: childPath(root, "workspace"),
userData: childPath(root, "user-data"),
extensions: childPath(root, "extensions"),
results: childPath(root, "results"),
}

await Promise.all(
[
scenarioPath(scenarioWorkspace, "workspace"),
scenarioPath(scenarioWorkspace, "userData"),
scenarioPath(scenarioWorkspace, "extensions"),
scenarioPath(scenarioWorkspace, "results"),
].map((directory) => fs.mkdir(directory, { recursive: true })),
)

return scenarioWorkspace
}

function scenarioPath(scenarioWorkspace: ScenarioWorkspace, key: "workspace" | "userData" | "extensions" | "results") {
return scenarioWorkspace[key]
}

export async function removeScenarioWorkspace(scenarioWorkspace: ScenarioWorkspace): Promise<void> {
const root = path.resolve(scenarioWorkspace.root)
const tempRoot = path.resolve(os.tmpdir())
if (path.dirname(root) !== tempRoot || !path.basename(root).startsWith(SCENARIO_ROOT_PREFIX)) {
throw new Error(`Refusing to remove an unowned scenario root: ${scenarioWorkspace.root}`)
}

await fs.rm(root, { recursive: true, force: true })
}
90 changes: 90 additions & 0 deletions apps/vscode-e2e/src/restart/vscodeCoordinator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { spawn } from "child_process"
import * as path from "path"

import { readPhaseResult, type RestartPhase, type PhaseResult } from "./phaseProtocol"
import type { ScenarioWorkspace } from "./scenarioWorkspace"

export type RestartCoordinatorOptions = {
vscodeExecutablePath: string
extensionDevelopmentPath: string
extensionTestsPath: string
scenario: string
workspace: ScenarioWorkspace
environment: NodeJS.ProcessEnv
expectedExitPolicies: readonly ExpectedExitPolicy[]
}

export type ExpectedExitPolicy = {
phase: RestartPhase
termination: "graceful-quit"
code: number
signal: NodeJS.Signals | null
}

function phaseEnvironment(options: RestartCoordinatorOptions, phase: RestartPhase): NodeJS.ProcessEnv {
return {
...options.environment,
E2E_PHASE: phase,
E2E_SCENARIO: options.scenario,
E2E_RESULTS_DIR: options.workspace.results,
}
}

function phaseArguments(options: RestartCoordinatorOptions): string[] {
return [
options.workspace.workspace,
`--user-data-dir=${options.workspace.userData}`,
`--extensions-dir=${options.workspace.extensions}`,
"--no-sandbox",
"--disable-gpu-sandbox",
"--disable-updates",
"--skip-welcome",
"--skip-release-notes",
"--disable-workspace-trust",
`--extensionTestsPath=${options.extensionTestsPath}`,
`--extensionDevelopmentPath=${options.extensionDevelopmentPath}`,
]
}

async function runPhase(options: RestartCoordinatorOptions, phase: RestartPhase): Promise<PhaseResult> {
const args = phaseArguments(options)
console.log(`[restart:${phase}] spawning VS Code: ${path.basename(options.vscodeExecutablePath)}`)

const child = spawn(options.vscodeExecutablePath, args, {
env: phaseEnvironment(options, phase),
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
})

child.stdout?.on("data", (chunk: Buffer) => process.stdout.write(`[restart:${phase}] ${chunk}`))
child.stderr?.on("data", (chunk: Buffer) => process.stderr.write(`[restart:${phase}] ${chunk}`))

const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.once("error", reject)
child.once("close", (code, signal) => resolve({ code, signal }))
})

const result = await readPhaseResult(options.workspace.results, phase)
if (result.status !== "passed") {
throw new Error(`[restart:${phase}] phase result was not passed: ${result.error?.message ?? "unknown failure"}`)
}

const expectedExit = options.expectedExitPolicies.find((policy) => policy.phase === phase)
const isExpectedNonzeroExit =
exit.code !== 0 &&
expectedExit !== undefined &&
expectedExit.termination === "graceful-quit" &&
exit.code === expectedExit.code &&
exit.signal === expectedExit.signal
const isSuccessfulExit = exit.code === 0 && exit.signal === null
if (!isSuccessfulExit && !isExpectedNonzeroExit) {
throw new Error(`[restart:${phase}] VS Code exited with ${exit.code ?? exit.signal}`)
}

return result
}

export async function runRestartScenario(options: RestartCoordinatorOptions): Promise<void> {
await runPhase(options, "create")
await runPhase(options, "verify")
}
Loading
Loading