explorbot 0.4.3 → 0.4.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/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 +33 -43
- 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 +29 -41
- 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/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/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +27 -33
- package/dist/src/ai/tools.js +5 -0
- package/dist/src/commands/config-command.js +6 -2
- package/dist/src/config.d.ts +3 -0
- package/dist/src/config.js +14 -0
- package/dist/src/state-manager.js +5 -1
- package/docs/api-testing/basics.md +12 -4
- package/docs/reference/commands.md +1 -0
- package/docs/workflow/agentic-usage.md +3 -1
- package/package.json +1 -1
- package/src/action-result.ts +7 -0
- package/src/action.ts +14 -2
- 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/tester.ts +25 -30
- package/src/ai/tools.ts +6 -0
- package/src/commands/config-command.ts +4 -1
- package/src/config.ts +16 -0
- package/src/state-manager.ts +6 -1
|
@@ -10,7 +10,7 @@ import { setVerboseMode, tag } from '../../../src/utils/logger.ts';
|
|
|
10
10
|
import { Chief } from './ai/chief.ts';
|
|
11
11
|
import { Curler } from './ai/curler.ts';
|
|
12
12
|
import { ApiClient } from './api-client.ts';
|
|
13
|
-
import { type ApibotConfig, ApibotConfigParser } from './config.ts';
|
|
13
|
+
import { type ApibotConfig, ApibotConfigParser, type ApibotRunOptions } from './config.ts';
|
|
14
14
|
|
|
15
15
|
export class ApiBot {
|
|
16
16
|
private configParser: ApibotConfigParser;
|
|
@@ -141,7 +141,7 @@ export class ApiBot {
|
|
|
141
141
|
return this.currentPlan;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
savePlan(
|
|
144
|
+
savePlan(suffix?: string): string | null {
|
|
145
145
|
if (!this.currentPlan) return null;
|
|
146
146
|
|
|
147
147
|
const plansDir = this.configParser.getPlansDir();
|
|
@@ -149,8 +149,7 @@ export class ApiBot {
|
|
|
149
149
|
mkdirSync(plansDir, { recursive: true });
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
const
|
|
153
|
-
const planPath = path.join(plansDir, planFilename);
|
|
152
|
+
const planPath = path.join(plansDir, this.generatePlanFilename(suffix));
|
|
154
153
|
this.currentPlan.saveToMarkdown(planPath);
|
|
155
154
|
return planPath;
|
|
156
155
|
}
|
|
@@ -192,20 +191,16 @@ export class ApiBot {
|
|
|
192
191
|
}
|
|
193
192
|
}
|
|
194
193
|
|
|
195
|
-
private generatePlanFilename(): string {
|
|
194
|
+
private generatePlanFilename(suffix?: string): string {
|
|
196
195
|
const endpoint = this.currentPlan?.url || '/';
|
|
197
|
-
|
|
198
|
-
|
|
196
|
+
let name = endpoint.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '_') || 'root';
|
|
197
|
+
if (suffix) name = `${name}_${suffix}`;
|
|
198
|
+
return `${name.slice(0, 200)}.md`;
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
interface ApibotOptions {
|
|
202
|
+
interface ApibotOptions extends ApibotRunOptions {
|
|
203
203
|
verbose?: boolean;
|
|
204
|
-
config?: string;
|
|
205
|
-
path?: string;
|
|
206
|
-
endpoint?: string;
|
|
207
|
-
baseEndpoint?: string;
|
|
208
|
-
spec?: string;
|
|
209
204
|
}
|
|
210
205
|
|
|
211
206
|
export type { ApibotOptions };
|
|
@@ -16,6 +16,7 @@ function buildOptions(options: any): ApibotOptions {
|
|
|
16
16
|
path: options.path,
|
|
17
17
|
baseEndpoint: options.endpoint,
|
|
18
18
|
spec: options.spec,
|
|
19
|
+
header: options.header,
|
|
19
20
|
};
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -26,7 +27,8 @@ function addCommonOptions(cmd: Command): Command {
|
|
|
26
27
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
27
28
|
.option('-p, --path <path>', 'Working directory path')
|
|
28
29
|
.option('--endpoint <url>', 'Base API endpoint to test (env: EXPLORBOT_URL)')
|
|
29
|
-
.option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)')
|
|
30
|
+
.option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)')
|
|
31
|
+
.option('-H, --header <header>', 'Header sent with every request, as "Name: value". Repeatable (env: EXPLORBOT_API_HEADERS)', (value: string, previous: string[] = []) => [...previous, value]);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
function selectTests(tests: any[], index?: string): any[] {
|
|
@@ -101,6 +103,7 @@ export function createApiCommands(name = 'api'): Command {
|
|
|
101
103
|
const [site] = listSites();
|
|
102
104
|
const runOptions = buildOptions(options);
|
|
103
105
|
runOptions.endpoint = endpoint || site?.url;
|
|
106
|
+
if (runOptions.endpoint && URL.canParse(runOptions.endpoint)) runOptions.baseEndpoint ||= runOptions.endpoint;
|
|
104
107
|
try {
|
|
105
108
|
const config = await parser.loadConfig(runOptions);
|
|
106
109
|
console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
|
|
@@ -149,9 +152,10 @@ export function createApiCommands(name = 'api'): Command {
|
|
|
149
152
|
}
|
|
150
153
|
});
|
|
151
154
|
|
|
152
|
-
addCommonOptions(cmd.command('explore <endpoint>').description('Full cycle: plan all styles, execute tests, re-plan')).action(async (endpoint, options) => {
|
|
155
|
+
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) => {
|
|
153
156
|
setPreserveConsoleLogs(true);
|
|
154
157
|
try {
|
|
158
|
+
if (URL.canParse(endpoint)) options.endpoint ||= endpoint;
|
|
155
159
|
const bot = new ApiBot({ ...buildOptions(options), endpoint });
|
|
156
160
|
await bot.start();
|
|
157
161
|
|
|
@@ -182,7 +186,7 @@ export function createApiCommands(name = 'api'): Command {
|
|
|
182
186
|
else totalFailed++;
|
|
183
187
|
}
|
|
184
188
|
|
|
185
|
-
bot.savePlan(
|
|
189
|
+
bot.savePlan(style);
|
|
186
190
|
}
|
|
187
191
|
|
|
188
192
|
console.log('\n=== Final Results ===');
|
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import path, { resolve } from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { parseEnv } from 'node:util';
|
|
5
|
-
import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot } from '../../../src/config.ts';
|
|
5
|
+
import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot, setOutputDir } from '../../../src/config.ts';
|
|
6
6
|
import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
|
|
7
7
|
|
|
8
8
|
export type { AIConfig };
|
|
@@ -24,6 +24,20 @@ interface ApibotConfig {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function isAbsoluteEndpoint(value?: string): boolean {
|
|
28
|
+
return !!value && (value.startsWith('http://') || value.startsWith('https://'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseHeaders(raw: string): Record<string, string> {
|
|
32
|
+
const headers: Record<string, string> = {};
|
|
33
|
+
for (const line of raw.split('\n')) {
|
|
34
|
+
const separator = line.indexOf(':');
|
|
35
|
+
if (separator < 1) continue;
|
|
36
|
+
headers[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
37
|
+
}
|
|
38
|
+
return headers;
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
export class ApibotConfigParser {
|
|
28
42
|
private static instance: ApibotConfigParser;
|
|
29
43
|
private config: ApibotConfig | null = null;
|
|
@@ -45,7 +59,7 @@ export class ApibotConfigParser {
|
|
|
45
59
|
Object.assign(process.env, parseEnv(readFileSync(resolved, 'utf8')));
|
|
46
60
|
}
|
|
47
61
|
|
|
48
|
-
async loadConfig(options?:
|
|
62
|
+
async loadConfig(options?: ApibotRunOptions): Promise<ApibotConfig> {
|
|
49
63
|
if (this.config && !options?.config && !options?.path) return this.config;
|
|
50
64
|
|
|
51
65
|
const originalCwd = process.cwd();
|
|
@@ -84,6 +98,7 @@ export class ApibotConfigParser {
|
|
|
84
98
|
|
|
85
99
|
this.config = this.mergeWithDefaults(loadedConfig);
|
|
86
100
|
this.applyEnvSpec(this.config.api);
|
|
101
|
+
this.applyEnvHeaders(this.config.api);
|
|
87
102
|
if (options?.baseEndpoint) this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
|
|
88
103
|
await resolveConfigModels(this.config.ai);
|
|
89
104
|
this.configPath = resolvedPath;
|
|
@@ -94,6 +109,7 @@ export class ApibotConfigParser {
|
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
this.validateConfig(this.config);
|
|
112
|
+
setOutputDir(this.getOutputDir());
|
|
97
113
|
|
|
98
114
|
return this.config;
|
|
99
115
|
} finally {
|
|
@@ -125,12 +141,15 @@ export class ApibotConfigParser {
|
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
resolveEndpointPath(endpoint: string): string {
|
|
128
|
-
if (!this.site) return endpoint;
|
|
144
|
+
if (!this.site && !isAbsoluteEndpoint(endpoint)) return endpoint;
|
|
129
145
|
|
|
130
|
-
const
|
|
131
|
-
|
|
146
|
+
const base = new URL(this.getConfig().api.baseEndpoint);
|
|
147
|
+
const origin = this.site?.url || base.origin;
|
|
132
148
|
|
|
133
|
-
const
|
|
149
|
+
const resolved = resolveSiteTarget(endpoint, origin);
|
|
150
|
+
if (resolved.baseUrl !== origin) return endpoint;
|
|
151
|
+
|
|
152
|
+
const basePath = base.pathname.replace(/\/$/, '');
|
|
134
153
|
if (!basePath) return resolved.path;
|
|
135
154
|
if (resolved.path === basePath) return '/';
|
|
136
155
|
if (resolved.path.startsWith(`${basePath}/`)) return resolved.path.slice(basePath.length);
|
|
@@ -156,9 +175,10 @@ export class ApibotConfigParser {
|
|
|
156
175
|
}
|
|
157
176
|
}
|
|
158
177
|
|
|
159
|
-
private applyRunOptions(options?:
|
|
178
|
+
private applyRunOptions(options?: ApibotRunOptions): void {
|
|
160
179
|
if (options?.baseEndpoint) process.env.EXPLORBOT_URL = options.baseEndpoint;
|
|
161
180
|
if (options?.spec) process.env.EXPLORBOT_API_SPEC = options.spec;
|
|
181
|
+
if (options?.header?.length) process.env.EXPLORBOT_API_HEADERS = options.header.join('\n');
|
|
162
182
|
}
|
|
163
183
|
|
|
164
184
|
private applyEnvSpec(api: ApiConfig): void {
|
|
@@ -166,6 +186,11 @@ export class ApibotConfigParser {
|
|
|
166
186
|
api.spec = [process.env.EXPLORBOT_API_SPEC];
|
|
167
187
|
}
|
|
168
188
|
|
|
189
|
+
private applyEnvHeaders(api: ApiConfig): void {
|
|
190
|
+
if (!process.env.EXPLORBOT_API_HEADERS) return;
|
|
191
|
+
api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
|
|
192
|
+
}
|
|
193
|
+
|
|
169
194
|
private enterGlobalMode(config: ApibotConfig, endpoint?: string): void {
|
|
170
195
|
const site = resolveSiteTarget(endpoint);
|
|
171
196
|
this.site = registerSite(site.baseUrl);
|
|
@@ -189,7 +214,7 @@ export class ApibotConfigParser {
|
|
|
189
214
|
throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
|
|
190
215
|
}
|
|
191
216
|
|
|
192
|
-
const baseEndpoint = process.env.EXPLORBOT_URL;
|
|
217
|
+
const baseEndpoint = process.env.EXPLORBOT_URL?.replace(/\/$/, '');
|
|
193
218
|
if (!baseEndpoint) {
|
|
194
219
|
throw new Error('No API endpoint to test. Pass --endpoint or set EXPLORBOT_URL to the API base endpoint');
|
|
195
220
|
}
|
|
@@ -199,6 +224,7 @@ export class ApibotConfigParser {
|
|
|
199
224
|
|
|
200
225
|
const api: ApiConfig = { baseEndpoint };
|
|
201
226
|
this.applyEnvSpec(api);
|
|
227
|
+
this.applyEnvHeaders(api);
|
|
202
228
|
|
|
203
229
|
let model: any;
|
|
204
230
|
if (provider && modelSpec) model = await createModel(provider, modelSpec);
|
|
@@ -212,6 +238,7 @@ export class ApibotConfigParser {
|
|
|
212
238
|
};
|
|
213
239
|
this.configPath = path.join(outputRoot, 'apibot.config.js');
|
|
214
240
|
this.validateConfig(this.config);
|
|
241
|
+
setOutputDir(this.getOutputDir());
|
|
215
242
|
|
|
216
243
|
return this.config;
|
|
217
244
|
}
|
|
@@ -282,4 +309,13 @@ export class ApibotConfigParser {
|
|
|
282
309
|
}
|
|
283
310
|
}
|
|
284
311
|
|
|
285
|
-
|
|
312
|
+
interface ApibotRunOptions {
|
|
313
|
+
config?: string;
|
|
314
|
+
path?: string;
|
|
315
|
+
endpoint?: string;
|
|
316
|
+
baseEndpoint?: string;
|
|
317
|
+
spec?: string;
|
|
318
|
+
header?: string[];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export type { ApibotConfig, ApiConfig, HookFn, ApibotRunOptions };
|
package/boat/prima/src/cli.ts
CHANGED
|
@@ -1,81 +1,13 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import dedent from 'dedent';
|
|
3
2
|
import { keepServerRunning } from '../../../src/browser-server.ts';
|
|
4
3
|
import { RecommendedModelsCommand } from '../../../src/commands/recommended-models-command.ts';
|
|
5
4
|
import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
|
|
6
5
|
import { isVerboseMode, setQuietMode } from '../../../src/utils/logger.ts';
|
|
7
6
|
import { clearActivityLine, trackActivityLine } from './activity-line.ts';
|
|
8
7
|
import { type EnvelopeData, renderEnvelope } from './envelope.ts';
|
|
8
|
+
import { askHelp, checkHelp, doHelp, helpContract, reportHelp, researchHelp, sessionHelp, statusHelp, verifyHelp } from './help.ts';
|
|
9
9
|
import { Prima, type PrimaOptions } from './prima.ts';
|
|
10
10
|
|
|
11
|
-
const helpContract = dedent`
|
|
12
|
-
Prima is a high-level AI extension to playwright-cli, driving the browser it has open.
|
|
13
|
-
|
|
14
|
-
playwright-cli open <url> starts the session
|
|
15
|
-
prima <command> ... drives it
|
|
16
|
-
playwright-cli close ends it
|
|
17
|
-
|
|
18
|
-
One call takes a whole job:
|
|
19
|
-
|
|
20
|
-
prima check "a workflow can be created and appears in the list" --expected "the new workflow is listed"
|
|
21
|
-
prima do "open the account menu" "choose the settings entry" "switch the theme to dark" "check it took effect"
|
|
22
|
-
prima pw "({ page }) => page.click('[data-test=submit]')"
|
|
23
|
-
`;
|
|
24
|
-
|
|
25
|
-
const checkHelp = dedent`
|
|
26
|
-
check takes an outcome rather than a click path, and works out how to reach it. It runs
|
|
27
|
-
on the page you are already on and never reloads it, so an open dialog survives the check.
|
|
28
|
-
--expected one outcome the run must reach, repeatable for several. Without it the
|
|
29
|
-
scenario text is the single expected outcome. Each comes back under
|
|
30
|
-
### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
|
|
31
|
-
"not verified" means the run never checked it, which is not the same
|
|
32
|
-
as false.
|
|
33
|
-
Outcomes are settled against a screenshot of the whole page: what a user can see is
|
|
34
|
-
the proof, and the run log only says what was done. CONTRADICTION means the two
|
|
35
|
-
disagree - reported with both sides rather than settled one way, so read the html,
|
|
36
|
-
aria and screenshot named under ### Artifacts and judge it yourself. Not finding
|
|
37
|
-
something in the picture is not enough on its own; that is "not verified".
|
|
38
|
-
ok: follows those outcomes - false when one FAILED or CONTRADICTED, or when the run
|
|
39
|
-
could not complete, which is reported as such rather than as an app failure.
|
|
40
|
-
Page problems seen on the way appear under ### Answer, not as step failures.
|
|
41
|
-
`;
|
|
42
|
-
|
|
43
|
-
const doHelp = dedent`
|
|
44
|
-
Each instruction is numbered and accounted for: ### Steps reports each as ok, FAIL or ??.
|
|
45
|
-
?? means the action ran but the run ended without confirming that instruction - read the
|
|
46
|
-
steps above it. Only FAIL and an instruction the page could not carry out fail the command.
|
|
47
|
-
Nothing runs past the last instruction given. A whole remaining sequence in one call is
|
|
48
|
-
what makes this tier cheap.
|
|
49
|
-
`;
|
|
50
|
-
|
|
51
|
-
const verifyHelp = dedent`
|
|
52
|
-
Reports each assertion it could express as PASSED or FAILED with its playwright form,
|
|
53
|
-
and gives no overall verdict - read the lines and decide. "none ran" means the claim
|
|
54
|
-
could not be expressed, which is not the same as false.
|
|
55
|
-
`;
|
|
56
|
-
|
|
57
|
-
const statusHelp = dedent`
|
|
58
|
-
Reads the files a command recorded, so it needs no browser and outlives the session.
|
|
59
|
-
The hash is looked up across every recorded site. ### Artifacts names every file kept
|
|
60
|
-
under it: the aria tree, the html, the screenshot and network log when they were
|
|
61
|
-
captured, and the per-step captures of a do run.
|
|
62
|
-
`;
|
|
63
|
-
|
|
64
|
-
const reportHelp = dedent`
|
|
65
|
-
Commands are logged as they run, so the report needs no browser and outlives the session.
|
|
66
|
-
The most recent session is reported unless --pw-session names another.
|
|
67
|
-
`;
|
|
68
|
-
|
|
69
|
-
const sessionHelp = dedent`
|
|
70
|
-
--endpoint <ep> attach to a browser server endpoint directly, skipping discovery
|
|
71
|
-
--instance <name> which prima-owned browser you talk to; parallel work needs one each
|
|
72
|
-
--session [file] cookies and storage persisted across processes; ignored while
|
|
73
|
-
attached, since the attached session keeps its own
|
|
74
|
-
--framework parsed but not active yet; reported code is CodeceptJS either way
|
|
75
|
-
DEBUG='explorbot:*' in front of a command prints the log of everything it does.
|
|
76
|
-
When no AI model is usable pw still works; for everything else drive playwright-cli.
|
|
77
|
-
`;
|
|
78
|
-
|
|
79
11
|
let rootOptions: () => any = () => ({});
|
|
80
12
|
|
|
81
13
|
function buildOptions(subcommand: any): PrimaOptions {
|
|
@@ -114,13 +46,16 @@ function addCommonOptions(cmd: Command): Command {
|
|
|
114
46
|
.option('--session [file]', 'Persist cookies and storage to a session file')
|
|
115
47
|
.option('--model <model>', 'Main model, as provider/model-id')
|
|
116
48
|
.option('--vision-model <model>', 'Model for screenshot analysis, as provider/model-id')
|
|
117
|
-
.option('--ephemeral', 'Keep no state
|
|
118
|
-
.option('--framework <name>', '
|
|
49
|
+
.option('--ephemeral', 'Keep no state; config-free runs use a temp directory')
|
|
50
|
+
.option('--framework <name>', 'Inactive: reported code targets codeceptjs or playwright')
|
|
119
51
|
.option('--url <url>', 'Page to open when the session has no page yet')
|
|
120
52
|
.option('--spec <path>', 'Docbot application spec directory or index.md to read as page knowledge')
|
|
121
53
|
.option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
|
|
122
|
-
.option('--pw-session <title>', 'Title of the playwright-cli session to attach to')
|
|
123
|
-
|
|
54
|
+
.option('--pw-session <title>', 'Title of the playwright-cli session to attach to');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addBrowserOptions(cmd: Command): Command {
|
|
58
|
+
return addCommonOptions(cmd).addHelpText('after', `\n${sessionHelp}`);
|
|
124
59
|
}
|
|
125
60
|
|
|
126
61
|
function primaFor(options: any): Prima {
|
|
@@ -132,7 +67,8 @@ function primaFor(options: any): Prima {
|
|
|
132
67
|
return new Prima(buildOptions(options));
|
|
133
68
|
}
|
|
134
69
|
|
|
135
|
-
async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData
|
|
70
|
+
async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>, opts: { browser?: boolean; record?: boolean } = {}): Promise<void> {
|
|
71
|
+
const { browser = true, record = true } = opts;
|
|
136
72
|
setQuietMode(!isVerboseMode());
|
|
137
73
|
trackActivityLine();
|
|
138
74
|
const prima = primaFor(options);
|
|
@@ -140,13 +76,13 @@ async function runPrima(options: any, command: string, run: (prima: Prima) => Pr
|
|
|
140
76
|
|
|
141
77
|
let envelope: EnvelopeData;
|
|
142
78
|
try {
|
|
143
|
-
await prima.start();
|
|
79
|
+
if (browser) await prima.start();
|
|
144
80
|
envelope = await run(prima);
|
|
145
81
|
} catch (error) {
|
|
146
82
|
envelope = await prima.toolFailureEnvelope(command, error);
|
|
147
83
|
}
|
|
148
84
|
|
|
149
|
-
prima.record(envelope, Date.now() - startedAt);
|
|
85
|
+
if (record) prima.record(envelope, Date.now() - startedAt);
|
|
150
86
|
clearActivityLine();
|
|
151
87
|
console.log(renderEnvelope(envelope));
|
|
152
88
|
await prima.stop().catch(() => {});
|
|
@@ -167,46 +103,48 @@ async function runBrowser(options: any, run: (prima: Prima) => Promise<boolean>)
|
|
|
167
103
|
|
|
168
104
|
export function createPrimaCommands(name = 'prima'): Command {
|
|
169
105
|
const cmd = new Command(name);
|
|
170
|
-
cmd.description('
|
|
106
|
+
cmd.description('Drives a web app through described behaviour, not locators');
|
|
171
107
|
cmd.option('--pw-session <title>', 'Title of the playwright-cli session to attach to');
|
|
172
108
|
cmd.option('--url <url>', 'Page to open when the session has no page yet');
|
|
173
109
|
cmd.addHelpText('after', `\n${helpContract}`);
|
|
174
110
|
rootOptions = () => cmd.opts();
|
|
175
111
|
|
|
176
|
-
|
|
112
|
+
addBrowserOptions(cmd.command('pw <fn>').description('Run a Playwright function expression against the open page')).action(async (fn, options) => {
|
|
177
113
|
await runPrima(options, `pw ${fn}`, (prima) => prima.pw(fn));
|
|
178
114
|
});
|
|
179
115
|
|
|
180
|
-
|
|
116
|
+
addBrowserOptions(cmd.command('do <instructions...>').description('Run instructions, one argument each'))
|
|
181
117
|
.addHelpText('after', `\n${doHelp}`)
|
|
182
118
|
.action(async (instructions, options) => {
|
|
183
119
|
await runPrima(options, `do ${instructions.join(' ')}`, (prima) => prima.do(instructions));
|
|
184
120
|
});
|
|
185
121
|
|
|
186
|
-
|
|
122
|
+
addBrowserOptions(cmd.command('check <scenario>').description('Run a scenario as a test, with verification'))
|
|
187
123
|
.option('--expected <outcome>', 'An outcome the run must reach; repeat the flag for several', (value: string, all: string[]) => [...all, value], [])
|
|
188
124
|
.addHelpText('after', `\n${checkHelp}`)
|
|
189
125
|
.action(async (scenario, options) => {
|
|
190
126
|
await runPrima(options, `check ${scenario}`, (prima) => prima.check(scenario, options.expected));
|
|
191
127
|
});
|
|
192
128
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
129
|
+
addBrowserOptions(cmd.command('ask <question>').description('Answer a question about the current page').option('--no-vision', 'Answer from page structure only, without a screenshot'))
|
|
130
|
+
.addHelpText('after', `\n${askHelp}`)
|
|
131
|
+
.action(async (question, options) => {
|
|
132
|
+
await runPrima(options, `ask ${question}`, (prima) => prima.ask(question));
|
|
133
|
+
});
|
|
196
134
|
|
|
197
|
-
|
|
135
|
+
addBrowserOptions(cmd.command('verify <assertion>').alias('assert').description('Assert a statement about the current page'))
|
|
198
136
|
.addHelpText('after', `\n${verifyHelp}`)
|
|
199
137
|
.action(async (assertion, options) => {
|
|
200
138
|
await runPrima(options, `verify ${assertion}`, (prima) => prima.verify(assertion));
|
|
201
139
|
});
|
|
202
140
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
141
|
+
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'))
|
|
142
|
+
.addHelpText('after', `\n${researchHelp}`)
|
|
143
|
+
.action(async (options) => {
|
|
144
|
+
await runPrima(options, 'research', (prima) => prima.research({ data: options.data, deep: options.deep, fresh: options.fresh }));
|
|
145
|
+
});
|
|
208
146
|
|
|
209
|
-
|
|
147
|
+
addBrowserOptions(cmd.command('go <target>').description('Navigate to a url, a path, or a page described in plain words')).action(async (target, options) => {
|
|
210
148
|
if (URL.canParse(target)) options.baseUrl = target;
|
|
211
149
|
await runPrima(options, `go ${target}`, (prima) => prima.go(target));
|
|
212
150
|
});
|
|
@@ -226,11 +164,7 @@ export function createPrimaCommands(name = 'prima'): Command {
|
|
|
226
164
|
addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
|
|
227
165
|
.addHelpText('after', `\n${statusHelp}`)
|
|
228
166
|
.action(async (hash, options) => {
|
|
229
|
-
|
|
230
|
-
const prima = primaFor(options);
|
|
231
|
-
const envelope = await prima.status(hash).catch((error: unknown) => prima.toolFailureEnvelope(`status ${hash}`, error));
|
|
232
|
-
console.log(renderEnvelope(envelope));
|
|
233
|
-
process.exit(envelope.ok ? 0 : 1);
|
|
167
|
+
await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), { browser: false, record: false });
|
|
234
168
|
});
|
|
235
169
|
|
|
236
170
|
addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report'))
|
|
@@ -247,7 +181,7 @@ export function createPrimaCommands(name = 'prima'): Command {
|
|
|
247
181
|
|
|
248
182
|
const browser = cmd.command('browser').description('Manage the browsers prima drives');
|
|
249
183
|
|
|
250
|
-
|
|
184
|
+
addBrowserOptions(browser.command('start').description('Start a prima-owned browser and hold it open until Ctrl+C'))
|
|
251
185
|
.option('-s, --show', 'Launch the browser in a visible window')
|
|
252
186
|
.option('--headless', 'Launch the browser without a window')
|
|
253
187
|
.action(async (options) => {
|
|
@@ -258,7 +192,7 @@ export function createPrimaCommands(name = 'prima'): Command {
|
|
|
258
192
|
});
|
|
259
193
|
});
|
|
260
194
|
|
|
261
|
-
|
|
195
|
+
addBrowserOptions(browser.command('stop').description('Stop the browser of this instance'))
|
|
262
196
|
.option('--all', 'Stop every running instance')
|
|
263
197
|
.action(async (options) => {
|
|
264
198
|
await runBrowser(options, async (prima) => {
|
|
@@ -268,14 +202,14 @@ export function createPrimaCommands(name = 'prima'): Command {
|
|
|
268
202
|
});
|
|
269
203
|
});
|
|
270
204
|
|
|
271
|
-
|
|
205
|
+
addBrowserOptions(browser.command('status').description('Report the browser of this instance')).action(async (options) => {
|
|
272
206
|
await runBrowser(options, async (prima) => {
|
|
273
207
|
console.log(await prima.browserStatus());
|
|
274
208
|
return true;
|
|
275
209
|
});
|
|
276
210
|
});
|
|
277
211
|
|
|
278
|
-
|
|
212
|
+
addBrowserOptions(browser.command('list').description('List every browser instance that is running')).action(async (options) => {
|
|
279
213
|
await runBrowser(options, async (prima) => {
|
|
280
214
|
console.log(await prima.browserList());
|
|
281
215
|
return true;
|
|
@@ -5,6 +5,8 @@ export const STATUS_FILE = 'status.json';
|
|
|
5
5
|
|
|
6
6
|
const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' };
|
|
7
7
|
|
|
8
|
+
export const STEP_FILES = { aria: 'aria.yaml', html: 'html', diff: 'diff.yaml' };
|
|
9
|
+
|
|
8
10
|
const EXPECTATION_LABELS = {
|
|
9
11
|
passed: 'PASSED ',
|
|
10
12
|
failed: 'FAILED ',
|
|
@@ -117,7 +119,7 @@ function renderSteps(data: EnvelopeData): string | null {
|
|
|
117
119
|
lines.push(`${index + 1}. ${mark} ${step.label}`);
|
|
118
120
|
for (const line of (step.proof || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
|
|
119
121
|
});
|
|
120
|
-
if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{
|
|
122
|
+
if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{${Object.values(STEP_FILES).join(',')}}`);
|
|
121
123
|
return section('Steps', lines.join('\n'));
|
|
122
124
|
}
|
|
123
125
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import dedent from 'dedent';
|
|
2
|
+
|
|
3
|
+
export const helpContract = dedent`
|
|
4
|
+
Prima drives the browser opened by playwright-cli.
|
|
5
|
+
|
|
6
|
+
playwright-cli open <url> starts the session
|
|
7
|
+
prima <command> ... drives it
|
|
8
|
+
playwright-cli close ends it
|
|
9
|
+
|
|
10
|
+
One call runs a whole job:
|
|
11
|
+
|
|
12
|
+
prima check "a workflow can be created and appears in the list" --expected "the new workflow is listed"
|
|
13
|
+
prima do "open the account menu" "choose the settings entry" "switch the theme to dark" "check it took effect"
|
|
14
|
+
prima pw "({ page }) => page.click('[data-test=submit]')"
|
|
15
|
+
|
|
16
|
+
Only research maps a page. Other commands read the accessibility tree; a cached
|
|
17
|
+
research map joins when present. On large or unclear pages, run prima research first.
|
|
18
|
+
Without a usable AI model only pw works; otherwise use playwright-cli.
|
|
19
|
+
DEBUG='explorbot:*' in front of a command logs everything it does.
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export const checkHelp = dedent`
|
|
23
|
+
check states the outcome, not the clicks; it finds the path itself. It stays
|
|
24
|
+
on the current page and never reloads it, so an open dialog survives.
|
|
25
|
+
--expected one required outcome, repeatable for several. Without it the
|
|
26
|
+
scenario text is the outcome. Each returns under
|
|
27
|
+
### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
|
|
28
|
+
"not verified" means never checked, not false.
|
|
29
|
+
Proof is a full-page screenshot: what a user sees counts, the log only shows actions.
|
|
30
|
+
CONTRADICTION means screenshot and log disagree; judge the html, aria and
|
|
31
|
+
screenshot under ### Artifacts yourself.
|
|
32
|
+
ok is false when any outcome FAILED or CONTRADICTED, or the run could not finish,
|
|
33
|
+
reported as such rather than as an app failure.
|
|
34
|
+
Side issues found on the way go under ### Answer, not as step failures.
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export const doHelp = dedent`
|
|
38
|
+
### Steps marks each instruction ok, FAIL or ??. ?? means it ran but the run ended
|
|
39
|
+
without confirming it - read the steps above. Only FAIL fails the command.
|
|
40
|
+
Nothing runs past the last instruction. Batch the whole sequence in one call;
|
|
41
|
+
that is what keeps this tier cheap.
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
export const askHelp = dedent`
|
|
45
|
+
Answers from a page screenshot, or from its structure with --no-vision.
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
export const verifyHelp = dedent`
|
|
49
|
+
Reports each expressible assertion as PASSED or FAILED with its playwright form;
|
|
50
|
+
no overall verdict, read the lines. "none ran" means unexpressible, not false.
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
export const researchHelp = dedent`
|
|
54
|
+
The map is saved per page state and joins later commands there, so one research run pays for all that follow it.
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
export const statusHelp = dedent`
|
|
58
|
+
Reads recorded files, so it needs no browser and outlives the session.
|
|
59
|
+
The hash is matched across all recorded sites. ### Artifacts lists every kept
|
|
60
|
+
file: aria, html, screenshot and network log when captured, plus per-step captures of a do run.
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
export const reportHelp = dedent`
|
|
64
|
+
Built from the command log, so it needs no browser and outlives the session.
|
|
65
|
+
Reports the latest session unless --pw-session names another.
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
export const sessionHelp = dedent`
|
|
69
|
+
Parallel jobs need one --instance each. --session is ignored when attached,
|
|
70
|
+
since the attached session keeps its own. --framework is parsed but inactive;
|
|
71
|
+
reported code is CodeceptJS either way.
|
|
72
|
+
`;
|