@canaryai/cli 0.2.0 → 0.2.2

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.
Files changed (39) hide show
  1. package/dist/{chunk-2T64Z2NI.js → chunk-7R4YFGP6.js} +53 -2
  2. package/dist/chunk-7R4YFGP6.js.map +1 -0
  3. package/dist/chunk-DXJNFJ3A.js +64 -0
  4. package/dist/chunk-DXJNFJ3A.js.map +1 -0
  5. package/dist/{chunk-V7U52ISX.js → chunk-HOYYXZPV.js} +136 -131
  6. package/dist/chunk-HOYYXZPV.js.map +1 -0
  7. package/dist/{chunk-ROTCL5WO.js → chunk-TO66FC4R.js} +688 -479
  8. package/dist/chunk-TO66FC4R.js.map +1 -0
  9. package/dist/debug-workflow-EHKNO7BJ.js +240 -0
  10. package/dist/debug-workflow-EHKNO7BJ.js.map +1 -0
  11. package/dist/{feature-flag-ESPSOSKG.js → feature-flag-ZDLDYRSF.js} +15 -92
  12. package/dist/feature-flag-ZDLDYRSF.js.map +1 -0
  13. package/dist/index.js +50 -66
  14. package/dist/index.js.map +1 -1
  15. package/dist/issues-FI3RIWGV.js +362 -0
  16. package/dist/issues-FI3RIWGV.js.map +1 -0
  17. package/dist/{knobs-HKONHY55.js → knobs-3MKMOXIV.js} +19 -104
  18. package/dist/knobs-3MKMOXIV.js.map +1 -0
  19. package/dist/{local-browser-MKKPBTYI.js → local-browser-GG5GUXDS.js} +2 -2
  20. package/dist/{mcp-4F4HI7L2.js → mcp-AD67OLQM.js} +3 -3
  21. package/dist/{psql-6IFVXM3A.js → psql-IVAPNYZV.js} +2 -2
  22. package/dist/{redis-HZC32IEO.js → redis-LWY7L6AS.js} +2 -2
  23. package/dist/{release-WOD3DAX4.js → release-KQFCTAXA.js} +5 -35
  24. package/dist/release-KQFCTAXA.js.map +1 -0
  25. package/dist/runner/preload.js +7 -323
  26. package/dist/runner/preload.js.map +1 -1
  27. package/dist/test.js +5 -340
  28. package/dist/test.js.map +1 -1
  29. package/package.json +1 -1
  30. package/dist/chunk-2T64Z2NI.js.map +0 -1
  31. package/dist/chunk-ROTCL5WO.js.map +0 -1
  32. package/dist/chunk-V7U52ISX.js.map +0 -1
  33. package/dist/feature-flag-ESPSOSKG.js.map +0 -1
  34. package/dist/knobs-HKONHY55.js.map +0 -1
  35. package/dist/release-WOD3DAX4.js.map +0 -1
  36. /package/dist/{local-browser-MKKPBTYI.js.map → local-browser-GG5GUXDS.js.map} +0 -0
  37. /package/dist/{mcp-4F4HI7L2.js.map → mcp-AD67OLQM.js.map} +0 -0
  38. /package/dist/{psql-6IFVXM3A.js.map → psql-IVAPNYZV.js.map} +0 -0
  39. /package/dist/{redis-HZC32IEO.js.map → redis-LWY7L6AS.js.map} +0 -0
@@ -1,3 +1,7 @@
1
+ import {
2
+ __require
3
+ } from "./chunk-DGUM43GV.js";
4
+
1
5
  // src/runner/config.ts
2
6
  import path2 from "path";
3
7
 
@@ -122,9 +126,12 @@ function appendEvent(event) {
122
126
  }
123
127
  }
124
128
 
129
+ // src/runner/healing-helpers.ts
130
+ import { createRequire } from "module";
131
+ import fs3 from "fs";
132
+
125
133
  // src/runner/healer.ts
126
- import { stepCountIs, streamText, tool } from "ai";
127
- import { z } from "zod";
134
+ import { stepCountIs, streamText } from "ai";
128
135
 
129
136
  // src/runner/ai-client.ts
130
137
  import { createOpenAI } from "@ai-sdk/openai";
@@ -171,441 +178,236 @@ function resolveHealerModel(config = loadCanaryConfig()) {
171
178
  }
172
179
  }
173
180
 
