explorbot 0.5.2 → 0.5.4

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 (70) hide show
  1. package/bin/explorbot-cli.ts +3 -0
  2. package/boat/doc-collector/src/config.ts +1 -0
  3. package/boat/doc-collector/src/docbot.ts +10 -1
  4. package/boat/prima/src/envelope.ts +12 -6
  5. package/boat/prima/src/help.ts +4 -2
  6. package/boat/prima/src/prima.ts +37 -4
  7. package/boat/prima/src/session-log.ts +3 -3
  8. package/dist/bin/explorbot-cli.js +2 -0
  9. package/dist/boat/doc-collector/src/docbot.js +11 -1
  10. package/dist/boat/prima/src/envelope.js +12 -3
  11. package/dist/boat/prima/src/help.js +4 -2
  12. package/dist/boat/prima/src/prima.js +37 -4
  13. package/dist/boat/prima/src/session-log.js +3 -3
  14. package/dist/package.json +1 -1
  15. package/dist/rules/navigator/output.md +3 -3
  16. package/dist/src/action.js +6 -0
  17. package/dist/src/ai/driller.js +2 -28
  18. package/dist/src/ai/historian/codeceptjs.js +2 -0
  19. package/dist/src/ai/judge-tool.js +5 -1
  20. package/dist/src/ai/navigator.d.ts +5 -2
  21. package/dist/src/ai/navigator.js +15 -10
  22. package/dist/src/ai/pilot.d.ts +2 -7
  23. package/dist/src/ai/pilot.js +37 -7
  24. package/dist/src/ai/rerunner.js +3 -1
  25. package/dist/src/ai/researcher.js +10 -8
  26. package/dist/src/ai/rules.js +1 -1
  27. package/dist/src/ai/tester.js +11 -0
  28. package/dist/src/ai/tools.js +23 -7
  29. package/dist/src/config.d.ts +10 -3
  30. package/dist/src/config.js +42 -6
  31. package/dist/src/explorbot.js +5 -1
  32. package/dist/src/explorer.d.ts +2 -0
  33. package/dist/src/explorer.js +33 -1
  34. package/dist/src/global-config.js +1 -1
  35. package/dist/src/test-plan.d.ts +11 -0
  36. package/dist/src/test-plan.js +21 -0
  37. package/dist/src/utils/code-extractor.d.ts +1 -0
  38. package/dist/src/utils/code-extractor.js +40 -0
  39. package/dist/src/utils/html.d.ts +2 -0
  40. package/dist/src/utils/html.js +36 -1
  41. package/dist/src/utils/mdq/cli.js +3 -1
  42. package/dist/src/utils/test-files.d.ts +5 -0
  43. package/dist/src/utils/test-files.js +28 -1
  44. package/dist/src/utils/web-sandbox.js +4 -4
  45. package/docs/doc-collection/basics.md +13 -0
  46. package/docs/reference/commands.md +12 -0
  47. package/docs/web-testing/researcher.md +8 -0
  48. package/package.json +1 -1
  49. package/rules/navigator/output.md +3 -3
  50. package/src/action.ts +6 -0
  51. package/src/ai/driller.ts +2 -33
  52. package/src/ai/historian/codeceptjs.ts +2 -0
  53. package/src/ai/judge-tool.ts +5 -1
  54. package/src/ai/navigator.ts +15 -11
  55. package/src/ai/pilot.ts +38 -15
  56. package/src/ai/rerunner.ts +3 -1
  57. package/src/ai/researcher.ts +10 -9
  58. package/src/ai/rules.ts +1 -1
  59. package/src/ai/tester.ts +12 -0
  60. package/src/ai/tools.ts +21 -12
  61. package/src/config.ts +48 -6
  62. package/src/explorbot.ts +3 -1
  63. package/src/explorer.ts +37 -2
  64. package/src/global-config.ts +1 -1
  65. package/src/test-plan.ts +23 -0
  66. package/src/utils/code-extractor.ts +41 -0
  67. package/src/utils/html.ts +33 -1
  68. package/src/utils/mdq/cli.ts +3 -2
  69. package/src/utils/test-files.ts +27 -1
  70. package/src/utils/web-sandbox.ts +4 -4
@@ -41,6 +41,27 @@ export class ActiveNote {
41
41
  return this.startTime;
42
42
  }
43
43
  }
