diff --git a/.gitignore b/.gitignore index 81a65b3..2affdfb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,11 @@ coverage/ .vite/ *.tsbuildinfo +# Playwright e2e artifacts +test-results/ +playwright-report/ +.playwright/ + # Local docs scratch space .docs/tmp/ diff --git a/CLAUDE.md b/CLAUDE.md index e6e0bd6..b497dbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,12 @@ Non-trivial work is tracked as a change folder under `.docs/`: `proposal.md` (wh - `npm run check` — the gate: `typecheck && lint && test && build`. Run before any PR. - `npm run dev` / `npm run build` — Vite dev server / production build into `dist/`. -- `npm run test` — Vitest (`vitest run`). Single file: `npx vitest run tests/capture.test.ts`. Single test: `npx vitest run -t "name substring"`. Watch: `npx vitest`. +- `npm run test` — Vitest unit tests (`vitest run`, scoped to `tests/**/*.test.ts` by `vitest.config.ts`). Single file: `npx vitest run tests/capture.test.ts`. Single test: `npx vitest run -t "name substring"`. Watch: `npx vitest`. +- `npm run test:e2e` — Playwright end-to-end smoke test (`tests/e2e/extension.spec.ts`, config `playwright.config.ts`). Builds `dist/`, then launches **real Chromium in new-headless with the unpacked extension loaded** (`--load-extension`) and asserts: the extension/service worker loads (no popup, `downloads` permission), the on-page capture notice shows → hides cleanly → is removed (driven through the real content script via `SB_CAPTURE_*` messages), and the editor page renders. Requires a one-time `npx playwright install chromium`. **Deliberately excluded from `npm run check`/CI** (needs downloaded browsers + new-headless) — run it locally on demand. - `npm run typecheck`, `npm run lint` (`lint:fix`), `npm run format` (`format:check`). - `npm run gen:icons` — regenerate `public/icons/*` from `scripts/generate-icons.mjs`. -To run the extension: `npm run build`, then load the `dist/` folder via `chrome://extensions` (Developer mode → Load unpacked). There is no test runner for the live extension — UI flows are verified manually (capture, annotate/comment, timeline select/remove, viewer, cloud-LLM fallback). +To run the extension: `npm run build`, then load the `dist/` folder via `chrome://extensions` (Developer mode → Load unpacked). `npm run test:e2e` automates the page-side flows; the parts it can't (clicking the toolbar icon, real `captureVisibleTab`) are still verified manually (capture, annotate/comment, timeline select/remove, viewer, cloud-LLM fallback). ## Architecture diff --git a/package-lock.json b/package-lock.json index 44f011a..e90b45b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.0", + "@playwright/test": "^1.61.1", "@types/chrome": "^0.2.0", "@types/node": "^22.10.0", "@types/react": "^18.3.10", @@ -676,6 +677,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -3285,6 +3302,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/package.json b/package.json index cccd7c5..98effb4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "vite build", "preview": "vite preview", "test": "vitest run", + "test:e2e": "npm run build && playwright test", "typecheck": "tsc --noEmit", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -31,6 +32,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.0", + "@playwright/test": "^1.61.1", "@types/chrome": "^0.2.0", "@types/node": "^22.10.0", "@types/react": "^18.3.10", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5be7c45 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "@playwright/test"; + +// E2E smoke test for the built extension. It launches real Chromium (new +// headless) with the unpacked `dist/` loaded, so it is intentionally kept out +// of the `npm run check` gate and CI — run it locally with `npm run test:e2e` +// (which builds `dist/` first). Browsers must be installed once via +// `npx playwright install chromium`. +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: false, + workers: 1, + reporter: "list", + timeout: 60_000, + expect: { timeout: 10_000 } +}); diff --git a/tests/e2e/extension.spec.ts b/tests/e2e/extension.spec.ts new file mode 100644 index 0000000..30422a0 --- /dev/null +++ b/tests/e2e/extension.spec.ts @@ -0,0 +1,133 @@ +import { existsSync } from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + chromium, + expect, + test, + type BrowserContext, + type Worker as ServiceWorker +} from "@playwright/test"; + +// Loads the unpacked `dist/` extension in real Chromium and verifies the parts +// of one-click capture that are automatable without the browser's toolbar UI: +// the extension loads, the on-page capture notice shows/hides/removes cleanly +// (driven through the real content script), and the editor page renders. + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const EXT = path.resolve(dir, "..", "..", "dist"); + +const PAGE_HTML = `Acme Dashboard + +
Acme Dashboard
+

