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
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/tools.js
CHANGED
|
@@ -91,7 +91,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
|
|
|
91
91
|
const attempts = [];
|
|
92
92
|
let ambiguityError = null;
|
|
93
93
|
for (let i = 0; i < commands.length; i++) {
|
|
94
|
-
|
|
94
|
+
let command = transformContainsCommand(commands[i]);
|
|
95
95
|
let success = await action.attempt(command, explanation);
|
|
96
96
|
const attempt = { command, success };
|
|
97
97
|
if (action.lastError)
|
|
@@ -104,9 +104,14 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }, task) {
|
|
|
104
104
|
const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
|
|
105
105
|
if (index)
|
|
106
106
|
success = await action.attemptExactElementIndex(command, index, explanation);
|
|
107
|
+
if (success)
|
|
108
|
+
command = action.executedSteps.map((step) => step.command).join('\n') || command;
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
if (success) {
|
|
112
|
+
attempt.command = command;
|
|
113
|
+
attempt.success = true;
|
|
114
|
+
attempt.error = undefined;
|
|
110
115
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
|
|
111
116
|
if (!hasObservablePageChange(toolResult)) {
|
|
112
117
|
activeNote.commit(TestResult.FAILED);
|
package/dist/src/explorbot.js
CHANGED
|
@@ -125,7 +125,7 @@ export class ExplorBot {
|
|
|
125
125
|
await this.visit(this.options.from || '/');
|
|
126
126
|
}
|
|
127
127
|
async visit(url) {
|
|
128
|
-
|
|
128
|
+
await this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
|
|
129
129
|
}
|
|
130
130
|
async openTab() {
|
|
131
131
|
await this.explorer.openTab();
|
package/dist/src/test-plan.d.ts
CHANGED
|
@@ -33,6 +33,17 @@ export declare class ActiveNote {
|
|
|
33
33
|
getMessage(): string;
|
|
34
34
|
getStartTime(): number;
|
|
35
35
|
}
|
|
36
|
+
export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
|
|
37
|
+
export declare class SettledExpectation {
|
|
38
|
+
readonly text: string;
|
|
39
|
+
readonly status: SettledStatus;
|
|
40
|
+
readonly evidence?: string;
|
|
41
|
+
readonly resolution?: string;
|
|
42
|
+
constructor(text: string, status: SettledStatus, evidence?: string, resolution?: string);
|
|
43
|
+
get failed(): boolean;
|
|
44
|
+
get contradicted(): boolean;
|
|
45
|
+
resolve(status: SettledStatus, resolution: string): SettledExpectation;
|
|
46
|
+
}
|
|
36
47
|
export interface StepData {
|
|
37
48
|
text: string;
|
|
38
49
|
duration?: number;
|
package/dist/src/test-plan.js
CHANGED
|
@@ -41,6 +41,27 @@ export class ActiveNote {
|
|
|
41
41
|
return this.startTime;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
export class SettledExpectation {
|
|
45
|
+
text;
|
|
46
|
+
status;
|
|
47
|
+
evidence;
|
|
48
|
+
resolution;
|
|
49
|
+
constructor(text, status, evidence, resolution) {
|
|
50
|
+
this.text = text;
|
|
51
|
+
this.status = status;
|
|
52
|
+
this.evidence = evidence;
|
|
53
|
+
this.resolution = resolution;
|
|
54
|
+
}
|
|
55
|
+
get failed() {
|
|
56
|
+
return this.status === 'failed';
|
|
57
|
+
}
|
|
58
|
+
get contradicted() {
|
|
59
|
+
return this.status === 'contradiction';
|
|
60
|
+
}
|
|
61
|
+
resolve(status, resolution) {
|
|
62
|
+
return new SettledExpectation(this.text, status, this.evidence, resolution);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
44
65
|
export class Task {
|
|
45
66
|
id;
|
|
46
67
|
description;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createDebug } from './logger.js';
|
|
2
2
|
const debugLog = createDebug('explorbot:code-extractor');
|
|
3
3
|
const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
|
|
4
|
+
const QUOTES = ["'", '"', '`'];
|
|
5
|
+
const OPENING_BRACKETS = ['(', '[', '{'];
|
|
6
|
+
const CLOSING_BRACKETS = [')', ']', '}'];
|
|
4
7
|
export function extractCodeBlocks(aiResponse) {
|
|
5
8
|
const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
|
|
6
9
|
const codeBlocks = [];
|
|
@@ -22,3 +25,40 @@ export function extractCodeBlocks(aiResponse) {
|
|
|
22
25
|
}
|
|
23
26
|
return codeBlocks;
|
|
24
27
|
}
|
|
28
|
+
export function splitTopLevel(code, separator) {
|
|
29
|
+
const parts = [];
|
|
30
|
+
let current = '';
|
|
31
|
+
let depth = 0;
|
|
32
|
+
let quote = '';
|
|
33
|
+
for (let i = 0; i < code.length; i++) {
|
|
34
|
+
const char = code[i];
|
|
35
|
+
if (quote) {
|
|
36
|
+
current += char;
|
|
37
|
+
if (char === '\\')
|
|
38
|
+
current += code[++i] || '';
|
|
39
|
+
if (char === quote)
|
|
40
|
+
quote = '';
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (char === '/' && code[i + 1] === '/') {
|
|
44
|
+
while (i + 1 < code.length && code[i + 1] !== '\n')
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === separator && depth <= 0) {
|
|
49
|
+
parts.push(current);
|
|
50
|
+
current = '';
|
|
51
|
+
depth = 0;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
current += char;
|
|
55
|
+
if (QUOTES.includes(char))
|
|
56
|
+
quote = char;
|
|
57
|
+
if (OPENING_BRACKETS.includes(char))
|
|
58
|
+
depth++;
|
|
59
|
+
if (CLOSING_BRACKETS.includes(char))
|
|
60
|
+
depth--;
|
|
61
|
+
}
|
|
62
|
+
parts.push(current);
|
|
63
|
+
return parts;
|
|
64
|
+
}
|
package/dist/src/utils/html.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare const EXPLORBOT_ATTRS: {
|
|
|
5
5
|
readonly coveredBy: "data-explorbot-covered-by";
|
|
6
6
|
readonly context: "data-explorbot-context";
|
|
7
7
|
readonly eidx: "data-explorbot-eidx";
|
|
8
|
+
readonly hidden: "data-explorbot-hidden";
|
|
8
9
|
readonly hit: "data-explorbot-hit";
|
|
9
10
|
readonly variant: "data-explorbot-variant";
|
|
10
11
|
};
|
|
@@ -40,6 +41,7 @@ export declare const ELEMENT_EXTRACTION_CONFIG: {
|
|
|
40
41
|
readonly coveredBy: "data-explorbot-covered-by";
|
|
41
42
|
readonly context: "data-explorbot-context";
|
|
42
43
|
readonly eidx: "data-explorbot-eidx";
|
|
44
|
+
readonly hidden: "data-explorbot-hidden";
|
|
43
45
|
readonly hit: "data-explorbot-hit";
|
|
44
46
|
readonly variant: "data-explorbot-variant";
|
|
45
47
|
};
|
package/dist/src/utils/html.js
CHANGED
|
@@ -70,6 +70,7 @@ export const EXPLORBOT_ATTRS = {
|
|
|
70
70
|
coveredBy: 'data-explorbot-covered-by',
|
|
71
71
|
context: 'data-explorbot-context',
|
|
72
72
|
eidx: 'data-explorbot-eidx',
|
|
73
|
+
hidden: 'data-explorbot-hidden',
|
|
73
74
|
hit: 'data-explorbot-hit',
|
|
74
75
|
variant: 'data-explorbot-variant',
|
|
75
76
|
};
|
|
@@ -159,6 +160,20 @@ export function captureHtmlForSnapshot() {
|
|
|
159
160
|
.join(','));
|
|
160
161
|
}
|
|
161
162
|
}
|
|
163
|
+
const liveElements = Array.from(document.body?.querySelectorAll('*') || []);
|
|
164
|
+
const clonedElements = Array.from(clone.querySelector('body')?.querySelectorAll('*') || []);
|
|
165
|
+
for (let i = 0; i < liveElements.length; i++) {
|
|
166
|
+
const source = liveElements[i];
|
|
167
|
+
if (source.matches('input, textarea, select, option, optgroup'))
|
|
168
|
+
continue;
|
|
169
|
+
if (source.checkVisibility())
|
|
170
|
+
continue;
|
|
171
|
+
if (!source.parentElement?.checkVisibility())
|
|
172
|
+
continue;
|
|
173
|
+
if (getComputedStyle(source).display === 'contents')
|
|
174
|
+
continue;
|
|
175
|
+
clonedElements[i]?.setAttribute('data-explorbot-hidden', 'true');
|
|
176
|
+
}
|
|
162
177
|
return clone.outerHTML;
|
|
163
178
|
}
|
|
164
179
|
export function extractElementData(el, config) {
|
|
@@ -1347,7 +1362,27 @@ function getAttribute(element, name) {
|
|
|
1347
1362
|
const attr = element.attrs.find((a) => a.name === name);
|
|
1348
1363
|
return attr?.value;
|
|
1349
1364
|
}
|
|
1365
|
+
function getImgAlt(element) {
|
|
1366
|
+
let alt = '';
|
|
1367
|
+
function processNode(node) {
|
|
1368
|
+
if (alt)
|
|
1369
|
+
return;
|
|
1370
|
+
if ('tagName' in node) {
|
|
1371
|
+
const el = node;
|
|
1372
|
+
if (el.tagName.toLowerCase() === 'img') {
|
|
1373
|
+
alt = getAttribute(el, 'alt') || '';
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if ('childNodes' in node)
|
|
1378
|
+
node.childNodes.forEach(processNode);
|
|
1379
|
+
}
|
|
1380
|
+
processNode(element);
|
|
1381
|
+
return alt;
|
|
1382
|
+
}
|
|
1350
1383
|
function hasHiddenClass(element) {
|
|
1384
|
+
if (getAttribute(element, EXPLORBOT_ATTRS.hidden))
|
|
1385
|
+
return true;
|
|
1351
1386
|
const classAttr = element.attrs.find((attr) => attr.name === 'class');
|
|
1352
1387
|
if (!classAttr)
|
|
1353
1388
|
return false;
|
|
@@ -1393,7 +1428,7 @@ export function extractLinks(html) {
|
|
|
1393
1428
|
if (href) {
|
|
1394
1429
|
const shouldSkip = skipPrefixes.some((prefix) => href.startsWith(prefix));
|
|
1395
1430
|
if (!shouldSkip) {
|
|
1396
|
-
const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element);
|
|
1431
|
+
const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element) || getImgAlt(element);
|
|
1397
1432
|
const title = sanitizeLinkTitle(rawTitle);
|
|
1398
1433
|
if (title && title.length <= 100) {
|
|
1399
1434
|
const key = `${href}|${title}`;
|
|
@@ -5,7 +5,7 @@ const EDIT_FLAGS = ['remove', 'replace', 'insertBefore', 'insertAfter', 'prepend
|
|
|
5
5
|
export async function runMdq(argv, readStdin) {
|
|
6
6
|
const program = new Command();
|
|
7
7
|
program
|
|
8
|
-
.name('mdq')
|
|
8
|
+
.name('mdq-cli')
|
|
9
9
|
.description('query and edit markdown')
|
|
10
10
|
.argument('[selector]', 'markdown selector')
|
|
11
11
|
.argument('[file]', 'file to read; stdin when omitted')
|
|
@@ -29,6 +29,8 @@ export async function runMdq(argv, readStdin) {
|
|
|
29
29
|
program.parse(argv, { from: 'user' });
|
|
30
30
|
}
|
|
31
31
|
catch (error) {
|
|
32
|
+
if (error.code === 'commander.helpDisplayed')
|
|
33
|
+
return { output: program.helpInformation(), code: 0 };
|
|
32
34
|
return { output: String(error.message), code: 2 };
|
|
33
35
|
}
|
|
34
36
|
const options = program.opts();
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { faker } from '@faker-js/faker';
|
|
2
2
|
import { hopeThat, retryTo, tryTo, within } from 'codeceptjs/lib/effects';
|
|
3
3
|
import step from 'codeceptjs/steps';
|
|
4
|
+
import { splitTopLevel } from "./code-extractor.js";
|
|
4
5
|
const SHADOWED_GLOBALS = ['process', 'global', 'globalThis', 'fetch', 'Bun', 'require', 'module', 'exports'];
|
|
5
6
|
const ALLOWED_COMMAND_HEADS = ['I.', 'page.', 'await page.', 'await I.'];
|
|
6
7
|
const PLAYWRIGHT_COMMAND_HEADS = ['page.', 'await page.'];
|
|
7
8
|
const PLAYWRIGHT_ARG_NAMES = ['page'];
|
|
8
9
|
const CODECEPT_ARG_NAMES = ['I', 'tryTo', 'retryTo', 'within', 'hopeThat', 'step', 'faker'];
|
|
9
10
|
export function sanitizeCodeBlock(code) {
|
|
10
|
-
return code
|
|
11
|
-
.
|
|
12
|
-
.
|
|
13
|
-
.filter((line) => ALLOWED_COMMAND_HEADS.some((head) => line.startsWith(head)))
|
|
11
|
+
return splitTopLevel(code, '\n')
|
|
12
|
+
.map((statement) => statement.trim())
|
|
13
|
+
.filter((statement) => ALLOWED_COMMAND_HEADS.some((head) => statement.startsWith(head)))
|
|
14
14
|
.join('\n');
|
|
15
15
|
}
|
|
16
16
|
export function hasPlaywrightCommands(code) {
|
|
@@ -21,6 +21,19 @@ The collector starts at the path you give it. For each page it:
|
|
|
21
21
|
|
|
22
22
|
It repeats this until the queue is empty or the page budget is spent. Only same-origin links are followed.
|
|
23
23
|
|
|
24
|
+
## Research depth
|
|
25
|
+
|
|
26
|
+
In static mode the Researcher runs in a **light, single pass**: one AI call maps the sections and interactive elements, and the locator validation, repair conversations, and screenshot annotation passes of a full research run are skipped. Docs describe pages rather than click them, so unverified locators are a fair trade for roughly one AI call per page.
|
|
27
|
+
|
|
28
|
+
Interactive mode needs locators it can actually click, so it keeps the full pipeline: every locator is validated in the browser, broken ones get fixed, and a vision pass adds coordinates. Override the automatic choice with `research`:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
docs: {
|
|
32
|
+
research: 'full', // always run the verified pipeline
|
|
33
|
+
// research: 'light', // single pass even in interactive mode
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
24
37
|
## Configure
|
|
25
38
|
|
|
26
39
|
The collector runs on your regular Explorbot setup — the AI provider and browser come from `explorbot.config.js` (see [configuration](../reference/configuration.md)). Crawl behavior lives in a separate file: `docbot.config.ts` (`.js` and `.mjs` work too).
|
|
@@ -155,6 +155,14 @@ Expands hidden elements (dropdowns, accordions, tabs) to discover more UI. Click
|
|
|
155
155
|
|
|
156
156
|
Extracts domain-specific content (articles, products, users) as structured data.
|
|
157
157
|
|
|
158
|
+
#### Light Research
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
researcher.research(state, { light: true });
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A single-pass mode for consumers that describe pages instead of acting on them. Stage 1 runs as usual; stages 2–5 (locator testing, AI fixing, the visual annotation pass, backfills, and pagination probing) are skipped, so the whole run is one AI call with no browser verification roundtrips. Locators in the UI map come straight from the model and are not verified; a light run never writes to the shared research cache, so it cannot replace a verified result. The [doc collector](../doc-collection/basics.md#research-depth) uses this mode by default.
|
|
165
|
+
|
|
158
166
|
## Page Sections
|
|
159
167
|
|
|
160
168
|
The Researcher breaks each page into sections by UI purpose. Sections are identified in priority order:
|
package/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/src/action.ts
CHANGED
|
@@ -589,6 +589,10 @@ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Arra
|
|
|
589
589
|
if (batchFailed) return;
|
|
590
590
|
|
|
591
591
|
const executed: ExecutedStep = { command: step.toCode(), success: !error };
|
|
592
|
+
if (Object.keys(step.opts || {}).length) {
|
|
593
|
+
const args = [step.humanizeArgs(), `step.opts(${JSON.stringify(step.opts)})`].filter(Boolean).join(', ');
|
|
594
|
+
executed.command = `${step.prefix}${step.actor}.${step.title}(${args})${step.suffix}`;
|
|
595
|
+
}
|
|
592
596
|
if (error) {
|
|
593
597
|
executed.error = errorToString(error);
|
|
594
598
|
batchFailed = true;
|
package/src/ai/driller.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { setActivity } from '../activity.ts';
|
|
|
6
6
|
import { Observability } from '../observability.ts';
|
|
7
7
|
import { Plan, Test, TestResult } from '../test-plan.ts';
|
|
8
8
|
import { collectInteractiveNodes } from '../utils/aria.ts';
|
|
9
|
+
import { splitTopLevel } from '../utils/code-extractor.ts';
|
|
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.ts';
|
|
10
11
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
11
12
|
import { loop, pause } from '../utils/loop.ts';
|
|
@@ -1011,39 +1012,7 @@ function countTopLevelArgCount(line: string): number {
|
|
|
1011
1012
|
const end = line.lastIndexOf(')');
|
|
1012
1013
|
if (start === -1 || end === -1 || end <= start + 1) return 0;
|
|
1013
1014
|
|
|
1014
|
-
|
|
1015
|
-
let count = 1;
|
|
1016
|
-
let depth = 0;
|
|
1017
|
-
let quote = '';
|
|
1018
|
-
|
|
1019
|
-
for (let i = 0; i < body.length; i++) {
|
|
1020
|
-
const char = body[i];
|
|
1021
|
-
const escaped = body[i - 1] === '\\';
|
|
1022
|
-
|
|
1023
|
-
if (quote) {
|
|
1024
|
-
if (char === quote && !escaped) quote = '';
|
|
1025
|
-
continue;
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
if (char === '"' || char === "'" || char === '`') {
|
|
1029
|
-
quote = char;
|
|
1030
|
-
continue;
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
if (char === '(' || char === '[' || char === '{') {
|
|
1034
|
-
depth++;
|
|
1035
|
-
continue;
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
if (char === ')' || char === ']' || char === '}') {
|
|
1039
|
-
depth = Math.max(0, depth - 1);
|
|
1040
|
-
continue;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
if (char === ',' && depth === 0) count++;
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
return count;
|
|
1015
|
+
return splitTopLevel(line.slice(start + 1, end), ',').length;
|
|
1047
1016
|
}
|
|
1048
1017
|
|
|
1049
1018
|
function buildClassSelector(tag: string, classes: string[]): string {
|
package/src/ai/navigator.ts
CHANGED
|
@@ -136,11 +136,11 @@ class Navigator implements Agent {
|
|
|
136
136
|
return matchesNavigationUrl(expectedUrl, currentUrl);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
async visit(url: string): Promise<
|
|
140
|
-
return this.visitOnce(url);
|
|
139
|
+
async visit(url: string, opts: { recover?: boolean } = {}): Promise<boolean> {
|
|
140
|
+
return this.visitOnce(url, opts.recover !== false);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
private async visitOnce(url: string): Promise<
|
|
143
|
+
private async visitOnce(url: string, recover: boolean): Promise<boolean> {
|
|
144
144
|
try {
|
|
145
145
|
const action = this.explorer.action();
|
|
146
146
|
|
|
@@ -153,7 +153,10 @@ class Navigator implements Agent {
|
|
|
153
153
|
|
|
154
154
|
await this.hooksRunner.runBeforeHook('navigator', url);
|
|
155
155
|
|
|
156
|
-
if (!
|
|
156
|
+
if (!recover && action.lastError) throw new Error(`Navigation to ${url} failed: ${action.lastError.message}`);
|
|
157
|
+
|
|
158
|
+
const arrived = this.isOnExpectedPage(url, action.stateManager);
|
|
159
|
+
if (recover && !arrived) {
|
|
157
160
|
const actualPath = action.stateManager.getCurrentState()?.url || '';
|
|
158
161
|
const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState()!);
|
|
159
162
|
const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
|
|
@@ -177,6 +180,7 @@ class Navigator implements Agent {
|
|
|
177
180
|
}
|
|
178
181
|
await this.explorer.capture({ screenshot: true });
|
|
179
182
|
await this.hooksRunner.runAfterHook('navigator', url);
|
|
183
|
+
return arrived || recover;
|
|
180
184
|
} catch (error) {
|
|
181
185
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
182
186
|
if (errorMessage.includes('ERR_CONNECTION_REFUSED')) {
|
|
@@ -284,7 +288,7 @@ class Navigator implements Agent {
|
|
|
284
288
|
return;
|
|
285
289
|
}
|
|
286
290
|
tag('operation').log('Feeding failures back to AI for a new batch...');
|
|
287
|
-
conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, actionResult));
|
|
291
|
+
conversation.addUserText(await this.buildRetryFeedback(batchFailures, !htmlContextAdded, action.actionResult ?? actionResult));
|
|
288
292
|
htmlContextAdded = true;
|
|
289
293
|
codeBlocks = [];
|
|
290
294
|
batchFailures.length = 0;
|
|
@@ -384,10 +388,6 @@ class Navigator implements Agent {
|
|
|
384
388
|
|
|
385
389
|
<page>
|
|
386
390
|
${actionResult.toAiContext()}
|
|
387
|
-
|
|
388
|
-
<page_html>
|
|
389
|
-
${await actionResult.combinedHtml()}
|
|
390
|
-
</page_html>
|
|
391
391
|
</page>
|
|
392
392
|
|
|
393
393
|
<task>
|
|
@@ -397,6 +397,8 @@ class Navigator implements Agent {
|
|
|
397
397
|
Propose different solutions to achieve the result.
|
|
398
398
|
Solution should be valid CodeceptJS code.
|
|
399
399
|
Use only data from the <page> context to plan the solution.
|
|
400
|
+
The page is given as an ARIA snapshot only: use ARIA and text locators from it.
|
|
401
|
+
Full HTML is provided if these solutions fail.
|
|
400
402
|
Try various ways to achieve the result
|
|
401
403
|
</task>
|
|
402
404
|
|
|
@@ -813,7 +815,9 @@ class Navigator implements Agent {
|
|
|
813
815
|
|
|
814
816
|
const verified = await action.attempt(codeBlock, message);
|
|
815
817
|
const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
|
|
816
|
-
|
|
818
|
+
const result: AssertionResult = { code: codeBlock, passed: verified, proof };
|
|
819
|
+
if (!verified && action.lastError) result.error = action.lastError.message;
|
|
820
|
+
results.push(result);
|
|
817
821
|
|
|
818
822
|
if (verified) {
|
|
819
823
|
tag('success').log('Verification passed');
|
|
@@ -862,6 +866,6 @@ class Navigator implements Agent {
|
|
|
862
866
|
|
|
863
867
|
type BatchFailure = { code: string; error: string; ariaChanges?: string | null; urlAfter?: string };
|
|
864
868
|
|
|
865
|
-
export type AssertionResult = { code: string; passed: boolean; proof: string[] };
|
|
869
|
+
export type AssertionResult = { code: string; passed: boolean; proof: string[]; error?: string };
|
|
866
870
|
|
|
867
871
|
export { Navigator };
|