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
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