explorbot 0.1.28 → 0.1.30
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/README.md +83 -245
- package/bin/explorbot-cli.ts +1 -0
- package/boat/doc-collector/src/ai/documentarian.ts +37 -13
- package/boat/doc-collector/src/ai/tools.ts +60 -20
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/config.ts +7 -0
- package/boat/doc-collector/src/docbot.ts +23 -5
- package/boat/doc-collector/src/docs-renderer.ts +14 -1
- package/boat/doc-collector/src/screenshots.ts +126 -0
- package/dist/bin/explorbot-cli.js +1 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
- package/dist/boat/doc-collector/src/ai/tools.js +53 -20
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/config.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +19 -4
- package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
- package/dist/boat/doc-collector/src/screenshots.js +90 -0
- package/dist/package.json +8 -6
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action.js +26 -23
- package/dist/src/ai/fisherman.js +14 -3
- package/dist/src/ai/historian/codeceptjs.js +3 -2
- package/dist/src/ai/historian/experience.js +48 -6
- package/dist/src/ai/historian/playwright.js +2 -1
- package/dist/src/ai/historian/utils.js +1 -19
- package/dist/src/ai/historian.js +1 -1
- package/dist/src/ai/pilot.js +19 -4
- package/dist/src/ai/planner.js +16 -5
- package/dist/src/ai/provider.js +53 -18
- package/dist/src/ai/quartermaster.js +2 -2
- package/dist/src/ai/researcher.js +7 -1
- package/dist/src/ai/rules.js +44 -0
- package/dist/src/ai/tester.js +73 -7
- package/dist/src/ai/tools.js +66 -1
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +14 -3
- package/dist/src/explorer.js +30 -27
- package/dist/src/stats.js +16 -0
- package/dist/src/utils/aria.js +66 -6
- package/dist/src/utils/browser-errors.js +5 -0
- package/dist/src/utils/page-readiness.js +48 -0
- package/dist/src/utils/step-analyzer.js +68 -0
- package/package.json +8 -6
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action.ts +24 -26
- package/src/ai/fisherman.ts +14 -3
- package/src/ai/historian/codeceptjs.ts +3 -2
- package/src/ai/historian/experience.ts +51 -6
- package/src/ai/historian/playwright.ts +2 -1
- package/src/ai/historian/utils.ts +1 -21
- package/src/ai/historian.ts +1 -1
- package/src/ai/pilot.ts +19 -4
- package/src/ai/planner.ts +16 -5
- package/src/ai/provider.ts +51 -19
- package/src/ai/quartermaster.ts +2 -2
- package/src/ai/researcher.ts +8 -1
- package/src/ai/rules.ts +46 -0
- package/src/ai/tester.ts +77 -7
- package/src/ai/tools.ts +79 -1
- package/src/config.ts +2 -0
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +13 -3
- package/src/explorer.ts +28 -27
- package/src/stats.ts +18 -0
- package/src/utils/aria.ts +63 -6
- package/src/utils/browser-errors.ts +6 -0
- package/src/utils/page-readiness.ts +59 -0
- package/src/utils/step-analyzer.ts +73 -0
|
@@ -2,11 +2,11 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
|
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { marked } from 'marked';
|
|
5
|
-
import { isNonReusableCode } from "./ai/historian/utils.js";
|
|
6
5
|
import { ConfigParser } from './config.js';
|
|
7
6
|
import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
8
7
|
import { createDebug, tag } from './utils/logger.js';
|
|
9
8
|
import { mdq } from './utils/markdown-query.js';
|
|
9
|
+
import { isNonReusableCode } from "./utils/step-analyzer.js";
|
|
10
10
|
import { extractStatePath } from './utils/url-matcher.js';
|
|
11
11
|
const debugLog = createDebug('explorbot:experience');
|
|
12
12
|
const DEFAULT_MAX_EXPERIENCE_LINES = 100;
|
package/dist/src/explorbot.js
CHANGED
|
@@ -23,11 +23,12 @@ import { ConfigParser } from "./config.js";
|
|
|
23
23
|
import { ExperienceTracker } from "./experience-tracker.js";
|
|
24
24
|
import Explorer from "./explorer.js";
|
|
25
25
|
import { KnowledgeTracker } from "./knowledge-tracker.js";
|
|
26
|
+
import { Stats } from "./stats.js";
|
|
26
27
|
import { Plan } from "./test-plan.js";
|
|
27
|
-
import { parsePlansFromMarkdown } from "./utils/test-plan-markdown.js";
|
|
28
28
|
import { setVerboseMode, tag } from "./utils/logger.js";
|
|
29
29
|
import { relativeToCwd } from "./utils/next-steps.js";
|
|
30
30
|
import { sanitizeFilename } from "./utils/strings.js";
|
|
31
|
+
import { parsePlansFromMarkdown } from "./utils/test-plan-markdown.js";
|
|
31
32
|
export class ExplorBot {
|
|
32
33
|
configParser;
|
|
33
34
|
explorer;
|
|
@@ -179,7 +180,13 @@ export class ExplorBot {
|
|
|
179
180
|
this.agents.tester = this.createAgent(({ ai, explorer }) => {
|
|
180
181
|
const researcher = this.agentResearcher();
|
|
181
182
|
const navigator = this.agentNavigator();
|
|
182
|
-
const
|
|
183
|
+
const stateManager = explorer.getStateManager();
|
|
184
|
+
const experienceTracker = stateManager.getExperienceTracker();
|
|
185
|
+
const getState = () => {
|
|
186
|
+
const state = stateManager.getCurrentState();
|
|
187
|
+
return state ? ActionResult.fromState(state) : null;
|
|
188
|
+
};
|
|
189
|
+
const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
|
|
183
190
|
return new Tester(explorer, ai, researcher, navigator, tools);
|
|
184
191
|
});
|
|
185
192
|
const qm = this.agentQuartermaster();
|
|
@@ -427,7 +434,11 @@ export class ExplorBot {
|
|
|
427
434
|
tag('info').log(`Session report saved: ${relativeToCwd(filePath)}`);
|
|
428
435
|
const reporter = this.explorer?.getReporter();
|
|
429
436
|
if (reporter?.isEnabled()) {
|
|
430
|
-
|
|
437
|
+
let description = markdown;
|
|
438
|
+
const modelsTable = Stats.modelsTable(this.provider.getConfiguredModels());
|
|
439
|
+
if (modelsTable)
|
|
440
|
+
description = `${markdown}\n\n${modelsTable}`;
|
|
441
|
+
await reporter.setRunDescription(description);
|
|
431
442
|
}
|
|
432
443
|
this.lastReportedTestCount = tests.length;
|
|
433
444
|
}
|
package/dist/src/explorer.js
CHANGED
|
@@ -2,10 +2,10 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
// @ts-ignore
|
|
4
4
|
import * as codeceptjs from 'codeceptjs';
|
|
5
|
-
import dedent from 'dedent';
|
|
6
5
|
import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
7
6
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
8
7
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
8
|
+
import dedent from 'dedent';
|
|
9
9
|
import { ActionResult } from "./action-result.js";
|
|
10
10
|
import Action from './action.js';
|
|
11
11
|
import { visuallyAnnotateContainers } from "./ai/researcher/coordinates.js";
|
|
@@ -17,10 +17,11 @@ import { PlaywrightRecorder } from "./playwright-recorder.js";
|
|
|
17
17
|
import { Reporter } from "./reporter.js";
|
|
18
18
|
import { StateManager } from './state-manager.js';
|
|
19
19
|
import { TestResult } from "./test-plan.js";
|
|
20
|
+
import { BrowserRecoveryError, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
|
|
20
21
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./utils/html.js";
|
|
21
22
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
23
|
+
import { waitForPageReadiness } from "./utils/page-readiness.js";
|
|
22
24
|
import { WebElement } from "./utils/web-element.js";
|
|
23
|
-
import { BrowserRecoveryError, isFatalBrowserError } from "./utils/browser-errors.js";
|
|
24
25
|
const debugLog = createDebug('explorbot:explorer');
|
|
25
26
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
26
27
|
class Explorer {
|
|
@@ -250,16 +251,29 @@ class Explorer {
|
|
|
250
251
|
return await operation();
|
|
251
252
|
}
|
|
252
253
|
catch (error) {
|
|
253
|
-
|
|
254
|
-
|
|
254
|
+
let recoveryError = error;
|
|
255
|
+
if (isNavigationTransitionError(error)) {
|
|
256
|
+
tag('warning').log(`${label}: page is still navigating, waiting before retry...`);
|
|
257
|
+
await this.waitForPageReadiness();
|
|
258
|
+
try {
|
|
259
|
+
return await operation();
|
|
260
|
+
}
|
|
261
|
+
catch (retryError) {
|
|
262
|
+
if (!isNavigationTransitionError(retryError) && !this.isFatalBrowserError(retryError))
|
|
263
|
+
throw retryError;
|
|
264
|
+
recoveryError = retryError;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!this.isFatalBrowserError(recoveryError))
|
|
268
|
+
throw recoveryError;
|
|
255
269
|
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
256
270
|
let recovered = await this.recoverFromBrowserError();
|
|
257
271
|
if (!recovered)
|
|
258
272
|
recovered = await this.restartBrowser();
|
|
259
273
|
if (!recovered)
|
|
260
|
-
throw new BrowserRecoveryError(label,
|
|
261
|
-
if (!(await this.
|
|
262
|
-
throw new BrowserRecoveryError(label,
|
|
274
|
+
throw new BrowserRecoveryError(label, recoveryError, false);
|
|
275
|
+
if (!(await this.waitForPageReadiness()))
|
|
276
|
+
throw new BrowserRecoveryError(label, recoveryError, true);
|
|
263
277
|
try {
|
|
264
278
|
return await operation();
|
|
265
279
|
}
|
|
@@ -308,7 +322,7 @@ class Explorer {
|
|
|
308
322
|
if (!RECOVERABLE_NAVIGATION_ERRORS.test(msg))
|
|
309
323
|
throw err;
|
|
310
324
|
tag('warning').log(`Navigation warning (continuing after load): ${msg.split('\n')[0]}`);
|
|
311
|
-
await this.
|
|
325
|
+
await this.waitForPageReadiness();
|
|
312
326
|
await action.capturePageState();
|
|
313
327
|
}
|
|
314
328
|
}
|
|
@@ -412,11 +426,11 @@ class Explorer {
|
|
|
412
426
|
if (url) {
|
|
413
427
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
414
428
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
415
|
-
return this.
|
|
429
|
+
return this.waitForPageReadiness();
|
|
416
430
|
}
|
|
417
431
|
tag('warning').log('Browser error detected, reloading page');
|
|
418
432
|
await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
419
|
-
return this.
|
|
433
|
+
return this.waitForPageReadiness();
|
|
420
434
|
}
|
|
421
435
|
catch (err) {
|
|
422
436
|
tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
|
|
@@ -450,7 +464,7 @@ class Explorer {
|
|
|
450
464
|
this.listenToStateChanged();
|
|
451
465
|
if (url) {
|
|
452
466
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
453
|
-
if (!(await this.
|
|
467
|
+
if (!(await this.waitForPageReadiness()))
|
|
454
468
|
return false;
|
|
455
469
|
}
|
|
456
470
|
tag('success').log('Browser restarted');
|
|
@@ -467,25 +481,14 @@ class Explorer {
|
|
|
467
481
|
await this.playwrightHelper.switchTo();
|
|
468
482
|
}
|
|
469
483
|
}
|
|
470
|
-
async
|
|
484
|
+
async waitForPageReadiness() {
|
|
471
485
|
const page = this.playwrightHelper?.page;
|
|
472
486
|
if (!page)
|
|
473
487
|
return false;
|
|
474
|
-
await page
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const body = document.body;
|
|
479
|
-
if (!body)
|
|
480
|
-
return false;
|
|
481
|
-
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
482
|
-
}, undefined, { timeout: 5000 })
|
|
483
|
-
.then(() => true)
|
|
484
|
-
.catch(() => false);
|
|
485
|
-
if (!hasUsableDom)
|
|
486
|
-
return false;
|
|
487
|
-
}
|
|
488
|
-
await page.waitForLoadState?.('networkidle', { timeout: 3000 }).catch(() => { });
|
|
488
|
+
await waitForPageReadiness(page, {
|
|
489
|
+
timeout: this.config.playwright.waitForTimeout,
|
|
490
|
+
spinnerSelectors: this.config.playwright.spinnerSelectors,
|
|
491
|
+
});
|
|
489
492
|
return true;
|
|
490
493
|
}
|
|
491
494
|
async isInsideIframe() {
|
package/dist/src/stats.js
CHANGED
|
@@ -39,6 +39,22 @@ export class Stats {
|
|
|
39
39
|
}
|
|
40
40
|
return String(num);
|
|
41
41
|
}
|
|
42
|
+
static modelsTable(roleModels) {
|
|
43
|
+
const usedModels = Object.entries(Stats.models).filter(([, tokens]) => tokens.total > 0);
|
|
44
|
+
if (usedModels.length === 0)
|
|
45
|
+
return '';
|
|
46
|
+
const rolesByModel = {};
|
|
47
|
+
for (const [role, model] of Object.entries(roleModels)) {
|
|
48
|
+
if (!rolesByModel[model])
|
|
49
|
+
rolesByModel[model] = [];
|
|
50
|
+
rolesByModel[model].push(role);
|
|
51
|
+
}
|
|
52
|
+
const rows = usedModels.map(([model, tokens]) => {
|
|
53
|
+
const roles = rolesByModel[model]?.join(', ') || '-';
|
|
54
|
+
return `| ${roles} | ${model} | ${Stats.humanizeTokens(tokens.total)} |`;
|
|
55
|
+
});
|
|
56
|
+
return ['## Models', '', '| Role | Model | Tokens |', '| --- | --- | --- |', ...rows].join('\n');
|
|
57
|
+
}
|
|
42
58
|
static hasActivity() {
|
|
43
59
|
if (Stats.tests > 0 || Stats.plans > 0 || Stats.researches > 0)
|
|
44
60
|
return true;
|
package/dist/src/utils/aria.js
CHANGED
|
@@ -334,6 +334,56 @@ const detectRenames = (prev, curr, prevTotals, currTotals) => {
|
|
|
334
334
|
}
|
|
335
335
|
return { added, removed };
|
|
336
336
|
};
|
|
337
|
+
// Interactive controls keep a stable role+name across a state flip; only an ARIA state
|
|
338
|
+
// attribute changes. Report those flips on their own line so the model always sees
|
|
339
|
+
// "now checked / now collapsed", in both directions, regardless of other page churn.
|
|
340
|
+
const STATE_WORDS = {
|
|
341
|
+
checked: { on: 'checked', off: 'unchecked' },
|
|
342
|
+
selected: { on: 'selected', off: 'unselected' },
|
|
343
|
+
pressed: { on: 'pressed', off: 'unpressed' },
|
|
344
|
+
expanded: { on: 'expanded', off: 'collapsed' },
|
|
345
|
+
};
|
|
346
|
+
const STATE_ATTRS = Object.keys(STATE_WORDS);
|
|
347
|
+
const stateWord = (attr, value) => {
|
|
348
|
+
if (attr === 'checked' && value === 'mixed')
|
|
349
|
+
return 'partially checked';
|
|
350
|
+
const words = STATE_WORDS[attr];
|
|
351
|
+
if (value === true || value === 'true')
|
|
352
|
+
return words.on;
|
|
353
|
+
return words.off;
|
|
354
|
+
};
|
|
355
|
+
// Pair entries by path; when role and name match but a state attr differs, it's a toggle.
|
|
356
|
+
const detectToggles = (prev, curr) => {
|
|
357
|
+
const toggled = [];
|
|
358
|
+
const togglePaths = new Set();
|
|
359
|
+
const currByPath = new Map(curr.map((e) => [e.path, e]));
|
|
360
|
+
for (const before of prev) {
|
|
361
|
+
const after = currByPath.get(before.path);
|
|
362
|
+
if (!after)
|
|
363
|
+
continue;
|
|
364
|
+
if (before.entry.role !== after.entry.role)
|
|
365
|
+
continue;
|
|
366
|
+
if (before.entry.name !== after.entry.name)
|
|
367
|
+
continue;
|
|
368
|
+
const transitions = [];
|
|
369
|
+
for (const attr of STATE_ATTRS) {
|
|
370
|
+
const was = stateWord(attr, before.entry[attr]);
|
|
371
|
+
const now = stateWord(attr, after.entry[attr]);
|
|
372
|
+
if (was === now)
|
|
373
|
+
continue;
|
|
374
|
+
transitions.push(`${was} -> ${now}`);
|
|
375
|
+
}
|
|
376
|
+
if (transitions.length === 0)
|
|
377
|
+
continue;
|
|
378
|
+
togglePaths.add(before.path);
|
|
379
|
+
let label = String(after.entry.role);
|
|
380
|
+
const name = after.entry.name;
|
|
381
|
+
if (typeof name === 'string' && name.trim())
|
|
382
|
+
label += ` "${name.trim()}"`;
|
|
383
|
+
toggled.push(`${label}: ${transitions.join(', ')}`);
|
|
384
|
+
}
|
|
385
|
+
return { toggled, togglePaths };
|
|
386
|
+
};
|
|
337
387
|
const TOP_DIFF_ITEMS = 10;
|
|
338
388
|
const formatDiffSection = (label, items) => {
|
|
339
389
|
const summary = countBy(items);
|
|
@@ -357,10 +407,17 @@ const formatDiffSection = (label, items) => {
|
|
|
357
407
|
}
|
|
358
408
|
return lines;
|
|
359
409
|
};
|
|
360
|
-
const formatDiff = (added, removed) => {
|
|
361
|
-
if (added.length === 0 && removed.length === 0)
|
|
410
|
+
const formatDiff = (added, removed, toggled) => {
|
|
411
|
+
if (added.length === 0 && removed.length === 0 && toggled.length === 0)
|
|
362
412
|
return null;
|
|
363
|
-
|
|
413
|
+
const sections = ['ariaDiff:'];
|
|
414
|
+
if (toggled.length > 0) {
|
|
415
|
+
sections.push(' toggled:');
|
|
416
|
+
for (const line of toggled)
|
|
417
|
+
sections.push(` - ${line}`);
|
|
418
|
+
}
|
|
419
|
+
sections.push(...formatDiffSection('added', added), ...formatDiffSection('removed', removed));
|
|
420
|
+
return sections.join('\n');
|
|
364
421
|
};
|
|
365
422
|
const CLOSE_OVERLAY_BUTTON_RE = /^close\s+(modal|dialog|popup|drawer|panel|sheet)\b/i;
|
|
366
423
|
const findOverlayByCloseButton = (nodeList) => {
|
|
@@ -424,13 +481,16 @@ export const diffAriaSnapshots = (previous, current) => {
|
|
|
424
481
|
tree = dropEmpty(tree);
|
|
425
482
|
return flatten(tree);
|
|
426
483
|
};
|
|
427
|
-
const
|
|
428
|
-
const
|
|
484
|
+
const prevAll = flat(previous);
|
|
485
|
+
const currAll = flat(current);
|
|
486
|
+
const { toggled, togglePaths } = detectToggles(prevAll, currAll);
|
|
487
|
+
const prev = prevAll.filter((e) => !togglePaths.has(e.path));
|
|
488
|
+
const curr = currAll.filter((e) => !togglePaths.has(e.path));
|
|
429
489
|
const prevTotals = countBy(prev.map((e) => e.summary));
|
|
430
490
|
const currTotals = countBy(curr.map((e) => e.summary));
|
|
431
491
|
const byCount = diffByCount(prevTotals, currTotals);
|
|
432
492
|
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
433
|
-
return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed]);
|
|
493
|
+
return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed], toggled);
|
|
434
494
|
};
|
|
435
495
|
export const detectFocusArea = (snapshot) => {
|
|
436
496
|
let tree = parseSnapshot(snapshot);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// not typed exceptions. Keep those external message markers in one adapter so
|
|
3
3
|
// recovery decisions are not duplicated across agents/actions.
|
|
4
4
|
const FATAL_BROWSER_ERROR_MARKERS = ['Frame was detached', 'Target closed', 'Target page, context or browser has been closed', 'Execution context was destroyed', 'Protocol error', 'Session closed'];
|
|
5
|
+
const NAVIGATION_TRANSITION_ERROR_MARKERS = ['most likely because of a navigation', 'navigating and changing the content'];
|
|
5
6
|
export class BrowserRecoveryError extends Error {
|
|
6
7
|
originalError;
|
|
7
8
|
recovered;
|
|
@@ -18,6 +19,10 @@ export function isFatalBrowserError(error) {
|
|
|
18
19
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
19
20
|
return FATAL_BROWSER_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
20
21
|
}
|
|
22
|
+
export function isNavigationTransitionError(error) {
|
|
23
|
+
const message = browserErrorMessage(error).toLowerCase();
|
|
24
|
+
return NAVIGATION_TRANSITION_ERROR_MARKERS.some((marker) => message.includes(marker.toLowerCase()));
|
|
25
|
+
}
|
|
21
26
|
export function browserErrorMessage(error) {
|
|
22
27
|
return error instanceof Error ? error.message : String(error);
|
|
23
28
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export async function waitForPageReadiness(page, options = {}) {
|
|
2
|
+
if (!page)
|
|
3
|
+
return;
|
|
4
|
+
const timeout = options.timeout ?? 6000;
|
|
5
|
+
await page.waitForLoadState?.('domcontentloaded', { timeout })?.catch(() => { });
|
|
6
|
+
await Promise.race([waitForNetworkIdle(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => { });
|
|
7
|
+
await waitForPageBodyContent(page, timeout);
|
|
8
|
+
}
|
|
9
|
+
function waitForNetworkIdle(page, timeout) {
|
|
10
|
+
if (!page?.waitForLoadState)
|
|
11
|
+
return Promise.resolve();
|
|
12
|
+
return page.waitForLoadState('networkidle', { timeout }).catch(() => { });
|
|
13
|
+
}
|
|
14
|
+
async function waitForVisibleSpinnersHidden(page, selectors, timeout) {
|
|
15
|
+
if (!selectors.length)
|
|
16
|
+
return new Promise(() => { });
|
|
17
|
+
if (!page?.locator)
|
|
18
|
+
return new Promise(() => { });
|
|
19
|
+
const visibleSpinners = [];
|
|
20
|
+
for (const selector of selectors) {
|
|
21
|
+
const locator = page.locator(selector);
|
|
22
|
+
const isVisible = await locator
|
|
23
|
+
.first()
|
|
24
|
+
.isVisible({ timeout: 100 })
|
|
25
|
+
.catch(() => false);
|
|
26
|
+
if (!isVisible)
|
|
27
|
+
continue;
|
|
28
|
+
visibleSpinners.push(locator);
|
|
29
|
+
}
|
|
30
|
+
if (visibleSpinners.length === 0)
|
|
31
|
+
return new Promise(() => { });
|
|
32
|
+
return Promise.all(visibleSpinners.map((locator) => locator.waitFor({ state: 'hidden', timeout }).catch(() => { }))).then(() => { });
|
|
33
|
+
}
|
|
34
|
+
function waitForPageBodyContent(page, timeout) {
|
|
35
|
+
if (!page?.waitForFunction)
|
|
36
|
+
return Promise.resolve();
|
|
37
|
+
return page
|
|
38
|
+
.waitForFunction(() => {
|
|
39
|
+
const body = document.body;
|
|
40
|
+
if (!body)
|
|
41
|
+
return false;
|
|
42
|
+
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
43
|
+
}, undefined, { timeout })
|
|
44
|
+
.catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
function sleep(ms) {
|
|
47
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isDynamicId } from "./xpath.js";
|
|
2
|
+
export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'];
|
|
3
|
+
const CODECEPT_FORM_COMMANDS = ['I.fillField', 'I.type', 'I.selectOption', 'I.attachFile', 'I.checkOption', 'I.uncheckOption'];
|
|
4
|
+
export function isCodeceptToolName(toolName) {
|
|
5
|
+
return CODECEPT_TOOLS.includes(toolName);
|
|
6
|
+
}
|
|
7
|
+
export function getCodeceptToolName(commandName) {
|
|
8
|
+
const toolName = CODECEPT_TOOLS.find((name) => commandName === `I.${name}`);
|
|
9
|
+
if (toolName)
|
|
10
|
+
return toolName;
|
|
11
|
+
if (CODECEPT_FORM_COMMANDS.includes(commandName))
|
|
12
|
+
return 'form';
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
export function getCodeceptToolNameFromCode(code) {
|
|
16
|
+
const parenIndex = code.trim().indexOf('(');
|
|
17
|
+
if (parenIndex < 1)
|
|
18
|
+
return null;
|
|
19
|
+
return getCodeceptToolName(code.trim().slice(0, parenIndex));
|
|
20
|
+
}
|
|
21
|
+
export function stripComments(code) {
|
|
22
|
+
return code
|
|
23
|
+
.split('\n')
|
|
24
|
+
.filter((line) => {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
|
|
27
|
+
})
|
|
28
|
+
.join('\n');
|
|
29
|
+
}
|
|
30
|
+
export function isNonReusableCode(code) {
|
|
31
|
+
if (/\bI\.clickXY\s*\(/.test(code))
|
|
32
|
+
return true;
|
|
33
|
+
for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
|
|
34
|
+
if (isDynamicId(m[1]))
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
export function toReusableSessionStep(step) {
|
|
40
|
+
if (step.status !== 'passed')
|
|
41
|
+
return null;
|
|
42
|
+
const code = stripComments(step.text);
|
|
43
|
+
if (!code || isNonReusableCode(code))
|
|
44
|
+
return null;
|
|
45
|
+
const toolName = getCodeceptToolNameFromCode(code);
|
|
46
|
+
if (!toolName)
|
|
47
|
+
return null;
|
|
48
|
+
return {
|
|
49
|
+
message: step.text,
|
|
50
|
+
status: 'passed',
|
|
51
|
+
tool: toolName,
|
|
52
|
+
code,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function mergeUniqueStepsByCode(primary, secondary) {
|
|
56
|
+
const merged = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const step of [...primary, ...secondary]) {
|
|
59
|
+
const identity = stripComments(step.code || '').trim();
|
|
60
|
+
if (!identity)
|
|
61
|
+
continue;
|
|
62
|
+
if (seen.has(identity))
|
|
63
|
+
continue;
|
|
64
|
+
seen.add(identity);
|
|
65
|
+
merged.push(step);
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -65,9 +65,10 @@
|
|
|
65
65
|
},
|
|
66
66
|
"author": "",
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@ai-sdk/anthropic": "^
|
|
69
|
-
"@ai-sdk/groq": "^
|
|
70
|
-
"@ai-sdk/openai": "^
|
|
68
|
+
"@ai-sdk/anthropic": "^4.0",
|
|
69
|
+
"@ai-sdk/groq": "^4.0",
|
|
70
|
+
"@ai-sdk/openai": "^4.0",
|
|
71
|
+
"@ai-sdk/otel": "^1.0.2",
|
|
71
72
|
"@axe-core/playwright": "^4.11.0",
|
|
72
73
|
"@codeceptjs/reflection": "^0.5.2",
|
|
73
74
|
"@faker-js/faker": "^10.4.0",
|
|
@@ -82,8 +83,8 @@
|
|
|
82
83
|
"@opentelemetry/sdk-trace-base": "^2.2.0",
|
|
83
84
|
"@opentelemetry/semantic-conventions": "^1.38.0",
|
|
84
85
|
"@scalar/openapi-parser": "^0.25.6",
|
|
85
|
-
"@testomatio/reporter": "^2.
|
|
86
|
-
"ai": "^
|
|
86
|
+
"@testomatio/reporter": "^2.9.1",
|
|
87
|
+
"ai": "^7.0.2",
|
|
87
88
|
"axe-core": "^4.11.1",
|
|
88
89
|
"bash-tool": "^1.3.15",
|
|
89
90
|
"cli-highlight": "^2.1.11",
|
|
@@ -110,6 +111,7 @@
|
|
|
110
111
|
"parse5": "^8.0.0",
|
|
111
112
|
"playwright": "^1.60",
|
|
112
113
|
"react": "^19.1.1",
|
|
114
|
+
"sambanova-ai-provider": "^1.2.2",
|
|
113
115
|
"strip-ansi": "^7.1.2",
|
|
114
116
|
"turndown": "^7.2.1",
|
|
115
117
|
"unique-names-generator": "^4.7.1",
|
|
@@ -113,6 +113,8 @@ For input field values, ALWAYS use I.seeInField() — never check value via CSS
|
|
|
113
113
|
Prefer text locators (label, name, placeholder) for form fields: I.seeInField('Search', 'value') over I.seeInField('input[name="search"]', 'value').
|
|
114
114
|
Only use locators that exist in the provided HTML or ARIA snapshot.
|
|
115
115
|
Verify exact conditions, not approximate matches.
|
|
116
|
+
When the claim contains a concrete quoted value, generated assertion code MUST include that whole value. Do not shorten names, IDs, titles, emails, URLs, or other user-created values.
|
|
117
|
+
For exact visible text, prefer a text assertion scoped to a specific container or an ARIA locator with the complete text. Partial text is not valid evidence for a claim about the full value.
|
|
116
118
|
NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking text inside an element is the job of I.see(text, context) — the `:has-text()` form duplicates that capability with a fragile selector.
|
|
117
119
|
NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
|
|
118
120
|
</verification_rules>
|
package/src/action.ts
CHANGED
|
@@ -18,14 +18,16 @@ import type { UserResolveFunction } from './explorbot.ts';
|
|
|
18
18
|
import { Observability } from './observability.ts';
|
|
19
19
|
import type { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
20
20
|
import type { StateManager } from './state-manager.js';
|
|
21
|
+
import { isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
|
|
21
22
|
import { extractCodeBlocks } from './utils/code-extractor.js';
|
|
22
23
|
import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
23
24
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
25
|
+
import { waitForPageReadiness } from './utils/page-readiness.ts';
|
|
24
26
|
import { safeFilename } from './utils/strings.ts';
|
|
25
27
|
import { throttle } from './utils/throttle.ts';
|
|
26
|
-
import { isFatalBrowserError } from './utils/browser-errors.ts';
|
|
27
28
|
|
|
28
29
|
const debugLog = createDebug('explorbot:action');
|
|
30
|
+
const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
|
|
29
31
|
|
|
30
32
|
class Action {
|
|
31
33
|
private actor: CodeceptJS.I;
|
|
@@ -78,16 +80,21 @@ class Action {
|
|
|
78
80
|
const timestamp = Date.now();
|
|
79
81
|
const page = this.playwrightHelper.page;
|
|
80
82
|
const frame = this.playwrightHelper.frame;
|
|
81
|
-
await page
|
|
82
|
-
await waitForUsablePageDom(page);
|
|
83
|
+
await this.waitForPageReadiness(page);
|
|
83
84
|
const grabAll = () => Promise.all([captureHtml(page, frame, this.actor), captureTitle(page, this.actor), this.captureBrowserLogs()]);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
let html = '';
|
|
86
|
+
let title = '';
|
|
87
|
+
let browserLogs: any[] = [];
|
|
88
|
+
for (let attempt = 1; attempt <= CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS; attempt++) {
|
|
89
|
+
try {
|
|
90
|
+
[html, title, browserLogs] = await grabAll();
|
|
91
|
+
break;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (!isNavigationTransitionError(err)) throw err;
|
|
94
|
+
if (attempt === CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS) throw err;
|
|
95
|
+
await this.waitForPageReadiness(page);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
91
98
|
const url = page?.url() || (await (this.actor as any).grabCurrentUrl?.());
|
|
92
99
|
|
|
93
100
|
let screenshotFile: string | undefined = undefined;
|
|
@@ -442,6 +449,13 @@ class Action {
|
|
|
442
449
|
getActionResult(): ActionResult | null {
|
|
443
450
|
return this.actionResult;
|
|
444
451
|
}
|
|
452
|
+
|
|
453
|
+
private async waitForPageReadiness(page: any): Promise<void> {
|
|
454
|
+
await waitForPageReadiness(page, {
|
|
455
|
+
timeout: this.config.playwright.waitForTimeout,
|
|
456
|
+
spinnerSelectors: this.config.playwright.spinnerSelectors,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
445
459
|
}
|
|
446
460
|
|
|
447
461
|
export default Action;
|
|
@@ -453,22 +467,6 @@ function errorToString(error: any): string {
|
|
|
453
467
|
return error.message || error.toString();
|
|
454
468
|
}
|
|
455
469
|
|
|
456
|
-
async function waitForUsablePageDom(page: any): Promise<void> {
|
|
457
|
-
if (!page?.waitForFunction) return;
|
|
458
|
-
|
|
459
|
-
await page
|
|
460
|
-
.waitForFunction(
|
|
461
|
-
() => {
|
|
462
|
-
const body = document.body;
|
|
463
|
-
if (!body) return false;
|
|
464
|
-
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
465
|
-
},
|
|
466
|
-
undefined,
|
|
467
|
-
{ timeout: 5000 }
|
|
468
|
-
)
|
|
469
|
-
.catch(() => {});
|
|
470
|
-
}
|
|
471
|
-
|
|
472
470
|
async function captureHtml(page: any, frame: any, actor: any): Promise<string> {
|
|
473
471
|
if (frame?.content) return frame.content();
|
|
474
472
|
if (page?.content) return page.content();
|
package/src/ai/fisherman.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { loop } from '../utils/loop.ts';
|
|
|
9
9
|
import type { Agent } from './agent.ts';
|
|
10
10
|
import { type FishermanResult, createFishermanTools } from './fisherman-tools.ts';
|
|
11
11
|
import type { Provider } from './provider.ts';
|
|
12
|
+
import { dataProtectionRules } from './rules.ts';
|
|
12
13
|
|
|
13
14
|
const MAX_ITERATIONS = 15;
|
|
14
15
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
@@ -85,7 +86,7 @@ export class Fisherman implements Agent {
|
|
|
85
86
|
baseEndpoint: this.baseEndpoint,
|
|
86
87
|
});
|
|
87
88
|
|
|
88
|
-
const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, scopeUrl), 'fisherman');
|
|
89
|
+
const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
|
|
89
90
|
conversation.addUserText(this.buildTaskPrompt(instructions));
|
|
90
91
|
|
|
91
92
|
await loop(
|
|
@@ -187,7 +188,7 @@ export class Fisherman implements Agent {
|
|
|
187
188
|
return lines.join('\n');
|
|
188
189
|
}
|
|
189
190
|
|
|
190
|
-
private buildSystemPrompt(endpointList: string, scopeUrl?: string): string {
|
|
191
|
+
private buildSystemPrompt(endpointList: string, toolNames: string[], scopeUrl?: string): string {
|
|
191
192
|
const scopeBlock = scopeUrl ? `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.` : '';
|
|
192
193
|
|
|
193
194
|
return dedent`
|
|
@@ -197,6 +198,11 @@ export class Fisherman implements Agent {
|
|
|
197
198
|
${endpointList}
|
|
198
199
|
${scopeBlock}
|
|
199
200
|
|
|
201
|
+
AVAILABLE TOOLS:
|
|
202
|
+
${toolNames.join(', ')}.
|
|
203
|
+
Use tool names exactly as listed. Do not invent aliases, combined names, or names with channel markers such as "commentary".
|
|
204
|
+
Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
|
|
205
|
+
|
|
200
206
|
WORKFLOW:
|
|
201
207
|
1. Call getEndpointSpec to see the request body example for the endpoint
|
|
202
208
|
2. Make requests — the response automatically extracts IDs, names, and status fields
|
|
@@ -208,6 +214,8 @@ export class Fisherman implements Agent {
|
|
|
208
214
|
- Chain requests logically — create parent resources before children
|
|
209
215
|
- If a request fails, try once more with adjusted data before reporting failure
|
|
210
216
|
- Use realistic but unique data for each item (vary names, titles)
|
|
217
|
+
|
|
218
|
+
${dataProtectionRules}
|
|
211
219
|
`;
|
|
212
220
|
}
|
|
213
221
|
|
|
@@ -217,7 +225,10 @@ export class Fisherman implements Agent {
|
|
|
217
225
|
|
|
218
226
|
${instructions}
|
|
219
227
|
|
|
220
|
-
|
|
228
|
+
${dataProtectionRules}
|
|
229
|
+
|
|
230
|
+
If data preparation is allowed by these rules, execute the necessary API requests to create this data.
|
|
231
|
+
When done, call finish with the summary. If data preparation is forbidden, call stop with the reason.
|
|
221
232
|
`;
|
|
222
233
|
}
|
|
223
234
|
}
|
|
@@ -7,10 +7,11 @@ import type { Plan } from '../../test-plan.ts';
|
|
|
7
7
|
import { tag } from '../../utils/logger.ts';
|
|
8
8
|
import { relativeToCwd } from '../../utils/next-steps.ts';
|
|
9
9
|
import { safeFilename } from '../../utils/strings.ts';
|
|
10
|
+
import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
|
|
10
11
|
import type { Conversation } from '../conversation.ts';
|
|
11
|
-
import { ASSERTION_TOOLS
|
|
12
|
+
import { ASSERTION_TOOLS } from '../tools.ts';
|
|
12
13
|
import type { Constructor } from './mixin.ts';
|
|
13
|
-
import { escapeString, getExecutionLabel
|
|
14
|
+
import { escapeString, getExecutionLabel } from './utils.ts';
|
|
14
15
|
|
|
15
16
|
export interface CodeceptJSMethods {
|
|
16
17
|
toCode(conversation: Conversation, scenario: string): string;
|