explorbot 0.2.3 → 0.2.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/README.md +1 -1
- package/bin/explorbot-cli.ts +26 -8
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/api-tester/src/config.ts +4 -2
- package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
- package/boat/doc-collector/src/ai/documentarian.ts +61 -31
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/doc-collector/src/config.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +0 -0
- package/boat/prima/src/activity-line.ts +33 -0
- package/boat/prima/src/cli.ts +127 -86
- package/boat/prima/src/envelope.ts +102 -52
- package/boat/prima/src/prima.ts +567 -128
- package/boat/prima/src/pw-parser.ts +11 -1
- package/boat/prima/src/pw-registry.ts +4 -5
- package/boat/prima/src/session-log.ts +126 -0
- package/dist/bin/explorbot-cli.js +26 -8
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/api-tester/src/config.js +4 -2
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/doc-collector/src/config.js +4 -2
- package/dist/boat/prima/src/activity-line.js +30 -0
- package/dist/boat/prima/src/cli.js +109 -77
- package/dist/boat/prima/src/envelope.js +94 -44
- package/dist/boat/prima/src/prima.js +533 -119
- package/dist/boat/prima/src/pw-parser.js +13 -1
- package/dist/boat/prima/src/pw-registry.js +4 -5
- package/dist/boat/prima/src/session-log.js +108 -0
- package/dist/package.json +3 -2
- package/dist/rules/navigator/verification-actions.md +20 -0
- package/dist/src/action-result.d.ts +7 -0
- package/dist/src/action-result.js +4 -0
- package/dist/src/action.d.ts +2 -0
- package/dist/src/action.js +41 -2
- package/dist/src/ai/captain/web-mode.js +6 -3
- package/dist/src/ai/captain.js +2 -0
- package/dist/src/ai/navigator.d.ts +34 -0
- package/dist/src/ai/navigator.js +237 -181
- package/dist/src/ai/pilot.d.ts +7 -0
- package/dist/src/ai/pilot.js +90 -2
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +14 -23
- package/dist/src/ai/rerunner.js +2 -1
- package/dist/src/ai/researcher/cache.d.ts +2 -0
- package/dist/src/ai/researcher/cache.js +10 -2
- package/dist/src/ai/researcher.js +3 -2
- package/dist/src/ai/rules.js +17 -10
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/task-agent.js +4 -1
- package/dist/src/ai/tester.d.ts +6 -3
- package/dist/src/ai/tester.js +50 -46
- package/dist/src/ai/tools.d.ts +14 -0
- package/dist/src/ai/tools.js +117 -37
- package/dist/src/commands/config-command.d.ts +51 -0
- package/dist/src/commands/config-command.js +117 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/config.d.ts +9 -1
- package/dist/src/config.js +53 -4
- package/dist/src/execution-controller.d.ts +2 -0
- package/dist/src/execution-controller.js +6 -0
- package/dist/src/explorbot.d.ts +2 -1
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +2 -3
- package/dist/src/playwright-recorder.js +30 -0
- package/dist/src/remote.d.ts +55 -0
- package/dist/src/remote.js +235 -0
- package/dist/src/reporter.d.ts +1 -0
- package/dist/src/reporter.js +7 -1
- package/dist/src/state-manager.d.ts +2 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/stats.d.ts +1 -0
- package/dist/src/stats.js +1 -0
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +26 -0
- package/dist/src/utils/aria.d.ts +2 -8
- package/dist/src/utils/aria.js +69 -40
- package/dist/src/utils/html.js +1 -0
- package/dist/src/utils/logger.d.ts +7 -1
- package/dist/src/utils/logger.js +32 -0
- package/dist/src/utils/page-readiness.js +18 -1
- package/dist/src/utils/url-matcher.js +3 -0
- package/dist/src/utils/web-element.d.ts +2 -0
- package/dist/src/utils/web-element.js +8 -0
- package/dist/src/utils/web-sandbox.d.ts +1 -1
- package/dist/src/utils/web-sandbox.js +2 -3
- package/docs/api-testing/basics.md +90 -0
- package/docs/api-testing/planning.md +57 -0
- package/docs/api-testing/running-tests.md +55 -0
- package/docs/assets/cloud-report.png +0 -0
- package/docs/assets/html-report.png +0 -0
- package/docs/assets/langfuse-trace.png +0 -0
- package/docs/assets/successful-explore-run.png +0 -0
- package/docs/basics/getting-started.md +140 -0
- package/docs/basics/prerequisites.md +63 -0
- package/docs/basics/providers.md +362 -0
- package/docs/basics/running.md +78 -0
- package/docs/contributing/ai-integration-tests.md +57 -0
- package/docs/contributing/contributing.md +90 -0
- package/docs/contributing/demo-videos.md +36 -0
- package/docs/contributing/npm-package.md +138 -0
- package/docs/contributing/observability.md +227 -0
- package/docs/contributing/regression-tests.md +103 -0
- package/docs/contributing/testing.md +95 -0
- package/docs/doc-collection/basics.md +128 -0
- package/docs/doc-collection/crawling.md +67 -0
- package/docs/doc-collection/interactive-mode.md +99 -0
- package/docs/index.json +87 -0
- package/docs/reference/commands.md +997 -0
- package/docs/reference/configuration.md +569 -0
- package/docs/reference/scripting.md +303 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
- package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
- package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
- package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
- package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
- package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
- package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/docs/web-testing/agents.md +158 -0
- package/docs/web-testing/automated-tests.md +134 -0
- package/docs/web-testing/basics.md +91 -0
- package/docs/web-testing/customization.md +131 -0
- package/docs/web-testing/hooks.md +238 -0
- package/docs/web-testing/page-interaction.md +84 -0
- package/docs/web-testing/planner.md +122 -0
- package/docs/web-testing/rerun.md +164 -0
- package/docs/web-testing/researcher.md +380 -0
- package/docs/workflow/agentic-usage.md +233 -0
- package/docs/workflow/application-spec.md +73 -0
- package/docs/workflow/ci.md +202 -0
- package/docs/workflow/knowledge.md +310 -0
- package/docs/workflow/planning-styles.md +67 -0
- package/docs/workflow/reporting.md +133 -0
- package/docs/workflow/test-plans.md +90 -0
- package/package.json +3 -2
- package/rules/navigator/verification-actions.md +20 -0
- package/src/action-result.ts +11 -0
- package/src/action.ts +43 -3
- package/src/ai/captain/web-mode.ts +6 -3
- package/src/ai/captain.ts +3 -0
- package/src/ai/navigator.ts +255 -186
- package/src/ai/pilot.ts +104 -2
- package/src/ai/provider.ts +14 -24
- package/src/ai/rerunner.ts +2 -1
- package/src/ai/researcher/cache.ts +12 -2
- package/src/ai/researcher.ts +3 -2
- package/src/ai/rules.ts +17 -10
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/task-agent.ts +3 -1
- package/src/ai/tester.ts +52 -45
- package/src/ai/tools.ts +136 -37
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +60 -5
- package/src/execution-controller.ts +8 -0
- package/src/explorbot.ts +7 -3
- package/src/explorer.ts +2 -2
- package/src/playwright-recorder.ts +23 -0
- package/src/remote.ts +244 -0
- package/src/reporter.ts +7 -1
- package/src/state-manager.ts +6 -2
- package/src/stats.ts +1 -0
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +65 -45
- package/src/utils/html.ts +1 -0
- package/src/utils/logger.ts +33 -2
- package/src/utils/page-readiness.ts +24 -1
- package/src/utils/url-matcher.ts +3 -0
- package/src/utils/web-element.ts +9 -0
- package/src/utils/web-sandbox.ts +3 -4
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
# Prima Boat Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Ship `boat/prima` — an intent-level browser driver CLI (`explorbot prima ...`) that lets an orchestrating agent act on pages via Playwright calls or natural language while explorbot's cheap models handle perception, healing, and evidence.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A boat following the `boat/doc-collector` pattern: `Prima` class wraps `ExplorBot`, reuses Navigator/Researcher/Explorer/StateManager, adds a pure envelope renderer and a pw function wrapper. Core changes are minimal: named browser-server instances, a Navigator heal-attempt hook, and a global-config/per-host-state ladder in config loading.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript on Bun (Node.js supported through the package build step), commander, CodeceptJS/Playwright via Explorer, `bun:test`, Biome.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-01-prima-boat-design.md` — read it first.
|
|
12
|
+
|
|
13
|
+
**Execution model:** Run implementation subagents on Opus (user directive).
|
|
14
|
+
|
|
15
|
+
## Global Constraints
|
|
16
|
+
|
|
17
|
+
- Tests via `bun:test`; keep sources compatible with the existing package build step (`dist/` output runs on Node.js, `bun` condition points at `src/` — see `docs/contributing/npm-package.md`).
|
|
18
|
+
- No code comments unless explicitly specified; premature exit over if/else; no ternary operators; no `...(cond ? {} : {})` spreads; `?.` over `&&` chains; private methods after public; types at end of file; `dedent` for prompts.
|
|
19
|
+
- Prompts and tool descriptions must be GENERAL — never encode a specific failing example.
|
|
20
|
+
- Business logic lives in the `Prima` class / agents; CLI handlers only parse options and delegate (repo rule).
|
|
21
|
+
- Run `bun run format` after each code change; `bun run lint:fix` after big ones.
|
|
22
|
+
- Clean stdout: respect `EXPLORBOT_NO_BANNER`; envelope output goes to `console.log`, logs go through `tag()` logger.
|
|
23
|
+
- DO NOT duplicate existing code — reuse `src/utils/aria.ts`, `src/action-result.ts`, `src/browser-server.ts`, Historian converters.
|
|
24
|
+
|
|
25
|
+
## File Structure
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
boat/prima/
|
|
29
|
+
├── package.json # name "prima", bin prima
|
|
30
|
+
├── bin/prima-cli.ts # standalone CLI entry
|
|
31
|
+
├── src/
|
|
32
|
+
│ ├── cli.ts # createPrimaCommands(name = 'prima')
|
|
33
|
+
│ ├── prima.ts # Prima class (all business logic)
|
|
34
|
+
│ ├── envelope.ts # EnvelopeData type, renderEnvelope(), writeArtifacts()
|
|
35
|
+
│ └── pw-parser.ts # isFunctionExpression(), toCodeceptWrapper()
|
|
36
|
+
└── tests/
|
|
37
|
+
├── envelope.test.ts
|
|
38
|
+
├── pw-parser.test.ts
|
|
39
|
+
└── prima.test.ts # duck-typed ExplorBot mocks
|
|
40
|
+
Core modifications:
|
|
41
|
+
├── src/browser-server.ts # named instances (endpoint file per instance)
|
|
42
|
+
├── src/ai/navigator.ts # resolveState onAttempt hook (small)
|
|
43
|
+
├── src/config.ts # global config + global .env + per-host state dir
|
|
44
|
+
└── bin/explorbot-cli.ts # program.addCommand(createPrimaCommands('prima'))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
### Task 1: Envelope module
|
|
50
|
+
|
|
51
|
+
**Files:**
|
|
52
|
+
- Create: `boat/prima/package.json`, `boat/prima/src/envelope.ts`
|
|
53
|
+
- Test: `boat/prima/tests/envelope.test.ts`
|
|
54
|
+
|
|
55
|
+
**Interfaces:**
|
|
56
|
+
- Produces (used by Tasks 3–7 and 9):
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
export interface InstanceInfo {
|
|
60
|
+
name: string;
|
|
61
|
+
tabs: number;
|
|
62
|
+
startedAgo?: string;
|
|
63
|
+
attached?: string;
|
|
64
|
+
others: Array<{ name: string; tabs: number }>;
|
|
65
|
+
}
|
|
66
|
+
export interface HealAttempt {
|
|
67
|
+
code: string;
|
|
68
|
+
outcome: string;
|
|
69
|
+
}
|
|
70
|
+
export interface EnvelopeData {
|
|
71
|
+
ok: boolean;
|
|
72
|
+
command: string;
|
|
73
|
+
healed?: boolean;
|
|
74
|
+
healNote?: string;
|
|
75
|
+
used?: string[];
|
|
76
|
+
page: { url: string; previousUrl?: string; title: string; state: string; visits: number };
|
|
77
|
+
changes?: string | null;
|
|
78
|
+
answer?: string;
|
|
79
|
+
research?: string;
|
|
80
|
+
verdict?: { passed: boolean; evidence: string; code: string };
|
|
81
|
+
failure?: { error: string; attempts: HealAttempt[]; reasoning?: string; compactAria?: string };
|
|
82
|
+
instance: InstanceInfo;
|
|
83
|
+
artifacts?: { aria: string; html: string; network: string };
|
|
84
|
+
}
|
|
85
|
+
export function renderEnvelope(data: EnvelopeData): string;
|
|
86
|
+
export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; requests: unknown[] }): { aria: string; html: string; network: string };
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- [ ] **Step 1: Scaffold the boat package**
|
|
90
|
+
|
|
91
|
+
`boat/prima/package.json` (mirror `boat/api-tester/package.json` shape):
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"name": "prima",
|
|
96
|
+
"version": "1.0.0",
|
|
97
|
+
"description": "High-level browser driver CLI for orchestrating agents",
|
|
98
|
+
"type": "module",
|
|
99
|
+
"bin": { "prima": "./bin/prima-cli.ts" },
|
|
100
|
+
"scripts": {
|
|
101
|
+
"format": "biome format --write .",
|
|
102
|
+
"lint:fix": "biome lint --write .",
|
|
103
|
+
"check:fix": "biome check --write ."
|
|
104
|
+
},
|
|
105
|
+
"dependencies": {
|
|
106
|
+
"commander": "^14.0.1",
|
|
107
|
+
"dedent": "^1.6.0"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- [ ] **Step 2: Write failing envelope tests**
|
|
113
|
+
|
|
114
|
+
`boat/prima/tests/envelope.test.ts`:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
import { describe, expect, test } from 'bun:test';
|
|
118
|
+
import { mkdtempSync } from 'node:fs';
|
|
119
|
+
import { tmpdir } from 'node:os';
|
|
120
|
+
import path from 'node:path';
|
|
121
|
+
import { readFileSync } from 'node:fs';
|
|
122
|
+
import { type EnvelopeData, renderEnvelope, writeArtifacts } from '../src/envelope.ts';
|
|
123
|
+
|
|
124
|
+
const base: EnvelopeData = {
|
|
125
|
+
ok: true,
|
|
126
|
+
command: "pw ({ page }) => page.click('text=Login')",
|
|
127
|
+
used: ["I.click('Login')"],
|
|
128
|
+
page: { url: 'https://app.example.com/dashboard', previousUrl: 'https://app.example.com/login', title: 'Dashboard', state: 'dashboard_h1_dashboard', visits: 1 },
|
|
129
|
+
changes: 'ariaDiff:\n added:\n - heading "Dashboard"',
|
|
130
|
+
instance: { name: 'default', tabs: 3, startedAgo: '12m', others: [{ name: 'auth-test', tabs: 1 }] },
|
|
131
|
+
artifacts: { aria: '/tmp/x/aria.yml', html: '/tmp/x/page.html', network: '/tmp/x/network.jsonl' },
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
describe('renderEnvelope', () => {
|
|
135
|
+
test('success envelope contains all sections in order', () => {
|
|
136
|
+
const out = renderEnvelope(base);
|
|
137
|
+
const sections = ['### Result', '### Page', '### Changes', '### Instance', '### Artifacts'];
|
|
138
|
+
const positions = sections.map((s) => out.indexOf(s));
|
|
139
|
+
expect(positions.every((p) => p >= 0)).toBe(true);
|
|
140
|
+
expect([...positions].sort((a, b) => a - b)).toEqual(positions);
|
|
141
|
+
expect(out).toContain('ok: true');
|
|
142
|
+
expect(out).toContain("used: I.click('Login')");
|
|
143
|
+
expect(out).toContain('(changed: https://app.example.com/login → https://app.example.com/dashboard)');
|
|
144
|
+
expect(out).toContain('instance: default (3 tabs) | other instances: auth-test (1 tab)');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('unchanged url renders without changed marker', () => {
|
|
148
|
+
const out = renderEnvelope({ ...base, page: { ...base.page, previousUrl: base.page.url } });
|
|
149
|
+
expect(out).not.toContain('(changed:');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('failure envelope renders attempts, reasoning and compact aria', () => {
|
|
153
|
+
const out = renderEnvelope({
|
|
154
|
+
...base,
|
|
155
|
+
ok: false,
|
|
156
|
+
failure: {
|
|
157
|
+
error: "locator 'text=Login' not found",
|
|
158
|
+
attempts: [{ code: "I.click('Login')", outcome: 'not visible' }, { code: 'scroll + retry', outcome: 'covered by cookie banner' }],
|
|
159
|
+
reasoning: 'element hidden behind consent overlay',
|
|
160
|
+
compactAria: '- button "Accept all"',
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
expect(out).toContain('### Failure');
|
|
164
|
+
expect(out).toContain('### Healing attempts (2)');
|
|
165
|
+
expect(out).toContain("1. I.click('Login')");
|
|
166
|
+
expect(out).toContain('→ not visible');
|
|
167
|
+
expect(out).toContain('### Current page (compact ARIA)');
|
|
168
|
+
expect(out).toContain('- button "Accept all"');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('answer replaces changes for ask', () => {
|
|
172
|
+
const out = renderEnvelope({ ...base, changes: undefined, answer: 'A login form with email and password fields' });
|
|
173
|
+
expect(out).toContain('### Answer');
|
|
174
|
+
expect(out).not.toContain('### Changes');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('verdict replaces changes for verify', () => {
|
|
178
|
+
const out = renderEnvelope({ ...base, changes: undefined, verdict: { passed: true, evidence: 'heading "Dashboard" present', code: "I.see('Dashboard')" } });
|
|
179
|
+
expect(out).toContain('### Verdict');
|
|
180
|
+
expect(out).toContain('passed: true');
|
|
181
|
+
expect(out).toContain("I.see('Dashboard')");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('healed success carries note', () => {
|
|
185
|
+
const out = renderEnvelope({ ...base, healed: true, healNote: 'dismissed overlay first' });
|
|
186
|
+
expect(out).toContain('healed: true (dismissed overlay first)');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('writeArtifacts', () => {
|
|
191
|
+
test('writes aria, html and network files and returns absolute paths', () => {
|
|
192
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'prima-'));
|
|
193
|
+
const result = writeArtifacts(dir, { aria: '- button "Login"', html: '<html></html>', requests: [{ url: '/api/user', status: 200 }] });
|
|
194
|
+
expect(readFileSync(result.aria, 'utf-8')).toContain('button "Login"');
|
|
195
|
+
expect(readFileSync(result.html, 'utf-8')).toContain('<html>');
|
|
196
|
+
expect(readFileSync(result.network, 'utf-8')).toContain('/api/user');
|
|
197
|
+
expect(path.isAbsolute(result.aria)).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
- [ ] **Step 3: Run tests, verify they fail**
|
|
203
|
+
|
|
204
|
+
Run: `bun test boat/prima/tests/envelope.test.ts`
|
|
205
|
+
Expected: FAIL — cannot resolve `../src/envelope.ts`.
|
|
206
|
+
|
|
207
|
+
- [ ] **Step 4: Implement `boat/prima/src/envelope.ts`**
|
|
208
|
+
|
|
209
|
+
Pure string building. Rules:
|
|
210
|
+
- Sections always in order: Result, Page, then exactly one of Changes/Answer/Verdict (Changes only when `changes` is a non-empty string), Failure + Healing attempts + Current page (only when `failure` set), Instance, Artifacts (only when set).
|
|
211
|
+
- `healed: true (note)` on one line when healNote present, plain `healed: false` otherwise; omit line when `healed` is undefined.
|
|
212
|
+
- `used:` joins multiple codes with `; `.
|
|
213
|
+
- Page line: `url: <url> (changed: <prev> → <url>)` only when previousUrl differs.
|
|
214
|
+
- State line: `state: <state> (visit #<visits>)`.
|
|
215
|
+
- Instance line exactly as tested; `others` empty → `| other instances: none`.
|
|
216
|
+
- When `attached` is set, the browser line renders `browser: attached (<attached>)` instead of the running/started form (add a render test: `attached: 'playwright-cli session "default", workspace /w'` → contains `browser: attached (playwright-cli session "default"`).
|
|
217
|
+
- Network artifact written as JSONL (one `JSON.stringify` per request).
|
|
218
|
+
- Every attempt line: `<n>. <code>` padded, then `→ <outcome>`.
|
|
219
|
+
|
|
220
|
+
Keep it one exported function plus small private helpers below it; no classes.
|
|
221
|
+
|
|
222
|
+
- [ ] **Step 5: Run tests, verify pass**
|
|
223
|
+
|
|
224
|
+
Run: `bun test boat/prima/tests/envelope.test.ts`
|
|
225
|
+
Expected: PASS (7 tests).
|
|
226
|
+
|
|
227
|
+
- [ ] **Step 6: Format and commit**
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
bun run format
|
|
231
|
+
git add boat/prima
|
|
232
|
+
git commit -m "feat(prima): boat scaffold and result envelope"
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
### Task 2: pw function wrapper
|
|
238
|
+
|
|
239
|
+
The `pw` argument is a function expression in the exact shape `I.usePlaywrightTo` accepts — `({ page, browserContext, browser }) => ...` — so callers destructure whichever Playwright objects they need. The parser only answers "is this a parseable function expression" (for clean `tool:` errors) and interpolates it verbatim into the CodeceptJS call that `Action.execute` expects.
|
|
240
|
+
|
|
241
|
+
**Files:**
|
|
242
|
+
- Create: `boat/prima/src/pw-parser.ts`
|
|
243
|
+
- Test: `boat/prima/tests/pw-parser.test.ts`
|
|
244
|
+
|
|
245
|
+
**Interfaces:**
|
|
246
|
+
- Produces (used by Task 4):
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
export function isFunctionExpression(expr: string): { valid: boolean; error?: string };
|
|
250
|
+
export function toCodeceptWrapper(expr: string): string;
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- [ ] **Step 1: Write failing tests**
|
|
254
|
+
|
|
255
|
+
`boat/prima/tests/pw-parser.test.ts`:
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
import { describe, expect, test } from 'bun:test';
|
|
259
|
+
import { isFunctionExpression, toCodeceptWrapper } from '../src/pw-parser.ts';
|
|
260
|
+
|
|
261
|
+
describe('isFunctionExpression', () => {
|
|
262
|
+
test.each([
|
|
263
|
+
"({ page }) => page.click('text=Login')",
|
|
264
|
+
"async ({ page }) => { await page.fill('#email', 'user@example.com'); await page.keyboard.press('Enter'); }",
|
|
265
|
+
"({ browserContext }) => browserContext.clearCookies()",
|
|
266
|
+
"({ page, browser }) => browser.version()",
|
|
267
|
+
"function ({ page }) { return page.title() }",
|
|
268
|
+
])('accepts %s', (expr) => {
|
|
269
|
+
expect(isFunctionExpression(expr).valid).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test.each([
|
|
273
|
+
"page.click('text=Login')",
|
|
274
|
+
"({ page }) => page.click('a'",
|
|
275
|
+
"just some text",
|
|
276
|
+
"",
|
|
277
|
+
])('rejects %s', (expr) => {
|
|
278
|
+
const result = isFunctionExpression(expr);
|
|
279
|
+
expect(result.valid).toBe(false);
|
|
280
|
+
expect(result.error).toBeTruthy();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('toCodeceptWrapper', () => {
|
|
285
|
+
test('interpolates the function verbatim into usePlaywrightTo', () => {
|
|
286
|
+
const code = toCodeceptWrapper("({ page }) => page.click('text=Login')");
|
|
287
|
+
expect(code).toBe("I.usePlaywrightTo('pw', ({ page }) => page.click('text=Login'))");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
- [ ] **Step 2: Run tests, verify fail**
|
|
293
|
+
|
|
294
|
+
Run: `bun test boat/prima/tests/pw-parser.test.ts`
|
|
295
|
+
Expected: FAIL — module not found.
|
|
296
|
+
|
|
297
|
+
- [ ] **Step 3: Implement `boat/prima/src/pw-parser.ts`**
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
const FUNCTION_SHAPE = /^(async\s+)?(function\b|\()/;
|
|
301
|
+
|
|
302
|
+
export function isFunctionExpression(expr: string): { valid: boolean; error?: string } {
|
|
303
|
+
const trimmed = expr.trim();
|
|
304
|
+
if (!trimmed) return { valid: false, error: 'empty expression; pass a function like ({ page }) => ...' };
|
|
305
|
+
if (!FUNCTION_SHAPE.test(trimmed)) return { valid: false, error: 'expression must be a function like ({ page }) => ... destructuring the playwright objects it needs' };
|
|
306
|
+
try {
|
|
307
|
+
new Function(`return (${trimmed})`);
|
|
308
|
+
} catch (e) {
|
|
309
|
+
return { valid: false, error: `not a valid function expression: ${(e as Error).message}` };
|
|
310
|
+
}
|
|
311
|
+
return { valid: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function toCodeceptWrapper(expr: string): string {
|
|
315
|
+
return `I.usePlaywrightTo('pw', ${expr.trim()})`;
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`new Function` is construction-only — the user's code is never invoked here (invoking would execute non-function inputs). The shape regex rejects bare call chains like `page.click(...)` with an error pointing at the expected form; the construction catch turns syntax errors (unbalanced brackets, garbage text) into the `tool:` error in the envelope. Note the shape regex also requires arrow parameters to be parenthesized — acceptable since the destructured `({ page })` form is the documented contract.
|
|
320
|
+
|
|
321
|
+
- [ ] **Step 4: Run tests, verify pass**
|
|
322
|
+
|
|
323
|
+
Run: `bun test boat/prima/tests/pw-parser.test.ts`
|
|
324
|
+
Expected: PASS.
|
|
325
|
+
|
|
326
|
+
- [ ] **Step 5: Commit**
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
bun run format
|
|
330
|
+
git add boat/prima/src/pw-parser.ts boat/prima/tests/pw-parser.test.ts
|
|
331
|
+
git commit -m "feat(prima): pw function wrapper"
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
### Task 3: Named browser instances in browser-server
|
|
337
|
+
|
|
338
|
+
**Files:**
|
|
339
|
+
- Modify: `src/browser-server.ts` (exports at line 88: `readEndpoint, removeEndpointFile, isServerRunning, launchServer, getEndpointFilePath, getAliveEndpoint`)
|
|
340
|
+
- Modify: `bin/explorbot-cli.ts` browser start/stop/status handlers (lines ~736-821) to pass instance through
|
|
341
|
+
- Test: `tests/unit/browser-server-instances.test.ts`
|
|
342
|
+
|
|
343
|
+
**Interfaces:**
|
|
344
|
+
- Produces (used by Tasks 4 and 7): every exported function gains an optional trailing `instance = 'default'` parameter; endpoint file becomes `.browser-endpoint` for `default` and `.browser-endpoint-<name>` otherwise. New export:
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
export function listInstances(): Array<{ name: string; endpoint: string }>;
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
- [ ] **Step 1: Read `src/browser-server.ts` fully** — understand `getEndpointFilePath`, `writeEndpoint`, `getAliveEndpoint` before touching anything.
|
|
351
|
+
|
|
352
|
+
- [ ] **Step 2: Write failing test**
|
|
353
|
+
|
|
354
|
+
`tests/unit/browser-server-instances.test.ts`:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
import { describe, expect, test } from 'bun:test';
|
|
358
|
+
import path from 'node:path';
|
|
359
|
+
import { getEndpointFilePath } from '../../src/browser-server.ts';
|
|
360
|
+
|
|
361
|
+
describe('named instances', () => {
|
|
362
|
+
test('default instance keeps legacy filename', () => {
|
|
363
|
+
expect(path.basename(getEndpointFilePath())).toBe('.browser-endpoint');
|
|
364
|
+
expect(path.basename(getEndpointFilePath('default'))).toBe('.browser-endpoint');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('named instance gets suffixed filename', () => {
|
|
368
|
+
expect(path.basename(getEndpointFilePath('staging'))).toBe('.browser-endpoint-staging');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Add a test for `listInstances()` writing two endpoint files into a temp output dir if `getEndpointFilePath` resolves from a configurable root; if the output root comes from config at import time, keep `listInstances` scanning `path.dirname(getEndpointFilePath())` for files matching `.browser-endpoint*` and test via that dir.
|
|
374
|
+
|
|
375
|
+
- [ ] **Step 3: Run test, verify fail**
|
|
376
|
+
|
|
377
|
+
Run: `bun test tests/unit/browser-server-instances.test.ts`
|
|
378
|
+
Expected: FAIL — `getEndpointFilePath` does not accept an argument (or wrong filename).
|
|
379
|
+
|
|
380
|
+
- [ ] **Step 4: Implement**
|
|
381
|
+
|
|
382
|
+
Thread `instance = 'default'` through `getEndpointFilePath`, `readEndpoint`, `writeEndpoint`, `removeEndpointFile`, `isServerRunning`, `getAliveEndpoint`, `launchServer`. Filename: `default` → `.browser-endpoint` (backward compatible), else `.browser-endpoint-${instance}`. Sanitize instance to `[a-z0-9-]` and reject others with a thrown Error. `listInstances()` scans the endpoint dir with `readdirSync`, maps filenames back to names.
|
|
383
|
+
|
|
384
|
+
- [ ] **Step 5: Run full unit suite**
|
|
385
|
+
|
|
386
|
+
Run: `bun test tests/unit/`
|
|
387
|
+
Expected: PASS, no regressions.
|
|
388
|
+
|
|
389
|
+
- [ ] **Step 6: Commit**
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
bun run format
|
|
393
|
+
git add src/browser-server.ts tests/unit/browser-server-instances.test.ts bin/explorbot-cli.ts
|
|
394
|
+
git commit -m "feat: named browser server instances"
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
### Task 4: Prima class core — lifecycle and pw command
|
|
400
|
+
|
|
401
|
+
**Files:**
|
|
402
|
+
- Create: `boat/prima/src/prima.ts`
|
|
403
|
+
- Test: `boat/prima/tests/prima.test.ts`
|
|
404
|
+
|
|
405
|
+
**Interfaces:**
|
|
406
|
+
- Consumes: `renderEnvelope`/`writeArtifacts`/`EnvelopeData`/`InstanceInfo` (Task 1), `isFunctionExpression`/`toCodeceptWrapper` (Task 2), `getAliveEndpoint(instance)`/`launchServer` (Task 3), `ExplorBot` API (`src/explorbot.ts`: `start()`, `stop()`, `visit(url)`, `getExplorer()`, `stateManager()`, `getCurrentState()`, `agentNavigator()`, `agentResearcher()`, `agentHistorian()`, `requestStore()`), `Explorer.action(): Action` (`src/explorer.ts:151`), `Action.execute(code)` / `Action.capturePageState()` (`src/action.ts:64-68`), `ActionResult` (`getStateHash()`, `ariaSnapshot`, `combinedHtml()`, `url`, `title`), `compactAriaSnapshot` (`src/utils/aria.ts`), `Diff`/`PageDiff` via `ActionResult` (`src/action-result.ts`).
|
|
407
|
+
- Produces (used by Tasks 5–7, 9):
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
export interface PrimaOptions {
|
|
411
|
+
verbose?: boolean;
|
|
412
|
+
config?: string;
|
|
413
|
+
path?: string;
|
|
414
|
+
instance?: string;
|
|
415
|
+
session?: string;
|
|
416
|
+
heal?: boolean;
|
|
417
|
+
ephemeral?: boolean;
|
|
418
|
+
framework?: 'codeceptjs' | 'playwright';
|
|
419
|
+
noVision?: boolean;
|
|
420
|
+
url?: string;
|
|
421
|
+
}
|
|
422
|
+
export class Prima {
|
|
423
|
+
constructor(options?: PrimaOptions);
|
|
424
|
+
async start(): Promise<void>;
|
|
425
|
+
async stop(): Promise<void>;
|
|
426
|
+
async pw(expression: string): Promise<EnvelopeData>;
|
|
427
|
+
async instanceInfo(): Promise<InstanceInfo>;
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
- [ ] **Step 1: Study `boat/doc-collector/src/docbot.ts`** — the Prima mirrors how DocBot wraps ExplorBot (constructor builds `new ExplorBot({...})`, `start()` boots it, agents accessed lazily).
|
|
432
|
+
|
|
433
|
+
- [ ] **Step 2: Write failing tests with duck-typed mocks**
|
|
434
|
+
|
|
435
|
+
`boat/prima/tests/prima.test.ts` — follow the duck-type-mock style of `tests/integration/` (mock Explorer/StateManager, no real browser):
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
import { describe, expect, test } from 'bun:test';
|
|
439
|
+
import { Prima } from '../src/prima.ts';
|
|
440
|
+
|
|
441
|
+
function fakeState(over: Record<string, unknown> = {}) {
|
|
442
|
+
return {
|
|
443
|
+
url: 'https://app.example.com/login',
|
|
444
|
+
title: 'Login',
|
|
445
|
+
getStateHash: () => 'login_h1_login',
|
|
446
|
+
ariaSnapshot: '- textbox "Email"\n- button "Sign in"',
|
|
447
|
+
combinedHtml: () => '<form></form>',
|
|
448
|
+
...over,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function fakePrima() {
|
|
453
|
+
const prima = new Prima({ instance: 'default' });
|
|
454
|
+
const executed: string[] = [];
|
|
455
|
+
const after = fakeState({ url: 'https://app.example.com/dashboard', title: 'Dashboard', getStateHash: () => 'dashboard_h1_dashboard' });
|
|
456
|
+
(prima as any).bot = {
|
|
457
|
+
getExplorer: () => ({
|
|
458
|
+
action: () => ({
|
|
459
|
+
execute: async (code: string) => {
|
|
460
|
+
executed.push(code);
|
|
461
|
+
return { actionResult: after, lastError: null };
|
|
462
|
+
},
|
|
463
|
+
}),
|
|
464
|
+
capture: async () => after,
|
|
465
|
+
}),
|
|
466
|
+
stateManager: () => ({
|
|
467
|
+
getCurrentState: () => fakeState(),
|
|
468
|
+
getVisitCount: () => 1,
|
|
469
|
+
}),
|
|
470
|
+
requestStore: () => ({ getRequests: () => [] }),
|
|
471
|
+
};
|
|
472
|
+
(prima as any).artifactsDir = '/tmp/prima-test';
|
|
473
|
+
return { prima, executed };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
describe('Prima.pw', () => {
|
|
477
|
+
test('rejects non-function argument as tool error without executing', async () => {
|
|
478
|
+
const { prima, executed } = fakePrima();
|
|
479
|
+
const envelope = await prima.pw("page.click('text=Login')");
|
|
480
|
+
expect(envelope.ok).toBe(false);
|
|
481
|
+
expect(envelope.failure?.error).toContain('function');
|
|
482
|
+
expect(executed.length).toBe(0);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('executes wrapped function and returns success envelope data', async () => {
|
|
486
|
+
const { prima, executed } = fakePrima();
|
|
487
|
+
const envelope = await prima.pw("({ page }) => page.click('text=Login')");
|
|
488
|
+
expect(executed[0]).toContain("I.usePlaywrightTo");
|
|
489
|
+
expect(envelope.ok).toBe(true);
|
|
490
|
+
expect(envelope.used).toEqual(["({ page }) => page.click('text=Login')"]);
|
|
491
|
+
expect(envelope.page.url).toBe('https://app.example.com/dashboard');
|
|
492
|
+
expect(envelope.page.previousUrl).toBe('https://app.example.com/login');
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
- [ ] **Step 3: Run tests, verify fail**
|
|
498
|
+
|
|
499
|
+
Run: `bun test boat/prima/tests/prima.test.ts`
|
|
500
|
+
Expected: FAIL — module not found.
|
|
501
|
+
|
|
502
|
+
- [ ] **Step 4: Implement Prima core**
|
|
503
|
+
|
|
504
|
+
`boat/prima/src/prima.ts` responsibilities in this task:
|
|
505
|
+
- Constructor stores options, builds `ExplorBot` options (`config`, `path`, `verbose`, `session`, `headless: true`) — but do NOT boot in constructor (DocBot pattern).
|
|
506
|
+
- `start()`: resolve instance endpoint via `getAliveEndpoint(this.options.instance ?? 'default')`; when absent, throw an Error instructing the caller to create a session first — start one with playwright-cli (`playwright-cli open <url>`) or `prima browser start` (the CLI renders it as a `tool:` failure envelope, exit 1). NEVER launch a browser implicitly. Then `await this.bot.start()`. When `options.url` is set and no current state exists, `await this.bot.visit(options.url)`. (Task 11 retrofits the playwright-cli attach ladder in front of this owned-instance path — keep the owned-instance lookup in a private method Task 11 calls late in its ladder.)
|
|
507
|
+
- `pw(expression)`:
|
|
508
|
+
1. `isFunctionExpression` — invalid → return tool-error envelope (`ok: false`, `failure.error` prefixed `tool:`), never execute.
|
|
509
|
+
2. Capture `before = stateManager.getCurrentState()`.
|
|
510
|
+
3. `const action = explorer.action(); await action.execute(toCodeceptWrapper(expression))`.
|
|
511
|
+
4. On success build `EnvelopeData` with `used: [expression]`, page block from resulting `ActionResult` (`previousUrl` from `before`), `changes` from the pageDiff ariaChanges the Action pipeline computed (see `ActionResult.toToolResult` usage in `src/ai/tools.ts:1122` for how diffs are obtained — reuse the same path, do not recompute).
|
|
512
|
+
5. Write artifacts via `writeArtifacts(this.nextArtifactDir(), { aria: result.ariaSnapshot, html: result.combinedHtml(), requests: this.bot.requestStore().getRequests() })`.
|
|
513
|
+
6. On execution error: this task returns a plain failure envelope (heal comes in Task 5).
|
|
514
|
+
- `instanceInfo()`: name from options; tabs from `explorer` playwright context pages count (add a small public accessor if none exists — check `src/explorer.ts:79` `playwrightHelper?.page`); others from `listInstances()` (Task 3) excluding self; tabs for others may be reported as 0 when unreachable — do not connect to other instances.
|
|
515
|
+
- `nextArtifactDir()`: `<output>/prima/<ISO-timestamp>/`, one per command invocation.
|
|
516
|
+
|
|
517
|
+
Mockability rule: everything the tests stub lives behind `this.bot` — keep all ExplorBot access via that single field.
|
|
518
|
+
|
|
519
|
+
- [ ] **Step 5: Run tests, verify pass**
|
|
520
|
+
|
|
521
|
+
Run: `bun test boat/prima/tests/`
|
|
522
|
+
Expected: PASS.
|
|
523
|
+
|
|
524
|
+
- [ ] **Step 6: Commit**
|
|
525
|
+
|
|
526
|
+
```bash
|
|
527
|
+
bun run format
|
|
528
|
+
git add boat/prima
|
|
529
|
+
git commit -m "feat(prima): Prima core with pw execution and instance info"
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
### Task 5: Heal loop
|
|
535
|
+
|
|
536
|
+
**Files:**
|
|
537
|
+
- Modify: `src/ai/navigator.ts:192` (`resolveState` signature), `boat/prima/src/prima.ts`
|
|
538
|
+
- Test: `boat/prima/tests/prima.test.ts` (extend), `tests/integration/` untouched
|
|
539
|
+
|
|
540
|
+
**Interfaces:**
|
|
541
|
+
- Consumes: `navigator.resolveState(message, actionResult, opts)` (`src/ai/navigator.ts:192`).
|
|
542
|
+
- Produces: `resolveState` opts gains `onAttempt?: (attempt: { code: string; error?: string }) => void`, invoked once per executed recovery attempt with the exact code string and the error message when it failed. Prima gains private `heal(...)` used by pw (and Task 6 commands).
|
|
543
|
+
|
|
544
|
+
- [ ] **Step 1: Extend `resolveState` with the attempt hook**
|
|
545
|
+
|
|
546
|
+
Read `src/ai/navigator.ts` `resolveState` implementation; find where recovery code executes (each `action.execute`/attempt site). Add `opts.onAttempt` invocation at each attempt completion with `{ code, error: lastError?.message }`. Smallest change possible; no behavior change when the callback is absent.
|
|
547
|
+
|
|
548
|
+
- [ ] **Step 2: Write failing Prima heal test**
|
|
549
|
+
|
|
550
|
+
Extend `boat/prima/tests/prima.test.ts`; stub `agentNavigator` on the fake bot:
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
test('failed pw heals via navigator and reports healed envelope', async () => {
|
|
554
|
+
const { prima } = fakePrima();
|
|
555
|
+
(prima as any).bot.getExplorer = () => ({
|
|
556
|
+
action: () => ({
|
|
557
|
+
execute: async () => {
|
|
558
|
+
throw new Error("locator 'text=Login' not found");
|
|
559
|
+
},
|
|
560
|
+
}),
|
|
561
|
+
capture: async () => fakeState(),
|
|
562
|
+
});
|
|
563
|
+
(prima as any).bot.agentNavigator = () => ({
|
|
564
|
+
resolveState: async (_msg: string, _result: unknown, opts: any) => {
|
|
565
|
+
opts?.onAttempt?.({ code: "I.click('Login')", error: 'not visible' });
|
|
566
|
+
opts?.onAttempt?.({ code: "I.click('#login-btn')" });
|
|
567
|
+
return true;
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
const envelope = await prima.pw("({ page }) => page.click('text=Login')");
|
|
571
|
+
expect(envelope.ok).toBe(true);
|
|
572
|
+
expect(envelope.healed).toBe(true);
|
|
573
|
+
expect(envelope.used).toEqual(["I.click('#login-btn')"]);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('exhausted heal returns failure envelope with attempts and compact aria', async () => {
|
|
577
|
+
const { prima } = fakePrima();
|
|
578
|
+
(prima as any).bot.getExplorer = () => ({
|
|
579
|
+
action: () => ({
|
|
580
|
+
execute: async () => {
|
|
581
|
+
throw new Error("locator 'text=Login' not found");
|
|
582
|
+
},
|
|
583
|
+
}),
|
|
584
|
+
capture: async () => fakeState(),
|
|
585
|
+
});
|
|
586
|
+
(prima as any).bot.agentNavigator = () => ({
|
|
587
|
+
resolveState: async (_msg: string, _result: unknown, opts: any) => {
|
|
588
|
+
opts?.onAttempt?.({ code: "I.click('Login')", error: 'not visible' });
|
|
589
|
+
return false;
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
const envelope = await prima.pw("({ page }) => page.click('text=Login')");
|
|
593
|
+
expect(envelope.ok).toBe(false);
|
|
594
|
+
expect(envelope.failure?.attempts.length).toBe(1);
|
|
595
|
+
expect(envelope.failure?.compactAria).toContain('button');
|
|
596
|
+
});
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
- [ ] **Step 3: Run tests, verify fail**
|
|
600
|
+
|
|
601
|
+
Run: `bun test boat/prima/tests/prima.test.ts`
|
|
602
|
+
Expected: new tests FAIL (heal not implemented).
|
|
603
|
+
|
|
604
|
+
- [ ] **Step 4: Implement heal in Prima**
|
|
605
|
+
|
|
606
|
+
Private `heal(errorMessage, actionResult, originalCode)`:
|
|
607
|
+
- Skip entirely when `options.heal === false` — go straight to failure envelope.
|
|
608
|
+
- Skip with an envelope note (`healed: false (ai unavailable)`) when no AI provider is usable — `pw` must keep working without AI.
|
|
609
|
+
- Collect attempts array via `onAttempt`; call `navigator.resolveState(errorMessage, actionResult, { onAttempt })`.
|
|
610
|
+
- `true` → success envelope: `healed: true`, `healNote` = last attempt outcome summary, `used` = codes of successful attempts (last attempt without error), current state re-read from `stateManager.getCurrentState()`.
|
|
611
|
+
- `false` → failure envelope: `error`, `attempts` (map error→outcome, success→'ok'), `compactAria` from `compactAriaSnapshot(state.ariaSnapshot, true)` (`src/utils/aria.ts`), reasoning left to Task 8's compaction if trivial — set `reasoning` to a one-line join of distinct outcomes for now (general, not model-generated).
|
|
612
|
+
- Failure envelopes still include artifacts and instance blocks.
|
|
613
|
+
|
|
614
|
+
- [ ] **Step 5: Run boat tests and repo unit tests**
|
|
615
|
+
|
|
616
|
+
Run: `bun test boat/prima/tests/ tests/unit/`
|
|
617
|
+
Expected: PASS.
|
|
618
|
+
|
|
619
|
+
- [ ] **Step 6: Commit**
|
|
620
|
+
|
|
621
|
+
```bash
|
|
622
|
+
bun run format
|
|
623
|
+
git add src/ai/navigator.ts boat/prima
|
|
624
|
+
git commit -m "feat(prima): heal loop over navigator recovery with attempt trace"
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
|
|
629
|
+
### Task 6: do, click, fill, ask, verify, research commands
|
|
630
|
+
|
|
631
|
+
**Files:**
|
|
632
|
+
- Modify: `boat/prima/src/prima.ts`
|
|
633
|
+
- Test: `boat/prima/tests/prima.test.ts` (extend)
|
|
634
|
+
|
|
635
|
+
**Interfaces:**
|
|
636
|
+
- Consumes: `collectInteractiveNodes(snapshot)` (`src/utils/aria.ts`), `createCodeceptJSTools` (`src/ai/tools.ts:30`) — tool objects expose `.execute(input)`; `provider.invokeConversation` with `maxToolRoundtrips` (Driller pattern, `src/ai/driller.ts:325-329`); `researcher.answerQuestionAboutScreenshot(state, question)` and `researcher.summary(state)` (`src/ai/researcher.ts:544, 659`); `navigator.verifyState(message, actionResult)` (`src/ai/navigator.ts:618`) returning `{ verified, successfulCodes, assertionSteps, totalAttempted }`.
|
|
637
|
+
- Produces:
|
|
638
|
+
|
|
639
|
+
```typescript
|
|
640
|
+
async do(instructions: string[]): Promise<EnvelopeData>;
|
|
641
|
+
async click(target: string): Promise<EnvelopeData>;
|
|
642
|
+
async fill(field: string, value: string): Promise<EnvelopeData>;
|
|
643
|
+
async ask(question: string): Promise<EnvelopeData>;
|
|
644
|
+
async verify(assertion: string): Promise<EnvelopeData>;
|
|
645
|
+
async research(opts?: { data?: boolean; deep?: boolean; fresh?: boolean }): Promise<EnvelopeData>;
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
`EnvelopeData` gains an optional `research?: string` field (Task 1's renderer: `### Research` replaces `### Changes` when set, mutually exclusive with `answer`/`verdict` — add a render test alongside the answer/verdict ones).
|
|
649
|
+
|
|
650
|
+
- [ ] **Step 1: Write failing tests**
|
|
651
|
+
|
|
652
|
+
```typescript
|
|
653
|
+
test('do runs a bounded AI loop over the instruction set', async () => {
|
|
654
|
+
const { prima } = fakePrima();
|
|
655
|
+
const prompts: string[] = [];
|
|
656
|
+
(prima as any).bot.getProvider = () => ({
|
|
657
|
+
startConversation: () => ({ addUserText: (t: string) => prompts.push(t) }),
|
|
658
|
+
invokeConversation: async () => ({ toolExecutions: [{ toolName: 'click', args: { locator: 'Login' }, result: { success: true, code: "I.click('Login')" } }] }),
|
|
659
|
+
});
|
|
660
|
+
const envelope = await prima.do(['open the first invoice', 'download its PDF']);
|
|
661
|
+
expect(prompts.join('\n')).toContain('open the first invoice');
|
|
662
|
+
expect(prompts.join('\n')).toContain('download its PDF');
|
|
663
|
+
expect(envelope.used).toEqual(["I.click('Login')"]);
|
|
664
|
+
expect(envelope.ok).toBe(true);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test('click is a single-instruction alias over do', async () => {
|
|
668
|
+
const { prima } = fakePrima();
|
|
669
|
+
const received: string[][] = [];
|
|
670
|
+
(prima as any).do = async (instructions: string[]) => { received.push(instructions); return { ok: true }; };
|
|
671
|
+
await prima.click('the login link');
|
|
672
|
+
expect(received[0].length).toBe(1);
|
|
673
|
+
expect(received[0][0]).toContain('the login link');
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('verify returns verdict with assertion code', async () => {
|
|
677
|
+
const { prima } = fakePrima();
|
|
678
|
+
(prima as any).bot.agentNavigator = () => ({
|
|
679
|
+
verifyState: async () => ({ verified: true, successfulCodes: ["I.see('Dashboard')"], assertionSteps: [], totalAttempted: 1 }),
|
|
680
|
+
});
|
|
681
|
+
const envelope = await prima.verify('user sees the dashboard');
|
|
682
|
+
expect(envelope.verdict?.passed).toBe(true);
|
|
683
|
+
expect(envelope.verdict?.code).toBe("I.see('Dashboard')");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test('research returns UI map in envelope', async () => {
|
|
687
|
+
const { prima } = fakePrima();
|
|
688
|
+
(prima as any).bot.agentResearcher = () => ({ research: async () => '## Section: Login Form\n| Element | ARIA | CSS |' });
|
|
689
|
+
const envelope = await prima.research({ data: true });
|
|
690
|
+
expect(envelope.research).toContain('Login Form');
|
|
691
|
+
expect(envelope.ok).toBe(true);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test('ask defaults to vision via screenshot analysis', async () => {
|
|
695
|
+
const { prima } = fakePrima();
|
|
696
|
+
(prima as any).bot.getProvider = () => ({ hasVision: () => true });
|
|
697
|
+
(prima as any).bot.agentResearcher = () => ({ answerQuestionAboutScreenshot: async () => 'A login page with an email form' });
|
|
698
|
+
const envelope = await prima.ask('what do I see?');
|
|
699
|
+
expect(envelope.answer).toContain('login');
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test('ask with noVision answers from researcher summary', async () => {
|
|
703
|
+
const { prima } = fakePrima();
|
|
704
|
+
(prima as any).options.noVision = true;
|
|
705
|
+
(prima as any).bot.agentResearcher = () => ({ summary: async () => 'Login form with email and password' });
|
|
706
|
+
(prima as any).bot.getProvider = () => ({ chat: async () => ({ text: 'A login page with an email form' }) });
|
|
707
|
+
const envelope = await prima.ask('what do I see?');
|
|
708
|
+
expect(envelope.answer).toContain('login');
|
|
709
|
+
});
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
- [ ] **Step 2: Run tests, verify fail** — `bun test boat/prima/tests/prima.test.ts`.
|
|
713
|
+
|
|
714
|
+
- [ ] **Step 3: Implement**
|
|
715
|
+
|
|
716
|
+
- `do(instructions)`: tester-style bounded AI loop, no deterministic path (callers describe elements, they never hold locators — precision belongs to `pw`). One conversation via `provider.startConversation(...)` with a dedent system prompt: current compact ARIA + relevant experience TOC + the numbered instruction list + rule to perform the instructions in order and stop when done. Execute `provider.invokeConversation(conversation, createCodeceptJSTools(explorer, ...), { maxToolRoundtrips: 5 })` per iteration, re-injecting fresh page context between iterations when state changed (same pattern as Driller's bounded loop, `src/ai/driller.ts:325-329`), capped at `min(instructions.length + 2, 6)` iterations. Collect executed codes from tool results into `used`. Failures feed `heal(...)` from Task 5.
|
|
717
|
+
- `click(target)`: alias — `this.do([`click ${target}`])`. `fill(field, value)`: alias — `this.do([`fill ${field} with value: ${value}`])`. Phrase construction must stay general (verb + caller's description verbatim), never enumerate app-specific element names.
|
|
718
|
+
- AI-availability guard: every model-requiring command (`do`, `click`, `fill`, `ask`, `verify`, `research`, intent-`go`) checks provider availability first (provider construction failed, or no model configured — surface the underlying reason from `bot.getProvider()`/config); on failure return a tool-error envelope whose message states the reason and suggests the fallback: use playwright-cli for direct browser control, or fix AI config (`~/.explorbot/config.js` / `EXPLORBOT_AI_PROVIDER`). Add a test: provider getter throws → `do` returns `ok: false` with error containing 'playwright-cli'. The message must describe the failure layer generally — never hardcode a specific provider's error text.
|
|
719
|
+
- `ask(question)`: vision by default — `researcher.answerQuestionAboutScreenshot(state, question)`; falls to the text path when `options.noVision` or `!provider.hasVision()` (`src/ai/provider.ts:635`; append a one-line note to the answer when degrading for the latter reason). Text path: `provider.chat` over dedent prompt containing `researcher.summary(state)` + compact ARIA + the question. Non-mutating: envelope has `answer`, no `changes`, artifacts still written.
|
|
720
|
+
- `verify(assertion)`: `explorer.capture()` then `navigator.verifyState(assertion, actionResult)`; verdict `{ passed: verified, evidence: <first successful step or failure note>, code: successfulCodes.join('\n') }`.
|
|
721
|
+
- `research(opts)`: `researcher.research(state, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh })` (`src/ai/researcher.ts:94`); envelope `research` = returned UI map verbatim (staleness banner included when cached), no `changes`. Non-mutating; artifacts still written.
|
|
722
|
+
- All command methods end by building `used` from actually executed code (never the requested input when they differ).
|
|
723
|
+
|
|
724
|
+
- [ ] **Step 4: Run tests, verify pass** — `bun test boat/prima/tests/`.
|
|
725
|
+
|
|
726
|
+
- [ ] **Step 5: Commit**
|
|
727
|
+
|
|
728
|
+
```bash
|
|
729
|
+
bun run format
|
|
730
|
+
git add boat/prima
|
|
731
|
+
git commit -m "feat(prima): do, click, fill, ask, verify commands"
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
736
|
+
### Task 7: go command and browser instance management
|
|
737
|
+
|
|
738
|
+
**Files:**
|
|
739
|
+
- Modify: `boat/prima/src/prima.ts`
|
|
740
|
+
- Test: `boat/prima/tests/prima.test.ts` (extend)
|
|
741
|
+
|
|
742
|
+
**Interfaces:**
|
|
743
|
+
- Consumes: `navigator.visit(destination)` (`src/ai/navigator.ts:140` — handles both URLs and NL/state destinations, same call the TUI `/navigate` uses per `src/commands/navigate-command.ts`), Task 3 instance functions.
|
|
744
|
+
- Produces:
|
|
745
|
+
|
|
746
|
+
```typescript
|
|
747
|
+
async go(target: string): Promise<EnvelopeData>;
|
|
748
|
+
async browserStart(): Promise<void>;
|
|
749
|
+
async browserStop(all?: boolean): Promise<void>;
|
|
750
|
+
async browserStatus(): Promise<string>;
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
- [ ] **Step 1: Write failing tests**
|
|
754
|
+
|
|
755
|
+
```typescript
|
|
756
|
+
test('go delegates to navigator.visit and returns envelope', async () => {
|
|
757
|
+
const { prima } = fakePrima();
|
|
758
|
+
const visited: string[] = [];
|
|
759
|
+
(prima as any).bot.agentNavigator = () => ({ visit: async (dest: string) => { visited.push(dest); } });
|
|
760
|
+
const envelope = await prima.go('billing settings');
|
|
761
|
+
expect(visited).toEqual(['billing settings']);
|
|
762
|
+
expect(envelope.ok).toBe(true);
|
|
763
|
+
expect(envelope.page.url).toBeTruthy();
|
|
764
|
+
});
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
- [ ] **Step 2: Run, verify fail.**
|
|
768
|
+
|
|
769
|
+
- [ ] **Step 3: Implement**
|
|
770
|
+
|
|
771
|
+
- `go(target)`: `navigator.visit(target)` (it resolves URL vs intent internally); envelope from resulting state; navigation errors feed `heal`.
|
|
772
|
+
- `browserStart/Stop/Status`: thin delegation to Task 3's browser-server functions with `options.instance`; `browserStop(true)` iterates `listInstances()`. Status string includes what `instanceInfo()` knows.
|
|
773
|
+
- No implicit launch: verify `go` (like every command) fails with the create-a-session guidance when no browser is available. `browserStart()` is the only launch path; with `options.session` set there, the launched context loads storage state (Explorer already honors `session` — `src/explorer.ts:106,337`).
|
|
774
|
+
|
|
775
|
+
- [ ] **Step 4: Run tests, verify pass; commit**
|
|
776
|
+
|
|
777
|
+
```bash
|
|
778
|
+
bun run format
|
|
779
|
+
git add boat/prima
|
|
780
|
+
git commit -m "feat(prima): go command and instance management"
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
---
|
|
784
|
+
|
|
785
|
+
### Task 8: Config ladder — global config, global .env, per-host state dir
|
|
786
|
+
|
|
787
|
+
**Files:**
|
|
788
|
+
- Modify: `src/config.ts` (`ConfigParser.loadConfig` at line 324, `buildEnvConfig` at line 489, `resolveOutputRoot` at line 667)
|
|
789
|
+
- Test: `tests/unit/config-ladder.test.ts`
|
|
790
|
+
|
|
791
|
+
**Interfaces:**
|
|
792
|
+
- Produces: `loadConfig` resolution order becomes: explicit `--config` path → project `explorbot.config.js|ts` in cwd → `~/.explorbot/config.js|ts` → env-var config (`buildEnvConfig`). `.env` loading order: cwd `.env` (existing behavior) then `~/.explorbot/.env` (only for keys not already set). All home paths via `os.homedir()` — one cross-platform dir, no XDG/AppData branching. New exported helper:
|
|
793
|
+
|
|
794
|
+
```typescript
|
|
795
|
+
export function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
returning `~/.explorbot/state/<host>/` (created), or a `mkdtempSync` temp dir when `ephemeral`.
|
|
799
|
+
|
|
800
|
+
- [ ] **Step 1: Read `src/config.ts` load path fully** (lines 297-560) before changing anything.
|
|
801
|
+
|
|
802
|
+
- [ ] **Step 2: Write failing tests**
|
|
803
|
+
|
|
804
|
+
`tests/unit/config-ladder.test.ts`:
|
|
805
|
+
|
|
806
|
+
```typescript
|
|
807
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
808
|
+
import { existsSync } from 'node:fs';
|
|
809
|
+
import path from 'node:path';
|
|
810
|
+
import os from 'node:os';
|
|
811
|
+
import { resolveStateRoot } from '../../src/config.ts';
|
|
812
|
+
|
|
813
|
+
describe('resolveStateRoot', () => {
|
|
814
|
+
test('derives persistent per-host dir', () => {
|
|
815
|
+
const dir = resolveStateRoot('https://app.example.com/login');
|
|
816
|
+
expect(dir).toBe(path.join(os.homedir(), '.explorbot', 'state', 'app.example.com'));
|
|
817
|
+
expect(existsSync(dir)).toBe(true);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test('ephemeral returns fresh temp dir', () => {
|
|
821
|
+
const a = resolveStateRoot('https://app.example.com', true);
|
|
822
|
+
const b = resolveStateRoot('https://app.example.com', true);
|
|
823
|
+
expect(a).not.toBe(b);
|
|
824
|
+
expect(a).toContain('explorbot');
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
Global-config precedence test: point `ConfigParser.loadConfig` at a temp `HOME` (set `process.env.HOME` in the test, restore in `afterEach`; note `os.homedir()` ignores `$HOME` on Windows — gate the test or stub `homedir` if CI runs there), write `~/.explorbot/config.js` exporting a marker value, assert it loads when cwd has no project config and that a project config wins when both exist. Follow existing config tests in `tests/unit/` for how ConfigParser is instantiated.
|
|
830
|
+
|
|
831
|
+
- [ ] **Step 3: Run, verify fail.**
|
|
832
|
+
|
|
833
|
+
- [ ] **Step 4: Implement**
|
|
834
|
+
|
|
835
|
+
- `resolveStateRoot`: host from `new URL(baseUrl).host`; `mkdirSync(..., { recursive: true })`; ephemeral via `mkdtempSync(path.join(os.tmpdir(), 'explorbot-'))`.
|
|
836
|
+
- In `loadConfig`: after project-config lookup misses, try `path.join(os.homedir(), '.config', 'explorbot', 'config.js')` then `.ts` through the existing `loadConfigModule`.
|
|
837
|
+
- `.env`: where the existing cwd `.env` loads, additionally load `~/.explorbot/.env` without overwriting already-set keys.
|
|
838
|
+
- In `buildEnvConfig` (config-free mode): when no project config, set `dirs` (knowledge/experience/output) under `resolveStateRoot(baseUrl, ephemeralFlag)`; ephemeral flag arrives via new `EXPLORBOT_EPHEMERAL` env var so the boat can pass it without new plumbing.
|
|
839
|
+
|
|
840
|
+
- [ ] **Step 5: Run full unit suite** — `bun test tests/unit/` — PASS, no regressions.
|
|
841
|
+
|
|
842
|
+
- [ ] **Step 6: Commit**
|
|
843
|
+
|
|
844
|
+
```bash
|
|
845
|
+
bun run format
|
|
846
|
+
git add src/config.ts tests/unit/config-ladder.test.ts
|
|
847
|
+
git commit -m "feat: global config ladder and per-host state dirs"
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
---
|
|
851
|
+
|
|
852
|
+
### Task 9: CLI wiring and --help contract
|
|
853
|
+
|
|
854
|
+
**Files:**
|
|
855
|
+
- Create: `boat/prima/src/cli.ts`, `boat/prima/bin/prima-cli.ts`
|
|
856
|
+
- Modify: `bin/explorbot-cli.ts` (add near line 874: `program.addCommand(createPrimaCommands('prima'))`)
|
|
857
|
+
- Test: manual smoke via `--help` (Step 4)
|
|
858
|
+
|
|
859
|
+
**Interfaces:**
|
|
860
|
+
- Consumes: `Prima`/`PrimaOptions` (Tasks 4-7), `renderEnvelope` (Task 1).
|
|
861
|
+
- Produces: `export function createPrimaCommands(name = 'prima'): Command`.
|
|
862
|
+
|
|
863
|
+
- [ ] **Step 1: Implement `boat/prima/src/cli.ts`**
|
|
864
|
+
|
|
865
|
+
Mirror `boat/doc-collector/src/cli.ts` structure (`addCommonOptions`, `buildOptions`, subcommands). Subcommands: `pw <fn>`, `do <instructions...>` (variadic — each arg is one high-level instruction), `click <target>`, `fill <field> <value>`, `ask <question>`, `verify <assertion>` (alias `assert`), `research` (flags `--data`, `--deep`, `--fresh`), `go <target>`, `browser <start|stop|status|list>`. Common options:
|
|
866
|
+
|
|
867
|
+
```
|
|
868
|
+
-v, --verbose --debug
|
|
869
|
+
-c, --config <path> -p, --path <path>
|
|
870
|
+
-i, --instance <name> --session [file]
|
|
871
|
+
--no-heal --ephemeral
|
|
872
|
+
--framework <name> --no-vision (ask only)
|
|
873
|
+
--url <url> (start page for config-free mode)
|
|
874
|
+
--endpoint <ep> --pw-session <title> (attach to playwright-cli browser, Task 11)
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
Every action handler: `setPreserveConsoleLogs(true)`, build `Prima`, `await prima.start()`, run the method, `console.log(renderEnvelope(result))`, `await prima.stop()`, `process.exit(result.ok ? 0 : 1)`. `--ephemeral` sets `process.env.EXPLORBOT_EPHEMERAL = '1'` before Prima construction. Zero business logic in handlers.
|
|
878
|
+
|
|
879
|
+
- [ ] **Step 2: Write the --help contract text**
|
|
880
|
+
|
|
881
|
+
The command description plus `addHelpText('after', ...)` on the `prima` group is the sole teaching surface for orchestrating agents. It must compactly cover (dedent block, ~40 lines): the tiering (pw = precise with a locator you hold, click/fill = one described action AI-resolved, do = a set of high-level instructions run tester-style — never pass locators to NL commands, never pass descriptions to pw), the recommended loop (research once for verified locators → drive with pw → verify; reach for do/click when you have no locator), the envelope sections and their meaning, `used:` as reusable verified code, heal semantics and `--no-heal`, failure = inline compact ARIA + artifact file paths for deep dives, `--instance` vs `--session`, the no-implicit-launch rule (create sessions via playwright-cli or `prima browser start`), the AI-unavailable fallback (use playwright-cli directly), the close-when-finished convention driven by `### Instance`, and one usage example per tier. General shapes only — no app-specific examples.
|
|
882
|
+
|
|
883
|
+
- [ ] **Step 3: Wire into main CLI and standalone bin**
|
|
884
|
+
|
|
885
|
+
- `bin/explorbot-cli.ts`: `import { createPrimaCommands } from '../boat/prima/src/cli.ts';` + `program.addCommand(createPrimaCommands('prima'));` next to the existing api/docs registrations (line ~874).
|
|
886
|
+
- `boat/prima/bin/prima-cli.ts`: mirror `boat/api-tester/bin` entry — a commander program that mounts the same subcommands at top level.
|
|
887
|
+
|
|
888
|
+
- [ ] **Step 4: Smoke the help output**
|
|
889
|
+
|
|
890
|
+
Run: `bun bin/explorbot-cli.ts prima --help` and `bun boat/prima/bin/prima-cli.ts --help`
|
|
891
|
+
Expected: full contract text, all 8 subcommands listed, no banner noise with `EXPLORBOT_NO_BANNER=1`.
|
|
892
|
+
|
|
893
|
+
- [ ] **Step 5: Commit**
|
|
894
|
+
|
|
895
|
+
```bash
|
|
896
|
+
bun run format && bun run lint:fix
|
|
897
|
+
git add boat/prima bin/explorbot-cli.ts
|
|
898
|
+
git commit -m "feat(prima): prima CLI namespace and standalone prima bin"
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
---
|
|
902
|
+
|
|
903
|
+
### Task 10: End-to-end smoke, changelog, docs
|
|
904
|
+
|
|
905
|
+
**Files:**
|
|
906
|
+
- Create: `tests/node/prima-smoke.test.ts` (or `tests/regression/` — match where existing browser-driving tests live; inspect both dirs first)
|
|
907
|
+
- Modify: `docs/reference/commands.md`, `CHANGELOG.md` (via `/changelog` skill at commit time)
|
|
908
|
+
|
|
909
|
+
- [ ] **Step 1: Inspect existing e2e/browser test setup** — `tests/node/` and `tests/regression/` — reuse their fixture-server pattern for a local page (a form with a button and an input; fictional content only).
|
|
910
|
+
|
|
911
|
+
- [ ] **Step 2: Write the smoke test**
|
|
912
|
+
|
|
913
|
+
Scenarios, driven through the `Prima` class directly against the local fixture (real browser, no AI provider needed for these paths):
|
|
914
|
+
1. `pw "({ page }) => page.click('text=Submit')"` on the fixture → `ok: true`, envelope contains `### Changes` and artifact files exist on disk.
|
|
915
|
+
2. `pw` with a non-function argument → `ok: false`, `tool:`-prefixed error, exit path returns without browser action.
|
|
916
|
+
3. `do`/`click` are NOT in the browser smoke (they always require a model) — cover them in `tests/integration/prima-do.test.ts` via aimock: mock model returns click tool calls for a two-instruction `do`, assert both instructions appear in the prompt, `used` collects executed codes, envelope `ok: true`.
|
|
917
|
+
4. No implicit launch: run without any browser session → failure envelope containing the create-a-session guidance (`playwright-cli open` mentioned), exit code 1, and no endpoint file created.
|
|
918
|
+
5. `pw` with no AI provider configured: executes normally, envelope notes healing unavailable.
|
|
919
|
+
|
|
920
|
+
Heal-path e2e requires an AI provider — cover it with an aimock integration test in `tests/integration/prima-heal.test.ts` following `tests/integration/planner.test.ts` (mock provider returns a recovery instruction; assert `healed: true` envelope and `onAttempt` trace).
|
|
921
|
+
|
|
922
|
+
- [ ] **Step 3: Run everything**
|
|
923
|
+
|
|
924
|
+
Run: `bun test boat/prima/tests/ tests/unit/ tests/integration/` plus the smoke file.
|
|
925
|
+
Expected: all PASS.
|
|
926
|
+
|
|
927
|
+
- [ ] **Step 4: Document**
|
|
928
|
+
|
|
929
|
+
Add an "Prima boat" section to `docs/reference/commands.md`: command table, envelope sample, tiering guidance, instance/session flags, config-free example (`EXPLORBOT_AI_PROVIDER=groq explorbot prima go https://app.example.com`).
|
|
930
|
+
|
|
931
|
+
- [ ] **Step 5: Final checks and commit**
|
|
932
|
+
|
|
933
|
+
```bash
|
|
934
|
+
bun run format && bun run check:fix
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
Invoke the `/changelog` skill, then:
|
|
938
|
+
|
|
939
|
+
```bash
|
|
940
|
+
git add -A
|
|
941
|
+
git commit -m "feat(prima): e2e smoke, docs and changelog"
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
---
|
|
945
|
+
|
|
946
|
+
### Task 11: playwright-cli browser attach (Playwright-protocol registry)
|
|
947
|
+
|
|
948
|
+
Prima's default browser source becomes the playwright-cli daemon's browser for this workspace; prima's own daemon is the fallback. Every playwright-cli daemon browser is auto-`bind()`-ed as a Playwright-protocol server with a descriptor JSON in `~/.cache/ms-playwright/b/<guid>` (Linux `$XDG_CACHE_HOME/ms-playwright/b` fallback `~/.cache/...`; macOS `~/Library/Caches/ms-playwright/b`; Windows `%LOCALAPPDATA%\ms-playwright\b`) shaped `{ playwrightVersion, playwrightLib, title, endpoint, workspaceDir, browser: { browserName } }` where `title` is the playwright-cli session name (`default` unless `-s=`/`PLAYWRIGHT_CLI_SESSION`).
|
|
949
|
+
|
|
950
|
+
**Files:**
|
|
951
|
+
- Create: `boat/prima/src/pw-registry.ts`
|
|
952
|
+
- Modify: `boat/prima/src/actor.ts` (`start()` ladder), `boat/prima/src/cli.ts` (`--endpoint`, `--pw-session`), `src/explorer.ts` (attach path: adopt existing browser/context)
|
|
953
|
+
- Test: `boat/prima/tests/pw-registry.test.ts`, `boat/prima/tests/prima.test.ts` (extend)
|
|
954
|
+
|
|
955
|
+
**Interfaces:**
|
|
956
|
+
- Consumes: `PrimaOptions` (Task 4) gains `endpoint?: string; pwSession?: string`; `InstanceInfo.attached` (Task 1); Explorer internals `playwrightHelper.browser` / `_createContextPage` (`src/explorer.ts:107,146` — Explorer already assigns `playwrightHelper.browser`, so injection is supported).
|
|
957
|
+
- Produces:
|
|
958
|
+
|
|
959
|
+
```typescript
|
|
960
|
+
export interface PwServerDescriptor {
|
|
961
|
+
file: string;
|
|
962
|
+
title: string;
|
|
963
|
+
endpoint: string;
|
|
964
|
+
workspaceDir: string;
|
|
965
|
+
browserName: string;
|
|
966
|
+
playwrightLib: string;
|
|
967
|
+
}
|
|
968
|
+
export function registryDir(): string;
|
|
969
|
+
export function readDescriptors(dir?: string): PwServerDescriptor[];
|
|
970
|
+
export function selectDescriptor(descriptors: PwServerDescriptor[], opts: { workspaceDir: string; title?: string }): { match?: PwServerDescriptor; candidates: PwServerDescriptor[] };
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
- [ ] **Step 1: Write failing registry tests**
|
|
974
|
+
|
|
975
|
+
`boat/prima/tests/pw-registry.test.ts`:
|
|
976
|
+
|
|
977
|
+
```typescript
|
|
978
|
+
import { describe, expect, test } from 'bun:test';
|
|
979
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
980
|
+
import { tmpdir } from 'node:os';
|
|
981
|
+
import path from 'node:path';
|
|
982
|
+
import { readDescriptors, selectDescriptor } from '../src/pw-registry.ts';
|
|
983
|
+
|
|
984
|
+
function writeDescriptor(dir: string, name: string, data: Record<string, unknown>) {
|
|
985
|
+
writeFileSync(path.join(dir, name), JSON.stringify({ playwrightVersion: '1.62.0', playwrightLib: '/lib/pw', endpoint: `/tmp/pw/${name}.sock`, browser: { browserName: 'chromium' }, ...data }));
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
describe('readDescriptors', () => {
|
|
989
|
+
test('parses descriptor files and skips malformed ones', () => {
|
|
990
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'pwb-'));
|
|
991
|
+
writeDescriptor(dir, 'a', { title: 'default', workspaceDir: '/work/app' });
|
|
992
|
+
writeFileSync(path.join(dir, 'broken'), 'not json');
|
|
993
|
+
const list = readDescriptors(dir);
|
|
994
|
+
expect(list.length).toBe(1);
|
|
995
|
+
expect(list[0].title).toBe('default');
|
|
996
|
+
expect(list[0].browserName).toBe('chromium');
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
describe('selectDescriptor', () => {
|
|
1001
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'pwb-'));
|
|
1002
|
+
writeDescriptor(dir, 'a', { title: 'default', workspaceDir: '/work/app' });
|
|
1003
|
+
writeDescriptor(dir, 'b', { title: 'auth', workspaceDir: '/work/app' });
|
|
1004
|
+
writeDescriptor(dir, 'c', { title: 'default', workspaceDir: '/work/other' });
|
|
1005
|
+
const all = readDescriptors(dir);
|
|
1006
|
+
|
|
1007
|
+
test('explicit title wins within workspace', () => {
|
|
1008
|
+
const { match } = selectDescriptor(all, { workspaceDir: '/work/app', title: 'auth' });
|
|
1009
|
+
expect(match?.title).toBe('auth');
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test('default title picked for workspace when present', () => {
|
|
1013
|
+
const { match } = selectDescriptor(all, { workspaceDir: '/work/app' });
|
|
1014
|
+
expect(match?.title).toBe('default');
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
test('single survivor for workspace picked without title', () => {
|
|
1018
|
+
const { match } = selectDescriptor(all, { workspaceDir: '/work/other' });
|
|
1019
|
+
expect(match?.workspaceDir).toBe('/work/other');
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
test('no workspace match returns candidates empty', () => {
|
|
1023
|
+
const { match, candidates } = selectDescriptor(all, { workspaceDir: '/elsewhere' });
|
|
1024
|
+
expect(match).toBeUndefined();
|
|
1025
|
+
expect(candidates.length).toBe(0);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
test('ambiguity returns no match with candidates listed', () => {
|
|
1029
|
+
const noDefault = all.filter((d) => d.title !== 'default');
|
|
1030
|
+
const extra = [...noDefault, { ...noDefault[0], title: 'second' }];
|
|
1031
|
+
const { match, candidates } = selectDescriptor(extra, { workspaceDir: '/work/app' });
|
|
1032
|
+
expect(match).toBeUndefined();
|
|
1033
|
+
expect(candidates.length).toBe(2);
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
- [ ] **Step 2: Run, verify fail** — `bun test boat/prima/tests/pw-registry.test.ts`.
|
|
1039
|
+
|
|
1040
|
+
- [ ] **Step 3: Implement `pw-registry.ts`**
|
|
1041
|
+
|
|
1042
|
+
- `registryDir()`: per-OS cache root (`process.platform`: darwin → `~/Library/Caches/ms-playwright/b`; win32 → `path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'), 'ms-playwright', 'b')`; else `path.join(process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache'), 'ms-playwright', 'b')`).
|
|
1043
|
+
- `readDescriptors(dir = registryDir())`: `readdirSync` (return `[]` when dir missing), JSON.parse each file, skip on parse error or missing `endpoint`/`title`/`workspaceDir`, map to `PwServerDescriptor`.
|
|
1044
|
+
- `selectDescriptor`: filter by `workspaceDir` (compare `path.resolve` of both); explicit title → exact match; no title → prefer `title === 'default'`, else single survivor; ambiguity or explicit-title miss → `{ candidates }` with no match.
|
|
1045
|
+
|
|
1046
|
+
- [ ] **Step 4: Run, verify pass; commit**
|
|
1047
|
+
|
|
1048
|
+
```bash
|
|
1049
|
+
bun run format
|
|
1050
|
+
git add boat/prima/src/pw-registry.ts boat/prima/tests/pw-registry.test.ts
|
|
1051
|
+
git commit -m "feat(prima): playwright-cli browser-server registry discovery"
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
- [ ] **Step 5: Write failing attach-ladder test in `prima.test.ts`**
|
|
1055
|
+
|
|
1056
|
+
```typescript
|
|
1057
|
+
test('start attaches to workspace playwright-cli browser before own daemon', async () => {
|
|
1058
|
+
const prima = new Prima({});
|
|
1059
|
+
const calls: string[] = [];
|
|
1060
|
+
(prima as any).discover = () => ({ match: { title: 'default', endpoint: '/tmp/pw/a.sock', workspaceDir: process.cwd(), browserName: 'chromium', playwrightLib: '/lib/pw', file: 'a' }, candidates: [] });
|
|
1061
|
+
(prima as any).attachToEndpoint = async (d: unknown) => { calls.push('attach'); };
|
|
1062
|
+
(prima as any).connectOwnInstance = async () => { calls.push('own'); return true; };
|
|
1063
|
+
await prima.start();
|
|
1064
|
+
expect(calls).toEqual(['attach']);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
test('start uses explicitly started own instance when nothing attachable', async () => {
|
|
1068
|
+
const prima = new Prima({});
|
|
1069
|
+
const calls: string[] = [];
|
|
1070
|
+
(prima as any).discover = () => ({ candidates: [] });
|
|
1071
|
+
(prima as any).attachToEndpoint = async () => { calls.push('attach'); };
|
|
1072
|
+
(prima as any).connectOwnInstance = async () => { calls.push('own'); return true; };
|
|
1073
|
+
await prima.start();
|
|
1074
|
+
expect(calls).toEqual(['own']);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
test('start fails with playwright-cli guidance when no session exists anywhere', async () => {
|
|
1078
|
+
const prima = new Prima({});
|
|
1079
|
+
(prima as any).discover = () => ({ candidates: [] });
|
|
1080
|
+
(prima as any).connectOwnInstance = async () => false;
|
|
1081
|
+
await expect(prima.start()).rejects.toThrow(/playwright-cli open/);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('ambiguous sessions surface as tool error listing candidates', async () => {
|
|
1085
|
+
const prima = new Prima({});
|
|
1086
|
+
(prima as any).discover = () => ({ candidates: [{ title: 'auth' }, { title: 'smoke' }] });
|
|
1087
|
+
await expect(prima.start()).rejects.toThrow(/auth.*smoke|smoke.*auth/);
|
|
1088
|
+
});
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
- [ ] **Step 6: Run, verify fail; implement the ladder in `Prima.start()`**
|
|
1092
|
+
|
|
1093
|
+
Order: `options.endpoint` (skip discovery, attach directly) → `discover()` = `selectDescriptor(readDescriptors(), { workspaceDir: resolved cwd, title: options.pwSession ?? process.env.PLAYWRIGHT_CLI_SESSION })` → match → `attachToEndpoint(descriptor)` → no match, candidates non-empty → throw Error listing candidate titles (CLI renders it as a `tool:` failure envelope) → no candidates → `connectOwnInstance()` (Task 4's alive-endpoint lookup, extracted private method returning boolean; it never launches) → false → throw Error with the create-a-session guidance naming `playwright-cli open <url>` first and `prima browser start` second. Record attachment description for `InstanceInfo.attached` (`playwright-cli session "<title>", workspace <dir>`).
|
|
1094
|
+
|
|
1095
|
+
`attachToEndpoint(descriptor)`:
|
|
1096
|
+
- Liveness probe: attempt `playwright[descriptor.browserName].connect(descriptor.endpoint, { timeout: 3000 })` with our own playwright-core import; on handshake/version failure, retry once with `require(descriptor.playwrightLib)` (daemon's own lib — the version-skew escape playwright-cli itself uses); on both failing, treat descriptor as dead and continue the ladder.
|
|
1097
|
+
- Inject into Explorer: assign the connected browser to `playwrightHelper.browser` and adopt the browser's existing default context and its last open page instead of `_createContextPage()` (add an Explorer attach path beside `src/explorer.ts:107`; keep it the smallest change that skips context creation and skips `storageState` — `--session` is ignored in attached mode).
|
|
1098
|
+
- Lifecycle guards: mark `this.attachedExternally = true`; `stop()`/`browserStop()` call `browser.close()`, which for a `connect()`ed browser is documented as disconnect-only ("clears all created contexts belonging to this browser and disconnects from the browser server" — pre-existing contexts survive; since prima adopts the existing context rather than creating one, nothing of the shared browser is torn down). Never remove registry/endpoint files. Add a unit test asserting `stop()` in attached mode does not invoke the daemon-kill path.
|
|
1099
|
+
|
|
1100
|
+
- [ ] **Step 7: Run all boat tests** — `bun test boat/prima/tests/` — PASS.
|
|
1101
|
+
|
|
1102
|
+
- [ ] **Step 8: Wire CLI flags and help**
|
|
1103
|
+
|
|
1104
|
+
`--endpoint <ep>` and `--pw-session <title>` in `addCommonOptions` (Task 9); extend `browser list` output with attachable playwright-cli sessions from `readDescriptors()` for this workspace; extend the `--help` contract text: prima attaches to the workspace's playwright-cli browser by default — mixed playwright-cli + prima workflows on the same tabs are the intended usage.
|
|
1105
|
+
|
|
1106
|
+
- [ ] **Step 9: Commit**
|
|
1107
|
+
|
|
1108
|
+
```bash
|
|
1109
|
+
bun run format
|
|
1110
|
+
git add boat/prima src/explorer.ts
|
|
1111
|
+
git commit -m "feat(prima): attach to playwright-cli browser via protocol registry"
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
---
|
|
1115
|
+
|
|
1116
|
+
## Self-Review Notes
|
|
1117
|
+
|
|
1118
|
+
- Spec coverage: pw/do/click/fill/ask/verify+assert/research/go (Tasks 4-7, 9), playwright-cli attach ladder + `--endpoint`/`--pw-session` + attached lifecycle (Task 11), envelope + used code (1, 4), heal + attempt trace + `--no-heal` + ai-unavailable skip (5), instances + no-implicit-launch + `--session` reuse (3, 4, 7), AI-unavailable fallback guidance (6), config-free ladder + per-host state + `--ephemeral` (8), `--help`-only discovery (9), testing incl. aimock heal test (10). Framework flag (`--framework`) is parsed (9) and stored (4); Historian-based conversion of `used:` into Playwright dialect is deliberately deferred until `used` collection stabilizes — v1 emits the executed CodeceptJS (pw commands echo the Playwright expression itself), which satisfies "actual used locator" for both dialect inputs. If reviewers want full conversion in v1, extend Task 6 with `historian.toPlaywrightCode` per `src/ai/historian/playwright.ts:21`.
|
|
1119
|
+
- Vision `ask` degrades to text path when no `visionModel` configured (`provider.hasVision()`, `src/ai/provider.ts:635`) — implementer: guard in `ask`.
|
|
1120
|
+
- Type consistency: `EnvelopeData`/`InstanceInfo`/`HealAttempt` defined once in Task 1 and only consumed elsewhere; `PrimaOptions` defined in Task 4 and consumed by 9.
|