44
+ export class SettledExpectation {
45
+ text;
46
+ status;
47
+ evidence;
48
+ resolution;
49
+ constructor(text, status, evidence, resolution) {
50
+ this.text = text;
51
+ this.status = status;
52
+ this.evidence = evidence;
53
+ this.resolution = resolution;
54
+ }
55
+ get failed() {
56
+ return this.status === 'failed';
57
+ }
58
+ get contradicted() {
59
+ return this.status === 'contradiction';
60
+ }
61
+ resolve(status, resolution) {
62
+ return new SettledExpectation(this.text, status, this.evidence, resolution);
63
+ }
64
+ }
44
65
  export class Task {
45
66
  id;
46
67
  description;
@@ -1 +1,2 @@
1
1
  export declare function extractCodeBlocks(aiResponse: string): string[];
2
+ export declare function splitTopLevel(code: string, separator: string): string[];
@@ -1,6 +1,9 @@
1
1
  import { createDebug } from './logger.js';
2
2
  const debugLog = createDebug('explorbot:code-extractor');
3
3
  const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
4
+ const QUOTES = ["'", '"', '`'];
5
+ const OPENING_BRACKETS = ['(', '[', '{'];
6
+ const CLOSING_BRACKETS = [')', ']', '}'];
4
7
  export function extractCodeBlocks(aiResponse) {
5
8
  const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
6
9
  const codeBlocks = [];
@@ -22,3 +25,40 @@ export function extractCodeBlocks(aiResponse) {
22
25
  }
23
26
  return codeBlocks;
24
27
  }
28
+ export function splitTopLevel(code, separator) {
29
+ const parts = [];
30
+ let current = '';
31
+ let depth = 0;
32
+ let quote = '';
33
+ for (let i = 0; i < code.length; i++) {
34
+ const char = code[i];
35
+ if (quote) {
36
+ current += char;
37
+ if (char === '\\')
38
+ current += code[++i] || '';
39
+ if (char === quote)
40
+ quote = '';
41
+ continue;
42
+ }
43
+ if (char === '/' && code[i + 1] === '/') {
44
+ while (i + 1 < code.length && code[i + 1] !== '\n')
45
+ i++;
46
+ continue;
47
+ }
48
+ if (char === separator && depth <= 0) {
49
+ parts.push(current);
50
+ current = '';
51
+ depth = 0;
52
+ continue;
53
+ }
54
+ current += char;
55
+ if (QUOTES.includes(char))
56
+ quote = char;
57
+ if (OPENING_BRACKETS.includes(char))
58
+ depth++;
59
+ if (CLOSING_BRACKETS.includes(char))
60
+ depth--;
61
+ }
62
+ parts.push(current);
63
+ return parts;
64
+ }
@@ -5,6 +5,7 @@ export declare const EXPLORBOT_ATTRS: {
5
5
  readonly coveredBy: "data-explorbot-covered-by";
6
6
  readonly context: "data-explorbot-context";
7
7
  readonly eidx: "data-explorbot-eidx";
8
+ readonly hidden: "data-explorbot-hidden";
8
9
  readonly hit: "data-explorbot-hit";
9
10
  readonly variant: "data-explorbot-variant";
10
11
  };
@@ -40,6 +41,7 @@ export declare const ELEMENT_EXTRACTION_CONFIG: {
40
41
  readonly coveredBy: "data-explorbot-covered-by";
41
42
  readonly context: "data-explorbot-context";
42
43
  readonly eidx: "data-explorbot-eidx";
44
+ readonly hidden: "data-explorbot-hidden";
43
45
  readonly hit: "data-explorbot-hit";
44
46
  readonly variant: "data-explorbot-variant";
45
47
  };
@@ -70,6 +70,7 @@ export const EXPLORBOT_ATTRS = {
70
70
  coveredBy: 'data-explorbot-covered-by',
71
71
  context: 'data-explorbot-context',
72
72
  eidx: 'data-explorbot-eidx',
73
+ hidden: 'data-explorbot-hidden',
73
74
  hit: 'data-explorbot-hit',
74
75
  variant: 'data-explorbot-variant',
75
76
  };
@@ -159,6 +160,20 @@ export function captureHtmlForSnapshot() {
159
160
  .join(','));
160
161
  }
161
162
  }
163
+ const liveElements = Array.from(document.body?.querySelectorAll('*') || []);
164
+ const clonedElements = Array.from(clone.querySelector('body')?.querySelectorAll('*') || []);
165
+ for (let i = 0; i < liveElements.length; i++) {
166
+ const source = liveElements[i];
167
+ if (source.matches('input, textarea, select, option, optgroup'))
168
+ continue;
169
+ if (source.checkVisibility())
170
+ continue;
171
+ if (!source.parentElement?.checkVisibility())
172
+ continue;
173
+ if (getComputedStyle(source).display === 'contents')
174
+ continue;
175
+ clonedElements[i]?.setAttribute('data-explorbot-hidden', 'true');
176
+ }
162
177
  return clone.outerHTML;
