explorbot 0.2.1 → 0.2.3
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 +52 -37
- package/boat/api-tester/src/apibot.ts +4 -2
- package/boat/api-tester/src/cli.ts +2 -2
- package/boat/api-tester/src/config.ts +39 -8
- package/boat/doc-collector/src/ai/documentarian.ts +3 -0
- package/boat/doc-collector/src/ai/tools.ts +17 -5
- package/boat/doc-collector/src/cli.ts +2 -0
- package/boat/doc-collector/src/config.ts +2 -0
- package/boat/doc-collector/src/docbot.ts +39 -11
- package/boat/doc-collector/src/docs-renderer.ts +18 -4
- package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
- package/boat/doc-collector/src/screenshots.ts +22 -14
- package/boat/doc-collector/src/state-diagram.ts +61 -14
- package/boat/prima/bin/prima-cli.ts +5 -0
- package/boat/prima/package.json +16 -0
- package/boat/prima/src/cli.ts +222 -0
- package/boat/prima/src/envelope.ts +141 -0
- package/boat/prima/src/prima.ts +705 -0
- package/boat/prima/src/pw-parser.ts +17 -0
- package/boat/prima/src/pw-registry.ts +75 -0
- package/dist/bin/explorbot-cli.js +44 -31
- package/dist/boat/api-tester/src/apibot.js +3 -2
- package/dist/boat/api-tester/src/cli.js +2 -2
- package/dist/boat/api-tester/src/config.js +36 -8
- package/dist/boat/doc-collector/src/ai/documentarian.js +3 -0
- package/dist/boat/doc-collector/src/ai/tools.js +10 -4
- package/dist/boat/doc-collector/src/cli.js +2 -0
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +36 -10
- package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
- package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
- package/dist/boat/doc-collector/src/screenshots.js +23 -14
- package/dist/boat/doc-collector/src/state-diagram.js +57 -13
- package/dist/boat/prima/bin/prima-cli.js +4 -0
- package/dist/boat/prima/src/cli.js +200 -0
- package/dist/boat/prima/src/envelope.js +116 -0
- package/dist/boat/prima/src/prima.js +635 -0
- package/dist/boat/prima/src/pw-parser.js +18 -0
- package/dist/boat/prima/src/pw-registry.js +66 -0
- package/dist/models.json +3 -0
- package/dist/package.json +9 -2
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +5 -5
- package/dist/src/ai/captain/mixin.js +3 -4
- package/dist/src/ai/captain/web-mode.js +1 -1
- package/dist/src/ai/navigator.d.ts +4 -0
- package/dist/src/ai/navigator.js +11 -6
- package/dist/src/ai/planner.d.ts +1 -0
- package/dist/src/ai/planner.js +6 -0
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/task-agent.js +1 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +13 -0
- package/dist/src/application-spec-contract.d.ts +8 -0
- package/dist/src/application-spec-contract.js +8 -0
- package/dist/src/application-spec.d.ts +15 -0
- package/dist/src/application-spec.js +71 -0
- package/dist/src/browser-server.d.ts +12 -6
- package/dist/src/browser-server.js +74 -19
- package/dist/src/commands/clean-command.js +2 -7
- package/dist/src/commands/init-command.d.ts +5 -0
- package/dist/src/commands/init-command.js +119 -1
- package/dist/src/commands/navigate-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/sites-command.d.ts +6 -0
- package/dist/src/commands/sites-command.js +23 -0
- package/dist/src/components/InitWizard.d.ts +10 -0
- package/dist/src/components/InitWizard.js +133 -0
- package/dist/src/components/InputReadline.d.ts +1 -0
- package/dist/src/components/InputReadline.js +7 -4
- package/dist/src/config.d.ts +24 -5
- package/dist/src/config.js +146 -37
- package/dist/src/explorbot.d.ts +9 -0
- package/dist/src/explorbot.js +24 -5
- package/dist/src/explorer.d.ts +5 -1
- package/dist/src/explorer.js +54 -19
- package/dist/src/global-config.d.ts +22 -0
- package/dist/src/global-config.js +117 -0
- package/dist/src/knowledge-tracker.d.ts +5 -1
- package/dist/src/knowledge-tracker.js +14 -1
- package/dist/src/utils/cli-name.js +6 -2
- package/dist/src/utils/test-files.js +1 -2
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +9 -0
- package/models.json +3 -0
- package/package.json +9 -2
- package/src/action.ts +9 -5
- package/src/ai/captain/mixin.ts +3 -3
- package/src/ai/captain/web-mode.ts +1 -1
- package/src/ai/navigator.ts +12 -7
- package/src/ai/planner.ts +7 -0
- package/src/ai/researcher/locators.ts +1 -1
- package/src/ai/researcher.ts +1 -1
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +15 -0
- package/src/application-spec-contract.ts +10 -0
- package/src/application-spec.ts +87 -0
- package/src/browser-server.ts +74 -19
- package/src/commands/clean-command.ts +1 -6
- package/src/commands/init-command.ts +146 -1
- package/src/commands/navigate-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/sites-command.ts +27 -0
- package/src/components/InitWizard.tsx +166 -0
- package/src/components/InputReadline.tsx +8 -4
- package/src/config.ts +162 -39
- package/src/explorbot.ts +30 -5
- package/src/explorer.ts +59 -20
- package/src/global-config.ts +148 -0
- package/src/knowledge-tracker.ts +17 -1
- package/src/utils/cli-name.ts +5 -2
- package/src/utils/test-files.ts +1 -2
- package/src/utils/url-matcher.ts +10 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import pixelmatch from 'pixelmatch';
|
|
3
|
+
import { PNG } from 'pngjs';
|
|
4
|
+
const REGION_PADDING = 30;
|
|
5
|
+
const SCREENSHOT_OPTIONS = { animations: 'disabled', caret: 'hide' };
|
|
6
|
+
export async function captureInteractionBefore(page) {
|
|
7
|
+
await removeVisualAnnotations(page);
|
|
8
|
+
try {
|
|
9
|
+
return await page.screenshot(SCREENSHOT_OPTIONS);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function captureInteractionAfter(page, beforeScreenshot, filePath, detectUnmarkedOverlay = false) {
|
|
16
|
+
if (!beforeScreenshot)
|
|
17
|
+
return 'failed';
|
|
18
|
+
await removeVisualAnnotations(page);
|
|
19
|
+
try {
|
|
20
|
+
const afterScreenshot = await page.screenshot(SCREENSHOT_OPTIONS);
|
|
21
|
+
const before = PNG.sync.read(beforeScreenshot);
|
|
22
|
+
const after = PNG.sync.read(afterScreenshot);
|
|
23
|
+
if (before.width !== after.width || before.height !== after.height)
|
|
24
|
+
return 'failed';
|
|
25
|
+
const changedPixels = findChangedPixelBounds(before, after);
|
|
26
|
+
if (!changedPixels)
|
|
27
|
+
return 'unchanged';
|
|
28
|
+
const fullViewportChanged = changedPixels.x === 0 && changedPixels.y === 0 && changedPixels.width === after.width && changedPixels.height === after.height;
|
|
29
|
+
const changedRegion = addPadding(changedPixels, after.width, after.height);
|
|
30
|
+
const overlayRegion = fullViewportChanged ? await findOverlayRegion(page, after, detectUnmarkedOverlay) : null;
|
|
31
|
+
saveRegion(after, overlayRegion || changedRegion, filePath);
|
|
32
|
+
return 'captured';
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return 'failed';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function findChangedRegion(beforeScreenshot, afterScreenshot, padding = REGION_PADDING) {
|
|
39
|
+
const before = PNG.sync.read(beforeScreenshot);
|
|
40
|
+
const after = PNG.sync.read(afterScreenshot);
|
|
41
|
+
if (before.width !== after.width || before.height !== after.height)
|
|
42
|
+
return null;
|
|
43
|
+
const changedPixels = findChangedPixelBounds(before, after);
|
|
44
|
+
return changedPixels ? addPadding(changedPixels, before.width, before.height, padding) : null;
|
|
45
|
+
}
|
|
46
|
+
function saveRegion(after, region, filePath) {
|
|
47
|
+
const cropped = new PNG({ width: region.width, height: region.height });
|
|
48
|
+
PNG.bitblt(after, cropped, region.x, region.y, region.width, region.height, 0, 0);
|
|
49
|
+
writeFileSync(filePath, PNG.sync.write(cropped));
|
|
50
|
+
}
|
|
51
|
+
function findChangedPixelBounds(before, after) {
|
|
52
|
+
const diff = Buffer.alloc(before.width * before.height * 4);
|
|
53
|
+
const changedPixels = pixelmatch(before.data, after.data, diff, before.width, before.height, { diffMask: true });
|
|
54
|
+
if (changedPixels === 0)
|
|
55
|
+
return null;
|
|
56
|
+
let left = before.width;
|
|
57
|
+
let top = before.height;
|
|
58
|
+
let right = 0;
|
|
59
|
+
let bottom = 0;
|
|
60
|
+
for (let y = 0; y < before.height; y++) {
|
|
61
|
+
for (let x = 0; x < before.width; x++) {
|
|
62
|
+
if (diff[(y * before.width + x) * 4 + 3] === 0)
|
|
63
|
+
continue;
|
|
64
|
+
left = Math.min(left, x);
|
|
65
|
+
top = Math.min(top, y);
|
|
66
|
+
right = Math.max(right, x);
|
|
67
|
+
bottom = Math.max(bottom, y);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
|
71
|
+
}
|
|
72
|
+
function addPadding(region, imageWidth, imageHeight, padding = REGION_PADDING) {
|
|
73
|
+
const x = Math.max(0, region.x - padding);
|
|
74
|
+
const y = Math.max(0, region.y - padding);
|
|
75
|
+
const maxX = Math.min(imageWidth, region.x + region.width + padding);
|
|
76
|
+
const maxY = Math.min(imageHeight, region.y + region.height + padding);
|
|
77
|
+
return { x, y, width: maxX - x, height: maxY - y };
|
|
78
|
+
}
|
|
79
|
+
async function removeVisualAnnotations(page) {
|
|
80
|
+
try {
|
|
81
|
+
await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
|
|
82
|
+
for (const element of elements)
|
|
83
|
+
element.remove();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
}
|
|
88
|
+
async function findOverlayRegion(page, image, detectUnmarkedOverlay) {
|
|
89
|
+
let box = null;
|
|
90
|
+
try {
|
|
91
|
+
const dialogs = page.locator('[role="dialog"]:visible, [role="alertdialog"]:visible, [aria-modal="true"]:visible');
|
|
92
|
+
if ((await dialogs.count()) > 0)
|
|
93
|
+
box = await dialogs.last().boundingBox();
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
if (!box && detectUnmarkedOverlay) {
|
|
97
|
+
try {
|
|
98
|
+
box = await findUnmarkedOverlay(page);
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const viewport = page.viewportSize();
|
|
104
|
+
if (!box || !viewport)
|
|
105
|
+
return null;
|
|
106
|
+
const scaleX = image.width / viewport.width;
|
|
107
|
+
const scaleY = image.height / viewport.height;
|
|
108
|
+
const x = Math.max(0, Math.floor(box.x * scaleX) - REGION_PADDING);
|
|
109
|
+
const y = Math.max(0, Math.floor(box.y * scaleY) - REGION_PADDING);
|
|
110
|
+
const maxX = Math.min(image.width, Math.ceil((box.x + box.width) * scaleX) + REGION_PADDING);
|
|
111
|
+
const maxY = Math.min(image.height, Math.ceil((box.y + box.height) * scaleY) + REGION_PADDING);
|
|
112
|
+
if (maxX <= x || maxY <= y)
|
|
113
|
+
return null;
|
|
114
|
+
return { x, y, width: maxX - x, height: maxY - y };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function findUnmarkedOverlay(page) {
|
|
121
|
+
return page.evaluate(() => {
|
|
122
|
+
const elements = [...document.body.querySelectorAll('*')].map((element) => {
|
|
123
|
+
const style = getComputedStyle(element);
|
|
124
|
+
const box = element.getBoundingClientRect();
|
|
125
|
+
const zIndex = Number.parseInt(style.zIndex, 10);
|
|
126
|
+
return { element, style, box, zIndex, area: box.width * box.height };
|
|
127
|
+
});
|
|
128
|
+
const isVisibleLayer = ({ style, box, zIndex }) => {
|
|
129
|
+
if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0)
|
|
130
|
+
return false;
|
|
131
|
+
if (box.width <= 0 || box.height <= 0)
|
|
132
|
+
return false;
|
|
133
|
+
if (style.position !== 'fixed' && style.position !== 'absolute')
|
|
134
|
+
return false;
|
|
135
|
+
return Number.isFinite(zIndex);
|
|
136
|
+
};
|
|
137
|
+
const backdropZIndex = elements.filter((item) => isVisibleLayer(item) && item.box.width >= window.innerWidth && item.box.height >= window.innerHeight).reduce((highest, item) => Math.max(highest, item.zIndex), Number.NEGATIVE_INFINITY);
|
|
138
|
+
if (!Number.isFinite(backdropZIndex))
|
|
139
|
+
return null;
|
|
140
|
+
const candidates = elements
|
|
141
|
+
.filter((item) => {
|
|
142
|
+
if (!isVisibleLayer(item))
|
|
143
|
+
return false;
|
|
144
|
+
if (item.box.width >= window.innerWidth && item.box.height >= window.innerHeight)
|
|
145
|
+
return false;
|
|
146
|
+
if (item.zIndex < backdropZIndex)
|
|
147
|
+
return false;
|
|
148
|
+
return item.element.matches('button, input, select, textarea, a[href]') || !!item.element.querySelector('button, input, select, textarea, a[href]');
|
|
149
|
+
})
|
|
150
|
+
.sort((left, right) => right.zIndex - left.zIndex || left.area - right.area);
|
|
151
|
+
const box = candidates[0]?.box;
|
|
152
|
+
if (!box)
|
|
153
|
+
return null;
|
|
154
|
+
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseResearchSections } from "../../../src/ai/researcher/parser.js";
|
|
4
|
-
import { detectFocusArea } from "../../../src/utils/aria.js";
|
|
5
4
|
import { safeFilename, sanitizeFilename } from "../../../src/utils/strings.js";
|
|
5
|
+
import { captureInteractionAfter, captureInteractionBefore } from "./interaction-screenshots.js";
|
|
6
6
|
const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
|
|
7
7
|
export async function captureDocumentationScreenshots(explorer, state, research, options) {
|
|
8
8
|
const page = explorer.page;
|
|
@@ -47,7 +47,7 @@ export function getScreenshotSections(research) {
|
|
|
47
47
|
}
|
|
48
48
|
return sections;
|
|
49
49
|
}
|
|
50
|
-
export async function captureInteractionScreenshot(explorer, state, transition, options) {
|
|
50
|
+
export async function captureInteractionScreenshot(explorer, beforeScreenshot, state, transition, options) {
|
|
51
51
|
const page = explorer.page;
|
|
52
52
|
if (!page) {
|
|
53
53
|
return null;
|
|
@@ -55,19 +55,13 @@ export async function captureInteractionScreenshot(explorer, state, transition,
|
|
|
55
55
|
mkdirSync(options.screenshotsDir, { recursive: true });
|
|
56
56
|
const pageName = sanitizeFilename(state.url || 'page') || 'page';
|
|
57
57
|
const stateName = sanitizeFilename(transition.targetState?.label || transition.action) || 'state';
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
else {
|
|
65
|
-
await page.screenshot({ path: filePath });
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
58
|
+
const stateId = state.id ? `_${state.id}` : '';
|
|
59
|
+
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}${stateId}`, '.png'));
|
|
60
|
+
const result = await captureInteractionAfter(page, beforeScreenshot, filePath, transition.changes?.urlChanged !== true);
|
|
61
|
+
if (result === 'unchanged')
|
|
62
|
+
return null;
|
|
63
|
+
if (result === 'failed' && !(await captureViewport(page, filePath)))
|
|
69
64
|
return null;
|
|
70
|
-
}
|
|
71
65
|
return {
|
|
72
66
|
title: transition.targetState?.label || transition.action,
|
|
73
67
|
path: filePath,
|
|
@@ -75,6 +69,21 @@ export async function captureInteractionScreenshot(explorer, state, transition,
|
|
|
75
69
|
kind: 'state',
|
|
76
70
|
};
|
|
77
71
|
}
|
|
72
|
+
export async function captureBeforeInteraction(explorer) {
|
|
73
|
+
const page = explorer.page;
|
|
74
|
+
if (!page)
|
|
75
|
+
return null;
|
|
76
|
+
return captureInteractionBefore(page);
|
|
77
|
+
}
|
|
78
|
+
async function captureViewport(page, filePath) {
|
|
79
|
+
try {
|
|
80
|
+
await page.screenshot({ path: filePath });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
78
87
|
async function captureFullPageScreenshot(page, pageName, options) {
|
|
79
88
|
const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
|
|
80
89
|
try {
|
|
@@ -60,8 +60,11 @@ function buildStateGraph(outputDir, pages) {
|
|
|
60
60
|
if (!targetId) {
|
|
61
61
|
continue;
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
if (adjacency.get(sourceId)?.has(targetId)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (createsCycle(sourceId, targetId, adjacency)) {
|
|
67
|
+
const pairKey = `${sourceId}>${targetId}`;
|
|
65
68
|
if (drawnBack.has(pairKey)) {
|
|
66
69
|
continue;
|
|
67
70
|
}
|
|
@@ -69,9 +72,6 @@ function buildStateGraph(outputDir, pages) {
|
|
|
69
72
|
edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: true });
|
|
70
73
|
continue;
|
|
71
74
|
}
|
|
72
|
-
if (adjacency.get(sourceId)?.has(targetId) || createsCycle(sourceId, targetId, adjacency)) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
75
|
adjacency.get(sourceId)?.add(targetId);
|
|
76
76
|
edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: false });
|
|
77
77
|
}
|
|
@@ -90,8 +90,8 @@ function buildStateGraph(outputDir, pages) {
|
|
|
90
90
|
function renderMermaidBody(outputDir, pages) {
|
|
91
91
|
return renderMermaidFromGraph(buildStateGraph(outputDir, pages));
|
|
92
92
|
}
|
|
93
|
-
function renderMermaidFromGraph(graph) {
|
|
94
|
-
const lines = ['flowchart TD'];
|
|
93
|
+
function renderMermaidFromGraph(graph, compact = false) {
|
|
94
|
+
const lines = [compact ? 'flowchart LR' : 'flowchart TD'];
|
|
95
95
|
if (graph.pages.length === 0) {
|
|
96
96
|
lines.push(' empty["No documented states"]');
|
|
97
97
|
return lines.join('\n');
|
|
@@ -102,6 +102,12 @@ function renderMermaidFromGraph(graph) {
|
|
|
102
102
|
if (!children || children.length === 0) {
|
|
103
103
|
continue;
|
|
104
104
|
}
|
|
105
|
+
if (compact) {
|
|
106
|
+
for (const child of children) {
|
|
107
|
+
lines.push(` ${renderNodeLine(child)}`);
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
105
111
|
lines.push(` subgraph sg_${page.id} ["${escapeMermaidLabel(page.label)} — transient states"]`);
|
|
106
112
|
for (const child of children) {
|
|
107
113
|
lines.push(` ${renderNodeLine(child)}`);
|
|
@@ -109,11 +115,13 @@ function renderMermaidFromGraph(graph) {
|
|
|
109
115
|
lines.push(' end');
|
|
110
116
|
}
|
|
111
117
|
for (const edge of graph.edges) {
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
arrow
|
|
118
|
+
const arrow = edge.isBack ? '-.->' : '-->';
|
|
119
|
+
if (compact) {
|
|
120
|
+
lines.push(` ${edge.source} ${arrow} ${edge.target}`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
lines.push(` ${edge.source} ${arrow}|"${escapeMermaidLabel(edge.action)}"| ${edge.target}`);
|
|
115
124
|
}
|
|
116
|
-
lines.push(` ${edge.source} ${arrow}|"${escapeMermaidLabel(edge.action)}"| ${edge.target}`);
|
|
117
125
|
}
|
|
118
126
|
lines.push(' classDef page fill:#dbeafe,stroke:#2563eb,color:#0f172a;');
|
|
119
127
|
lines.push(' classDef dialog fill:#ffedd5,stroke:#ea580c,color:#0f172a;');
|
|
@@ -153,6 +161,42 @@ function renderStateMapFromGraph(graph) {
|
|
|
153
161
|
}
|
|
154
162
|
return rows.join('\n');
|
|
155
163
|
}
|
|
164
|
+
function renderPageStateDiagram(label, url, interactions) {
|
|
165
|
+
const targets = new Map();
|
|
166
|
+
let index = 0;
|
|
167
|
+
for (const interaction of interactions) {
|
|
168
|
+
const targetState = interaction.targetState;
|
|
169
|
+
if (!targetState) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const key = `${targetState.kind}:${targetState.label}:${normalizeUrl(targetState.url)}`;
|
|
173
|
+
if (targets.has(key)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
targets.set(key, {
|
|
177
|
+
node: { id: `target${index++}`, kind: targetState.kind, label: targetState.label, subLabel: targetState.kind },
|
|
178
|
+
action: interaction.action,
|
|
179
|
+
screenshot: interaction.screenshot,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (targets.size === 0) {
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
const lines = ['flowchart LR'];
|
|
186
|
+
lines.push(` ${renderNodeLine({ id: 'self', kind: 'page', label, subLabel: url })}`);
|
|
187
|
+
for (const target of targets.values()) {
|
|
188
|
+
lines.push(` ${renderNodeLine(target.node)}`);
|
|
189
|
+
}
|
|
190
|
+
for (const target of targets.values()) {
|
|
191
|
+
lines.push(` self -->|"${escapeMermaidLabel(target.action)}"| ${target.node.id}`);
|
|
192
|
+
}
|
|
193
|
+
for (const target of targets.values()) {
|
|
194
|
+
if (target.screenshot) {
|
|
195
|
+
lines.push(` click ${target.node.id} "${target.screenshot.relativePath}" "${escapeMermaidLabel(target.screenshot.title)}"`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
156
200
|
function renderNodeLine(node) {
|
|
157
201
|
const label = `${escapeMermaidLabel(node.label)}<br/>${escapeMermaidLabel(node.subLabel)}`;
|
|
158
202
|
if (node.kind === 'dialog' || node.kind === 'modal') {
|
|
@@ -183,7 +227,7 @@ function createsCycle(sourceId, targetId, adjacency) {
|
|
|
183
227
|
return false;
|
|
184
228
|
}
|
|
185
229
|
function escapeMermaidLabel(value) {
|
|
186
|
-
return normalizeInlineText(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll('|', '|');
|
|
230
|
+
return normalizeInlineText(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll('#', '#').replaceAll('<', '<').replaceAll('>', '>').replaceAll('|', '|');
|
|
187
231
|
}
|
|
188
232
|
function escapeTable(value) {
|
|
189
233
|
return normalizeInlineText(value).replaceAll('|', '\\|');
|
|
@@ -197,4 +241,4 @@ function classForKind(kind) {
|
|
|
197
241
|
}
|
|
198
242
|
return 'dialog';
|
|
199
243
|
}
|
|
200
|
-
export { buildStateGraph, renderMermaidBody, renderMermaidFromGraph, renderStateMapFromGraph };
|
|
244
|
+
export { buildStateGraph, renderMermaidBody, renderMermaidFromGraph, renderPageStateDiagram, renderStateMapFromGraph };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import dedent from 'dedent';
|
|
3
|
+
import { keepServerRunning } from "../../../src/browser-server.js";
|
|
4
|
+
import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
|
|
5
|
+
import { setPreserveConsoleLogs } from "../../../src/utils/logger.js";
|
|
6
|
+
import { renderEnvelope } from "./envelope.js";
|
|
7
|
+
import { Prima } from "./prima.js";
|
|
8
|
+
const helpContract = dedent `
|
|
9
|
+
Prima drives a browser that is already open. One command per process; every command
|
|
10
|
+
prints a plain-text envelope on stdout and exits 0 when ok, 1 when not.
|
|
11
|
+
|
|
12
|
+
TIERS - choose by what you hold, not by how hard the step looks
|
|
13
|
+
pw <fn> Precise. A Playwright function expression built from a locator you
|
|
14
|
+
already verified. No AI on the happy path.
|
|
15
|
+
prima pw "({ page }) => page.click('[data-test=submit]')"
|
|
16
|
+
click / fill One action described in words; AI resolves it on the current page.
|
|
17
|
+
prima click "the primary action button in the header"
|
|
18
|
+
prima fill "the search box" "a search term"
|
|
19
|
+
do <steps...> Several described steps, run tester-style in one process.
|
|
20
|
+
prima do "open the account menu" "choose the settings entry"
|
|
21
|
+
Never pass a locator or a function expression to click/fill/do - describe the target.
|
|
22
|
+
Never pass a description to pw - it takes executable code only.
|
|
23
|
+
|
|
24
|
+
LOOP
|
|
25
|
+
prima go <url|path|words> reach the page you want to work on
|
|
26
|
+
prima research once per new page; returns verified locators
|
|
27
|
+
prima pw "..." drive the page with those locators
|
|
28
|
+
prima verify "..." assert the outcome (prima ask "..." to inspect instead)
|
|
29
|
+
Fall back to click/fill/do whenever research left you no locator to hold.
|
|
30
|
+
|
|
31
|
+
ENVELOPE
|
|
32
|
+
### Result ok, command, healed, used
|
|
33
|
+
### Page url, title, state hash, visit count
|
|
34
|
+
### Changes what the accessibility tree gained or lost
|
|
35
|
+
### Answer | ### Research | ### Verdict output of ask, research, verify
|
|
36
|
+
### Failure error, reasoning, healing attempts, compact ARIA of the page
|
|
37
|
+
### Instance the browser you are on and the other instances running
|
|
38
|
+
### Artifacts paths to the full aria.yml, page.html and network.jsonl
|
|
39
|
+
used: is code that already executed - CodeceptJS steps to copy as they are, except
|
|
40
|
+
for pw, whose Playwright expression a test needs inside I.usePlaywrightTo(...).
|
|
41
|
+
Log lines can precede the envelope; start parsing at the first ### line.
|
|
42
|
+
|
|
43
|
+
HEALING AND FAILURE
|
|
44
|
+
A failed action is retried by AI along a different route; healed: true means the
|
|
45
|
+
outcome was reached another way and used: holds the code that worked.
|
|
46
|
+
--no-heal skips that and fails fast.
|
|
47
|
+
Failures print compact ARIA inline, so retarget from the envelope itself and open
|
|
48
|
+
the artifact files only when the inline snapshot is not enough.
|
|
49
|
+
|
|
50
|
+
SESSIONS
|
|
51
|
+
By default prima attaches to the playwright-cli browser of this workspace and works
|
|
52
|
+
on the tabs it already has open; driving the same session from both tools is the
|
|
53
|
+
intended usage.
|
|
54
|
+
playwright-cli open <url> the session prima attaches to
|
|
55
|
+
--pw-session <title> which playwright-cli session, when several are open
|
|
56
|
+
--endpoint <ep> attach to a browser server endpoint directly
|
|
57
|
+
prima browser start a prima-owned browser instead, when no session is open
|
|
58
|
+
--instance <name> which prima-owned browser you talk to; parallel work
|
|
59
|
+
needs one each
|
|
60
|
+
--session [file] cookies and storage persisted across processes; ignored
|
|
61
|
+
while attached, the attached session keeps its own
|
|
62
|
+
Prima never launches a browser implicitly and never closes an attached one - it
|
|
63
|
+
disconnects. browser list shows both kinds; ### Instance names the one you are on.
|
|
64
|
+
Every browser is reached over a Playwright browser-server endpoint, which needs the
|
|
65
|
+
Node build - run prima as "npx explorbot prima ..." or through the published prima
|
|
66
|
+
bin; from source under Bun the connection does not open.
|
|
67
|
+
When no AI model is usable pw still works; for everything else drive
|
|
68
|
+
playwright-cli directly.
|
|
69
|
+
Parsed but not active yet: --framework, so reported code is CodeceptJS whatever
|
|
70
|
+
you pass.
|
|
71
|
+
`;
|
|
72
|
+
function buildOptions(options) {
|
|
73
|
+
return {
|
|
74
|
+
verbose: options.verbose || options.debug,
|
|
75
|
+
config: options.config,
|
|
76
|
+
path: options.path,
|
|
77
|
+
instance: options.instance,
|
|
78
|
+
session: options.session,
|
|
79
|
+
heal: options.heal,
|
|
80
|
+
ephemeral: options.ephemeral,
|
|
81
|
+
framework: options.framework,
|
|
82
|
+
noVision: options.vision === false,
|
|
83
|
+
url: options.url,
|
|
84
|
+
baseUrl: options.baseUrl,
|
|
85
|
+
show: options.show,
|
|
86
|
+
headless: options.headless,
|
|
87
|
+
endpoint: options.endpoint,
|
|
88
|
+
pwSession: options.pwSession,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function addCommonOptions(cmd) {
|
|
92
|
+
return cmd
|
|
93
|
+
.option('-v, --verbose', 'Enable verbose logging')
|
|
94
|
+
.option('--debug', 'Enable debug logging (same as --verbose)')
|
|
95
|
+
.option('-c, --config <path>', 'Path to explorbot configuration file')
|
|
96
|
+
.option('-p, --path <path>', 'Working directory path')
|
|
97
|
+
.option('-i, --instance <name>', 'Browser instance to drive')
|
|
98
|
+
.option('--session [file]', 'Persist cookies and storage to a session file')
|
|
99
|
+
.option('--no-heal', 'Fail immediately instead of letting AI retry a failed action')
|
|
100
|
+
.option('--ephemeral', 'Keep no state between runs; applies to config-free runs, where output goes to a temp directory')
|
|
101
|
+
.option('--framework <name>', 'Not active yet: framework the reported code targets, codeceptjs or playwright')
|
|
102
|
+
.option('--url <url>', 'Page to open when the session has no page yet')
|
|
103
|
+
.option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
|
|
104
|
+
.option('--pw-session <title>', 'Title of the playwright-cli session to attach to');
|
|
105
|
+
}
|
|
106
|
+
function primaFor(options) {
|
|
107
|
+
setPreserveConsoleLogs(true);
|
|
108
|
+
if (options.ephemeral)
|
|
109
|
+
process.env.EXPLORBOT_EPHEMERAL = '1';
|
|
110
|
+
return new Prima(buildOptions(options));
|
|
111
|
+
}
|
|
112
|
+
async function runPrima(options, command, run) {
|
|
113
|
+
const prima = primaFor(options);
|
|
114
|
+
let envelope;
|
|
115
|
+
try {
|
|
116
|
+
await prima.start();
|
|
117
|
+
envelope = await run(prima);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
envelope = await prima.toolFailureEnvelope(command, error);
|
|
121
|
+
}
|
|
122
|
+
console.log(renderEnvelope(envelope));
|
|
123
|
+
await prima.stop().catch(() => { });
|
|
124
|
+
process.exit(envelope.ok ? 0 : 1);
|
|
125
|
+
}
|
|
126
|
+
async function runBrowser(options, run) {
|
|
127
|
+
let ok = false;
|
|
128
|
+
try {
|
|
129
|
+
ok = await run(primaFor(options));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.error(browserErrorMessage(error));
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
process.exit(ok ? 0 : 1);
|
|
136
|
+
}
|
|
137
|
+
export function createPrimaCommands(name = 'prima') {
|
|
138
|
+
const cmd = new Command(name);
|
|
139
|
+
cmd.description('Drive an already-open browser one command at a time and report back in a plain-text envelope');
|
|
140
|
+
cmd.addHelpText('after', `\n${helpContract}`);
|
|
141
|
+
addCommonOptions(cmd.command('pw <fn>').description('Run a Playwright function expression against the open page')).action(async (fn, options) => {
|
|
142
|
+
await runPrima(options, `pw ${fn}`, (prima) => prima.pw(fn));
|
|
143
|
+
});
|
|
144
|
+
addCommonOptions(cmd.command('do <instructions...>').description('Run high-level instructions tester-style, one argument per instruction')).action(async (instructions, options) => {
|
|
145
|
+
await runPrima(options, `do ${instructions.join(' ')}`, (prima) => prima.do(instructions));
|
|
146
|
+
});
|
|
147
|
+
addCommonOptions(cmd.command('click <target>').description('Click an element described in plain words')).action(async (target, options) => {
|
|
148
|
+
await runPrima(options, `click ${target}`, (prima) => prima.click(target));
|
|
149
|
+
});
|
|
150
|
+
addCommonOptions(cmd.command('fill <field> <value>').description('Fill a field described in plain words')).action(async (field, value, options) => {
|
|
151
|
+
await runPrima(options, `fill ${field} ${value}`, (prima) => prima.fill(field, value));
|
|
152
|
+
});
|
|
153
|
+
addCommonOptions(cmd.command('ask <question>').description('Answer a question about the current page').option('--no-vision', 'Answer from page structure only, without a screenshot')).action(async (question, options) => {
|
|
154
|
+
await runPrima(options, `ask ${question}`, (prima) => prima.ask(question));
|
|
155
|
+
});
|
|
156
|
+
addCommonOptions(cmd.command('verify <assertion>').alias('assert').description('Assert a statement about the current page')).action(async (assertion, options) => {
|
|
157
|
+
await runPrima(options, `verify ${assertion}`, (prima) => prima.verify(assertion));
|
|
158
|
+
});
|
|
159
|
+
addCommonOptions(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')).action(async (options) => {
|
|
160
|
+
await runPrima(options, 'research', (prima) => prima.research({ data: options.data, deep: options.deep, fresh: options.fresh }));
|
|
161
|
+
});
|
|
162
|
+
addCommonOptions(cmd.command('go <target>').description('Navigate to a url, a path, or a page described in plain words')).action(async (target, options) => {
|
|
163
|
+
if (URL.canParse(target))
|
|
164
|
+
options.baseUrl = target;
|
|
165
|
+
await runPrima(options, `go ${target}`, (prima) => prima.go(target));
|
|
166
|
+
});
|
|
167
|
+
const browser = cmd.command('browser').description('Manage the browsers prima drives');
|
|
168
|
+
addCommonOptions(browser.command('start').description('Start a prima-owned browser and hold it open until Ctrl+C'))
|
|
169
|
+
.option('-s, --show', 'Launch the browser in a visible window')
|
|
170
|
+
.option('--headless', 'Launch the browser without a window')
|
|
171
|
+
.action(async (options) => {
|
|
172
|
+
await runBrowser(options, async (prima) => {
|
|
173
|
+
await prima.browserStart();
|
|
174
|
+
console.log(await prima.browserStatus());
|
|
175
|
+
return keepServerRunning(() => prima.browserStop());
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
addCommonOptions(browser.command('stop').description('Stop the browser of this instance'))
|
|
179
|
+
.option('--all', 'Stop every running instance')
|
|
180
|
+
.action(async (options) => {
|
|
181
|
+
await runBrowser(options, async (prima) => {
|
|
182
|
+
const stopped = await prima.browserStop(options.all);
|
|
183
|
+
console.log(await prima.browserStatus());
|
|
184
|
+
return stopped;
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
addCommonOptions(browser.command('status').description('Report the browser of this instance')).action(async (options) => {
|
|
188
|
+
await runBrowser(options, async (prima) => {
|
|
189
|
+
console.log(await prima.browserStatus());
|
|
190
|
+
return true;
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
addCommonOptions(browser.command('list').description('List every browser instance that is running')).action(async (options) => {
|
|
194
|
+
await runBrowser(options, async (prima) => {
|
|
195
|
+
console.log(await prima.browserList());
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
return cmd;
|
|
200
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function renderEnvelope(data) {
|
|
4
|
+
const sections = [renderResult(data), renderPage(data), renderOutcome(data), ...renderFailure(data), renderInstance(data.instance), renderArtifacts(data)];
|
|
5
|
+
return sections.filter((section) => section).join('\n\n');
|
|
6
|
+
}
|
|
7
|
+
export function writeArtifacts(dir, snapshot) {
|
|
8
|
+
mkdirSync(dir, { recursive: true });
|
|
9
|
+
const paths = {
|
|
10
|
+
aria: path.resolve(dir, 'aria.yml'),
|
|
11
|
+
html: path.resolve(dir, 'page.html'),
|
|
12
|
+
network: path.resolve(dir, 'network.jsonl'),
|
|
13
|
+
};
|
|
14
|
+
writeFileSync(paths.aria, snapshot.aria ?? '', 'utf-8');
|
|
15
|
+
writeFileSync(paths.html, snapshot.html ?? '', 'utf-8');
|
|
16
|
+
writeFileSync(paths.network, snapshot.requests.map((request) => `${JSON.stringify(request)}\n`).join(''), 'utf-8');
|
|
17
|
+
return paths;
|
|
18
|
+
}
|
|
19
|
+
function renderResult(data) {
|
|
20
|
+
const lines = [`ok: ${data.ok}`, `command: ${data.command}`];
|
|
21
|
+
const healed = renderHealed(data);
|
|
22
|
+
if (healed)
|
|
23
|
+
lines.push(healed);
|
|
24
|
+
if (data.used?.length)
|
|
25
|
+
lines.push(`used: ${data.used.join('; ')}`);
|
|
26
|
+
return section('Result', lines.join('\n'));
|
|
27
|
+
}
|
|
28
|
+
function renderHealed(data) {
|
|
29
|
+
if (data.healed === undefined)
|
|
30
|
+
return null;
|
|
31
|
+
if (data.healNote)
|
|
32
|
+
return `healed: ${data.healed} (${data.healNote})`;
|
|
33
|
+
return `healed: ${data.healed}`;
|
|
34
|
+
}
|
|
35
|
+
function renderPage(data) {
|
|
36
|
+
const { url, previousUrl, title, state, visits } = data.page;
|
|
37
|
+
const urlLabel = `url: ${url}`;
|
|
38
|
+
const stateLabel = `state: ${state}`;
|
|
39
|
+
const width = Math.max(urlLabel.length, stateLabel.length) + 3;
|
|
40
|
+
let changedMarker = '';
|
|
41
|
+
if (previousUrl && previousUrl !== url)
|
|
42
|
+
changedMarker = `(changed: ${previousUrl} → ${url})`;
|
|
43
|
+
const lines = [align(urlLabel, changedMarker, width), `title: ${title}`, align(stateLabel, `(visit #${visits})`, width)];
|
|
44
|
+
return section('Page', lines.join('\n'));
|
|
45
|
+
}
|
|
46
|
+
function renderOutcome(data) {
|
|
47
|
+
if (data.changes)
|
|
48
|
+
return section('Changes', data.changes);
|
|
49
|
+
if (data.answer)
|
|
50
|
+
return section('Answer', data.answer);
|
|
51
|
+
if (data.research)
|
|
52
|
+
return section('Research', data.research);
|
|
53
|
+
if (!data.verdict)
|
|
54
|
+
return null;
|
|
55
|
+
const lines = [`passed: ${data.verdict.passed}`, `evidence: ${data.verdict.evidence}`, `code: ${data.verdict.code}`];
|
|
56
|
+
return section('Verdict', lines.join('\n'));
|
|
57
|
+
}
|
|
58
|
+
function renderFailure(data) {
|
|
59
|
+
if (!data.failure)
|
|
60
|
+
return [];
|
|
61
|
+
const lines = [`error: ${data.failure.error}`];
|
|
62
|
+
if (data.failure.reasoning)
|
|
63
|
+
lines.push(`reasoning: ${data.failure.reasoning}`);
|
|
64
|
+
return [section('Failure', lines.join('\n')), renderAttempts(data.failure.attempts), renderCompactAria(data.failure.compactAria)];
|
|
65
|
+
}
|
|
66
|
+
function renderAttempts(attempts) {
|
|
67
|
+
if (!attempts?.length)
|
|
68
|
+
return null;
|
|
69
|
+
const labels = attempts.map((attempt, index) => `${index + 1}. ${attempt.code}`);
|
|
70
|
+
const width = Math.max(...labels.map((label) => label.length)) + 3;
|
|
71
|
+
const lines = labels.map((label, index) => align(label, `→ ${attempts[index].outcome}`, width));
|
|
72
|
+
return section(`Healing attempts (${attempts.length})`, lines.join('\n'));
|
|
73
|
+
}
|
|
74
|
+
function renderCompactAria(compactAria) {
|
|
75
|
+
if (!compactAria)
|
|
76
|
+
return null;
|
|
77
|
+
return section('Current page (compact ARIA)', compactAria);
|
|
78
|
+
}
|
|
79
|
+
function renderInstance(instance) {
|
|
80
|
+
const others = instance.others.map((other) => `${other.name} (${tabsLabel(other.tabs)})`);
|
|
81
|
+
const lines = [`instance: ${instance.name} (${tabsLabel(instance.tabs)}) | other instances: ${otherInstances(others)}`, browserLine(instance)];
|
|
82
|
+
return section('Instance', lines.join('\n'));
|
|
83
|
+
}
|
|
84
|
+
function otherInstances(others) {
|
|
85
|
+
if (!others.length)
|
|
86
|
+
return 'none';
|
|
87
|
+
return others.join(', ');
|
|
88
|
+
}
|
|
89
|
+
function browserLine(instance) {
|
|
90
|
+
if (instance.attached)
|
|
91
|
+
return `browser: attached (${instance.attached})`;
|
|
92
|
+
if (instance.startedAgo)
|
|
93
|
+
return `browser: running, started ${instance.startedAgo} ago`;
|
|
94
|
+
if (instance.tabs > 0)
|
|
95
|
+
return 'browser: running';
|
|
96
|
+
return 'browser: not running';
|
|
97
|
+
}
|
|
98
|
+
function tabsLabel(tabs) {
|
|
99
|
+
if (tabs === 1)
|
|
100
|
+
return '1 tab';
|
|
101
|
+
return `${tabs} tabs`;
|
|
102
|
+
}
|
|
103
|
+
function renderArtifacts(data) {
|
|
104
|
+
if (!data.artifacts)
|
|
105
|
+
return null;
|
|
106
|
+
const lines = [`aria: ${data.artifacts.aria}`, `html: ${data.artifacts.html}`, `network: ${data.artifacts.network}`];
|
|
107
|
+
return section('Artifacts', lines.join('\n'));
|
|
108
|
+
}
|
|
109
|
+
function align(label, marker, width) {
|
|
110
|
+
if (!marker)
|
|
111
|
+
return label;
|
|
112
|
+
return `${label.padEnd(width)}${marker}`;
|
|
113
|
+
}
|
|
114
|
+
function section(title, body) {
|
|
115
|
+
return `### ${title}\n${body}`;
|
|
116
|
+
}
|