@canaryai/cli 0.2.0 → 0.2.1
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/dist/{chunk-2T64Z2NI.js → chunk-7R4YFGP6.js} +53 -2
- package/dist/chunk-7R4YFGP6.js.map +1 -0
- package/dist/chunk-DXJNFJ3A.js +64 -0
- package/dist/chunk-DXJNFJ3A.js.map +1 -0
- package/dist/{chunk-V7U52ISX.js → chunk-HOYYXZPV.js} +136 -131
- package/dist/chunk-HOYYXZPV.js.map +1 -0
- package/dist/{chunk-ROTCL5WO.js → chunk-TO66FC4R.js} +688 -479
- package/dist/chunk-TO66FC4R.js.map +1 -0
- package/dist/{feature-flag-ESPSOSKG.js → feature-flag-ZDLDYRSF.js} +15 -92
- package/dist/feature-flag-ZDLDYRSF.js.map +1 -0
- package/dist/index.js +17 -65
- package/dist/index.js.map +1 -1
- package/dist/{knobs-HKONHY55.js → knobs-3MKMOXIV.js} +19 -104
- package/dist/knobs-3MKMOXIV.js.map +1 -0
- package/dist/{local-browser-MKKPBTYI.js → local-browser-GG5GUXDS.js} +2 -2
- package/dist/{mcp-4F4HI7L2.js → mcp-AD67OLQM.js} +3 -3
- package/dist/{psql-6IFVXM3A.js → psql-IVAPNYZV.js} +2 -2
- package/dist/{redis-HZC32IEO.js → redis-LWY7L6AS.js} +2 -2
- package/dist/{release-WOD3DAX4.js → release-KQFCTAXA.js} +5 -35
- package/dist/release-KQFCTAXA.js.map +1 -0
- package/dist/runner/preload.js +7 -323
- package/dist/runner/preload.js.map +1 -1
- package/dist/test.js +5 -340
- package/dist/test.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-2T64Z2NI.js.map +0 -1
- package/dist/chunk-ROTCL5WO.js.map +0 -1
- package/dist/chunk-V7U52ISX.js.map +0 -1
- package/dist/feature-flag-ESPSOSKG.js.map +0 -1
- package/dist/knobs-HKONHY55.js.map +0 -1
- package/dist/release-WOD3DAX4.js.map +0 -1
- /package/dist/{local-browser-MKKPBTYI.js.map → local-browser-GG5GUXDS.js.map} +0 -0
- /package/dist/{mcp-4F4HI7L2.js.map → mcp-AD67OLQM.js.map} +0 -0
- /package/dist/{psql-6IFVXM3A.js.map → psql-IVAPNYZV.js.map} +0 -0
- /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
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
}
|
|
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
|
|
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:
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
|
|
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:
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
|
|
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:
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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:
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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:
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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:
|
|
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
|
-
|
|
445
|
-
|
|
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:
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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:
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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:
|
|
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
|
-
|
|
556
|
-
|
|
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:
|
|
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
|
-
|
|
576
|
-
|
|
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:
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
log(`screenshot succeeded`);
|
|
667
|
-
return { screenshot: dataUrl };
|
|
463
|
+
return { screenshot: `data:image/png;base64,${truncate(base64, maxPayloadBytes)}` };
|
|
668
464
|
} catch (err) {
|
|
669
|
-
|
|
670
|
-
log(`screenshot error: ${msg}`);
|
|
671
|
-
return { error: msg };
|
|
465
|
+
return { error: errMsg(err) };
|
|
672
466
|
}
|
|
673
467
|
}
|
|
674
468
|
}),
|
|
675
|
-
get_text:
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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:
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
-
${
|
|
746
|
+
${truncate2(initial.html, 4e3)}
|
|
985
747
|
\`\`\``);
|
|
986
748
|
}
|
|
987
749
|
if (initial.screenshot) {
|
|
988
|
-
parts.push(`Screenshot (base64 data URL, truncated): ${
|
|
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 =
|
|
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,${
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
|
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
|
-
|
|
1079
|
-
|
|
1287
|
+
wrapExpect,
|
|
1288
|
+
wrapPage
|
|
1080
1289
|
};
|
|
1081
|
-
//# sourceMappingURL=chunk-
|
|
1290
|
+
//# sourceMappingURL=chunk-TO66FC4R.js.map
|