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,380 @@
1
+ import type { Page } from "playwright";
2
+ import { waitForAttrsReady } from "../browser/attrs";
3
+
4
+ export type WaitTarget =
5
+ | { kind: "loadState"; state: "load" | "domcontentloaded" | "networkidle" }
6
+ | { kind: "ms"; ms: number }
7
+ | { kind: "selector"; selector: string }
8
+ | { kind: "text"; text: string }
9
+ | { kind: "urlStable"; quietMs: number }
10
+ | { kind: "urlContains"; substring: string; timeoutMs: number };
11
+
12
+ export type Step =
13
+ | { kind: "goto"; url: string }
14
+ | { kind: "click"; selector: string }
15
+ | { kind: "fill"; selector: string; value: string }
16
+ | { kind: "reactFill"; selector: string; value: string }
17
+ | { kind: "press"; selector: string; key: string }
18
+ | { kind: "select"; selector: string; value: string }
19
+ | { kind: "hover"; selector: string }
20
+ | { kind: "scroll"; target: "top" | "bottom" | "px"; px?: number }
21
+ | { kind: "wait"; target: WaitTarget }
22
+ | { kind: "settle"; timeoutMs?: number }
23
+ | { kind: "screenshot"; name?: string; fullPage?: boolean }
24
+ | { kind: "eval"; script: string }
25
+ | { kind: "reload" };
26
+
27
+ const LOAD_STATES = new Set(["load", "domcontentloaded", "networkidle"]);
28
+
29
+ /**
30
+ * Split a `<selector>=<value>` step argument on the first `=` that sits
31
+ * outside any `[...]`, `(...)`, or quotes. Attribute selectors like
32
+ * `input[name=email]` and `:has-text("a=b")` keep their inner `=`; only the
33
+ * real separator splits the value off. Returns null when there is no
34
+ * top-level `=` (i.e. no value was supplied).
35
+ */
36
+ function splitSelectorValue(
37
+ arg: string
38
+ ): { selector: string; value: string } | null {
39
+ let depth = 0;
40
+ let quote: string | null = null;
41
+ for (let i = 0; i < arg.length; i++) {
42
+ const c = arg[i];
43
+ if (quote) {
44
+ // A closing quote only counts if preceded by an even number of
45
+ // backslashes (so `\"` stays escaped but `\\"` closes the string).
46
+ if (c === quote) {
47
+ let backslashes = 0;
48
+ for (let j = i - 1; j >= 0 && arg[j] === "\\"; j--) backslashes++;
49
+ if (backslashes % 2 === 0) quote = null;
50
+ }
51
+ continue;
52
+ }
53
+ if (c === '"' || c === "'") quote = c;
54
+ else if (c === "[" || c === "(") depth++;
55
+ else if (c === "]" || c === ")") depth = Math.max(0, depth - 1);
56
+ else if (c === "=" && depth === 0)
57
+ return { selector: arg.slice(0, i), value: arg.slice(i + 1) };
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Parse a `--step <type>:<arg>` shorthand into a typed step.
64
+ *
65
+ * Examples:
66
+ * "settle" → settle
67
+ * "goto:/checkout" → goto
68
+ * "wait:networkidle" → wait loadState
69
+ * "wait:2000" → wait ms
70
+ * "wait:#cta" → wait selector
71
+ * "wait:text=Submit" → wait text
72
+ * "click:button:has-text(\"Submit\")" → click (note: selector may contain `:`)
73
+ * "fill:input[name=email]=user@example.com" → fill (first `=` after selector splits value)
74
+ * "screenshot:after-submit" → screenshot
75
+ * "screenshot" → screenshot anonymous
76
+ */
77
+ export function parseStep(raw: string): Step {
78
+ const trimmed = raw.trim();
79
+ if (!trimmed) throw new Error("empty --step");
80
+
81
+ const colonAt = trimmed.indexOf(":");
82
+ const type = colonAt === -1 ? trimmed : trimmed.slice(0, colonAt);
83
+ const arg = colonAt === -1 ? "" : trimmed.slice(colonAt + 1);
84
+
85
+ switch (type) {
86
+ case "settle": {
87
+ if (!arg) return { kind: "settle" };
88
+ const ms = Number(arg);
89
+ if (!Number.isFinite(ms) || ms <= 0)
90
+ throw new Error("`settle:<ms>` needs a positive integer");
91
+ return { kind: "settle", timeoutMs: ms };
92
+ }
93
+ case "reload":
94
+ return { kind: "reload" };
95
+ case "goto":
96
+ if (!arg) throw new Error("`goto` needs a URL");
97
+ return { kind: "goto", url: arg };
98
+ case "click":
99
+ if (!arg) throw new Error("`click` needs a selector");
100
+ return { kind: "click", selector: arg };
101
+ case "hover":
102
+ if (!arg) throw new Error("`hover` needs a selector");
103
+ return { kind: "hover", selector: arg };
104
+ case "fill": {
105
+ const parts = splitSelectorValue(arg);
106
+ if (!parts) throw new Error("`fill` needs `<selector>=<value>`");
107
+ return { kind: "fill", selector: parts.selector, value: parts.value };
108
+ }
109
+ case "react-fill": {
110
+ const parts = splitSelectorValue(arg);
111
+ if (!parts) throw new Error("`react-fill` needs `<selector>=<value>`");
112
+ return { kind: "reactFill", selector: parts.selector, value: parts.value };
113
+ }
114
+ case "press": {
115
+ const parts = splitSelectorValue(arg);
116
+ if (!parts) throw new Error("`press` needs `<selector>=<key>`");
117
+ return { kind: "press", selector: parts.selector, key: parts.value };
118
+ }
119
+ case "select": {
120
+ const parts = splitSelectorValue(arg);
121
+ if (!parts) throw new Error("`select` needs `<selector>=<value>`");
122
+ return { kind: "select", selector: parts.selector, value: parts.value };
123
+ }
124
+ case "scroll":
125
+ if (arg === "top") return { kind: "scroll", target: "top" };
126
+ if (arg === "bottom") return { kind: "scroll", target: "bottom" };
127
+ if (/^\d+$/.test(arg))
128
+ return { kind: "scroll", target: "px", px: Number(arg) };
129
+ throw new Error(`unknown scroll target: ${arg}`);
130
+ case "wait": {
131
+ if (!arg) throw new Error("`wait` needs a target");
132
+ if (LOAD_STATES.has(arg))
133
+ return {
134
+ kind: "wait",
135
+ target: {
136
+ kind: "loadState",
137
+ state: arg as "load" | "domcontentloaded" | "networkidle"
138
+ }
139
+ };
140
+ if (/^\d+$/.test(arg))
141
+ return { kind: "wait", target: { kind: "ms", ms: Number(arg) } };
142
+ if (arg.startsWith("text="))
143
+ return {
144
+ kind: "wait",
145
+ target: { kind: "text", text: arg.slice("text=".length) }
146
+ };
147
+ if (arg === "url-stable")
148
+ return { kind: "wait", target: { kind: "urlStable", quietMs: 250 } };
149
+ if (arg.startsWith("url-stable=")) {
150
+ const ms = Number(arg.slice("url-stable=".length));
151
+ if (!Number.isFinite(ms) || ms <= 0)
152
+ throw new Error("`wait:url-stable=<ms>` needs a positive integer");
153
+ return { kind: "wait", target: { kind: "urlStable", quietMs: ms } };
154
+ }
155
+ if (arg.startsWith("url-contains:")) {
156
+ const rest = arg.slice("url-contains:".length);
157
+ // Optional trailing `@<timeoutMs>` overrides the 10s default. The
158
+ // separator is `@` (not `=`) so the substring can itself contain `=`
159
+ // — e.g. `wait:url-contains:tab=details@30000`.
160
+ const at = rest.lastIndexOf("@");
161
+ if (at === -1 || !/^\d+$/.test(rest.slice(at + 1)))
162
+ return {
163
+ kind: "wait",
164
+ target: { kind: "urlContains", substring: rest, timeoutMs: 10_000 }
165
+ };
166
+ const timeoutMs = Number(rest.slice(at + 1));
167
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
168
+ throw new Error(
169
+ "`wait:url-contains:<sub>@<ms>` timeout must be a positive integer"
170
+ );
171
+ return {
172
+ kind: "wait",
173
+ target: {
174
+ kind: "urlContains",
175
+ substring: rest.slice(0, at),
176
+ timeoutMs
177
+ }
178
+ };
179
+ }
180
+ return { kind: "wait", target: { kind: "selector", selector: arg } };
181
+ }
182
+ case "screenshot":
183
+ return { kind: "screenshot", name: arg || undefined, fullPage: false };
184
+ case "screenshot-full":
185
+ return { kind: "screenshot", name: arg || undefined, fullPage: true };
186
+ case "eval":
187
+ if (!arg) throw new Error("`eval` needs a JS expression");
188
+ return { kind: "eval", script: arg };
189
+ default:
190
+ throw new Error(
191
+ `unknown step type "${type}". See \`pnpm web-tester help\`.`
192
+ );
193
+ }
194
+ }
195
+
196
+ const DEFAULT_TIMEOUT_MS = Number(process.env.STEP_TIMEOUT_MS ?? 15_000);
197
+ const DEFAULT_SETTLE_MS = Number(process.env.SETTLE_TIMEOUT_MS ?? 30_000);
198
+
199
+ /**
200
+ * Execute a single step against the page. Returns a short label describing
201
+ * what happened and, optionally, an `evalResult` for `eval` steps.
202
+ */
203
+ export async function executeStep(
204
+ step: Step,
205
+ page: Page
206
+ ): Promise<{ label: string; evalResult?: unknown }> {
207
+ switch (step.kind) {
208
+ case "goto": {
209
+ const baseUrl = new URL(page.url()).origin;
210
+ const target = step.url.startsWith("http")
211
+ ? step.url
212
+ : new URL(step.url, baseUrl).toString();
213
+ const response = await page.goto(target, {
214
+ waitUntil: "domcontentloaded",
215
+ timeout: DEFAULT_TIMEOUT_MS
216
+ });
217
+ return { label: `goto ${target} (${response?.status() ?? "?"})` };
218
+ }
219
+ case "reload":
220
+ await page.reload({ waitUntil: "domcontentloaded" });
221
+ return { label: "reload" };
222
+ case "click":
223
+ await page.locator(step.selector).first().click({ timeout: DEFAULT_TIMEOUT_MS });
224
+ return { label: `click ${step.selector}` };
225
+ case "hover":
226
+ await page.locator(step.selector).first().hover({ timeout: DEFAULT_TIMEOUT_MS });
227
+ return { label: `hover ${step.selector}` };
228
+ case "fill":
229
+ await page
230
+ .locator(step.selector)
231
+ .first()
232
+ .fill(step.value, { timeout: DEFAULT_TIMEOUT_MS });
233
+ return { label: `fill ${step.selector} = ${step.value}` };
234
+ case "reactFill": {
235
+ // React controlled inputs reset to their state value when you mutate
236
+ // the DOM value directly, so Playwright's `fill` doesn't stick. Call
237
+ // the native value setter on the prototype, then dispatch input/change
238
+ // so React's synthetic event system picks up the change.
239
+ const result = await page.evaluate(
240
+ ({ selector, value }) => {
241
+ const el = document.querySelector(
242
+ selector
243
+ ) as HTMLInputElement | HTMLTextAreaElement | null;
244
+ if (!el) return { ok: false, reason: `selector not found: ${selector}` };
245
+ const proto =
246
+ el.tagName === "TEXTAREA"
247
+ ? window.HTMLTextAreaElement.prototype
248
+ : window.HTMLInputElement.prototype;
249
+ const desc = Object.getOwnPropertyDescriptor(proto, "value");
250
+ if (!desc?.set)
251
+ return { ok: false, reason: "no value setter on prototype" };
252
+ desc.set.call(el, value);
253
+ el.dispatchEvent(new Event("input", { bubbles: true }));
254
+ el.dispatchEvent(new Event("change", { bubbles: true }));
255
+ el.blur();
256
+ return { ok: true, finalDomValue: el.value };
257
+ },
258
+ { selector: step.selector, value: step.value }
259
+ );
260
+ if (!result.ok)
261
+ throw new Error(`react-fill failed: ${result.reason ?? "unknown"}`);
262
+ return {
263
+ label: `react-fill ${step.selector} = ${step.value}`,
264
+ evalResult: result
265
+ };
266
+ }
267
+ case "press":
268
+ await page
269
+ .locator(step.selector)
270
+ .first()
271
+ .press(step.key, { timeout: DEFAULT_TIMEOUT_MS });
272
+ return { label: `press ${step.key} on ${step.selector}` };
273
+ case "select":
274
+ await page
275
+ .locator(step.selector)
276
+ .first()
277
+ .selectOption(step.value, { timeout: DEFAULT_TIMEOUT_MS });
278
+ return { label: `select ${step.value} in ${step.selector}` };
279
+ case "scroll":
280
+ if (step.target === "top") {
281
+ await page.evaluate(() => window.scrollTo(0, 0));
282
+ return { label: "scroll top" };
283
+ }
284
+ if (step.target === "bottom") {
285
+ await page.evaluate(() =>
286
+ window.scrollTo(0, document.body.scrollHeight)
287
+ );
288
+ return { label: "scroll bottom" };
289
+ }
290
+ await page.evaluate((px) => window.scrollTo(0, px), step.px ?? 0);
291
+ return { label: `scroll ${step.px}px` };
292
+ case "wait": {
293
+ const t = step.target;
294
+ if (t.kind === "loadState") {
295
+ await page.waitForLoadState(t.state, { timeout: DEFAULT_TIMEOUT_MS });
296
+ return { label: `wait load=${t.state}` };
297
+ }
298
+ if (t.kind === "ms") {
299
+ await page.waitForTimeout(t.ms);
300
+ return { label: `wait ${t.ms}ms` };
301
+ }
302
+ if (t.kind === "selector") {
303
+ await page.locator(t.selector).first().waitFor({ timeout: DEFAULT_TIMEOUT_MS });
304
+ return { label: `wait selector ${t.selector}` };
305
+ }
306
+ if (t.kind === "urlStable") {
307
+ // Wait for the URL to change at least once, then hold steady for
308
+ // `quietMs`. Requiring an observed change (not just "stable from the
309
+ // start") avoids a false pass when the action under test hasn't
310
+ // written to the URL yet. Useful after a debounced router.replace.
311
+ const POLL_MS = 50;
312
+ const DEADLINE = Date.now() + DEFAULT_TIMEOUT_MS;
313
+ const initial = page.url();
314
+ let last = initial;
315
+ let stableSince = Date.now();
316
+ let hasChanged = false;
317
+ while (Date.now() < DEADLINE) {
318
+ await page.waitForTimeout(POLL_MS);
319
+ const current = page.url();
320
+ if (current !== last) {
321
+ last = current;
322
+ stableSince = Date.now();
323
+ if (current !== initial) hasChanged = true;
324
+ } else if (
325
+ hasChanged &&
326
+ Date.now() - stableSince >= t.quietMs
327
+ ) {
328
+ return { label: `wait url-stable (${t.quietMs}ms quiet)` };
329
+ }
330
+ }
331
+ throw new Error(
332
+ hasChanged
333
+ ? `wait:url-stable timed out — URL kept changing past ${DEFAULT_TIMEOUT_MS}ms`
334
+ : `wait:url-stable timed out — URL never changed from "${initial}" within ${DEFAULT_TIMEOUT_MS}ms. If the action under test doesn't write to the URL, use \`wait:<ms>\` or \`wait:url-contains:<sub>\` instead.`
335
+ );
336
+ }
337
+ if (t.kind === "urlContains") {
338
+ // Deterministic alternative to url-stable: wait until the URL contains
339
+ // a known substring. Use when an action pushes a specific param or
340
+ // navigates to a known path.
341
+ const POLL_MS = 100;
342
+ const DEADLINE = Date.now() + t.timeoutMs;
343
+ while (Date.now() < DEADLINE) {
344
+ if (page.url().includes(t.substring))
345
+ return {
346
+ label: `wait url-contains "${t.substring}" (${Date.now() - (DEADLINE - t.timeoutMs)}ms)`
347
+ };
348
+ await page.waitForTimeout(POLL_MS);
349
+ }
350
+ throw new Error(
351
+ `wait:url-contains "${t.substring}" timed out after ${t.timeoutMs}ms (final URL: ${page.url()})`
352
+ );
353
+ }
354
+ await page.getByText(t.text).first().waitFor({ timeout: DEFAULT_TIMEOUT_MS });
355
+ return { label: `wait text="${t.text}"` };
356
+ }
357
+ case "settle":
358
+ await waitForAttrsReady(page, step.timeoutMs ?? DEFAULT_SETTLE_MS);
359
+ return {
360
+ label:
361
+ step.timeoutMs !== undefined
362
+ ? `settle (attrs ready, ${step.timeoutMs}ms cap)`
363
+ : "settle (attrs ready)"
364
+ };
365
+ case "screenshot":
366
+ // Screenshot is captured by the runner, which also names it. The runner
367
+ // looks at the step kind directly — we just pass through.
368
+ return {
369
+ label: step.fullPage
370
+ ? `screenshot-full ${step.name ?? ""}`
371
+ : `screenshot ${step.name ?? ""}`
372
+ };
373
+ case "eval": {
374
+ // Pass as a raw expression string so we don't go through esbuild's
375
+ // function compilation (which would inject `__name` helpers).
376
+ const value = await page.evaluate(step.script);
377
+ return { label: `eval ${step.script.slice(0, 40)}`, evalResult: value };
378
+ }
379
+ }
380
+ }
@@ -0,0 +1,178 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { resolve } from "node:path";
5
+ import { log } from "../util/log";
6
+ import type { InspectResult } from "./run";
7
+
8
+ /**
9
+ * Locate the `claude` binary. Order:
10
+ * 1. `which claude` (anything on PATH)
11
+ * 2. VSCode extension bundled binary (anthropic.claude-code-*)
12
+ * 3. Anthropic desktop app (macOS)
13
+ * Returns the absolute path, or null if not found.
14
+ */
15
+ function findClaudeBinary(): string | null {
16
+ const which = spawnSync("which", ["claude"], { encoding: "utf-8" });
17
+ if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
18
+
19
+ const extRoot = resolve(homedir(), ".vscode/extensions");
20
+ if (existsSync(extRoot)) {
21
+ const matches = readdirSync(extRoot)
22
+ .filter((d) => d.startsWith("anthropic.claude-code-"))
23
+ .sort()
24
+ .reverse();
25
+ for (const dir of matches) {
26
+ const candidate = resolve(extRoot, dir, "resources/native-binary/claude");
27
+ if (existsSync(candidate)) return candidate;
28
+ }
29
+ }
30
+
31
+ const desktopCandidate = "/Applications/Claude.app/Contents/Resources/bin/claude";
32
+ if (existsSync(desktopCandidate)) return desktopCandidate;
33
+
34
+ return null;
35
+ }
36
+
37
+ function summariseConsole(result: InspectResult): string {
38
+ const errs = result.console.entries
39
+ .filter((e) => e.type === "error")
40
+ .slice(0, 6)
41
+ .map((e) => ` - ${e.text.split("\n")[0]?.slice(0, 200)}`)
42
+ .join("\n");
43
+ return errs || " (no console errors)";
44
+ }
45
+
46
+ function summariseNetworkFailures(result: InspectResult): string {
47
+ const failed = result.network.entries
48
+ .filter(
49
+ (e) => e.failureText !== null || (e.status !== null && e.status >= 400)
50
+ )
51
+ .slice(0, 6);
52
+ if (!failed.length) return " (no failed/4xx requests)";
53
+ return failed
54
+ .map((e) =>
55
+ e.failureText
56
+ ? ` - ${e.method} ${e.url} :: ${e.failureText}`
57
+ : ` - ${e.status} ${e.method} ${e.url}`
58
+ )
59
+ .join("\n");
60
+ }
61
+
62
+ function summarisePageErrors(result: InspectResult): string {
63
+ if (!result.pageErrors.length) return " (no uncaught JS errors)";
64
+ const grouped = new Map<string, number>();
65
+ for (const e of result.pageErrors)
66
+ grouped.set(e.message.split("\n")[0] ?? e.message, (grouped.get(e.message.split("\n")[0] ?? e.message) ?? 0) + 1);
67
+ return Array.from(grouped.entries())
68
+ .slice(0, 6)
69
+ .map(([msg, count]) => ` - ${msg}${count > 1 ? ` (×${count})` : ""}`)
70
+ .join("\n");
71
+ }
72
+
73
+ function summariseSteps(result: InspectResult): string {
74
+ if (!result.steps.length) return " (no steps — single-page snapshot)";
75
+ return result.steps
76
+ .map((s) => {
77
+ const tag = s.ok ? "✓" : "✗";
78
+ const evalStr =
79
+ s.evalResult !== undefined
80
+ ? ` -> ${JSON.stringify(s.evalResult).slice(0, 120)}`
81
+ : "";
82
+ return ` ${tag} ${s.index}. ${s.label} (${s.durationMs}ms)${evalStr}`;
83
+ })
84
+ .join("\n");
85
+ }
86
+
87
+ function summariseAttrs(
88
+ attrs: { name: string; value: string; label: string }[]
89
+ ): string {
90
+ if (!attrs.length) return "(none captured)";
91
+ return attrs
92
+ .slice(0, 20)
93
+ .map((a) => `${a.name}=${a.label || a.value}`)
94
+ .join(", ");
95
+ }
96
+
97
+ function buildPrompt(result: InspectResult): string {
98
+ return `You are summarising the result of a web-tester run against the developer's web app for a developer who is about to open the HTML report. Be concise and useful.
99
+
100
+ # Run context
101
+ - URL: ${result.requestedUrl}
102
+ - Final URL: ${result.finalUrl}
103
+ - Page title: ${result.title || "(unknown)"}
104
+ - Duration: ${result.durationMs}ms
105
+ - Verdict: ${result.ok ? "all steps executed" : `${result.failedSteps} step(s) failed`}
106
+
107
+ # Steps (✓=ok, ✗=error during step)
108
+ ${summariseSteps(result)}
109
+
110
+ # Page errors (uncaught JS)
111
+ ${summarisePageErrors(result)}
112
+
113
+ # Console errors
114
+ ${summariseConsole(result)}
115
+
116
+ # Failed / 4xx network requests
117
+ ${summariseNetworkFailures(result)}
118
+
119
+ # Final on-page attrs
120
+ ${summariseAttrs(result.final.attrs)}
121
+
122
+ ---
123
+
124
+ Write a short summary aimed at a developer scanning the report. Format:
125
+
126
+ **TL;DR:** one sentence — what the run did and whether it looks healthy.
127
+
128
+ **Notable findings:** 2–4 bullets, each one short line. Focus on signal: real failures, surprising state, unexpected URLs, missing attrs, suspicious network calls. Skip hydration warnings unless they are the only issue. Skip generic noise.
129
+
130
+ **Suggested next look:** 1 bullet if there is anything specific in the report worth zooming into; omit otherwise.
131
+
132
+ Output only the summary in markdown — no preamble, no closing remarks, no headers other than the three bolded labels above.`;
133
+ }
134
+
135
+ const SUMMARY_TIMEOUT_MS = 60_000;
136
+
137
+ /**
138
+ * Generate a short Sonnet-written summary of the run. Returns null if the
139
+ * claude CLI isn't available, errors out, or times out — callers should treat
140
+ * a null as "no summary, render the report without one".
141
+ */
142
+ export async function summariseRun(
143
+ result: InspectResult,
144
+ opts: { enabled: boolean }
145
+ ): Promise<string | null> {
146
+ if (!opts.enabled) return null;
147
+ const bin = findClaudeBinary();
148
+ if (!bin) {
149
+ log.dim(" summary: claude CLI not found, skipping");
150
+ return null;
151
+ }
152
+
153
+ log.dim(" summary: asking sonnet to summarise…");
154
+ const started = Date.now();
155
+ const prompt = buildPrompt(result);
156
+ const proc = spawnSync(
157
+ bin,
158
+ ["-p", "--model", "claude-sonnet-4-6", "--output-format", "text"],
159
+ {
160
+ input: prompt,
161
+ encoding: "utf-8",
162
+ timeout: SUMMARY_TIMEOUT_MS,
163
+ maxBuffer: 4 * 1024 * 1024
164
+ }
165
+ );
166
+ if (proc.error) {
167
+ log.dim(` summary: ${proc.error.message}, skipping`);
168
+ return null;
169
+ }
170
+ if (proc.status !== 0) {
171
+ log.dim(` summary: claude exited ${proc.status}, skipping`);
172
+ return null;
173
+ }
174
+ const text = proc.stdout.trim();
175
+ if (!text) return null;
176
+ log.dim(` summary: done in ${Date.now() - started}ms`);
177
+ return text;
178
+ }