explorbot 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/boat/api-tester/src/apibot.ts +8 -13
- package/boat/api-tester/src/cli.ts +7 -3
- package/boat/api-tester/src/config.ts +45 -9
- package/boat/prima/src/cli.ts +33 -99
- package/boat/prima/src/envelope.ts +3 -1
- package/boat/prima/src/help.ts +72 -0
- package/boat/prima/src/prima.ts +41 -46
- package/dist/boat/api-tester/src/apibot.js +7 -6
- package/dist/boat/api-tester/src/cli.js +9 -3
- package/dist/boat/api-tester/src/config.js +32 -6
- package/dist/boat/prima/src/cli.js +30 -86
- package/dist/boat/prima/src/envelope.js +2 -1
- package/dist/boat/prima/src/help.js +63 -0
- package/dist/boat/prima/src/prima.js +39 -44
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +3 -0
- package/dist/src/action-result.js +5 -0
- package/dist/src/action.js +12 -1
- package/dist/src/ai/fisherman/tools.js +7 -1
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/pilot.d.ts +0 -1
- package/dist/src/ai/pilot.js +8 -24
- package/dist/src/ai/planner.d.ts +4 -0
- package/dist/src/ai/planner.js +28 -0
- package/dist/src/ai/provider.js +3 -1
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +4 -1
- package/dist/src/ai/researcher/sections.d.ts +1 -1
- package/dist/src/ai/researcher/sections.js +2 -1
- package/dist/src/ai/researcher.js +25 -11
- package/dist/src/ai/rules.js +8 -7
- package/dist/src/ai/scout/tools.d.ts +17 -0
- package/dist/src/ai/scout/tools.js +130 -0
- package/dist/src/ai/scout.d.ts +21 -0
- package/dist/src/ai/scout.js +150 -0
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +27 -33
- package/dist/src/ai/tools.js +61 -30
- package/dist/src/application-spec.d.ts +3 -0
- package/dist/src/application-spec.js +21 -5
- package/dist/src/commands/config-command.js +6 -2
- package/dist/src/config.d.ts +9 -1
- package/dist/src/config.js +14 -0
- package/dist/src/explorbot.d.ts +3 -0
- package/dist/src/explorbot.js +33 -0
- package/dist/src/knowledge-tracker.d.ts +1 -0
- package/dist/src/knowledge-tracker.js +3 -0
- package/dist/src/state-manager.js +5 -1
- package/dist/src/utils/aria-ref.d.ts +16 -0
- package/dist/src/utils/aria-ref.js +47 -0
- package/dist/src/utils/aria.js +3 -3
- package/dist/src/utils/web-annotate.js +3 -15
- package/dist/src/utils/web-element.d.ts +0 -2
- package/dist/src/utils/web-element.js +0 -8
- package/docs/api-testing/basics.md +12 -4
- package/docs/reference/commands.md +1 -0
- package/docs/reference/configuration.md +28 -1
- package/docs/web-testing/agents.md +9 -1
- package/docs/web-testing/planner.md +5 -0
- package/docs/workflow/agentic-usage.md +3 -1
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +7 -0
- package/src/action.ts +14 -2
- package/src/ai/fisherman/tools.ts +8 -1
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/pilot.ts +8 -25
- package/src/ai/planner.ts +33 -0
- package/src/ai/provider.ts +2 -1
- package/src/ai/researcher/deep-analysis.ts +4 -2
- package/src/ai/researcher/sections.ts +2 -2
- package/src/ai/researcher.ts +28 -11
- package/src/ai/rules.ts +8 -7
- package/src/ai/scout/tools.ts +150 -0
- package/src/ai/scout.ts +173 -0
- package/src/ai/tester.ts +25 -30
- package/src/ai/tools.ts +75 -36
- package/src/application-spec.ts +22 -4
- package/src/commands/config-command.ts +4 -1
- package/src/config.ts +23 -0
- package/src/explorbot.ts +36 -0
- package/src/knowledge-tracker.ts +4 -0
- package/src/state-manager.ts +6 -1
- package/src/utils/aria-ref.ts +61 -0
- package/src/utils/aria.ts +3 -3
- package/src/utils/web-annotate.ts +3 -15
- package/src/utils/web-element.ts +0 -9
package/boat/prima/src/prima.ts
CHANGED
|
@@ -20,20 +20,20 @@ import { Reporter } from '../../../src/reporter.ts';
|
|
|
20
20
|
import type { WebPageState } from '../../../src/state-manager.ts';
|
|
21
21
|
import { Stats } from '../../../src/stats.ts';
|
|
22
22
|
import { Task, Test, TestResult } from '../../../src/test-plan.ts';
|
|
23
|
+
import { ariaRefSnapshot } from '../../../src/utils/aria-ref.ts';
|
|
23
24
|
import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
|
|
24
25
|
import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
|
|
25
26
|
import { pluralize } from '../../../src/utils/logger.ts';
|
|
26
27
|
import { mdq } from '../../../src/utils/markdown-query.ts';
|
|
27
28
|
import { safeFilename } from '../../../src/utils/strings.ts';
|
|
28
|
-
import { type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts';
|
|
29
|
+
import { type ArtifactPaths, type EnvelopeData, type InstanceInfo, STATUS_FILE, STEP_FILES, readArtifacts, writeArtifacts } from './envelope.ts';
|
|
29
30
|
import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts';
|
|
30
31
|
import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
|
|
31
32
|
import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
|
|
32
33
|
|
|
33
|
-
const
|
|
34
|
+
const WITHHELD_TOOLS = ['learnExperience', 'askUser', 'research'];
|
|
34
35
|
const ITERATIONS_PER_INSTRUCTION = 2;
|
|
35
36
|
const MAX_INSTRUCTION_ITERATIONS = 24;
|
|
36
|
-
const DEFAULT_RESEARCH_AFTER_VISITS = 3;
|
|
37
37
|
const CONTEXT_HTML_CAP = 6000;
|
|
38
38
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
39
39
|
const AI_AGENT_NAME = 'prima';
|
|
@@ -74,7 +74,6 @@ export class Prima {
|
|
|
74
74
|
private server: { close: () => Promise<void> } | null = null;
|
|
75
75
|
private attached: string | null = null;
|
|
76
76
|
private session: SessionRun | null = null;
|
|
77
|
-
private artifacts?: EnvelopeData['artifacts'];
|
|
78
77
|
|
|
79
78
|
constructor(options: PrimaOptions = {}) {
|
|
80
79
|
this.options = options;
|
|
@@ -108,6 +107,7 @@ export class Prima {
|
|
|
108
107
|
const config = await this.loadConfig();
|
|
109
108
|
await this.resolveBrowser(config, discovery);
|
|
110
109
|
await this.bot.start();
|
|
110
|
+
this.bot.agentResearcher().disable();
|
|
111
111
|
|
|
112
112
|
if (!this.options.url) return;
|
|
113
113
|
if (this.bot.getCurrentState()) return;
|
|
@@ -325,11 +325,15 @@ export class Prima {
|
|
|
325
325
|
return null;
|
|
326
326
|
});
|
|
327
327
|
|
|
328
|
-
|
|
329
|
-
|
|
328
|
+
const stillOpen = ledger.filter((entry) => entry.status === 'open').length;
|
|
330
329
|
for (const execution of invoked?.toolExecutions || []) {
|
|
331
330
|
this.applyLedgerReport(execution, ledger, trace);
|
|
332
331
|
}
|
|
332
|
+
|
|
333
|
+
let unsettled = '';
|
|
334
|
+
if (settleError) unsettled = browserErrorMessage(settleError);
|
|
335
|
+
if (invoked && ledger.filter((entry) => entry.status === 'open').length === stillOpen) unsettled = 'the model was asked to report every remaining instruction and reported none';
|
|
336
|
+
if (unsettled) trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: unsettled });
|
|
333
337
|
}
|
|
334
338
|
|
|
335
339
|
private ledgerProgress(ledger: LedgerEntry[]): string {
|
|
@@ -422,9 +426,12 @@ export class Prima {
|
|
|
422
426
|
const guard = await this.aiGuard(command);
|
|
423
427
|
if (guard) return guard;
|
|
424
428
|
|
|
429
|
+
const researcher = this.bot.agentResearcher();
|
|
430
|
+
researcher.enable();
|
|
431
|
+
|
|
425
432
|
const previousState = this.bot.stateManager().getCurrentState();
|
|
426
433
|
const result = await this.capturedResult(previousState);
|
|
427
|
-
const uiMap = await
|
|
434
|
+
const uiMap = await researcher.research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
|
|
428
435
|
return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
|
|
429
436
|
}
|
|
430
437
|
|
|
@@ -820,7 +827,7 @@ export class Prima {
|
|
|
820
827
|
if (!researcher || !navigator) return {};
|
|
821
828
|
|
|
822
829
|
const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
|
|
823
|
-
for (const name of
|
|
830
|
+
for (const name of WITHHELD_TOOLS) delete tools[name];
|
|
824
831
|
return tools;
|
|
825
832
|
}
|
|
826
833
|
|
|
@@ -878,14 +885,15 @@ export class Prima {
|
|
|
878
885
|
|
|
879
886
|
private async pageContext(result: ActionResult): Promise<string> {
|
|
880
887
|
const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
|
|
881
|
-
const map =
|
|
888
|
+
const map = getPreviousResearch(result.baseHash);
|
|
889
|
+
let uiMap = '';
|
|
882
890
|
if (map) {
|
|
883
|
-
|
|
884
|
-
<page_ui_map
|
|
891
|
+
uiMap = dedent`
|
|
892
|
+
<page_ui_map>
|
|
893
|
+
A map of this page recorded by an earlier research run. It names parts the accessibility
|
|
894
|
+
tree does not, and can be out of date — the tree is what the page holds now.
|
|
885
895
|
${map}
|
|
886
896
|
</page_ui_map>
|
|
887
|
-
|
|
888
|
-
${experience}
|
|
889
897
|
`;
|
|
890
898
|
}
|
|
891
899
|
|
|
@@ -894,21 +902,12 @@ export class Prima {
|
|
|
894
902
|
${compactAriaSnapshot(await this.refAriaSnapshot(result), true, (value) => this.offloadValue(value))}
|
|
895
903
|
</page>
|
|
896
904
|
|
|
905
|
+
${uiMap}
|
|
906
|
+
|
|
897
907
|
${experience}
|
|
898
908
|
`;
|
|
899
909
|
}
|
|
900
910
|
|
|
901
|
-
private researchMap(result: ActionResult): string {
|
|
902
|
-
if (this.bot.stateManager().getVisitCount(result.url) < this.researchAfterVisits()) return '';
|
|
903
|
-
return getPreviousResearch(result.getStateHash());
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
private researchAfterVisits(): number {
|
|
907
|
-
const configured = this.bot.getConfig?.()?.ai?.agents?.prima?.researchAfterVisits;
|
|
908
|
-
if (typeof configured === 'number') return configured;
|
|
909
|
-
return DEFAULT_RESEARCH_AFTER_VISITS;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
911
|
private offloadValue(value: string): string | undefined {
|
|
913
912
|
const dir = this.statusDir();
|
|
914
913
|
const name = `value-${createHash('sha1').update(value).digest('hex').slice(0, 8)}.txt`;
|
|
@@ -922,7 +921,7 @@ export class Prima {
|
|
|
922
921
|
}
|
|
923
922
|
|
|
924
923
|
private async refAriaSnapshot(result: ActionResult): Promise<string | null> {
|
|
925
|
-
const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(
|
|
924
|
+
const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(ariaRefSnapshot)).catch(() => null);
|
|
926
925
|
return snapshot || result.ariaSnapshot;
|
|
927
926
|
}
|
|
928
927
|
|
|
@@ -982,16 +981,13 @@ export class Prima {
|
|
|
982
981
|
|
|
983
982
|
private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
|
|
984
983
|
const changes = await this.pageChanges(result, previousState, used[0]);
|
|
985
|
-
const status = await this.saveStatus(result);
|
|
986
984
|
return {
|
|
987
985
|
ok: true,
|
|
988
986
|
command,
|
|
989
987
|
used,
|
|
990
988
|
page: this.pageBlock(result, previousState),
|
|
991
989
|
changes,
|
|
992
|
-
|
|
993
|
-
status,
|
|
994
|
-
artifacts: this.artifacts,
|
|
990
|
+
...(await this.envelopeTail(result)),
|
|
995
991
|
};
|
|
996
992
|
}
|
|
997
993
|
|
|
@@ -1000,31 +996,30 @@ export class Prima {
|
|
|
1000
996
|
const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) };
|
|
1001
997
|
if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
|
|
1002
998
|
|
|
1003
|
-
const status = await this.saveStatus(result);
|
|
1004
999
|
return {
|
|
1005
1000
|
ok: false,
|
|
1006
1001
|
command,
|
|
1007
1002
|
page: this.pageBlock(result, previousState),
|
|
1008
1003
|
failure,
|
|
1009
|
-
|
|
1010
|
-
status,
|
|
1011
|
-
artifacts: this.artifacts,
|
|
1004
|
+
...(await this.envelopeTail(result)),
|
|
1012
1005
|
};
|
|
1013
1006
|
}
|
|
1014
1007
|
|
|
1015
1008
|
private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial<EnvelopeData>): Promise<EnvelopeData> {
|
|
1016
|
-
const status = await this.saveStatus(result);
|
|
1017
1009
|
return {
|
|
1018
1010
|
ok: true,
|
|
1019
1011
|
command,
|
|
1020
1012
|
page: this.pageBlock(result, previousState),
|
|
1021
1013
|
...outcome,
|
|
1022
|
-
|
|
1023
|
-
status,
|
|
1024
|
-
artifacts: this.artifacts,
|
|
1014
|
+
...(await this.envelopeTail(result)),
|
|
1025
1015
|
};
|
|
1026
1016
|
}
|
|
1027
1017
|
|
|
1018
|
+
private async envelopeTail(result: ActionResult): Promise<Pick<EnvelopeData, 'instance' | 'status' | 'artifacts'>> {
|
|
1019
|
+
const { hash, artifacts } = await this.saveStatus(result);
|
|
1020
|
+
return { instance: await this.instanceInfo(), status: hash, artifacts };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1028
1023
|
private async capturedResult(previousState: WebPageState | null, opts: { screenshot?: boolean } = {}): Promise<ActionResult> {
|
|
1029
1024
|
const captured = await this.bot
|
|
1030
1025
|
.getExplorer()
|
|
@@ -1086,11 +1081,11 @@ export class Prima {
|
|
|
1086
1081
|
};
|
|
1087
1082
|
}
|
|
1088
1083
|
|
|
1089
|
-
private async saveStatus(result: ActionResult): Promise<string> {
|
|
1084
|
+
private async saveStatus(result: ActionResult): Promise<{ hash: string; artifacts: ArtifactPaths }> {
|
|
1090
1085
|
const hash = this.statusHash();
|
|
1091
|
-
await this.writeSnapshot(result);
|
|
1086
|
+
const artifacts = await this.writeSnapshot(result);
|
|
1092
1087
|
writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
|
|
1093
|
-
return hash;
|
|
1088
|
+
return { hash, artifacts };
|
|
1094
1089
|
}
|
|
1095
1090
|
|
|
1096
1091
|
private async writeStepFiles(index: number, label: string, diff: string): Promise<void> {
|
|
@@ -1102,17 +1097,17 @@ export class Prima {
|
|
|
1102
1097
|
const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`);
|
|
1103
1098
|
const result = ActionResult.fromState(state);
|
|
1104
1099
|
|
|
1105
|
-
writeFileSync(`${stem}.aria
|
|
1106
|
-
writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8');
|
|
1107
|
-
if (diff) writeFileSync(`${stem}.diff
|
|
1100
|
+
writeFileSync(`${stem}.${STEP_FILES.aria}`, result.ariaSnapshot ?? '', 'utf-8');
|
|
1101
|
+
writeFileSync(`${stem}.${STEP_FILES.html}`, await result.combinedHtml(), 'utf-8');
|
|
1102
|
+
if (diff) writeFileSync(`${stem}.${STEP_FILES.diff}`, diff, 'utf-8');
|
|
1108
1103
|
}
|
|
1109
1104
|
|
|
1110
|
-
private async writeSnapshot(result: ActionResult): Promise<
|
|
1111
|
-
|
|
1105
|
+
private async writeSnapshot(result: ActionResult): Promise<ArtifactPaths> {
|
|
1106
|
+
return writeArtifacts(this.statusDir(), {
|
|
1112
1107
|
aria: result.ariaSnapshot,
|
|
1113
1108
|
html: await result.combinedHtml(),
|
|
1114
1109
|
screenshot: result.screenshot,
|
|
1115
|
-
requests: this.bot.requestStore().
|
|
1110
|
+
requests: this.bot.requestStore().getCapturedRequests(),
|
|
1116
1111
|
});
|
|
1117
1112
|
}
|
|
1118
1113
|
|
|
@@ -120,15 +120,14 @@ export class ApiBot {
|
|
|
120
120
|
this.currentPlan = Plan.fromMarkdown(planPath);
|
|
121
121
|
return this.currentPlan;
|
|
122
122
|
}
|
|
123
|
-
savePlan(
|
|
123
|
+
savePlan(suffix) {
|
|
124
124
|
if (!this.currentPlan)
|
|
125
125
|
return null;
|
|
126
126
|
const plansDir = this.configParser.getPlansDir();
|
|
127
127
|
if (!existsSync(plansDir)) {
|
|
128
128
|
mkdirSync(plansDir, { recursive: true });
|
|
129
129
|
}
|
|
130
|
-
const
|
|
131
|
-
const planPath = path.join(plansDir, planFilename);
|
|
130
|
+
const planPath = path.join(plansDir, this.generatePlanFilename(suffix));
|
|
132
131
|
this.currentPlan.saveToMarkdown(planPath);
|
|
133
132
|
return planPath;
|
|
134
133
|
}
|
|
@@ -162,9 +161,11 @@ export class ApiBot {
|
|
|
162
161
|
return undefined;
|
|
163
162
|
}
|
|
164
163
|
}
|
|
165
|
-
generatePlanFilename() {
|
|
164
|
+
generatePlanFilename(suffix) {
|
|
166
165
|
const endpoint = this.currentPlan?.url || '/';
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
let name = endpoint.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '_') || 'root';
|
|
167
|
+
if (suffix)
|
|
168
|
+
name = `${name}_${suffix}`;
|
|
169
|
+
return `${name.slice(0, 200)}.md`;
|
|
169
170
|
}
|
|
170
171
|
}
|
|
@@ -15,6 +15,7 @@ function buildOptions(options) {
|
|
|
15
15
|
path: options.path,
|
|
16
16
|
baseEndpoint: options.endpoint,
|
|
17
17
|
spec: options.spec,
|
|
18
|
+
header: options.header,
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
21
|
function addCommonOptions(cmd) {
|
|
@@ -24,7 +25,8 @@ function addCommonOptions(cmd) {
|
|
|
24
25
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
25
26
|
.option('-p, --path <path>', 'Working directory path')
|
|
26
27
|
.option('--endpoint <url>', 'Base API endpoint to test (env: EXPLORBOT_URL)')
|
|
27
|
-
.option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)')
|
|
28
|
+
.option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)')
|
|
29
|
+
.option('-H, --header <header>', 'Header sent with every request, as "Name: value". Repeatable (env: EXPLORBOT_API_HEADERS)', (value, previous = []) => [...previous, value]);
|
|
28
30
|
}
|
|
29
31
|
function selectTests(tests, index) {
|
|
30
32
|
if (!index || index === '*' || index === 'all') {
|
|
@@ -87,6 +89,8 @@ export function createApiCommands(name = 'api') {
|
|
|
87
89
|
const [site] = listSites();
|
|
88
90
|
const runOptions = buildOptions(options);
|
|
89
91
|
runOptions.endpoint = endpoint || site?.url;
|
|
92
|
+
if (runOptions.endpoint && URL.canParse(runOptions.endpoint))
|
|
93
|
+
runOptions.baseEndpoint ||= runOptions.endpoint;
|
|
90
94
|
try {
|
|
91
95
|
const config = await parser.loadConfig(runOptions);
|
|
92
96
|
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
@@ -130,9 +134,11 @@ export function createApiCommands(name = 'api') {
|
|
|
130
134
|
process.exit(1);
|
|
131
135
|
}
|
|
132
136
|
});
|
|
133
|
-
addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan all styles, execute tests, re-plan')).action(async (endpoint, options) => {
|
|
137
|
+
addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan all styles, execute tests, re-plan. The endpoint may be the base endpoint itself')).action(async (endpoint, options) => {
|
|
134
138
|
setPreserveConsoleLogs(true);
|
|
135
139
|
try {
|
|
140
|
+
if (URL.canParse(endpoint))
|
|
141
|
+
options.endpoint ||= endpoint;
|
|
136
142
|
const bot = new ApiBot({ ...buildOptions(options), endpoint });
|
|
137
143
|
await bot.start();
|
|
138
144
|
const styles = Object.keys(getStyles());
|
|
@@ -160,7 +166,7 @@ export function createApiCommands(name = 'api') {
|
|
|
160
166
|
else
|
|
161
167
|
totalFailed++;
|
|
162
168
|
}
|
|
163
|
-
bot.savePlan(
|
|
169
|
+
bot.savePlan(style);
|
|
164
170
|
}
|
|
165
171
|
console.log('\n=== Final Results ===');
|
|
166
172
|
console.log(`Total: ${totalTests} tests, ${totalPassed} passed, ${totalFailed} failed`);
|
|
@@ -10,8 +10,21 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
|
10
10
|
import path, { resolve } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
12
|
import { parseEnv } from 'node:util';
|
|
13
|
-
import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot } from "../../../src/config.js";
|
|
13
|
+
import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot, setOutputDir } from "../../../src/config.js";
|
|
14
14
|
import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
|
|
15
|
+
function isAbsoluteEndpoint(value) {
|
|
16
|
+
return !!value && (value.startsWith('http://') || value.startsWith('https://'));
|
|
17
|
+
}
|
|
18
|
+
function parseHeaders(raw) {
|
|
19
|
+
const headers = {};
|
|
20
|
+
for (const line of raw.split('\n')) {
|
|
21
|
+
const separator = line.indexOf(':');
|
|
22
|
+
if (separator < 1)
|
|
23
|
+
continue;
|
|
24
|
+
headers[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
25
|
+
}
|
|
26
|
+
return headers;
|
|
27
|
+
}
|
|
15
28
|
export class ApibotConfigParser {
|
|
16
29
|
static instance;
|
|
17
30
|
config = null;
|
|
@@ -65,6 +78,7 @@ export class ApibotConfigParser {
|
|
|
65
78
|
}
|
|
66
79
|
this.config = this.mergeWithDefaults(loadedConfig);
|
|
67
80
|
this.applyEnvSpec(this.config.api);
|
|
81
|
+
this.applyEnvHeaders(this.config.api);
|
|
68
82
|
if (options?.baseEndpoint)
|
|
69
83
|
this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
|
|
70
84
|
await resolveConfigModels(this.config.ai);
|
|
@@ -74,6 +88,7 @@ export class ApibotConfigParser {
|
|
|
74
88
|
this.enterGlobalMode(this.config, options?.endpoint);
|
|
75
89
|
}
|
|
76
90
|
this.validateConfig(this.config);
|
|
91
|
+
setOutputDir(this.getOutputDir());
|
|
77
92
|
return this.config;
|
|
78
93
|
}
|
|
79
94
|
finally {
|
|
@@ -103,12 +118,14 @@ export class ApibotConfigParser {
|
|
|
103
118
|
return path.dirname(configPath);
|
|
104
119
|
}
|
|
105
120
|
resolveEndpointPath(endpoint) {
|
|
106
|
-
if (!this.site)
|
|
121
|
+
if (!this.site && !isAbsoluteEndpoint(endpoint))
|
|
107
122
|
return endpoint;
|
|
108
|
-
const
|
|
109
|
-
|
|
123
|
+
const base = new URL(this.getConfig().api.baseEndpoint);
|
|
124
|
+
const origin = this.site?.url || base.origin;
|
|
125
|
+
const resolved = resolveSiteTarget(endpoint, origin);
|
|
126
|
+
if (resolved.baseUrl !== origin)
|
|
110
127
|
return endpoint;
|
|
111
|
-
const basePath =
|
|
128
|
+
const basePath = base.pathname.replace(/\/$/, '');
|
|
112
129
|
if (!basePath)
|
|
113
130
|
return resolved.path;
|
|
114
131
|
if (resolved.path === basePath)
|
|
@@ -137,12 +154,19 @@ export class ApibotConfigParser {
|
|
|
137
154
|
process.env.EXPLORBOT_URL = options.baseEndpoint;
|
|
138
155
|
if (options?.spec)
|
|
139
156
|
process.env.EXPLORBOT_API_SPEC = options.spec;
|
|
157
|
+
if (options?.header?.length)
|
|
158
|
+
process.env.EXPLORBOT_API_HEADERS = options.header.join('\n');
|
|
140
159
|
}
|
|
141
160
|
applyEnvSpec(api) {
|
|
142
161
|
if (!process.env.EXPLORBOT_API_SPEC)
|
|
143
162
|
return;
|
|
144
163
|
api.spec = [process.env.EXPLORBOT_API_SPEC];
|
|
145
164
|
}
|
|
165
|
+
applyEnvHeaders(api) {
|
|
166
|
+
if (!process.env.EXPLORBOT_API_HEADERS)
|
|
167
|
+
return;
|
|
168
|
+
api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
|
|
169
|
+
}
|
|
146
170
|
enterGlobalMode(config, endpoint) {
|
|
147
171
|
const site = resolveSiteTarget(endpoint);
|
|
148
172
|
this.site = registerSite(site.baseUrl);
|
|
@@ -163,7 +187,7 @@ export class ApibotConfigParser {
|
|
|
163
187
|
if (modelSpec && !provider && !modelSpec.includes('/')) {
|
|
164
188
|
throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
|
|
165
189
|
}
|
|
166
|
-
const baseEndpoint = process.env.EXPLORBOT_URL;
|
|
190
|
+
const baseEndpoint = process.env.EXPLORBOT_URL?.replace(/\/$/, '');
|
|
167
191
|
if (!baseEndpoint) {
|
|
168
192
|
throw new Error('No API endpoint to test. Pass --endpoint or set EXPLORBOT_URL to the API base endpoint');
|
|
169
193
|
}
|
|
@@ -171,6 +195,7 @@ export class ApibotConfigParser {
|
|
|
171
195
|
materializeKnowledge(outputRoot);
|
|
172
196
|
const api = { baseEndpoint };
|
|
173
197
|
this.applyEnvSpec(api);
|
|
198
|
+
this.applyEnvHeaders(api);
|
|
174
199
|
let model;
|
|
175
200
|
if (provider && modelSpec)
|
|
176
201
|
model = await createModel(provider, modelSpec);
|
|
@@ -185,6 +210,7 @@ export class ApibotConfigParser {
|
|
|
185
210
|
};
|
|
186
211
|
this.configPath = path.join(outputRoot, 'apibot.config.js');
|
|
187
212
|
this.validateConfig(this.config);
|
|
213
|
+
setOutputDir(this.getOutputDir());
|
|
188
214
|
return this.config;
|
|
189
215
|
}
|
|
190
216
|
findConfigFile() {
|
|
@@ -1,73 +1,12 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import dedent from 'dedent';
|
|
3
2
|
import { keepServerRunning } from "../../../src/browser-server.js";
|
|
4
3
|
import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
|
|
5
4
|
import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
|
|
6
5
|
import { isVerboseMode, setQuietMode } from "../../../src/utils/logger.js";
|
|
7
6
|
import { clearActivityLine, trackActivityLine } from "./activity-line.js";
|
|
8
7
|
import { renderEnvelope } from "./envelope.js";
|
|
8
|
+
import { askHelp, checkHelp, doHelp, helpContract, reportHelp, researchHelp, sessionHelp, statusHelp, verifyHelp } from "./help.js";
|
|
9
9
|
import { Prima } from "./prima.js";
|
|
10
|
-
const helpContract = dedent `
|
|
11
|
-
Prima is a high-level AI extension to playwright-cli, driving the browser it has open.
|
|
12
|
-
|
|
13
|
-
playwright-cli open <url> starts the session
|
|
14
|
-
prima <command> ... drives it
|
|
15
|
-
playwright-cli close ends it
|
|
16
|
-
|
|
17
|
-
One call takes a whole job:
|
|
18
|
-
|
|
19
|
-
prima check "a workflow can be created and appears in the list" --expected "the new workflow is listed"
|
|
20
|
-
prima do "open the account menu" "choose the settings entry" "switch the theme to dark" "check it took effect"
|
|
21
|
-
prima pw "({ page }) => page.click('[data-test=submit]')"
|
|
22
|
-
`;
|
|
23
|
-
const checkHelp = dedent `
|
|
24
|
-
check takes an outcome rather than a click path, and works out how to reach it. It runs
|
|
25
|
-
on the page you are already on and never reloads it, so an open dialog survives the check.
|
|
26
|
-
--expected one outcome the run must reach, repeatable for several. Without it the
|
|
27
|
-
scenario text is the single expected outcome. Each comes back under
|
|
28
|
-
### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
|
|
29
|
-
"not verified" means the run never checked it, which is not the same
|
|
30
|
-
as false.
|
|
31
|
-
Outcomes are settled against a screenshot of the whole page: what a user can see is
|
|
32
|
-
the proof, and the run log only says what was done. CONTRADICTION means the two
|
|
33
|
-
disagree - reported with both sides rather than settled one way, so read the html,
|
|
34
|
-
aria and screenshot named under ### Artifacts and judge it yourself. Not finding
|
|
35
|
-
something in the picture is not enough on its own; that is "not verified".
|
|
36
|
-
ok: follows those outcomes - false when one FAILED or CONTRADICTED, or when the run
|
|
37
|
-
could not complete, which is reported as such rather than as an app failure.
|
|
38
|
-
Page problems seen on the way appear under ### Answer, not as step failures.
|
|
39
|
-
`;
|
|
40
|
-
const doHelp = dedent `
|
|
41
|
-
Each instruction is numbered and accounted for: ### Steps reports each as ok, FAIL or ??.
|
|
42
|
-
?? means the action ran but the run ended without confirming that instruction - read the
|
|
43
|
-
steps above it. Only FAIL and an instruction the page could not carry out fail the command.
|
|
44
|
-
Nothing runs past the last instruction given. A whole remaining sequence in one call is
|
|
45
|
-
what makes this tier cheap.
|
|
46
|
-
`;
|
|
47
|
-
const verifyHelp = dedent `
|
|
48
|
-
Reports each assertion it could express as PASSED or FAILED with its playwright form,
|
|
49
|
-
and gives no overall verdict - read the lines and decide. "none ran" means the claim
|
|
50
|
-
could not be expressed, which is not the same as false.
|
|
51
|
-
`;
|
|
52
|
-
const statusHelp = dedent `
|
|
53
|
-
Reads the files a command recorded, so it needs no browser and outlives the session.
|
|
54
|
-
The hash is looked up across every recorded site. ### Artifacts names every file kept
|
|
55
|
-
under it: the aria tree, the html, the screenshot and network log when they were
|
|
56
|
-
captured, and the per-step captures of a do run.
|
|
57
|
-
`;
|
|
58
|
-
const reportHelp = dedent `
|
|
59
|
-
Commands are logged as they run, so the report needs no browser and outlives the session.
|
|
60
|
-
The most recent session is reported unless --pw-session names another.
|
|
61
|
-
`;
|
|
62
|
-
const sessionHelp = dedent `
|
|
63
|
-
--endpoint <ep> attach to a browser server endpoint directly, skipping discovery
|
|
64
|
-
--instance <name> which prima-owned browser you talk to; parallel work needs one each
|
|
65
|
-
--session [file] cookies and storage persisted across processes; ignored while
|
|
66
|
-
attached, since the attached session keeps its own
|
|
67
|
-
--framework parsed but not active yet; reported code is CodeceptJS either way
|
|
68
|
-
DEBUG='explorbot:*' in front of a command prints the log of everything it does.
|
|
69
|
-
When no AI model is usable pw still works; for everything else drive playwright-cli.
|
|
70
|
-
`;
|
|
71
10
|
let rootOptions = () => ({});
|
|
72
11
|
function buildOptions(subcommand) {
|
|
73
12
|
const options = { ...rootOptions(), ...stripEmpty(subcommand) };
|
|
@@ -104,13 +43,15 @@ function addCommonOptions(cmd) {
|
|
|
104
43
|
.option('--session [file]', 'Persist cookies and storage to a session file')
|
|
105
44
|
.option('--model <model>', 'Main model, as provider/model-id')
|
|
106
45
|
.option('--vision-model <model>', 'Model for screenshot analysis, as provider/model-id')
|
|
107
|
-
.option('--ephemeral', 'Keep no state
|
|
108
|
-
.option('--framework <name>', '
|
|
46
|
+
.option('--ephemeral', 'Keep no state; config-free runs use a temp directory')
|
|
47
|
+
.option('--framework <name>', 'Inactive: reported code targets codeceptjs or playwright')
|
|
109
48
|
.option('--url <url>', 'Page to open when the session has no page yet')
|
|
110
49
|
.option('--spec <path>', 'Docbot application spec directory or index.md to read as page knowledge')
|
|
111
50
|
.option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
|
|
112
|
-
.option('--pw-session <title>', 'Title of the playwright-cli session to attach to')
|
|
113
|
-
|
|
51
|
+
.option('--pw-session <title>', 'Title of the playwright-cli session to attach to');
|
|
52
|
+
}
|
|
53
|
+
function addBrowserOptions(cmd) {
|
|
54
|
+
return addCommonOptions(cmd).addHelpText('after', `\n${sessionHelp}`);
|
|
114
55
|
}
|
|
115
56
|
function primaFor(options) {
|
|
116
57
|
Prima.applyEnv();
|
|
@@ -124,20 +65,23 @@ function primaFor(options) {
|
|
|
124
65
|
process.env.EXPLORBOT_SPEC = options.spec;
|
|
125
66
|
return new Prima(buildOptions(options));
|
|
126
67
|
}
|
|
127
|
-
async function runPrima(options, command, run) {
|
|
68
|
+
async function runPrima(options, command, run, opts = {}) {
|
|
69
|
+
const { browser = true, record = true } = opts;
|
|
128
70
|
setQuietMode(!isVerboseMode());
|
|
129
71
|
trackActivityLine();
|
|
130
72
|
const prima = primaFor(options);
|
|
131
73
|
const startedAt = Date.now();
|
|
132
74
|
let envelope;
|
|
133
75
|
try {
|
|
134
|
-
|
|
76
|
+
if (browser)
|
|
77
|
+
await prima.start();
|
|
135
78
|
envelope = await run(prima);
|
|
136
79
|
}
|
|
137
80
|
catch (error) {
|
|
138
81
|
envelope = await prima.toolFailureEnvelope(command, error);
|
|
139
82
|
}
|
|
140
|
-
|
|
83
|
+
if (record)
|
|
84
|
+
prima.record(envelope, Date.now() - startedAt);
|
|
141
85
|
clearActivityLine();
|
|
142
86
|
console.log(renderEnvelope(envelope));
|
|
143
87
|
await prima.stop().catch(() => { });
|
|
@@ -156,37 +100,41 @@ async function runBrowser(options, run) {
|
|
|
156
100
|
}
|
|
157
101
|
export function createPrimaCommands(name = 'prima') {
|
|
158
102
|
const cmd = new Command(name);
|
|
159
|
-
cmd.description('
|
|
103
|
+
cmd.description('Drives a web app through described behaviour, not locators');
|
|
160
104
|
cmd.option('--pw-session <title>', 'Title of the playwright-cli session to attach to');
|
|
161
105
|
cmd.option('--url <url>', 'Page to open when the session has no page yet');
|
|
162
106
|
cmd.addHelpText('after', `\n${helpContract}`);
|
|
163
107
|
rootOptions = () => cmd.opts();
|
|
164
|
-
|
|
108
|
+
addBrowserOptions(cmd.command('pw <fn>').description('Run a Playwright function expression against the open page')).action(async (fn, options) => {
|
|
165
109
|
await runPrima(options, `pw ${fn}`, (prima) => prima.pw(fn));
|
|
166
110
|
});
|
|
167
|
-
|
|
111
|
+
addBrowserOptions(cmd.command('do <instructions...>').description('Run instructions, one argument each'))
|
|
168
112
|
.addHelpText('after', `\n${doHelp}`)
|
|
169
113
|
.action(async (instructions, options) => {
|
|
170
114
|
await runPrima(options, `do ${instructions.join(' ')}`, (prima) => prima.do(instructions));
|
|
171
115
|
});
|
|
172
|
-
|
|
116
|
+
addBrowserOptions(cmd.command('check <scenario>').description('Run a scenario as a test, with verification'))
|
|
173
117
|
.option('--expected <outcome>', 'An outcome the run must reach; repeat the flag for several', (value, all) => [...all, value], [])
|
|
174
118
|
.addHelpText('after', `\n${checkHelp}`)
|
|
175
119
|
.action(async (scenario, options) => {
|
|
176
120
|
await runPrima(options, `check ${scenario}`, (prima) => prima.check(scenario, options.expected));
|
|
177
121
|
});
|
|
178
|
-
|
|
122
|
+
addBrowserOptions(cmd.command('ask <question>').description('Answer a question about the current page').option('--no-vision', 'Answer from page structure only, without a screenshot'))
|
|
123
|
+
.addHelpText('after', `\n${askHelp}`)
|
|
124
|
+
.action(async (question, options) => {
|
|
179
125
|
await runPrima(options, `ask ${question}`, (prima) => prima.ask(question));
|
|
180
126
|
});
|
|
181
|
-
|
|
127
|
+
addBrowserOptions(cmd.command('verify <assertion>').alias('assert').description('Assert a statement about the current page'))
|
|
182
128
|
.addHelpText('after', `\n${verifyHelp}`)
|
|
183
129
|
.action(async (assertion, options) => {
|
|
184
130
|
await runPrima(options, `verify ${assertion}`, (prima) => prima.verify(assertion));
|
|
185
131
|
});
|
|
186
|
-
|
|
132
|
+
addBrowserOptions(cmd.command('research').description('Map the current page and return verified locators').option('--data', 'Include data extraction in the map').option('--deep', 'Expand hidden elements for a deeper map').option('--fresh', 'Ignore the cached map and research the page again'))
|
|
133
|
+
.addHelpText('after', `\n${researchHelp}`)
|
|
134
|
+
.action(async (options) => {
|
|
187
135
|
await runPrima(options, 'research', (prima) => prima.research({ data: options.data, deep: options.deep, fresh: options.fresh }));
|
|
188
136
|
});
|
|
189
|
-
|
|
137
|
+
addBrowserOptions(cmd.command('go <target>').description('Navigate to a url, a path, or a page described in plain words')).action(async (target, options) => {
|
|
190
138
|
if (URL.canParse(target))
|
|
191
139
|
options.baseUrl = target;
|
|
192
140
|
await runPrima(options, `go ${target}`, (prima) => prima.go(target));
|
|
@@ -204,11 +152,7 @@ export function createPrimaCommands(name = 'prima') {
|
|
|
204
152
|
addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
|
|
205
153
|
.addHelpText('after', `\n${statusHelp}`)
|
|
206
154
|
.action(async (hash, options) => {
|
|
207
|
-
|
|
208
|
-
const prima = primaFor(options);
|
|
209
|
-
const envelope = await prima.status(hash).catch((error) => prima.toolFailureEnvelope(`status ${hash}`, error));
|
|
210
|
-
console.log(renderEnvelope(envelope));
|
|
211
|
-
process.exit(envelope.ok ? 0 : 1);
|
|
155
|
+
await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), { browser: false, record: false });
|
|
212
156
|
});
|
|
213
157
|
addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report'))
|
|
214
158
|
.addHelpText('after', `\n${reportHelp}`)
|
|
@@ -220,7 +164,7 @@ export function createPrimaCommands(name = 'prima') {
|
|
|
220
164
|
process.exit(0);
|
|
221
165
|
});
|
|
222
166
|
const browser = cmd.command('browser').description('Manage the browsers prima drives');
|
|
223
|
-
|
|
167
|
+
addBrowserOptions(browser.command('start').description('Start a prima-owned browser and hold it open until Ctrl+C'))
|
|
224
168
|
.option('-s, --show', 'Launch the browser in a visible window')
|
|
225
169
|
.option('--headless', 'Launch the browser without a window')
|
|
226
170
|
.action(async (options) => {
|
|
@@ -230,7 +174,7 @@ export function createPrimaCommands(name = 'prima') {
|
|
|
230
174
|
return keepServerRunning(() => prima.browserStop());
|
|
231
175
|
});
|
|
232
176
|
});
|
|
233
|
-
|
|
177
|
+
addBrowserOptions(browser.command('stop').description('Stop the browser of this instance'))
|
|
234
178
|
.option('--all', 'Stop every running instance')
|
|
235
179
|
.action(async (options) => {
|
|
236
180
|
await runBrowser(options, async (prima) => {
|
|
@@ -239,13 +183,13 @@ export function createPrimaCommands(name = 'prima') {
|
|
|
239
183
|
return stopped;
|
|
240
184
|
});
|
|
241
185
|
});
|
|
242
|
-
|
|
186
|
+
addBrowserOptions(browser.command('status').description('Report the browser of this instance')).action(async (options) => {
|
|
243
187
|
await runBrowser(options, async (prima) => {
|
|
244
188
|
console.log(await prima.browserStatus());
|
|
245
189
|
return true;
|
|
246
190
|
});
|
|
247
191
|
});
|
|
248
|
-
|
|
192
|
+
addBrowserOptions(browser.command('list').description('List every browser instance that is running')).action(async (options) => {
|
|
249
193
|
await runBrowser(options, async (prima) => {
|
|
250
194
|
console.log(await prima.browserList());
|
|
251
195
|
return true;
|
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
export const STATUS_FILE = 'status.json';
|
|
4
4
|
const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' };
|
|
5
|
+
export const STEP_FILES = { aria: 'aria.yaml', html: 'html', diff: 'diff.yaml' };
|
|
5
6
|
const EXPECTATION_LABELS = {
|
|
6
7
|
passed: 'PASSED ',
|
|
7
8
|
failed: 'FAILED ',
|
|
@@ -86,7 +87,7 @@ function renderSteps(data) {
|
|
|
86
87
|
lines.push(` ${line}`);
|
|
87
88
|
});
|
|
88
89
|
if (data.stepFiles)
|
|
89
|
-
lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{
|
|
90
|
+
lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{${Object.values(STEP_FILES).join(',')}}`);
|
|
90
91
|
return section('Steps', lines.join('\n'));
|
|
91
92
|
}
|
|
92
93
|
function renderExpectations(data) {
|