Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
19ebb09
fix(webview): detect dead webview renderer via heartbeat and auto-reload
Sep 20, 2026
77d02f3
fix(webview): scope recovery reload to the provider's own webview
Sep 20, 2026
dfbcc6e
fix(webview): stop recovery watchdog when sidebar webview is disposed
Sep 20, 2026
36e6bcb
test(webview): capture sidebar dispose callback in task history spec
Sep 20, 2026
ec7d375
fix(webview): invalidate in-flight recovery reload on dispose or replace
Sep 22, 2026
ba72a4e
test(webview): cover recovery rejection and watchdog precondition
Sep 22, 2026
1e6fd39
test(webview): assert global webview reload command is not used in pr…
myk1yt Sep 27, 2026
da8d752
fix(webview): harden recovery reload and scope sidebar disposal cleanup
myk1yt Sep 27, 2026
148b641
test(e2e): poll restart task-history presence before asserting
myk1yt Sep 27, 2026
54f6a93
fix(webview): guard recovery with heartbeat revision and in-flight flag
myk1yt Sep 27, 2026
fad059e
fix(webview): scope recovery in-flight guard to epoch
myk1yt Sep 27, 2026
939e85f
fix(webview): bump recovery epoch when resolveWebviewView replaces th…
myk1yt Sep 27, 2026
1c9ac52
chore(e2e): restore restart-persistence test to merge-base state
myk1yt Sep 28, 2026
aa666fc
fix(webview): gate obsolete-view messages and dispose replaced-view s…
myk1yt Sep 28, 2026
9f05e75
fix(webview): bail out of a resolve that went stale during its awaits
myk1yt Sep 28, 2026
325436d
fix(webview): skip the initial html assignment for a stale resolve
myk1yt Sep 28, 2026
e575537
Merge origin/main into fix/1675-webview-heartbeat-recovery
myk1yt Oct 5, 2026
63c82c3
chore: retrigger review-state reconciliation
myk1yt Oct 5, 2026
0985510
chore: retrigger review-state reconciliation
myk1yt Oct 5, 2026
2dfc91a
Merge remote-tracking branch 'upstream/main' into fix/1675-webview-he…
myk1yt Oct 9, 2026
ff9aa8a
fix(webview): ignore suspend gap in webview heartbeat watchdog
myk1yt Oct 9, 2026
ee7109b
Merge main and bound webview heartbeat recovery retries
edelauna Oct 26, 2026
ce63424
fix(webview): stop stale-heartbeat logging after retry limit
edelauna Oct 26, 2026
f98b941
test(webview): verify sidebar disposal cleanup and recovery invalidation
edelauna Oct 26, 2026
606685c
Merge remote-tracking branch 'origin/main' into fix/1675-webview-hear…
edelauna Oct 28, 2026
a985f03
fix(webview): reset recovery budget on view replacement and strengthe…
edelauna Oct 28, 2026
3106b78
test: cover webview heartbeat recovery and reproduce renderer crash f…
edelauna Oct 29, 2026
c90830a
fix: recreate unresponsive webview hosts and preserve active tasks
edelauna Oct 29, 2026
361a48b
fix: address heartbeat recovery review feedback
edelauna Oct 29, 2026
dea0067
fix(webview): keep recovery warnings and probe results retryable
edelauna Oct 29, 2026
437abce
fix(webview): distinguish HMR probe timeouts from unavailable dev server
edelauna Oct 30, 2026
190f6c1
fix(webview): preserve recovery state for delayed sidebar replacements
edelauna Oct 31, 2026
cefc4ec
fix(webview): retry failed tab initialization and clear failed sideba…
edelauna Oct 31, 2026
ec42ed1
fix(webview): warn on tab initialization failures without stopping re…
edelauna Oct 31, 2026
18dc5c2
fix(webview): restore bounded recovery for failed sidebar initialization
edelauna Oct 31, 2026
82c2a47
test(webview): add bounded model checking for recovery protocols
edelauna Oct 31, 2026
c8335eb
fix(webview): log terminal recovery stops after retry warnings
edelauna Oct 31, 2026
4c8b6cf
feat(webview): add explicit interface recovery retry with generation …
edelauna Oct 31, 2026
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 @@ -110,6 +110,8 @@ jobs:
run: pnpm agent-evals:check-types
- name: Model-check task lifecycle protocols
run: pnpm lifecycle:model-check
- name: Model-check webview recovery
run: pnpm webview-recovery:check-types && pnpm webview-recovery:model-check
- name: Validate MCP OAuth integration
run: pnpm mcp:integration-check

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/visual-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ jobs:

