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,35 @@
1
+ #!/usr/bin/env node
2
+ // web-tester launcher. Delegates to the TypeScript CLI via tsx so we can ship
3
+ // source rather than pre-built JS — the runtime cost is one tsx startup
4
+ // (~150ms on warm cache) and it keeps the published package tiny.
5
+ //
6
+ // We resolve tsx from this package's own node_modules so the launcher never
7
+ // fights with the consumer project's installed version. The CLI entry is
8
+ // `../src/cli.ts` relative to this file regardless of where npm placed us
9
+ // (top-level node_modules, pnpm's nested layout, npx cache, etc).
10
+
11
+ const { spawnSync } = require("node:child_process");
12
+ const { resolve } = require("node:path");
13
+
14
+ const PACKAGE_ROOT = resolve(__dirname, "..");
15
+ const CLI_ENTRY = resolve(PACKAGE_ROOT, "src/cli.ts");
16
+
17
+ let tsxBin;
18
+ try {
19
+ tsxBin = require.resolve("tsx/cli", { paths: [PACKAGE_ROOT] });
20
+ } catch (err) {
21
+ console.error(
22
+ "web-tester: could not locate tsx (web-tester's TypeScript runner).\n" +
23
+ " Reinstall web-tester-for-claude (this should have come with it as a dep).\n" +
24
+ ` Underlying error: ${err && err.message ? err.message : err}`
25
+ );
26
+ process.exit(1);
27
+ }
28
+
29
+ const result = spawnSync(
30
+ process.execPath,
31
+ [tsxBin, CLI_ENTRY, ...process.argv.slice(2)],
32
+ { stdio: "inherit" }
33
+ );
34
+
35
+ process.exit(result.status ?? 1);
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "web-tester-for-claude",
3
+ "version": "0.4.0",
4
+ "description": "Drive your dev site in Playwright, map it, capture console + network + DOM + screenshots + video, write one structured report. Built for AI coding agents and humans.",
5
+ "keywords": [
6
+ "playwright",
7
+ "ai",
8
+ "claude",
9
+ "claude-code",
10
+ "agent",
11
+ "agent-skill",
12
+ "browser-automation",
13
+ "testing",
14
+ "qa",
15
+ "e2e",
16
+ "crawler",
17
+ "sitemap",
18
+ "regression",
19
+ "report"
20
+ ],
21
+ "license": "MIT",
22
+ "author": "Haroon Khan <hkhan6916@gmail.com>",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/hkhan6916/web-tester-for-claude.git"
26
+ },
27
+ "homepage": "https://github.com/hkhan6916/web-tester-for-claude#readme",
28
+ "bugs": "https://github.com/hkhan6916/web-tester-for-claude/issues",
29
+ "type": "commonjs",
30
+ "bin": {
31
+ "web-tester": "./bin/web-tester.js"
32
+ },
33
+ "files": [
34
+ "bin",
35
+ "src",
36
+ "tsconfig.json",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "scripts": {
44
+ "web-tester": "tsx src/cli.ts",
45
+ "init": "tsx src/cli.ts init",
46
+ "map": "tsx src/cli.ts map",
47
+ "inspect": "tsx src/cli.ts inspect",
48
+ "kb": "tsx src/cli.ts kb",
49
+ "tsc": "tsc --noEmit",
50
+ "prepublishOnly": "tsc --noEmit"
51
+ },
52
+ "dependencies": {
53
+ "dotenv": "^16.4.7",
54
+ "playwright": "^1.59.1",
55
+ "tsx": "^4.21.0"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^20.19.41",
59
+ "typescript": "^5.9.3"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }
@@ -0,0 +1,79 @@
1
+ import type { Page } from "playwright";
2
+
3
+ export type UiAttribute = {
4
+ name: string;
5
+ alias: string;
6
+ type: string | null;
7
+ hidden: boolean;
8
+ value: string;
9
+ label: string;
10
+ };
11
+
12
+ /**
13
+ * Best-effort "is the page done rendering?" gate, driven by `data-attr-*`
14
+ * markers on the page.
15
+ *
16
+ * Convention:
17
+ * <element data-attr-name="…" data-attr-selected-label="…" />
18
+ *
19
+ * If your app paints any element with a `data-attr-name` attribute, this
20
+ * step will wait until at least one of them carries a non-empty
21
+ * `data-attr-selected-label` — typically the last attribute to populate
22
+ * when async state has finished arriving. That's a reliable "everything
23
+ * settled" signal that doesn't depend on network idle.
24
+ *
25
+ * Fast-path: if no `data-attr-name` exists on the page within `probeMs`,
26
+ * we return immediately rather than waiting out the full timeout. So
27
+ * `settle` is cheap on pages that don't use the convention at all — it
28
+ * adds ~3 s and moves on.
29
+ *
30
+ * Apps that don't use `data-attr-*` markers should prefer
31
+ * `--step wait:networkidle` over `--step settle`.
32
+ */
33
+ const SETTLE_PROBE_MS = 3_000;
34
+
35
+ export async function waitForAttrsReady(
36
+ page: Page,
37
+ timeoutMs: number
38
+ ): Promise<{ probed: boolean; settled: boolean }> {
39
+ try {
40
+ await page
41
+ .locator("[data-attr-name]")
42
+ .first()
43
+ .waitFor({ state: "attached", timeout: SETTLE_PROBE_MS });
44
+ } catch {
45
+ return { probed: false, settled: false };
46
+ }
47
+ const settled = await page
48
+ .waitForFunction(
49
+ () => {
50
+ const candidates = document.querySelectorAll("[data-attr-name]");
51
+ for (const el of Array.from(candidates)) {
52
+ const label = el.getAttribute("data-attr-selected-label") ?? "";
53
+ if (label.trim().length > 0) return true;
54
+ }
55
+ return false;
56
+ },
57
+ undefined,
58
+ { timeout: timeoutMs, polling: 100 }
59
+ )
60
+ .then(() => true)
61
+ .catch(() => false);
62
+ return { probed: true, settled };
63
+ }
64
+
65
+ export async function readUiAttributes(page: Page): Promise<UiAttribute[]> {
66
+ return page
67
+ .evaluate(() => {
68
+ const elements = document.querySelectorAll<HTMLElement>("[data-attr-name]");
69
+ return Array.from(elements).map((el) => ({
70
+ name: el.getAttribute("data-attr-name") ?? "",
71
+ alias: el.getAttribute("data-attr-alias") ?? "",
72
+ type: el.getAttribute("data-attr-type"),
73
+ hidden: el.getAttribute("data-attr-hidden") === "true",
74
+ value: el.getAttribute("data-attr-selected-value") ?? "",
75
+ label: el.getAttribute("data-attr-selected-label") ?? ""
76
+ }));
77
+ })
78
+ .catch(() => [] as UiAttribute[]);
79
+ }
@@ -0,0 +1,139 @@
1
+ import { existsSync } from "node:fs";
2
+ import {
3
+ type Browser,
4
+ type BrowserContext,
5
+ chromium,
6
+ type Page
7
+ } from "playwright";
8
+ import { ensureWebTesterHome, SESSION_STATE_PATH } from "../util/paths";
9
+
10
+ const DEFAULT_VIEWPORT = { width: 1280, height: 900 };
11
+ const DEFAULT_UA = "Mozilla/5.0 (compatible; web-tester)";
12
+
13
+ export type SessionOptions = {
14
+ baseUrl: string;
15
+ headed?: boolean;
16
+ viewport?: { width: number; height: number };
17
+ userAgent?: string;
18
+ /** Directory to record a video into. If omitted, no recording. */
19
+ videoDir?: string;
20
+ /**
21
+ * When true (the default), `~/.web-tester/session.json` is loaded into
22
+ * the browser context if it exists, so runs against authenticated pages
23
+ * can skip the login flow. Pass `false` to force an anonymous context
24
+ * (e.g. to test the logged-out experience).
25
+ */
26
+ loadStorageState?: boolean;
27
+ };
28
+
29
+ export type Session = {
30
+ browser: Browser;
31
+ context: BrowserContext;
32
+ page: Page;
33
+ baseUrl: string;
34
+ /** True if `SESSION_STATE_PATH` existed and was loaded into this context. */
35
+ storageStateLoaded: boolean;
36
+ /**
37
+ * Persist the current browser context (cookies + localStorage) to
38
+ * `~/.web-tester/session.json` so subsequent runs can skip the login
39
+ * dance. Safe to call multiple times — overwrites in place.
40
+ */
41
+ saveStorageState: () => Promise<void>;
42
+ /** Resolves to the saved video file path after the context is closed. */
43
+ videoPath: () => Promise<string | null>;
44
+ close: () => Promise<void>;
45
+ };
46
+
47
+ /**
48
+ * Hides Next.js dev-mode overlays (portal, toasts, error dialogs) so they
49
+ * don't intercept clicks or appear in screenshots. They use Shadow DOM and
50
+ * survive page CSS `display:none`, so a MutationObserver also removes them.
51
+ * Consent banners and other app-specific widgets are deliberately left alone.
52
+ */
53
+ const NEXTJS_OVERLAY_HIDE_CSS = `
54
+ nextjs-portal,
55
+ [data-nextjs-toast],
56
+ [data-nextjs-dialog-overlay],
57
+ [data-nextjs-dialog] {
58
+ display: none !important;
59
+ visibility: hidden !important;
60
+ pointer-events: none !important;
61
+ opacity: 0 !important;
62
+ }
63
+ html, body { overflow: auto !important; }
64
+ `;
65
+
66
+ export const DEFAULT_SESSION_VIEWPORT = DEFAULT_VIEWPORT;
67
+ export const DEFAULT_SESSION_UA = DEFAULT_UA;
68
+
69
+ export async function configureContext(
70
+ context: BrowserContext,
71
+ _baseUrl: string
72
+ ): Promise<void> {
73
+ // Injected as a content string, not a function: tsx's esbuild compile
74
+ // would otherwise wrap functions with `__name(...)` helpers that don't
75
+ // exist in the browser and surface as a page error on every run.
76
+ const initScript = `
77
+ (function (css) {
78
+ function inject() {
79
+ var style = document.createElement('style');
80
+ style.setAttribute('data-web-tester', 'overlay-suppression');
81
+ style.textContent = css;
82
+ (document.head || document.documentElement).appendChild(style);
83
+ }
84
+ if (document.head) inject();
85
+ else document.addEventListener('DOMContentLoaded', inject, { once: true });
86
+
87
+ function killPortals() {
88
+ var portals = document.querySelectorAll('nextjs-portal, [data-nextjs-toast], [data-nextjs-dialog-overlay]');
89
+ portals.forEach(function (p) { p.remove(); });
90
+ }
91
+ killPortals();
92
+ var observer = new MutationObserver(killPortals);
93
+ function start() {
94
+ if (document.body) observer.observe(document.body, { childList: true, subtree: true });
95
+ }
96
+ if (document.body) start();
97
+ else document.addEventListener('DOMContentLoaded', start, { once: true });
98
+ })(${JSON.stringify(NEXTJS_OVERLAY_HIDE_CSS)});
99
+ `;
100
+ await context.addInitScript({ content: initScript });
101
+ }
102
+
103
+ export async function openSession(opts: SessionOptions): Promise<Session> {
104
+ const baseUrl = opts.baseUrl.replace(/\/$/, "");
105
+ const viewport = opts.viewport ?? DEFAULT_VIEWPORT;
106
+ const browser = await chromium.launch({ headless: !opts.headed });
107
+ const shouldLoadState = opts.loadStorageState !== false;
108
+ const storageStateLoaded = shouldLoadState && existsSync(SESSION_STATE_PATH);
109
+ const context = await browser.newContext({
110
+ viewport,
111
+ userAgent: opts.userAgent ?? DEFAULT_UA,
112
+ recordVideo: opts.videoDir ? { dir: opts.videoDir, size: viewport } : undefined,
113
+ ...(storageStateLoaded ? { storageState: SESSION_STATE_PATH } : {})
114
+ });
115
+ await configureContext(context, baseUrl);
116
+ const page = await context.newPage();
117
+ return {
118
+ browser,
119
+ context,
120
+ page,
121
+ baseUrl,
122
+ storageStateLoaded,
123
+ saveStorageState: async () => {
124
+ ensureWebTesterHome();
125
+ await context.storageState({ path: SESSION_STATE_PATH });
126
+ },
127
+ videoPath: async () => {
128
+ const video = page.video();
129
+ if (!video) return null;
130
+ // Playwright finalises the video file on context close, so callers must
131
+ // call this *after* close(). Resolves to the absolute path on disk.
132
+ return video.path().catch(() => null);
133
+ },
134
+ close: async () => {
135
+ await context.close().catch(() => {});
136
+ await browser.close().catch(() => {});
137
+ }
138
+ };
139
+ }