explorbot 0.5.3 → 0.5.5
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/README.md +5 -10
- package/boat/prima/package.json +1 -1
- package/boat/prima/src/cli.ts +8 -50
- package/boat/prima/src/envelope.ts +12 -6
- package/boat/prima/src/help.ts +4 -2
- package/boat/prima/src/prima.ts +58 -81
- package/boat/prima/src/session-log.ts +3 -3
- package/dist/boat/doc-collector/src/docbot.js +11 -1
- package/dist/boat/prima/src/cli.js +9 -45
- 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 +58 -77
- 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 +23 -12
- 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/rules.d.ts +1 -0
- package/dist/src/ai/rules.js +7 -0
- 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/error-page.d.ts +1 -0
- package/dist/src/utils/error-page.js +8 -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/mdq/query.d.ts +4 -1
- package/dist/src/utils/mdq/query.js +48 -7
- package/dist/src/utils/web-sandbox.js +4 -4
- package/docs/doc-collection/basics.md +13 -0
- package/docs/reference/commands.md +4 -6
- 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 +23 -13
- package/src/ai/pilot.ts +35 -15
- package/src/ai/researcher.ts +10 -9
- package/src/ai/rules.ts +8 -0
- 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/error-page.ts +8 -0
- package/src/utils/html.ts +33 -1
- package/src/utils/mdq/cli.ts +3 -2
- package/src/utils/mdq/query.ts +48 -7
- package/src/utils/web-sandbox.ts +4 -4
|
@@ -11,7 +11,7 @@ import { JUDGE_PAGE_CAP, UNDECIDED } from "../../../src/ai/judge.js";
|
|
|
11
11
|
import { getPreviousResearch } from "../../../src/ai/researcher/cache.js";
|
|
12
12
|
import { actionRule, locatorRule } from "../../../src/ai/rules.js";
|
|
13
13
|
import { createAgentTools, createCodeceptJSTools, createRefTools } from "../../../src/ai/tools.js";
|
|
14
|
-
import { getAliveEndpoint,
|
|
14
|
+
import { getAliveEndpoint, listInstances } from "../../../src/browser-server.js";
|
|
15
15
|
import { ConfigCommand } from "../../../src/commands/config-command.js";
|
|
16
16
|
import { ConfigMissingError, ConfigParser, EXPLORBOT_ENV_VARS, outputPath } from "../../../src/config.js";
|
|
17
17
|
import { ExplorBot } from "../../../src/explorbot.js";
|
|
@@ -67,7 +67,6 @@ export class Prima {
|
|
|
67
67
|
artifactsDir;
|
|
68
68
|
hash;
|
|
69
69
|
sessionUrl;
|
|
70
|
-
server = null;
|
|
71
70
|
attached = null;
|
|
72
71
|
session = null;
|
|
73
72
|
constructor(options = {}) {
|
|
@@ -220,6 +219,9 @@ export class Prima {
|
|
|
220
219
|
}
|
|
221
220
|
if (aiError)
|
|
222
221
|
return this.failureEnvelope(command, aiError, previousState);
|
|
222
|
+
if (trace.length && ledger.some((entry) => entry.status === 'open')) {
|
|
223
|
+
await this.judgeUnreportedInstructions(ledger, trace);
|
|
224
|
+
}
|
|
223
225
|
if (trace.length && ledger.some((entry) => entry.status === 'open')) {
|
|
224
226
|
await this.settleLedger(conversation, provider, ledger, trace);
|
|
225
227
|
}
|
|
@@ -283,6 +285,32 @@ export class Prima {
|
|
|
283
285
|
}
|
|
284
286
|
return true;
|
|
285
287
|
}
|
|
288
|
+
async judgeUnreportedInstructions(ledger, trace) {
|
|
289
|
+
const judge = this.bot.judge?.();
|
|
290
|
+
if (!judge)
|
|
291
|
+
return;
|
|
292
|
+
const current = this.bot.stateManager().getCurrentState();
|
|
293
|
+
let page = '';
|
|
294
|
+
if (current)
|
|
295
|
+
page = ((await this.refAriaSnapshot(ActionResult.fromState(current))) || '').slice(0, JUDGE_PAGE_CAP);
|
|
296
|
+
const actions = trace
|
|
297
|
+
.map((step) => {
|
|
298
|
+
if (step.ok)
|
|
299
|
+
return `ok ${step.label}`;
|
|
300
|
+
return `FAIL ${step.label}`;
|
|
301
|
+
})
|
|
302
|
+
.join('\n');
|
|
303
|
+
const state = { actions, finalUrl: current?.url || '', finalPage: page };
|
|
304
|
+
const open = ledger.filter((entry) => entry.status === 'open');
|
|
305
|
+
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)));
|
|
306
|
+
open.forEach((entry, index) => {
|
|
307
|
+
if (decisions[index].rejected)
|
|
308
|
+
return;
|
|
309
|
+
entry.status = 'done';
|
|
310
|
+
entry.proof = 'judged from the actions that ran and the final page';
|
|
311
|
+
trace.push({ label: `done: ${entry.text}`, ok: true, proof: entry.proof });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
286
314
|
async settleLedger(conversation, provider, ledger, trace) {
|
|
287
315
|
conversation.addUserText(dedent `
|
|
288
316
|
The run is over and these instructions were never reported:
|
|
@@ -343,8 +371,8 @@ export class Prima {
|
|
|
343
371
|
if (!result.screenshot || !this.visionEnabled()) {
|
|
344
372
|
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
373
|
}
|
|
346
|
-
const unreached = envelope.expectations.filter((expectation) => expectation.
|
|
347
|
-
const contradicted = envelope.expectations.filter((expectation) => expectation.
|
|
374
|
+
const unreached = envelope.expectations.filter((expectation) => expectation.failed);
|
|
375
|
+
const contradicted = envelope.expectations.filter((expectation) => expectation.contradicted);
|
|
348
376
|
envelope.ok = !unreached.length && !contradicted.length;
|
|
349
377
|
const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
|
|
350
378
|
if (problems.length)
|
|
@@ -413,8 +441,9 @@ export class Prima {
|
|
|
413
441
|
return judged;
|
|
414
442
|
}
|
|
415
443
|
let navigationError = null;
|
|
444
|
+
let arrived = true;
|
|
416
445
|
try {
|
|
417
|
-
await this.bot.agentNavigator().visit(target);
|
|
446
|
+
arrived = await this.bot.agentNavigator().visit(target, { recover: !isUrl });
|
|
418
447
|
}
|
|
419
448
|
catch (error) {
|
|
420
449
|
navigationError = error;
|
|
@@ -425,29 +454,18 @@ export class Prima {
|
|
|
425
454
|
if (isUrl)
|
|
426
455
|
used.push(code);
|
|
427
456
|
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
428
|
-
|
|
457
|
+
const envelope = await this.successEnvelope(command, used, result, previousState);
|
|
458
|
+
if (!arrived)
|
|
459
|
+
envelope.page.redirectedFrom = target;
|
|
460
|
+
return envelope;
|
|
429
461
|
}
|
|
430
|
-
async
|
|
431
|
-
const config = await this.loadConfig();
|
|
432
|
-
let show = config.playwright.show || false;
|
|
433
|
-
if (this.options.show)
|
|
434
|
-
show = true;
|
|
435
|
-
if (this.options.headless)
|
|
436
|
-
show = false;
|
|
437
|
-
this.server = await this.launchOwnServer({ browser: config.playwright.browser, show }, this.instanceName());
|
|
438
|
-
}
|
|
439
|
-
async browserStop(all = false) {
|
|
462
|
+
async browserCheck() {
|
|
440
463
|
await this.loadConfig();
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (!
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
for (const instance of listInstances()) {
|
|
447
|
-
if (await this.stopInstance(instance.name))
|
|
448
|
-
stopped = true;
|
|
449
|
-
}
|
|
450
|
-
return stopped;
|
|
464
|
+
const { match, candidates, browser } = await this.discover();
|
|
465
|
+
await browser?.close().catch(() => { });
|
|
466
|
+
if (!match)
|
|
467
|
+
throw this.missingSessionError(candidates);
|
|
468
|
+
return `browser: ${this.attachmentLabel(match)} at ${match.endpoint}`;
|
|
451
469
|
}
|
|
452
470
|
async config(json) {
|
|
453
471
|
const [site] = listSites();
|
|
@@ -496,36 +514,6 @@ export class Prima {
|
|
|
496
514
|
`upload: TESTOMATIO=<apiKey> npx @testomatio/reporter replay ${file}`,
|
|
497
515
|
].join('\n');
|
|
498
516
|
}
|
|
499
|
-
async browserStatus() {
|
|
500
|
-
await this.loadConfig();
|
|
501
|
-
const info = await this.instanceInfo();
|
|
502
|
-
const endpoint = await getAliveEndpoint(info.name);
|
|
503
|
-
const others = info.others.map((other) => other.name).join(', ') || 'none';
|
|
504
|
-
const lines = [`instance: ${info.name} (${info.tabs} ${pluralize(info.tabs, 'tab')}) | other instances: ${others}`];
|
|
505
|
-
if (!endpoint)
|
|
506
|
-
lines.push('browser: not running');
|
|
507
|
-
if (endpoint)
|
|
508
|
-
lines.push(`browser: running at ${endpoint}`);
|
|
509
|
-
return lines.join('\n');
|
|
510
|
-
}
|
|
511
|
-
async browserList() {
|
|
512
|
-
await this.loadConfig();
|
|
513
|
-
const lines = [];
|
|
514
|
-
for (const instance of listInstances()) {
|
|
515
|
-
const endpoint = await getAliveEndpoint(instance.name);
|
|
516
|
-
if (!endpoint)
|
|
517
|
-
continue;
|
|
518
|
-
lines.push(`prima --instance ${instance.name} ${endpoint}`);
|
|
519
|
-
}
|
|
520
|
-
const discovery = await this.discover();
|
|
521
|
-
await discovery.browser?.close().catch(() => { });
|
|
522
|
-
for (const descriptor of discovery.candidates) {
|
|
523
|
-
lines.push(`playwright-cli --pw-session ${descriptor.title} ${descriptor.endpoint}`);
|
|
524
|
-
}
|
|
525
|
-
if (!lines.length)
|
|
526
|
-
return 'no browser instances running';
|
|
527
|
-
return lines.join('\n');
|
|
528
|
-
}
|
|
529
517
|
async instanceInfo() {
|
|
530
518
|
const name = this.instanceName();
|
|
531
519
|
const others = listInstances()
|
|
@@ -588,21 +576,25 @@ export class Prima {
|
|
|
588
576
|
const { match, candidates, browser } = discovered || (await this.discover());
|
|
589
577
|
if (match && (await this.attachToEndpoint(match, browser)))
|
|
590
578
|
return;
|
|
591
|
-
if (!match && candidates.length)
|
|
579
|
+
if (!match && candidates.length)
|
|
580
|
+
throw this.missingSessionError(candidates);
|
|
581
|
+
if (await this.connectOwnInstance())
|
|
582
|
+
return;
|
|
583
|
+
throw this.missingSessionError(candidates);
|
|
584
|
+
}
|
|
585
|
+
missingSessionError(candidates) {
|
|
586
|
+
if (candidates.length) {
|
|
592
587
|
const titles = candidates.map((candidate) => candidate.title).join(', ');
|
|
593
|
-
|
|
588
|
+
return new Error(dedent `
|
|
594
589
|
Several playwright-cli sessions are open: ${titles}
|
|
595
590
|
Pick one with --pw-session <title>.
|
|
596
591
|
`);
|
|
597
592
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
playwright-cli open <url> prima attaches to this workspace session by default
|
|
604
|
-
prima browser start starts a prima-owned browser instead
|
|
605
|
-
Prima never launches a browser implicitly.
|
|
593
|
+
return new Error(dedent `
|
|
594
|
+
No playwright-cli session is open for prima to drive.
|
|
595
|
+
Start one first:
|
|
596
|
+
playwright-cli open ${this.options.url || '<url>'}
|
|
597
|
+
Prima attaches to this workspace session by default and never launches a browser itself.
|
|
606
598
|
`);
|
|
607
599
|
}
|
|
608
600
|
async discover(descriptors = readDescriptors()) {
|
|
@@ -670,17 +662,6 @@ export class Prima {
|
|
|
670
662
|
this.session = { key: this.instanceName(), endpoint, title: `prima instance "${this.instanceName()}"` };
|
|
671
663
|
return true;
|
|
672
664
|
}
|
|
673
|
-
async launchOwnServer(opts, instance) {
|
|
674
|
-
return launchServer(opts, instance);
|
|
675
|
-
}
|
|
676
|
-
async stopInstance(instance) {
|
|
677
|
-
if (instance === this.instanceName()) {
|
|
678
|
-
const server = this.server;
|
|
679
|
-
this.server = null;
|
|
680
|
-
await server?.close();
|
|
681
|
-
}
|
|
682
|
-
return stopServer(instance);
|
|
683
|
-
}
|
|
684
665
|
isUrlTarget(target) {
|
|
685
666
|
const value = target.trim();
|
|
686
667
|
if (value.startsWith('/'))
|
|
@@ -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
|
@@ -7,6 +7,7 @@ import { normalizeUrl } from '../state-manager.js';
|
|
|
7
7
|
import { isFatalBrowserError } from "../utils/browser-errors.js";
|
|
8
8
|
import { getCliName } from "../utils/cli-name.js";
|
|
9
9
|
import { extractCodeBlocks } from '../utils/code-extractor.js';
|
|
10
|
+
import { isLoginPage } from "../utils/error-page.js";
|
|
10
11
|
import { HooksRunner } from "../utils/hooks-runner.js";
|
|
11
12
|
import { createDebug, pluralize, tag } from '../utils/logger.js';
|
|
12
13
|
import { loop, pause } from '../utils/loop.js';
|
|
@@ -15,7 +16,7 @@ import { normalizeInlineText } from "../utils/strings.js";
|
|
|
15
16
|
import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
|
|
16
17
|
import { JUDGE_PAGE_CAP, UNDECIDED } from "./judge.js";
|
|
17
18
|
import { Researcher } from "./researcher.js";
|
|
18
|
-
import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
|
|
19
|
+
import { actionRule, credentialsRule, locatorRule, unexpectedPopupRule } from './rules.js';
|
|
19
20
|
import { isInteractive } from './task-agent.js';
|
|
20
21
|
import { createLearnExperienceTool } from "./tools.js";
|
|
21
22
|
const debugLog = createDebug('explorbot:navigator');
|
|
@@ -46,7 +47,7 @@ class Navigator {
|
|
|
46
47
|
NEVER use executeScript, executeAsyncScript, or any JS evaluation to change the URL, bypass redirects, or fake the page state.
|
|
47
48
|
If the target URL redirects to an authentication/login page, DO NOT try to force the original URL. Instead:
|
|
48
49
|
1. Look for credentials in the provided knowledge/hint context and perform a real login through the form.
|
|
49
|
-
2. If no credentials are available,
|
|
50
|
+
2. If no credentials are available, stop and name the credentials that are missing. Never guess them.
|
|
50
51
|
A redirect to /login, /sign_in, /auth, or similar is a signal that authentication is required — treat it as such, never as an obstacle to bypass.
|
|
51
52
|
</constraints>
|
|
52
53
|
`;
|
|
@@ -119,10 +120,10 @@ class Navigator {
|
|
|
119
120
|
const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
|
|
120
121
|
return matchesNavigationUrl(expectedUrl, currentUrl);
|
|
121
122
|
}
|
|
122
|
-
async visit(url) {
|
|
123
|
-
return this.visitOnce(url);
|
|
123
|
+
async visit(url, opts = {}) {
|
|
124
|
+
return this.visitOnce(url, opts.recover !== false);
|
|
124
125
|
}
|
|
125
|
-
async visitOnce(url) {
|
|
126
|
+
async visitOnce(url, recover) {
|
|
126
127
|
try {
|
|
127
128
|
const action = this.explorer.action();
|
|
128
129
|
await action.execute(`I.amOnPage('${url}')`);
|
|
@@ -131,7 +132,10 @@ class Navigator {
|
|
|
131
132
|
throw new Error(`Navigation to "${url}" opened an empty page. The target must be a valid URL path starting with "/".`);
|
|
132
133
|
}
|
|
133
134
|
await this.hooksRunner.runBeforeHook('navigator', url);
|
|
134
|
-
if (!
|
|
135
|
+
if (!recover && action.lastError)
|
|
136
|
+
throw new Error(`Navigation to ${url} failed: ${action.lastError.message}`);
|
|
137
|
+
const arrived = this.isOnExpectedPage(url, action.stateManager);
|
|
138
|
+
if (recover && !arrived) {
|
|
135
139
|
const actualPath = action.stateManager.getCurrentState()?.url || '';
|
|
136
140
|
const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState());
|
|
137
141
|
const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
|
|
@@ -154,6 +158,7 @@ class Navigator {
|
|
|
154
158
|
}
|
|
155
159
|
await this.explorer.capture({ screenshot: true });
|
|
156
160
|
await this.hooksRunner.runAfterHook('navigator', url);
|
|
161
|
+
return arrived || recover;
|
|
157
162
|
}
|
|
158
163
|
catch (error) {
|
|
159
164
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -252,7 +257,7 @@ class Navigator {
|
|
|
252
257
|
return;
|
|
253
258
|
}
|
|
254
259
|
tag('operation').log('Feeding failures back to AI for a new batch...');
|
|
255
|
-
conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
|
|
260
|
+
conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, action.actionResult ?? actionResult));
|
|
256
261
|
htmlContextAdded = true;
|
|
257
262
|
codeBlocks = [];
|
|
258
263
|
batchFailures.length = 0;
|
|
@@ -340,6 +345,9 @@ class Navigator {
|
|
|
340
345
|
if (!experience && !actionResult.isInsideIframe) {
|
|
341
346
|
experience = this.experienceTracker.renderExperienceFor(actionResult);
|
|
342
347
|
}
|
|
348
|
+
let credentials = '';
|
|
349
|
+
if (isLoginPage(actionResult))
|
|
350
|
+
credentials = credentialsRule;
|
|
343
351
|
return dedent `
|
|
344
352
|
<message>
|
|
345
353
|
${message}
|
|
@@ -347,10 +355,6 @@ class Navigator {
|
|
|
347
355
|
|
|
348
356
|
<page>
|
|
349
357
|
${actionResult.toAiContext()}
|
|
350
|
-
|
|
351
|
-
<page_html>
|
|
352
|
-
${await actionResult.combinedHtml()}
|
|
353
|
-
</page_html>
|
|
354
358
|
</page>
|
|
355
359
|
|
|
356
360
|
<task>
|
|
@@ -360,6 +364,8 @@ class Navigator {
|
|
|
360
364
|
Propose different solutions to achieve the result.
|
|
361
365
|
Solution should be valid CodeceptJS code.
|
|
362
366
|
Use only data from the <page> context to plan the solution.
|
|
367
|
+
The page is given as an ARIA snapshot only: use ARIA and text locators from it.
|
|
368
|
+
Full HTML is provided if these solutions fail.
|
|
363
369
|
Try various ways to achieve the result
|
|
364
370
|
</task>
|
|
365
371
|
|
|
@@ -367,6 +373,8 @@ class Navigator {
|
|
|
367
373
|
|
|
368
374
|
${unexpectedPopupRule}
|
|
369
375
|
|
|
376
|
+
${credentials}
|
|
377
|
+
|
|
370
378
|
${RulesLoader.loadRules('navigator', ['multiple-locator', 'output'], actionResult.url || '').replace('{{maxAttempts}}', String(this.MAX_ATTEMPTS))}
|
|
371
379
|
|
|
372
380
|
${experience}
|
|
@@ -722,7 +730,10 @@ class Navigator {
|
|
|
722
730
|
await action.exitIframe();
|
|
723
731
|
const verified = await action.attempt(codeBlock, message);
|
|
724
732
|
const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
|
|
725
|
-
|
|
733
|
+
const result = { code: codeBlock, passed: verified, proof };
|
|
734
|
+
if (!verified && action.lastError)
|
|
735
|
+
result.error = action.lastError.message;
|
|
736
|
+
results.push(result);
|
|
726
737
|
if (verified) {
|
|
727
738
|
tag('success').log('Verification passed');
|
|
728
739
|
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
|
-
}
|
package/dist/src/ai/pilot.js
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { ActionResult } from "../action-result.js";
|
|
5
5
|
import { ConfigParser } from "../config.js";
|
|
6
6
|
import { Stats } from "../stats.js";
|
|
7
|
-
import { TestResult, TestStatus } from "../test-plan.js";
|
|
7
|
+
import { SettledExpectation, TestResult, TestStatus } from "../test-plan.js";
|
|
8
8
|
import { collectInteractiveNodes } from "../utils/aria.js";
|
|
9
9
|
import { ErrorPageError } from "../utils/error-page.js";
|
|
10
10
|
import { createDebug, tag } from "../utils/logger.js";
|
|
@@ -26,6 +26,11 @@ const OUTCOME_STATUS = {
|
|
|
26
26
|
'The run shows this outcome happened.': 'passed',
|
|
27
27
|
'The run shows this outcome did not happen.': 'failed',
|
|
28
28
|
};
|
|
29
|
+
const CONTRADICTION_RESOLUTION = {
|
|
30
|
+
'The page structure agrees with the screenshot, and the outcome happened.': { status: 'passed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
|
|
31
|
+
'The page structure agrees with the screenshot, and the outcome did not happen.': { status: 'failed', resolution: 'the page structure agrees with the screenshot, so the run log was wrong' },
|
|
32
|
+
'The page structure agrees with the run log: the page holds it, but the screenshot does not show it.': { status: 'contradiction', resolution: 'the page structure agrees with the run log: the page holds it but does not display it, a likely rendering bug' },
|
|
33
|
+
};
|
|
29
34
|
export class Pilot {
|
|
30
35
|
emoji = '🧭';
|
|
31
36
|
provider;
|
|
@@ -544,7 +549,7 @@ export class Pilot {
|
|
|
544
549
|
settledByJudge = await this.settleByJudge(task, undecided);
|
|
545
550
|
undecided = undecided.filter((text) => !settledByJudge.has(text));
|
|
546
551
|
if (!undecided.length)
|
|
547
|
-
return task.expected.map((text) => (
|
|
552
|
+
return task.expected.map((text) => new SettledExpectation(text, settledByJudge.get(text) || decided(text)));
|
|
548
553
|
const schema = z.object({
|
|
549
554
|
outcomes: z.array(z.object({
|
|
550
555
|
expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
|
|
@@ -607,17 +612,39 @@ export class Pilot {
|
|
|
607
612
|
if (!response)
|
|
608
613
|
response = await settle(userContent, this.provider.getAgenticModel('pilot'));
|
|
609
614
|
const judged = new Map((response?.object?.outcomes || []).map((outcome) => [outcome.expectation, outcome]));
|
|
610
|
-
|
|
615
|
+
const settled = task.expected.map((text) => {
|
|
611
616
|
const byJudge = settledByJudge.get(text);
|
|
612
617
|
if (byJudge)
|
|
613
|
-
return
|
|
618
|
+
return new SettledExpectation(text, byJudge);
|
|
614
619
|
if (!undecided.includes(text))
|
|
615
|
-
return
|
|
620
|
+
return new SettledExpectation(text, decided(text));
|
|
616
621
|
const outcome = judged.get(text);
|
|
617
622
|
if (!outcome)
|
|
618
|
-
return
|
|
619
|
-
return
|
|
623
|
+
return new SettledExpectation(text, 'unverified');
|
|
624
|
+
return new SettledExpectation(text, outcome.status || 'unverified', outcome.evidence);
|
|
620
625
|
});
|
|
626
|
+
return this.resolveContradictions(settled, finalState);
|
|
627
|
+
}
|
|
628
|
+
async resolveContradictions(outcomes, finalState) {
|
|
629
|
+
const judge = this.judge;
|
|
630
|
+
if (!judge || !finalState)
|
|
631
|
+
return outcomes;
|
|
632
|
+
if (!outcomes.some((outcome) => outcome.contradicted))
|
|
633
|
+
return outcomes;
|
|
634
|
+
const page = finalState.getCompactARIA().slice(0, JUDGE_PAGE_CAP);
|
|
635
|
+
return Promise.all(outcomes.map(async (outcome) => {
|
|
636
|
+
if (!outcome.contradicted)
|
|
637
|
+
return outcome;
|
|
638
|
+
const decision = await judge.decide(`The screenshot and the run log disagree about the expected outcome: ${outcome.text}. Which side does the page structure support?`, [...Object.keys(CONTRADICTION_RESOLUTION), UNDECIDED], {
|
|
639
|
+
expectation: outcome.text,
|
|
640
|
+
disagreement: outcome.evidence || '',
|
|
641
|
+
page,
|
|
642
|
+
});
|
|
643
|
+
const resolved = CONTRADICTION_RESOLUTION[decision.value ?? ''];
|
|
644
|
+
if (!resolved)
|
|
645
|
+
return outcome;
|
|
646
|
+
return outcome.resolve(resolved.status, resolved.resolution);
|
|
647
|
+
}));
|
|
621
648
|
}
|
|
622
649
|
async settleByJudge(task, expectations) {
|
|
623
650
|
const settled = new Map();
|
|
@@ -77,7 +77,7 @@ export class Researcher extends ResearcherBase {
|
|
|
77
77
|
`;
|
|
78
78
|
}
|
|
79
79
|
async research(state, opts = {}) {
|
|
80
|
-
const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
|
|
80
|
+
const { screenshot = false, force = false, deep = false, data = false, fix = true, light = false } = opts;
|
|
81
81
|
const maxRetries = this.settings.retries ?? 2;
|
|
82
82
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
83
83
|
this.actionResult = ActionResult.fromState(state);
|
|
@@ -103,6 +103,8 @@ export class Researcher extends ResearcherBase {
|
|
|
103
103
|
return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
|
|
104
104
|
const displayUrl = state.fullUrl || state.url;
|
|
105
105
|
tag('info').log(`Researching ${displayUrl} to understand the context...`);
|
|
106
|
+
if (light)
|
|
107
|
+
tag('info').log('Light research: single pass, locator verification skipped');
|
|
106
108
|
setActivity(`${this.emoji} Researching...`, 'action');
|
|
107
109
|
await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
|
|
108
110
|
await this.hooksRunner.runBeforeHook('researcher', state.url);
|
|
@@ -135,7 +137,7 @@ export class Researcher extends ResearcherBase {
|
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
const isOnCurrentState = this.actionResult.getStateHash() === this.stateManager.getCurrentState()?.hash;
|
|
138
|
-
this.hasScreenshotToAnalyze = screenshot && this.provider.hasVision() && isOnCurrentState;
|
|
140
|
+
this.hasScreenshotToAnalyze = !light && screenshot && this.provider.hasVision() && isOnCurrentState;
|
|
139
141
|
const conversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
|
|
140
142
|
const prompt = await this.buildResearchPrompt();
|
|
141
143
|
conversation.addUserText(prompt);
|
|
@@ -164,12 +166,12 @@ export class Researcher extends ResearcherBase {
|
|
|
164
166
|
// Stage 2: Test containers + locators
|
|
165
167
|
result.parseLocators();
|
|
166
168
|
debugLog(`Extracted ${result.locators.length} locators from research`);
|
|
167
|
-
if (!interrupted() && result.locators.length === 0 && retriesLeft > 0) {
|
|
169
|
+
if (!light && !interrupted() && result.locators.length === 0 && retriesLeft > 0) {
|
|
168
170
|
tag('warning').log(`No locators parsed, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
|
|
169
171
|
await new Promise((r) => setTimeout(r, 1000));
|
|
170
172
|
return this.research(state, { ...opts, force: true, _retriesLeft: retriesLeft - 1 });
|
|
171
173
|
}
|
|
172
|
-
if (!interrupted()) {
|
|
174
|
+
if (!light && !interrupted()) {
|
|
173
175
|
const brokenContainers = await this.resolveContainers(result);
|
|
174
176
|
const containerCount = result.containers.length;
|
|
175
177
|
if (containerCount > 0 && brokenContainers.length === containerCount && retriesLeft > 0) {
|
|
@@ -187,7 +189,7 @@ export class Researcher extends ResearcherBase {
|
|
|
187
189
|
await this.testLocators(toTest);
|
|
188
190
|
}
|
|
189
191
|
// Stage 3: Fix broken sections via AI conversation continuation
|
|
190
|
-
if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
|
|
192
|
+
if (!light && !interrupted() && fix && result.locators.some((l) => l.valid === false)) {
|
|
191
193
|
await this.fixBrokenSections(result, activeConversation);
|
|
192
194
|
}
|
|
193
195
|
// Focused section: unified Playwright probe (HTML+CSS+visibility).
|
|
@@ -227,11 +229,11 @@ export class Researcher extends ResearcherBase {
|
|
|
227
229
|
}
|
|
228
230
|
}
|
|
229
231
|
// Stage 5: Backfill broken elements
|
|
230
|
-
if (!interrupted()) {
|
|
232
|
+
if (!light && !interrupted()) {
|
|
231
233
|
await this.backfillCoordinates(result);
|
|
232
234
|
await this.backfillBrokenLocators(result);
|
|
233
235
|
}
|
|
234
|
-
if (!interrupted()) {
|
|
236
|
+
if (!light && !interrupted()) {
|
|
235
237
|
await this.detectPagination(result);
|
|
236
238
|
}
|
|
237
239
|
// Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
|
|
@@ -258,7 +260,7 @@ export class Researcher extends ResearcherBase {
|
|
|
258
260
|
}
|
|
259
261
|
result.cleanup();
|
|
260
262
|
let researchFile = null;
|
|
261
|
-
if (stateHash) {
|
|
263
|
+
if (stateHash && !light) {
|
|
262
264
|
researchFile = saveResearch(researchState, result.text, combinedHtml);
|
|
263
265
|
}
|
|
264
266
|
const summaryText = mdq(result.text).query('section2(/^summary/i)').query('paragraph[0]').text().trim();
|
package/dist/src/ai/rules.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare const locatorRule: string;
|
|
|
3
3
|
export declare const drillLocatorRule: string;
|
|
4
4
|
export declare const fileUploadRule: string;
|
|
5
5
|
export declare const formRequirementsRule: string;
|
|
6
|
+
export declare const credentialsRule: string;
|
|
6
7
|
export declare const protectionRule: string;
|
|
7
8
|
export declare const dataProtectionRules: string;
|
|
8
9
|
export declare const capabilityGroundingRule: string;
|