explorbot 0.3.4 → 0.4.0
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 +18 -13
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +3 -1
- package/boat/prima/src/cli.ts +21 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +19 -13
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +3 -1
- package/dist/boat/prima/src/cli.js +19 -8
- package/dist/boat/prima/src/envelope.js +24 -6
- package/dist/boat/prima/src/prima.js +23 -11
- package/dist/package.json +2 -2
- package/dist/src/action-result.d.ts +9 -1
- package/dist/src/action-result.js +57 -18
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +87 -12
- package/dist/src/ai/driller.d.ts +0 -1
- package/dist/src/ai/driller.js +8 -20
- package/dist/src/ai/fisherman-tools.d.ts +9 -0
- package/dist/src/ai/fisherman-tools.js +52 -6
- package/dist/src/ai/fisherman.d.ts +4 -2
- package/dist/src/ai/fisherman.js +48 -27
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +15 -1
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.js +47 -4
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.js +3 -3
- package/dist/src/ai/tester.d.ts +3 -0
- package/dist/src/ai/tester.js +40 -3
- package/dist/src/ai/tools.d.ts +1 -0
- package/dist/src/ai/tools.js +13 -6
- package/dist/src/api/request-result.d.ts +2 -0
- package/dist/src/api/request-result.js +8 -2
- package/dist/src/api/request-store.d.ts +3 -2
- package/dist/src/api/request-store.js +66 -14
- package/dist/src/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +27 -2
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.js +1 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +23 -36
- package/dist/src/state-manager.d.ts +5 -1
- package/dist/src/state-manager.js +10 -7
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +27 -0
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +6 -42
- package/dist/src/utils/html-diff.d.ts +4 -0
- package/dist/src/utils/html-diff.js +62 -7
- package/dist/src/utils/html.d.ts +5 -15
- package/dist/src/utils/html.js +14 -85
- package/dist/src/utils/overlay.d.ts +56 -11
- package/dist/src/utils/overlay.js +191 -21
- package/dist/src/utils/request-map.d.ts +7 -0
- package/dist/src/utils/request-map.js +16 -0
- package/dist/src/utils/url-matcher.js +4 -2
- package/docs/reference/commands.md +8 -1
- package/docs/reference/websocket.md +1 -0
- package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
- package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
- package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
- package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
- package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
- package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
- package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
- package/docs/workflow/agentic-usage.md +1 -0
- package/docs/workflow/ci.md +1 -0
- package/package.json +2 -2
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- package/src/ai/driller.ts +7 -39
- package/src/ai/fisherman-tools.ts +56 -7
- package/src/ai/fisherman.ts +48 -28
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +1 -1
- package/src/ai/pilot.ts +11 -1
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +48 -4
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.ts +3 -3
- package/src/ai/tester.ts +40 -3
- package/src/ai/tools.ts +17 -9
- package/src/api/request-result.ts +10 -2
- package/src/api/request-store.ts +60 -13
- package/src/commands/explore-command.ts +25 -2
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +20 -36
- package/src/state-manager.ts +13 -7
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +7 -44
- package/src/utils/html-diff.ts +62 -7
- package/src/utils/html.ts +14 -91
- package/src/utils/overlay.ts +226 -23
- package/src/utils/request-map.ts +19 -0
- package/src/utils/url-matcher.ts +3 -2
package/bin/explorbot-cli.ts
CHANGED
|
@@ -146,6 +146,7 @@ addCommonOptions(
|
|
|
146
146
|
.command('explore <path>')
|
|
147
147
|
.description('Explore a page autonomously and run invented scenarios')
|
|
148
148
|
.option('--max-tests <count>', 'Maximum number of tests to run')
|
|
149
|
+
.option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run; wraps up before the limit is hit')
|
|
149
150
|
.option('--focus <feature>', 'Focus area for exploration')
|
|
150
151
|
.option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
|
|
151
152
|
.option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')
|
|
@@ -157,6 +158,8 @@ addCommonOptions(
|
|
|
157
158
|
const { ExploreCommand } = await import('../src/commands/explore-command.js');
|
|
158
159
|
const cmd = new ExploreCommand(explorBot);
|
|
159
160
|
if (options.maxTests) cmd.maxTests = Number.parseInt(options.maxTests, 10);
|
|
161
|
+
if (options.maxDuration) cmd.maxDurationMinutes = Number.parseInt(options.maxDuration, 10);
|
|
162
|
+
else if (process.env.EXPLORBOT_MAX_DURATION) cmd.maxDurationMinutes = Number.parseInt(process.env.EXPLORBOT_MAX_DURATION, 10);
|
|
160
163
|
if (options.dryRun) cmd.dryRun = true;
|
|
161
164
|
const execArgs: string[] = [];
|
|
162
165
|
if (options.focus) execArgs.push('--focus', `"${options.focus}"`);
|
|
@@ -257,14 +260,13 @@ addCommonOptions(program.command('plan <path>').description('Generate test plan
|
|
|
257
260
|
|
|
258
261
|
addCommonOptions(program.command('plan:load <planfile> [index]').description('Load a plan file and display its tests. Pass index to see test details.')).action(async (planfile: string, index: string | undefined) => {
|
|
259
262
|
try {
|
|
260
|
-
const
|
|
261
|
-
if (!
|
|
262
|
-
console.error(`Plan file not found: ${
|
|
263
|
+
const plan = Plan.loadFromFile(planfile);
|
|
264
|
+
if (!plan?.filePath) {
|
|
265
|
+
console.error(`Plan file not found: ${planfile}`);
|
|
263
266
|
process.exit(1);
|
|
264
267
|
}
|
|
265
268
|
|
|
266
|
-
const
|
|
267
|
-
const planFile = path.basename(resolvedPath);
|
|
269
|
+
const planFile = path.basename(plan.filePath);
|
|
268
270
|
|
|
269
271
|
if (index) {
|
|
270
272
|
const idx = Number.parseInt(index, 10);
|
|
@@ -276,7 +278,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
|
|
|
276
278
|
const lines: string[] = [];
|
|
277
279
|
lines.push(`## #${idx} ${test.scenario}\n`);
|
|
278
280
|
lines.push(`**Priority:** ${test.priority}`);
|
|
279
|
-
const planUrl = plan.
|
|
281
|
+
const planUrl = plan.startUrl;
|
|
280
282
|
if (planUrl) lines.push(`**Plan URL:** ${planUrl}`);
|
|
281
283
|
if (test.startUrl && test.startUrl !== planUrl) lines.push(`**Test URL:** ${test.startUrl}`);
|
|
282
284
|
if (test.plannedSteps.length) {
|
|
@@ -293,7 +295,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
|
|
|
293
295
|
return;
|
|
294
296
|
}
|
|
295
297
|
|
|
296
|
-
const planUrl = plan.
|
|
298
|
+
const planUrl = plan.startUrl;
|
|
297
299
|
const lines: string[] = [`**${plan.title}** (${plan.tests.length} tests)\n`];
|
|
298
300
|
if (planUrl) {
|
|
299
301
|
lines.push(`URL: ${planUrl}\n`);
|
|
@@ -325,9 +327,6 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
|
|
|
325
327
|
addCommonOptions(program.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1,3, 1-5, *, all').option('--grep <pattern>', 'Run tests matching pattern').option('--from-plan <file>', 'Load plan file when the first argument is a test index')).action(
|
|
326
328
|
async (planfile, index, options) => {
|
|
327
329
|
try {
|
|
328
|
-
const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
|
|
329
|
-
await explorBot.start();
|
|
330
|
-
|
|
331
330
|
let planfileArg = planfile;
|
|
332
331
|
let indexArg = index;
|
|
333
332
|
if (options.fromPlan) {
|
|
@@ -335,11 +334,16 @@ addCommonOptions(program.command('test <planfile> [index]').description('Execute
|
|
|
335
334
|
indexArg = planfile;
|
|
336
335
|
}
|
|
337
336
|
|
|
337
|
+
const planTarget = Plan.loadFromFile(planfileArg)?.startUrl;
|
|
338
|
+
|
|
339
|
+
const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options));
|
|
340
|
+
await explorBot.start();
|
|
341
|
+
|
|
338
342
|
const plan = explorBot.loadPlan(planfileArg);
|
|
339
343
|
const pending = plan.getPendingTests();
|
|
340
344
|
log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
|
|
341
345
|
|
|
342
|
-
const startUrl = plan.
|
|
346
|
+
const startUrl = plan.startUrl;
|
|
343
347
|
if (!startUrl) {
|
|
344
348
|
throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
|
|
345
349
|
}
|
|
@@ -347,7 +351,7 @@ addCommonOptions(program.command('test <planfile> [index]').description('Execute
|
|
|
347
351
|
log(`Navigating to ${startUrl}`);
|
|
348
352
|
await explorBot.visit(startUrl);
|
|
349
353
|
|
|
350
|
-
let args = '';
|
|
354
|
+
let args = '*';
|
|
351
355
|
if (indexArg) args = indexArg;
|
|
352
356
|
else if (options.grep) args = options.grep;
|
|
353
357
|
|
|
@@ -437,10 +441,11 @@ addCommonOptions(
|
|
|
437
441
|
.option('--shallow', 'Breadth-first: pick globally least-visited page')
|
|
438
442
|
.option('--scope <prefix>', 'Restrict navigation to URL prefix')
|
|
439
443
|
.option('--max-tests <count>', 'Maximum number of tests to run')
|
|
444
|
+
.option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run')
|
|
440
445
|
).action(async (startUrl, options) => {
|
|
441
446
|
const explorBot = new ExplorBot(buildExplorBotOptions(startUrl || '/', options));
|
|
442
447
|
await explorBot.start();
|
|
443
|
-
const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`].filter(Boolean).join(' ');
|
|
448
|
+
const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`, options.maxDuration && `--max-duration ${options.maxDuration}`].filter(Boolean).join(' ');
|
|
444
449
|
const { FreesailCommand } = await import('../src/commands/freesail-command.js');
|
|
445
450
|
const cmd = new FreesailCommand(explorBot);
|
|
446
451
|
await cmd.execute(args);
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import { ConfigCommand } from '../../../src/commands/config-command.ts';
|
|
5
|
+
import { remote } from '../../../src/remote.ts';
|
|
5
6
|
import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from '../../../src/utils/logger.ts';
|
|
6
7
|
import { DocBot, type DocbotOptions } from './docbot.ts';
|
|
7
8
|
|
|
@@ -59,9 +60,11 @@ export function createDocsCommands(name = 'docs'): Command {
|
|
|
59
60
|
console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);
|
|
60
61
|
|
|
61
62
|
await bot.stop();
|
|
63
|
+
await remote.close(0);
|
|
62
64
|
process.exit(0);
|
|
63
65
|
} catch (error) {
|
|
64
66
|
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
|
|
67
|
+
await remote.close(1);
|
|
65
68
|
process.exit(1);
|
|
66
69
|
}
|
|
67
70
|
});
|
|
@@ -465,7 +465,9 @@ class DocBot {
|
|
|
465
465
|
private saveIndex(startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): { indexPath: string; diagramPath: string } {
|
|
466
466
|
const outputDir = this.configParser.getOutputDir();
|
|
467
467
|
const indexPath = path.join(outputDir, 'index.md');
|
|
468
|
-
|
|
468
|
+
const index = renderSpecIndex(outputDir, startPath, pages, skipped, maxPages);
|
|
469
|
+
writeFileSync(indexPath, index, 'utf8');
|
|
470
|
+
tag('data').log('docs', { path: indexPath, content: index });
|
|
469
471
|
const diagramPath = path.join(outputDir, 'state-diagram.mmd');
|
|
470
472
|
writeFileSync(diagramPath, renderMermaidBody(outputDir, pages), 'utf8');
|
|
471
473
|
return { indexPath, diagramPath };
|
package/boat/prima/src/cli.ts
CHANGED
|
@@ -31,9 +31,9 @@ const checkHelp = dedent`
|
|
|
31
31
|
as false.
|
|
32
32
|
Outcomes are settled against a screenshot of the whole page: what a user can see is
|
|
33
33
|
the proof, and the run log only says what was done. CONTRADICTION means the two
|
|
34
|
-
disagree - reported with both sides rather than settled one way,
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
disagree - reported with both sides rather than settled one way, so read the html,
|
|
35
|
+
aria and screenshot named under ### Artifacts and judge it yourself. Not finding
|
|
36
|
+
something in the picture is not enough on its own; that is "not verified".
|
|
37
37
|
ok: follows those outcomes - false when one FAILED or CONTRADICTED, or when the run
|
|
38
38
|
could not complete, which is reported as such rather than as an app failure.
|
|
39
39
|
Page problems seen on the way appear under ### Answer, not as step failures.
|
|
@@ -53,6 +53,13 @@ const verifyHelp = dedent`
|
|
|
53
53
|
could not be expressed, which is not the same as false.
|
|
54
54
|
`;
|
|
55
55
|
|
|
56
|
+
const statusHelp = dedent`
|
|
57
|
+
Reads the files a command recorded, so it needs no browser and outlives the session.
|
|
58
|
+
The hash is looked up across every recorded site. ### Artifacts names every file kept
|
|
59
|
+
under it: the aria tree, the html, the screenshot and network log when they were
|
|
60
|
+
captured, and the per-step captures of a do run.
|
|
61
|
+
`;
|
|
62
|
+
|
|
56
63
|
const reportHelp = dedent`
|
|
57
64
|
Commands are logged as they run, so the report needs no browser and outlives the session.
|
|
58
65
|
The most recent session is reported unless --pw-session names another.
|
|
@@ -122,7 +129,7 @@ function primaFor(options: any): Prima {
|
|
|
122
129
|
return new Prima(buildOptions(options));
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData
|
|
132
|
+
async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>): Promise<void> {
|
|
126
133
|
setQuietMode(!isVerboseMode());
|
|
127
134
|
trackActivityLine();
|
|
128
135
|
const prima = primaFor(options);
|
|
@@ -136,7 +143,7 @@ async function runPrima(options: any, command: string, run: (prima: Prima) => Pr
|
|
|
136
143
|
envelope = await prima.toolFailureEnvelope(command, error);
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
|
|
146
|
+
prima.record(envelope, Date.now() - startedAt);
|
|
140
147
|
clearActivityLine();
|
|
141
148
|
console.log(renderEnvelope(envelope));
|
|
142
149
|
await prima.stop().catch(() => {});
|
|
@@ -211,9 +218,15 @@ export function createPrimaCommands(name = 'prima'): Command {
|
|
|
211
218
|
process.exit(0);
|
|
212
219
|
});
|
|
213
220
|
|
|
214
|
-
addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
|
|
215
|
-
|
|
216
|
-
|
|
221
|
+
addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
|
|
222
|
+
.addHelpText('after', `\n${statusHelp}`)
|
|
223
|
+
.action(async (hash, options) => {
|
|
224
|
+
setQuietMode(!isVerboseMode());
|
|
225
|
+
const prima = primaFor(options);
|
|
226
|
+
const envelope = await prima.status(hash).catch((error: unknown) => prima.toolFailureEnvelope(`status ${hash}`, error));
|
|
227
|
+
console.log(renderEnvelope(envelope));
|
|
228
|
+
process.exit(envelope.ok ? 0 : 1);
|
|
229
|
+
});
|
|
217
230
|
|
|
218
231
|
addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report'))
|
|
219
232
|
.addHelpText('after', `\n${reportHelp}`)
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
export const STATUS_FILE = 'status.json';
|
|
5
|
+
|
|
6
|
+
const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' };
|
|
7
|
+
|
|
4
8
|
const EXPECTATION_LABELS = {
|
|
5
9
|
passed: 'PASSED ',
|
|
6
10
|
failed: 'FAILED ',
|
|
@@ -33,7 +37,7 @@ export interface EnvelopeData {
|
|
|
33
37
|
failure?: { error: string; compactAria?: string };
|
|
34
38
|
instance: InstanceInfo;
|
|
35
39
|
status?: string;
|
|
36
|
-
artifacts?:
|
|
40
|
+
artifacts?: ArtifactPaths;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export function renderEnvelope(data: EnvelopeData): string {
|
|
@@ -41,27 +45,40 @@ export function renderEnvelope(data: EnvelopeData): string {
|
|
|
41
45
|
return sections.filter((section) => section).join('\n\n');
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; screenshot?: Buffer; requests: unknown[] }):
|
|
48
|
+
export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; screenshot?: Buffer; requests: unknown[] }): ArtifactPaths {
|
|
45
49
|
mkdirSync(dir, { recursive: true });
|
|
46
|
-
const paths:
|
|
47
|
-
aria: path.resolve(dir,
|
|
48
|
-
html: path.resolve(dir,
|
|
50
|
+
const paths: ArtifactPaths = {
|
|
51
|
+
aria: path.resolve(dir, ARTIFACT_FILES.aria),
|
|
52
|
+
html: path.resolve(dir, ARTIFACT_FILES.html),
|
|
49
53
|
};
|
|
50
54
|
writeFileSync(paths.aria, snapshot.aria ?? '', 'utf-8');
|
|
51
55
|
writeFileSync(paths.html, snapshot.html ?? '', 'utf-8');
|
|
52
56
|
|
|
53
57
|
if (snapshot.screenshot) {
|
|
54
|
-
paths.screenshot = path.resolve(dir,
|
|
58
|
+
paths.screenshot = path.resolve(dir, ARTIFACT_FILES.screenshot);
|
|
55
59
|
writeFileSync(paths.screenshot, snapshot.screenshot);
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
if (!snapshot.requests.length) return paths;
|
|
59
63
|
|
|
60
|
-
paths.network = path.resolve(dir,
|
|
64
|
+
paths.network = path.resolve(dir, ARTIFACT_FILES.network);
|
|
61
65
|
writeFileSync(paths.network, snapshot.requests.map((request) => `${JSON.stringify(request)}\n`).join(''), 'utf-8');
|
|
62
66
|
return paths;
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
export function readArtifacts(dir: string): ArtifactPaths {
|
|
70
|
+
const paths: ArtifactPaths = { aria: path.resolve(dir, ARTIFACT_FILES.aria), html: path.resolve(dir, ARTIFACT_FILES.html) };
|
|
71
|
+
if (existsSync(path.resolve(dir, ARTIFACT_FILES.screenshot))) paths.screenshot = path.resolve(dir, ARTIFACT_FILES.screenshot);
|
|
72
|
+
if (existsSync(path.resolve(dir, ARTIFACT_FILES.network))) paths.network = path.resolve(dir, ARTIFACT_FILES.network);
|
|
73
|
+
|
|
74
|
+
const recorded = [...Object.values(ARTIFACT_FILES), STATUS_FILE];
|
|
75
|
+
const files = readdirSync(dir)
|
|
76
|
+
.filter((entry) => !recorded.includes(entry))
|
|
77
|
+
.sort();
|
|
78
|
+
if (files.length) paths.files = files;
|
|
79
|
+
return paths;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
function renderResult(data: EnvelopeData): string {
|
|
66
83
|
const lines = [`ok: ${data.ok}`, `command: ${data.command}`];
|
|
67
84
|
if (data.used?.length) lines.push(`used: ${data.used.join('; ')}`);
|
|
@@ -100,7 +117,7 @@ function renderSteps(data: EnvelopeData): string | null {
|
|
|
100
117
|
lines.push(`${index + 1}. ${mark} ${step.label}`);
|
|
101
118
|
for (const line of (step.proof || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
|
|
102
119
|
});
|
|
103
|
-
if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}`);
|
|
120
|
+
if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{aria.yaml,html,diff.yaml}`);
|
|
104
121
|
return section('Steps', lines.join('\n'));
|
|
105
122
|
}
|
|
106
123
|
|
|
@@ -178,6 +195,7 @@ export function renderArtifacts(data: EnvelopeData): string | null {
|
|
|
178
195
|
const lines = [`aria: ${data.artifacts.aria}`, `html: ${data.artifacts.html}`];
|
|
179
196
|
if (data.artifacts.screenshot) lines.push(`screenshot: ${data.artifacts.screenshot}`);
|
|
180
197
|
if (data.artifacts.network) lines.push(`network: ${data.artifacts.network}`);
|
|
198
|
+
if (data.artifacts.files?.length) lines.push(`also here: ${data.artifacts.files.join(', ')}`);
|
|
181
199
|
return section('Artifacts', lines.join('\n'));
|
|
182
200
|
}
|
|
183
201
|
|
|
@@ -189,3 +207,11 @@ function align(label: string, marker: string, width: number): string {
|
|
|
189
207
|
function section(title: string, body: string): string {
|
|
190
208
|
return `### ${title}\n${body}`;
|
|
191
209
|
}
|
|
210
|
+
|
|
211
|
+
export interface ArtifactPaths {
|
|
212
|
+
aria: string;
|
|
213
|
+
html: string;
|
|
214
|
+
screenshot?: string;
|
|
215
|
+
network?: string;
|
|
216
|
+
files?: string[];
|
|
217
|
+
}
|
package/boat/prima/src/prima.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
|
|
|
25
25
|
import { pluralize } from '../../../src/utils/logger.ts';
|
|
26
26
|
import { mdq } from '../../../src/utils/markdown-query.ts';
|
|
27
27
|
import { safeFilename } from '../../../src/utils/strings.ts';
|
|
28
|
-
import { type EnvelopeData, type InstanceInfo, writeArtifacts } from './envelope.ts';
|
|
28
|
+
import { type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts';
|
|
29
29
|
import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts';
|
|
30
30
|
import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
|
|
31
31
|
import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
|
|
@@ -376,7 +376,6 @@ export class Prima {
|
|
|
376
376
|
|
|
377
377
|
const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
|
|
378
378
|
if (problems.length) envelope.failure = { error: problems.join('\n') };
|
|
379
|
-
if (contradicted.length) envelope.artifacts = this.artifacts;
|
|
380
379
|
|
|
381
380
|
if (!test.hasFinished || test.isSkipped) {
|
|
382
381
|
envelope.ok = false;
|
|
@@ -982,14 +981,17 @@ export class Prima {
|
|
|
982
981
|
}
|
|
983
982
|
|
|
984
983
|
private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
|
|
984
|
+
const changes = await this.pageChanges(result, previousState, used[0]);
|
|
985
|
+
const status = await this.saveStatus(result);
|
|
985
986
|
return {
|
|
986
987
|
ok: true,
|
|
987
988
|
command,
|
|
988
989
|
used,
|
|
989
990
|
page: this.pageBlock(result, previousState),
|
|
990
|
-
changes
|
|
991
|
+
changes,
|
|
991
992
|
instance: await this.instanceInfo(),
|
|
992
|
-
status
|
|
993
|
+
status,
|
|
994
|
+
artifacts: this.artifacts,
|
|
993
995
|
};
|
|
994
996
|
}
|
|
995
997
|
|
|
@@ -998,24 +1000,28 @@ export class Prima {
|
|
|
998
1000
|
const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) };
|
|
999
1001
|
if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
|
|
1000
1002
|
|
|
1003
|
+
const status = await this.saveStatus(result);
|
|
1001
1004
|
return {
|
|
1002
1005
|
ok: false,
|
|
1003
1006
|
command,
|
|
1004
1007
|
page: this.pageBlock(result, previousState),
|
|
1005
1008
|
failure,
|
|
1006
1009
|
instance: await this.instanceInfo(),
|
|
1007
|
-
status
|
|
1010
|
+
status,
|
|
1011
|
+
artifacts: this.artifacts,
|
|
1008
1012
|
};
|
|
1009
1013
|
}
|
|
1010
1014
|
|
|
1011
1015
|
private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial<EnvelopeData>): Promise<EnvelopeData> {
|
|
1016
|
+
const status = await this.saveStatus(result);
|
|
1012
1017
|
return {
|
|
1013
1018
|
ok: true,
|
|
1014
1019
|
command,
|
|
1015
1020
|
page: this.pageBlock(result, previousState),
|
|
1016
1021
|
...outcome,
|
|
1017
1022
|
instance: await this.instanceInfo(),
|
|
1018
|
-
status
|
|
1023
|
+
status,
|
|
1024
|
+
artifacts: this.artifacts,
|
|
1019
1025
|
};
|
|
1020
1026
|
}
|
|
1021
1027
|
|
|
@@ -1060,9 +1066,16 @@ export class Prima {
|
|
|
1060
1066
|
}
|
|
1061
1067
|
|
|
1062
1068
|
async status(hash: string): Promise<EnvelopeData> {
|
|
1069
|
+
if (!this.artifactsDir) {
|
|
1070
|
+
const sites = listSites();
|
|
1071
|
+
const site = sites.find((candidate) => existsSync(path.join(candidate.dir, 'output', 'prima', hash))) || sites[0];
|
|
1072
|
+
if (site && !this.configBaseUrl()) this.sessionUrl = site.url;
|
|
1073
|
+
await this.loadConfig();
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1063
1076
|
const dir = this.statusDir(hash);
|
|
1064
|
-
const statusFile = path.join(dir,
|
|
1065
|
-
if (!existsSync(statusFile)) return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${
|
|
1077
|
+
const statusFile = path.join(dir, STATUS_FILE);
|
|
1078
|
+
if (!existsSync(statusFile)) return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${dir}. Every envelope prints its own hash on the Instance line.`);
|
|
1066
1079
|
|
|
1067
1080
|
const saved = JSON.parse(readFileSync(statusFile, 'utf-8'));
|
|
1068
1081
|
return {
|
|
@@ -1070,14 +1083,14 @@ export class Prima {
|
|
|
1070
1083
|
command: `status ${hash}`,
|
|
1071
1084
|
page: saved.page,
|
|
1072
1085
|
instance: await this.instanceInfo(),
|
|
1073
|
-
artifacts:
|
|
1086
|
+
artifacts: readArtifacts(dir),
|
|
1074
1087
|
};
|
|
1075
1088
|
}
|
|
1076
1089
|
|
|
1077
1090
|
private async saveStatus(result: ActionResult): Promise<string> {
|
|
1078
1091
|
const hash = this.statusHash();
|
|
1079
1092
|
await this.writeSnapshot(result);
|
|
1080
|
-
writeFileSync(path.join(this.statusDir(hash),
|
|
1093
|
+
writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
|
|
1081
1094
|
return hash;
|
|
1082
1095
|
}
|
|
1083
1096
|
|
|
@@ -114,6 +114,7 @@ addCommonOptions(program
|
|
|
114
114
|
.command('explore <path>')
|
|
115
115
|
.description('Explore a page autonomously and run invented scenarios')
|
|
116
116
|
.option('--max-tests <count>', 'Maximum number of tests to run')
|
|
117
|
+
.option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run; wraps up before the limit is hit')
|
|
117
118
|
.option('--focus <feature>', 'Focus area for exploration')
|
|
118
119
|
.option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
|
|
119
120
|
.option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')).action(async (explorePath, options) => {
|
|
@@ -125,6 +126,10 @@ addCommonOptions(program
|
|
|
125
126
|
const cmd = new ExploreCommand(explorBot);
|
|
126
127
|
if (options.maxTests)
|
|
127
128
|
cmd.maxTests = Number.parseInt(options.maxTests, 10);
|
|
129
|
+
if (options.maxDuration)
|
|
130
|
+
cmd.maxDurationMinutes = Number.parseInt(options.maxDuration, 10);
|
|
131
|
+
else if (process.env.EXPLORBOT_MAX_DURATION)
|
|
132
|
+
cmd.maxDurationMinutes = Number.parseInt(process.env.EXPLORBOT_MAX_DURATION, 10);
|
|
128
133
|
if (options.dryRun)
|
|
129
134
|
cmd.dryRun = true;
|
|
130
135
|
const execArgs = [];
|
|
@@ -219,13 +224,12 @@ addCommonOptions(program.command('plan <path>').description('Generate test plan
|
|
|
219
224
|
});
|
|
220
225
|
addCommonOptions(program.command('plan:load <planfile> [index]').description('Load a plan file and display its tests. Pass index to see test details.')).action(async (planfile, index) => {
|
|
221
226
|
try {
|
|
222
|
-
const
|
|
223
|
-
if (!
|
|
224
|
-
console.error(`Plan file not found: ${
|
|
227
|
+
const plan = Plan.loadFromFile(planfile);
|
|
228
|
+
if (!plan?.filePath) {
|
|
229
|
+
console.error(`Plan file not found: ${planfile}`);
|
|
225
230
|
process.exit(1);
|
|
226
231
|
}
|
|
227
|
-
const
|
|
228
|
-
const planFile = path.basename(resolvedPath);
|
|
232
|
+
const planFile = path.basename(plan.filePath);
|
|
229
233
|
if (index) {
|
|
230
234
|
const idx = Number.parseInt(index, 10);
|
|
231
235
|
if (Number.isNaN(idx) || idx < 1 || idx > plan.tests.length) {
|
|
@@ -236,7 +240,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
|
|
|
236
240
|
const lines = [];
|
|
237
241
|
lines.push(`## #${idx} ${test.scenario}\n`);
|
|
238
242
|
lines.push(`**Priority:** ${test.priority}`);
|
|
239
|
-
const planUrl = plan.
|
|
243
|
+
const planUrl = plan.startUrl;
|
|
240
244
|
if (planUrl)
|
|
241
245
|
lines.push(`**Plan URL:** ${planUrl}`);
|
|
242
246
|
if (test.startUrl && test.startUrl !== planUrl)
|
|
@@ -256,7 +260,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
|
|
|
256
260
|
console.log(parseMarkdownToTerminal(lines.join('\n')));
|
|
257
261
|
return;
|
|
258
262
|
}
|
|
259
|
-
const planUrl = plan.
|
|
263
|
+
const planUrl = plan.startUrl;
|
|
260
264
|
const lines = [`**${plan.title}** (${plan.tests.length} tests)\n`];
|
|
261
265
|
if (planUrl) {
|
|
262
266
|
lines.push(`URL: ${planUrl}\n`);
|
|
@@ -284,24 +288,25 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
|
|
|
284
288
|
});
|
|
285
289
|
addCommonOptions(program.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1,3, 1-5, *, all').option('--grep <pattern>', 'Run tests matching pattern').option('--from-plan <file>', 'Load plan file when the first argument is a test index')).action(async (planfile, index, options) => {
|
|
286
290
|
try {
|
|
287
|
-
const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
|
|
288
|
-
await explorBot.start();
|
|
289
291
|
let planfileArg = planfile;
|
|
290
292
|
let indexArg = index;
|
|
291
293
|
if (options.fromPlan) {
|
|
292
294
|
planfileArg = options.fromPlan;
|
|
293
295
|
indexArg = planfile;
|
|
294
296
|
}
|
|
297
|
+
const planTarget = Plan.loadFromFile(planfileArg)?.startUrl;
|
|
298
|
+
const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options));
|
|
299
|
+
await explorBot.start();
|
|
295
300
|
const plan = explorBot.loadPlan(planfileArg);
|
|
296
301
|
const pending = plan.getPendingTests();
|
|
297
302
|
log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
|
|
298
|
-
const startUrl = plan.
|
|
303
|
+
const startUrl = plan.startUrl;
|
|
299
304
|
if (!startUrl) {
|
|
300
305
|
throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
|
|
301
306
|
}
|
|
302
307
|
log(`Navigating to ${startUrl}`);
|
|
303
308
|
await explorBot.visit(startUrl);
|
|
304
|
-
let args = '';
|
|
309
|
+
let args = '*';
|
|
305
310
|
if (indexArg)
|
|
306
311
|
args = indexArg;
|
|
307
312
|
else if (options.grep)
|
|
@@ -387,10 +392,11 @@ addCommonOptions(program
|
|
|
387
392
|
.option('--deep', 'Depth-first: prioritize newly discovered pages')
|
|
388
393
|
.option('--shallow', 'Breadth-first: pick globally least-visited page')
|
|
389
394
|
.option('--scope <prefix>', 'Restrict navigation to URL prefix')
|
|
390
|
-
.option('--max-tests <count>', 'Maximum number of tests to run')
|
|
395
|
+
.option('--max-tests <count>', 'Maximum number of tests to run')
|
|
396
|
+
.option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run')).action(async (startUrl, options) => {
|
|
391
397
|
const explorBot = new ExplorBot(buildExplorBotOptions(startUrl || '/', options));
|
|
392
398
|
await explorBot.start();
|
|
393
|
-
const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`].filter(Boolean).join(' ');
|
|
399
|
+
const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`, options.maxDuration && `--max-duration ${options.maxDuration}`].filter(Boolean).join(' ');
|
|
394
400
|
const { FreesailCommand } = await import('../src/commands/freesail-command.js');
|
|
395
401
|
const cmd = new FreesailCommand(explorBot);
|
|
396
402
|
await cmd.execute(args);
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import { ConfigCommand } from "../../../src/commands/config-command.js";
|
|
5
|
+
import { remote } from "../../../src/remote.js";
|
|
5
6
|
import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from "../../../src/utils/logger.js";
|
|
6
7
|
import { DocBot } from "./docbot.js";
|
|
7
8
|
function buildOptions(options) {
|
|
@@ -50,10 +51,12 @@ export function createDocsCommands(name = 'docs') {
|
|
|
50
51
|
console.log(`Pages dir: ${path.join(result.outputDir, 'pages')}`);
|
|
51
52
|
console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);
|
|
52
53
|
await bot.stop();
|
|
54
|
+
await remote.close(0);
|
|
53
55
|
process.exit(0);
|
|
54
56
|
}
|
|
55
57
|
catch (error) {
|
|
56
58
|
console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
|
|
59
|
+
await remote.close(1);
|
|
57
60
|
process.exit(1);
|
|
58
61
|
}
|
|
59
62
|
});
|
|
@@ -409,7 +409,9 @@ class DocBot {
|
|
|
409
409
|
saveIndex(startPath, pages, skipped, maxPages) {
|
|
410
410
|
const outputDir = this.configParser.getOutputDir();
|
|
411
411
|
const indexPath = path.join(outputDir, 'index.md');
|
|
412
|
-
|
|
412
|
+
const index = renderSpecIndex(outputDir, startPath, pages, skipped, maxPages);
|
|
413
|
+
writeFileSync(indexPath, index, 'utf8');
|
|
414
|
+
tag('data').log('docs', { path: indexPath, content: index });
|
|
413
415
|
const diagramPath = path.join(outputDir, 'state-diagram.mmd');
|
|
414
416
|
writeFileSync(diagramPath, renderMermaidBody(outputDir, pages), 'utf8');
|
|
415
417
|
return { indexPath, diagramPath };
|
|
@@ -29,9 +29,9 @@ const checkHelp = dedent `
|
|
|
29
29
|
as false.
|
|
30
30
|
Outcomes are settled against a screenshot of the whole page: what a user can see is
|
|
31
31
|
the proof, and the run log only says what was done. CONTRADICTION means the two
|
|
32
|
-
disagree - reported with both sides rather than settled one way,
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
disagree - reported with both sides rather than settled one way, so read the html,
|
|
33
|
+
aria and screenshot named under ### Artifacts and judge it yourself. Not finding
|
|
34
|
+
something in the picture is not enough on its own; that is "not verified".
|
|
35
35
|
ok: follows those outcomes - false when one FAILED or CONTRADICTED, or when the run
|
|
36
36
|
could not complete, which is reported as such rather than as an app failure.
|
|
37
37
|
Page problems seen on the way appear under ### Answer, not as step failures.
|
|
@@ -48,6 +48,12 @@ const verifyHelp = dedent `
|
|
|
48
48
|
and gives no overall verdict - read the lines and decide. "none ran" means the claim
|
|
49
49
|
could not be expressed, which is not the same as false.
|
|
50
50
|
`;
|
|
51
|
+
const statusHelp = dedent `
|
|
52
|
+
Reads the files a command recorded, so it needs no browser and outlives the session.
|
|
53
|
+
The hash is looked up across every recorded site. ### Artifacts names every file kept
|
|
54
|
+
under it: the aria tree, the html, the screenshot and network log when they were
|
|
55
|
+
captured, and the per-step captures of a do run.
|
|
56
|
+
`;
|
|
51
57
|
const reportHelp = dedent `
|
|
52
58
|
Commands are logged as they run, so the report needs no browser and outlives the session.
|
|
53
59
|
The most recent session is reported unless --pw-session names another.
|
|
@@ -114,7 +120,7 @@ function primaFor(options) {
|
|
|
114
120
|
process.env.EXPLORBOT_VISION_MODEL = options.visionModel;
|
|
115
121
|
return new Prima(buildOptions(options));
|
|
116
122
|
}
|
|
117
|
-
async function runPrima(options, command, run
|
|
123
|
+
async function runPrima(options, command, run) {
|
|
118
124
|
setQuietMode(!isVerboseMode());
|
|
119
125
|
trackActivityLine();
|
|
120
126
|
const prima = primaFor(options);
|
|
@@ -127,8 +133,7 @@ async function runPrima(options, command, run, record = true) {
|
|
|
127
133
|
catch (error) {
|
|
128
134
|
envelope = await prima.toolFailureEnvelope(command, error);
|
|
129
135
|
}
|
|
130
|
-
|
|
131
|
-
prima.record(envelope, Date.now() - startedAt);
|
|
136
|
+
prima.record(envelope, Date.now() - startedAt);
|
|
132
137
|
clearActivityLine();
|
|
133
138
|
console.log(renderEnvelope(envelope));
|
|
134
139
|
await prima.stop().catch(() => { });
|
|
@@ -191,8 +196,14 @@ export function createPrimaCommands(name = 'prima') {
|
|
|
191
196
|
await prima.stop().catch(() => { });
|
|
192
197
|
process.exit(0);
|
|
193
198
|
});
|
|
194
|
-
addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
|
|
195
|
-
|
|
199
|
+
addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
|
|
200
|
+
.addHelpText('after', `\n${statusHelp}`)
|
|
201
|
+
.action(async (hash, options) => {
|
|
202
|
+
setQuietMode(!isVerboseMode());
|
|
203
|
+
const prima = primaFor(options);
|
|
204
|
+
const envelope = await prima.status(hash).catch((error) => prima.toolFailureEnvelope(`status ${hash}`, error));
|
|
205
|
+
console.log(renderEnvelope(envelope));
|
|
206
|
+
process.exit(envelope.ok ? 0 : 1);
|
|
196
207
|
});
|
|
197
208
|
addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report'))
|
|
198
209
|
.addHelpText('after', `\n${reportHelp}`)
|