import { _electron as electron, expect, type ElectronApplication, } from "@playwright/test"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import crypto from "node:crypto"; import { fixtureCourse } from "../tests/v2/fixtures/course"; import type { Catalog, AppState, Command } from "../src/shared/model"; import type { AppStatus } from "../src/shared/app-update"; import { gitInfo, writeAppUpdate } from "./app-update-package"; async function main(): Promise { const packaged = process.argv.includes("--packaged"); const root = await fs.mkdtemp(path.join(os.tmpdir(), "vibe-smoke-")); const workspace = path.join(root, "my-code"); await fs.mkdir(workspace); await fs.writeFile( path.join(workspace, "go.mod"), "module smoke\n\ngo 1.22\n", ); const source = 'package smoke\nimport "testing"\nfunc TestAddition(t *testing.T) { if 2+2 != 4 { t.Fatal("addition") } }\n'; await fs.writeFile(path.join(workspace, "addition_test.go"), source); let executablePath = packaged ? process.env.VIBE_PACKAGED_EXECUTABLE : process.env.ELECTRON_PATH; if (packaged && !executablePath) { const parent = path.resolve( "out", `Vibe Learn-${process.platform}-${process.arch}`, ); executablePath = process.platform === "darwin" ? path.join(parent, "Vibe Learn.app/Contents/MacOS/vibe-learn") : path.join( parent, process.platform === "win32" ? "vibe-learn.exe" : "vibe-learn", ); } const env = { ...process.env, VIBE_USER_DATA_DIR: path.join(root, "profile"), ELECTRON_DISABLE_SECURITY_WARNINGS: "true", } as Record; if (!packaged) { const catalog = JSON.parse( await fs.readFile("build/content/catalog.json", "utf8"), ) as Catalog; catalog.items.push({ ...fixtureCourse, title: "Course activities · preview", }); env.VIBE_DEV_CATALOG = path.join(root, "test-catalog.json"); await fs.writeFile(env.VIBE_DEV_CATALOG, JSON.stringify(catalog)); } delete env.ELECTRON_RUN_AS_NODE; // Packaged runs trust a throwaway key so the smoke can sign its own app update. const updateKeys = crypto.generateKeyPairSync("ed25519"); if (packaged) env.VIBE_APP_UPDATE_PUBLIC_KEY = updateKeys.publicKey .export({ type: "spki", format: "der" }) .toString("base64"); // Empty PATH proves packaged basic Go checks use the bundled toolchain, not a host installation. if (packaged) { env.PATH = ""; env.GOPROXY = "off"; env.GOSUMDB = "off"; } const launch = () => electron.launch({ executablePath, args: [ ...(process.platform === "linux" ? ["--no-sandbox"] : []), ...(packaged ? [] : [path.resolve(".")]), ], env, timeout: 30000, }); let app: ElectronApplication | undefined; try { app = await launch(); expect(await app.evaluate(({ app }) => app.isPackaged)).toBe(packaged); let page = await app.firstWindow(); await expect( page.getByRole("heading", { name: "Continue", exact: true }), ).toBeVisible(); const call = (input: Command) => page.evaluate((command) => window.learning!.invoke(command), input); const catalog = (await call({ type: "catalog" })) as Catalog; const relay = catalog.items.find((i) => i.id === "project:ingest-relay")!; const operator = catalog.items.find( (i) => i.id === "project:relay-operator", )!; expect(operator.checks).toEqual([]); await page.evaluate( ({ itemId, stageId }) => { location.hash = `/read/${encodeURIComponent(itemId)}/${stageId}`; }, { itemId: relay.id, stageId: relay.stages[0]!.id }, ); await page.getByRole("button", { name: "Notes", exact: true }).click(); await page .getByRole("textbox", { name: "Notes", exact: true }) .fill("Keep my reasoning and my tests."); await page.getByRole("button", { name: "Save notes", exact: true }).click(); await expect(page.getByText("Saved locally")).toBeVisible(); await page.getByRole("button", { name: "Close panel" }).click(); await page.getByRole("button", { name: "☆ Bookmark", exact: true }).click(); await page.getByRole("button", { name: "Copy stage context" }).click(); expect( await app.evaluate(({ clipboard }) => clipboard.readText()), ).toContain("Build an ingest relay"); await app.evaluate(({ dialog }, folder) => { dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [folder], })) as typeof dialog.showOpenDialog; }, workspace); await call({ type: "workspace", itemId: relay.id, action: "attach", mode: "mentor", }); await page.getByRole("button", { name: "Workspace", exact: true }).click(); await page .getByRole("button", { name: "Run your tests", exact: true }) .click(); await expect .poll( async () => ((await call({ type: "state" })) as AppState).runs.length, ) .toBe(1); const runId = ((await call({ type: "state" })) as AppState).runs[0]!.id; await expect .poll( async () => ((await call({ type: "state" })) as AppState).runs.find( (r) => r.id === runId, )?.status, { timeout: 120000 }, ) .toBe("passed"); await expect( page.locator("summary").filter({ hasText: "Your tests · passed" }), ).toBeVisible(); const state = (await call({ type: "state" })) as AppState; expect(state.runs[0]?.sourceHash).toMatch(/^[a-f0-9]{64}$/); expect( await fs.readFile(path.join(workspace, "addition_test.go"), "utf8"), ).toBe(source); const reporter = catalog.items.find( (i) => i.id === "project:cloud-reporter", )!; const parent = path.join(root, "projects"); await fs.mkdir(parent); await app.evaluate(({ dialog }, folder) => { dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [folder], })) as typeof dialog.showOpenDialog; }, parent); const created = (await call({ type: "workspace", itemId: reporter.id, action: "create", mode: "mentor", })) as { path: string }; // Compare names, not full paths: Windows may report the temp directory in // its 8.3 short form (RUNNER~1) on one side and long form on the other. expect(path.basename(created.path)).toBe("cloud-reporter"); const folder = path.join(parent, "cloud-reporter"); expect( await fs.readFile(path.join(folder, "steps/02-fetch-page.md"), "utf8"), ).toContain("
"); for (const file of [ "README.md", "AGENTS.md", "NOTES.md", "MENTOR-NOTES.md", "go.mod", ".claude/settings.local.json", ]) await fs.access(path.join(folder, file)); const admission = catalog.items.find((i) => i.id === "project:kubernetes-admission-lab")!; const lab = (await call({ type: "workspace", itemId: admission.id, action: "create", mode: "mentor", })) as { path: string }; expect(await fs.readFile(path.join(lab.path, "Taskfile.yml"), "utf8")) .toContain("go run doctor.go"); expect(await fs.readFile(path.join(lab.path, "doctor.go"), "utf8")) .toContain("func doctorMain("); await expect(fs.access(path.join(lab.path, "main.go"))).rejects.toThrow(); await fs.writeFile(path.join(lab.path, "Taskfile.yml"), "# My tasks\n"); await fs.unlink(path.join(lab.path, "doctor.go")); const restoredHelpers = (await call({ type: "workspace", itemId: admission.id, action: "assistant-files", mode: "mentor", })) as { written: string[]; skipped: string[] }; expect(restoredHelpers.written).toEqual(["doctor.go"]); expect(restoredHelpers.skipped).toContain("Taskfile.yml"); expect(await fs.readFile(path.join(lab.path, "Taskfile.yml"), "utf8")) .toBe("# My tasks\n"); expect(((await call({ type: "state" })) as AppState).runs).toHaveLength(1); await expect( call({ type: "run", itemId: operator.id, checkId: "go-test" }), ).rejects.toThrow(); await expect( call({ type: "progress", itemId: relay.id, stageId: "not-a-stage", scroll: 0, }), ).rejects.toThrow(); const backup = path.join(root, "export.json"); await app.evaluate(({ dialog }, file) => { dialog.showSaveDialog = (async () => ({ canceled: false, filePath: file, })) as typeof dialog.showSaveDialog; }, backup); await call({ type: "backup" }); expect( JSON.parse(await fs.readFile(backup, "utf8")).state.notes[0].text, ).toContain("reasoning"); await fs.mkdir("build/screenshots", { recursive: true }); await page.screenshot({ path: "build/screenshots/desktop.png" }); await page.getByRole("button", { name: "Close panel" }).click(); await page.evaluate(() => { location.hash = "/read/project%3Aingest-relay/session-2"; }); await expect(page.locator("article")).toContainText("Put it behind HTTP"); await expect .poll( async () => ((await call({ type: "state" })) as AppState).progress.find( (p) => p.itemId === relay.id, )?.stageId, ) .toBe("session-2"); await page.evaluate(async () => { await document.fonts.ready; await Promise.all( [...document.images].map((image) => image.decode().catch(() => {})), ); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ); }); await page.evaluate(() => window.scrollTo( 0, (document.documentElement.scrollHeight - innerHeight) * 0.55, ), ); await expect .poll( async () => ((await call({ type: "state" })) as AppState).progress.find( (p) => p.itemId === relay.id, )?.scroll || 0, ) .toBeGreaterThan(0.5); await page .getByRole("navigation") .getByRole("link", { name: "Projects" }) .click(); const position = ( (await call({ type: "state" })) as AppState ).progress.find((p) => p.itemId === relay.id)!; expect(position.stageId).toBe("session-2"); expect(position.scroll).toBeCloseTo(0.55, 1); if (!packaged) { await page.evaluate(() => { location.hash = "/courses/course%3Afixture/exercises"; }); await page .getByRole("button", { name: "Prepare exercise", exact: true }) .click(); await expect( page.getByRole("button", { name: "Run checks", exact: true }), ).toBeEnabled(); await page .getByRole("button", { name: "Run checks", exact: true }) .click(); await expect( page.getByRole("heading", { name: "Last run: failed" }), ).toBeVisible({ timeout: 120000 }); const folder = ((await call({ type: "state" })) as AppState) .exerciseWorkspaces[0]!.path; await fs.writeFile( path.join(folder, "addition.go"), "package addition\nfunc Add(a,b int) int {return a+b}\n", ); await page .getByRole("button", { name: "Run checks", exact: true }) .click(); await expect( page.getByRole("heading", { name: "Last run: passed" }), ).toBeVisible({ timeout: 120000 }); await page.getByText("Hint 1", { exact: true }).click(); await page.evaluate(() => window.scrollTo(0, 0)); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); await page.screenshot({ path: "build/screenshots/desktop-exercise.png", fullPage: true, }); await page.evaluate(() => { location.hash = "/courses/course%3Afixture/flashcards"; }); await page.getByRole("button", { name: "Reveal answer" }).click(); await page.screenshot({ path: "build/screenshots/desktop-flashcard-browse.png", fullPage: true, }); await page.getByRole("button", { name: /Review available/ }).click(); await page.getByRole("button", { name: "Reveal answer" }).click(); await page.screenshot({ path: "build/screenshots/desktop-flashcard-review.png", fullPage: true, }); await page.getByRole("button", { name: "Good", exact: true }).click(); await expect(page.getByText("What is -1 + 1?")).toBeVisible(); await page.getByRole("button", { name: "Reveal answer" }).click(); await page.getByRole("button", { name: "Easy", exact: true }).click(); await expect( page.getByRole("heading", { name: "Review complete" }), ).toBeVisible(); expect( ((await call({ type: "state" })) as AppState).reviews, ).toHaveLength(2); await call({ type: "backup" }); expect( JSON.parse(await fs.readFile(backup, "utf8")).state.reviews, ).toHaveLength(2); } // Serve an update through the same main-process fetch path without using the public channel. const updatedCatalog = structuredClone(catalog); updatedCatalog.items.push({ id: "project:download-fixture", kind: "project", title: "Downloaded project", description: "Content update smoke fixture", source: "tests", version: "1", stages: [ { id: "overview", title: "Overview", html: "

Downloaded project

", text: "Downloaded project", }, ], related: [], checks: [], prerequisites: [], }); const contentData = JSON.stringify(updatedCatalog); const contentManifest = { formatVersion: 1, minimumAppVersion: "2.0.2", publishedAt: new Date(Date.now() + 60000).toISOString(), revision: crypto.createHash("sha256").update(contentData).digest("hex"), }; await app.evaluate( (_electron, payload) => { globalThis.fetch = (async (input) => { const url = String(input); if (url === "https://vibe-learn.ai/updates/latest.json") return new Response(JSON.stringify(payload.manifest)); if ( url === `https://vibe-learn.ai/updates/${payload.manifest.revision}.json` ) return new Response(payload.data); throw new Error(`Unexpected update URL: ${url}`); }) as typeof fetch; }, { manifest: contentManifest, data: contentData }, ); await page.getByRole("link", { name: "Settings & backups" }).click(); const beforeContentUpdate = (await call({ type: "state" })) as AppState; await page .getByRole("button", { name: "Update content", exact: true }) .click(); await expect( page.getByText("Content updated.", { exact: true }), ).toBeVisible(); expect(await call({ type: "state" })).toEqual(beforeContentUpdate); await page.screenshot({ path: "build/screenshots/content-updates.png" }); expect( await fs.readFile(path.join(workspace, "addition_test.go"), "utf8"), ).toBe(source); await page .getByRole("navigation") .getByRole("link", { name: "Projects", exact: true }) .click(); await expect( page.getByRole("heading", { name: "Downloaded project", exact: true }), ).toBeVisible(); await app.evaluate(() => { globalThis.fetch = async () => new Response("Unavailable", { status: 503 }); }); await page.getByRole("link", { name: "Settings & backups" }).click(); await page .getByRole("button", { name: "Update content", exact: true }) .click(); await expect( page.getByText(/Content server returned HTTP 503/), ).toBeVisible(); expect( ((await call({ type: "catalog" })) as Catalog).items.some( (i) => i.id === "project:download-fixture", ), ).toBe(true); if (packaged) { // Publish this build's own code with a marker, then update through Settings. const updateApp = path.join(root, "update-app"); await fs.cp("build/app", updateApp, { recursive: true }); const index = path.join(updateApp, "renderer/index.html"); await fs.writeFile( index, (await fs.readFile(index, "utf8")).replace( "", '', ), ); const server = path.join(root, "update-server"); const manifest = writeAppUpdate({ appDir: updateApp, lockRoot: ".", destination: server, privateKey: updateKeys.privateKey, version: "9.9.9", commit: gitInfo(".").commit, publishedAt: new Date(Date.now() + 60000).toISOString(), }); await app.evaluate( (_electron, payload) => { globalThis.fetch = (async (input) => { const url = String(input); if (url === "https://vibe-learn.ai/updates/app/latest.json") return new Response(payload.latest); if ( url === `https://vibe-learn.ai/updates/app/${payload.revision}.json` ) return new Response(payload.bundle); throw new Error(`Unexpected update URL: ${url}`); }) as typeof fetch; }, { revision: manifest.revision, latest: await fs.readFile(path.join(server, "latest.json"), "utf8"), bundle: await fs.readFile( path.join(server, `${manifest.revision}.json`), "utf8", ), }, ); await page .getByRole("button", { name: "Dismiss notification", exact: true }) .click(); const beforeAppUpdate = (await call({ type: "state" })) as AppState; await page .getByRole("button", { name: "Update app", exact: true }) .click(); await expect( page.getByText("App update downloaded. Restart to use it.", { exact: true, }), ).toBeVisible(); await expect( page.getByRole("button", { name: "Restart now", exact: true }), ).toBeVisible(); expect(await call({ type: "state" })).toEqual(beforeAppUpdate); await page.screenshot({ path: "build/screenshots/app-updates.png" }); } else { await expect( page.getByRole("button", { name: "Update app", exact: true }), ).toBeDisabled(); } await page.getByRole("button", { name: "Switch theme" }).click(); await app.close(); app = undefined; app = await launch(); page = await app.firstWindow(); await expect(page.getByRole("heading", { name: "Continue" })).toBeVisible(); expect( ((await call({ type: "catalog" })) as Catalog).items.some( (i) => i.id === "project:download-fixture", ), ).toBe(true); const restored = (await call({ type: "state" })) as AppState; expect(restored.notes[0]?.text).toBe("Keep my reasoning and my tests."); expect(restored.bookmarks).toHaveLength(1); if (!packaged) { expect(restored.reviews).toHaveLength(2); expect(restored.exerciseWorkspaces).toHaveLength(1); expect( restored.runs.filter((r) => r.kind === "exercise").map((r) => r.status), ).toEqual(["failed", "passed"]); } expect(restored.runs[0]?.status).toBe("passed"); expect( restored.progress.find((p) => p.itemId === relay.id)?.scroll, ).toBeCloseTo(0.55, 1); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); if (packaged) { // The restart ran the downloaded code and recorded it as healthy. await expect(page.locator('meta[name="vibe-smoke-update"]')).toHaveCount( 1, ); expect(((await call({ type: "app-status" })) as AppStatus).source).toBe( "downloaded", ); await expect .poll(() => fs.access(path.join(root, "profile/app-update/launching.json")).then( () => true, () => false, ), ) .toBe(false); await page.getByRole("link", { name: "Settings & backups" }).click(); await expect(page.getByText(/Version 9\.9\.9 · updated/)).toBeVisible(); await page.screenshot({ path: "build/screenshots/app-updated.png" }); } console.log( `${packaged ? "Packaged" : "Development"} desktop smoke passed: SQLite restart, content update/recovery, ${packaged ? "signed app update and restart, " : ""}notes, bookmarks, validated IPC, clipboard, untouched learner code, ${packaged ? "bundled offline" : "local"} Go execution.`, ); } finally { await app?.close(); await fs.rm(root, { recursive: true, force: true }); } } void main().catch((error) => { console.error(error); process.exitCode = 1; });