explorbot 0.5.8 → 0.5.9

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 (62) hide show
  1. package/bin/explorbot-cli.ts +23 -3
  2. package/boat/prima/README.md +3 -0
  3. package/boat/prima/src/cli.ts +7 -1
  4. package/boat/prima/src/envelope.ts +18 -4
  5. package/boat/prima/src/help.ts +15 -4
  6. package/boat/prima/src/prima.ts +181 -25
  7. package/dist/bin/explorbot-cli.js +29 -3
  8. package/dist/boat/prima/src/cli.js +6 -1
  9. package/dist/boat/prima/src/envelope.js +16 -3
  10. package/dist/boat/prima/src/help.js +14 -4
  11. package/dist/boat/prima/src/prima.js +191 -27
  12. package/dist/package.json +1 -1
  13. package/dist/rules/navigator/verification-actions.md +5 -3
  14. package/dist/rules/planner/ignored-pages.md +6 -0
  15. package/dist/rules/quartermaster/ux/accessibility.md +8 -0
  16. package/dist/rules/quartermaster/ux/charts.md +6 -0
  17. package/dist/rules/quartermaster/ux/copy.md +9 -0
  18. package/dist/rules/quartermaster/ux/forms.md +7 -0
  19. package/dist/rules/quartermaster/ux/interaction.md +7 -0
  20. package/dist/rules/quartermaster/ux/motion.md +5 -0
  21. package/dist/rules/quartermaster/ux/navigation.md +7 -0
  22. package/dist/rules/quartermaster/ux/readability.md +6 -0
  23. package/dist/rules/quartermaster/ux/responsive.md +7 -0
  24. package/dist/src/ai/judge.d.ts +3 -1
  25. package/dist/src/ai/judge.js +13 -3
  26. package/dist/src/ai/navigator.js +3 -2
  27. package/dist/src/ai/pilot.js +10 -4
  28. package/dist/src/ai/planner.js +9 -1
  29. package/dist/src/ai/provider.d.ts +4 -0
  30. package/dist/src/ai/provider.js +27 -5
  31. package/dist/src/ai/quartermaster.d.ts +31 -3
  32. package/dist/src/ai/quartermaster.js +262 -17
  33. package/dist/src/ai/tester.js +6 -3
  34. package/dist/src/explorbot.d.ts +1 -1
  35. package/dist/src/explorbot.js +8 -13
  36. package/dist/src/explorer.js +1 -0
  37. package/dist/src/global-config.js +6 -2
  38. package/dist/src/utils/ux-probe.d.ts +25 -0
  39. package/dist/src/utils/ux-probe.js +155 -0
  40. package/package.json +1 -1
  41. package/rules/navigator/verification-actions.md +5 -3
  42. package/rules/planner/ignored-pages.md +6 -0
  43. package/rules/quartermaster/ux/accessibility.md +8 -0
  44. package/rules/quartermaster/ux/charts.md +6 -0
  45. package/rules/quartermaster/ux/copy.md +9 -0
  46. package/rules/quartermaster/ux/forms.md +7 -0
  47. package/rules/quartermaster/ux/interaction.md +7 -0
  48. package/rules/quartermaster/ux/motion.md +5 -0
  49. package/rules/quartermaster/ux/navigation.md +7 -0
  50. package/rules/quartermaster/ux/readability.md +6 -0
  51. package/rules/quartermaster/ux/responsive.md +7 -0
  52. package/src/ai/judge.ts +11 -3
  53. package/src/ai/navigator.ts +2 -2
  54. package/src/ai/pilot.ts +10 -4
  55. package/src/ai/planner.ts +10 -1
  56. package/src/ai/provider.ts +29 -6
  57. package/src/ai/quartermaster.ts +300 -18
  58. package/src/ai/tester.ts +5 -3
  59. package/src/explorbot.ts +9 -13
  60. package/src/explorer.ts +1 -0
  61. package/src/global-config.ts +5 -2
  62. package/src/utils/ux-probe.ts +163 -0
@@ -10,7 +10,7 @@ const EXPECTATION_LABELS = {
10
10
  contradiction: 'CONTRADICTION',
11
11
  };
12
12
  export function renderEnvelope(data) {
13
- const sections = [renderResult(data), renderPage(data), renderValue(data), renderChanges(data), renderSteps(data), renderExpectations(data), renderWarning(data), renderOutcome(data), ...renderFailure(data), renderInstance(data), renderArtifacts(data)];
13
+ const sections = [renderResult(data), renderPage(data), renderValue(data), renderChanges(data), renderSteps(data), renderStates(data), renderExpectations(data), renderWarning(data), renderOutcome(data), renderPageIssues(data), ...renderFailure(data), renderInstance(data), renderArtifacts(data)];
14
14
  return sections.filter((section) => section).join('\n\n');
15
15
  }