Quarterly report

+
+
`; + +let ctx: BrowserContext; +let sw: ServiceWorker; +let extId: string; +let server: http.Server; +let base: string; + +test.beforeAll(async () => { + expect(existsSync(EXT), "dist/ must be built first (run: npm run build)").toBe(true); + + server = http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(PAGE_HTML); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + base = `http://127.0.0.1:${(server.address() as { port: number }).port}/`; + + ctx = await chromium.launchPersistentContext("", { + headless: false, + args: [ + "--headless=new", + `--disable-extensions-except=${EXT}`, + `--load-extension=${EXT}`, + "--no-sandbox" + ] + }); + + sw = ctx.serviceWorkers()[0] ?? (await ctx.waitForEvent("serviceworker")); + extId = new URL(sw.url()).host; +}); + +test.afterAll(async () => { + await ctx?.close(); + await new Promise((resolve) => server?.close(() => resolve())); +}); + +/** + * Send a message to the target tab's content script from the service worker, + * retrying until it is delivered. The content script registers its listener at + * `document_idle`, so the first send can race page load; this waits it out and + * fails clearly if the message is never received. + */ +async function send(message: Record): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + const delivered = await sw.evaluate( + async ([b, msg]) => { + const [tab] = await chrome.tabs.query({ url: (b as string) + "*" }); + if (tab?.id == null) return false; + try { + await chrome.tabs.sendMessage(tab.id, msg); + return true; + } catch { + return false; + } + }, + [base, message] as const + ); + if (delivered) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`content script never received ${JSON.stringify(message)}`); +} + +test("extension loads with no popup and the downloads permission", async () => { + const manifest = await sw.evaluate(() => chrome.runtime.getManifest()); + expect(manifest.action?.default_popup).toBeUndefined(); + expect(manifest.permissions).toContain("downloads"); +}); + +test("capture notice shows, hides for the frame, and is removed", async () => { + const page = await ctx.newPage(); + await page.goto(base, { waitUntil: "load" }); + + await send({ type: "SB_CAPTURE_BEGIN" }); + const shown = await page.evaluate(() => { + const el = document.querySelector("[data-shotback-overlay]") as HTMLElement | null; + return { + present: !!el, + display: el && getComputedStyle(el).display, + text: el?.innerText ?? "" + }; + }); + expect(shown.present).toBe(true); + expect(shown.display).toBe("flex"); + expect(shown.text).toContain("Capturing full page"); + + await send({ type: "SB_SET_OVERLAY", visible: false }); + const hidden = await page.evaluate(() => { + const el = document.querySelector("[data-shotback-overlay]") as HTMLElement | null; + return el && getComputedStyle(el).display; + }); + expect(hidden).toBe("none"); + + await send({ type: "SB_CAPTURE_END" }); + const removed = await page.evaluate(() => !document.querySelector("[data-shotback-overlay]")); + expect(removed).toBe(true); + + await page.close(); +}); + +test("editor page renders the capture UI", async () => { + const editor = await ctx.newPage(); + await editor.goto(`chrome-extension://${extId}/editor.html`, { waitUntil: "load" }); + await expect(editor.getByRole("button", { name: "Capture Page" })).toBeVisible(); + await expect(editor.getByRole("button", { name: "Copy for Claude Code" })).toBeVisible(); + await editor.close(); +}); diff --git a/tsconfig.json b/tsconfig.json index faddf08..cfd5a2c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,12 @@ "@/*": ["src/*"] } }, - "include": ["src", "tests", "vite.config.ts", "eslint.config.js"] + "include": [ + "src", + "tests", + "vite.config.ts", + "vitest.config.ts", + "playwright.config.ts", + "eslint.config.js" + ] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2bddf0b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +// Unit tests only. The Playwright e2e spec under tests/e2e/ uses the +// @playwright/test runner (see playwright.config.ts), so it is excluded here. +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"] + } +});