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.
- package/bin/explorbot-cli.ts +3 -0
- package/boat/doc-collector/src/config.ts +1 -0
- package/boat/doc-collector/src/docbot.ts +10 -1
- package/boat/prima/src/envelope.ts +12 -6
- package/boat/prima/src/help.ts +4 -2
- package/boat/prima/src/prima.ts +37 -4
- package/boat/prima/src/session-log.ts +3 -3
- package/dist/bin/explorbot-cli.js +2 -0
- package/dist/boat/doc-collector/src/docbot.js +11 -1
- package/dist/boat/prima/src/envelope.js +12 -3
- package/dist/boat/prima/src/help.js +4 -2
- package/dist/boat/prima/src/prima.js +37 -4
- package/dist/boat/prima/src/session-log.js +3 -3
- package/dist/package.json +1 -1
- package/dist/rules/navigator/output.md +3 -3
- package/dist/src/action.js +6 -0
- package/dist/src/ai/driller.js +2 -28
- package/dist/src/ai/historian/codeceptjs.js +2 -0
- package/dist/src/ai/judge-tool.js +5 -1
- package/dist/src/ai/navigator.d.ts +5 -2
- package/dist/src/ai/navigator.js +15 -10
- package/dist/src/ai/pilot.d.ts +2 -7
- package/dist/src/ai/pilot.js +37 -7
- package/dist/src/ai/rerunner.js +3 -1
- package/dist/src/ai/researcher.js +10 -8
- package/dist/src/ai/rules.js +1 -1
- package/dist/src/ai/tester.js +11 -0
- package/dist/src/ai/tools.js +23 -7
- package/dist/src/config.d.ts +10 -3
- package/dist/src/config.js +42 -6
- package/dist/src/explorbot.js +5 -1
- package/dist/src/explorer.d.ts +2 -0
- package/dist/src/explorer.js +33 -1
- package/dist/src/global-config.js +1 -1
- package/dist/src/test-plan.d.ts +11 -0
- package/dist/src/test-plan.js +21 -0
- package/dist/src/utils/code-extractor.d.ts +1 -0
- package/dist/src/utils/code-extractor.js +40 -0
- package/dist/src/utils/html.d.ts +2 -0
- package/dist/src/utils/html.js +36 -1
- package/dist/src/utils/mdq/cli.js +3 -1
- package/dist/src/utils/test-files.d.ts +5 -0
- package/dist/src/utils/test-files.js +28 -1
- package/dist/src/utils/web-sandbox.js +4 -4
- package/docs/doc-collection/basics.md +13 -0
- package/docs/reference/commands.md +12 -0
- package/docs/web-testing/researcher.md +8 -0
- package/package.json +1 -1
- package/rules/navigator/output.md +3 -3
- package/src/action.ts +6 -0
- package/src/ai/driller.ts +2 -33
- package/src/ai/historian/codeceptjs.ts +2 -0
- package/src/ai/judge-tool.ts +5 -1
- package/src/ai/navigator.ts +15 -11
- package/src/ai/pilot.ts +38 -15
- package/src/ai/rerunner.ts +3 -1
- package/src/ai/researcher.ts +10 -9
- package/src/ai/rules.ts +1 -1
- package/src/ai/tester.ts +12 -0
- package/src/ai/tools.ts +21 -12
- package/src/config.ts +48 -6
- package/src/explorbot.ts +3 -1
- package/src/explorer.ts +37 -2
- package/src/global-config.ts +1 -1
- package/src/test-plan.ts +23 -0
- package/src/utils/code-extractor.ts +41 -0
- package/src/utils/html.ts +33 -1
- package/src/utils/mdq/cli.ts +3 -2
- package/src/utils/test-files.ts +27 -1
- package/src/utils/web-sandbox.ts +4 -4
package/bin/explorbot-cli.ts
CHANGED
|
@@ -56,6 +56,7 @@ interface CLIOptions {
|
|
|
56
56
|
debug?: boolean;
|
|
57
57
|
config?: string;
|
|
58
58
|
path?: string;
|
|
59
|
+
baseUrl?: string;
|
|
59
60
|
show?: boolean;
|
|
60
61
|
headless?: boolean;
|
|
61
62
|
incognito?: boolean;
|
|
@@ -66,6 +67,7 @@ interface CLIOptions {
|
|
|
66
67
|
function buildExplorBotOptions(from: string | undefined, options: CLIOptions): ExplorBotOptions {
|
|
67
68
|
return {
|
|
68
69
|
from,
|
|
70
|
+
baseUrl: options.baseUrl,
|
|
69
71
|
verbose: options.verbose || options.debug,
|
|
70
72
|
config: options.config,
|
|
71
73
|
path: options.path,
|
|
@@ -83,6 +85,7 @@ function addCommonOptions(cmd: Command): Command {
|
|
|
83
85
|
.option('--debug', 'Enable debug logging (same as --verbose)')
|
|
84
86
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
85
87
|
.option('-p, --path <path>', 'Working directory path')
|
|
88
|
+
.option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
|
|
86
89
|
.option('-s, --show', 'Show browser window')
|
|
87
90
|
.option('--headless', 'Run browser in headless mode')
|
|
88
91
|
.option('--incognito', 'Run without recording experiences')
|
|
@@ -118,9 +118,11 @@ class DocBot {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
const light = this.useLightResearch();
|
|
121
122
|
const research = await this.explorBot.agentResearcher().research(state, {
|
|
122
|
-
screenshot: this.shouldUseScreenshots(),
|
|
123
|
+
screenshot: !light && this.shouldUseScreenshots(),
|
|
123
124
|
force: true,
|
|
125
|
+
light,
|
|
124
126
|
});
|
|
125
127
|
const pagePath = this.getPageFilePath(state.url);
|
|
126
128
|
const captureState = this.shouldUseScreenshots()
|
|
@@ -221,6 +223,13 @@ class DocBot {
|
|
|
221
223
|
return true;
|
|
222
224
|
}
|
|
223
225
|
|
|
226
|
+
private useLightResearch(): boolean {
|
|
227
|
+
const research = this.config.docs?.research;
|
|
228
|
+
if (research === 'light') return true;
|
|
229
|
+
if (research === 'full') return false;
|
|
230
|
+
return this.config.docs?.interactive !== true;
|
|
231
|
+
}
|
|
232
|
+
|
|
224
233
|
private shouldCollapseTemplates(override?: boolean): boolean {
|
|
225
234
|
if (override === false) {
|
|
226
235
|
return false;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import type { SettledExpectation } from '../../../src/test-plan.ts';
|
|
3
4
|
|
|
4
5
|
export const STATUS_FILE = 'status.json';
|
|
5
6
|
|
|
@@ -26,16 +27,16 @@ export interface EnvelopeData {
|
|
|
26
27
|
ok: boolean;
|
|
27
28
|
command: string;
|
|
28
29
|
used?: string[];
|
|
29
|
-
page: { url: string; previousUrl?: string; title: string; state: string; visits: number };
|
|
30
|
+
page: { url: string; previousUrl?: string; redirectedFrom?: string; title: string; state: string; visits: number };
|
|
30
31
|
changes?: string | null;
|
|
31
32
|
steps?: Array<{ label: string; ok: boolean; unconfirmed?: boolean; proof: string }>;
|
|
32
|
-
expectations?:
|
|
33
|
+
expectations?: SettledExpectation[];
|
|
33
34
|
warning?: string;
|
|
34
35
|
stepFiles?: string;
|
|
35
36
|
value?: string;
|
|
36
37
|
answer?: string;
|
|
37
38
|
research?: string;
|
|
38
|
-
assertions?: Array<{ code: string; passed: boolean; proof: string[] }>;
|
|
39
|
+
assertions?: Array<{ code: string; passed: boolean; proof: string[]; error?: string }>;
|
|
39
40
|
failure?: { error: string; compactAria?: string };
|
|
40
41
|
instance: InstanceInfo;
|
|
41
42
|
status?: string;
|
|
@@ -88,12 +89,13 @@ function renderResult(data: EnvelopeData): string {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
function renderPage(data: EnvelopeData): string {
|
|
91
|
-
const { url, previousUrl, title, state, visits } = data.page;
|
|
92
|
+
const { url, previousUrl, redirectedFrom, title, state, visits } = data.page;
|
|
92
93
|
const urlLabel = `url: ${url}`;
|
|
93
94
|
const stateLabel = `state: ${state}`;
|
|
94
95
|
const width = Math.max(urlLabel.length, stateLabel.length) + 3;
|
|
95
96
|
let changedMarker = '';
|
|
96
97
|
if (previousUrl && previousUrl !== url) changedMarker = `(changed: ${previousUrl} → ${url})`;
|
|
98
|
+
if (redirectedFrom) changedMarker = `(redirected: ${redirectedFrom} → ${url})`;
|
|
97
99
|
const lines = [align(urlLabel, changedMarker, width), `title: ${title}`, align(stateLabel, `(visit #${visits})`, width)];
|
|
98
100
|
return section('Page', lines.join('\n'));
|
|
99
101
|
}
|
|
@@ -129,7 +131,8 @@ function renderExpectations(data: EnvelopeData): string | null {
|
|
|
129
131
|
const lines: string[] = [];
|
|
130
132
|
data.expectations.forEach((expectation, index) => {
|
|
131
133
|
lines.push(`${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
|
|
132
|
-
if (expectation.
|
|
134
|
+
if (expectation.resolution) lines.push(` resolved: ${expectation.resolution}`);
|
|
135
|
+
if (!expectation.contradicted && !expectation.failed && !expectation.resolution) return;
|
|
133
136
|
for (const line of (expectation.evidence || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
|
|
134
137
|
});
|
|
135
138
|
return section('Expected outcomes', lines.join('\n'));
|
|
@@ -153,7 +156,10 @@ function renderOutcome(data: EnvelopeData): string | null {
|
|
|
153
156
|
.map((line) => line.trim())
|
|
154
157
|
.filter((line) => line && !line.startsWith('//'))
|
|
155
158
|
.join(' ');
|
|
156
|
-
return `${code} =>
|
|
159
|
+
if (assertion.passed) return `${code} => PASSED`;
|
|
160
|
+
const reason = assertion.error?.split('\n').find(Boolean);
|
|
161
|
+
if (!reason) return `${code} => FAILED`;
|
|
162
|
+
return `${code} => FAILED\n ${reason}`;
|
|
157
163
|
});
|
|
158
164
|
|
|
159
165
|
const proof = data.assertions.flatMap((assertion) => assertion.proof);
|
package/boat/prima/src/help.ts
CHANGED
|
@@ -27,8 +27,10 @@ export const checkHelp = dedent`
|
|
|
27
27
|
### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
|
|
28
28
|
"not verified" means never checked, not false.
|
|
29
29
|
Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
When screenshot and log disagree, the page structure breaks the tie: the outcome
|
|
31
|
+
becomes PASSED or FAILED with a "resolved:" line naming the side that was wrong.
|
|
32
|
+
CONTRADICTION remains when it cannot be broken, or when the page holds something
|
|
33
|
+
it does not display; judge the html, aria and screenshot under ### Artifacts yourself.
|
|
32
34
|
ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
|
|
33
35
|
reported as such rather than as an app failure.
|
|
34
36
|
Side issues found on the way go under ### Answer, not as step failures.
|
package/boat/prima/src/prima.ts
CHANGED
|
@@ -239,6 +239,10 @@ export class Prima {
|
|
|
239
239
|
|
|
240
240
|
if (aiError) return this.failureEnvelope(command, aiError, previousState);
|
|
241
241
|
|
|
242
|
+
if (trace.length && ledger.some((entry) => entry.status === 'open')) {
|
|
243
|
+
await this.judgeUnreportedInstructions(ledger, trace);
|
|
244
|
+
}
|
|
245
|
+
|
|
242
246
|
if (trace.length && ledger.some((entry) => entry.status === 'open')) {
|
|
243
247
|
await this.settleLedger(conversation, provider, ledger, trace);
|
|
244
248
|
}
|
|
@@ -310,6 +314,32 @@ export class Prima {
|
|
|
310
314
|
return true;
|
|
311
315
|
}
|
|
312
316
|
|
|
317
|
+
private async judgeUnreportedInstructions(ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): Promise<void> {
|
|
318
|
+
const judge = this.bot.judge?.();
|
|
319
|
+
if (!judge) return;
|
|
320
|
+
|
|
321
|
+
const current = this.bot.stateManager().getCurrentState();
|
|
322
|
+
let page = '';
|
|
323
|
+
if (current) page = ((await this.refAriaSnapshot(ActionResult.fromState(current))) || '').slice(0, JUDGE_PAGE_CAP);
|
|
324
|
+
const actions = trace
|
|
325
|
+
.map((step) => {
|
|
326
|
+
if (step.ok) return `ok ${step.label}`;
|
|
327
|
+
return `FAIL ${step.label}`;
|
|
328
|
+
})
|
|
329
|
+
.join('\n');
|
|
330
|
+
const state = { actions, finalUrl: current?.url || '', finalPage: page };
|
|
331
|
+
|
|
332
|
+
const open = ledger.filter((entry) => entry.status === 'open');
|
|
333
|
+
const decisions = await Promise.all(open.map((entry) => judge.decide(`The actions that ran and the final page show this instruction was carried out: ${entry.text}`, null, state)));
|
|
334
|
+
|
|
335
|
+
open.forEach((entry, index) => {
|
|
336
|
+
if (decisions[index].rejected) return;
|
|
337
|
+
entry.status = 'done';
|
|
338
|
+
entry.proof = 'judged from the actions that ran and the final page';
|
|
339
|
+
trace.push({ label: `done: ${entry.text}`, ok: true, proof: entry.proof });
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
313
343
|
private async settleLedger(conversation: any, provider: any, ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): Promise<void> {
|
|
314
344
|
conversation.addUserText(dedent`
|
|
315
345
|
The run is over and these instructions were never reported:
|
|
@@ -376,8 +406,8 @@ export class Prima {
|
|
|
376
406
|
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.';
|
|
377
407
|
}
|
|
378
408
|
|
|
379
|
-
const unreached = envelope.expectations.filter((expectation) => expectation.
|
|
380
|
-
const contradicted = envelope.expectations.filter((expectation) => expectation.
|
|
409
|
+
const unreached = envelope.expectations.filter((expectation) => expectation.failed);
|
|
410
|
+
const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
|
|
381
411
|
envelope.ok = !unreached.length && !contradicted.length;
|
|
382
412
|
|
|
383
413
|
const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
|
|
@@ -455,9 +485,10 @@ export class Prima {
|
|
|
455
485
|
}
|
|
456
486
|
|
|
457
487
|
let navigationError: unknown = null;
|
|
488
|
+
let arrived = true;
|
|
458
489
|
|
|
459
490
|
try {
|
|
460
|
-
await this.bot.agentNavigator().visit(target);
|
|
491
|
+
arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
|
|
461
492
|
} catch (error) {
|
|
462
493
|
navigationError = error;
|
|
463
494
|
}
|
|
@@ -467,7 +498,9 @@ export class Prima {
|
|
|
467
498
|
const used: string[] = [];
|
|
468
499
|
if (isUrl) used.push(code);
|
|
469
500
|
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
470
|
-
|
|
501
|
+
const envelope = await this.successEnvelope(command, used, result, previousState);
|
|
502
|
+
if (!arrived) envelope.page.redirectedFrom = target;
|
|
503
|
+
return envelope;
|
|
471
504
|
}
|
|
472
505
|
|
|
473
506
|
async browserStart(): Promise<void> {
|
|
@@ -4,7 +4,7 @@ import { outputPath } from '../../../src/config.ts';
|
|
|
4
4
|
import { safeFilename } from '../../../src/utils/strings.ts';
|
|
5
5
|
import { type EnvelopeData, renderEnvelope } from './envelope.ts';
|
|
6
6
|
|
|
7
|
-
const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped' };
|
|
7
|
+
const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped', contradiction: 'failed' };
|
|
8
8
|
|
|
9
9
|
export function sessionsDir(): string {
|
|
10
10
|
return outputPath('prima', 'sessions');
|
|
@@ -74,7 +74,7 @@ function commandSteps(envelope: EnvelopeData): any[] {
|
|
|
74
74
|
|
|
75
75
|
for (const expectation of envelope.expectations || []) {
|
|
76
76
|
const entry: any = { category: 'user', title: `expected: ${expectation.text}`, status: EXPECTATION_STATUS[expectation.status], duration: 0 };
|
|
77
|
-
if (expectation.
|
|
77
|
+
if (expectation.failed) entry.error = 'the run did not reach this outcome';
|
|
78
78
|
steps.push(entry);
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -82,7 +82,7 @@ function commandSteps(envelope: EnvelopeData): any[] {
|
|
|
82
82
|
const entry: any = { category: 'user', title: assertion.code, status: 'passed', duration: 0, log: assertion.proof.join('\n') };
|
|
83
83
|
if (!assertion.passed) {
|
|
84
84
|
entry.status = 'failed';
|
|
85
|
-
entry.error = 'the assertion did not hold on the page';
|
|
85
|
+
entry.error = assertion.error || 'the assertion did not hold on the page';
|
|
86
86
|
}
|
|
87
87
|
steps.push(entry);
|
|
88
88
|
}
|
|
@@ -46,6 +46,7 @@ if (!process.env.EXPLORBOT_NO_BANNER && !process.argv.includes('prima') && !prin
|
|
|
46
46
|
function buildExplorBotOptions(from, options) {
|
|
47
47
|
return {
|
|
48
48
|
from,
|
|
49
|
+
baseUrl: options.baseUrl,
|
|
49
50
|
verbose: options.verbose || options.debug,
|
|
50
51
|
config: options.config,
|
|
51
52
|
path: options.path,
|
|
@@ -62,6 +63,7 @@ function addCommonOptions(cmd) {
|
|
|
62
63
|
.option('--debug', 'Enable debug logging (same as --verbose)')
|
|
63
64
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
64
65
|
.option('-p, --path <path>', 'Working directory path')
|
|
66
|
+
.option('--base-url <url>', 'Run against this URL: its path scopes relative paths (plans, commands, saved tests on rerun), its query params ride along with every page load')
|
|
65
67
|
.option('-s, --show', 'Show browser window')
|
|
66
68
|
.option('--headless', 'Run browser in headless mode')
|
|
67
69
|
.option('--incognito', 'Run without recording experiences')
|
|
@@ -103,9 +103,11 @@ class DocBot {
|
|
|
103
103
|
continue;
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
const light = this.useLightResearch();
|
|
106
107
|
const research = await this.explorBot.agentResearcher().research(state, {
|
|
107
|
-
screenshot: this.shouldUseScreenshots(),
|
|
108
|
+
screenshot: !light && this.shouldUseScreenshots(),
|
|
108
109
|
force: true,
|
|
110
|
+
light,
|
|
109
111
|
});
|
|
110
112
|
const pagePath = this.getPageFilePath(state.url);
|
|
111
113
|
const captureState = this.shouldUseScreenshots()
|
|
@@ -197,6 +199,14 @@ class DocBot {
|
|
|
197
199
|
}
|
|
198
200
|
return true;
|
|
199
201
|
}
|
|
202
|
+
useLightResearch() {
|
|
203
|
+
const research = this.config.docs?.research;
|
|
204
|
+
if (research === 'light')
|
|
205
|
+
return true;
|
|
206
|
+
if (research === 'full')
|
|
207
|
+
return false;
|
|
208
|
+
return this.config.docs?.interactive !== true;
|
|
209
|
+
}
|
|
200
210
|
shouldCollapseTemplates(override) {
|
|
201
211
|
if (override === false) {
|
|
202
212
|
return false;
|
|
@@ -52,13 +52,15 @@ function renderResult(data) {
|
|
|
52
52
|
return section('Result', lines.join('\n'));
|
|
53
53
|
}
|
|
54
54
|
function renderPage(data) {
|
|
55
|
-
const { url, previousUrl, title, state, visits } = data.page;
|
|
55
|
+
const { url, previousUrl, redirectedFrom, title, state, visits } = data.page;
|
|
56
56
|
const urlLabel = `url: ${url}`;
|
|
57
57
|
const stateLabel = `state: ${state}`;
|
|
58
58
|
const width = Math.max(urlLabel.length, stateLabel.length) + 3;
|
|
59
59
|
let changedMarker = '';
|
|
60
60
|
if (previousUrl && previousUrl !== url)
|
|
61
61
|
changedMarker = `(changed: ${previousUrl} → ${url})`;
|
|
62
|
+
if (redirectedFrom)
|
|
63
|
+
changedMarker = `(redirected: ${redirectedFrom} → ${url})`;
|
|
62
64
|
const lines = [align(urlLabel, changedMarker, width), `title: ${title}`, align(stateLabel, `(visit #${visits})`, width)];
|
|
63
65
|
return section('Page', lines.join('\n'));
|
|
64
66
|
}
|
|
@@ -96,7 +98,9 @@ function renderExpectations(data) {
|
|
|
96
98
|
const lines = [];
|
|
97
99
|
data.expectations.forEach((expectation, index) => {
|
|
98
100
|
lines.push(`${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
|
|
99
|
-
if (expectation.
|
|
101
|
+
if (expectation.resolution)
|
|
102
|
+
lines.push(` resolved: ${expectation.resolution}`);
|
|
103
|
+
if (!expectation.contradicted && !expectation.failed && !expectation.resolution)
|
|
100
104
|
return;
|
|
101
105
|
for (const line of (expectation.evidence || '').split('\n').filter(Boolean))
|
|
102
106
|
lines.push(` ${line}`);
|
|
@@ -123,7 +127,12 @@ function renderOutcome(data) {
|
|
|
123
127
|
.map((line) => line.trim())
|
|
124
128
|
.filter((line) => line && !line.startsWith('//'))
|
|
125
129
|
.join(' ');
|
|
126
|
-
|
|
130
|
+
if (assertion.passed)
|
|
131
|
+
return `${code} => PASSED`;
|
|
132
|
+
const reason = assertion.error?.split('\n').find(Boolean);
|
|
133
|
+
if (!reason)
|
|
134
|
+
return `${code} => FAILED`;
|
|
135
|
+
return `${code} => FAILED\n ${reason}`;
|
|
127
136
|
});
|
|
128
137
|
const proof = data.assertions.flatMap((assertion) => assertion.proof);
|
|
129
138
|
if (proof.length)
|
|
@@ -25,8 +25,10 @@ export const checkHelp = dedent `
|
|
|
25
25
|
### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
|
|
26
26
|
"not verified" means never checked, not false.
|
|
27
27
|
Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
When screenshot and log disagree, the page structure breaks the tie: the outcome
|
|
29
|
+
becomes PASSED or FAILED with a "resolved:" line naming the side that was wrong.
|
|
30
|
+
CONTRADICTION remains when it cannot be broken, or when the page holds something
|
|
31
|
+
it does not display; judge the html, aria and screenshot under ### Artifacts yourself.
|
|
30
32
|
ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
|
|
31
33
|
reported as such rather than as an app failure.
|
|
32
34
|
Side issues found on the way go under ### Answer, not as step failures.
|
|
@@ -220,6 +220,9 @@ export class Prima {
|
|
|
220
220
|
}
|
|
221
221
|
if (aiError)
|
|
222
222
|
return this.failureEnvelope(command, aiError, previousState);
|
|
223
|
+
if (trace.length && ledger.some((entry) => entry.status === 'open')) {
|
|
224
|
+
await this.judgeUnreportedInstructions(ledger, trace);
|
|
225
|
+
}
|
|
223
226
|
if (trace.length && ledger.some((entry) => entry.status === 'open')) {
|
|
224
227
|
await this.settleLedger(conversation, provider, ledger, trace);
|
|
225
228
|
}
|
|
@@ -283,6 +286,32 @@ export class Prima {
|
|
|
283
286
|
}
|
|
284
287
|
return true;
|
|
285
288
|
}
|
|
289
|
+
async judgeUnreportedInstructions(ledger, trace) {
|
|
290
|
+
const judge = this.bot.judge?.();
|
|
291
|
+
if (!judge)
|
|
292
|
+
return;
|
|
293
|
+
const current = this.bot.stateManager().getCurrentState();
|
|
294
|
+
let page = '';
|
|
295
|
+
if (current)
|
|
296
|
+
page = ((await this.refAriaSnapshot(ActionResult.fromState(current))) || '').slice(0, JUDGE_PAGE_CAP);
|
|
297
|
+
const actions = trace
|
|
298
|
+
.map((step) => {
|
|
299
|
+
if (step.ok)
|
|
300
|
+
return `ok ${step.label}`;
|
|
301
|
+
return `FAIL ${step.label}`;
|
|
302
|
+
})
|
|
303
|
+
.join('\n');
|
|
304
|
+
const state = { actions, finalUrl: current?.url || '', finalPage: page };
|
|
305
|
+
const open = ledger.filter((entry) => entry.status === 'open');
|
|
306
|
+
const decisions = await Promise.all(open.map((entry) => judge.decide(`The actions that ran and the final page show this instruction was carried out: ${entry.text}`, null, state)));
|
|
307
|
+
open.forEach((entry, index) => {
|
|
308
|
+
if (decisions[index].rejected)
|
|
309
|
+
return;
|
|
310
|
+
entry.status = 'done';
|
|
311
|
+
entry.proof = 'judged from the actions that ran and the final page';
|
|
312
|
+
trace.push({ label: `done: ${entry.text}`, ok: true, proof: entry.proof });
|
|
313
|
+
});
|
|
314
|
+
}
|
|
286
315
|
async settleLedger(conversation, provider, ledger, trace) {
|
|
287
316
|
conversation.addUserText(dedent `
|
|
288
317
|
The run is over and these instructions were never reported:
|
|
@@ -343,8 +372,8 @@ export class Prima {
|
|
|
343
372
|
if (!result.screenshot || !this.visionEnabled()) {
|
|
344
373
|
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.';
|
|
345
374
|
}
|
|
346
|
-
const unreached = envelope.expectations.filter((expectation) => expectation.
|
|
347
|
-
const contradicted = envelope.expectations.filter((expectation) => expectation.
|
|
375
|
+
const unreached = envelope.expectations.filter((expectation) => expectation.failed);
|
|
376
|
+
const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
|
|
348
377
|
envelope.ok = !unreached.length && !contradicted.length;
|
|
349
378
|
const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
|
|
350
379
|
if (problems.length)
|
|
@@ -413,8 +442,9 @@ export class Prima {
|
|
|
413
442
|
return judged;
|
|
414
443
|
}
|
|
415
444
|
let navigationError = null;
|
|
445
|
+
let arrived = true;
|
|
416
446
|
try {
|
|
417
|
-
await this.bot.agentNavigator().visit(target);
|
|
447
|
+
arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
|
|
418
448
|
}
|
|
419
449
|
catch (error) {
|
|
420
450
|
navigationError = error;
|
|
@@ -425,7 +455,10 @@ export class Prima {
|
|
|
425
455
|
if (isUrl)
|
|
426
456
|
used.push(code);
|
|
427
457
|
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
428
|
-
|
|
458
|
+
const envelope = await this.successEnvelope(command, used, result, previousState);
|
|
459
|
+
if (!arrived)
|
|
460
|
+
envelope.page.redirectedFrom = target;
|
|
461
|
+
return envelope;
|
|
429
462
|
}
|
|
430
463
|
async browserStart() {
|
|
431
464
|
const config = await this.loadConfig();
|
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { outputPath } from "../../../src/config.js";
|
|
4
4
|
import { safeFilename } from "../../../src/utils/strings.js";
|
|
5
5
|
import { renderEnvelope } from "./envelope.js";
|
|
6
|
-
const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped' };
|
|
6
|
+
const EXPECTATION_STATUS = { passed: 'passed', failed: 'failed', unverified: 'skipped', contradiction: 'failed' };
|
|
7
7
|
export function sessionsDir() {
|
|
8
8
|
return outputPath('prima', 'sessions');
|
|
9
9
|
}
|
|
@@ -64,7 +64,7 @@ function commandSteps(envelope) {
|
|
|
64
64
|
}
|
|
65
65
|
for (const expectation of envelope.expectations || []) {
|
|
66
66
|
const entry = { category: 'user', title: `expected: ${expectation.text}`, status: EXPECTATION_STATUS[expectation.status], duration: 0 };
|
|
67
|
-
if (expectation.
|
|
67
|
+
if (expectation.failed)
|
|
68
68
|
entry.error = 'the run did not reach this outcome';
|
|
69
69
|
steps.push(entry);
|
|
70
70
|
}
|
|
@@ -72,7 +72,7 @@ function commandSteps(envelope) {
|
|
|
72
72
|
const entry = { category: 'user', title: assertion.code, status: 'passed', duration: 0, log: assertion.proof.join('\n') };
|
|
73
73
|
if (!assertion.passed) {
|
|
74
74
|
entry.status = 'failed';
|
|
75
|
-
entry.error = 'the assertion did not hold on the page';
|
|
75
|
+
entry.error = assertion.error || 'the assertion did not hold on the page';
|
|
76
76
|
}
|
|
77
77
|
steps.push(entry);
|
|
78
78
|
}
|
package/dist/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<rules>
|
|
2
|
-
Do not invent locators, focus only on locators from
|
|
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
|
|
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
|
|
27
|
+
Use only locators from the <page> context.
|
|
28
28
|
</output>
|
|
29
29
|
|
|
30
30
|
|
package/dist/src/action.js
CHANGED
|
@@ -105,6 +105,7 @@ class Action {
|
|
|
105
105
|
if (includeScreenshot) {
|
|
106
106
|
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
107
107
|
const screenshotPath = join(statesDir, filename);
|
|
108
|
+
await page?.screencast?.hideOverlays().catch(() => { });
|
|
108
109
|
screenshotFile = await page
|
|
109
110
|
?.screenshot({ path: screenshotPath, fullPage: true })
|
|
110
111
|
.then(() => {
|
|
@@ -115,6 +116,7 @@ class Action {
|
|
|
115
116
|
debugLog('Screenshot failed, continuing without it:', err);
|
|
116
117
|
return undefined;
|
|
117
118
|
});
|
|
119
|
+
await page?.screencast?.showOverlays().catch(() => { });
|
|
118
120
|
}
|
|
119
121
|
// Save HTML to file
|
|
120
122
|
const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
|
|
@@ -546,6 +548,10 @@ export const attachStepLogger = (target, assertionsTarget) => {
|
|
|
546
548
|
if (batchFailed)
|
|
547
549
|
return;
|
|
548
550
|
const executed = { command: step.toCode(), success: !error };
|
|
551
|
+
if (Object.keys(step.opts || {}).length) {
|
|
552
|
+
const args = [step.humanizeArgs(), `step.opts(${JSON.stringify(step.opts)})`].filter(Boolean).join(', ');
|
|
553
|
+
executed.command = `${step.prefix}${step.actor}.${step.title}(${args})${step.suffix}`;
|
|
554
|
+
}
|
|
549
555
|
if (error) {
|
|
550
556
|
executed.error = errorToString(error);
|
|
551
557
|
batchFailed = true;
|
package/dist/src/ai/driller.js
CHANGED
|
@@ -6,6 +6,7 @@ import { setActivity } from "../activity.js";
|
|
|
6
6
|
import { Observability } from "../observability.js";
|
|
7
7
|
import { Plan, Test, TestResult } from "../test-plan.js";
|
|
8
8
|
import { collectInteractiveNodes } from "../utils/aria.js";
|
|
9
|
+
import { splitTopLevel } from "../utils/code-extractor.js";
|
|
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.js";
|
|
10
11
|
import { createDebug, tag } from "../utils/logger.js";
|
|
11
12
|
import { loop, pause } from "../utils/loop.js";
|
|
@@ -933,34 +934,7 @@ function countTopLevelArgCount(line) {
|
|
|
933
934
|
const end = line.lastIndexOf(')');
|
|
934
935
|
if (start === -1 || end === -1 || end <= start + 1)
|
|
935
936
|
return 0;
|
|
936
|
-
|
|
937
|
-
let count = 1;
|
|
938
|
-
let depth = 0;
|
|
939
|
-
let quote = '';
|
|
940
|
-
for (let i = 0; i < body.length; i++) {
|
|
941
|
-
const char = body[i];
|
|
942
|
-
const escaped = body[i - 1] === '\\';
|
|
943
|
-
if (quote) {
|
|
944
|
-
if (char === quote && !escaped)
|
|
945
|
-
quote = '';
|
|
946
|
-
continue;
|
|
947
|
-
}
|
|
948
|
-
if (char === '"' || char === "'" || char === '`') {
|
|
949
|
-
quote = char;
|
|
950
|
-
continue;
|
|
951
|
-
}
|
|
952
|
-
if (char === '(' || char === '[' || char === '{') {
|
|
953
|
-
depth++;
|
|
954
|
-
continue;
|
|
955
|
-
}
|
|
956
|
-
if (char === ')' || char === ']' || char === '}') {
|
|
957
|
-
depth = Math.max(0, depth - 1);
|
|
958
|
-
continue;
|
|
959
|
-
}
|
|
960
|
-
if (char === ',' && depth === 0)
|
|
961
|
-
count++;
|
|
962
|
-
}
|
|
963
|
-
return count;
|
|
937
|
+
return splitTopLevel(line.slice(start + 1, end), ',').length;
|
|
964
938
|
}
|
|
965
939
|
function buildClassSelector(tag, classes) {
|
|
966
940
|
const safeClasses = classes.filter((cls) => /^[a-z0-9_-]+$/i.test(cls)).slice(0, 5);
|
|
@@ -6,6 +6,7 @@ import { tag } from "../../utils/logger.js";
|
|
|
6
6
|
import { relativeToCwd } from "../../utils/next-steps.js";
|
|
7
7
|
import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from "../../utils/step-analyzer.js";
|
|
8
8
|
import { safeFilename } from "../../utils/strings.js";
|
|
9
|
+
import { ensureCodeceptResolvable } from "../../utils/test-files.js";
|
|
9
10
|
import { ASSERTION_TOOLS } from "../tools.js";
|
|
10
11
|
import { escapeString, getExecutionLabel } from "./utils.js";
|
|
11
12
|
export function WithCodeceptJS(Base) {
|
|
@@ -79,6 +80,7 @@ export function WithCodeceptJS(Base) {
|
|
|
79
80
|
}
|
|
80
81
|
const testsDir = ConfigParser.getInstance().getTestsDir();
|
|
81
82
|
mkdirSync(testsDir, { recursive: true });
|
|
83
|
+
ensureCodeceptResolvable(testsDir);
|
|
82
84
|
const filePath = join(testsDir, safeFilename(plan.title, '.js'));
|
|
83
85
|
writeFileSync(filePath, lines.join('\n'));
|
|
84
86
|
this.savedFiles.add(filePath);
|
|
@@ -9,7 +9,11 @@ export function createJudgeTool(deps, buildState) {
|
|
|
9
9
|
return {
|
|
10
10
|
judge: tool({
|
|
11
11
|
description: dedent `
|
|
12
|
-
|
|
12
|
+
Fast yes/no or pick-one decision about the current page, answered from <page_aria> and your recent steps.
|
|
13
|
+
No screenshot, no HTML, produces no test code.
|
|
14
|
+
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.
|
|
15
|
+
Not for final assertions (use verify/finish) and not for anything page_aria does not show, such as styling or visual state (use see).
|
|
16
|
+
Phrase the question literally and concretely.
|
|
13
17
|
`,
|
|
14
18
|
inputSchema: z.object({
|
|
15
19
|
question: z.string().describe('The statement to confirm, or the question the options answer'),
|
|
@@ -35,8 +35,10 @@ declare class Navigator implements Agent {
|
|
|
35
35
|
}, expectedUrl: string): string;
|
|
36
36
|
isSameExpectedOrigin(expectedUrl: string, stateManager: any): boolean;
|
|
37
37
|
isOnExpectedPage(expectedUrl: string, stateManager: any): boolean;
|
|
38
|
-
visit(url: string
|
|
39
|
-
|
|
38
|
+
visit(url: string, opts?: {
|
|
39
|
+
recover?: boolean;
|
|
40
|
+
}): Promise<boolean>;
|
|
41
|
+
visitOnce(url: string, recover: boolean): Promise<boolean>;
|
|
40
42
|
navigationError(url: string, fallback: string): Error;
|
|
41
43
|
failureReason(stopReason: string | null, knowledge: string, url: string): string | null;
|
|
42
44
|
resolveState(message: string, actionResult: ActionResult, opts?: {
|
|
@@ -98,5 +100,6 @@ export type AssertionResult = {
|
|
|
98
100
|
code: string;
|
|
99
101
|
passed: boolean;
|
|
100
102
|
proof: string[];
|
|
103
|
+
error?: string;
|
|
101
104
|
};
|
|
102
105
|
export { Navigator };
|