Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933c197060 | ||
|
|
de3591a25b |
@@ -61,6 +61,9 @@ watching the page change.
|
|||||||
|
|
||||||
- `~/.agents/skills/ticket/scripts/ticket-page <command> <KEY> ...` — the only writer of the
|
- `~/.agents/skills/ticket/scripts/ticket-page <command> <KEY> ...` — the only writer of the
|
||||||
ticket page and epic index. See [SITE.md](SITE.md), or `ticket-page help`. Runs under bun.
|
ticket page and epic index. See [SITE.md](SITE.md), or `ticket-page help`. Runs under bun.
|
||||||
|
- `~/.agents/skills/ticket/scripts/capture-proof` — sharp Playwright screenshots of the
|
||||||
|
running app for proof and spike screenshots. `capture-proof help` lists its options. The
|
||||||
|
first run installs a pinned Playwright into `~/.cache/ticket-page/`.
|
||||||
- `~/.agents/skills/ticket/scripts/jira-fetch-issue.sh <KEY> <OUT.json>` — fetches one
|
- `~/.agents/skills/ticket/scripts/jira-fetch-issue.sh <KEY> <OUT.json>` — fetches one
|
||||||
issue (`fields=*all`, rendered HTML description, field-name map) via `JIRA_BASE_URL` /
|
issue (`fields=*all`, rendered HTML description, field-name map) via `JIRA_BASE_URL` /
|
||||||
`JIRA_CREDENTIALS`.
|
`JIRA_CREDENTIALS`.
|
||||||
@@ -362,8 +365,8 @@ what was quick to wire up. Ship both, and don't let either stand in for the othe
|
|||||||
than a static mock would, say so in the plan's approach rather than silently scoping it down
|
than a static mock would, say so in the plan's approach rather than silently scoping it down
|
||||||
without mentioning the tradeoff.
|
without mentioning the tradeoff.
|
||||||
- **Screenshot the real thing.** Once it's built and running, use the `run` skill to
|
- **Screenshot the real thing.** Once it's built and running, use the `run` skill to
|
||||||
get the app up and a browser automation tool (e.g. `claude-in-chrome`) to capture
|
get the app up and capture the actual screens with `capture-proof`, as the proof step
|
||||||
the actual screens, instead of drawing a wireframe. Save screenshots under
|
in Phase 2 describes, instead of drawing a wireframe. Save screenshots under
|
||||||
`deliverables/<slug>/screenshots/` and the visual mocks under
|
`deliverables/<slug>/screenshots/` and the visual mocks under
|
||||||
`deliverables/<slug>/mocks/`, and embed both in `<slug>.typ` via `image()` when
|
`deliverables/<slug>/mocks/`, and embed both in `<slug>.typ` via `image()` when
|
||||||
building the mockups deliverable. Label which is which. A reader who can't tell a
|
building the mockups deliverable. Label which is which. A reader who can't tell a
|
||||||
@@ -831,7 +834,32 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
|
|
||||||
**Proof first.** Before filling any proof column, capture working feature proof
|
**Proof first.** Before filling any proof column, capture working feature proof
|
||||||
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
||||||
feature with browser automation, or capture test output for behavior with no UI.
|
feature with `capture-proof`, or capture test output for behavior with no UI.
|
||||||
|
|
||||||
|
**Screenshots come from Playwright, never from a browser-extension screen grab.**
|
||||||
|
Extension captures come out soft and hard to read, and a proof screenshot exists to be
|
||||||
|
read. `capture-proof shot` renders at twice the pixel density with animations frozen,
|
||||||
|
fonts loaded, and dev overlays hidden:
|
||||||
|
|
||||||
|
```
|
||||||
|
capture-proof shot http://localhost:3000/companies --wait-for "text=Canonical ID" --out <target-dir>/proof/companies-grid-canonical-ids.png
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Sign-in.** The first `shot` against an app that needs a session stops with a sign-in
|
||||||
|
hint. Ask Gib to run `capture-proof login <origin>` himself: it opens a visible
|
||||||
|
browser, he signs in and closes the window, and every later `shot` against that
|
||||||
|
origin reuses the session. It is saved under `~/.cache/ticket-page/auth/`, never in a
|
||||||
|
repo. The hint only catches a redirect to a sign-in path, so look at every capture
|
||||||
|
before recording it.
|
||||||
|
- **Frame the evidence.** Pass `--wait-for` with the thing the proof is about, so the
|
||||||
|
capture never catches a loading spinner. Crop to the part that matters with
|
||||||
|
`--selector`, or take the whole page with `--full`.
|
||||||
|
- **Interaction first.** For state that takes clicks or typing (an open dialog, a
|
||||||
|
form showing its warning), write a small script,
|
||||||
|
`export default async (page) => { await page.getByLabel("Canonical ID").fill("NIKON"); }`,
|
||||||
|
and pass it with `--script`. Keep scripts in a scratch path, not in `proof/`.
|
||||||
|
- Read every capture with the Read tool before recording it. A crisp picture of the
|
||||||
|
wrong screen is still wrong.
|
||||||
Save artifacts under `<target-dir>/proof/` with names that match the test-case
|
Save artifacts under `<target-dir>/proof/` with names that match the test-case
|
||||||
rows they prove, and put each on the page with `ticket-page proof`, naming the
|
rows they prove, and put each on the page with `ticket-page proof`, naming the
|
||||||
criteria it proves and the mock it answers. A proof cell references its artifact by filename plus "attached"
|
criteria it proves and the mock it answers. A proof cell references its artifact by filename plus "attached"
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Screenshots a running app for a ticket's proof with Playwright. See: capture-proof help
|
||||||
|
# The first run installs a pinned Playwright and its Chromium into ~/.cache/ticket-page.
|
||||||
|
set -euo pipefail
|
||||||
|
version=1.60.0
|
||||||
|
here="$(dirname "$(readlink -f "$0")")"
|
||||||
|
pw="${XDG_CACHE_HOME:-$HOME/.cache}/ticket-page/playwright"
|
||||||
|
command -v node >/dev/null 2>&1 || { echo "capture-proof: node is not on PATH" >&2; exit 127; }
|
||||||
|
if [[ "$(node -p "try{require('$pw/node_modules/playwright/package.json').version}catch{''}" 2>/dev/null)" != "$version" ]]; then
|
||||||
|
echo "capture-proof: installing Playwright $version into $pw" >&2
|
||||||
|
mkdir -p "$pw"
|
||||||
|
[[ -f "$pw/package.json" ]] || echo '{"private":true}' > "$pw/package.json"
|
||||||
|
(cd "$pw" && npm install --silent --no-audit --no-fund "playwright@$version") >&2
|
||||||
|
node "$pw/node_modules/playwright/cli.js" install chromium >&2
|
||||||
|
fi
|
||||||
|
exec node "$here/capture-proof.ts" "$@"
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// capture-proof: sharp, repeatable screenshots of a running app for a ticket's proof/,
|
||||||
|
// taken with Playwright instead of a screen-capture tool. Run through the `capture-proof`
|
||||||
|
// wrapper, which installs a pinned Playwright into a cache on first use.
|
||||||
|
//
|
||||||
|
// capture-proof login <url> sign in once in a visible browser, save the session
|
||||||
|
// capture-proof shot <url> --out <file.png> take a screenshot with the saved session
|
||||||
|
//
|
||||||
|
// Sessions live under ~/.cache/ticket-page/auth/, one per origin, readable only by you.
|
||||||
|
// They are never written inside a repository.
|
||||||
|
|
||||||
|
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import type { Browser, BrowserContextOptions, Page } from "playwright";
|
||||||
|
|
||||||
|
const cache = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "ticket-page");
|
||||||
|
const { chromium } = createRequire(join(cache, "playwright", "package.json"))("playwright") as typeof import("playwright");
|
||||||
|
|
||||||
|
// The Next.js dev badge and toast stacks sit over the content in dev builds.
|
||||||
|
const ALWAYS_HIDE = ["nextjs-portal", "[data-nextjs-toast]", "[data-sonner-toaster]"];
|
||||||
|
|
||||||
|
class UsageError extends Error {}
|
||||||
|
function fail(msg: string): never { throw new UsageError(msg); }
|
||||||
|
|
||||||
|
function authFile(url: string): string {
|
||||||
|
const u = new URL(url);
|
||||||
|
return join(cache, "auth", `${u.hostname}_${u.port || (u.protocol === "https:" ? "443" : "80")}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(argv: string[]) {
|
||||||
|
const pos: string[] = [];
|
||||||
|
const opt = new Map<string, string>();
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (!a.startsWith("--")) { pos.push(a); continue; }
|
||||||
|
const next = argv[i + 1];
|
||||||
|
opt.set(a.slice(2), next === undefined || next.startsWith("--") ? "true" : (i++, next));
|
||||||
|
}
|
||||||
|
return { pos, opt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a visible browser at the app. Gib signs in and closes the window, and the session
|
||||||
|
* is saved for every later `shot` against the same origin. */
|
||||||
|
async function login(url: string) {
|
||||||
|
const browser = await chromium.launch({ headless: false });
|
||||||
|
const context = await browser.newContext({ viewport: null });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(url);
|
||||||
|
console.log("Sign in in the browser window, then close it. The session is saved when the window closes.");
|
||||||
|
await page.waitForEvent("close", { timeout: 0 });
|
||||||
|
const file = authFile(url);
|
||||||
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||||
|
await context.storageState({ path: file });
|
||||||
|
chmodSync(file, 0o600);
|
||||||
|
await browser.close();
|
||||||
|
console.log(`Saved the session for ${new URL(url).origin}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Actions = (page: Page) => Promise<void>;
|
||||||
|
|
||||||
|
async function shot(url: string, opt: Map<string, string>) {
|
||||||
|
const out = resolve(opt.get("out") ?? fail("shot needs --out <file.png>"));
|
||||||
|
const file = authFile(url);
|
||||||
|
const context: BrowserContextOptions = {
|
||||||
|
viewport: { width: Number(opt.get("width") ?? 1440), height: Number(opt.get("height") ?? 900) },
|
||||||
|
deviceScaleFactor: Number(opt.get("scale") ?? 2),
|
||||||
|
colorScheme: opt.has("dark") ? "dark" : "light",
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
...(existsSync(file) && !opt.has("anonymous") ? { storageState: file } : {}),
|
||||||
|
};
|
||||||
|
let browser: Browser | undefined;
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
const page = await (await browser.newContext(context)).newPage();
|
||||||
|
await page.goto(url, { waitUntil: "networkidle" });
|
||||||
|
if (!opt.has("anonymous") && /sign-?in|login|auth\//i.test(new URL(page.url()).pathname)) {
|
||||||
|
fail(`${url} landed on ${page.url()}, a sign-in page. Run: capture-proof login ${new URL(url).origin}`);
|
||||||
|
}
|
||||||
|
const script = opt.get("script");
|
||||||
|
if (script) {
|
||||||
|
const mod: { default: Actions } = await import(pathToFileURL(resolve(script)).href);
|
||||||
|
await mod.default(page);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
}
|
||||||
|
// Network idle is not enough for content rendered on the client, which can still show
|
||||||
|
// a spinner. Wait for the thing the proof is about when it is named, then settle.
|
||||||
|
const waitFor = opt.get("wait-for");
|
||||||
|
if (waitFor) await page.locator(waitFor).first().waitFor({ state: "visible", timeout: 20_000 });
|
||||||
|
await page.waitForTimeout(Number(opt.get("settle") ?? 500));
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
const hide = [...ALWAYS_HIDE, ...(opt.get("hide")?.split(",") ?? [])].join(", ");
|
||||||
|
await page.addStyleTag({ content: `${hide} { visibility: hidden !important; }` });
|
||||||
|
const selector = opt.get("selector");
|
||||||
|
mkdirSync(dirname(out), { recursive: true });
|
||||||
|
const settings = { path: out, animations: "disabled", caret: "hide" } as const;
|
||||||
|
if (selector) await page.locator(selector).first().screenshot(settings);
|
||||||
|
else await page.screenshot({ ...settings, fullPage: opt.has("full") });
|
||||||
|
console.log(out);
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HELP = `capture-proof login <url>
|
||||||
|
Opens a visible browser at <url>. Sign in, then close the window to save the session.
|
||||||
|
capture-proof shot <url> --out <file.png> [options]
|
||||||
|
--selector <css> capture one element instead of the viewport
|
||||||
|
--full capture the whole scrolling page
|
||||||
|
--wait-for <loc> wait until this is visible first: a CSS selector, or text=Some label
|
||||||
|
--settle <ms> pause after loading, 500 by default
|
||||||
|
--script <file.mjs> run \`export default async (page) => {...}\` first: clicks, typing, opening a dialog
|
||||||
|
--hide <css,css> hide more overlays, beyond the Next.js dev badge and toasts
|
||||||
|
--width 1440 --height 900 --scale 2 viewport and pixel density
|
||||||
|
--dark dark color scheme
|
||||||
|
--anonymous ignore the saved session, as a signed-out user`;
|
||||||
|
|
||||||
|
const [cmd, ...rest] = process.argv.slice(2);
|
||||||
|
const { pos, opt } = parse(rest);
|
||||||
|
try {
|
||||||
|
if (cmd === "login") await login(pos[0] ?? fail("login needs a url"));
|
||||||
|
else if (cmd === "shot") await shot(pos[0] ?? fail("shot needs a url"), opt);
|
||||||
|
else { console.log(HELP); process.exit(cmd && cmd !== "help" ? 2 : 0); }
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`capture-proof ${cmd}: ${err instanceof Error ? err.message : err}`);
|
||||||
|
process.exit(err instanceof UsageError ? 2 : 1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user