explorbot 0.5.7 → 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.
- package/bin/explorbot-cli.ts +25 -3
- package/boat/prima/README.md +3 -0
- package/boat/prima/src/cli.ts +7 -1
- package/boat/prima/src/envelope.ts +18 -4
- package/boat/prima/src/help.ts +15 -4
- package/boat/prima/src/prima.ts +181 -25
- package/dist/bin/explorbot-cli.js +31 -3
- package/dist/boat/prima/src/cli.js +6 -1
- package/dist/boat/prima/src/envelope.js +16 -3
- package/dist/boat/prima/src/help.js +14 -4
- package/dist/boat/prima/src/prima.js +191 -27
- package/dist/package.json +1 -1
- package/dist/rules/navigator/verification-actions.md +5 -3
- package/dist/rules/planner/ignored-pages.md +6 -0
- package/dist/rules/quartermaster/ux/accessibility.md +8 -0
- package/dist/rules/quartermaster/ux/charts.md +6 -0
- package/dist/rules/quartermaster/ux/copy.md +9 -0
- package/dist/rules/quartermaster/ux/forms.md +7 -0
- package/dist/rules/quartermaster/ux/interaction.md +7 -0
- package/dist/rules/quartermaster/ux/motion.md +5 -0
- package/dist/rules/quartermaster/ux/navigation.md +7 -0
- package/dist/rules/quartermaster/ux/readability.md +6 -0
- package/dist/rules/quartermaster/ux/responsive.md +7 -0
- package/dist/src/ai/judge.d.ts +3 -1
- package/dist/src/ai/judge.js +13 -3
- package/dist/src/ai/navigator.js +3 -2
- package/dist/src/ai/pilot.js +10 -4
- package/dist/src/ai/planner.js +9 -1
- package/dist/src/ai/provider.d.ts +4 -0
- package/dist/src/ai/provider.js +27 -5
- package/dist/src/ai/quartermaster.d.ts +31 -3
- package/dist/src/ai/quartermaster.js +262 -17
- package/dist/src/ai/tester.js +6 -3
- package/dist/src/commands/explore-command.js +7 -0
- package/dist/src/commands/init-command.d.ts +1 -0
- package/dist/src/commands/init-command.js +87 -36
- package/dist/src/components/InitWizard.d.ts +5 -1
- package/dist/src/components/InitWizard.js +29 -11
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +29 -7
- package/dist/src/explorbot.d.ts +1 -1
- package/dist/src/explorbot.js +8 -13
- package/dist/src/explorer.js +1 -0
- package/dist/src/global-config.js +6 -2
- package/dist/src/utils/ux-probe.d.ts +25 -0
- package/dist/src/utils/ux-probe.js +155 -0
- package/docs/basics/getting-started.md +23 -10
- package/docs/basics/providers.md +2 -0
- package/docs/reference/commands.md +5 -2
- package/docs/reference/configuration.md +1 -0
- package/package.json +1 -1
- package/rules/navigator/verification-actions.md +5 -3
- package/rules/planner/ignored-pages.md +6 -0
- package/rules/quartermaster/ux/accessibility.md +8 -0
- package/rules/quartermaster/ux/charts.md +6 -0
- package/rules/quartermaster/ux/copy.md +9 -0
- package/rules/quartermaster/ux/forms.md +7 -0
- package/rules/quartermaster/ux/interaction.md +7 -0
- package/rules/quartermaster/ux/motion.md +5 -0
- package/rules/quartermaster/ux/navigation.md +7 -0
- package/rules/quartermaster/ux/readability.md +6 -0
- package/rules/quartermaster/ux/responsive.md +7 -0
- package/src/ai/judge.ts +11 -3
- package/src/ai/navigator.ts +2 -2
- package/src/ai/pilot.ts +10 -4
- package/src/ai/planner.ts +10 -1
- package/src/ai/provider.ts +29 -6
- package/src/ai/quartermaster.ts +300 -18
- package/src/ai/tester.ts +5 -3
- package/src/commands/explore-command.ts +8 -0
- package/src/commands/init-command.ts +105 -38
- package/src/components/InitWizard.tsx +43 -11
- package/src/config.ts +32 -7
- package/src/explorbot.ts +9 -13
- package/src/explorer.ts +1 -0
- package/src/global-config.ts +5 -2
- 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
|
-
|
|
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 (
|
|
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,
|
|
33
|
-
reported as such rather than as an app failure.
|
|
34
|
-
|
|
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
|
|
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().
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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.
|
|
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.
|
|
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: !
|
|
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 (
|
|
459
|
-
envelope
|
|
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
|
|
871
|
-
|
|
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
|
@@ -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('
|
|
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('
|
|
107
|
+
I.dontSeeInSource('Session expired');
|
|
106
108
|
</example>
|
|
107
109
|
|
|
108
110
|
### Asserting the state of a control
|
|
@@ -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,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,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>
|
package/dist/src/ai/judge.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|
package/dist/src/ai/judge.js
CHANGED
|
@@ -10,9 +10,11 @@ export const JUDGE_PAGE_CAP = 12000;
|
|
|
10
10
|
export class Decision {
|
|
11
11
|
value;
|
|
12
12
|
confidence;
|
|
13
|
-
|
|
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);
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -763,8 +763,9 @@ class Navigator {
|
|
|
763
763
|
page?.setDefaultTimeout(originalTimeout);
|
|
764
764
|
}
|
|
765
765
|
const totalAttempted = Math.min(codeBlocks.length, this.verifyAttempts);
|
|
766
|
-
|
|
767
|
-
|
|
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;
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -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
|
|
557
|
-
|
|
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
|
-
|
|
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, {
|