web-tester-for-claude 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ # Recipes
2
+
3
+ Copy-paste one-liners for common web-tester runs. Add new entries as you
4
+ discover new flows so future sessions don't re-derive the step grammar.
5
+
6
+ ## Format
7
+
8
+ Each recipe has:
9
+
10
+ - A **title** (one line, action-oriented: "Verify the contact form submits")
11
+ - A **when** clause (one line — the trigger that should pull this recipe)
12
+ - A **command** block — the exact shell line, ready to copy
13
+ - An **expected outcome** line — what "pass" looks like
14
+
15
+ ## Template
16
+
17
+ ```
18
+ ### <title>
19
+
20
+ **When:** <one-line trigger>
21
+
22
+ **Command:**
23
+
24
+ ```bash
25
+ web-tester inspect "<path>" \
26
+ --step settle --quick \
27
+ --expect "<assertion>" \
28
+ --fail-on http-5xx
29
+ ```
30
+
31
+ **Expected:** <one line — what verdict / evidence proves the run worked>
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Built-in starters
37
+
38
+ ### Smoke-check the homepage
39
+
40
+ **When:** "is the site up?" / "did my change break the homepage?"
41
+
42
+ **Command:**
43
+
44
+ ```bash
45
+ web-tester inspect / \
46
+ --step wait:networkidle --quick \
47
+ --expect "selector=header" \
48
+ --expect "selector=footer" \
49
+ --expect "selector=main" \
50
+ --fail-on http-5xx
51
+ ```
52
+
53
+ **Expected:** verdict: pass, all three selectors visible.
54
+
55
+ ### Verify a form submits and lands on a thank-you page
56
+
57
+ **When:** "did my form change still work?"
58
+
59
+ **Command:**
60
+
61
+ ```bash
62
+ web-tester inspect "/contact" \
63
+ --step "wait:networkidle" \
64
+ --step "fill:input[name=email]=test@example.com" \
65
+ --step "fill:textarea[name=message]=hello from web-tester" \
66
+ --step "click:button[type=submit]" \
67
+ --step "wait:url-contains:/thanks@10000" \
68
+ --quick \
69
+ --expect "text=Thanks" \
70
+ --fail-on http-5xx
71
+ ```
72
+
73
+ **Expected:** URL transitions to `/thanks`, "Thanks" text visible.
74
+
75
+ ### Sweep your smoke URL list against localhost
76
+
77
+ **When:** structural change (layout / header / shared component) — broad regression check.
78
+
79
+ **Command:**
80
+
81
+ ```bash
82
+ web-tester sweep --preset smoke --fail-on http-5xx
83
+ ```
84
+
85
+ **Expected:** every URL reports ok in the sweep summary; the HTML report opens to a green table.
86
+
87
+ ### Diff-aware advisory run
88
+
89
+ **When:** about to push — "what might my diff have broken?"
90
+
91
+ **Command:**
92
+
93
+ ```bash
94
+ web-tester impact
95
+ ```
96
+
97
+ **Expected:** plan prints matching rules, then advisory findings (or "nothing flagged"). Exits 0 either way.
98
+
99
+ ---
100
+
101
+ ## Project-specific recipes
102
+
103
+ <!-- Add your project's recipes below. Copy the template above, fill it in,
104
+ point the URL at whatever you're testing. `web-tester map` can generate
105
+ a starter set of these for you. -->
@@ -0,0 +1,17 @@
1
+ {
2
+ "description": "Example: user signs up and lands on a dashboard. Edit the URL, selectors, and expectations to match your app.",
3
+ "url": "/signup",
4
+ "steps": [
5
+ "wait:networkidle",
6
+ "fill:input[name=email]=test@example.com",
7
+ "fill:input[name=password]=hunter2-hunter2",
8
+ "click:button[type=submit]",
9
+ "wait:url-contains:/dashboard@15000"
10
+ ],
11
+ "expectations": [
12
+ "text=Welcome",
13
+ "selector=[data-test=dashboard]"
14
+ ],
15
+ "failOn": "http-5xx",
16
+ "persistMs": 0
17
+ }
@@ -0,0 +1,19 @@
1
+ # urls-smoke.txt — high-value pages to sweep on any structural change.
2
+ #
3
+ # One path per line. `#pack=<name>` annotations apply a built-in expectation
4
+ # pack to that URL. Built-in packs:
5
+ # homepage — header + footer present
6
+ # static — header + footer present
7
+ # category — header + footer + at least one internal anchor with an <img> in <main>
8
+ # has-main — <main> present
9
+ # has-h1 — <h1> present
10
+ #
11
+ # Run: web-tester sweep --preset smoke --fail-on http-5xx
12
+ #
13
+ # These are placeholders — replace with your real routes (or generate this
14
+ # file with `web-tester map`).
15
+
16
+ / #pack=homepage
17
+ /about #pack=static
18
+ /pricing #pack=has-h1
19
+ /contact #pack=static
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: web-tester
3
+ description: Drive the running dev site in a real browser (Playwright) to reproduce bugs, verify changes, and read runtime behavior — console, network, page errors, DOM, screenshots, video. Use this FIRST for any "does X work", bug-reproduction, "this page renders wrong", "the state is off", or "verify my change" question, before reading source. Also use to map a site, sweep many URLs, or run a saved journey.
4
+ allowed-tools: Bash(npx web-tester-for-claude *), Bash(web-tester *), Bash(npx web-tester *), Bash(npm run web-tester *), Bash(pnpm web-tester *), Read(**)
5
+ ---
6
+
7
+ # Driving the site with web-tester
8
+
9
+ For any runtime-behavior question, reach for web-tester **before** Read/Grep. The
10
+ browser is the source of truth for runtime bugs; read code *after* the run,
11
+ guided by what the run shows.
12
+
13
+ ## The core loop
14
+
15
+ 1. **Run it** against your **localhost** dev server, always with `--quick` and
16
+ `--fail-on http-5xx`. Add `--expect` for the specific thing you're checking:
17
+
18
+ ```bash
19
+ npx web-tester-for-claude inspect "/products/widget" \
20
+ --step settle --quick \
21
+ --expect "text=Add to Cart" \
22
+ --fail-on http-5xx
23
+ ```
24
+
25
+ 2. **Read the report** at the path the CLI prints — `result.json` for
26
+ programmatic reads (`ok`, `verdictTriggers`, `expectations`, `pageErrors`,
27
+ `console.entries`, `network.entries`, `steps[N].evalResult`), `report.html`
28
+ to scrub the video. **Only then** open source files.
29
+
30
+ 3. **When the DOM isn't enough**, re-run with `--deep`: it adds request/response
31
+ bodies, the **local-scope variables at every uncaught exception**, and
32
+ unhandled promise rejections (`result.json` → `deepErrors`,
33
+ `unhandledRejections`, `network.entries[].responseBody`).
34
+
35
+ ## Commands
36
+
37
+ | Command | Use it for |
38
+ |---|---|
39
+ | `inspect <url> [--step …]` | Drive one page / flow, capture everything. |
40
+ | `sweep --preset <name>` | Health-check many URLs in parallel. |
41
+ | `journey <name>` | Run a saved flow from `.web-tester/journeys/`. |
42
+ | `map` | Crawl the site and auto-generate a preset, recipes, and journey drafts. |
43
+ | `impact` | Diff-aware advisory run (reads `.web-tester/impact-rules.json`). |
44
+ | `kb [topic]` | List/print project recipe notes in `.web-tester/instructions/`. |
45
+
46
+ ## Before you write a `--step` chain from scratch
47
+
48
+ Run `web-tester kb` first — the project's recipes live there. Step grammar
49
+ gotchas: `click:` takes a Playwright CSS locator (not `role=`); `settle` only
50
+ does something on pages with `[data-attr-name]` markers — otherwise prefer
51
+ `wait:networkidle`. Run `web-tester help` for the full grammar.
52
+
53
+ ## Don't
54
+
55
+ - Don't verify a **local** change against **prod** — prod doesn't have your edit.
56
+ localhost is the default and the right target.
57
+ - Don't `--fail-on page-errors` by default — most apps have baseline framework
58
+ warnings. `http-5xx` is the safe gate.
59
+ - Don't trust a single `--expect` for async/derived state — add `--persist 2500`.
@@ -0,0 +1,26 @@
1
+ /* eslint-disable no-console */
2
+
3
+ const COLORS = {
4
+ reset: "\x1b[0m",
5
+ dim: "\x1b[2m",
6
+ red: "\x1b[31m",
7
+ green: "\x1b[32m",
8
+ yellow: "\x1b[33m",
9
+ blue: "\x1b[34m",
10
+ cyan: "\x1b[36m"
11
+ };
12
+
13
+ export const log = {
14
+ info: (msg: string): void => console.log(msg),
15
+ dim: (msg: string): void =>
16
+ console.log(`${COLORS.dim}${msg}${COLORS.reset}`),
17
+ ok: (msg: string): void =>
18
+ console.log(`${COLORS.green}${msg}${COLORS.reset}`),
19
+ warn: (msg: string): void =>
20
+ console.log(`${COLORS.yellow}${msg}${COLORS.reset}`),
21
+ fail: (msg: string): void => console.log(`${COLORS.red}${msg}${COLORS.reset}`),
22
+ step: (msg: string): void => console.log(`${COLORS.cyan}${msg}${COLORS.reset}`),
23
+ header: (msg: string): void =>
24
+ console.log(`${COLORS.blue}\n=== ${msg} ===${COLORS.reset}`),
25
+ raw: (msg: string): void => console.log(msg)
26
+ };
@@ -0,0 +1,141 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+
5
+ /** Root of the installed package (used to resolve bundled templates). */
6
+ export const PACKAGE_ROOT = resolve(__dirname, "../..");
7
+
8
+ /** Bundled scaffolding consumed by `web-tester init`. */
9
+ export const TEMPLATES_DIR = resolve(PACKAGE_ROOT, "src/templates");
10
+
11
+ /**
12
+ * Where run artifacts are written. Defaults to `.web-tester/runs/` in the
13
+ * project the CLI was invoked from, so output is namespaced under one dir
14
+ * (which `init` adds to `.gitignore`) rather than dropping a stray top-level
15
+ * `runs/`. Override with `WEB_TESTER_RUNS_DIR`.
16
+ */
17
+ export const RUNS_DIR = process.env.WEB_TESTER_RUNS_DIR
18
+ ? resolve(process.env.WEB_TESTER_RUNS_DIR)
19
+ : resolve(process.cwd(), ".web-tester", "runs");
20
+
21
+ /**
22
+ * Project config lives in `.web-tester/` at the project root, resolved
23
+ * against `process.cwd()`. Everything in it is optional:
24
+ *
25
+ * .web-tester/
26
+ * impact-rules.json rules for `web-tester impact`
27
+ * urls-<name>.txt URL presets for `web-tester sweep --preset <name>`
28
+ * journeys/<name>.json named flows for `web-tester journey <name>`
29
+ * instructions/*.md knowledge base for `web-tester kb [topic]`
30
+ */
31
+ export const USER_CONFIG_DIRNAME = ".web-tester";
32
+
33
+ export function userConfigDir(cwd: string = process.cwd()): string {
34
+ return resolve(cwd, USER_CONFIG_DIRNAME);
35
+ }
36
+
37
+ export function userImpactRulesPath(cwd: string = process.cwd()): string {
38
+ return resolve(userConfigDir(cwd), "impact-rules.json");
39
+ }
40
+
41
+ export function userJourneysDir(cwd: string = process.cwd()): string {
42
+ return resolve(userConfigDir(cwd), "journeys");
43
+ }
44
+
45
+ export function userPresetsDir(cwd: string = process.cwd()): string {
46
+ // Presets are flat `urls-<name>.txt` files at the top of `.web-tester/`.
47
+ return userConfigDir(cwd);
48
+ }
49
+
50
+ /**
51
+ * KB markdown locations, in priority order: `.web-tester/instructions/`
52
+ * first, then `.web-tester/` itself. First existing dir wins.
53
+ */
54
+ export function userKnowledgeDirs(cwd: string = process.cwd()): string[] {
55
+ return [resolve(userConfigDir(cwd), "instructions"), userConfigDir(cwd)];
56
+ }
57
+
58
+ /** `.web-tester/config.json` — persistent project defaults written by `init`. */
59
+ export function projectConfigPath(cwd: string = process.cwd()): string {
60
+ return resolve(userConfigDir(cwd), "config.json");
61
+ }
62
+
63
+ export type ProjectConfig = {
64
+ /** Default base URL, used when `WEB_TESTER_BASE_URL` isn't set. */
65
+ baseUrl?: string;
66
+ };
67
+
68
+ /** Read `.web-tester/config.json`. Returns {} when missing or unreadable. */
69
+ export function readProjectConfig(cwd: string = process.cwd()): ProjectConfig {
70
+ try {
71
+ const path = projectConfigPath(cwd);
72
+ if (!existsSync(path)) return {};
73
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as ProjectConfig;
74
+ return parsed && typeof parsed === "object" ? parsed : {};
75
+ } catch {
76
+ return {};
77
+ }
78
+ }
79
+
80
+ /** The project's `.claude/` integration directory and the files `init` touches. */
81
+ export function claudeDir(cwd: string = process.cwd()): string {
82
+ return resolve(cwd, ".claude");
83
+ }
84
+
85
+ export function claudeSettingsLocalPath(cwd: string = process.cwd()): string {
86
+ return resolve(claudeDir(cwd), "settings.local.json");
87
+ }
88
+
89
+ /** `.claude/skills/web-tester/SKILL.md` — the generated Claude Code skill. */
90
+ export function claudeSkillPath(cwd: string = process.cwd()): string {
91
+ return resolve(claudeDir(cwd), "skills", "web-tester", "SKILL.md");
92
+ }
93
+
94
+ /**
95
+ * Machine-local state in `~/.web-tester/`, kept outside the repo so saved
96
+ * auth state is never committed. Holds the Playwright `storageState` dump
97
+ * (cookies + localStorage) when a run persists a session.
98
+ */
99
+ export const WEB_TESTER_HOME = resolve(homedir(), ".web-tester");
100
+ export const SESSION_STATE_PATH = resolve(WEB_TESTER_HOME, "session.json");
101
+
102
+ export function ensureWebTesterHome(): void {
103
+ mkdirSync(WEB_TESTER_HOME, { recursive: true });
104
+ }
105
+
106
+ export function newRunId(): string {
107
+ const now = new Date();
108
+ const pad = (n: number): string => String(n).padStart(2, "0");
109
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
110
+ now.getDate()
111
+ )}T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
112
+ }
113
+
114
+ export type RunPaths = {
115
+ runId: string;
116
+ runDir: string;
117
+ stepsDir: string;
118
+ videoDir: string;
119
+ resultPath: string;
120
+ reportHtmlPath: string;
121
+ consolePath: string;
122
+ networkPath: string;
123
+ };
124
+
125
+ export function ensureRunPaths(runId: string): RunPaths {
126
+ const runDir = resolve(RUNS_DIR, runId);
127
+ const stepsDir = resolve(runDir, "steps");
128
+ const videoDir = resolve(runDir, "video");
129
+ mkdirSync(stepsDir, { recursive: true });
130
+ mkdirSync(videoDir, { recursive: true });
131
+ return {
132
+ runId,
133
+ runDir,
134
+ stepsDir,
135
+ videoDir,
136
+ resultPath: resolve(runDir, "result.json"),
137
+ reportHtmlPath: resolve(runDir, "report.html"),
138
+ consolePath: resolve(runDir, "console.json"),
139
+ networkPath: resolve(runDir, "network.json")
140
+ };
141
+ }
@@ -0,0 +1,50 @@
1
+ import { createInterface } from "node:readline";
2
+
3
+ /** True when we can run an interactive prompt (both ends are a TTY). */
4
+ export function isInteractive(): boolean {
5
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
6
+ }
7
+
8
+ function question(query: string): Promise<string> {
9
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
10
+ return new Promise<string>((resolve) => {
11
+ rl.question(query, (answer) => {
12
+ rl.close();
13
+ resolve(answer);
14
+ });
15
+ });
16
+ }
17
+
18
+ /** Free-text prompt with a default shown in brackets. Empty input → default. */
19
+ export async function ask(label: string, def: string): Promise<string> {
20
+ const answer = (await question(`${label} [${def}]: `)).trim();
21
+ return answer || def;
22
+ }
23
+
24
+ /** Yes/no prompt. Empty input → default. */
25
+ export async function confirm(label: string, def: boolean): Promise<boolean> {
26
+ const hint = def ? "Y/n" : "y/N";
27
+ const answer = (await question(`${label} (${hint}): `)).trim().toLowerCase();
28
+ if (!answer) return def;
29
+ return answer === "y" || answer === "yes";
30
+ }
31
+
32
+ /**
33
+ * Pick one of `options`. Accepts a full value or a unique prefix
34
+ * (case-insensitive). Empty input → default.
35
+ */
36
+ export async function choice<T extends string>(
37
+ label: string,
38
+ options: readonly T[],
39
+ def: T
40
+ ): Promise<T> {
41
+ const rendered = options
42
+ .map((o) => (o === def ? o.toUpperCase() : o))
43
+ .join("/");
44
+ const answer = (await question(`${label} [${rendered}]: `)).trim().toLowerCase();
45
+ if (!answer) return def;
46
+ const match = options.find(
47
+ (o) => o.toLowerCase() === answer || o.toLowerCase().startsWith(answer)
48
+ );
49
+ return match ?? def;
50
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "moduleResolution": "node",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "skipLibCheck": true,
10
+ "resolveJsonModule": true,
11
+ "forceConsistentCasingInFileNames": true
12
+ },
13
+ "include": ["src/**/*.ts"]
14
+ }