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/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,4 +1,9 @@
|
|
|
1
1
|
export declare function listTestFiles(testsDir: string): string[];
|
|
2
|
+
/**
|
|
3
|
+
* Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
|
|
4
|
+
* far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function ensureCodeceptResolvable(dir: string): void;
|
|
2
7
|
export declare function loadTestSuites(testsDir: string): any[];
|
|
3
8
|
export declare function printTestList(suites: any[]): void;
|
|
4
9
|
export declare function dryRunTestFile(filePath: string): Promise<void>;
|
|
@@ -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';
|
|
@@ -8,6 +9,7 @@ import storeListener from 'codeceptjs/lib/listener/store';
|
|
|
8
9
|
import store from 'codeceptjs/lib/store';
|
|
9
10
|
import figureSet from 'figures';
|
|
10
11
|
import { ConfigParser } from "../config.js";
|
|
12
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
11
13
|
export function listTestFiles(testsDir) {
|
|
12
14
|
if (!existsSync(testsDir))
|
|
13
15
|
return [];
|
|
@@ -15,10 +17,34 @@ export function listTestFiles(testsDir) {
|
|
|
15
17
|
.filter((f) => f.endsWith('.js'))
|
|
16
18
|
.map((f) => path.resolve(testsDir, f));
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Generated tests import `codeceptjs/steps`, but in global mode they lie in ~/.explorbot,
|
|
22
|
+
* far from any node_modules. Link the bot's own codeceptjs next to them so Node resolves it.
|
|
23
|
+
*/
|
|
24
|
+
export function ensureCodeceptResolvable(dir) {
|
|
25
|
+
const link = path.join(dir, 'node_modules', 'codeceptjs');
|
|
26
|
+
if (existsSync(link))
|
|
27
|
+
return;
|
|
28
|
+
let target;
|
|
29
|
+
try {
|
|
30
|
+
target = path.dirname(nodeRequire.resolve('codeceptjs/package.json'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(path.dirname(link), { recursive: true });
|
|
37
|
+
symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Best effort: a project install already resolves codeceptjs without the link.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
18
43
|
export function loadTestSuites(testsDir) {
|
|
19
44
|
const jsFiles = listTestFiles(testsDir);
|
|
20
45
|
if (jsFiles.length === 0)
|
|
21
46
|
return [];
|
|
47
|
+
ensureCodeceptResolvable(testsDir);
|
|
22
48
|
codeceptjs.container.createMocha();
|
|
23
49
|
const mocha = codeceptjs.container.mocha();
|
|
24
50
|
mocha.files = jsFiles;
|
|
@@ -79,6 +105,7 @@ export async function dryRunTestFile(filePath) {
|
|
|
79
105
|
const mocha = codeceptjs.container.mocha();
|
|
80
106
|
mocha.reporter(class {
|
|
81
107
|
});
|
|
108
|
+
ensureCodeceptResolvable(path.dirname(absPath));
|
|
82
109
|
mocha.files = [absPath];
|
|
83
110
|
mocha.loadFiles();
|
|
84
111
|
let currentSuite = '';
|
|
@@ -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).
|
|
@@ -66,11 +66,23 @@ Every CLI command that drives a browser accepts these options (`start`, `explore
|
|
|
66
66
|
| `--debug` | Enable debug logging (same as `--verbose`) |
|
|
67
67
|
| `-c, --config <path>` | Path to configuration file |
|
|
68
68
|
| `-p, --path <path>` | Working directory path |
|
|
69
|
+
| `--base-url <url>` | Run against this URL: its path scopes relative paths, its query params ride along with every page load |
|
|
69
70
|
| `-s, --show` | Show browser window |
|
|
70
71
|
| `--headless` | Run browser in headless mode |
|
|
71
72
|
| `--incognito` | Run without recording experiences |
|
|
72
73
|
| `--session [file]` | Save/restore browser session (cookies, localStorage) from file |
|
|
73
74
|
|
|
75
|
+
### `--base-url`
|
|
76
|
+
|
|
77
|
+
Points one run at one area of a site, leaving every config file alone. The origin selects the site, so the knowledge and experience already stored for that host still apply. The path prefixes every relative path a plan or a command gives, and the query params are added to each page load.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx explorbot test tests/plans/runs.md '*' \
|
|
81
|
+
--base-url 'https://app.example.com/teams/acme/?preview=pr-42'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A plan whose tests start at `/runs` runs them at `/teams/acme/runs`, and every page load carries `?preview=pr-42` — enough to keep a preview deployment selected for a whole session. The param travels on the request rather than in the address bar, so Explorbot navigates to clean URLs, though an app that redirects can still echo the param back. Absolute URLs are never prefixed: `https://app.example.com/users/sign_in` still points outside the scoped area, as a login page usually must.
|
|
85
|
+
|
|
74
86
|
### `--knowledge`
|
|
75
87
|
|
|
76
88
|
Passes facts to the run without creating a file in `knowledge/`. Plain text applies everywhere; add frontmatter to scope it to a page or an API endpoint. Repeat the flag for several facts. See [Knowledge](../workflow/knowledge.md#per-session-knowledge).
|
|
@@ -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
|
@@ -114,6 +114,7 @@ class Action {
|
|
|
114
114
|
if (includeScreenshot) {
|
|
115
115
|
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
116
116
|
const screenshotPath = join(statesDir, filename);
|
|
117
|
+
await page?.screencast?.hideOverlays().catch(() => {});
|
|
117
118
|
screenshotFile = await page
|
|
118
119
|
?.screenshot({ path: screenshotPath, fullPage: true })
|
|
119
120
|
.then(() => {
|
|
@@ -124,6 +125,7 @@ class Action {
|
|
|
124
125
|
debugLog('Screenshot failed, continuing without it:', err);
|
|
125
126
|
return undefined;
|
|
126
127
|
});
|
|
128
|
+
await page?.screencast?.showOverlays().catch(() => {});
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
// Save HTML to file
|
|
@@ -587,6 +589,10 @@ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Arra
|
|
|
587
589
|
if (batchFailed) return;
|
|
588
590
|
|
|
589
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
|
+
}
|
|
590
596
|
if (error) {
|
|
591
597
|
executed.error = errorToString(error);
|
|
592
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 {
|
|
@@ -8,6 +8,7 @@ import { tag } from '../../utils/logger.ts';
|
|
|
8
8
|
import { relativeToCwd } from '../../utils/next-steps.ts';
|
|
9
9
|
import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from '../../utils/step-analyzer.ts';
|
|
10
10
|
import { safeFilename } from '../../utils/strings.ts';
|
|
11
|
+
import { ensureCodeceptResolvable } from '../../utils/test-files.ts';
|
|
11
12
|
import type { Conversation } from '../conversation.ts';
|
|
12
13
|
import { ASSERTION_TOOLS } from '../tools.ts';
|
|
13
14
|
import type { Constructor } from './mixin.ts';
|
|
@@ -99,6 +100,7 @@ export function WithCodeceptJS<T extends Constructor>(Base: T) {
|
|
|
99
100
|
|
|
100
101
|
const testsDir = ConfigParser.getInstance().getTestsDir();
|
|
101
102
|
mkdirSync(testsDir, { recursive: true });
|
|
103
|
+
ensureCodeceptResolvable(testsDir);
|
|
102
104
|
|
|
103
105
|
const filePath = join(testsDir, safeFilename(plan.title, '.js'));
|
|
104
106
|
writeFileSync(filePath, lines.join('\n'));
|
package/src/ai/judge-tool.ts
CHANGED
|
@@ -11,7 +11,11 @@ export function createJudgeTool(deps: ToolDeps, buildState: () => Promise<Record
|
|
|
11
11
|
return {
|
|
12
12
|
judge: tool({
|
|
13
13
|
description: dedent`
|
|
14
|
-
|
|
14
|
+
Fast yes/no or pick-one decision about the current page, answered from <page_aria> and your recent steps.
|
|
15
|
+
No screenshot, no HTML, produces no test code.
|
|
16
|
+
Use for in-flow judgements you would otherwise guess: did the last action already take effect, which of several similar elements the scenario means, is this the expected page or dialog.
|
|
17
|
+
Not for final assertions (use verify/finish) and not for anything page_aria does not show, such as styling or visual state (use see).
|
|
18
|
+
Phrase the question literally and concretely.
|
|
15
19
|
`,
|
|
16
20
|
inputSchema: z.object({
|
|
17
21
|
question: z.string().describe('The statement to confirm, or the question the options answer'),
|
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 };
|
package/src/ai/pilot.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type Explorer from '../explorer.ts';
|
|
|
8
8
|
import type { PlaywrightRecorder } from '../playwright-recorder.ts';
|
|
9
9
|
import type { StateManager } from '../state-manager.ts';
|
|
10
10
|
import { Stats } from '../stats.ts';
|
|
11
|
-
import { type Test, TestResult, TestStatus } from '../test-plan.ts';
|
|
11
|
+
import { SettledExpectation, type SettledStatus, type Test, TestResult, TestStatus } from '../test-plan.ts';
|
|
12
12
|
import { collectInteractiveNodes } from '../utils/aria.ts';
|
|
13
13
|
import { ErrorPageError } from '../utils/error-page.ts';
|
|
14
14
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
@@ -39,6 +39,12 @@ const OUTCOME_STATUS: Record<string, SettledStatus> = {
|
|
|
39
39
|
'The run shows this outcome did not happen.': 'failed',
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
const CONTRADICTION_RESOLUTION: Record<string, { status: SettledStatus; resolution: string }> = {
|
|
43
|
+
'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' },
|
|
44
|
+
'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' },
|
|
45
|
+
'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' },
|
|
46
|
+
};
|
|
47
|
+
|
|
42
48
|
export class Pilot implements Agent {
|
|
43
49
|
emoji = '🧭';
|
|
44
50
|
private provider: Provider;
|
|
@@ -619,7 +625,7 @@ export class Pilot implements Agent {
|
|
|
619
625
|
let settledByJudge = new Map<string, SettledStatus>();
|
|
620
626
|
if (!image) settledByJudge = await this.settleByJudge(task, undecided);
|
|
621
627
|
undecided = undecided.filter((text) => !settledByJudge.has(text));
|
|
622
|
-
if (!undecided.length) return task.expected.map((text) => (
|
|
628
|
+
if (!undecided.length) return task.expected.map((text) => new SettledExpectation(text, settledByJudge.get(text) || decided(text)));
|
|
623
629
|
|
|
624
630
|
const schema = z.object({
|
|
625
631
|
outcomes: z.array(
|
|
@@ -691,14 +697,36 @@ export class Pilot implements Agent {
|
|
|
691
697
|
if (!response) response = await settle(userContent, this.provider.getAgenticModel('pilot'));
|
|
692
698
|
|
|
693
699
|
const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome]));
|
|
694
|
-
|
|
700
|
+
const settled = task.expected.map((text) => {
|
|
695
701
|
const byJudge = settledByJudge.get(text);
|
|
696
|
-
if (byJudge) return
|
|
697
|
-
if (!undecided.includes(text)) return
|
|
702
|
+
if (byJudge) return new SettledExpectation(text, byJudge);
|
|
703
|
+
if (!undecided.includes(text)) return new SettledExpectation(text, decided(text));
|
|
698
704
|
const outcome = judged.get(text) as { status: SettledStatus; evidence?: string } | undefined;
|
|
699
|
-
if (!outcome) return
|
|
700
|
-
return
|
|
705
|
+
if (!outcome) return new SettledExpectation(text, 'unverified');
|
|
706
|
+
return new SettledExpectation(text, outcome.status || 'unverified', outcome.evidence);
|
|
701
707
|
});
|
|
708
|
+
return this.resolveContradictions(settled, finalState);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private async resolveContradictions(outcomes: SettledExpectation[], finalState?: ActionResult): Promise<SettledExpectation[]> {
|
|
712
|
+
const judge = this.judge;
|
|
713
|
+
if (!judge || !finalState) return outcomes;
|
|
714
|
+
if (!outcomes.some((outcome) => outcome.contradicted)) return outcomes;
|
|
715
|
+
|
|
716
|
+
const page = finalState.getCompactARIA().slice(0, JUDGE_PAGE_CAP);
|
|
717
|
+
return Promise.all(
|
|
718
|
+
outcomes.map(async (outcome) => {
|
|
719
|
+
if (!outcome.contradicted) return outcome;
|
|
720
|
+
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], {
|
|
721
|
+
expectation: outcome.text,
|
|
722
|
+
disagreement: outcome.evidence || '',
|
|
723
|
+
page,
|
|
724
|
+
});
|
|
725
|
+
const resolved = CONTRADICTION_RESOLUTION[decision.value ?? ''];
|
|
726
|
+
if (!resolved) return outcome;
|
|
727
|
+
return outcome.resolve(resolved.status, resolved.resolution);
|
|
728
|
+
})
|
|
729
|
+
);
|
|
702
730
|
}
|
|
703
731
|
|
|
704
732
|
private async settleByJudge(task: Test, expectations: string[]): Promise<Map<string, SettledStatus>> {
|
|
@@ -1162,6 +1190,9 @@ export class Pilot implements Agent {
|
|
|
1162
1190
|
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
1163
1191
|
if (consoleError) line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1164
1192
|
|
|
1193
|
+
const disabledReason = t.output?.disabledReason?.[0];
|
|
1194
|
+
if (disabledReason) line += `\n shown on hover of disabled target: ${disabledReason.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1195
|
+
|
|
1165
1196
|
return line;
|
|
1166
1197
|
})
|
|
1167
1198
|
.join('\n\n');
|
|
@@ -1272,11 +1303,3 @@ export class Pilot implements Agent {
|
|
|
1272
1303
|
`;
|
|
1273
1304
|
}
|
|
1274
1305
|
}
|
|
1275
|
-
|
|
1276
|
-
export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
|
|
1277
|
-
|
|
1278
|
-
export interface SettledExpectation {
|
|
1279
|
-
text: string;
|
|
1280
|
-
status: SettledStatus;
|
|
1281
|
-
evidence?: string;
|
|
1282
|
-
}
|