16
16
  export function writeArtifacts(dir, snapshot) {
@@ -84,7 +84,10 @@ function renderSteps(data) {
84
84
  mark = 'ok ';
85
85
  if (step.unconfirmed)
86
86
  mark = '?? ';
87
- lines.push(`${index + 1}. ${mark} ${step.label}`);
87
+ let label = step.label;
88
+ if (step.state)
89
+ label += ` // state ${step.state}`;
90
+ lines.push(`${index + 1}. ${mark} ${label}`);
88
91
  for (const line of (step.proof || '').split('\n').filter(Boolean))
89
92
  lines.push(` ${line}`);
90
93
  });
@@ -92,6 +95,11 @@ function renderSteps(data) {
92
95
  lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{${Object.values(STEP_FILES).join(',')}}`);
93
96
  return section('Steps', lines.join('\n'));
94
97
  }
98
+ function renderStates(data) {
99
+ if (!data.states?.length)
100
+ return null;
101
+ return section('States', data.states.map((state, index) => `${index + 1}. ${state.url}\n aria: ${state.aria}`).join('\n'));
102
+ }
95
103
  function renderExpectations(data) {
96
104
  if (!data.expectations?.length)
97
105
  return null;
@@ -100,7 +108,7 @@ function renderExpectations(data) {
100
108
  lines.push(`${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
101
109
  if (expectation.resolution)
102
110
  lines.push(` resolved: ${expectation.resolution}`);
103
- if (!expectation.contradicted && !expectation.failed && !expectation.resolution)
111
+ if (expectation.status === 'passed' && !expectation.resolution)
104
112
  return;
105
113
  for (const line of (expectation.evidence || '').split('\n').filter(Boolean))
106
114
  lines.push(` ${line}`);
@@ -139,6 +147,11 @@ function renderOutcome(data) {
139
147
  lines.push('', 'playwright:', ...proof);
140
148
  return section('Assertions', lines.join('\n'));
141
149
  }
150
+ function renderPageIssues(data) {
151
+ if (!data.pageIssues?.length)
152
+ return null;
153
+ return section('Page issues', ['Noticed on the page while running, unrelated to the steps or the outcome:', ...data.pageIssues.map((issue) => `- ${issue}`)].join('\n'));
154
+ }
142
155
  function renderFailure(data) {
143
156
  if (!data.failure)
144
157
  return [];
@@ -23,15 +23,17 @@ export const checkHelp = dedent `
23
23
  --expected one required outcome, repeatable for several. Without it the
24
24
  scenario text is the outcome. Each returns under
25
25
  ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
26
- "not verified" means never checked, not false.
26
+ "not verified" means never checked, not false; the line under it names
27
+ what kept the run from checking it.
27
28
  Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
28
29
  When screenshot and log disagree, the page structure breaks the tie: the outcome
29
30
  becomes PASSED or FAILED with a "resolved:" line naming the side that was wrong.
30
31
  CONTRADICTION remains when it cannot be broken, or when the page holds something
31
32
  it does not display; judge the html, aria and screenshot under ### Artifacts yourself.
32
- ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
33
- reported as such rather than as an app failure.
34
- Side issues found on the way go under ### Answer, not as step failures.
33
+ ok is false when any outcome FAILED or CONTRADICTED, when none was verified, or when
34
+ the run could not finish, reported as such rather than as an app failure.
35
+ ### Steps lists only the steps that failed.
36
+ Side issues found on the way go under ### Page issues, not as step failures.
35
37
  `;
36
38
  export const doHelp = dedent `
37
39
  ### Steps marks each instruction ok, FAIL or ??. ?? means it ran but the run ended
@@ -49,6 +51,14 @@ export const verifyHelp = dedent `
49
51
  export const researchHelp = dedent `
50
52
  The map is saved per page state and joins later commands there, so one research run pays for all that follow it.
51
53
  `;
54
+ export const uxHelp = dedent `
55
+ Reviews one area, not the page. A named focus is shown first with at most one click,
56
+ which may open a dialog, tab or section or go to another page; nothing is submitted. The area reviewed is the dialog or region open on the page,
57
+ else the named focus, else where the session's commands on this page acted, else main.
58
+ Measures it at the current width, 768px and 375px, then restores the viewport.
59
+ Findings come under ### Answer by priority; the review reports problems and never
60
+ proposes wording or visual style. ok stays true whatever it finds.
61
+ `;
52
62
  export const statusHelp = dedent `
53
63
  Reads recorded files, so it needs no browser and outlives the session.
54
64
  The hash is matched across all recorded sites. ### Artifacts lists every kept
@@ -18,7 +18,7 @@ import { ExplorBot } from "../../../src/explorbot.js";
18
18
  import { findSiteWith, listSites } from "../../../src/global-config.js";
19
19
  import { Reporter } from "../../../src/reporter.js";
20
20
  import { Stats } from "../../../src/stats.js";
21
- import { Task, Test, TestResult } from "../../../src/test-plan.js";
21
+ import { Task, Test } from "../../../src/test-plan.js";
22
22
  import { ariaRefSelector, ariaRefSnapshot, describeRef, parseAriaRefs, refIsGone } from "../../../src/utils/aria-ref.js";
23
23
  import { compactAriaSnapshot } from "../../../src/utils/aria.js";
24
24
  import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
@@ -34,6 +34,7 @@ const ITERATIONS_PER_INSTRUCTION = 2;
34
34
  const MAX_INSTRUCTION_ITERATIONS = 24;
35
35
  const CONTEXT_HTML_CAP = 6000;
36
36
  const MAX_TOOL_ROUNDTRIPS = 5;
37
+ const UX_SESSION_COMMANDS = 8;
37
38
  const AI_AGENT_NAME = 'prima';
38
39
  const CONNECT_TIMEOUT = 3000;
39
40
  const requireLib = createRequire(import.meta.url);
@@ -182,11 +183,14 @@ export class Prima {
182
183
  if (!unreported || nudged)
183
184
  break;
184
185
  nudged = true;
186
+ let reminder = 'Do not act again on anything you have already carried out.';
187
+ if (!trace.length)
188
+ reminder = 'Nothing has been carried out yet: text in a reply is never executed, only tool calls are. Act with tools.';
185
189
  conversation.addUserText(dedent `
186
190
  These instructions are still unreported:
187
191
  ${unreported}
188
192
 
189
- Report each one with completed() or blocked(). Do not act again on anything you have already carried out.
193
+ Report each one with completed() or blocked(). ${reminder}
190
194
  `);
191
195
  continue;
192
196
  }
@@ -234,7 +238,9 @@ export class Prima {
234
238
  return envelope;
235
239
  }
236
240
  if (!trace.length && unfinished.length === ledger.length) {
237
- const reason = ['No action was performed for these instructions on the current page.', narration].filter(Boolean).join(' ');
241
+ let reason = 'No action was performed for these instructions on the current page.';
242
+ if (narration)
243
+ reason += `\nThe model replied with text instead of calling a tool:\n${narration}`;
238
244
  return this.failureEnvelope(command, reason, previousState);
239
245
  }
240
246
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
@@ -353,28 +359,78 @@ export class Prima {
353
359
  const guard = await this.aiGuard(command);
354
360
  if (guard)
355
361
  return guard;
356
- const previousState = await this.baselineState();
357
362
  const outcomes = expected.length ? expected : [scenario];
363
+ const preparation = await this.preparationSteps(scenario, outcomes);
364
+ let prepared = null;
365
+ if (preparation.length) {
366
+ prepared = await this.do(preparation, command);
367
+ if (!prepared.ok) {
368
+ prepared.failure = { error: `the check never ran, because preparing the page for it failed:\n${prepared.failure?.error || ''}` };
369
+ return prepared;
370
+ }
371
+ }
372
+ const previousState = await this.baselineState();
358
373
  const test = new Test(scenario, 'normal', outcomes, previousState?.url || this.options.url || '');
359
374
  const tester = this.bot.agentTester();
360
- await tester.test(test, { startOnCurrentPage: true });
375
+ const states = [];
376
+ const visited = new Map();
377
+ const timeline = [];
378
+ const trackState = (state) => {
379
+ if (!state?.hash)
380
+ return;
381
+ let number = visited.get(state.hash);
382
+ if (!number) {
383
+ number = visited.size + 1;
384
+ visited.set(state.hash, number);
385
+ const aria = path.join(this.statusDir(), `state-${number}.aria.yml`);
386
+ mkdirSync(this.statusDir(), { recursive: true });
387
+ writeFileSync(aria, state.ariaSnapshot || '', 'utf-8');
388
+ states.push({ url: state.url, aria });
389
+ }
390
+ timeline.push({ time: performance.now(), state: number });
391
+ };
392
+ trackState(previousState);
393
+ const stopTracking = this.bot.stateManager().onStateChange((transition) => trackState(transition.toState));
394
+ await tester.test(test, { startOnCurrentPage: true }).finally(stopTracking);
361
395
  const notes = Object.values(test.notes || {});
362
396
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState(), { screenshot: this.visionEnabled() });
363
397
  const envelope = await this.reportEnvelope(command, result, previousState, {});
364
- const recorded = notes.filter((note) => !note.observation && !outcomes.includes(note.message));
365
- const failed = recorded.filter((note) => note.status === TestResult.FAILED);
366
- envelope.steps = failed.map((note) => ({ label: note.message, ok: false, proof: note.log || '' }));
367
- const routine = recorded.length - failed.length;
368
- if (routine)
369
- envelope.steps.push({ label: `${routine} further ${pluralize(routine, 'step')} ran without failing — prima status ${envelope.status} for the full log`, ok: true, proof: '' });
398
+ const repeats = new Map();
399
+ for (const [startedAt, step] of Object.entries(test.steps || {})) {
400
+ const ok = step.status !== 'failed';
401
+ const state = timeline.findLast((entry) => entry.time <= Number.parseFloat(startedAt))?.state;
402
+ const key = `${ok} ${state} ${step.text}`;
403
+ let proof = '';
404
+ if (!ok)
405
+ proof = step.error || '';
406
+ repeats.set(key, { message: step.text, ok, count: (repeats.get(key)?.count || 0) + 1, proof, state });
407
+ }
408
+ const preparedSteps = (prepared?.steps || []).map((step) => ({ ...step, label: `prepare: ${step.label}` }));
409
+ envelope.steps = [
410
+ ...preparedSteps,
411
+ ...[...repeats.values()].map(({ message, ok, count, proof, state }) => {
412
+ let label = message;
413
+ if (count > 1)
414
+ label += ` (×${count})`;
415
+ return { label, ok, proof, state };
416
+ }),
417
+ ];
418
+ if (prepared?.stepFiles)
419
+ envelope.stepFiles = prepared.stepFiles;
420
+ if (states.length)
421
+ envelope.states = states;
370
422
  envelope.expectations = await this.bot.agentPilot().settleExpectations(test, result);
371
423
  if (!result.screenshot || !this.visionEnabled()) {
372
424
  envelope.warning = 'These outcomes were settled from the run log alone — no screenshot backed them. Set ai.visionModel, or check anything visual with prima ask.';
373
425
  }
374
426
  const unreached = envelope.expectations.filter((expectation) => expectation.failed);
375
427
  const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
376
- envelope.ok = !unreached.length && !contradicted.length;
428
+ const unverified = envelope.expectations.filter((expectation) => expectation.status === 'unverified');
429
+ const nothingVerified = unverified.length === envelope.expectations.length;
430
+ envelope.ok = !unreached.length && !contradicted.length && !nothingVerified;
377
431
  const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
432
+ if (nothingVerified)
433
+ problems.push(...unverified.map((expectation) => `not verified: ${expectation.text}`));
378
434
  if (problems.length)
379
435
  envelope.failure = { error: problems.join('\n') };
380
436
  if (!test.hasFinished || test.isSkipped) {
@@ -383,7 +439,7 @@ export class Prima {
383
439
  }
384
440
  const observations = notes.filter((note) => note.observation).map((note) => note.message);
385
441
  if (observations.length)
386
- envelope.answer = ['Page problems noticed while running, not step failures:', ...observations.map((line) => `- ${line}`)].join('\n');
442
+ envelope.pageIssues = observations;
387
443
  return envelope;
388
444
  }
389
445
  async ask(question) {
@@ -425,6 +481,31 @@ export class Prima {
425
481
  const uiMap = await researcher.research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
426
482
  return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
427
483
  }
484
+ async ux(focus) {
485
+ const command = ['ux', focus].filter(Boolean).join(' ');
486
+ const guard = await this.aiGuard(command);
487
+ if (guard)
488
+ return guard;
489
+ const previousState = await this.baselineState();
490
+ let clicked = [];
491
+ if (focus) {
492
+ const shown = await this.showFocus(focus, previousState);
493
+ if (!shown)
494
+ return this.failureEnvelope(command, `Could not show "${focus}": it is not on this page and no single click from here leads to it, so no review was made.`, previousState);
495
+ clicked = shown;
496
+ }
497
+ const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
498
+ let auditFocus = focus;
499
+ if (result.overlay.isOpen && result.overlay.describe() !== previousState?.overlay?.describe())
500
+ auditFocus = undefined;
501
+ const answer = await this.bot.agentQuartermaster().auditUx(result, { focus: auditFocus, commands: this.sessionCommandsOn(result.url) });
502
+ const envelope = await this.reportEnvelope(command, result, previousState, { answer });
503
+ if (clicked.length)
504
+ envelope.steps = clicked.map((code) => ({ label: code, ok: true, proof: '' }));
505
+ if (!this.visionEnabled())
506
+ envelope.warning = 'No screenshot backed this review, so visual checks were skipped. Set ai.visionModel to include them.';
507
+ return envelope;
508
+ }
428
509
  async go(target) {
429
510
  const command = `go ${target}`;
430
511
  const code = `I.amOnPage('${target}')`;
@@ -443,7 +524,7 @@ export class Prima {
443
524
  let navigationError = null;
444
525
  let arrived = true;
445
526
  try {
446
- arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
527
+ arrived = await this.bot.agentNavigator().visit(target, { recover: !this.aiUnavailable() });
447
528
  }
448
529
  catch (error) {
449
530
  navigationError = error;
@@ -455,8 +536,11 @@ export class Prima {
455
536
  used.push(code);
456
537
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
457
538
  const envelope = await this.successEnvelope(command, used, result, previousState);
458
- if (!arrived)
459
- envelope.page.redirectedFrom = target;
539
+ if (arrived)
540
+ return envelope;
541
+ envelope.page.redirectedFrom = target;
542
+ envelope.ok = false;
543
+ envelope.failure = { error: `${target} was not reached: the page redirected to ${result.url} and no AI model is available to recover (log in, follow knowledge)` };
460
544
  return envelope;
461
545
  }
462
546
  async browserCheck() {
@@ -473,7 +557,7 @@ export class Prima {
473
557
  this.sessionUrl = site.url;
474
558
  const config = await this.loadConfig();
475
559
  const parser = ConfigParser.getInstance();
476
- return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json });
560
+ return ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json });
477
561
  }
478
562
  record(envelope, durationMs) {
479
563
  if (!this.session)
@@ -817,6 +901,40 @@ export class Prima {
817
901
  },
818
902
  });
819
903
  }
904
+ async preparationSteps(scenario, outcomes) {
905
+ const result = await this.capturedResult(await this.baselineState());
906
+ const page = compactAriaSnapshot(result.ariaSnapshot, true).slice(0, JUDGE_PAGE_CAP);
907
+ const judge = this.bot.judge?.();
908
+ if (judge) {
909
+ const question = 'Before this scenario can start, the current page needs actions the scenario does not perform itself: reaching the page where it starts, or setting up a precondition it relies on.';
910
+ const needed = await judge.decide(question, null, { scenario, outcomes, url: result.url, page });
911
+ if (needed.rejected)
912
+ return [];
913
+ }
914
+ const prompt = dedent `
915
+ <scenario>
916
+ ${scenario}
917
+ </scenario>
918
+
919
+ <expected_outcomes>
920
+ ${outcomes.map((outcome) => `- ${outcome}`).join('\n')}
921
+ </expected_outcomes>
922
+
923
+ <page>
924
+ url: ${result.url}
925
+ ${page}
926
+ </page>
927
+
928
+ A check of this scenario runs next, starting from the current page. Decide whether the page must be prepared first.
929
+ Preparation is only what the scenario assumes but does not do: reaching the page where it starts when this is a different one,
930
+ or setting up a precondition it relies on. Never include the actions the scenario describes — the check performs and judges those.
931
+ List the preparation as short imperative instructions, in order. Return an empty list when the scenario can start here.
932
+ `;
933
+ const schema = z.object({ steps: z.array(z.string()).describe('preparation instructions, in order; empty when none are needed') });
934
+ const provider = this.bot.getProvider();
935
+ const response = await provider.generateObject([{ role: 'user', content: prompt }], schema, provider.getAgenticModel?.(AI_AGENT_NAME), { agentName: AI_AGENT_NAME }).catch(() => null);
936
+ return response?.object?.steps || [];
937
+ }
820
938
  async pageContext(result) {
821
939
  const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
822
940
  const map = getPreviousResearch(result.baseHash);
@@ -867,13 +985,8 @@ export class Prima {
867
985
  const pick = await judge.decide(`Which listed control leads to: ${target}`, [...labels, UNDECIDED], { target });
868
986
  if (!pick.value)
869
987
  return null;
870
- const ref = refs[labels.indexOf(pick.value)].ref;
871
- const explorer = this.bot.getExplorer();
872
- if (await refIsGone(explorer, ref))
873
- return null;
874
- const named = await describeRef(explorer, ref);
875
- const run = `I.usePlaywrightTo(${JSON.stringify(`go to ${target}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
876
- if (!(await explorer.action().attempt(run, `go ${target}`)))
988
+ const code = await this.clickRef(refs[labels.indexOf(pick.value)].ref, `go to ${target}`);
989
+ if (!code)
877
990
  return null;
878
991
  const newState = this.bot.stateManager().getCurrentState();
879
992
  if (!newState)
@@ -884,11 +997,62 @@ export class Prima {
884
997
  const arrived = await judge.decide(`The page now shows: ${target}`, null, { target, page });
885
998
  if (arrived.rejected)
886
999
  return null;
887
- let code = run;
888
- if (named)
889
- code = `I.click(${JSON.stringify(named)})`;
890
1000
  return this.successEnvelope(`go ${target}`, [code], await this.capturedResult(newState), previousState);
891
1001
  }
1002
+ async showFocus(focus, state) {
1003
+ if (!state)
1004
+ return null;
1005
+ const snapshot = (await this.refAriaSnapshot(ActionResult.fromState(state))) || '';
1006
+ const refs = parseAriaRefs(snapshot).filter((entry) => entry.name);
1007
+ const schema = z.object({
1008
+ shown: z.boolean().describe('whether the page already shows the area'),
1009
+ ref: z.enum(['none', ...refs.map((entry) => entry.ref)]).describe('when it is not shown, the one control whose click leads to it, or none'),
1010
+ });
1011
+ const prompt = dedent `
1012
+ A UX review needs this area on screen: ${focus}
1013
+ Say whether the page already shows it. If not, pick the one control whose single click opens, reveals or navigates to it.
1014
+ Its label may differ from how the area was named: judge by what the control leads to.
1015
+ Answer none when no control plausibly leads there.
1016
+
1017
+ <page_aria>
1018
+ ${snapshot.slice(0, JUDGE_PAGE_CAP)}
1019
+ </page_aria>
1020
+ `;
1021
+ const response = await this.bot
1022
+ .getProvider()
1023
+ .generateObject([{ role: 'user', content: prompt }], schema, undefined, { agentName: AI_AGENT_NAME })
1024
+ .catch(() => null);
1025
+ if (response?.object?.shown)
1026
+ return [];
1027
+ const ref = response?.object?.ref;
1028
+ if (!ref || ref === 'none')
1029
+ return null;
1030
+ const code = await this.clickRef(ref, `show ${focus}`);
1031
+ if (!code)
1032
+ return null;
1033
+ return [code];
1034
+ }
1035
+ async clickRef(ref, purpose) {
1036
+ const explorer = this.bot.getExplorer();
1037
+ if (await refIsGone(explorer, ref))
1038
+ return null;
1039
+ const named = await describeRef(explorer, ref);
1040
+ const run = `I.usePlaywrightTo(${JSON.stringify(purpose)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
1041
+ if (!(await explorer.action().attempt(run, purpose)))
1042
+ return null;
1043
+ if (named)
1044
+ return `I.click(${JSON.stringify(named)})`;
1045
+ return run;
1046
+ }
1047
+ sessionCommandsOn(url) {
1048
+ if (!this.session)
1049
+ return [];
1050
+ const { tests } = readSession(sessionFile(this.session.key));
1051
+ return tests
1052
+ .filter((test) => test.description?.split('\n')[0] === `page: ${url}` && !test.title.startsWith('ux'))
1053
+ .map((test) => test.title)
1054
+ .slice(-UX_SESSION_COMMANDS);
1055
+ }
892
1056
  executedCodes(code) {
893
1057
  if (typeof code !== 'string')
894
1058
  return [];
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -55,9 +55,11 @@ Checks that page title contains expected text.
55
55
  I.seeInSource(<text>)
56
56
 
57
57
  Checks that page source contains expected text (including hidden elements).
58
+ Pass plain text only — never markup, tags, attributes or class names. The HTML you are shown is rewritten
59
+ and will not match the live source character for character. Use it only for text that no visible element carries.
58
60
 
59
61
  <example>
60
- I.seeInSource('<div class="hidden">');
62
+ I.seeInSource('Draft saved');
61
63
  </example>
62
64
 
63
65
  ### I.dontSee
@@ -99,10 +101,10 @@ Checks that an input field does NOT contain the specified value.
99
101
 
100
102
  I.dontSeeInSource(<text>)
101
103
 
102
- Checks that page source does NOT contain expected text.
104
+ Checks that page source does NOT contain expected text. Plain text only, same as I.seeInSource.
103
105
 
104
106
  <example>
105
- I.dontSeeInSource('error-class');
107
+ I.dontSeeInSource('Session expired');
106
108
  </example>
107
109
 
108
110
  ### Asserting the state of a control
@@ -0,0 +1,6 @@
1
+ <ignored_pages>
2
+ Do not plan tests for these pages, return an empty scenarios list:
3
+ - sign in
4
+ - sign up
5
+ - password recovery
6
+ </ignored_pages>
@@ -0,0 +1,8 @@
1
+ <accessibility priority="CRITICAL">
2
+ - Every control has an accessible name; icon-only controls included. Confirm against the ARIA tree, not the picture.
3
+ - Interactive elements are reachable by keyboard in visual order, with a visible focus indicator.
4
+ - Headings form a sequential outline without skipped levels.
5
+ - Meaning never rests on color alone: state, error and status also carry text or an icon.
6
+ - Dialogs and multi-step flows offer a visible way out.
7
+ - Contrast is reported only from axe results, never judged by eye.
8
+ </accessibility>
@@ -0,0 +1,6 @@
1
+ <charts priority="LOW">
2
+ Apply only when a chart or data visualisation is inside the focus area:
3
+ - Series are identified by a legend or direct labels, not by color alone.
4
+ - Exact values are reachable without hover.
5
+ - An empty or failed dataset shows a message, not a blank frame.
6
+ </charts>
@@ -0,0 +1,9 @@
1
+ <copy priority="HIGH">
2
+ Judge what a text does for the user, not how it is phrased:
3
+ - Intent: a label or heading must tell what happens or what the area is for; a text that names no outcome fails.
4
+ - Duplication: two texts in the focus area that say the same thing, or a text that repeats the visible label beside it.
5
+ - Relevance: a text that explains nothing the interface does not already show, or concerns something outside the user's task here.
6
+ - Filler: generic, promotional or self-congratulatory text that could sit unchanged on any product.
7
+ - Overload: more text than the task needs, competing with the controls for attention.
8
+ Quote the text and state which check it fails. Never propose replacement wording.
9
+ </copy>
@@ -0,0 +1,7 @@
1
+ <forms priority="MEDIUM">
2
+ - Each input has a visible label that stays when the user types; a placeholder alone is not a label.
3
+ - Errors appear next to the input they concern and state cause and recovery.
4
+ - Required inputs are marked before submission, not discovered after it.
5
+ - Long or complex forms reveal options progressively instead of all at once.
6
+ - A completed submit confirms its result.
7
+ </forms>
@@ -0,0 +1,7 @@
1
+ <interaction priority="CRITICAL">
2
+ - Targets meet the size and spacing limits in measurements: touch limits at touch widths, pointer limits above.
3
+ - Every action is reachable by click or tap; hover may reveal, never be the only way in.
4
+ - An action that waits on the server shows it is working and blocks a second submit.
5
+ - Destructive or irreversible actions ask for confirmation or offer undo.
6
+ - Disabled controls look disabled and say, or make evident, why.
7
+ </interaction>
@@ -0,0 +1,5 @@
1
+ <motion priority="MEDIUM">
2
+ Judge only what measurements report:
3
+ - Transitions on layout properties, which make content jump while they run.
4
+ - Animations that keep running when the user asks for reduced motion.
5
+ </motion>
@@ -0,0 +1,7 @@
1
+ <navigation priority="HIGH">
2
+ - The current location is marked in the navigation that leads to it.
3
+ - Back behaves predictably; a state worth sharing or returning to has its own URL.
4
+ - Navigation offers few top-level choices; grouping beats a long flat list.
5
+ - Destructive or session-ending items are set apart from ordinary destinations.
6
+ - Modals do not carry primary navigation flows.
7
+ </navigation>
@@ -0,0 +1,6 @@
1
+ <readability priority="MEDIUM">
2
+ Judge only what measurements report:
3
+ - Body text below the minimum size.
4
+ - Multi-line text with cramped line-height.
5
+ - Truncated text that hides meaning and offers no way to read it in full.
6
+ </readability>
@@ -0,0 +1,7 @@
1
+ <responsive priority="HIGH">
2
+ - Compare breakpoints: the focus area must stay usable at every width it was measured at.
3
+ - No horizontal scroll; content fits the viewport width. Zoom must never be disabled.
4
+ - When navigation does not fit, it collapses behind one named, reachable control; nothing primary disappears without a way back to it.
5
+ - On narrow screens core content comes first; secondary panels fold, they do not push the task below the fold.
6
+ - Fixed or sticky bars never cover the content or controls beneath them.
7
+ </responsive>
@@ -5,7 +5,8 @@ export declare const JUDGE_PAGE_CAP = 12000;
5
5
  export declare class Decision {
6
6
  readonly value: string | null;
7
7
  readonly confidence: number;
8
- constructor(value: string | null, confidence: number);
8
+ readonly answered: boolean;
9
+ constructor(value: string | null, confidence: number, answered?: boolean);
9
10
  get approved(): boolean;
10
11
  get rejected(): boolean;
11
12
  }
@@ -23,6 +24,7 @@ export declare class Judge {
23
24
  static fromConfig(config: AIConfig['decisionModel']): Judge | null;
24
25
  get toolEnabled(): boolean;
25
26
  decide(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
27
+ assert(statement: string, state: unknown): Promise<boolean | null>;
26
28
  pick(question: string, options: string[], state: unknown): Promise<number | null>;
27
29
  consult(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
28
30
  request(question: string, options: string[] | boolean | null, state: unknown): Promise<Decision>;
@@ -10,9 +10,11 @@ export const JUDGE_PAGE_CAP = 12000;
10
10
  export class Decision {
11
11
  value;
12
12
  confidence;
13
- constructor(value, confidence) {
13
+ answered;
14
+ constructor(value, confidence, answered = true) {
14
15
  this.value = value;
15
16
  this.confidence = confidence;
17
+ this.answered = answered;
16
18
  }
17
19
  get approved() {
18
20
  return this.value !== null;
@@ -43,9 +45,17 @@ export class Judge {
43
45
  }
44
46
  async decide(question, options, state) {
45
47
  if (!this.enabled.direct)
46
- return new Decision(null, 0);
48
+ return new Decision(null, 0, false);
47
49
  return this.consult(question, options, state);
48
50
  }
51
+ async assert(statement, state) {
52
+ const decision = await this.decide(statement, null, state);
53
+ if (decision.approved)
54
+ return true;
55
+ if (decision.answered && decision.confidence < 1 - this.threshold)
56
+ return false;
57
+ return null;
58
+ }
49
59
  async pick(question, options, state) {
50
60
  if (new Set(options).size < options.length)
51
61
  return null;
@@ -75,7 +85,7 @@ export class Judge {
75
85
  instructions = `${question} ${UNDECIDED_HINT}`;
76
86
  const answer = await this.provider.decide(state, instructions, list).catch((error) => this.recordFailure(error));
77
87
  if (!answer)
78
- return new Decision(null, 0);
88
+ return new Decision(null, 0, false);
79
89
  Observability.getSpan()?.setAttribute('langfuse.observation.output', JSON.stringify(answer));
80
90
  if (answer.probability <= this.threshold)
81
91
  return new Decision(null, answer.probability);
@@ -763,8 +763,9 @@ class Navigator {
763
763
  page?.setDefaultTimeout(originalTimeout);
764
764
  }
765
765
  const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
766
- const majorityNeeded = Math.floor(totalAttempted / 2) + 1;
767
- let verified = successfulCodes.length >= majorityNeeded;
766
+ let verified = successfulCodes.length > 0 && !failures;
767
+ if (successfulCodes.length && failures)
768
+ verified = await this.provider.oracle(message, { assertions: results.map(({ code, passed, error }) => ({ code, passed, error })) }, { agentName: 'navigator', telemetry: { functionId: 'navigator.verifyState' } });
768
769
  if (alreadyVerified)
769
770
  verified = true;
770
771
  const inexpressible = !alreadyVerified && totalAttempted === 0;
@@ -553,8 +553,10 @@ export class Pilot {
553
553
  const schema = z.object({
554
554
  outcomes: z.array(z.object({
555
555
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
556
- status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened to the thing it names, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
557
- evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
556
+ status: z
557
+ .enum(['passed', 'failed', 'unverified', 'contradiction'])
558
+ .describe('passed = the evidence shows it happened to the thing it names, failed = the evidence shows the application did not do it, unverified = the run never established either way, contradiction = the picture and the run disagree'),
559
+ evidence: z.string().nullable().describe('What settled it. For unverified, what stopped the run from checking it. For a contradiction, what each side shows. Null when there is nothing to add'),
558
560
  })),
559
561
  });
560
562
  let pageEvidence = '';
@@ -593,10 +595,14 @@ export class Pilot {
593
595
  The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
594
596
  differently. Judge by what the steps show happened, not by whether the wording matches.
595
597
  Wording may differ, the subject may not. An outcome refers to what the scenario names or chooses; it holds
596
- only when that same thing appears. A different route or a different thing reaching a similar state is "failed",
597
- and so is an outcome about something the run never managed to choose or create.
598
+ only when that same thing appears. A different route or a different thing reaching a similar state is "failed".
599
+ An outcome about something the run never managed to choose or create never passes: it is "failed" when the
600
+ application refused it, "unverified" when the run's own trouble got in the way.
598
601
  Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
599
602
  that is a statement about the run, not about the application.
603
+ "failed" is a statement about the application. When the run's own trouble — an earlier step it could not
604
+ get past, a control it could not reach or operate — left the outcome untested, it is "unverified", and
605
+ the evidence names that blocker.
600
606
  `;
601
607
  const settle = (content, model) => this.provider
602
608
  .generateObject([{ role: 'user', content }], schema, model, {