explorbot 0.5.3 → 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/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/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 +4 -0
- package/dist/src/ai/driller.js +2 -28
- 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 +34 -7
- package/dist/src/ai/researcher.js +10 -8
- package/dist/src/ai/tools.js +6 -1
- package/dist/src/explorbot.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/web-sandbox.js +4 -4
- package/docs/doc-collection/basics.md +13 -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 +4 -0
- package/src/ai/driller.ts +2 -33
- package/src/ai/navigator.ts +15 -11
- package/src/ai/pilot.ts +35 -15
- package/src/ai/researcher.ts +10 -9
- package/src/ai/tools.ts +5 -1
- package/src/explorbot.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/web-sandbox.ts +4 -4
|
@@ -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
|
}
|
|
@@ -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
|
@@ -548,6 +548,10 @@ export const attachStepLogger = (target, assertionsTarget) => {
|
|
|
548
548
|
if (batchFailed)
|
|
549
549
|
return;
|
|
550
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
|
+
}
|
|
551
555
|
if (error) {
|
|
552
556
|
executed.error = errorToString(error);
|
|
553
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);
|
|
@@ -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 };
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -119,10 +119,10 @@ class Navigator {
|
|
|
119
119
|
const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
|
|
120
120
|
return matchesNavigationUrl(expectedUrl, currentUrl);
|
|
121
121
|
}
|
|
122
|
-
async visit(url) {
|
|
123
|
-
return this.visitOnce(url);
|
|
122
|
+
async visit(url, opts = {}) {
|
|
123
|
+
return this.visitOnce(url, opts.recover !== false);
|
|
124
124
|
}
|
|
125
|
-
async visitOnce(url) {
|
|
125
|
+
async visitOnce(url, recover) {
|
|
126
126
|
try {
|
|
127
127
|
const action = this.explorer.action();
|
|
128
128
|
await action.execute(`I.amOnPage('${url}')`);
|
|
@@ -131,7 +131,10 @@ class Navigator {
|
|
|
131
131
|
throw new Error(`Navigation to "${url}" opened an empty page. The target must be a valid URL path starting with "/".`);
|
|
132
132
|
}
|
|
133
133
|
await this.hooksRunner.runBeforeHook('navigator', url);
|
|
134
|
-
if (!
|
|
134
|
+
if (!recover && action.lastError)
|
|
135
|
+
throw new Error(`Navigation to ${url} failed: ${action.lastError.message}`);
|
|
136
|
+
const arrived = this.isOnExpectedPage(url, action.stateManager);
|
|
137
|
+
if (recover && !arrived) {
|
|
135
138
|
const actualPath = action.stateManager.getCurrentState()?.url || '';
|
|
136
139
|
const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState());
|
|
137
140
|
const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
|
|
@@ -154,6 +157,7 @@ class Navigator {
|
|
|
154
157
|
}
|
|
155
158
|
await this.explorer.capture({ screenshot: true });
|
|
156
159
|
await this.hooksRunner.runAfterHook('navigator', url);
|
|
160
|
+
return arrived || recover;
|
|
157
161
|
}
|
|
158
162
|
catch (error) {
|
|
159
163
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -252,7 +256,7 @@ class Navigator {
|
|
|
252
256
|
return;
|
|
253
257
|
}
|
|
254
258
|
tag('operation').log('Feeding failures back to AI for a new batch...');
|
|
255
|
-
conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
|
|
259
|
+
conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, action.actionResult ?? actionResult));
|
|
256
260
|
htmlContextAdded = true;
|
|
257
261
|
codeBlocks = [];
|
|
258
262
|
batchFailures.length = 0;
|
|
@@ -347,10 +351,6 @@ class Navigator {
|
|
|
347
351
|
|
|
348
352
|
<page>
|
|
349
353
|
${actionResult.toAiContext()}
|
|
350
|
-
|
|
351
|
-
<page_html>
|
|
352
|
-
${await actionResult.combinedHtml()}
|
|
353
|
-
</page_html>
|
|
354
354
|
</page>
|
|
355
355
|
|
|
356
356
|
<task>
|
|
@@ -360,6 +360,8 @@ class Navigator {
|
|
|
360
360
|
Propose different solutions to achieve the result.
|
|
361
361
|
Solution should be valid CodeceptJS code.
|
|
362
362
|
Use only data from the <page> context to plan the solution.
|
|
363
|
+
The page is given as an ARIA snapshot only: use ARIA and text locators from it.
|
|
364
|
+
Full HTML is provided if these solutions fail.
|
|
363
365
|
Try various ways to achieve the result
|
|
364
366
|
</task>
|
|
365
367
|
|
|
@@ -722,7 +724,10 @@ class Navigator {
|
|
|
722
724
|
await action.exitIframe();
|
|
723
725
|
const verified = await action.attempt(codeBlock, message);
|
|
724
726
|
const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
|
|
725
|
-
|
|
727
|
+
const result = { code: codeBlock, passed: verified, proof };
|
|
728
|
+
if (!verified && action.lastError)
|
|
729
|
+
result.error = action.lastError.message;
|
|
730
|
+
results.push(result);
|
|
726
731
|
if (verified) {
|
|
727
732
|
tag('success').log('Verification passed');
|
|
728
733
|
successfulCodes.push(codeBlock);
|
package/dist/src/ai/pilot.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { RequestStore } from '../api/request-store.js';
|
|
|
3
3
|
import type Explorer from '../explorer.js';
|
|
4
4
|
import type { PlaywrightRecorder } from '../playwright-recorder.js';
|
|
5
5
|
import type { StateManager } from '../state-manager.js';
|
|
6
|
-
import { type Test } from '../test-plan.js';
|
|
6
|
+
import { SettledExpectation, type SettledStatus, type Test } from '../test-plan.js';
|
|
7
7
|
import type { Agent, AgentDeps } from './agent.js';
|
|
8
8
|
import type { Conversation } from './conversation.js';
|
|
9
9
|
import type { Fisherman } from './fisherman.js';
|
|
@@ -44,6 +44,7 @@ export declare class Pilot implements Agent {
|
|
|
44
44
|
periodicAnalyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
45
45
|
analyzeProgress(task: Test, currentState: ActionResult, testerConversation: Conversation): Promise<string>;
|
|
46
46
|
settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]>;
|
|
47
|
+
resolveContradictions(outcomes: SettledExpectation[], finalState?: ActionResult): Promise<SettledExpectation[]>;
|
|
47
48
|
settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>>;
|
|
48
49
|
formatExpectations(task: Test): string;
|
|
49
50
|
sendToPilot(userText: string, functionId: string, opts: {
|
|
@@ -104,9 +105,3 @@ export declare class Pilot implements Agent {
|
|
|
104
105
|
formatActions(toolCalls: any[]): string;
|
|
105
106
|
getSystemPrompt(task: Test, initialState: ActionResult): string;
|
|
106
107
|
}
|
|
107
|
-
export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
|
|
108
|
-
export interface SettledExpectation {
|
|
109
|
-
text: string;
|
|
110
|
-
status: SettledStatus;
|
|
111
|
-
evidence?: string;
|
|
112
|
-
}
|