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/src/impact.ts ADDED
@@ -0,0 +1,165 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { log } from "./util/log";
4
+ import { userImpactRulesPath } from "./util/paths";
5
+
6
+ /**
7
+ * impact-rules.json schema. Each rule has a name, a list of path globs,
8
+ * and one of {sweep, journey} describing what to run if any glob matches.
9
+ */
10
+ export type SweepDirective = {
11
+ /** Bundled preset name (urls-<name>.txt). */
12
+ preset?: string;
13
+ /** Inline URL list — replaces preset if both set. */
14
+ urls?: string[];
15
+ /** Built-in packs applied to every URL in this sweep. */
16
+ packs?: string[];
17
+ };
18
+
19
+ export type ImpactRule = {
20
+ name: string;
21
+ when_changed_any: string[];
22
+ /** Mutually exclusive — exactly one of sweep / journey. */
23
+ sweep?: SweepDirective;
24
+ journey?: string;
25
+ };
26
+
27
+ export type ImpactRulesFile = {
28
+ rules: ImpactRule[];
29
+ /** Anything else (e.g. `$comment`) is ignored. */
30
+ [k: string]: unknown;
31
+ };
32
+
33
+ /** Default rules location for the current cwd. Lazy so the cwd is honoured per-call. */
34
+ export function defaultImpactRulesPath(): string {
35
+ return userImpactRulesPath();
36
+ }
37
+
38
+ export function loadImpactRules(path: string = defaultImpactRulesPath()): ImpactRule[] {
39
+ if (!existsSync(path))
40
+ throw new Error(
41
+ `impact-rules.json not found at ${path}. Create one at .web-tester/impact-rules.json with at least one rule.`
42
+ );
43
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as ImpactRulesFile;
44
+ if (!Array.isArray(parsed.rules))
45
+ throw new Error(`${path} must have a top-level "rules" array.`);
46
+ for (const r of parsed.rules) {
47
+ if (!r.name || !Array.isArray(r.when_changed_any))
48
+ throw new Error(
49
+ `rule missing required fields: ${JSON.stringify(r).slice(0, 120)}`
50
+ );
51
+ const hasSweep = !!r.sweep;
52
+ const hasJourney = !!r.journey;
53
+ if (!hasSweep && !hasJourney)
54
+ throw new Error(
55
+ `rule "${r.name}" must specify either "sweep" or "journey".`
56
+ );
57
+ if (hasSweep && hasJourney)
58
+ throw new Error(
59
+ `rule "${r.name}" specifies both "sweep" and "journey" — pick one.`
60
+ );
61
+ }
62
+ return parsed.rules;
63
+ }
64
+
65
+ /** Convert a path glob (`**`, `*`) into a regex anchored at both ends. */
66
+ function globToRegex(glob: string): RegExp {
67
+ // Park double-star with a unique multi-char marker so the single-* rewrite
68
+ // below does not claw at it; restore as .* afterwards.
69
+ const escaped = glob
70
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
71
+ .replace(/\*\*/g, "__DOUBLESTAR__")
72
+ .replace(/\*/g, "[^/]*")
73
+ .replaceAll("__DOUBLESTAR__", ".*");
74
+ return new RegExp(`^${escaped}$`);
75
+ }
76
+
77
+ /**
78
+ * Returns the list of files changed between `base` and the current HEAD
79
+ * (including unstaged + uncommitted changes — that's what a developer is
80
+ * about to push). Empty list = no rules match = no impact run.
81
+ */
82
+ export function getChangedFiles(base: string, cwd: string): string[] {
83
+ // Combine three sources: committed diff vs base, staged-but-uncommitted,
84
+ // and unstaged tracked changes. Sorted-unique result. Untracked new files
85
+ // (`?? `) are intentionally not included — they can't have been changed.
86
+ const safeExec = (cmd: string): string => {
87
+ try {
88
+ // Swallow git's stderr too (e.g. "Not a git repository") — a missing
89
+ // ref or non-repo cwd just means "no changed files", not an error worth
90
+ // printing.
91
+ return execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });
92
+ } catch {
93
+ return "";
94
+ }
95
+ };
96
+ const committed = safeExec(`git diff ${base}...HEAD --name-only`)
97
+ .split("\n")
98
+ .filter(Boolean);
99
+ const staged = safeExec(`git diff --cached --name-only`).split("\n").filter(Boolean);
100
+ const unstaged = safeExec(`git diff --name-only`).split("\n").filter(Boolean);
101
+ return Array.from(new Set([...committed, ...staged, ...unstaged])).sort();
102
+ }
103
+
104
+ export type MatchedRule = {
105
+ rule: ImpactRule;
106
+ /** Subset of changed files that triggered this rule (for the plan output). */
107
+ triggers: string[];
108
+ };
109
+
110
+ export function matchRules(
111
+ rules: ImpactRule[],
112
+ changedFiles: string[]
113
+ ): MatchedRule[] {
114
+ const matched: MatchedRule[] = [];
115
+ for (const rule of rules) {
116
+ const triggers: string[] = [];
117
+ for (const glob of rule.when_changed_any) {
118
+ const re = globToRegex(glob);
119
+ for (const file of changedFiles)
120
+ if (re.test(file) && !triggers.includes(file)) triggers.push(file);
121
+ }
122
+ if (triggers.length > 0) matched.push({ rule, triggers });
123
+ }
124
+ return matched;
125
+ }
126
+
127
+ /**
128
+ * Pretty-print the impact plan so the developer can see what's about to
129
+ * happen before any browser launches. Always called before execution.
130
+ */
131
+ export function printPlan(
132
+ matched: MatchedRule[],
133
+ changedFiles: string[],
134
+ base: string
135
+ ): void {
136
+ log.header(
137
+ `impact plan — ${changedFiles.length} changed file(s) vs ${base}`
138
+ );
139
+ if (matched.length === 0) {
140
+ log.dim(
141
+ " no rules matched the changed files. Nothing to run."
142
+ );
143
+ log.dim(
144
+ " (You can still run `web-tester inspect <url>` manually.)"
145
+ );
146
+ return;
147
+ }
148
+ for (const m of matched) {
149
+ log.info(` · ${m.rule.name}`);
150
+ if (m.rule.sweep) {
151
+ const tgt = m.rule.sweep.preset
152
+ ? `preset ${m.rule.sweep.preset}`
153
+ : `${m.rule.sweep.urls?.length ?? 0} URL(s)`;
154
+ const packs = m.rule.sweep.packs
155
+ ? ` + packs [${m.rule.sweep.packs.join(", ")}]`
156
+ : "";
157
+ log.dim(` → sweep (${tgt}${packs})`);
158
+ }
159
+ if (m.rule.journey) log.dim(` → journey ${m.rule.journey}`);
160
+ log.dim(
161
+ ` triggered by: ${m.triggers.slice(0, 3).join(", ")}${m.triggers.length > 3 ? `, +${m.triggers.length - 3} more` : ""}`
162
+ );
163
+ }
164
+ log.info("");
165
+ }
package/src/init.ts ADDED
@@ -0,0 +1,260 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ statSync,
7
+ writeFileSync
8
+ } from "node:fs";
9
+ import { dirname, relative, resolve } from "node:path";
10
+ import {
11
+ claudeSettingsLocalPath,
12
+ claudeSkillPath,
13
+ projectConfigPath,
14
+ TEMPLATES_DIR,
15
+ USER_CONFIG_DIRNAME
16
+ } from "./util/paths";
17
+
18
+ export type AutoUse = "on" | "ask" | "off";
19
+
20
+ export type InitOptions = {
21
+ cwd: string;
22
+ /** Overwrite files that already exist (default: skip them). */
23
+ force: boolean;
24
+ /**
25
+ * Agent-instructions file to update (CLAUDE.md, AGENTS.md, …). When unset,
26
+ * an existing CLAUDE.md or AGENTS.md is reused; otherwise CLAUDE.md is
27
+ * created. Pass `null` to skip the agent file entirely.
28
+ */
29
+ agentFile: string | null | undefined;
30
+ /** Base URL to persist to `.web-tester/config.json`. Skipped when undefined. */
31
+ baseUrl?: string;
32
+ /** Auto-use preference written to `.claude/settings.local.json`. */
33
+ autoUse?: AutoUse;
34
+ /** Generate `.claude/skills/web-tester/SKILL.md` (default true). */
35
+ skill?: boolean;
36
+ };
37
+
38
+ export type InitResult = {
39
+ /** Files created or overwritten, relative to cwd. */
40
+ written: string[];
41
+ /** Files left untouched because they already existed (no --force). */
42
+ skipped: string[];
43
+ /** Path to the agent file that was written, relative to cwd, if any. */
44
+ agentFile?: string;
45
+ /** Whether the agent block was newly added (false = updated in place). */
46
+ agentAdded?: boolean;
47
+ /** Path to the generated skill, relative to cwd, if requested. */
48
+ skillFile?: string;
49
+ /** The auto-use value written to settings.local.json, if any. */
50
+ autoUse?: AutoUse;
51
+ /** Non-fatal warnings (e.g. a settings file we couldn't safely merge). */
52
+ warnings: string[];
53
+ };
54
+
55
+ const SECTION_START = "<!-- web-tester:start -->";
56
+ const SECTION_END = "<!-- web-tester:end -->";
57
+
58
+ /** Recursively list files under `dir`, returning paths relative to it. */
59
+ function listFiles(dir: string, base = dir): string[] {
60
+ const out: string[] = [];
61
+ for (const entry of readdirSync(dir)) {
62
+ const abs = resolve(dir, entry);
63
+ if (statSync(abs).isDirectory()) out.push(...listFiles(abs, base));
64
+ else out.push(relative(base, abs));
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Scaffold `.web-tester/`, persist project config, generate the Claude Code
71
+ * skill + agent-instructions block, and set the auto-use preference. Every
72
+ * step is independent and idempotent; existing files are skipped unless
73
+ * `force` is set (settings and config are merged, never clobbered).
74
+ */
75
+ export function runInit(opts: InitOptions): InitResult {
76
+ const written: string[] = [];
77
+ const skipped: string[] = [];
78
+ const warnings: string[] = [];
79
+ const rel = (p: string): string => relative(opts.cwd, p);
80
+
81
+ // 1. Scaffold .web-tester/ from the bundled templates.
82
+ const srcRoot = resolve(TEMPLATES_DIR, "dot-web-tester");
83
+ const destRoot = resolve(opts.cwd, USER_CONFIG_DIRNAME);
84
+ for (const r of listFiles(srcRoot)) {
85
+ const from = resolve(srcRoot, r);
86
+ const to = resolve(destRoot, r);
87
+ if (existsSync(to) && !opts.force) {
88
+ skipped.push(rel(to));
89
+ continue;
90
+ }
91
+ mkdirSync(dirname(to), { recursive: true });
92
+ writeFileSync(to, readFileSync(from));
93
+ written.push(rel(to));
94
+ }
95
+
96
+ // 2. Keep run artifacts out of version control via a scoped .gitignore.
97
+ const gitignorePath = resolve(destRoot, ".gitignore");
98
+ if (!existsSync(gitignorePath)) {
99
+ mkdirSync(destRoot, { recursive: true });
100
+ writeFileSync(gitignorePath, "runs/\n");
101
+ written.push(rel(gitignorePath));
102
+ }
103
+
104
+ // 3. Persist the chosen base URL so commands work without an env var.
105
+ if (opts.baseUrl !== undefined) {
106
+ const cfg = writeProjectConfig(opts.cwd, opts.baseUrl);
107
+ if (cfg.changed) written.push(rel(cfg.path));
108
+ }
109
+
110
+ const result: InitResult = { written, skipped, warnings };
111
+
112
+ // 4. Generate the Claude Code skill (.claude/skills/web-tester/SKILL.md).
113
+ if (opts.skill !== false) {
114
+ const skill = writeSkill(opts.cwd, opts.force);
115
+ result.skillFile = rel(skill.path);
116
+ (skill.written ? written : skipped).push(result.skillFile);
117
+ }
118
+
119
+ // 5. Inject the agent-instructions block into CLAUDE.md / AGENTS.md.
120
+ if (opts.agentFile !== null) {
121
+ const agent = writeAgentSection(opts.cwd, opts.agentFile);
122
+ result.agentFile = rel(agent.path);
123
+ result.agentAdded = agent.added;
124
+ if (agent.added) written.push(result.agentFile);
125
+ }
126
+
127
+ // 6. Record the auto-use preference (merged into settings.local.json).
128
+ if (opts.autoUse !== undefined) {
129
+ const settings = writeAutoUse(opts.cwd, opts.autoUse);
130
+ if (settings.changed) {
131
+ const p = rel(settings.path);
132
+ if (!written.includes(p) && !skipped.includes(p)) written.push(p);
133
+ result.autoUse = opts.autoUse;
134
+ }
135
+ if (settings.warning) warnings.push(settings.warning);
136
+ }
137
+
138
+ return result;
139
+ }
140
+
141
+ /** Merge `baseUrl` into `.web-tester/config.json`, preserving other keys. */
142
+ function writeProjectConfig(
143
+ cwd: string,
144
+ baseUrl: string
145
+ ): { path: string; changed: boolean } {
146
+ const path = projectConfigPath(cwd);
147
+ let config: Record<string, unknown> = {};
148
+ if (existsSync(path)) {
149
+ try {
150
+ config = JSON.parse(readFileSync(path, "utf-8"));
151
+ } catch {
152
+ config = {};
153
+ }
154
+ }
155
+ if (config.baseUrl === baseUrl) return { path, changed: false };
156
+ config.baseUrl = baseUrl;
157
+ mkdirSync(dirname(path), { recursive: true });
158
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
159
+ return { path, changed: true };
160
+ }
161
+
162
+ /** Write the bundled SKILL.md. Won't overwrite an existing skill unless forced. */
163
+ function writeSkill(
164
+ cwd: string,
165
+ force: boolean
166
+ ): { path: string; written: boolean } {
167
+ const path = claudeSkillPath(cwd);
168
+ if (existsSync(path) && !force) return { path, written: false };
169
+ const content = readFileSync(resolve(TEMPLATES_DIR, "skill.md"), "utf-8");
170
+ mkdirSync(dirname(path), { recursive: true });
171
+ writeFileSync(path, content);
172
+ return { path, written: true };
173
+ }
174
+
175
+ /**
176
+ * Merge `env.WEB_TESTER_AUTO_USE` into `.claude/settings.local.json`. Never
177
+ * clobbers: if the file exists but isn't valid JSON, we leave it alone and
178
+ * return a warning rather than risk destroying a developer's settings.
179
+ */
180
+ function writeAutoUse(
181
+ cwd: string,
182
+ value: AutoUse
183
+ ): { path: string; changed: boolean; warning?: string } {
184
+ const path = claudeSettingsLocalPath(cwd);
185
+ let settings: { env?: Record<string, unknown>; [k: string]: unknown } = {};
186
+ if (existsSync(path)) {
187
+ try {
188
+ settings = JSON.parse(readFileSync(path, "utf-8"));
189
+ } catch {
190
+ return {
191
+ path,
192
+ changed: false,
193
+ warning: `left ${relative(cwd, path)} untouched — it isn't valid JSON. Add \`"env": { "WEB_TESTER_AUTO_USE": "${value}" }\` yourself.`
194
+ };
195
+ }
196
+ }
197
+ if (settings.env?.WEB_TESTER_AUTO_USE === value) return { path, changed: false };
198
+ settings.env = { ...(settings.env ?? {}), WEB_TESTER_AUTO_USE: value };
199
+ mkdirSync(dirname(path), { recursive: true });
200
+ writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`);
201
+ return { path, changed: true };
202
+ }
203
+
204
+ /** Resolve which agent file to write, honouring an explicit choice. */
205
+ function resolveAgentFile(cwd: string, explicit: string | undefined): string {
206
+ if (explicit) return resolve(cwd, explicit);
207
+ for (const name of ["CLAUDE.md", "AGENTS.md"]) {
208
+ const candidate = resolve(cwd, name);
209
+ if (existsSync(candidate)) return candidate;
210
+ }
211
+ return resolve(cwd, "CLAUDE.md");
212
+ }
213
+
214
+ /**
215
+ * Insert or refresh the marker-fenced web-tester block in the agent file.
216
+ * Idempotent: re-running replaces the block in place rather than duplicating.
217
+ * Returns `added: true` when the block was newly inserted.
218
+ */
219
+ function writeAgentSection(
220
+ cwd: string,
221
+ explicit: string | undefined
222
+ ): { path: string; added: boolean } {
223
+ const path = resolveAgentFile(cwd, explicit);
224
+ const section = readFileSync(
225
+ resolve(TEMPLATES_DIR, "agent-section.md"),
226
+ "utf-8"
227
+ ).trim();
228
+ const block = `${SECTION_START}\n\n${section}\n\n${SECTION_END}`;
229
+
230
+ mkdirSync(dirname(path), { recursive: true });
231
+
232
+ if (!existsSync(path)) {
233
+ writeFileSync(path, `# Agent instructions\n\n${block}\n`);
234
+ return { path, added: true };
235
+ }
236
+
237
+ const current = readFileSync(path, "utf-8");
238
+ const start = current.indexOf(SECTION_START);
239
+ const end = current.indexOf(SECTION_END);
240
+ if (start !== -1 && end !== -1 && end > start) {
241
+ const before = current.slice(0, start);
242
+ const after = current.slice(end + SECTION_END.length);
243
+ writeFileSync(path, `${before}${block}${after}`);
244
+ return { path, added: false };
245
+ }
246
+ // Exactly one marker, or end-before-start: the block is corrupted. Appending
247
+ // would duplicate it (and again on every future run), so stop and let the
248
+ // user fix it rather than silently make a mess.
249
+ if (start !== -1 || end !== -1) {
250
+ throw new Error(
251
+ `${path} has a malformed web-tester block (a "${SECTION_START}" / ` +
252
+ `"${SECTION_END}" marker is missing or out of order). Fix or remove ` +
253
+ `the stray marker and re-run, or pass --no-agent to skip the agent file.`
254
+ );
255
+ }
256
+
257
+ const sep = current.endsWith("\n") ? "\n" : "\n\n";
258
+ writeFileSync(path, `${current}${sep}${block}\n`);
259
+ return { path, added: true };
260
+ }