Add wc-capture: forensic front-end capture of our deployed app
Playwright tool to mirror our own WorkClaw app's compiled front-end (CSS, JS, fonts, SVGs) and dump each screen's DOM + computed styles, for matching Clawmates to the real thing instead of guessing from screenshots. Auth is by hand (you log in, incl. the emailed code, it reuses the session); `live` mode does login + capture in one go for OTP logins. out/ + the one-off grab scripts are gitignored; eslint ignores the captured minified assets. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
23a4bad07d
commit
355a6f23b9
@@ -23,6 +23,9 @@ const eslintConfig = defineConfig([
|
|||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
// Forensic capture of our own deployed app — minified assets + one-off
|
||||||
|
// scripts, not part of the product source.
|
||||||
|
"tools/wc-capture/**",
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Captured reference material + session — never commit (it's a snapshot of the
|
||||||
|
# live app, and auth.json is a live session token).
|
||||||
|
out/
|
||||||
|
# One-off forensic scripts (hardcoded session/claw URLs) — keep local, not in repo
|
||||||
|
grab*.mjs
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# wc-capture — forensic front-end capture of our WorkClaw app
|
||||||
|
|
||||||
|
Pulls our own deployed app's compiled front-end (CSS, JS chunks, fonts, SVGs,
|
||||||
|
images) plus the rendered DOM and computed styles of each screen, so we can
|
||||||
|
reassemble the styled interface locally and match Clawmates to it exactly.
|
||||||
|
|
||||||
|
Your password is never entered here: you log in by hand once and the session
|
||||||
|
is reused. `out/` (including the session token) is gitignored.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
From `frontend/` (so Playwright resolves).
|
||||||
|
|
||||||
|
**Recommended for our emailed-code login** — log in and capture in one session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WC_BASE_URL="https://www.workclaw.com" node tools/wc-capture/capture.mjs live
|
||||||
|
# browser opens → log in by hand (enter the emailed code) → press Enter →
|
||||||
|
# it captures all ROUTES + mirrors assets in that same authenticated session
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative (only if the session survives a save/reload — i.e. cookie/
|
||||||
|
localStorage token, not sessionStorage):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/wc-capture/capture.mjs login # log in by hand, saves out/auth.json
|
||||||
|
node tools/wc-capture/capture.mjs run # headless; walks ROUTES
|
||||||
|
node tools/wc-capture/capture.mjs browse # headed; mirror assets as you click
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust `BASE`, `ROUTES`, and `SELECTORS` at the top of `capture.mjs` to the
|
||||||
|
live UI (the defaults mirror Clawmates' role-based selectors).
|
||||||
|
|
||||||
|
## Output (`out/`)
|
||||||
|
|
||||||
|
| dir | what |
|
||||||
|
|---|---|
|
||||||
|
| `assets/<host>/…` | mirrored CSS, JS, **fonts**, **SVGs**, images — preserved paths |
|
||||||
|
| `pages/<screen>.html` | rendered `outerHTML` per screen |
|
||||||
|
| `shots/<screen>.png` | reference screenshot |
|
||||||
|
| `styles/<screen>.json` | `getComputedStyle` + bounding rects for the parity selectors |
|
||||||
|
| `auth.json` | persisted session (gitignored — treat as a live credential) |
|
||||||
|
|
||||||
|
## View the static snapshot locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx serve tools/wc-capture/out/assets # serves mirrored assets
|
||||||
|
# open a pages/*.html to inspect styling next to Clawmates in devtools
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: this is a **static styling reference**, not a working app — the JS is
|
||||||
|
minified and hydration/API calls hit prod. Use it to read exact values
|
||||||
|
(`styles/*.json`) and lift static assets (fonts/SVGs), then apply to Clawmates.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Forensic front-end capture of our own WorkClaw app, for reassembling the
|
||||||
|
// styled interface locally and getting ground-truth styles to match Clawmates.
|
||||||
|
//
|
||||||
|
// Auth never touches this script: you log in by hand once, it persists the
|
||||||
|
// session, headless runs reuse it.
|
||||||
|
//
|
||||||
|
// node capture.mjs live headed; you log in by hand (incl. the emailed
|
||||||
|
// code), press Enter, and it captures everything in
|
||||||
|
// that same session — best for code/OTP logins
|
||||||
|
// node capture.mjs login headed; you log in manually; saves out/auth.json
|
||||||
|
// (only persists if the token lives in a cookie or
|
||||||
|
// localStorage — not sessionStorage)
|
||||||
|
// node capture.mjs browse headed + saved session; mirrors assets as you
|
||||||
|
// click around; close the window to stop
|
||||||
|
// node capture.mjs run headless + saved session; walks ROUTES, saving
|
||||||
|
// each screen's HTML, screenshot, computed styles
|
||||||
|
//
|
||||||
|
// Config via env: WC_BASE_URL (default below). Outputs land in ./out (gitignored).
|
||||||
|
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
import readline from "node:readline";
|
||||||
|
|
||||||
|
const BASE = process.env.WC_BASE_URL ?? "https://www.workclaw.com";
|
||||||
|
const LOGIN = process.env.WC_LOGIN_URL ?? new URL("/sign-in", BASE).href;
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const OUT = path.join(HERE, "out");
|
||||||
|
const AUTH = path.join(OUT, "auth.json");
|
||||||
|
const HOST = new URL(BASE).host;
|
||||||
|
const mode = process.argv[2] ?? "run";
|
||||||
|
|
||||||
|
// Screens to capture in `run` mode. `actions(page)` runs after navigation —
|
||||||
|
// e.g. enter a claw and open the Computer panel. Selectors mirror Clawmates'
|
||||||
|
// (role-based); adjust if the live WorkClaw UI differs.
|
||||||
|
const ROUTES = [
|
||||||
|
{ name: "workspace-home", url: "/" },
|
||||||
|
{
|
||||||
|
name: "computer-home",
|
||||||
|
url: "/",
|
||||||
|
actions: async (page) => {
|
||||||
|
await page.getByRole("link", { name: /claw|chat|scout/i }).first().click();
|
||||||
|
await page.waitForURL(/chat/i, { timeout: 8000 }).catch(() => {});
|
||||||
|
await page.getByRole("button", { name: /computer/i }).click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Elements we want exact computed styles for (the parity unknowns).
|
||||||
|
const SELECTORS = [
|
||||||
|
'[aria-label="Dock"]',
|
||||||
|
'[aria-label="Dock"] button',
|
||||||
|
'[role="complementary"]',
|
||||||
|
'[role="complementary"] .grid, [aria-label$="apps"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CAPTURE_TYPES = new Set([
|
||||||
|
"stylesheet",
|
||||||
|
"script",
|
||||||
|
"font",
|
||||||
|
"image",
|
||||||
|
"document",
|
||||||
|
"manifest",
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function saveResponse(resp) {
|
||||||
|
try {
|
||||||
|
const url = new URL(resp.url());
|
||||||
|
if (url.host !== HOST) return; // same-origin only
|
||||||
|
if (!CAPTURE_TYPES.has(resp.request().resourceType())) return;
|
||||||
|
const body = await resp.body().catch(() => null);
|
||||||
|
if (!body) return;
|
||||||
|
let rel = url.pathname;
|
||||||
|
if (rel.endsWith("/")) rel += "index.html";
|
||||||
|
const file = path.join(OUT, "assets", url.host, rel);
|
||||||
|
await mkdir(path.dirname(file), { recursive: true });
|
||||||
|
await writeFile(file, body);
|
||||||
|
} catch {
|
||||||
|
/* ignore individual asset failures */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForEnter(prompt) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin });
|
||||||
|
rl.question(prompt, () => {
|
||||||
|
rl.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const browser = await chromium.launch({ headless: false });
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
await ctx.newPage().then((p) => p.goto(LOGIN));
|
||||||
|
await waitForEnter(
|
||||||
|
"\n>>> Log in by hand in the browser window. When the app is fully loaded, press Enter here.\n",
|
||||||
|
);
|
||||||
|
await mkdir(OUT, { recursive: true });
|
||||||
|
await ctx.storageState({ path: AUTH });
|
||||||
|
console.log(`Saved session → ${AUTH}`);
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth() {
|
||||||
|
if (!existsSync(AUTH)) {
|
||||||
|
console.error("No session yet — run `node capture.mjs login` first.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browse() {
|
||||||
|
requireAuth();
|
||||||
|
const browser = await chromium.launch({ headless: false });
|
||||||
|
const ctx = await browser.newContext({ storageState: AUTH });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on("response", saveResponse);
|
||||||
|
await page.goto(BASE);
|
||||||
|
console.log("Mirroring assets as you browse. Close the window to finish.");
|
||||||
|
await new Promise((r) => browser.on("disconnected", r));
|
||||||
|
console.log(`Assets saved under ${path.join(OUT, "assets")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureScreens(page) {
|
||||||
|
for (const dir of ["pages", "shots", "styles"]) {
|
||||||
|
await mkdir(path.join(OUT, dir), { recursive: true });
|
||||||
|
}
|
||||||
|
for (const route of ROUTES) {
|
||||||
|
try {
|
||||||
|
await page.goto(new URL(route.url, BASE).href, {
|
||||||
|
waitUntil: "networkidle",
|
||||||
|
});
|
||||||
|
if (route.actions) await route.actions(page);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await writeFile(
|
||||||
|
path.join(OUT, "pages", `${route.name}.html`),
|
||||||
|
await page.content(),
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(OUT, "shots", `${route.name}.png`),
|
||||||
|
});
|
||||||
|
const styles = await page.evaluate((sels) => {
|
||||||
|
const props = [
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"margin",
|
||||||
|
"padding",
|
||||||
|
"gap",
|
||||||
|
"justifyContent",
|
||||||
|
"alignItems",
|
||||||
|
"borderRadius",
|
||||||
|
"boxShadow",
|
||||||
|
"backgroundColor",
|
||||||
|
"backdropFilter",
|
||||||
|
"fontFamily",
|
||||||
|
"color",
|
||||||
|
];
|
||||||
|
const dump = {};
|
||||||
|
for (const sel of sels) {
|
||||||
|
dump[sel] = [...document.querySelectorAll(sel)]
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((el) => {
|
||||||
|
const cs = getComputedStyle(el);
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const o = { rect: { w: r.width, h: r.height, x: r.x, y: r.y } };
|
||||||
|
for (const p of props) o[p] = cs[p];
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return dump;
|
||||||
|
}, SELECTORS);
|
||||||
|
await writeFile(
|
||||||
|
path.join(OUT, "styles", `${route.name}.json`),
|
||||||
|
JSON.stringify(styles, null, 2),
|
||||||
|
);
|
||||||
|
console.log(`captured ${route.name}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`! ${route.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\nDone. See ${OUT}/{assets,pages,shots,styles}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
requireAuth();
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const ctx = await browser.newContext({ storageState: AUTH });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on("response", saveResponse);
|
||||||
|
await captureScreens(page);
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined login + capture in one live session — robust to OTP logins and
|
||||||
|
// sessionStorage tokens that wouldn't survive a save/reload.
|
||||||
|
async function live() {
|
||||||
|
const browser = await chromium.launch({ headless: false });
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
page.on("response", saveResponse); // mirror assets from the first paint
|
||||||
|
await page.goto(LOGIN);
|
||||||
|
await waitForEnter(
|
||||||
|
"\n>>> Log in by hand (enter the emailed code). When the app is fully loaded, press Enter here.\n",
|
||||||
|
);
|
||||||
|
await captureScreens(page);
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await { live, login, browse, run }[mode]?.() ??
|
||||||
|
console.error(`Unknown mode "${mode}" — use live | login | browse | run`);
|
||||||
Reference in New Issue
Block a user