explorbot 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +3 -0
- package/boat/doc-collector/src/config.ts +1 -0
- package/boat/doc-collector/src/docbot.ts +10 -1
- package/boat/prima/src/envelope.ts +12 -6
- package/boat/prima/src/help.ts +4 -2
- package/boat/prima/src/prima.ts +37 -4
- package/boat/prima/src/session-log.ts +3 -3
- package/dist/bin/explorbot-cli.js +2 -0
- package/dist/boat/doc-collector/src/docbot.js +11 -1
- package/dist/boat/prima/src/envelope.js +12 -3
- package/dist/boat/prima/src/help.js +4 -2
- package/dist/boat/prima/src/prima.js +37 -4
- package/dist/boat/prima/src/session-log.js +3 -3
- package/dist/package.json +1 -1
- package/dist/rules/navigator/output.md +3 -3
- package/dist/src/action.js +6 -0
- package/dist/src/ai/driller.js +2 -28
- package/dist/src/ai/historian/codeceptjs.js +2 -0
- package/dist/src/ai/judge-tool.js +5 -1
- package/dist/src/ai/navigator.d.ts +5 -2
- package/dist/src/ai/navigator.js +15 -10
- package/dist/src/ai/pilot.d.ts +2 -7
- package/dist/src/ai/pilot.js +37 -7
- package/dist/src/ai/rerunner.js +3 -1
- package/dist/src/ai/researcher.js +10 -8
- package/dist/src/ai/rules.js +1 -1
- package/dist/src/ai/tester.js +11 -0
- package/dist/src/ai/tools.js +23 -7
- package/dist/src/config.d.ts +10 -3
- package/dist/src/config.js +42 -6
- package/dist/src/explorbot.js +5 -1
- package/dist/src/explorer.d.ts +2 -0
- package/dist/src/explorer.js +33 -1
- package/dist/src/global-config.js +1 -1
- package/dist/src/test-plan.d.ts +11 -0
- package/dist/src/test-plan.js +21 -0
- package/dist/src/utils/code-extractor.d.ts +1 -0
- package/dist/src/utils/code-extractor.js +40 -0
- package/dist/src/utils/html.d.ts +2 -0
- package/dist/src/utils/html.js +36 -1
- package/dist/src/utils/mdq/cli.js +3 -1
- package/dist/src/utils/test-files.d.ts +5 -0
- package/dist/src/utils/test-files.js +28 -1
- package/dist/src/utils/web-sandbox.js +4 -4
- package/docs/doc-collection/basics.md +13 -0
- package/docs/reference/commands.md +12 -0
- package/docs/web-testing/researcher.md +8 -0
- package/package.json +1 -1
- package/rules/navigator/output.md +3 -3
- package/src/action.ts +6 -0
- package/src/ai/driller.ts +2 -33
- package/src/ai/historian/codeceptjs.ts +2 -0
- package/src/ai/judge-tool.ts +5 -1
- package/src/ai/navigator.ts +15 -11
- package/src/ai/pilot.ts +38 -15
- package/src/ai/rerunner.ts +3 -1
- package/src/ai/researcher.ts +10 -9
- package/src/ai/rules.ts +1 -1
- package/src/ai/tester.ts +12 -0
- package/src/ai/tools.ts +21 -12
- package/src/config.ts +48 -6
- package/src/explorbot.ts +3 -1
- package/src/explorer.ts +37 -2
- package/src/global-config.ts +1 -1
- package/src/test-plan.ts +23 -0
- package/src/utils/code-extractor.ts +41 -0
- package/src/utils/html.ts +33 -1
- package/src/utils/mdq/cli.ts +3 -2
- package/src/utils/test-files.ts +27 -1
- package/src/utils/web-sandbox.ts +4 -4
package/src/ai/rerunner.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { relative, resolve } from 'node:path';
|
|
2
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
3
3
|
import { tool } from 'ai';
|
|
4
4
|
import { createBashTool } from 'bash-tool';
|
|
5
5
|
import chalk from 'chalk';
|
|
@@ -19,6 +19,7 @@ import { createDebug, tag } from '../utils/logger.ts';
|
|
|
19
19
|
import { loop } from '../utils/loop.ts';
|
|
20
20
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
21
21
|
import { isInternalStep } from '../utils/step-analyzer.ts';
|
|
22
|
+
import { ensureCodeceptResolvable } from '../utils/test-files.ts';
|
|
22
23
|
import type { Agent, AgentDeps } from './agent.ts';
|
|
23
24
|
import { toolExecutionLabel } from './conversation.ts';
|
|
24
25
|
import type { Navigator } from './navigator.ts';
|
|
@@ -135,6 +136,7 @@ export class Rerunner extends TaskAgent implements Agent {
|
|
|
135
136
|
codeceptjs.container.createMocha();
|
|
136
137
|
const mocha = codeceptjs.container.mocha();
|
|
137
138
|
mocha.reporter(class {});
|
|
139
|
+
ensureCodeceptResolvable(dirname(absPath));
|
|
138
140
|
mocha.files = [absPath];
|
|
139
141
|
mocha.loadFiles();
|
|
140
142
|
|
package/src/ai/researcher.ts
CHANGED
|
@@ -103,8 +103,8 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
103
103
|
`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; _retriesLeft?: number } = {}): Promise<string> {
|
|
107
|
-
const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
|
|
106
|
+
async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; light?: boolean; _retriesLeft?: number } = {}): Promise<string> {
|
|
107
|
+
const { screenshot = false, force = false, deep = false, data = false, fix = true, light = false } = opts;
|
|
108
108
|
const maxRetries = this.settings.retries ?? 2;
|
|
109
109
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
110
110
|
this.actionResult = ActionResult.fromState(state);
|
|
@@ -133,6 +133,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
133
133
|
return Observability.run(sessionName, { tags: ['researcher'], sessionId: stateHash }, async () => {
|
|
134
134
|
const displayUrl = state.fullUrl || state.url;
|
|
135
135
|
tag('info').log(`Researching ${displayUrl} to understand the context...`);
|
|
136
|
+
if (light) tag('info').log('Light research: single pass, locator verification skipped');
|
|
136
137
|
setActivity(`${this.emoji} Researching...`, 'action');
|
|
137
138
|
|
|
138
139
|
await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
|
|
@@ -171,7 +172,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
const isOnCurrentState = this.actionResult!.getStateHash() === this.stateManager.getCurrentState()?.hash;
|
|
174
|
-
this.hasScreenshotToAnalyze = screenshot && this.provider.hasVision() && isOnCurrentState;
|
|
175
|
+
this.hasScreenshotToAnalyze = !light && screenshot && this.provider.hasVision() && isOnCurrentState;
|
|
175
176
|
|
|
176
177
|
const conversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
|
|
177
178
|
|
|
@@ -205,13 +206,13 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
205
206
|
result.parseLocators();
|
|
206
207
|
debugLog(`Extracted ${result.locators.length} locators from research`);
|
|
207
208
|
|
|
208
|
-
if (!interrupted() && result.locators.length === 0 && retriesLeft > 0) {
|
|
209
|
+
if (!light && !interrupted() && result.locators.length === 0 && retriesLeft > 0) {
|
|
209
210
|
tag('warning').log(`No locators parsed, retrying research (${maxRetries - retriesLeft + 1}/${maxRetries})...`);
|
|
210
211
|
await new Promise((r) => setTimeout(r, 1000));
|
|
211
212
|
return this.research(state, { ...opts, force: true, _retriesLeft: retriesLeft - 1 } as any);
|
|
212
213
|
}
|
|
213
214
|
|
|
214
|
-
if (!interrupted()) {
|
|
215
|
+
if (!light && !interrupted()) {
|
|
215
216
|
const brokenContainers = await this.resolveContainers(result);
|
|
216
217
|
const containerCount = result.containers.length;
|
|
217
218
|
if (containerCount > 0 && brokenContainers.length === containerCount && retriesLeft > 0) {
|
|
@@ -232,7 +233,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
// Stage 3: Fix broken sections via AI conversation continuation
|
|
235
|
-
if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
|
|
236
|
+
if (!light && !interrupted() && fix && result.locators.some((l) => l.valid === false)) {
|
|
236
237
|
await this.fixBrokenSections(result, activeConversation);
|
|
237
238
|
}
|
|
238
239
|
|
|
@@ -273,12 +274,12 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
// Stage 5: Backfill broken elements
|
|
276
|
-
if (!interrupted()) {
|
|
277
|
+
if (!light && !interrupted()) {
|
|
277
278
|
await this.backfillCoordinates(result);
|
|
278
279
|
await this.backfillBrokenLocators(result);
|
|
279
280
|
}
|
|
280
281
|
|
|
281
|
-
if (!interrupted()) {
|
|
282
|
+
if (!light && !interrupted()) {
|
|
282
283
|
await this.detectPagination(result);
|
|
283
284
|
}
|
|
284
285
|
|
|
@@ -309,7 +310,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
309
310
|
result.cleanup();
|
|
310
311
|
|
|
311
312
|
let researchFile: string | null = null;
|
|
312
|
-
if (stateHash) {
|
|
313
|
+
if (stateHash && !light) {
|
|
313
314
|
researchFile = saveResearch(researchState, result.text, combinedHtml);
|
|
314
315
|
}
|
|
315
316
|
|
package/src/ai/rules.ts
CHANGED
|
@@ -237,7 +237,7 @@ export const unexpectedPopupRule = dedent`
|
|
|
237
237
|
If a modal/popup appeared that you didn't expect, dismiss it first before continuing with original task.
|
|
238
238
|
If elements become hidden or unclickable (timeout errors on visible elements), a dialog or overlay may have appeared on top.
|
|
239
239
|
If a click error mentions "intercepts pointer events", another element is covering the target — dismiss it first.
|
|
240
|
-
If buttons are disabled unexpectedly, check
|
|
240
|
+
If buttons are disabled unexpectedly, trust any explanation the app gives (tooltip, hint text) over your own guess; otherwise check for a blocking popup or empty required fields.
|
|
241
241
|
|
|
242
242
|
Dismiss strategy (try in order):
|
|
243
243
|
1. I.pressKey('Escape') — press Escape to dismiss
|
package/src/ai/tester.ts
CHANGED
|
@@ -752,6 +752,16 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
752
752
|
}
|
|
753
753
|
|
|
754
754
|
getSystemMessage(): string {
|
|
755
|
+
let judgeRule = '';
|
|
756
|
+
if (this.agentTools?.judge) {
|
|
757
|
+
judgeRule = dedent`
|
|
758
|
+
<judge_rule>
|
|
759
|
+
For a quick judgement about the current page (did the last action already take effect, which element the scenario means), ask judge() before see().
|
|
760
|
+
Keep see() for what only a screenshot shows.
|
|
761
|
+
</judge_rule>
|
|
762
|
+
`;
|
|
763
|
+
}
|
|
764
|
+
|
|
755
765
|
return dedent`
|
|
756
766
|
<role>
|
|
757
767
|
You are a senior test automation engineer with expertise in CodeceptJS and exploratory testing.
|
|
@@ -851,6 +861,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
851
861
|
|
|
852
862
|
${capabilityGroundingRule}
|
|
853
863
|
|
|
864
|
+
${judgeRule}
|
|
865
|
+
|
|
854
866
|
${dataProtectionRules}
|
|
855
867
|
|
|
856
868
|
${this.provider.getSystemPromptForAgent('tester', this.stateManager.getCurrentState()?.url) || ''}
|
package/src/ai/tools.ts
CHANGED
|
@@ -112,7 +112,7 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
|
|
|
112
112
|
let ambiguityError: Error | null = null;
|
|
113
113
|
|
|
114
114
|
for (let i = 0; i < commands.length; i++) {
|
|
115
|
-
|
|
115
|
+
let command = transformContainsCommand(commands[i]);
|
|
116
116
|
let success = await action.attempt(command, explanation);
|
|
117
117
|
|
|
118
118
|
const attempt: { command: string; success: boolean; error?: string } = { command, success };
|
|
@@ -125,10 +125,14 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
|
|
|
125
125
|
const labels = (await extractWebElements(ambiguityError))?.map((el) => el.label) || [];
|
|
126
126
|
const index = await judge.pick(PICK_ELEMENT_QUESTION, labels, { intent: explanation, task: task.description });
|
|
127
127
|
if (index) success = await action.attemptExactElementIndex(command, index, explanation);
|
|
128
|
+
if (success) command = action.executedSteps.map((step) => step.command).join('\n') || command;
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
if (success) {
|
|
133
|
+
attempt.command = command;
|
|
134
|
+
attempt.success = true;
|
|
135
|
+
attempt.error = undefined;
|
|
132
136
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
|
|
133
137
|
|
|
134
138
|
if (!hasObservablePageChange(toolResult)) {
|
|
@@ -156,17 +160,22 @@ export function createCodeceptJSTools({ explorer, stateManager, judge }: ToolDep
|
|
|
156
160
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
157
161
|
|
|
158
162
|
const suggestion = clickFailureSuggestion(attempts);
|
|
163
|
+
const data: Record<string, any> = { ...toolResult, attempts, suggestion };
|
|
164
|
+
const clickError = ambiguityError || action.lastError;
|
|
165
|
+
|
|
166
|
+
for (const disabled of attempts.filter((a) => a.error?.includes('not enabled'))) {
|
|
167
|
+
const hoverCommand = disabled.command.replace('I.click(', 'I.moveCursorTo(');
|
|
168
|
+
const beforeHover = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
169
|
+
if (!(await action.attempt(hoverCommand, 'Hover the disabled element to reveal why it is disabled'))) continue;
|
|
170
|
+
const hoverResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(beforeHover, hoverCommand);
|
|
171
|
+
const shown = hoverResult.pageDiff?.messages;
|
|
172
|
+
if (!shown?.length) break;
|
|
173
|
+
data.disabledReason = shown;
|
|
174
|
+
data.suggestion = `Element is DISABLED. Hovering it showed: "${shown.join(' | ')}". That is why — act on it or report it, do not guess another cause.`;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
159
177
|
|
|
160
|
-
return failedToolResult(
|
|
161
|
-
'click',
|
|
162
|
-
'All click commands failed',
|
|
163
|
-
{
|
|
164
|
-
...toolResult,
|
|
165
|
-
attempts,
|
|
166
|
-
suggestion,
|
|
167
|
-
},
|
|
168
|
-
ambiguityError || action.lastError
|
|
169
|
-
);
|
|
178
|
+
return failedToolResult('click', 'All click commands failed', data, clickError);
|
|
170
179
|
},
|
|
171
180
|
}),
|
|
172
181
|
|
|
@@ -1356,7 +1365,7 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
|
|
|
1356
1365
|
const errors = attempts.map((a) => a.error || '');
|
|
1357
1366
|
|
|
1358
1367
|
if (errors.some((e) => e.includes('not enabled'))) {
|
|
1359
|
-
return 'Element exists but is DISABLED — clicking it again cannot work.
|
|
1368
|
+
return 'Element exists but is DISABLED — clicking it again cannot work. Find the precondition the page shows as unmet, then retry.';
|
|
1360
1369
|
}
|
|
1361
1370
|
|
|
1362
1371
|
if (errors.some((e) => e.includes('intercepts pointer events'))) {
|
package/src/config.ts
CHANGED
|
@@ -327,6 +327,7 @@ export class ConfigParser {
|
|
|
327
327
|
private site: SiteRecord | null = null;
|
|
328
328
|
private siteStartPath = '/';
|
|
329
329
|
private siteConfigPath: string | null = null;
|
|
330
|
+
private runBase: RunBase | null = null;
|
|
330
331
|
|
|
331
332
|
private constructor() {}
|
|
332
333
|
|
|
@@ -368,6 +369,7 @@ export class ConfigParser {
|
|
|
368
369
|
if (this.config && !options?.config && !options?.path && this.runtimeTarget === target) {
|
|
369
370
|
return this.config;
|
|
370
371
|
}
|
|
372
|
+
this.runBase = parseRunBase(options?.baseUrl);
|
|
371
373
|
|
|
372
374
|
// Store the initial working directory for reference
|
|
373
375
|
if (!process.env.INITIAL_CWD) {
|
|
@@ -404,14 +406,21 @@ export class ConfigParser {
|
|
|
404
406
|
let envUrl = options?.baseUrl;
|
|
405
407
|
if (!envUrl && target?.startsWith('http')) envUrl = target;
|
|
406
408
|
|
|
407
|
-
|
|
409
|
+
if (options?.baseUrl) {
|
|
410
|
+
const envOrigin = URL.parse(process.env.EXPLORBOT_URL || '')?.origin;
|
|
411
|
+
const baseOrigin = URL.parse(options.baseUrl)?.origin;
|
|
412
|
+
if (envOrigin && envOrigin !== baseOrigin) {
|
|
413
|
+
tag('warning').log(`--base-url ${baseOrigin} selects the site for this run — EXPLORBOT_URL ${envOrigin} is ignored`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const outputRoot = resolveOutputRoot(options?.baseUrl || process.env.EXPLORBOT_URL || envUrl);
|
|
408
417
|
loadedConfig = await this.buildEnvConfig(envUrl, outputRoot);
|
|
409
418
|
sourcePath = join(outputRoot, 'explorbot.config.js');
|
|
410
419
|
|
|
411
420
|
log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
|
|
412
421
|
}
|
|
413
422
|
|
|
414
|
-
let config = this.resolveConfig(loadedConfig as ExplorbotConfig
|
|
423
|
+
let config = this.resolveConfig(loadedConfig as ExplorbotConfig);
|
|
415
424
|
await resolveConfigModels(config.ai);
|
|
416
425
|
this.site = null;
|
|
417
426
|
this.siteConfigPath = null;
|
|
@@ -484,6 +493,22 @@ export class ConfigParser {
|
|
|
484
493
|
return this.siteConfigPath;
|
|
485
494
|
}
|
|
486
495
|
|
|
496
|
+
public applyBasePath(target: string): string {
|
|
497
|
+
const base = this.runBase?.path;
|
|
498
|
+
if (!base) return target;
|
|
499
|
+
if (!target.startsWith('/')) return target;
|
|
500
|
+
if (target === base || target.startsWith(`${base}/`) || target.startsWith(`${base}?`)) return target;
|
|
501
|
+
return `${base}${target}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
public getBaseQuery(): string {
|
|
505
|
+
return this.runBase?.query || '';
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
public getBasePath(): string {
|
|
509
|
+
return this.runBase?.path || '';
|
|
510
|
+
}
|
|
511
|
+
|
|
487
512
|
public resolveTargetPath(target?: string): string {
|
|
488
513
|
if (!this.site) {
|
|
489
514
|
const configured = this.config?.playwright?.url || this.config?.web?.url;
|
|
@@ -492,12 +517,13 @@ export class ConfigParser {
|
|
|
492
517
|
if (targetOrigin && baseOrigin && targetOrigin !== baseOrigin) {
|
|
493
518
|
tag('warning').log(`Exploring ${targetOrigin} but base URL is ${baseOrigin}. Relative navigation resolves against the base URL — set web.url to ${targetOrigin} to avoid it.`);
|
|
494
519
|
}
|
|
495
|
-
return target || '/';
|
|
520
|
+
return this.applyBasePath(target || '/');
|
|
496
521
|
}
|
|
497
522
|
if (!target) return this.siteStartPath;
|
|
498
523
|
|
|
499
524
|
const resolved = resolveSiteTarget(target, this.site.url);
|
|
500
525
|
if (resolved.baseUrl !== this.site.url) return target;
|
|
526
|
+
if (target.startsWith('/')) return this.applyBasePath(resolved.path);
|
|
501
527
|
return resolved.path;
|
|
502
528
|
}
|
|
503
529
|
|
|
@@ -524,6 +550,7 @@ export class ConfigParser {
|
|
|
524
550
|
ConfigParser.instance.site = null;
|
|
525
551
|
ConfigParser.instance.siteStartPath = '/';
|
|
526
552
|
ConfigParser.instance.siteConfigPath = null;
|
|
553
|
+
ConfigParser.instance.runBase = null;
|
|
527
554
|
}
|
|
528
555
|
}
|
|
529
556
|
|
|
@@ -694,15 +721,15 @@ export class ConfigParser {
|
|
|
694
721
|
}
|
|
695
722
|
}
|
|
696
723
|
|
|
697
|
-
private resolveConfig(config: ExplorbotConfig
|
|
724
|
+
private resolveConfig(config: ExplorbotConfig): ExplorbotConfig {
|
|
698
725
|
if (config.web?.url && !config.playwright?.url) {
|
|
699
726
|
config.playwright = config.playwright || { browser: 'chromium', url: '' };
|
|
700
727
|
config.playwright.url = config.web.url;
|
|
701
728
|
}
|
|
702
729
|
|
|
703
|
-
if (
|
|
730
|
+
if (this.runBase) {
|
|
704
731
|
config.playwright = config.playwright || { browser: 'chromium', url: '' };
|
|
705
|
-
config.playwright.url =
|
|
732
|
+
config.playwright.url = this.runBase.origin;
|
|
706
733
|
}
|
|
707
734
|
|
|
708
735
|
resolveLangfuse(config.ai);
|
|
@@ -936,8 +963,23 @@ export async function createModel(provider: string, modelId: string): Promise<an
|
|
|
936
963
|
return (await info.load())(modelId);
|
|
937
964
|
}
|
|
938
965
|
|
|
966
|
+
function parseRunBase(baseUrl?: string): RunBase | null {
|
|
967
|
+
if (!baseUrl) return null;
|
|
968
|
+
|
|
969
|
+
const url = URL.parse(baseUrl);
|
|
970
|
+
if (!url) throw new Error(`Base URL must be a full URL like https://app.example.com/team/acme, got "${baseUrl}"`);
|
|
971
|
+
|
|
972
|
+
return { origin: url.origin, path: url.pathname.replace(/\/+$/, ''), query: url.search };
|
|
973
|
+
}
|
|
974
|
+
|
|
939
975
|
type ModelRole = 'model' | 'visionModel' | 'agenticModel';
|
|
940
976
|
|
|
977
|
+
interface RunBase {
|
|
978
|
+
origin: string;
|
|
979
|
+
path: string;
|
|
980
|
+
query: string;
|
|
981
|
+
}
|
|
982
|
+
|
|
941
983
|
interface ConfiguredModel {
|
|
942
984
|
name: string;
|
|
943
985
|
provider: string;
|
package/src/explorbot.ts
CHANGED
|
@@ -155,7 +155,7 @@ export class ExplorBot {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
async visit(url: string): Promise<void> {
|
|
158
|
-
|
|
158
|
+
await this.agentNavigator().visit(this.configParser.resolveTargetPath(url));
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
async openTab(): Promise<void> {
|
|
@@ -517,6 +517,8 @@ export class ExplorBot {
|
|
|
517
517
|
setCurrentPlan(plan?: Plan): void {
|
|
518
518
|
this.currentPlan = plan;
|
|
519
519
|
if (!plan) return;
|
|
520
|
+
if (plan.url) plan.url = this.configParser.applyBasePath(plan.url);
|
|
521
|
+
for (const test of plan.tests) test.startUrl = this.configParser.applyBasePath(test.startUrl);
|
|
520
522
|
if (!this.sessionPlans.includes(plan)) {
|
|
521
523
|
this.sessionPlans.push(plan);
|
|
522
524
|
}
|
package/src/explorer.ts
CHANGED
|
@@ -6,7 +6,7 @@ import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
|
6
6
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
7
7
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
8
8
|
import dedent from 'dedent';
|
|
9
|
-
import type { Browser, BrowserContextOptions, Page } from 'playwright';
|
|
9
|
+
import type { Browser, BrowserContextOptions, Page, Route } from 'playwright';
|
|
10
10
|
import { ActionResult } from './action-result.ts';
|
|
11
11
|
import Action from './action.js';
|
|
12
12
|
import type { RequestStore } from './api/request-store.ts';
|
|
@@ -103,6 +103,7 @@ class Explorer {
|
|
|
103
103
|
if (!this.playwrightHelper) {
|
|
104
104
|
throw new Error('Playwright helper not available');
|
|
105
105
|
}
|
|
106
|
+
this.applyBasePathToHelper();
|
|
106
107
|
await this.connectOrLaunchBrowser();
|
|
107
108
|
const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
|
|
108
109
|
await this.openContextPage();
|
|
@@ -163,7 +164,7 @@ class Explorer {
|
|
|
163
164
|
|
|
164
165
|
async visit(url: string, opts: CaptureOpts = {}): Promise<ActionResult> {
|
|
165
166
|
return this.runWithRecovery('visit', async () => {
|
|
166
|
-
const action = await this.visitOnce(url);
|
|
167
|
+
const action = await this.visitOnce(ConfigParser.getInstance().applyBasePath(url));
|
|
167
168
|
if (opts.screenshot) return action.capturePageState({ includeScreenshot: true });
|
|
168
169
|
return action.getActionResult() ?? action.capturePageState();
|
|
169
170
|
});
|
|
@@ -347,6 +348,7 @@ class Explorer {
|
|
|
347
348
|
const attached = this.options?.attachedBrowser;
|
|
348
349
|
if (!attached) {
|
|
349
350
|
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
351
|
+
await this.pinBaseQuery();
|
|
350
352
|
return;
|
|
351
353
|
}
|
|
352
354
|
|
|
@@ -356,6 +358,39 @@ class Explorer {
|
|
|
356
358
|
this.playwrightHelper.browserContext = context;
|
|
357
359
|
await this.playwrightHelper._setPage(page);
|
|
358
360
|
debugLog(`Adopted attached browser page: ${page.url()}`);
|
|
361
|
+
await this.pinBaseQuery();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private applyBasePathToHelper(): void {
|
|
365
|
+
if (!ConfigParser.getInstance().getBasePath()) return;
|
|
366
|
+
|
|
367
|
+
const helper = this.playwrightHelper;
|
|
368
|
+
const original = helper.amOnPage.bind(helper);
|
|
369
|
+
helper.amOnPage = (url: string) => original(ConfigParser.getInstance().applyBasePath(url));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async pinBaseQuery(): Promise<void> {
|
|
373
|
+
const query = ConfigParser.getInstance().getBaseQuery();
|
|
374
|
+
if (!query) return;
|
|
375
|
+
|
|
376
|
+
const pinned = new URLSearchParams(query);
|
|
377
|
+
const origin = URL.parse(this.config.playwright.url)?.origin;
|
|
378
|
+
|
|
379
|
+
await this.playwrightHelper.browserContext.route('**/*', async (route: Route) => {
|
|
380
|
+
const request = route.request();
|
|
381
|
+
if (request.resourceType() !== 'document') return route.continue();
|
|
382
|
+
|
|
383
|
+
const url = URL.parse(request.url());
|
|
384
|
+
if (!url || url.origin !== origin) return route.continue();
|
|
385
|
+
|
|
386
|
+
const missing = [...pinned].filter(([key]) => !url.searchParams.has(key));
|
|
387
|
+
if (!missing.length) return route.continue();
|
|
388
|
+
|
|
389
|
+
for (const [key, value] of missing) url.searchParams.set(key, value);
|
|
390
|
+
return route.continue({ url: url.toString() });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
tag('info').log(`Every page load of ${origin} carries ${query}`);
|
|
359
394
|
}
|
|
360
395
|
|
|
361
396
|
private createBrowserContextOptions(): BrowserContextOptions {
|
package/src/global-config.ts
CHANGED
|
@@ -131,7 +131,7 @@ export function resolveSiteTarget(target?: string, defaultBaseUrl?: string): Sit
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
if (raw.startsWith('/')) {
|
|
134
|
-
const base =
|
|
134
|
+
const base = process.env.EXPLORBOT_URL || defaultBaseUrl;
|
|
135
135
|
if (!base) {
|
|
136
136
|
throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
|
|
137
137
|
}
|
package/src/test-plan.ts
CHANGED
|
@@ -66,6 +66,29 @@ export class ActiveNote {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
|
|
70
|
+
|
|
71
|
+
export class SettledExpectation {
|
|
72
|
+
constructor(
|
|
73
|
+
readonly text: string,
|
|
74
|
+
readonly status: SettledStatus,
|
|
75
|
+
readonly evidence?: string,
|
|
76
|
+
readonly resolution?: string
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
get failed(): boolean {
|
|
80
|
+
return this.status === 'failed';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get contradicted(): boolean {
|
|
84
|
+
return this.status === 'contradiction';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
resolve(status: SettledStatus, resolution: string): SettledExpectation {
|
|
88
|
+
return new SettledExpectation(this.text, status, this.evidence, resolution);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
69
92
|
export interface StepData {
|
|
70
93
|
text: string;
|
|
71
94
|
duration?: number;
|
|
@@ -3,6 +3,9 @@ import { createDebug } from './logger.js';
|
|
|
3
3
|
const debugLog = createDebug('explorbot:code-extractor');
|
|
4
4
|
|
|
5
5
|
const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
|
|
6
|
+
const QUOTES = ["'", '"', '`'];
|
|
7
|
+
const OPENING_BRACKETS = ['(', '[', '{'];
|
|
8
|
+
const CLOSING_BRACKETS = [')', ']', '}'];
|
|
6
9
|
|
|
7
10
|
export function extractCodeBlocks(aiResponse: string): string[] {
|
|
8
11
|
const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
|
|
@@ -24,3 +27,41 @@ export function extractCodeBlocks(aiResponse: string): string[] {
|
|
|
24
27
|
|
|
25
28
|
return codeBlocks;
|
|
26
29
|
}
|
|
30
|
+
|
|
31
|
+
export function splitTopLevel(code: string, separator: string): string[] {
|
|
32
|
+
const parts: string[] = [];
|
|
33
|
+
let current = '';
|
|
34
|
+
let depth = 0;
|
|
35
|
+
let quote = '';
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < code.length; i++) {
|
|
38
|
+
const char = code[i];
|
|
39
|
+
|
|
40
|
+
if (quote) {
|
|
41
|
+
current += char;
|
|
42
|
+
if (char === '\\') current += code[++i] || '';
|
|
43
|
+
if (char === quote) quote = '';
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (char === '/' && code[i + 1] === '/') {
|
|
48
|
+
while (i + 1 < code.length && code[i + 1] !== '\n') i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (char === separator && depth <= 0) {
|
|
53
|
+
parts.push(current);
|
|
54
|
+
current = '';
|
|
55
|
+
depth = 0;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
current += char;
|
|
60
|
+
if (QUOTES.includes(char)) quote = char;
|
|
61
|
+
if (OPENING_BRACKETS.includes(char)) depth++;
|
|
62
|
+
if (CLOSING_BRACKETS.includes(char)) depth--;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
parts.push(current);
|
|
66
|
+
return parts;
|
|
67
|
+
}
|
package/src/utils/html.ts
CHANGED
|
@@ -88,6 +88,7 @@ export const EXPLORBOT_ATTRS = {
|
|
|
88
88
|
coveredBy: 'data-explorbot-covered-by',
|
|
89
89
|
context: 'data-explorbot-context',
|
|
90
90
|
eidx: 'data-explorbot-eidx',
|
|
91
|
+
hidden: 'data-explorbot-hidden',
|
|
91
92
|
hit: 'data-explorbot-hit',
|
|
92
93
|
variant: 'data-explorbot-variant',
|
|
93
94
|
} as const;
|
|
@@ -189,6 +190,17 @@ export function captureHtmlForSnapshot(): string {
|
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
const liveElements = Array.from(document.body?.querySelectorAll('*') || []);
|
|
194
|
+
const clonedElements = Array.from(clone.querySelector('body')?.querySelectorAll('*') || []);
|
|
195
|
+
for (let i = 0; i < liveElements.length; i++) {
|
|
196
|
+
const source = liveElements[i];
|
|
197
|
+
if (source.matches('input, textarea, select, option, optgroup')) continue;
|
|
198
|
+
if (source.checkVisibility()) continue;
|
|
199
|
+
if (!source.parentElement?.checkVisibility()) continue;
|
|
200
|
+
if (getComputedStyle(source).display === 'contents') continue;
|
|
201
|
+
clonedElements[i]?.setAttribute('data-explorbot-hidden', 'true');
|
|
202
|
+
}
|
|
203
|
+
|
|
192
204
|
return clone.outerHTML;
|
|
193
205
|
}
|
|
194
206
|
|
|
@@ -1493,7 +1505,27 @@ function getAttribute(element: parse5TreeAdapter.Element, name: string): string
|
|
|
1493
1505
|
return attr?.value;
|
|
1494
1506
|
}
|
|
1495
1507
|
|
|
1508
|
+
function getImgAlt(element: parse5TreeAdapter.Element): string {
|
|
1509
|
+
let alt = '';
|
|
1510
|
+
|
|
1511
|
+
function processNode(node: parse5TreeAdapter.Node) {
|
|
1512
|
+
if (alt) return;
|
|
1513
|
+
if ('tagName' in node) {
|
|
1514
|
+
const el = node as parse5TreeAdapter.Element;
|
|
1515
|
+
if (el.tagName.toLowerCase() === 'img') {
|
|
1516
|
+
alt = getAttribute(el, 'alt') || '';
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
if ('childNodes' in node) node.childNodes.forEach(processNode);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
processNode(element);
|
|
1524
|
+
return alt;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1496
1527
|
function hasHiddenClass(element: parse5TreeAdapter.Element): boolean {
|
|
1528
|
+
if (getAttribute(element, EXPLORBOT_ATTRS.hidden)) return true;
|
|
1497
1529
|
const classAttr = element.attrs.find((attr) => attr.name === 'class');
|
|
1498
1530
|
if (!classAttr) return false;
|
|
1499
1531
|
|
|
@@ -1553,7 +1585,7 @@ export function extractLinks(html: string): ExtractedLink[] {
|
|
|
1553
1585
|
if (href) {
|
|
1554
1586
|
const shouldSkip = skipPrefixes.some((prefix) => href.startsWith(prefix));
|
|
1555
1587
|
if (!shouldSkip) {
|
|
1556
|
-
const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element);
|
|
1588
|
+
const rawTitle = getAttribute(element, 'aria-label') || getTextContent(element) || getImgAlt(element);
|
|
1557
1589
|
const title = sanitizeLinkTitle(rawTitle);
|
|
1558
1590
|
if (title && title.length <= 100) {
|
|
1559
1591
|
const key = `${href}|${title}`;
|
package/src/utils/mdq/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { Command } from 'commander';
|
|
2
|
+
import { Command, type CommanderError } from 'commander';
|
|
3
3
|
import { type MarkdownQuery, MdqError, mdq } from './query.ts';
|
|
4
4
|
|
|
5
5
|
const EDIT_FLAGS = ['remove', 'replace', 'insertBefore', 'insertAfter', 'prepend', 'append', 'addRow', 'addItem', 'set'] as const;
|
|
@@ -7,7 +7,7 @@ const EDIT_FLAGS = ['remove', 'replace', 'insertBefore', 'insertAfter', 'prepend
|
|
|
7
7
|
export async function runMdq(argv: string[], readStdin: StdinReader): Promise<CliResult> {
|
|
8
8
|
const program = new Command();
|
|
9
9
|
program
|
|
10
|
-
.name('mdq')
|
|
10
|
+
.name('mdq-cli')
|
|
11
11
|
.description('query and edit markdown')
|
|
12
12
|
.argument('[selector]', 'markdown selector')
|
|
13
13
|
.argument('[file]', 'file to read; stdin when omitted')
|
|
@@ -31,6 +31,7 @@ export async function runMdq(argv: string[], readStdin: StdinReader): Promise<Cl
|
|
|
31
31
|
try {
|
|
32
32
|
program.parse(argv, { from: 'user' });
|
|
33
33
|
} catch (error) {
|
|
34
|
+
if ((error as CommanderError).code === 'commander.helpDisplayed') return { output: program.helpInformation(), code: 0 };
|
|
34
35
|
return { output: String((error as Error).message), code: 2 };
|
|
35
36
|
}
|
|
36
37
|
|
package/src/utils/test-files.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, symlinkSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import chalk from 'chalk';
|
|
4
5
|
import { highlight } from 'cli-highlight';
|
|
@@ -9,6 +10,8 @@ import store from 'codeceptjs/lib/store';
|
|
|
9
10
|
import figureSet from 'figures';
|
|
10
11
|
import { ConfigParser } from '../config.ts';
|
|
11
12
|
|
|
13
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
14
|
+
|
|
12
15
|
export function listTestFiles(testsDir: string): string[] {
|
|
13
16
|
if (!existsSync(testsDir)) return [];
|
|
14
17
|
|
|
@@ -17,10 +20,32 @@ export function listTestFiles(testsDir: string): string[] {
|
|
|
17
20
|
.map((f) => path.resolve(testsDir, f));
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
|
|
25
|
+
* far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
|
|
26
|
+
*/
|
|
27
|
+
export function ensureCodeceptResolvable(dir: string): void {
|
|
28
|
+
const link = path.join(dir, 'node_modules', 'codeceptjs');
|
|
29
|
+
if (existsSync(link)) return;
|
|
30
|
+
let target: string;
|
|
31
|
+
try {
|
|
32
|
+
target = path.dirname(nodeRequire.resolve('codeceptjs/package.json'));
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
mkdirSync(path.dirname(link), { recursive: true });
|
|
38
|
+
symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
39
|
+
} catch {
|
|
40
|
+
// Best effort: a project install already resolves codeceptjs without the link.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
export function loadTestSuites(testsDir: string): any[] {
|
|
21
45
|
const jsFiles = listTestFiles(testsDir);
|
|
22
46
|
if (jsFiles.length === 0) return [];
|
|
23
47
|
|
|
48
|
+
ensureCodeceptResolvable(testsDir);
|
|
24
49
|
codeceptjs.container.createMocha();
|
|
25
50
|
const mocha = codeceptjs.container.mocha();
|
|
26
51
|
mocha.files = jsFiles;
|
|
@@ -93,6 +118,7 @@ export async function dryRunTestFile(filePath: string): Promise<void> {
|
|
|
93
118
|
codeceptjs.container.createMocha();
|
|
94
119
|
const mocha = codeceptjs.container.mocha();
|
|
95
120
|
mocha.reporter(class {});
|
|
121
|
+
ensureCodeceptResolvable(path.dirname(absPath));
|
|
96
122
|
mocha.files = [absPath];
|
|
97
123
|
mocha.loadFiles();
|
|
98
124
|
|