174
- // src/runner/healer.ts
175
- var REDACTION_PATTERNS = [
176
- { regex: /bearer\s+[a-z0-9._-]+/gi, replacement: "[REDACTED_BEARER]" },
177
- { regex: /api[_-]?key[:\s"']+[a-z0-9._-]+/gi, replacement: "[REDACTED_API_KEY]" },
178
- { regex: /secret[:\s"']+[a-z0-9._-]+/gi, replacement: "[REDACTED_SECRET]" },
179
- { regex: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, replacement: "[REDACTED_EMAIL]" },
180
- { regex: /[0-9]{12,}/g, replacement: "[REDACTED_NUMBER]" }
181
- ];
182
- var CODE_DENY_PATTERNS = [/process\.env/i, /child_process/i, /\brequire\s*\(/i, /\bimport\s*\(/i];
183
- function classifyFailure(context) {
184
- const message = (context.errorMessage ?? "").toLowerCase();
185
- if (message.includes("closed") || message.includes("target page, context or browser has been closed")) {
186
- return { healable: false, reason: "context_closed" };
187
- }
188
- if (message.includes("navigation failed") && message.includes("net::")) {
189
- return { healable: false, reason: "navigation_failed" };
190
- }
191
- return { healable: true, reason: "agent_healing" };
181
+ // src/runner/healer-tools.ts
182
+ import { tool } from "ai";
183
+ import { z } from "zod";
184
+ function truncate(value, max) {
185
+ if (value.length <= max) return value;
186
+ return value.slice(0, max) + "...";
192
187
  }
193
- async function executeHealActions(decision, failure, execCtx) {
194
- const config = loadCanaryConfig();
195
- const started = Date.now();
196
- const mode = resolveMode(config);
197
- const actionsRun = [];
198
- const errors = [];
199
- const logTools = config.debug || process.env.CANARY_TOOL_LOG === "1";
200
- const initialContext = await buildInitialPageContext(execCtx.page, config);
201
- if (!decision.healable) {
202
- return baseOutcome({
203
- mode,
204
- started,
205
- healed: false,
206
- shouldRetryOriginal: false,
207
- reason: decision.reason,
208
- actionsRun,
209
- errors
210
- });
211
- }
212
- const { model, modelId, reason: modelReason } = resolveHealerModel(config);
213
- if (!model) {
214
- return baseOutcome({
215
- mode,
216
- started,
217
- healed: false,
218
- shouldRetryOriginal: false,
219
- reason: modelReason ?? "no_model",
220
- actionsRun,
221
- errors
222
- });
188
+ function safeJson(value) {
189
+ try {
190
+ return JSON.stringify(value);
191
+ } catch {
192
+ return String(value);
223
193
  }
224
- const testContext = {
225
- testFile: execCtx.testContext?.testFile,
226
- testTitle: execCtx.testContext?.testTitle,
227
- testSource: execCtx.testContext?.testSource ? sanitizeString(execCtx.testContext.testSource) : void 0,
228
- currentStep: `${failure.action ?? "unknown"}(${failure.target ?? "unknown"})`,
229
- expectedAfter: execCtx.testContext?.expectedAfter,
230
- action: failure.action ?? "unknown",
231
- target: failure.target,
232
- errorMessage: failure.errorMessage
194
+ }
195
+ function errMsg(err) {
196
+ return err instanceof Error ? err.message : String(err);
197
+ }
198
+ function makePageTool(ctx, opts) {
199
+ const { page, config, actionsRun, debug } = ctx;
200
+ const mutates = opts.mutates ?? true;
201
+ const log = (msg) => {
202
+ if (debug) {
203
+ console.log(`[canary][debug] ${msg}`);
204
+ }
233
205
  };
234
- const toolset = createAgenticTools({
235
- page: execCtx.page,
236
- config,
237
- actionsRun,
238
- debug: config.debug
239
- });
240
- let completionReason;
241
- try {
242
- const abort = AbortSignal.timeout(config.healTimeoutMs);
243
- const result = await streamText({
244
- model,
245
- system: buildSystemPrompt(testContext, config),
246
- messages: buildInitialMessages(testContext, failure, initialContext),
247
- tools: toolset.tools,
248
- stopWhen: stepCountIs(Math.max(1, config.maxActions)),
249
- abortSignal: abort,
250
- maxRetries: 0,
251
- onStepFinish: (step) => {
252
- if (config.debug) {
253
- console.log(
254
- `[canary][debug] step finish: finishReason=${step.finishReason}; toolCalls=${step.toolCalls?.length ?? 0}; toolResults=${step.toolResults?.length ?? 0}`
255
- );
256
- }
257
- if (logTools) {
258
- logToolStep(step);
259
- }
260
- }
261
- });
262
- for await (const _ of result.fullStream) {
263
- if (toolset.isComplete()) {
264
- completionReason = toolset.getCompletionReason();
265
- if (config.debug) {
266
- console.log(`[canary][debug] agent marked complete: ${completionReason}`);
267
- }
268
- break;
206
+ return tool({
207
+ description: opts.description,
208
+ inputSchema: opts.inputSchema,
209
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
210
+ execute: async (input) => {
211
+ const trackSuffix = input?.text ?? input?.selector ?? input?.name ?? input?.key ?? input?.url ?? input?.ms ?? "";
212
+ actionsRun.push(trackSuffix ? `${opts.name}:${trackSuffix}` : opts.name);
213
+ log(`${opts.name} called`);
214
+ if (mutates && config.readOnly) return { error: "read_only_mode" };
215
+ if (config.dryRun) return opts.dryResult(input);
216
+ if (opts.pageCheck && !page?.[opts.pageCheck]) return { error: "Page unavailable" };
217
+ try {
218
+ const result = await opts.execute(input);
219
+ log(`${opts.name} succeeded`);
220
+ return result;
221
+ } catch (err) {
222
+ const msg = errMsg(err);
223
+ log(`${opts.name} error: ${msg}`);
224
+ return { error: msg };
269
225
  }
270
226
  }
271
- } catch (error) {
272
- const message = error instanceof Error ? error.message : String(error);
273
- errors.push(message);
274
- if (config.debug) {
275
- console.log(`[canary][debug] agent error: ${message}`);
276
- }
277
- }
278
- const healed = toolset.isComplete();
279
- return {
280
- healed,
281
- shouldRetryOriginal: false,
282
- // Agent-controlled completion, no retry needed
283
- actionsRun,
284
- timedOut: Date.now() - started > config.healTimeoutMs,
285
- durationMs: Date.now() - started,
286
- errors,
287
- mode,
288
- reason: completionReason ?? (healed ? "agent_healed" : "agent_incomplete"),
289
- modelId,
290
- summary: toolset.getSummary()
291
- };
227
+ });
292
228
  }
293
229
  function createAgenticTools(params) {
294
- const { page, config, actionsRun, debug } = params;
230
+ const ctx = params;
231
+ const { page, config } = ctx;
295
232
  let complete = false;
296
233
  let completionReason;
297
234
  let completionSummary;
298
235
  const maxPayloadBytes = Math.max(1e3, config.maxPayloadBytes || 6e4);
299
- const log = (msg) => {
300
- if (debug) {
301
- console.log(`[canary][debug] ${msg}`);
302
- }
303
- };
304
- const track = (action) => {
305
- actionsRun.push(action);
306
- };
307
236
  const tools = {
308
237
  // === Core Action Tools ===
309
- click: tool({
238
+ click: makePageTool(ctx, {
239
+ name: "click",
310
240
  description: "Click an element by its visible text",
311
241
  inputSchema: z.object({ text: z.string().describe("The visible text to click") }),
242
+ pageCheck: "getByText",
243
+ dryResult: ({ text }) => ({ clicked: text, mode: "dry-run" }),
312
244
  execute: async ({ text }) => {
313
- track(`click:${text}`);
314
- log(`click called with text="${text}"`);
315
- if (config.readOnly) return { clicked: "", error: "read_only_mode" };
316
- if (config.dryRun) return { clicked: text, mode: "dry-run" };
317
- if (!page?.getByText) return { clicked: "", error: "Page unavailable" };
318
- try {
319
- const el = page.getByText(text);
320
- if (el?.scrollIntoViewIfNeeded) {
321
- await el.scrollIntoViewIfNeeded({ timeout: 2e3 });
322
- }
323
- const clickable = el;
324
- if (clickable?.click) {
325
- await clickable.click({ timeout: 5e3 });
326
- }
327
- log(`click succeeded for "${text}"`);
328
- return { clicked: text };
329
- } catch (err) {
330
- const msg = err instanceof Error ? err.message : String(err);
331
- log(`click error: ${msg}`);
332
- return { clicked: "", error: msg };
333
- }
245
+ const el = page.getByText(text);
246
+ if (el?.scrollIntoViewIfNeeded) await el.scrollIntoViewIfNeeded({ timeout: 2e3 });
247
+ const clickable = el;
248
+ if (clickable?.click) await clickable.click({ timeout: 5e3 });
249
+ return { clicked: text };
334
250
  }
335
251
  }),
336
- click_selector: tool({
252
+ click_selector: makePageTool(ctx, {
253
+ name: "click_selector",
337
254
  description: "Click an element by CSS or Playwright selector",
338
255
  inputSchema: z.object({ selector: z.string().describe("CSS or Playwright selector") }),
256
+ pageCheck: "locator",
257
+ dryResult: ({ selector }) => ({ clicked: selector, mode: "dry-run" }),
339
258
  execute: async ({ selector }) => {
340
- track(`click_selector:${selector}`);
341
- log(`click_selector called with selector="${selector}"`);
342
- if (config.readOnly) return { clicked: "", error: "read_only_mode" };
343
- if (config.dryRun) return { clicked: selector, mode: "dry-run" };
344
- if (!page?.locator) return { clicked: "", error: "Page unavailable" };
345
- try {
346
- const el = page.locator(selector);
347
- if (el?.scrollIntoViewIfNeeded) {
348
- await el.scrollIntoViewIfNeeded({ timeout: 2e3 });
349
- }
350
- if (el?.click) {
351
- await el.click({ timeout: 5e3 });
352
- }
353
- log(`click_selector succeeded for "${selector}"`);
354
- return { clicked: selector };
355
- } catch (err) {
356
- const msg = err instanceof Error ? err.message : String(err);
357
- log(`click_selector error: ${msg}`);
358
- return { clicked: "", error: msg };
359
- }
259
+ const el = page.locator(selector);
260
+ if (el?.scrollIntoViewIfNeeded) await el.scrollIntoViewIfNeeded({ timeout: 2e3 });
261
+ if (el?.click) await el.click({ timeout: 5e3 });
262
+ return { clicked: selector };
360
263
  }
361
264
  }),
362
- fill: tool({
265
+ fill: makePageTool(ctx, {
266
+ name: "fill",
363
267
  description: "Fill an input field with a value using CSS selector (clears existing content)",
364
268
  inputSchema: z.object({
365
269
  selector: z.string().describe('CSS selector for the input (e.g. input[placeholder="..."])'),
366
270
  value: z.string().describe("Value to fill")
367
271
  }),
272
+ pageCheck: "locator",
273
+ dryResult: ({ selector }) => ({ filled: selector, mode: "dry-run" }),
368
274
  execute: async ({ selector, value }) => {
369
- track(`fill:${selector}`);
370
- log(`fill called with selector="${selector}" value="${value}"`);
371
- if (config.readOnly) return { filled: "", error: "read_only_mode" };
372
- if (config.dryRun) return { filled: selector, mode: "dry-run" };
373
- if (!page?.locator) return { filled: "", error: "Page unavailable" };
374
- try {
375
- const el = page.locator(selector);
376
- if (el?.fill) {
377
- await el.fill(value, { timeout: 5e3 });
378
- }
379
- log(`fill succeeded`);
380
- return { filled: selector, value };
381
- } catch (err) {
382
- const msg = err instanceof Error ? err.message : String(err);
383
- log(`fill error: ${msg}`);
384
- return { filled: "", error: msg };
385
- }
275
+ const el = page.locator(selector);
276
+ if (el?.fill) await el.fill(value, { timeout: 5e3 });
277
+ return { filled: selector, value };
386
278
  }
387
279
  }),
388
- fill_by_name: tool({
280
+ fill_by_name: makePageTool(ctx, {
281
+ name: "fill_by_name",
389
282
  description: 'Fill a textbox by its accessible name (shown in snapshot as textbox "NAME"). This is the PREFERRED way to fill inputs - use the name from the snapshot directly.',
390
283
  inputSchema: z.object({
391
284
  name: z.string().describe('The accessible name of the textbox (from snapshot, e.g. "Enter 6-digit code")'),
392
285
  value: z.string().describe("Value to fill")
393
286
  }),
287
+ pageCheck: "locator",
288
+ dryResult: ({ name }) => ({ filled: name, mode: "dry-run" }),
394
289
  execute: async ({ name, value }) => {
395
- track(`fill_by_name:${name}`);
396
- log(`fill_by_name called with name="${name}" value="${value}"`);
397
- if (config.readOnly) return { filled: "", error: "read_only_mode" };
398
- if (config.dryRun) return { filled: name, mode: "dry-run" };
399
- if (!page?.locator) return { filled: "", error: "Page unavailable" };
400
- try {
401
- const el = page.locator(`role=textbox[name="${name}"]`);
402
- if (el?.fill) {
403
- await el.fill(value, { timeout: 5e3 });
404
- }
405
- log(`fill_by_name succeeded`);
406
- return { filled: name, value };
407
- } catch (err) {
408
- const msg = err instanceof Error ? err.message : String(err);
409
- log(`fill_by_name error: ${msg}`);
410
- return { filled: "", error: msg };
411
- }
290
+ const el = page.locator(`role=textbox[name="${name}"]`);
291
+ if (el?.fill) await el.fill(value, { timeout: 5e3 });
292
+ return { filled: name, value };
412
293
  }
413
294
  }),
414
- type: tool({
295
+ type: makePageTool(ctx, {
296
+ name: "type",
415
297
  description: "Type text character by character (triggers key events)",
416
298
  inputSchema: z.object({
417
299
  selector: z.string().describe("CSS or Playwright selector"),
418
300
  text: z.string().describe("Text to type")
419
301
  }),
302
+ pageCheck: "locator",
303
+ dryResult: ({ text }) => ({ typed: text, mode: "dry-run" }),
420
304
  execute: async ({ selector, text }) => {
421
- track(`type:${selector}`);
422
- log(`type called with selector="${selector}" text="${text}"`);
423
- if (config.readOnly) return { typed: "", error: "read_only_mode" };
424
- if (config.dryRun) return { typed: text, mode: "dry-run" };
425
- if (!page?.locator) return { typed: "", error: "Page unavailable" };
426
- try {
427
- const el = page.locator(selector);
428
- if (el?.pressSequentially) {
429
- await el.pressSequentially(text);
430
- }
431
- log(`type succeeded`);
432
- return { typed: text, into: selector };
433
- } catch (err) {
434
- const msg = err instanceof Error ? err.message : String(err);
435
- log(`type error: ${msg}`);
436
- return { typed: "", error: msg };
437
- }
305
+ const el = page.locator(selector);
306
+ if (el?.pressSequentially) await el.pressSequentially(text);
307
+ return { typed: text, into: selector };
438
308
  }
439
309
  }),
440
- press_key: tool({
310
+ press_key: makePageTool(ctx, {
311
+ name: "press_key",
441
312
  description: "Press a keyboard key (Enter, Tab, Escape, ArrowDown, etc.)",
442
313
  inputSchema: z.object({ key: z.string().describe("Key to press") }),
314
+ pageCheck: "keyboard",
315
+ dryResult: ({ key }) => ({ pressed: key, mode: "dry-run" }),
443
316
  execute: async ({ key }) => {
444
- track(`press_key:${key}`);
445
- log(`press_key called with key="${key}"`);
446
- if (config.readOnly) return { pressed: "", error: "read_only_mode" };
447
- if (config.dryRun) return { pressed: key, mode: "dry-run" };
448
- if (!page?.keyboard?.press) return { pressed: "", error: "Page unavailable" };
449
- try {
450
- await page.keyboard.press(key);
451
- log(`press_key succeeded`);
452
- return { pressed: key };
453
- } catch (err) {
454
- const msg = err instanceof Error ? err.message : String(err);
455
- log(`press_key error: ${msg}`);
456
- return { pressed: "", error: msg };
457
- }
317
+ await page.keyboard.press(key);
318
+ return { pressed: key };
458
319
  }
459
320
  }),
460
- hover: tool({
321
+ hover: makePageTool(ctx, {
322
+ name: "hover",
461
323
  description: "Hover over an element",
462
324
  inputSchema: z.object({ selector: z.string().describe("CSS or Playwright selector") }),
325
+ pageCheck: "locator",
326
+ dryResult: ({ selector }) => ({ hovered: selector, mode: "dry-run" }),
463
327
  execute: async ({ selector }) => {
464
- track(`hover:${selector}`);
465
- log(`hover called with selector="${selector}"`);
466
- if (config.readOnly) return { hovered: "", error: "read_only_mode" };
467
- if (config.dryRun) return { hovered: selector, mode: "dry-run" };
468
- if (!page?.locator) return { hovered: "", error: "Page unavailable" };
469
- try {
470
- const el = page.locator(selector);
471
- if (el?.hover) {
472
- await el.hover();
473
- }
474
- log(`hover succeeded`);
475
- return { hovered: selector };
476
- } catch (err) {
477
- const msg = err instanceof Error ? err.message : String(err);
478
- log(`hover error: ${msg}`);
479
- return { hovered: "", error: msg };
480
- }
328
+ const el = page.locator(selector);
329
+ if (el?.hover) await el.hover();
330
+ return { hovered: selector };
481
331
  }
482
332
  }),
483
- select_option: tool({
333
+ select_option: makePageTool(ctx, {
334
+ name: "select_option",
484
335
  description: "Select an option from a dropdown",
485
336
  inputSchema: z.object({
486
337
  selector: z.string().describe("CSS or Playwright selector for the select element"),
487
338
  value: z.string().describe("Value or label to select")
488
339
  }),
340
+ pageCheck: "locator",
341
+ dryResult: ({ value }) => ({ selected: value, mode: "dry-run" }),
489
342
  execute: async ({ selector, value }) => {
490
- track(`select_option:${selector}`);
491
- log(`select_option called with selector="${selector}" value="${value}"`);
492
- if (config.readOnly) return { selected: "", error: "read_only_mode" };
493
- if (config.dryRun) return { selected: value, mode: "dry-run" };
494
- if (!page?.locator) return { selected: "", error: "Page unavailable" };
495
- try {
496
- const el = page.locator(selector);
497
- if (el?.selectOption) {
498
- await el.selectOption(value);
499
- }
500
- log(`select_option succeeded`);
501
- return { selected: value, from: selector };
502
- } catch (err) {
503
- const msg = err instanceof Error ? err.message : String(err);
504
- log(`select_option error: ${msg}`);
505
- return { selected: "", error: msg };
506
- }
343
+ const el = page.locator(selector);
344
+ if (el?.selectOption) await el.selectOption(value);
345
+ return { selected: value, from: selector };
507
346
  }
508
347
  }),
509
348
  // === Navigation & Waiting ===
510
- wait: tool({
349
+ wait: makePageTool(ctx, {
350
+ name: "wait",
511
351
  description: "Wait for a duration in milliseconds",
512
352
  inputSchema: z.object({ ms: z.number().min(100).max(3e4).describe("Milliseconds to wait") }),
353
+ mutates: false,
354
+ dryResult: ({ ms }) => ({ waited: ms, mode: "dry-run" }),
513
355
  execute: async ({ ms }) => {
514
- track(`wait:${ms}`);
515
- log(`wait called with ms=${ms}`);
516
- if (config.dryRun) return { waited: ms, mode: "dry-run" };
517
356
  if (page?.waitForTimeout) {
518
357
  await page.waitForTimeout(ms);
519
358
  } else {
520
359
  await new Promise((resolve) => setTimeout(resolve, ms));
521
360
  }
522
- log(`wait completed`);
523
361
  return { waited: ms };
524
362
  }
525
363
  }),
526
- wait_for_selector: tool({
364
+ wait_for_selector: makePageTool(ctx, {
365
+ name: "wait_for_selector",
527
366
  description: "Wait for an element to reach a state (visible, hidden, attached, detached)",
528
367
  inputSchema: z.object({
529
368
  selector: z.string().describe("CSS or Playwright selector"),
530
369
  state: z.enum(["visible", "hidden", "attached", "detached"]).optional().describe("State to wait for")
531
370
  }),
371
+ mutates: false,
372
+ pageCheck: "locator",
373
+ dryResult: ({ selector }) => ({ found: selector, mode: "dry-run" }),
532
374
  execute: async ({ selector, state }) => {
533
- track(`wait_for_selector:${selector}`);
534
- log(`wait_for_selector called with selector="${selector}" state="${state ?? "visible"}"`);
535
- if (config.dryRun) return { found: selector, mode: "dry-run" };
536
- if (!page?.locator) return { found: "", error: "Page unavailable" };
537
- try {
538
- const el = page.locator(selector);
539
- if (el?.waitFor) {
540
- await el.waitFor({ state: state ?? "visible" });
541
- }
542
- log(`wait_for_selector succeeded`);
543
- return { found: selector, state: state ?? "visible" };
544
- } catch (err) {
545
- const msg = err instanceof Error ? err.message : String(err);
546
- log(`wait_for_selector error: ${msg}`);
547
- return { found: "", error: msg };
548
- }
375
+ const el = page.locator(selector);
376
+ if (el?.waitFor) await el.waitFor({ state: state ?? "visible" });
377
+ return { found: selector, state: state ?? "visible" };
549
378
  }
550
379
  }),
551
- navigate: tool({
380
+ navigate: makePageTool(ctx, {
381
+ name: "navigate",
552
382
  description: "Navigate to a URL",
553
383
  inputSchema: z.object({ url: z.string().describe("URL to navigate to") }),
384
+ pageCheck: "goto",
385
+ dryResult: ({ url }) => ({ navigated: url, mode: "dry-run" }),
554
386
  execute: async ({ url }) => {
555
- track(`navigate:${url}`);
556
- log(`navigate called with url="${url}"`);
557
- if (config.readOnly) return { navigated: "", error: "read_only_mode" };
558
- if (config.dryRun) return { navigated: url, mode: "dry-run" };
559
- if (!page?.goto) return { navigated: "", error: "Page unavailable" };
560
- try {
561
- await page.goto(url);
562
- log(`navigate succeeded`);
563
- return { navigated: url };
564
- } catch (err) {
565
- const msg = err instanceof Error ? err.message : String(err);
566
- log(`navigate error: ${msg}`);
567
- return { navigated: "", error: msg };
568
- }
387
+ await page.goto(url);
388
+ return { navigated: url };
569
389
  }
570
390
  }),
571
- go_back: tool({
391
+ go_back: makePageTool(ctx, {
392
+ name: "go_back",
572
393
  description: "Go back in browser history",
573
394
  inputSchema: z.object({}),
395
+ pageCheck: "goBack",
396
+ dryResult: () => ({ action: "back", mode: "dry-run" }),
574
397
  execute: async () => {
575
- track("go_back");
576
- log(`go_back called`);
577
- if (config.readOnly) return { action: "", error: "read_only_mode" };
578
- if (config.dryRun) return { action: "back", mode: "dry-run" };
579
- if (!page?.goBack) return { action: "", error: "Page unavailable" };
580
- try {
581
- await page.goBack();
582
- log(`go_back succeeded`);
583
- return { action: "back" };
584
- } catch (err) {
585
- const msg = err instanceof Error ? err.message : String(err);
586
- log(`go_back error: ${msg}`);
587
- return { action: "", error: msg };
588
- }
398
+ await page.goBack();
399
+ return { action: "back" };
589
400
  }
590
401
  }),
591
- reload: tool({
402
+ reload: makePageTool(ctx, {
403
+ name: "reload",
592
404
  description: "Reload the current page",
593
405
  inputSchema: z.object({}),
406
+ pageCheck: "reload",
407
+ dryResult: () => ({ action: "reload", mode: "dry-run" }),
594
408
  execute: async () => {
595
- track("reload");
596
- log(`reload called`);
597
- if (config.readOnly) return { action: "", error: "read_only_mode" };
598
- if (config.dryRun) return { action: "reload", mode: "dry-run" };
599
- if (!page?.reload) return { action: "", error: "Page unavailable" };
600
- try {
601
- await page.reload();
602
- log(`reload succeeded`);
603
- return { action: "reload" };
604
- } catch (err) {
605
- const msg = err instanceof Error ? err.message : String(err);
606
- log(`reload error: ${msg}`);
607
- return { action: "", error: msg };
608
- }
409
+ await page.reload();
410
+ return { action: "reload" };
609
411
  }
610
412
  }),
611
413
  // === Inspection Tools ===
@@ -613,8 +415,7 @@ function createAgenticTools(params) {
613
415
  description: "Get the accessibility tree of the current page to understand its structure. ALWAYS call this first!",
614
416
  inputSchema: z.object({}),
615
417
  execute: async () => {
616
- track("snapshot");
617
- log(`snapshot called`);
418
+ params.actionsRun.push("snapshot");
618
419
  if (config.dryRun) return { tree: null, mode: "dry-run" };
619
420
  if (!page) return { error: "Page unavailable" };
620
421
  let tree;
@@ -624,22 +425,20 @@ function createAgenticTools(params) {
624
425
  tree = await page.accessibility.snapshot();
625
426
  }
626
427
  } catch (err) {
627
- log(`accessibility snapshot error: ${err instanceof Error ? err.message : String(err)}`);
428
+ if (params.debug) {
429
+ console.log(`[canary][debug] accessibility snapshot error: ${errMsg(err)}`);
430
+ }
628
431
  }
629
432
  if (!tree && page.evaluate) {
630
433
  try {
631
434
  html = await page.evaluate("document.body.innerHTML");
632
- if (html) {
633
- log(`snapshot using HTML fallback`);
634
- }
635
435
  } catch (err) {
636
- log(`HTML fallback error: ${err instanceof Error ? err.message : String(err)}`);
436
+ if (params.debug) {
437
+ console.log(`[canary][debug] HTML fallback error: ${errMsg(err)}`);
438
+ }
637
439
  }
638
440
  }
639
- if (!tree && !html) {
640
- return { error: "Could not get page content" };
641
- }
642
- log(`snapshot succeeded`);
441
+ if (!tree && !html) return { error: "Could not get page content" };
643
442
  const sanitizedTree = sanitizeUnknown(tree, maxPayloadBytes);
644
443
  const sanitizedHtml = html ? truncate(sanitizeString(html), maxPayloadBytes) : void 0;
645
444
  return { tree: sanitizedTree, html: sanitizedHtml };
@@ -651,8 +450,7 @@ function createAgenticTools(params) {
651
450
  fullPage: z.boolean().optional().describe("Whether to capture the full page")
652
451
  }),
653
452
  execute: async ({ fullPage }) => {
654
- track("screenshot");
655
- log(`screenshot called fullPage=${fullPage}`);
453
+ params.actionsRun.push("screenshot");
656
454
  if (config.dryRun) return { screenshot: void 0, mode: "dry-run" };
657
455
  if (!page?.screenshot) return { error: "Page unavailable" };
658
456
  try {
@@ -662,63 +460,44 @@ function createAgenticTools(params) {
662
460
  if (b.byteLength > maxPayloadBytes || base64.length > maxPayloadBytes) {
663
461
  return { error: "screenshot_too_large" };
664
462
  }
665
- const dataUrl = `data:image/png;base64,${truncate(base64, maxPayloadBytes)}`;
666
- log(`screenshot succeeded`);
667
- return { screenshot: dataUrl };
463
+ return { screenshot: `data:image/png;base64,${truncate(base64, maxPayloadBytes)}` };
668
464
  } catch (err) {
669
- const msg = err instanceof Error ? err.message : String(err);
670
- log(`screenshot error: ${msg}`);
671
- return { error: msg };
465
+ return { error: errMsg(err) };
672
466
  }
673
467
  }
674
468
  }),
675
- get_text: tool({
469
+ get_text: makePageTool(ctx, {
470
+ name: "get_text",
676
471
  description: "Get the text content of an element",
677
472
  inputSchema: z.object({ selector: z.string().describe("CSS or Playwright selector") }),
473
+ mutates: false,
474
+ pageCheck: "locator",
475
+ dryResult: () => ({ text: null, mode: "dry-run" }),
678
476
  execute: async ({ selector }) => {
679
- track(`get_text:${selector}`);
680
- log(`get_text called with selector="${selector}"`);
681
- if (config.dryRun) return { text: null, mode: "dry-run" };
682
- if (!page?.locator) return { error: "Page unavailable" };
683
- try {
684
- const el = page.locator(selector);
685
- const text = el?.textContent ? await el.textContent() : null;
686
- const sanitized = text ? truncate(sanitizeString(text), maxPayloadBytes) : text;
687
- log(`get_text succeeded: "${sanitized}"`);
688
- return { text: sanitized };
689
- } catch (err) {
690
- const msg = err instanceof Error ? err.message : String(err);
691
- log(`get_text error: ${msg}`);
692
- return { error: msg };
693
- }
477
+ const el = page.locator(selector);
478
+ const text = el?.textContent ? await el.textContent() : null;
479
+ const sanitized = text ? truncate(sanitizeString(text), maxPayloadBytes) : text;
480
+ return { text: sanitized };
694
481
  }
695
482
  }),
696
- is_visible: tool({
483
+ is_visible: makePageTool(ctx, {
484
+ name: "is_visible",
697
485
  description: "Check if an element is visible",
698
486
  inputSchema: z.object({ selector: z.string().describe("CSS or Playwright selector") }),
487
+ mutates: false,
488
+ pageCheck: "locator",
489
+ dryResult: () => ({ visible: false, mode: "dry-run" }),
699
490
  execute: async ({ selector }) => {
700
- track(`is_visible:${selector}`);
701
- log(`is_visible called with selector="${selector}"`);
702
- if (config.dryRun) return { visible: false, mode: "dry-run" };
703
- if (!page?.locator) return { error: "Page unavailable" };
704
- try {
705
- const el = page.locator(selector);
706
- const visible = el?.isVisible ? await el.isVisible() : false;
707
- log(`is_visible succeeded: ${visible}`);
708
- return { visible };
709
- } catch (err) {
710
- const msg = err instanceof Error ? err.message : String(err);
711
- log(`is_visible error: ${msg}`);
712
- return { error: msg };
713
- }
491
+ const el = page.locator(selector);
492
+ const visible = el?.isVisible ? await el.isVisible() : false;
493
+ return { visible };
714
494
  }
715
495
  }),
716
496
  evaluate: tool({
717
497
  description: "Run JavaScript in the page context",
718
498
  inputSchema: z.object({ script: z.string().describe("JavaScript code to execute") }),
719
499
  execute: async ({ script }) => {
720
- track("evaluate");
721
- log(`evaluate called`);
500
+ params.actionsRun.push("evaluate");
722
501
  if (!config.allowEvaluate) return { error: "evaluate_disabled" };
723
502
  if (violatesCodeDenylist(script)) return { error: "evaluate_blocked" };
724
503
  if (config.dryRun) return { result: null, mode: "dry-run" };
@@ -726,25 +505,19 @@ function createAgenticTools(params) {
726
505
  try {
727
506
  const scriptSafe = truncate(script, maxPayloadBytes);
728
507
  const result = await page.evaluate(scriptSafe);
729
- log(`evaluate succeeded`);
730
508
  return { result: sanitizeUnknown(result, maxPayloadBytes) };
731
509
  } catch (err) {
732
- const msg = err instanceof Error ? err.message : String(err);
733
- log(`evaluate error: ${msg}`);
734
- return { error: msg };
510
+ return { error: errMsg(err) };
735
511
  }
736
512
  }
737
513
  }),
738
- // === Power Tool: Arbitrary Playwright Code ===
739
514
  run_playwright_code: tool({
740
515
  description: 'Execute arbitrary Playwright code. Has access to `page` object. Use for complex operations not covered by other tools. Example: `await page.getByRole("button", { name: "Submit" }).click();`',
741
516
  inputSchema: z.object({
742
517
  code: z.string().describe("Playwright code to execute (has access to `page` object)")
743
518
  }),
744
519
  execute: async ({ code }) => {
745
- track("run_playwright_code");
746
- log(`run_playwright_code called with code:
747
- ${code}`);
520
+ params.actionsRun.push("run_playwright_code");
748
521
  if (!config.allowRunCode) return { executed: false, error: "run_code_disabled" };
749
522
  if (violatesCodeDenylist(code)) return { executed: false, error: "run_code_blocked" };
750
523
  if (config.dryRun) return { executed: false, mode: "dry-run" };
@@ -753,12 +526,9 @@ ${code}`);
753
526
  const boundedCode = truncate(code, maxPayloadBytes);
754
527
  const fn = new Function("page", `return (async () => { ${boundedCode} })();`);
755
528
  const result = await fn(page);
756
- log(`run_playwright_code succeeded`);
757
529
  return { executed: true, result: sanitizeUnknown(result, maxPayloadBytes) };
758
530
  } catch (err) {
759
- const msg = err instanceof Error ? err.message : String(err);
760
- log(`run_playwright_code error: ${msg}`);
761
- return { executed: false, error: msg };
531
+ return { executed: false, error: errMsg(err) };
762
532
  }
763
533
  }
764
534
  }),
@@ -770,8 +540,7 @@ ${code}`);
770
540
  summary: z.string().describe(`One-line human-readable summary for the test report, e.g. "Clicked 'Send OTP' instead of 'Submit'" or "Filled email field by accessible name"`)
771
541
  }),
772
542
  execute: async ({ reason, summary }) => {
773
- track("mark_complete");
774
- log(`mark_complete called with reason="${reason}" summary="${summary}"`);
543
+ params.actionsRun.push("mark_complete");
775
544
  if (!config.dryRun) {
776
545
  complete = true;
777
546
  completionReason = reason;
@@ -788,6 +557,56 @@ ${code}`);
788
557
  getSummary: () => completionSummary
789
558
  };
790
559
  }
560
+ function logToolStep(step) {
561
+ const color = {
562
+ magenta: "\x1B[35m",
563
+ cyan: "\x1B[36m",
564
+ yellow: "\x1B[33m",
565
+ green: "\x1B[32m",
566
+ reset: "\x1B[0m"
567
+ };
568
+ const calls = step.toolCalls ?? [];
569
+ const results = step.toolResults ?? [];
570
+ if (calls.length === 0 && results.length === 0) {
571
+ console.log(
572
+ `${color.magenta}[canary][tool] step${color.reset} (no tool calls/results; finish=${step.finishReason ?? "unknown"})`
573
+ );
574
+ }
575
+ for (const call of calls) {
576
+ const name = call.toolName ?? "unknown_tool";
577
+ const args = call.args ? truncate(safeJson(call.args), 300) : "";
578
+ console.log(
579
+ `${color.magenta}[canary][tool] call${color.reset} ${color.cyan}${name}${color.reset}${args ? ` args=${args}` : ""}`
580
+ );
581
+ }
582
+ for (const result of results) {
583
+ const name = result.toolName ?? "unknown_tool";
584
+ const res = result.result ? truncate(safeJson(result.result), 300) : void 0;
585
+ const err = result.error ? truncate(safeJson(result.error), 300) : void 0;
586
+ const statusColor = err ? color.yellow : color.green;
587
+ console.log(
588
+ `${color.magenta}[canary][tool] result${color.reset} ${color.cyan}${name}${color.reset}` + (res ? ` ${statusColor}->${color.reset} ${res}` : "") + (err ? ` ${color.yellow}error=${err}${color.reset}` : "")
589
+ );
590
+ }
591
+ }
592
+
593
+ // src/runner/healer-prompt.ts
594
+ function resolveMode(config) {
595
+ if (config.dryRun) return "dry-run";
596
+ if (config.warnOnly) return "warn";
597
+ return "full";
598
+ }
599
+ function truncate2(value, max) {
600
+ if (value.length <= max) return value;
601
+ return value.slice(0, max) + "...";
602
+ }
603
+ function safeJson2(value) {
604
+ try {
605
+ return JSON.stringify(value);
606
+ } catch {
607
+ return String(value);
608
+ }
609
+ }
791
610
  function buildSystemPrompt(testContext, config) {
792
611
  const testInfo = testContext.testFile ? `
793
612
  ## Test Context
@@ -900,63 +719,6 @@ CORRECT:
900
719
 
901
720
  Mode: ${resolveMode(config)}.`;
902
721
  }
903
- function resolveMode(config) {
904
- if (config.dryRun) return "dry-run";
905
- if (config.warnOnly) return "warn";
906
- return "full";
907
- }
908
- function baseOutcome({
909
- started,
910
- healed,
911
- shouldRetryOriginal,
912
- mode,
913
- reason,
914
- actionsRun = [],
915
- errors = []
916
- }) {
917
- return {
918
- healed,
919
- shouldRetryOriginal,
920
- actionsRun,
921
- timedOut: false,
922
- durationMs: Date.now() - started,
923
- errors,
924
- mode,
925
- reason
926
- };
927
- }
928
- function logToolStep(step) {
929
- const color = {
930
- magenta: "\x1B[35m",
931
- cyan: "\x1B[36m",
932
- yellow: "\x1B[33m",
933
- green: "\x1B[32m",
934
- reset: "\x1B[0m"
935
- };
936
- const calls = step.toolCalls ?? [];
937
- const results = step.toolResults ?? [];
938
- if (calls.length === 0 && results.length === 0) {
939
- console.log(
940
- `${color.magenta}[canary][tool] step${color.reset} (no tool calls/results; finish=${step.finishReason ?? "unknown"})`
941
- );
942
- }
943
- for (const call of calls) {
944
- const name = call.toolName ?? "unknown_tool";
945
- const args = call.args ? truncate(safeJson(call.args), 300) : "";
946
- console.log(
947
- `${color.magenta}[canary][tool] call${color.reset} ${color.cyan}${name}${color.reset}${args ? ` args=${args}` : ""}`
948
- );
949
- }
950
- for (const result of results) {
951
- const name = result.toolName ?? "unknown_tool";
952
- const res = result.result ? truncate(safeJson(result.result), 300) : void 0;
953
- const err = result.error ? truncate(safeJson(result.error), 300) : void 0;
954
- const statusColor = err ? color.yellow : color.green;
955
- console.log(
956
- `${color.magenta}[canary][tool] result${color.reset} ${color.cyan}${name}${color.reset}` + (res ? ` ${statusColor}->${color.reset} ${res}` : "") + (err ? ` ${color.yellow}error=${err}${color.reset}` : "")
957
- );
958
- }
959
- }
960
722
  function buildInitialMessages(testContext, failure, initial) {
961
723
  const parts = [];
962
724
  parts.push(
@@ -966,7 +728,7 @@ function buildInitialMessages(testContext, failure, initial) {
966
728
  parts.push(
967
729
  `Full test source (truncated/redacted):
968
730
  \`\`\`typescript
969
- ${truncate(testContext.testSource, 15e3)}
731
+ ${truncate2(testContext.testSource, 15e3)}
970
732
  \`\`\``
971
733
  );
972
734
  }
@@ -975,17 +737,17 @@ ${truncate(testContext.testSource, 15e3)}
975
737
  if (initial.snapshot) {
976
738
  parts.push(`Initial accessibility snapshot (sanitized):
977
739
  \`\`\`json
978
- ${truncate(safeJson(initial.snapshot), 8e3)}
740
+ ${truncate2(safeJson2(initial.snapshot), 8e3)}
979
741
  \`\`\``);
980
742
  }
981
743
  if (initial.html) {
982
744
  parts.push(`HTML fallback (sanitized):
983
745
  \`\`\`html
984
- ${truncate(initial.html, 4e3)}
746
+ ${truncate2(initial.html, 4e3)}
985
747
  \`\`\``);
986
748
  }
987
749
  if (initial.screenshot) {
988
- parts.push(`Screenshot (base64 data URL, truncated): ${truncate(initial.screenshot, 12e3)}`);
750
+ parts.push(`Screenshot (base64 data URL, truncated): ${truncate2(initial.screenshot, 12e3)}`);
989
751
  }
990
752
  parts.push(`Remember: perform only the intended step and mark_complete only when the postcondition for this step is truly met.`);
991
753
  return [{ role: "user", content: parts.join("\n\n") }];
@@ -1007,7 +769,7 @@ async function buildInitialPageContext(page, config) {
1007
769
  try {
1008
770
  const html = await page.evaluate("document.body.innerHTML");
1009
771
  if (html) {
1010
- result.html = truncate(sanitizeString(html), maxPayloadBytes);
772
+ result.html = truncate2(sanitizeString(html), maxPayloadBytes);
1011
773
  }
1012
774
  } catch {
1013
775
  }
@@ -1018,23 +780,133 @@ async function buildInitialPageContext(page, config) {
1018
780
  const b = typeof buffer === "string" ? Buffer.from(buffer) : Buffer.from(buffer);
1019
781
  const base64 = b.toString("base64");
1020
782
  if (b.byteLength <= maxPayloadBytes && base64.length <= maxPayloadBytes) {
1021
- result.screenshot = `data:image/png;base64,${truncate(base64, maxPayloadBytes)}`;
783
+ result.screenshot = `data:image/png;base64,${truncate2(base64, maxPayloadBytes)}`;
1022
784
  }
1023
785
  } catch {
1024
786
  }
1025
787
  }
1026
788
  return result;
1027
789
  }
1028
- function truncate(value, max) {
1029
- if (value.length <= max) return value;
1030
- return value.slice(0, max) + "...";
790
+
791
+ // src/runner/healer.ts
792
+ var REDACTION_PATTERNS = [
793
+ { regex: /bearer\s+[a-z0-9._-]+/gi, replacement: "[REDACTED_BEARER]" },
794
+ { regex: /api[_-]?key[:\s"']+[a-z0-9._-]+/gi, replacement: "[REDACTED_API_KEY]" },
795
+ { regex: /secret[:\s"']+[a-z0-9._-]+/gi, replacement: "[REDACTED_SECRET]" },
796
+ { regex: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, replacement: "[REDACTED_EMAIL]" },
797
+ { regex: /[0-9]{12,}/g, replacement: "[REDACTED_NUMBER]" }
798
+ ];
799
+ var CODE_DENY_PATTERNS = [/process\.env/i, /child_process/i, /\brequire\s*\(/i, /\bimport\s*\(/i];
800
+ function classifyFailure(context) {
801
+ const message = (context.errorMessage ?? "").toLowerCase();
802
+ if (message.includes("closed") || message.includes("target page, context or browser has been closed")) {
803
+ return { healable: false, reason: "context_closed" };
804
+ }
805
+ if (message.includes("navigation failed") && message.includes("net::")) {
806
+ return { healable: false, reason: "navigation_failed" };
807
+ }
808
+ return { healable: true, reason: "agent_healing" };
1031
809
  }
1032
- function safeJson(value) {
810
+ async function executeHealActions(decision, failure, execCtx) {
811
+ const config = loadCanaryConfig();
812
+ const started = Date.now();
813
+ const mode = resolveMode2(config);
814
+ const actionsRun = [];
815
+ const errors = [];
816
+ const logTools = config.debug || process.env.CANARY_TOOL_LOG === "1";
817
+ const initialContext = await buildInitialPageContext(execCtx.page, config);
818
+ if (!decision.healable) {
819
+ return baseOutcome({ mode, started, healed: false, shouldRetryOriginal: false, reason: decision.reason, actionsRun, errors });
820
+ }
821
+ const { model, modelId, reason: modelReason } = resolveHealerModel(config);
822
+ if (!model) {
823
+ return baseOutcome({ mode, started, healed: false, shouldRetryOriginal: false, reason: modelReason ?? "no_model", actionsRun, errors });
824
+ }
825
+ const testContext = {
826
+ testFile: execCtx.testContext?.testFile,
827
+ testTitle: execCtx.testContext?.testTitle,
828
+ testSource: execCtx.testContext?.testSource ? sanitizeString(execCtx.testContext.testSource) : void 0,
829
+ currentStep: `${failure.action ?? "unknown"}(${failure.target ?? "unknown"})`,
830
+ expectedAfter: execCtx.testContext?.expectedAfter,
831
+ action: failure.action ?? "unknown",
832
+ target: failure.target,
833
+ errorMessage: failure.errorMessage
834
+ };
835
+ const toolset = createAgenticTools({ page: execCtx.page, config, actionsRun, debug: config.debug });
836
+ let completionReason;
1033
837
  try {
1034
- return JSON.stringify(value);
1035
- } catch {
1036
- return String(value);
838
+ const abort = AbortSignal.timeout(config.healTimeoutMs);
839
+ const result = await streamText({
840
+ model,
841
+ system: buildSystemPrompt(testContext, config),
842
+ messages: buildInitialMessages(testContext, failure, initialContext),
843
+ tools: toolset.tools,
844
+ stopWhen: stepCountIs(Math.max(1, config.maxActions)),
845
+ abortSignal: abort,
846
+ maxRetries: 0,
847
+ onStepFinish: (step) => {
848
+ if (config.debug) {
849
+ console.log(
850
+ `[canary][debug] step finish: finishReason=${step.finishReason}; toolCalls=${step.toolCalls?.length ?? 0}; toolResults=${step.toolResults?.length ?? 0}`
851
+ );
852
+ }
853
+ if (logTools) logToolStep(step);
854
+ }
855
+ });
856
+ for await (const _ of result.fullStream) {
857
+ if (toolset.isComplete()) {
858
+ completionReason = toolset.getCompletionReason();
859
+ if (config.debug) {
860
+ console.log(`[canary][debug] agent marked complete: ${completionReason}`);
861
+ }
862
+ break;
863
+ }
864
+ }
865
+ } catch (error) {
866
+ const message = error instanceof Error ? error.message : String(error);
867
+ errors.push(message);
868
+ if (config.debug) {
869
+ console.log(`[canary][debug] agent error: ${message}`);
870
+ }
1037
871
  }
872
+ const healed = toolset.isComplete();
873
+ return {
874
+ healed,
875
+ shouldRetryOriginal: false,
876
+ actionsRun,
877
+ timedOut: Date.now() - started > config.healTimeoutMs,
878
+ durationMs: Date.now() - started,
879
+ errors,
880
+ mode,
881
+ reason: completionReason ?? (healed ? "agent_healed" : "agent_incomplete"),
882
+ modelId,
883
+ summary: toolset.getSummary()
884
+ };
885
+ }
886
+ function resolveMode2(config) {
887
+ if (config.dryRun) return "dry-run";
888
+ if (config.warnOnly) return "warn";
889
+ return "full";
890
+ }
891
+ function baseOutcome({
892
+ started,
893
+ healed,
894
+ shouldRetryOriginal,
895
+ mode,
896
+ reason,
897
+ actionsRun = [],
898
+ errors = []
899
+ }) {
900
+ return {
901
+ healed,
902
+ shouldRetryOriginal,
903
+ actionsRun,
904
+ timedOut: false,
905
+ durationMs: Date.now() - started,
906
+ errors,
907
+ mode,
908
+ reason
909
+ };
1038
910
  }
1039
911
  function sanitizeString(value) {
1040
912
  let result = value;
@@ -1046,7 +918,7 @@ function sanitizeString(value) {
1046
918
  function sanitizeUnknown(value, maxPayloadBytes, seen = /* @__PURE__ */ new WeakSet()) {
1047
919
  if (value === null || value === void 0) return value;
1048
920
  if (typeof value === "string") {
1049
- return truncate(sanitizeString(value), maxPayloadBytes);
921
+ return truncate3(sanitizeString(value), maxPayloadBytes);
1050
922
  }
1051
923
  if (typeof value === "number" || typeof value === "boolean") return value;
1052
924
  if (typeof value === "object") {
@@ -1067,6 +939,343 @@ function sanitizeUnknown(value, maxPayloadBytes, seen = /* @__PURE__ */ new Weak
1067
939
  function violatesCodeDenylist(code) {
1068
940
  return CODE_DENY_PATTERNS.some((p) => p.test(code));
1069
941
  }
942
+ function truncate3(value, max) {
943
+ if (value.length <= max) return value;
944
+ return value.slice(0, max) + "...";
945
+ }
946
+
947
+ // src/runner/healing-helpers.ts
948
+ var requireFn = typeof __require !== "undefined" ? __require : createRequire(import.meta.url);
949
+ var LOCATOR_ACTIONS = /* @__PURE__ */ new Set([
950
+ "click",
951
+ "dblclick",
952
+ "fill",
953
+ "check",
954
+ "uncheck",
955
+ "hover",
956
+ "press",
957
+ "type",
958
+ "selectOption",
959
+ "tap"
960
+ ]);
961
+ var PAGE_ACTIONS = /* @__PURE__ */ new Set([
962
+ "goto",
963
+ "click",
964
+ "dblclick",
965
+ "fill",
966
+ "check",
967
+ "uncheck",
968
+ "hover",
969
+ "press",
970
+ "type",
971
+ "selectOption",
972
+ "tap",
973
+ "waitForSelector"
974
+ ]);
975
+ var LOCATOR_FACTORIES = /* @__PURE__ */ new Set([
976
+ "locator",
977
+ "getByRole",
978
+ "getByText",
979
+ "getByLabel",
980
+ "getByPlaceholder",
981
+ "getByAltText",
982
+ "getByTitle",
983
+ "getByTestId",
984
+ "frameLocator"
985
+ ]);
986
+ var LOCATOR_CHAIN_METHODS = /* @__PURE__ */ new Set([
987
+ "locator",
988
+ "first",
989
+ "last",
990
+ "nth",
991
+ "filter",
992
+ "getByRole",
993
+ "getByText",
994
+ "getByLabel",
995
+ "getByPlaceholder",
996
+ "getByAltText",
997
+ "getByTitle",
998
+ "getByTestId"
999
+ ]);
1000
+ function wrapExpect(expectImpl, options) {
1001
+ const wrapMatchers = (expectation, mode) => {
1002
+ return new Proxy(expectation ?? {}, {
1003
+ get(target, prop, receiver) {
1004
+ const value = Reflect.get(target, prop, receiver);
1005
+ if (typeof prop === "string" && typeof value === "function") {
1006
+ return (...args) => runMatcherWithHealing({
1007
+ matcher: value,
1008
+ matcherName: prop,
1009
+ args,
1010
+ expectTarget: target,
1011
+ mode,
1012
+ debug: options.debug
1013
+ });
1014
+ }
1015
+ return value;
1016
+ }
1017
+ });
1018
+ };
1019
+ const proxy = new Proxy(expectImpl, {
1020
+ apply(target, thisArg, argArray) {
1021
+ const expectation = target.apply(thisArg, argArray);
1022
+ return wrapMatchers(expectation, "hard");
1023
+ },
1024
+ get(target, prop, receiver) {
1025
+ const value = Reflect.get(target, prop, receiver);
1026
+ if (prop === "soft" && typeof value === "function") {
1027
+ return (...args) => wrapMatchers(value.apply(target, args), "soft");
1028
+ }
1029
+ return value;
1030
+ }
1031
+ });
1032
+ return proxy;
1033
+ }
1034
+ function wrapPage(page, options) {
1035
+ return new Proxy(page, {
1036
+ get(target, prop, receiver) {
1037
+ const value = Reflect.get(target, prop, receiver);
1038
+ if (typeof prop === "string") {
1039
+ if (LOCATOR_FACTORIES.has(prop) && typeof value === "function") {
1040
+ return (...args) => {
1041
+ const locator = value.apply(target, args);
1042
+ return wrapLocator(locator, options, target);
1043
+ };
1044
+ }
1045
+ if (PAGE_ACTIONS.has(prop) && typeof value === "function") {
1046
+ return async (...args) => {
1047
+ return attemptWithHealing({
1048
+ kind: "page",
1049
+ action: prop,
1050
+ target: safeTargetString(target),
1051
+ locator: void 0,
1052
+ page: target,
1053
+ invoke: () => value.apply(target, args),
1054
+ debug: options.debug
1055
+ });
1056
+ };
1057
+ }
1058
+ }
1059
+ return value;
1060
+ }
1061
+ });
1062
+ }
1063
+ function wrapLocator(locator, options, page) {
1064
+ if (!locator || typeof locator !== "object") return locator;
1065
+ return new Proxy(locator, {
1066
+ get(target, prop, receiver) {
1067
+ const value = Reflect.get(target, prop, receiver);
1068
+ if (typeof prop === "string" && LOCATOR_ACTIONS.has(prop) && typeof value === "function") {
1069
+ return async (...args) => {
1070
+ return attemptWithHealing({
1071
+ kind: "locator",
1072
+ action: prop,
1073
+ target: safeTargetString(target),
1074
+ locator: target,
1075
+ page,
1076
+ invoke: () => value.apply(target, args),
1077
+ debug: options.debug
1078
+ });
1079
+ };
1080
+ }
1081
+ if (typeof prop === "string" && LOCATOR_CHAIN_METHODS.has(prop) && typeof value === "function") {
1082
+ return (...args) => wrapLocator(value.apply(target, args), options, page);
1083
+ }
1084
+ return value;
1085
+ }
1086
+ });
1087
+ }
1088
+ async function attemptWithHealing(ctx) {
1089
+ try {
1090
+ return await Promise.resolve(ctx.invoke());
1091
+ } catch (error) {
1092
+ const failure = buildFailureContext(ctx.kind, ctx.action, ctx.target, error);
1093
+ recordHealingEvent({ ...failure, healed: false });
1094
+ const decision = classifyFailure(failure);
1095
+ if (ctx.debug) {
1096
+ console.log(
1097
+ `[canary][debug] failure intercepted: kind=${ctx.kind} action=${ctx.action} reason=${decision.healable ? decision.reason ?? "healable" : decision.reason}`
1098
+ );
1099
+ }
1100
+ if (!decision.healable) {
1101
+ throw error;
1102
+ }
1103
+ const testContext = getTestContext();
1104
+ const outcome = await executeHealActions(decision, failure, {
1105
+ kind: ctx.kind,
1106
+ action: ctx.action,
1107
+ target: ctx.target,
1108
+ locator: isLocatorLike(ctx.locator) ? ctx.locator : void 0,
1109
+ page: isPageLike(ctx.page) ? ctx.page : void 0,
1110
+ testContext: {
1111
+ testFile: testContext?.testFile,
1112
+ testTitle: testContext?.testTitle,
1113
+ testSource: loadTestSource(testContext?.testFile)
1114
+ }
1115
+ });
1116
+ const summaryBase = {
1117
+ ...failure,
1118
+ healed: false,
1119
+ strategy: "agentic",
1120
+ reason: outcome.reason ?? decision.reason,
1121
+ actions: actionsToEventItems(outcome.actionsRun),
1122
+ durationMs: outcome.durationMs,
1123
+ mode: outcome.mode,
1124
+ decision: decision.reason,
1125
+ modelId: outcome.modelId,
1126
+ summary: outcome.summary,
1127
+ testFile: testContext?.testFile,
1128
+ testTitle: testContext?.testTitle
1129
+ };
1130
+ if (outcome.healed) {
1131
+ if (ctx.debug) {
1132
+ console.log(`[canary][debug] healed via AI tool for action=${ctx.action}`);
1133
+ }
1134
+ recordHealingEvent({ ...summaryBase, healed: true });
1135
+ return void 0;
1136
+ }
1137
+ if (outcome.shouldRetryOriginal) {
1138
+ try {
1139
+ const retried = await Promise.resolve(ctx.invoke());
1140
+ if (ctx.debug) {
1141
+ console.log(`[canary][debug] retry_original succeeded for action=${ctx.action}`);
1142
+ }
1143
+ recordHealingEvent({ ...summaryBase, healed: true });
1144
+ return retried;
1145
+ } catch (retryError) {
1146
+ const retryInfo = errorInfo(retryError);
1147
+ recordHealingEvent({ ...summaryBase, healed: false, errorMessage: retryInfo.message });
1148
+ if (ctx.debug) {
1149
+ console.log(
1150
+ `[canary][debug] retry_original failed for action=${ctx.action}: ${retryInfo.message ?? retryError}`
1151
+ );
1152
+ }
1153
+ throw retryError;
1154
+ }
1155
+ }
1156
+ recordHealingEvent(summaryBase);
1157
+ throw error;
1158
+ }
1159
+ }
1160
+ async function runMatcherWithHealing(params) {
1161
+ const { matcher, matcherName, args, expectTarget, mode } = params;
1162
+ const invoke = () => matcher.apply(expectTarget, args);
1163
+ if (mode === "soft") {
1164
+ return Promise.resolve(invoke());
1165
+ }
1166
+ try {
1167
+ return await Promise.resolve(invoke());
1168
+ } catch (error) {
1169
+ const target = stringifyTarget(args?.[0]);
1170
+ const failure = buildFailureContext("expect", matcherName, target, error);
1171
+ recordHealingEvent({ ...failure, healed: false });
1172
+ const decision = classifyFailure(failure);
1173
+ if (!decision.healable) {
1174
+ throw error;
1175
+ }
1176
+ const outcome = await executeHealActions(decision, failure, {
1177
+ kind: "expect",
1178
+ action: matcherName,
1179
+ target,
1180
+ locator: isLocatorLike(args?.[0]) ? args[0] : void 0
1181
+ });
1182
+ const testContextMatcher = getTestContext();
1183
+ const summaryBase = {
1184
+ ...failure,
1185
+ healed: false,
1186
+ strategy: "agentic",
1187
+ reason: outcome.reason ?? decision.reason,
1188
+ actions: actionsToEventItems(outcome.actionsRun),
1189
+ durationMs: outcome.durationMs,
1190
+ mode: outcome.mode,
1191
+ decision: decision.reason,
1192
+ modelId: outcome.modelId,
1193
+ summary: outcome.summary,
1194
+ testFile: testContextMatcher?.testFile,
1195
+ testTitle: testContextMatcher?.testTitle
1196
+ };
1197
+ if (outcome.shouldRetryOriginal) {
1198
+ try {
1199
+ const retried = await Promise.resolve(invoke());
1200
+ recordHealingEvent({ ...summaryBase, healed: true });
1201
+ return retried;
1202
+ } catch (retryError) {
1203
+ const retryInfo = errorInfo(retryError);
1204
+ recordHealingEvent({ ...summaryBase, healed: false, errorMessage: retryInfo.message });
1205
+ throw retryError;
1206
+ }
1207
+ }
1208
+ recordHealingEvent(summaryBase);
1209
+ throw error;
1210
+ }
1211
+ }
1212
+ function buildFailureContext(kind, action, target, error) {
1213
+ const info = errorInfo(error);
1214
+ return {
1215
+ kind,
1216
+ action,
1217
+ target,
1218
+ errorMessage: info.message,
1219
+ errorName: info.name,
1220
+ stack: info.stack
1221
+ };
1222
+ }
1223
+ function actionsToEventItems(actions) {
1224
+ return actions.map((action) => ({ type: action }));
1225
+ }
1226
+ function errorInfo(error) {
1227
+ if (error instanceof Error) {
1228
+ return { message: error.message, name: error.name, stack: error.stack };
1229
+ }
1230
+ return { message: typeof error === "string" ? error : JSON.stringify(error) };
1231
+ }
1232
+ function isLocatorLike(candidate) {
1233
+ return Boolean(candidate && typeof candidate === "object" && "scrollIntoViewIfNeeded" in candidate);
1234
+ }
1235
+ function isPageLike(candidate) {
1236
+ return Boolean(candidate && typeof candidate === "object" && "waitForTimeout" in candidate);
1237
+ }
1238
+ function safeTargetString(target) {
1239
+ try {
1240
+ if (typeof target === "object" && target !== null && "toString" in target) {
1241
+ const s = String(target.toString());
1242
+ if (s && s !== "[object Object]") return s;
1243
+ }
1244
+ } catch {
1245
+ }
1246
+ return void 0;
1247
+ }
1248
+ function stringifyTarget(candidate) {
1249
+ if (!candidate) return void 0;
1250
+ if (typeof candidate === "string") return candidate;
1251
+ if (typeof candidate === "object") {
1252
+ if ("selector" in candidate && typeof candidate.selector === "string") {
1253
+ return String(candidate.selector);
1254
+ }
1255
+ }
1256
+ return safeTargetString(candidate);
1257
+ }
1258
+ function getTestContext() {
1259
+ try {
1260
+ const playwright = requireFn("@playwright/test");
1261
+ if (playwright?.test?.info) {
1262
+ const info = playwright.test.info();
1263
+ if (info) {
1264
+ return { testFile: info.file, testTitle: info.title };
1265
+ }
1266
+ }
1267
+ } catch {
1268
+ }
1269
+ return void 0;
1270
+ }
1271
+ function loadTestSource(filePath) {
1272
+ if (!filePath) return void 0;
1273
+ try {
1274
+ return fs3.readFileSync(filePath, "utf-8");
1275
+ } catch {
1276
+ return void 0;
1277
+ }
1278
+ }
1070
1279
 
1071
1280
  export {
1072
1281
  loadCanaryConfig,
@@ -1075,7 +1284,7 @@ export {
1075
1284
  recordHealingEvent,
1076
1285
  markPatched,
1077
1286
  alreadyPatched,
1078
- classifyFailure,
1079
- executeHealActions
1287
+ wrapExpect,
1288
+ wrapPage
1080
1289
  };
1081
- //# sourceMappingURL=chunk-ROTCL5WO.js.map
1290
+ //# sourceMappingURL=chunk-TO66FC4R.js.map