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.
- package/LICENSE +21 -0
- package/README.md +651 -0
- package/bin/web-tester.js +35 -0
- package/package.json +64 -0
- package/src/browser/attrs.ts +79 -0
- package/src/browser/session.ts +139 -0
- package/src/cli.ts +1488 -0
- package/src/impact.ts +165 -0
- package/src/init.ts +260 -0
- package/src/inspector/capture.ts +293 -0
- package/src/inspector/deep.ts +147 -0
- package/src/inspector/packs.ts +98 -0
- package/src/inspector/report.ts +667 -0
- package/src/inspector/run.ts +544 -0
- package/src/inspector/steps.ts +380 -0
- package/src/inspector/summarise.ts +178 -0
- package/src/inspector/verdict.ts +275 -0
- package/src/journeys.ts +78 -0
- package/src/kb.ts +84 -0
- package/src/map/classify.ts +149 -0
- package/src/map/crawl.ts +394 -0
- package/src/map/generate.ts +253 -0
- package/src/map/report.ts +112 -0
- package/src/map/run.ts +219 -0
- package/src/sitemap.ts +75 -0
- package/src/sweep.ts +476 -0
- package/src/templates/agent-section.md +77 -0
- package/src/templates/dot-web-tester/impact-rules.json +36 -0
- package/src/templates/dot-web-tester/instructions/getting-started.md +62 -0
- package/src/templates/dot-web-tester/instructions/recipes.md +105 -0
- package/src/templates/dot-web-tester/journeys/example-signup.json +17 -0
- package/src/templates/dot-web-tester/urls-smoke.txt +19 -0
- package/src/templates/skill.md +59 -0
- package/src/util/log.ts +26 -0
- package/src/util/paths.ts +141 -0
- package/src/util/prompt.ts +50 -0
- package/tsconfig.json +14 -0
|
@@ -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
|
+
}
|