163
178
  }
164
179
  export function extractElementData(el, config) {
@@ -1347,7 +1362,27 @@ function getAttribute(element, name) {
1347
1362
  const attr = element.attrs.find((a) => a.name === name);
1348
1363
  return attr?.value;
1349
1364
  }
1365
+ function getImgAlt(element) {
1366
+ let alt = '';
1367
+ function processNode(node) {
1368
+ if (alt)
1369
+ return;
1370
+ if ('tagName' in node) {
1371
+ const el = node;
1372
+ if (el.tagName.toLowerCase() === 'img') {
1373
+ alt = getAttribute(el, 'alt') || '';
1374
+ return;
1375
+ }
1376
+ }
1377
+ if ('childNodes' in node)
1378
+ node.childNodes.forEach(processNode);
1379
+ }
1380
+ processNode(element);
1381
+ return alt;
1382
+ }
1350
1383
  function hasHiddenClass(element) {
1384
+ if (getAttribute(element, EXPLORBOT_ATTRS.hidden))
1385
+ return true;
1351
1386
  const classAttr = element.attrs.find((attr) => attr.name === 'class');
1352
1387
  if (!classAttr)
1353
1388
  return false;
@@ -1393,7 +1428,7 @@ export function extractLinks(html) {
1393
1428
  if (href) {
1394
1429
  const shouldSkip = skipPrefixes.some((prefix) => href.startsWith(prefix));
1395
1430
  if (!shouldSkip) {
1396
- const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element);
1431
+ const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element) || getImgAlt(element);
1397
1432
  const title = sanitizeLinkTitle(rawTitle);
1398
1433
  if (title && title.length <= 100) {
1399
1434
  const key = `${href}|${title}`;
@@ -5,7 +5,7 @@ const EDIT_FLAGS = ['remove', 'replace', 'insertBefore', 'insertAfter', 'prepend
5
5
  export async function runMdq(argv, readStdin) {
6
6
  const program = new Command();
7
7
  program
8
- .name('mdq')
8
+ .name('mdq-cli')
9
9
  .description('query and edit markdown')
10
10
  .argument('[selector]', 'markdown selector')
11
11
  .argument('[file]', 'file to read; stdin when omitted')
@@ -29,6 +29,8 @@ export async function runMdq(argv, readStdin) {
29
29
  program.parse(argv, { from: 'user' });
30
30
  }
31
31
  catch (error) {
32
+ if (error.code === 'commander.helpDisplayed')
33
+ return { output: program.helpInformation(), code: 0 };
32
34
  return { output: String(error.message), code: 2 };
33
35
  }
34
36
  const options = program.opts();
@@ -1,4 +1,9 @@
1
1
  export declare function listTestFiles(testsDir: string): string[];
2
+ /**
3
+ * Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
4
+ * far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
5
+ */
6
+ export declare function ensureCodeceptResolvable(dir: string): void;
2
7
  export declare function loadTestSuites(testsDir: string): any[];
3
8
  export declare function printTestList(suites: any[]): void;
4
9
  export declare function dryRunTestFile(filePath: string): Promise<void>;
@@ -1,4 +1,5 @@
1
- import { existsSync, readdirSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, symlinkSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import path from 'node:path';
3
4
  import chalk from 'chalk';
4
5
  import { highlight } from 'cli-highlight';
@@ -8,6 +9,7 @@ import storeListener from 'codeceptjs/lib/listener/store';
8
9
  import store from 'codeceptjs/lib/store';
9
10
  import figureSet from 'figures';
10
11
  import { ConfigParser } from "../config.js";
12
+ const nodeRequire = createRequire(import.meta.url);
11
13
  export function listTestFiles(testsDir) {
12
14
  if (!existsSync(testsDir))
13
15
  return [];
@@ -15,10 +17,34 @@ export function listTestFiles(testsDir) {
15
17
  .filter((f) => f.endsWith('.js'))
16
18
  .map((f) => path.resolve(testsDir, f));
17
19
  }
20
+ /**
21
+ * Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
22
+ * far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
23
+ */
24
+ export function ensureCodeceptResolvable(dir) {
25
+ const link = path.join(dir, 'node_modules', 'codeceptjs');
26
+ if (existsSync(link))
27
+ return;
28
+ let target;
29
+ try {
30
+ target = path.dirname(nodeRequire.resolve('codeceptjs/package.json'));
31
+ }
32
+ catch {
33
+ return;
34
+ }
35
+ try {
36
+ mkdirSync(path.dirname(link), { recursive: true });
37
+ symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
38
+ }
39
+ catch {
40
+ // Best effort: a project install already resolves codeceptjs without the link.
41
+ }
42
+ }
18
43
  export function loadTestSuites(testsDir) {
19
44
  const jsFiles = listTestFiles(testsDir);
20
45
  if (jsFiles.length === 0)
21
46
  return [];
47
+ ensureCodeceptResolvable(testsDir);
22
48
  codeceptjs.container.createMocha();
23
49
  const mocha = codeceptjs.container.mocha();
24
50
  mocha.files = jsFiles;
@@ -79,6 +105,7 @@ export async function dryRunTestFile(filePath) {
79
105
  const mocha = codeceptjs.container.mocha();
80
106
  mocha.reporter(class {
81
107
  });
108
+ ensureCodeceptResolvable(path.dirname(absPath));
82
109
  mocha.files = [absPath];
83
110
  mocha.loadFiles();
84
111
  let currentSuite = '';
@@ -1,16 +1,16 @@
1
1
  import { faker } from '@faker-js/faker';
2
2
  import { hopeThat, retryTo, tryTo, within } from 'codeceptjs/lib/effects';
3
3
  import step from 'codeceptjs/steps';
4
+ import { splitTopLevel } from "./code-extractor.js";
4
5
  const SHADOWED_GLOBALS = ['process', 'global', 'globalThis', 'fetch', 'Bun', 'require', 'module', 'exports'];
5
6
  const ALLOWED_COMMAND_HEADS = ['I.', 'page.', 'await page.', 'await I.'];
6
7
  const PLAYWRIGHT_COMMAND_HEADS = ['page.', 'await page.'];
7
8
  const PLAYWRIGHT_ARG_NAMES = ['page'];
8
9
  const CODECEPT_ARG_NAMES = ['I', 'tryTo', 'retryTo', 'within', 'hopeThat', 'step', 'faker'];
9
10
  export function sanitizeCodeBlock(code) {
10
- return code
11
- .split('\n')
12
- .map((line) => line.trim())
13
- .filter((line) => ALLOWED_COMMAND_HEADS.some((head) => line.startsWith(head)))
11
+ return splitTopLevel(code, '\n')
12
+ .map((statement) => statement.trim())
13
+ .filter((statement) => ALLOWED_COMMAND_HEADS.some((head) => statement.startsWith(head)))
14
14
  .join('\n');
15
15
  }
16
16
  export function hasPlaywrightCommands(code) {
@@ -21,6 +21,19 @@ The collector starts at the path you give it. For each page it:
21
21
 
22
22
  It repeats this until the queue is empty or the page budget is spent. Only same-origin links are followed.
23
23
 
24
+ ## Research depth
25
+
26
+ In static mode the Researcher runs in a **light, single pass**: one AI call maps the sections and interactive elements, and the locator validation, repair conversations, and screenshot annotation passes of a full research run are skipped. Docs describe pages rather than click them, so unverified locators are a fair trade for roughly one AI call per page.
27
+
28
+ Interactive mode needs locators it can actually click, so it keeps the full pipeline: every locator is validated in the browser, broken ones get fixed, and a vision pass adds coordinates. Override the automatic choice with `research`:
29
+
30
+ ```ts
31
+ docs: {
32
+ research: 'full', // always run the verified pipeline
33
+ // research: 'light', // single pass even in interactive mode
34
+ }
35
+ ```
36
+
24
37
  ## Configure
25
38
 
26
39
  The collector runs on your regular Explorbot setup — the AI provider and browser come from `explorbot.config.js` (see [configuration](../reference/configuration.md)). Crawl behavior lives in a separate file: `docbot.config.ts` (`.js` and `.mjs` work too).
@@ -66,11 +66,23 @@ Every CLI command that drives a browser accepts these options (`start`, `explore
66
66
  | `--debug` | Enable debug logging (same as `--verbose`) |
67
67
  | `-c, --config <path>` | Path to configuration file |
68
68
  | `-p, --path <path>` | Working directory path |
69
+ | `--base-url <url>` | Run against this URL: its path scopes relative paths, its query params ride along with every page load |
69
70
  | `-s, --show` | Show browser window |
70
71
  | `--headless` | Run browser in headless mode |
71
72
  | `--incognito` | Run without recording experiences |
72
73
  | `--session [file]` | Save/restore browser session (cookies, localStorage) from file |
73
74
 
75
+ ### `--base-url`
76
+
77
+ Points one run at one area of a site, leaving every config file alone. The origin selects the site, so the knowledge and experience already stored for that host still apply. The path prefixes every relative path a plan or a command gives, and the query params are added to each page load.
78
+
79
+ ```bash
80
+ npx explorbot test tests/plans/runs.md '*' \
81
+ --base-url 'https://app.example.com/teams/acme/?preview=pr-42'
82
+ ```
83
+
84
+ A plan whose tests start at `/runs` runs them at `/teams/acme/runs`, and every page load carries `?preview=pr-42` — enough to keep a preview deployment selected for a whole session. The param travels on the request rather than in the address bar, so Explorbot navigates to clean URLs, though an app that redirects can still echo the param back. Absolute URLs are never prefixed: `https://app.example.com/users/sign_in` still points outside the scoped area, as a login page usually must.
85
+
74
86
  ### `--knowledge`
75
87
 
76
88
  Passes facts to the run without creating a file in `knowledge/`. Plain text applies everywhere; add frontmatter to scope it to a page or an API endpoint. Repeat the flag for several facts. See [Knowledge](../workflow/knowledge.md#per-session-knowledge).
@@ -155,6 +155,14 @@ Expands hidden elements (dropdowns, accordions, tabs) to discover more UI. Click
155
155
 
156
156
  Extracts domain-specific content (articles, products, users) as structured data.
157
157
 
158
+ #### Light Research
159
+
160
+ ```ts
161
+ researcher.research(state, { light: true });
162
+ ```
163
+
164
+ A single-pass mode for consumers that describe pages instead of acting on them. Stage 1 runs as usual; stages 2–5 (locator testing, AI fixing, the visual annotation pass, backfills, and pagination probing) are skipped, so the whole run is one AI call with no browser verification roundtrips. Locators in the UI map come straight from the model and are not verified; a light run never writes to the shared research cache, so it cannot replace a verified result. The [doc collector](../doc-collection/basics.md#research-depth) uses this mode by default.
165
+
158
166
  ## Page Sections
159
167
 
160
168
  The Researcher breaks each page into sections by UI purpose. Sections are identified in priority order:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -1,11 +1,11 @@
1
1
  <rules>
2
- Do not invent locators, focus only on locators from HTML PAGE.
2
+ Do not invent locators, focus only on locators from the <page> context.
3
3
  Provide up to {{maxAttempts}} various code suggestions to achieve the result.
4
4
  If there was already successful solution in <experience> use it as a first solution.
5
5
 
6
6
  If no successful solution was found in <experience> propose codeblocks for each area that can help to achieve the result.
7
7
  Do not stick only to the first found element as it might be hidden or not availble on the page.
8
- If you think HTML contains several areas that can help to achieve the result, propose codeblocks for each such area.
8
+ If you think the page contains several areas that can help to achieve the result, propose codeblocks for each such area.
9
9
  Use exact locators that can pick the elements from each areas.
10
10
  Detect such duplicated areas by looking for duplicate IDs, data-ids, forms, etc.
11
11
 
@@ -24,7 +24,7 @@ Your response must start explanation of what you are going to do to achive the r
24
24
  It is important to explain intention before proposing code.
25
25
  Response must also valid CodeceptJS code in code blocks.
26
26
  Propose codeblock from successful solutions in <experience> first if they exist.
27
- Use only locators from HTML PAGE that was passed in <page> context.
27
+ Use only locators from the <page> context.
28
28
  </output>
29
29
 
30
30
 
package/src/action.ts CHANGED
@@ -114,6 +114,7 @@ class Action {
114
114
  if (includeScreenshot) {
115
115
  const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
116
116
  const screenshotPath = join(statesDir, filename);
117
+ await page?.screencast?.hideOverlays().catch(() => {});
117
118
  screenshotFile = await page
118
119
  ?.screenshot({ path: screenshotPath, fullPage: true })
119
120
  .then(() => {
@@ -124,6 +125,7 @@ class Action {
124
125
  debugLog('Screenshot failed, continuing without it:', err);
125
126
  return undefined;
126
127
  });
128
+ await page?.screencast?.showOverlays().catch(() => {});
127
129
  }
128
130
 
129
131
  // Save HTML to file
@@ -587,6 +589,10 @@ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Arra
587
589
  if (batchFailed) return;
588
590
 
589
591
  const executed: ExecutedStep = { command: step.toCode(), success: !error };
592
+ if (Object.keys(step.opts || {}).length) {
593
+ const args = [step.humanizeArgs(), `step.opts(${JSON.stringify(step.opts)})`].filter(Boolean).join(', ');
594
+ executed.command = `${step.prefix}${step.actor}.${step.title}(${args})${step.suffix}`;
595
+ }
590
596
  if (error) {
591
597
  executed.error = errorToString(error);
592
598
  batchFailed = true;
package/src/ai/driller.ts CHANGED
@@ -6,6 +6,7 @@ import { setActivity } from '../activity.ts';
6
6
  import { Observability } from '../observability.ts';
7
7
  import { Plan, Test, TestResult } from '../test-plan.ts';
8
8
  import { collectInteractiveNodes } from '../utils/aria.ts';
9
+ import { splitTopLevel } from '../utils/code-extractor.ts';
9
10
  import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, getComponentScopeHtmlExtractorSource, inferHtmlRole } from '../utils/html.ts';
10
11
  import { createDebug, tag } from '../utils/logger.ts';
11
12
  import { loop, pause } from '../utils/loop.ts';
@@ -1011,39 +1012,7 @@ function countTopLevelArgCount(line: string): number {
1011
1012
  const end = line.lastIndexOf(')');
1012
1013
  if (start === -1 || end === -1 || end <= start + 1) return 0;
1013
1014
 
1014
- const body = line.slice(start + 1, end);
1015
- let count = 1;
1016
- let depth = 0;
1017
- let quote = '';
1018
-
1019
- for (let i = 0; i < body.length; i++) {
1020
- const char = body[i];
1021
- const escaped = body[i - 1] === '\\';
1022
-
1023
- if (quote) {
1024
- if (char === quote && !escaped) quote = '';
1025
- continue;
1026
- }
1027
-
1028
- if (char === '"' || char === "'" || char === '`') {
1029
- quote = char;
1030
- continue;
1031
- }
1032
-
1033
- if (char === '(' || char === '[' || char === '{') {
1034
- depth++;
1035
- continue;
1036
- }
1037
-
1038
- if (char === ')' || char === ']' || char === '}') {
1039
- depth = Math.max(0, depth - 1);
1040
- continue;
1041
- }
1042
-
1043
- if (char === ',' && depth === 0) count++;
1044
- }
1045
-
1046
- return count;
1015
+ return splitTopLevel(line.slice(start + 1, end), ',').length;
1047
1016
  }
1048
1017
 
1049
1018
  function buildClassSelector(tag: string, classes: string[]): string {
@@ -8,6 +8,7 @@ import { tag } from '../../utils/logger.ts';
8
8
  import { relativeToCwd } from '../../utils/next-steps.ts';
9
9
  import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
10
10
  import { safeFilename } from '../../utils/strings.ts';
11
+ import { ensureCodeceptResolvable } from '../../utils/test-files.ts';
11
12
  import type { Conversation } from '../conversation.ts';
12
13
  import { ASSERTION_TOOLS } from '../tools.ts';
13
14
  import type { Constructor } from './mixin.ts';
@@ -99,6 +100,7 @@ export function WithCodeceptJS<T extends Constructor>(Base: T) {
99
100
 
100
101
  const testsDir = ConfigParser.getInstance().getTestsDir();
101
102
  mkdirSync(testsDir, { recursive: true });
103
+ ensureCodeceptResolvable(testsDir);
102
104
 
103
105
  const filePath = join(testsDir, safeFilename(plan.title, '.js'));
104
106
  writeFileSync(filePath, lines.join('\n'));
@@ -11,7 +11,11 @@ export function createJudgeTool(deps: ToolDeps, buildState: () => Promise<Record
11
11
  return {
12
12
  judge: tool({
13
13
  description: dedent`
14
- Settle one judgement about the current page instead of guessing. Phrase it literally and concretely.
14
+ Fast yes/no or pick-one decision about the current page, answered from <page_aria> and your recent steps.
15
+ No screenshot, no HTML, produces no test code.
16
+ Use for in-flow judgements you would otherwise guess: did the last action already take effect, which of several similar elements the scenario means, is this the expected page or dialog.
17
+ Not for final assertions (use verify/finish) and not for anything page_aria does not show, such as styling or visual state (use see).
18
+ Phrase the question literally and concretely.
15
19
  `,
16
20
  inputSchema: z.object({
17
21
  question: z.string().describe('The statement to confirm, or the question the options answer'),
@@ -136,11 +136,11 @@ class Navigator implements Agent {
136
136
  return matchesNavigationUrl(expectedUrl, currentUrl);
137
137
  }
138
138
 
139
- async visit(url: string): Promise<void> {
140
- return this.visitOnce(url);
139
+ async visit(url: string, opts: { recover?: boolean } = {}): Promise<boolean> {
140
+ return this.visitOnce(url, opts.recover !== false);
141
141
  }
142
142
 
143
- private async visitOnce(url: string): Promise<void> {
143
+ private async visitOnce(url: string, recover: boolean): Promise<boolean> {
144
144
  try {
145
145
  const action = this.explorer.action();
146
146
 
@@ -153,7 +153,10 @@ class Navigator implements Agent {
153
153
 
154
154
  await this.hooksRunner.runBeforeHook('navigator', url);
155
155
 
156
- if (!this.isOnExpectedPage(url, action.stateManager)) {
156
+ if (!recover && action.lastError) throw new Error(`Navigation to ${url} failed: ${action.lastError.message}`);
157
+
158
+ const arrived = this.isOnExpectedPage(url, action.stateManager);
159
+ if (recover && !arrived) {
157
160
  const actualPath = action.stateManager.getCurrentState()?.url || '';
158
161
  const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState()!);
159
162
  const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
@@ -177,6 +180,7 @@ class Navigator implements Agent {
177
180
  }
178
181
  await this.explorer.capture({ screenshot: true });
179
182
  await this.hooksRunner.runAfterHook('navigator', url);
183
+ return arrived || recover;
180
184
  } catch (error) {
181
185
  const errorMessage = error instanceof Error ? error.message : String(error);
182
186
  if (errorMessage.includes('ERR_CONNECTION_REFUSED')) {
@@ -284,7 +288,7 @@ class Navigator implements Agent {
284
288
  return;
285
289
  }
286
290
  tag('operation').log('Feeding failures back to AI for a new batch...');
287
- conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
291
+ conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, action.actionResult ?? actionResult));
288
292
  htmlContextAdded = true;
289
293
  codeBlocks = [];
290
294
  batchFailures.length = 0;
@@ -384,10 +388,6 @@ class Navigator implements Agent {
384
388
 
385
389
  <page>
386
390
  ${actionResult.toAiContext()}
387
-
388
- <page_html>
389
- ${await actionResult.combinedHtml()}
390
- </page_html>
391
391
  </page>
392
392
 
393
393
  <task>
@@ -397,6 +397,8 @@ class Navigator implements Agent {
397
397
  Propose different solutions to achieve the result.
398
398
  Solution should be valid CodeceptJS code.
399
399
  Use only data from the <page> context to plan the solution.
400
+ The page is given as an ARIA snapshot only: use ARIA and text locators from it.
401
+ Full HTML is provided if these solutions fail.
400
402
  Try various ways to achieve the result
401
403
  </task>
402
404
 
@@ -813,7 +815,9 @@ class Navigator implements Agent {
813
815
 
814
816
  const verified = await action.attempt(codeBlock, message);
815
817
  const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
816
- results.push({ code: codeBlock, passed: verified, proof });
818
+ const result: AssertionResult = { code: codeBlock, passed: verified, proof };
819
+ if (!verified && action.lastError) result.error = action.lastError.message;
820
+ results.push(result);
817
821
 
818
822
  if (verified) {
819
823
  tag('success').log('Verification passed');
@@ -862,6 +866,6 @@ class Navigator implements Agent {
862
866
 
863
867
  type BatchFailure = { code: string; error: string; ariaChanges?: string | null; urlAfter?: string };
864
868
 
865
- export type AssertionResult = { code: string; passed: boolean; proof: string[] };
869
+ export type AssertionResult = { code: string; passed: boolean; proof: string[]; error?: string };
866
870
 
867
871
  export { Navigator };
package/src/ai/pilot.ts CHANGED
@@ -8,7 +8,7 @@ import type Explorer from '../explorer.ts';
8
8
  import type { PlaywrightRecorder } from '../playwright-recorder.ts';
9
9
  import type { StateManager } from '../state-manager.ts';
10
10
  import { Stats } from '../stats.ts';
11
- import { type Test, TestResult, TestStatus } from '../test-plan.ts';
11
+ import { SettledExpectation, type SettledStatus, type Test, TestResult, TestStatus } from '../test-plan.ts';
12
12
  import { collectInteractiveNodes } from '../utils/aria.ts';
13
13
  import { ErrorPageError } from '../utils/error-page.ts';
14
14
  import { createDebug, tag } from '../utils/logger.ts';
@@ -39,6 +39,12 @@ const OUTCOME_STATUS: Record<string, SettledStatus> = {
39
39
  'The run shows this outcome did not happen.': 'failed',
40
40
  };
41
41
 
42
+ const CONTRADICTION_RESOLUTION: Record<string, { status: SettledStatus; resolution: string }> = {
43
+ 'The page structure agrees with the screenshot, and the outcome happened.': { status: 'passed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
44
+ 'The page structure agrees with the screenshot, and the outcome did not happen.': { status: 'failed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
45
+ 'The page structure agrees with the run log: the page holds it, but the screenshot does not show it.': { status: 'contradiction', resolution: 'the page structure agrees with the run log: the page holds it but does not display it, a likely rendering bug' },
46
+ };
47
+
42
48
  export class Pilot implements Agent {
43
49
  emoji = '🧭';
44
50
  private provider: Provider;
@@ -619,7 +625,7 @@ export class Pilot implements Agent {
619
625
  let settledByJudge = new Map<string, SettledStatus>();
620
626
  if (!image) settledByJudge = await this.settleByJudge(task, undecided);
621
627
  undecided = undecided.filter((text) => !settledByJudge.has(text));
622
- if (!undecided.length) return task.expected.map((text) => ({ text, status: settledByJudge.get(text) || decided(text) }));
628
+ if (!undecided.length) return task.expected.map((text) => new SettledExpectation(text, settledByJudge.get(text) || decided(text)));
623
629
 
624
630
  const schema = z.object({
625
631
  outcomes: z.array(
@@ -691,14 +697,36 @@ export class Pilot implements Agent {
691
697
  if (!response) response = await settle(userContent, this.provider.getAgenticModel('pilot'));
692
698
 
693
699
  const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome]));
694
- return task.expected.map((text) => {
700
+ const settled = task.expected.map((text) => {
695
701
  const byJudge = settledByJudge.get(text);
696
- if (byJudge) return { text, status: byJudge };
697
- if (!undecided.includes(text)) return { text, status: decided(text) };
702
+ if (byJudge) return new SettledExpectation(text, byJudge);
703
+ if (!undecided.includes(text)) return new SettledExpectation(text, decided(text));
698
704
  const outcome = judged.get(text) as { status: SettledStatus; evidence?: string } | undefined;
699
- if (!outcome) return { text, status: 'unverified' as SettledStatus };
700
- return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
705
+ if (!outcome) return new SettledExpectation(text, 'unverified');
706
+ return new SettledExpectation(text, outcome.status || 'unverified', outcome.evidence);
701
707
  });
708
+ return this.resolveContradictions(settled, finalState);
709
+ }
710
+
711
+ private async resolveContradictions(outcomes: SettledExpectation[], finalState?: ActionResult): Promise<SettledExpectation[]> {
712
+ const judge = this.judge;
713
+ if (!judge || !finalState) return outcomes;
714
+ if (!outcomes.some((outcome) => outcome.contradicted)) return outcomes;
715
+
716
+ const page = finalState.getCompactARIA().slice(0, JUDGE_PAGE_CAP);
717
+ return Promise.all(
718
+ outcomes.map(async (outcome) => {
719
+ if (!outcome.contradicted) return outcome;
720
+ const decision = await judge.decide(`The screenshot and the run log disagree about the expected outcome: ${outcome.text}. Which side does the page structure support?`, [...Object.keys(CONTRADICTION_RESOLUTION), UNDECIDED], {
721
+ expectation: outcome.text,
722
+ disagreement: outcome.evidence || '',
723
+ page,
724
+ });
725
+ const resolved = CONTRADICTION_RESOLUTION[decision.value ?? ''];
726
+ if (!resolved) return outcome;
727
+ return outcome.resolve(resolved.status, resolved.resolution);
728
+ })
729
+ );
702
730
  }
703
731
 
704
732
  private async settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>> {
@@ -1162,6 +1190,9 @@ export class Pilot implements Agent {
1162
1190
  const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
1163
1191
  if (consoleError) line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
1164
1192
 
1193
+ const disabledReason = t.output?.disabledReason?.[0];
1194
+ if (disabledReason) line += `\n shown on hover of disabled target: ${disabledReason.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
1195
+
1165
1196
  return line;
1166
1197
  })
1167
1198
  .join('\n\n');
@@ -1272,11 +1303,3 @@ export class Pilot implements Agent {
1272
1303
  `;
1273
1304
  }
1274
1305
  }
1275
-
1276
- export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
1277
-
1278
- export interface SettledExpectation {
1279
- text: string;
1280
- status: SettledStatus;
1281
- evidence?: string;
1282
- }