extension-host-visual:
runs-on: ubuntu-latest
timeout-minutes: 20
# Includes dependency setup, builds, repeated visuals, and production watchdog waits.
timeout-minutes: 35
container:
image: mcr.microsoft.com/playwright:v1.62.1-noble@sha256:dcc5531e97840b9b5e794f2814476b21571c5124a3fca2267d73041f56e7580e
options: --ipc=host
Expand Down
6 changes: 4 additions & 2 deletions apps/vscode-e2e/playwright-electron.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { defineConfig } from "@playwright/test"

export default defineConfig({
testDir: "./src/visual",
testMatch: "electron.visual.ts",
outputDir: path.resolve(__dirname, "test-results-electron"),
snapshotPathTemplate: "{testDir}/__screenshots__/{arg}{ext}",
fullyParallel: false,
workers: 1,
repeatEach: process.env.CI ? 3 : 1,
projects: [
{ name: "visual", testMatch: "electron.visual.ts", repeatEach: process.env.CI ? 3 : 1 },
{ name: "heartbeat", testMatch: "heartbeat.electron.ts", repeatEach: 1 },
],
timeout: 3 * 60 * 1_000,
expect: {
timeout: 60_000,
Expand Down
189 changes: 11 additions & 178 deletions apps/vscode-e2e/src/visual/electron.visual.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,15 @@
import fs from "fs/promises"
import { readFileSync } from "fs"
import os from "os"
import path from "path"

import { LLMock } from "@copilotkit/aimock"
import { downloadAndUnzipVSCode } from "@vscode/test-electron"
import { _electron, expect, test, type ElectronApplication, type Page, type TestInfo } from "@playwright/test"
import { glob } from "glob"

const packageRoot = path.resolve(__dirname, "../..")
const repositoryRoot = path.resolve(packageRoot, "../..")
const extensionDevelopmentPath = path.join(repositoryRoot, "src")
const extensionTestsPath = path.join(packageRoot, "out", "visual", "sceneController")
const packageJson = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")) as {
devDependencies: Record<string, string>
}
const vscodeVersion = packageJson.devDependencies["@types/vscode"]

const CHAT_PROMPT = "electron-visual-smoke: render a deterministic completion"
const CHAT_RESULT = "Electron visual smoke response rendered inside the real VS Code webview."

interface VisualScenario {
name: string
scene: "welcome" | "chat"
themeId: "Default Dark Modern" | "Default High Contrast"
landmark: string
webviewSnapshot?: string
startMock?: boolean
}
import { expect, test } from "@playwright/test"

import {
attachFailureArtifacts,
CHAT_RESULT,
extensionDevelopmentPath,
startScene,
stopScene,
vscodeVersion,
type VisualScenario,
} from "./electronHarness"

const scenarios: VisualScenario[] = [
{
Expand All @@ -52,155 +34,6 @@ const scenarios: VisualScenario[] = [
},
]

interface RunningScene {
app: ElectronApplication
donePath: string
logPath: string
mock?: InstanceType<typeof LLMock>
page: Page
temporaryRoot: string
userDataPath: string
}

async function waitForJson(filePath: string): Promise<{ landmark: string; scene: string; themeId: string }> {
const deadline = Date.now() + 90_000
while (Date.now() < deadline) {
try {
return JSON.parse(await fs.readFile(filePath, "utf8")) as {
landmark: string
scene: string
themeId: string
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
throw new Error(`Timed out waiting for Electron visual readiness file: ${filePath}`)
}

async function attachLogs(userDataPath: string, logPath: string, testInfo: TestInfo) {
const sceneLog = await fs.readFile(logPath, "utf8").catch(() => "")
if (sceneLog) await testInfo.attach("scene.log", { body: sceneLog, contentType: "text/plain" })

for (const pattern of ["logs/**/exthost.log", "logs/**/renderer.log", "logs/**/*Zoo-Code.log"]) {
const logs = await glob(pattern, { cwd: userDataPath })
const latest = logs.at(-1)
if (!latest) continue
const contents = await fs.readFile(path.join(userDataPath, latest), "utf8").catch(() => "")
if (contents) await testInfo.attach(path.basename(latest), { body: contents, contentType: "text/plain" })
}
}

async function stopTracing(app: ElectronApplication, testInfo: TestInfo) {
await app
.context()
.tracing.stop({ path: testInfo.outputPath("trace.zip") })
.catch(() => undefined)
}

async function startScene(
vscodeExecutablePath: string,
scenario: VisualScenario,
testInfo: TestInfo,
): Promise<RunningScene> {
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-code-electron-visual-"))
const workspacePath = path.join(temporaryRoot, "workspace")
const userDataPath = path.join(temporaryRoot, "user-data")
const extensionsPath = path.join(temporaryRoot, "extensions")
const readyPath = path.join(temporaryRoot, "ready.json")
const donePath = path.join(temporaryRoot, "done")
const logPath = path.join(temporaryRoot, "scene.log")
await Promise.all([fs.mkdir(workspacePath), fs.mkdir(userDataPath), fs.mkdir(extensionsPath)])

let app: ElectronApplication | undefined
let mock: InstanceType<typeof LLMock> | undefined
let page: Page | undefined
try {
if (scenario.startMock) {
mock = new LLMock({ port: 0 })
mock.addFixture({
match: { userMessage: CHAT_PROMPT },
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: CHAT_RESULT }),
id: "call_electron_visual_completion",
},
],
},
})
await mock.start()
}

app = await _electron.launch({
executablePath: vscodeExecutablePath,
args: [
workspacePath,
`--extensionDevelopmentPath=${extensionDevelopmentPath}`,
`--extensionTestsPath=${extensionTestsPath}`,
`--user-data-dir=${userDataPath}`,
`--extensions-dir=${extensionsPath}`,
"--disable-workspace-trust",
"--skip-welcome",
"--skip-release-notes",
"--disable-updates",
"--disable-gpu",
"--force-device-scale-factor=1",
],
env: {
...process.env,
AIMOCK_URL: mock?.url ?? "",
ROO_CODE_VISUAL_CHAT_PROMPT: CHAT_PROMPT,
ROO_CODE_VISUAL_CHAT_RESULT: CHAT_RESULT,
ROO_CODE_VISUAL_DONE_PATH: donePath,
ROO_CODE_VISUAL_LOG_PATH: logPath,
ROO_CODE_VISUAL_READY_PATH: readyPath,
ROO_CODE_VISUAL_SCENE: scenario.scene,
ROO_CODE_VISUAL_THEME: scenario.themeId,
ROO_CODE_THEME_FIXTURE_PROBE: "1",
},
locale: "en-US",
timeout: 60_000,
timezoneId: "UTC",
})
await app.context().tracing.start({ screenshots: true, snapshots: true, sources: true })
page = await app.firstWindow({ timeout: 60_000 })
await app.evaluate(({ BrowserWindow }) => {
const window = BrowserWindow.getAllWindows()[0]
window?.setBounds({ x: 0, y: 0, width: 1200, height: 800 })
window?.focus()
})
const ready = await waitForJson(readyPath)
expect(ready).toMatchObject({ landmark: scenario.landmark, scene: scenario.scene, themeId: scenario.themeId })
return { app, donePath, logPath, mock, page, temporaryRoot, userDataPath }
} catch (error) {
if (page) await page.screenshot({ path: testInfo.outputPath("workbench.png") }).catch(() => undefined)
await attachLogs(userDataPath, logPath, testInfo)
await fs.writeFile(donePath, "done\n", "utf8").catch(() => undefined)
if (app) await stopTracing(app, testInfo)
await app?.close().catch(() => undefined)
await mock?.stop().catch(() => undefined)
await fs.rm(temporaryRoot, { recursive: true, force: true })
if (error instanceof Error) throw error
throw new Error(String(error))
}
}

async function attachFailureArtifacts(scene: RunningScene, testInfo: TestInfo) {
await scene.page.screenshot({ path: testInfo.outputPath("workbench.png") }).catch(() => undefined)
await attachLogs(scene.userDataPath, scene.logPath, testInfo)
}

async function stopScene(scene: RunningScene, testInfo: TestInfo) {
await fs.writeFile(scene.donePath, "done\n", "utf8").catch(() => undefined)
await stopTracing(scene.app, testInfo)
await scene.app.close().catch(() => undefined)
await scene.mock?.stop().catch(() => undefined)
await fs.rm(scene.temporaryRoot, { recursive: true, force: true })
}

test.describe.configure({ mode: "serial" })

let vscodeExecutablePath: string
Expand Down
Loading
Loading