Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ coverage/
.vite/
*.tsbuildinfo

# Playwright e2e artifacts
test-results/
playwright-report/
.playwright/

# Local docs scratch space
.docs/tmp/

Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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 }
});
133 changes: 133 additions & 0 deletions tests/e2e/extension.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html><html><head><meta charset="utf8"><title>Acme Dashboard</title></head>
<body style="margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif">
<header style="height:64px;background:#111827;color:#fff;display:flex;align-items:center;padding:0 24px;font-weight:700">Acme Dashboard</header>
<main style="padding:32px;max-width:900px"><h1>Quarterly report</h1>
<div style="height:160px;background:#e5e7eb;border-radius:12px;margin:24px 0"></div>
<div style="height:800px"></div></main></body></html>`;

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<void>((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<void>((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<string, unknown>): Promise<void> {
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();
});
9 changes: 8 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
9 changes: 9 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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"]
}
});
Loading