explorbot 0.3.5 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +23 -16
- package/boat/api-tester/src/ai/chief.ts +7 -1
- package/boat/api-tester/src/ai/curler.ts +7 -1
- package/boat/api-tester/src/apibot.ts +10 -4
- package/boat/api-tester/src/cli.ts +12 -2
- package/boat/api-tester/src/config.ts +28 -8
- package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
- package/boat/doc-collector/src/cli.ts +6 -1
- package/boat/doc-collector/src/docbot.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +2 -0
- package/boat/prima/src/cli.ts +23 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +24 -16
- package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
- package/dist/boat/api-tester/src/ai/chief.js +6 -1
- package/dist/boat/api-tester/src/ai/curler.js +6 -1
- package/dist/boat/api-tester/src/apibot.js +7 -3
- package/dist/boat/api-tester/src/cli.js +12 -2
- package/dist/boat/api-tester/src/config.js +31 -8
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
- package/dist/boat/doc-collector/src/cli.js +6 -1
- package/dist/boat/doc-collector/src/docbot.js +4 -2
- package/dist/boat/prima/bin/prima-cli.js +2 -0
- package/dist/boat/prima/src/cli.js +22 -8
- package/dist/boat/prima/src/envelope.js +24 -6
- package/dist/boat/prima/src/prima.js +23 -11
- package/dist/package.json +2 -2
- package/dist/rules/planner/styles/normal.md +1 -1
- package/dist/src/action-result.d.ts +9 -1
- package/dist/src/action-result.js +57 -18
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +87 -12
- package/dist/src/ai/captain.js +1 -1
- package/dist/src/ai/driller.d.ts +0 -1
- package/dist/src/ai/driller.js +8 -20
- package/dist/src/ai/fisherman-tools.d.ts +9 -0
- package/dist/src/ai/fisherman-tools.js +52 -6
- package/dist/src/ai/fisherman.d.ts +4 -2
- package/dist/src/ai/fisherman.js +48 -27
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/navigator.js +1 -1
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +13 -1
- package/dist/src/ai/planner.js +9 -7
- package/dist/src/ai/provider.js +20 -3
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.js +5 -3
- package/dist/src/ai/rules.js +3 -3
- package/dist/src/ai/tester.d.ts +3 -0
- package/dist/src/ai/tester.js +40 -3
- package/dist/src/ai/tools.d.ts +1 -0
- package/dist/src/ai/tools.js +13 -6
- package/dist/src/api/request-result.d.ts +2 -0
- package/dist/src/api/request-result.js +8 -2
- package/dist/src/api/request-store.d.ts +3 -2
- package/dist/src/api/request-store.js +66 -14
- package/dist/src/api/spec-reader.js +1 -1
- package/dist/src/commands/config-command.js +1 -1
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +39 -3
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/options/base-option.d.ts +8 -0
- package/dist/src/commands/options/base-option.js +12 -0
- package/dist/src/commands/options/index.d.ts +5 -0
- package/dist/src/commands/options/index.js +5 -0
- package/dist/src/commands/options/knowledge-option.d.ts +7 -0
- package/dist/src/commands/options/knowledge-option.js +12 -0
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +21 -0
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +12 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +24 -37
- package/dist/src/knowledge-tracker.d.ts +20 -7
- package/dist/src/knowledge-tracker.js +69 -31
- package/dist/src/remote.d.ts +0 -3
- package/dist/src/remote.js +0 -18
- package/dist/src/state-manager.d.ts +5 -1
- package/dist/src/state-manager.js +10 -7
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +27 -0
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +6 -42
- package/dist/src/utils/html-diff.d.ts +4 -0
- package/dist/src/utils/html-diff.js +62 -7
- package/dist/src/utils/html.d.ts +5 -15
- package/dist/src/utils/html.js +14 -85
- package/dist/src/utils/overlay.d.ts +56 -11
- package/dist/src/utils/overlay.js +191 -21
- package/dist/src/utils/request-map.d.ts +7 -0
- package/dist/src/utils/request-map.js +16 -0
- package/dist/src/utils/url-matcher.js +4 -2
- package/docs/api-testing/basics.md +15 -0
- package/docs/api-testing/planning.md +10 -1
- package/docs/reference/commands.md +32 -5
- package/docs/reference/websocket.md +1 -0
- package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
- package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
- package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
- package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
- package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
- package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
- package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
- package/docs/workflow/agentic-usage.md +12 -2
- package/docs/workflow/ci.md +1 -0
- package/docs/workflow/knowledge.md +46 -2
- package/package.json +2 -2
- package/rules/planner/styles/normal.md +1 -1
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- package/src/ai/captain.ts +1 -1
- package/src/ai/driller.ts +7 -39
- package/src/ai/fisherman-tools.ts +56 -7
- package/src/ai/fisherman.ts +48 -28
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +1 -1
- package/src/ai/navigator.ts +1 -1
- package/src/ai/pilot.ts +9 -1
- package/src/ai/planner.ts +9 -8
- package/src/ai/provider.ts +21 -3
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +3 -3
- package/src/ai/tester.ts +40 -3
- package/src/ai/tools.ts +17 -9
- package/src/api/request-result.ts +10 -2
- package/src/api/request-store.ts +60 -13
- package/src/api/spec-reader.ts +1 -1
- package/src/commands/config-command.ts +1 -1
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +37 -3
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/options/base-option.ts +18 -0
- package/src/commands/options/index.ts +7 -0
- package/src/commands/options/knowledge-option.ts +14 -0
- package/src/commands/options/ws-option.ts +24 -0
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +12 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +21 -37
- package/src/knowledge-tracker.ts +94 -36
- package/src/remote.ts +0 -20
- package/src/state-manager.ts +13 -7
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +7 -44
- package/src/utils/html-diff.ts +62 -7
- package/src/utils/html.ts +14 -91
- package/src/utils/overlay.ts +226 -23
- package/src/utils/request-map.ts +19 -0
- package/src/utils/url-matcher.ts +3 -2
package/src/knowledge-tracker.ts
CHANGED
|
@@ -11,36 +11,54 @@ import { loadMarkdownFiles } from './utils/markdown-files.js';
|
|
|
11
11
|
import { mdq } from './utils/markdown-query.js';
|
|
12
12
|
import { isSecretName, registerSecret } from './utils/secrets.js';
|
|
13
13
|
import { slugify } from './utils/strings.js';
|
|
14
|
+
import { extractStatePath, matchesUrl } from './utils/url-matcher.js';
|
|
14
15
|
|
|
15
16
|
const debugLog = createDebug('explorbot:knowledge-tracker');
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
filePath: string;
|
|
19
|
-
url: string;
|
|
20
|
-
content: string;
|
|
21
|
-
[key: string]: any;
|
|
22
|
-
}
|
|
18
|
+
const sessionEntries: string[] = [];
|
|
23
19
|
|
|
24
20
|
export class KnowledgeTracker {
|
|
25
21
|
private knowledgeDir: string;
|
|
26
22
|
private knowledgeFiles: Knowledge[] = [];
|
|
23
|
+
private sessionKnowledge: Knowledge[] = [];
|
|
27
24
|
private isLoaded = false;
|
|
28
25
|
private applicationSpec?: ApplicationSpec;
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
static appendSessionKnowledge(text: string): void {
|
|
28
|
+
sessionEntries.push(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static resetSessionKnowledge(): void {
|
|
32
|
+
sessionEntries.length = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
constructor(options: KnowledgeTrackerOptions = {}) {
|
|
36
|
+
let knowledgeDir = options.knowledgeDir;
|
|
37
|
+
let specPath = options.applicationSpec;
|
|
38
|
+
|
|
39
|
+
if (!knowledgeDir) {
|
|
40
|
+
const configParser = ConfigParser.getInstance();
|
|
41
|
+
const config = configParser.getConfig();
|
|
42
|
+
knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
|
|
43
|
+
specPath ||= config.dirs?.spec;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.knowledgeDir = knowledgeDir;
|
|
34
47
|
|
|
35
48
|
if (!existsSync(this.knowledgeDir)) {
|
|
36
49
|
mkdirSync(this.knowledgeDir, { recursive: true });
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
const specPath = applicationSpecPath || config.dirs?.spec;
|
|
40
52
|
if (specPath) {
|
|
41
53
|
this.applicationSpec = new ApplicationSpec(specPath);
|
|
42
54
|
tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
|
|
43
55
|
}
|
|
56
|
+
|
|
57
|
+
this.sessionKnowledge = sessionEntries.map((entry, index) => {
|
|
58
|
+
const parsed = matter(entry);
|
|
59
|
+
debugLog(`Session knowledge #${index + 1}`);
|
|
60
|
+
return this.toKnowledge(`--knowledge #${index + 1}`, parsed.data, parsed.content.trim());
|
|
61
|
+
});
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
private loadKnowledgeFiles(): void {
|
|
@@ -49,12 +67,7 @@ export class KnowledgeTracker {
|
|
|
49
67
|
this.knowledgeFiles = [];
|
|
50
68
|
|
|
51
69
|
for (const entry of loadMarkdownFiles(this.knowledgeDir, { recursive: true })) {
|
|
52
|
-
this.knowledgeFiles.push(
|
|
53
|
-
filePath: entry.filePath,
|
|
54
|
-
url: entry.data.url || entry.data.path || '*',
|
|
55
|
-
content: this.interpolateVars(entry.content),
|
|
56
|
-
...entry.data,
|
|
57
|
-
});
|
|
70
|
+
this.knowledgeFiles.push(this.toKnowledge(entry.filePath, entry.data, entry.content));
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
this.isLoaded = true;
|
|
@@ -63,28 +76,22 @@ export class KnowledgeTracker {
|
|
|
63
76
|
getRelevantKnowledge(state: ActionResult): Knowledge[] {
|
|
64
77
|
this.loadKnowledgeFiles();
|
|
65
78
|
|
|
66
|
-
return this.
|
|
67
|
-
return state.isMatchedBy(knowledge);
|
|
68
|
-
});
|
|
79
|
+
return this.allKnowledge().filter((knowledge) => knowledge.url && state.isMatchedBy(knowledge));
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
getEndpointKnowledge(endpoint: string): Knowledge[] {
|
|
83
|
+
this.loadKnowledgeFiles();
|
|
84
|
+
const path = extractStatePath(endpoint);
|
|
74
85
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.filter((k) => !!k)
|
|
78
|
-
.join('\n\n');
|
|
86
|
+
return this.allKnowledge().filter((knowledge) => knowledge.endpoint && matchesUrl(knowledge.endpoint, path));
|
|
87
|
+
}
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
Here is relevant knowledge for this page:
|
|
89
|
+
renderRelevantKnowledge(state: ActionResult): string {
|
|
90
|
+
return this.renderKnowledge(this.getRelevantKnowledge(state), 'page');
|
|
91
|
+
}
|
|
84
92
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
`;
|
|
93
|
+
renderEndpointKnowledge(endpoint: string): string {
|
|
94
|
+
return this.renderKnowledge(this.getEndpointKnowledge(endpoint), 'endpoint');
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
renderRelevantContext(state: ActionResult): string {
|
|
@@ -188,7 +195,7 @@ export class KnowledgeTracker {
|
|
|
188
195
|
getExistingUrls(): string[] {
|
|
189
196
|
this.loadKnowledgeFiles();
|
|
190
197
|
|
|
191
|
-
return this.knowledgeFiles.map((knowledge) => knowledge.url).filter((url) => url && url !== '*');
|
|
198
|
+
return this.knowledgeFiles.map((knowledge) => knowledge.url || '').filter((url) => url && url !== '*');
|
|
192
199
|
}
|
|
193
200
|
|
|
194
201
|
getKnowledgeForUrl(urlPattern: string): string[] {
|
|
@@ -205,7 +212,7 @@ export class KnowledgeTracker {
|
|
|
205
212
|
const content = knowledge.content.trim();
|
|
206
213
|
const firstLine = mdq(content).meta()[0]?.text.split('\n')[0]?.trim() || '';
|
|
207
214
|
return {
|
|
208
|
-
url: knowledge.url,
|
|
215
|
+
url: knowledge.url || knowledge.endpoint || '',
|
|
209
216
|
firstLine,
|
|
210
217
|
filePath: knowledge.filePath,
|
|
211
218
|
};
|
|
@@ -242,4 +249,55 @@ export class KnowledgeTracker {
|
|
|
242
249
|
|
|
243
250
|
return result;
|
|
244
251
|
}
|
|
252
|
+
|
|
253
|
+
private allKnowledge(): Knowledge[] {
|
|
254
|
+
return [...this.knowledgeFiles, ...this.sessionKnowledge];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private toKnowledge(filePath: string, data: Record<string, any>, content: string): Knowledge {
|
|
258
|
+
const knowledge: Knowledge = {
|
|
259
|
+
...data,
|
|
260
|
+
filePath,
|
|
261
|
+
url: data.url || data.path,
|
|
262
|
+
content: this.interpolateVars(content),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (!data.url && !data.path && !data.endpoint) {
|
|
266
|
+
knowledge.url = '*';
|
|
267
|
+
knowledge.endpoint = '*';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return knowledge;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private renderKnowledge(knowledgeFiles: Knowledge[], scope: string): string {
|
|
274
|
+
if (knowledgeFiles.length === 0) return '';
|
|
275
|
+
|
|
276
|
+
const knowledgeContent = knowledgeFiles
|
|
277
|
+
.map((k) => k.content)
|
|
278
|
+
.filter((k) => !!k)
|
|
279
|
+
.join('\n\n');
|
|
280
|
+
|
|
281
|
+
tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
|
|
282
|
+
return dedent`
|
|
283
|
+
<knowledge>
|
|
284
|
+
Here is relevant knowledge for this ${scope}:
|
|
285
|
+
|
|
286
|
+
${knowledgeContent}
|
|
287
|
+
</knowledge>
|
|
288
|
+
`;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface Knowledge {
|
|
293
|
+
filePath: string;
|
|
294
|
+
url?: string;
|
|
295
|
+
endpoint?: string;
|
|
296
|
+
content: string;
|
|
297
|
+
[key: string]: any;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface KnowledgeTrackerOptions {
|
|
301
|
+
applicationSpec?: string;
|
|
302
|
+
knowledgeDir?: string;
|
|
245
303
|
}
|
package/src/remote.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Command } from 'commander';
|
|
2
1
|
import stripAnsi from 'strip-ansi';
|
|
3
2
|
import { type ActivityEntry, addActivityListener } from './activity.ts';
|
|
4
3
|
import { executionController } from './execution-controller.ts';
|
|
@@ -30,15 +29,6 @@ export class Remote implements LogDestination {
|
|
|
30
29
|
private askCounter = 0;
|
|
31
30
|
private lastActivity: string | null = null;
|
|
32
31
|
|
|
33
|
-
registerOption(program: Command): void {
|
|
34
|
-
program.option('--ws <url>', 'Stream this run to a remote UI over WebSocket');
|
|
35
|
-
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
36
|
-
const url = actionCommand.optsWithGlobals().ws || process.env.EXPLORBOT_WS_URL;
|
|
37
|
-
if (!url) return;
|
|
38
|
-
this.attach(String(url), this.commandPath(actionCommand));
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
32
|
attach(url: string, command: string): void {
|
|
43
33
|
if (this.url) return;
|
|
44
34
|
this.url = url;
|
|
@@ -225,16 +215,6 @@ export class Remote implements LogDestination {
|
|
|
225
215
|
if (typeof failure.message === 'string') return failure.message;
|
|
226
216
|
return undefined;
|
|
227
217
|
}
|
|
228
|
-
|
|
229
|
-
private commandPath(command: Command): string {
|
|
230
|
-
const parts: string[] = [];
|
|
231
|
-
let node: Command | null = command;
|
|
232
|
-
while (node) {
|
|
233
|
-
parts.unshift(node.name());
|
|
234
|
-
node = node.parent;
|
|
235
|
-
}
|
|
236
|
-
return parts.slice(1).join(' ') || parts.join(' ');
|
|
237
|
-
}
|
|
238
218
|
}
|
|
239
219
|
|
|
240
220
|
export const remote = new Remote();
|
package/src/state-manager.ts
CHANGED
|
@@ -50,6 +50,10 @@ export interface WebPageState {
|
|
|
50
50
|
links?: Link[];
|
|
51
51
|
verifications?: Record<string, boolean>;
|
|
52
52
|
overlay?: Overlay;
|
|
53
|
+
/** Region name, the persisted scalar form of overlay.name used in experience frontmatter */
|
|
54
|
+
region?: string;
|
|
55
|
+
/** Region root selector, the persisted scalar form of overlay.root used in experience frontmatter */
|
|
56
|
+
root?: string;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
export interface StateTransition {
|
|
@@ -119,7 +123,9 @@ export class StateManager {
|
|
|
119
123
|
*/
|
|
120
124
|
private emitStateChange(event: StateTransition): void {
|
|
121
125
|
const state = event.toState;
|
|
122
|
-
|
|
126
|
+
const payload: Record<string, unknown> = { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 };
|
|
127
|
+
if (state.overlay?.present) payload.region = state.overlay.name || state.overlay.type;
|
|
128
|
+
tag('data').log('state', payload);
|
|
123
129
|
|
|
124
130
|
this.stateChangeListeners.forEach((listener) => {
|
|
125
131
|
try {
|
|
@@ -143,9 +149,9 @@ export class StateManager {
|
|
|
143
149
|
if (newState.url) this.allVisitedUrls.add(normalizeUrl(newState.url));
|
|
144
150
|
|
|
145
151
|
const hashChanged = actionResult.hash !== previousHash;
|
|
146
|
-
const
|
|
152
|
+
const regionAppeared = !hashChanged && this.hasRegionAppeared(previousState, newState);
|
|
147
153
|
|
|
148
|
-
if (hashChanged ||
|
|
154
|
+
if (hashChanged || regionAppeared) {
|
|
149
155
|
const transition: StateTransition = {
|
|
150
156
|
fromState: previousState,
|
|
151
157
|
toState: newState,
|
|
@@ -156,8 +162,8 @@ export class StateManager {
|
|
|
156
162
|
this.stateHistory.push(transition);
|
|
157
163
|
this.emitStateChange(transition);
|
|
158
164
|
|
|
159
|
-
if (
|
|
160
|
-
debugLog('State change detected:
|
|
165
|
+
if (regionAppeared) {
|
|
166
|
+
debugLog('State change detected: region of interest appeared');
|
|
161
167
|
}
|
|
162
168
|
}
|
|
163
169
|
|
|
@@ -206,10 +212,10 @@ export class StateManager {
|
|
|
206
212
|
return newState;
|
|
207
213
|
}
|
|
208
214
|
|
|
209
|
-
private
|
|
215
|
+
private hasRegionAppeared(previousState: WebPageState | null, newState: WebPageState): boolean {
|
|
210
216
|
const prevFocus = previousState?.overlay ?? Overlay.fromAria(previousState?.ariaSnapshot ?? null);
|
|
211
217
|
const newFocus = newState.overlay ?? Overlay.fromAria(newState.ariaSnapshot ?? null);
|
|
212
|
-
return !prevFocus.
|
|
218
|
+
return !prevFocus.present && newFocus.present;
|
|
213
219
|
}
|
|
214
220
|
|
|
215
221
|
/**
|
package/src/test-plan.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import figures from 'figures';
|
|
3
5
|
import type { ActionResult } from './action-result.ts';
|
|
6
|
+
import { listSites } from './global-config.ts';
|
|
4
7
|
import { WebPageState } from './state-manager.ts';
|
|
5
8
|
import { tag } from './utils/logger.ts';
|
|
6
9
|
import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts';
|
|
@@ -388,12 +391,15 @@ export class Test extends Task {
|
|
|
388
391
|
}
|
|
389
392
|
}
|
|
390
393
|
|
|
394
|
+
const SITE_PLANS_DIR = ['output', 'plans'];
|
|
395
|
+
|
|
391
396
|
type PlanChangeListener = (tests: Test[]) => void;
|
|
392
397
|
|
|
393
398
|
export class Plan {
|
|
394
399
|
title: string;
|
|
395
400
|
tests: Test[] = [];
|
|
396
401
|
url?: string;
|
|
402
|
+
filePath?: string;
|
|
397
403
|
iteration = 0;
|
|
398
404
|
parentPlan?: Plan;
|
|
399
405
|
private changeListeners: PlanChangeListener[] = [];
|
|
@@ -455,6 +461,10 @@ export class Plan {
|
|
|
455
461
|
return this.tests.filter((test) => test.status === 'pending' && test.enabled);
|
|
456
462
|
}
|
|
457
463
|
|
|
464
|
+
get startUrl(): string | undefined {
|
|
465
|
+
return this.url || this.tests[0]?.startUrl;
|
|
466
|
+
}
|
|
467
|
+
|
|
458
468
|
get isComplete(): boolean {
|
|
459
469
|
return this.tests.length > 0 && this.tests.every((test) => test.hasFinished);
|
|
460
470
|
}
|
|
@@ -469,6 +479,25 @@ export class Plan {
|
|
|
469
479
|
|
|
470
480
|
updateStatus(): void {}
|
|
471
481
|
|
|
482
|
+
static loadFromFile(file: string, plansDir?: string): Plan | null {
|
|
483
|
+
const names = [file];
|
|
484
|
+
if (!file.endsWith('.md')) names.push(`${file}.md`);
|
|
485
|
+
|
|
486
|
+
const dirs = [process.cwd()];
|
|
487
|
+
if (plansDir) dirs.push(plansDir);
|
|
488
|
+
if (!plansDir) dirs.push(...listSites().map((site) => path.join(site.dir, ...SITE_PLANS_DIR)));
|
|
489
|
+
|
|
490
|
+
for (const dir of dirs) {
|
|
491
|
+
const filePath = names.map((name) => path.resolve(dir, name)).find(existsSync);
|
|
492
|
+
if (!filePath) continue;
|
|
493
|
+
const loaded = parsePlanFromMarkdown(filePath);
|
|
494
|
+
loaded.filePath = filePath;
|
|
495
|
+
return loaded;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
472
501
|
static fromMarkdown(filePath: string): Plan {
|
|
473
502
|
return parsePlanFromMarkdown(filePath);
|
|
474
503
|
}
|
package/src/utils/aria.ts
CHANGED
|
@@ -470,51 +470,20 @@ const formatDiff = (added: string[], removed: string[], toggled: string[], typed
|
|
|
470
470
|
|
|
471
471
|
export interface FocusAreaResult {
|
|
472
472
|
detected: boolean;
|
|
473
|
-
type: '
|
|
473
|
+
type: 'modal' | null;
|
|
474
474
|
name: string | null;
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
-
const CLOSE_OVERLAY_BUTTON_RE = /^close\s+(modal|dialog|popup|drawer|panel|sheet)\b/i;
|
|
478
|
-
|
|
479
|
-
const findOverlayByCloseButton = (nodeList: AriaNode[]): FocusAreaResult | null => {
|
|
480
|
-
const closeIdx = nodeList.findIndex((n) => n.role === 'button' && CLOSE_OVERLAY_BUTTON_RE.test(n.name || ''));
|
|
481
|
-
if (closeIdx !== -1) {
|
|
482
|
-
let heading: AriaNode | undefined;
|
|
483
|
-
for (let i = closeIdx - 1; i >= 0; i--) {
|
|
484
|
-
if (nodeList[i].role === 'heading' && nodeList[i].name) {
|
|
485
|
-
heading = nodeList[i];
|
|
486
|
-
break;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
if (!heading) {
|
|
490
|
-
for (let i = closeIdx + 1; i < nodeList.length; i++) {
|
|
491
|
-
if (nodeList[i].role === 'heading' && nodeList[i].name) {
|
|
492
|
-
heading = nodeList[i];
|
|
493
|
-
break;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
return { detected: true, type: 'dialog', name: heading?.name || null };
|
|
498
|
-
}
|
|
499
|
-
for (const node of nodeList) {
|
|
500
|
-
const inner = findOverlayByCloseButton(node.children);
|
|
501
|
-
if (inner) return inner;
|
|
502
|
-
}
|
|
503
|
-
return null;
|
|
504
|
-
};
|
|
505
|
-
|
|
506
477
|
const findDialogOrModal = (nodes: AriaNode[]): FocusAreaResult | null => {
|
|
478
|
+
let topmost: FocusAreaResult | null = null;
|
|
507
479
|
for (const node of nodes) {
|
|
508
|
-
if (node.role === 'dialog' || node.role === 'alertdialog') {
|
|
509
|
-
|
|
510
|
-
}
|
|
511
|
-
if (node.attributes.modal === true || node.attributes.modal === 'true') {
|
|
512
|
-
return { detected: true, type: 'modal', name: node.name || null };
|
|
480
|
+
if (node.role === 'dialog' || node.role === 'alertdialog' || node.attributes.modal === true || node.attributes.modal === 'true') {
|
|
481
|
+
topmost = { detected: true, type: 'modal', name: node.name || null };
|
|
513
482
|
}
|
|
514
483
|
const child = findDialogOrModal(node.children);
|
|
515
|
-
if (child)
|
|
484
|
+
if (child) topmost = child;
|
|
516
485
|
}
|
|
517
|
-
return
|
|
486
|
+
return topmost;
|
|
518
487
|
};
|
|
519
488
|
|
|
520
489
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -561,13 +530,7 @@ export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
|
|
|
561
530
|
tree = unwrapIgnored(tree);
|
|
562
531
|
tree = dropEmpty(tree, { keepNamed: true });
|
|
563
532
|
|
|
564
|
-
|
|
565
|
-
if (direct) return direct;
|
|
566
|
-
|
|
567
|
-
const fallback = findOverlayByCloseButton(tree);
|
|
568
|
-
if (fallback?.name) return fallback;
|
|
569
|
-
|
|
570
|
-
return { detected: false, type: null, name: null };
|
|
533
|
+
return findDialogOrModal(tree) ?? { detected: false, type: null, name: null };
|
|
571
534
|
};
|
|
572
535
|
|
|
573
536
|
export const collectInteractiveNodes = (snapshot: string | null): Array<Record<string, unknown>> => {
|
package/src/utils/html-diff.ts
CHANGED
|
@@ -7,8 +7,10 @@ import { isDynamicId, isGenericClass } from './xpath.ts';
|
|
|
7
7
|
export interface HtmlDiffPart {
|
|
8
8
|
container: string;
|
|
9
9
|
subtree: string;
|
|
10
|
+
rawSize: number;
|
|
10
11
|
added: string[];
|
|
11
12
|
removed: string[];
|
|
13
|
+
appearedSelector?: string;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export interface HtmlDiffResult {
|
|
@@ -16,6 +18,7 @@ export interface HtmlDiffResult {
|
|
|
16
18
|
added: string[];
|
|
17
19
|
removed: string[];
|
|
18
20
|
similarity: number;
|
|
21
|
+
pageSize: number;
|
|
19
22
|
summary: string;
|
|
20
23
|
messages: string[];
|
|
21
24
|
}
|
|
@@ -30,6 +33,9 @@ interface HtmlNode {
|
|
|
30
33
|
|
|
31
34
|
const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
|
|
32
35
|
|
|
36
|
+
const SHELL_RATIO = 0.8;
|
|
37
|
+
const ROOT_CONTENT_RATIO = 0.8;
|
|
38
|
+
|
|
33
39
|
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
|
|
34
40
|
const TEXT_LINE_PREFIX = 'TEXT:';
|
|
35
41
|
const MESSAGE_MAX_LENGTH = 200;
|
|
@@ -211,7 +217,12 @@ export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlC
|
|
|
211
217
|
|
|
212
218
|
const originalMap = collectElementMap(originalDocument);
|
|
213
219
|
const modifiedMap = collectElementMap(modifiedDocument);
|
|
214
|
-
|
|
220
|
+
|
|
221
|
+
const modifiedBody = findBodyElement(modifiedDocument);
|
|
222
|
+
let pageSize = modifiedHtml.length;
|
|
223
|
+
if (modifiedBody) pageSize = serializeNode(modifiedBody).length;
|
|
224
|
+
|
|
225
|
+
const parts = await buildDiffParts(originalMap, modifiedMap, pageSize);
|
|
215
226
|
|
|
216
227
|
const structuralAdditions = parts.flatMap((p) => p.added.filter((a) => a.startsWith('ELEMENT:')));
|
|
217
228
|
const allAdded = [...added, ...structuralAdditions];
|
|
@@ -223,11 +234,16 @@ export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlC
|
|
|
223
234
|
added: allAdded,
|
|
224
235
|
removed,
|
|
225
236
|
similarity,
|
|
237
|
+
pageSize,
|
|
226
238
|
summary,
|
|
227
239
|
messages: collectMessages(originalMap, modifiedMap, allAdded),
|
|
228
240
|
};
|
|
229
241
|
}
|
|
230
242
|
|
|
243
|
+
function serializeNode(node: parse5TreeAdapter.Node): string {
|
|
244
|
+
return serialize({ childNodes: [node], nodeName: '#document-fragment' } as any);
|
|
245
|
+
}
|
|
246
|
+
|
|
231
247
|
/**
|
|
232
248
|
* Text the app announced while the page stayed the same: live region content first, then any other text that appeared.
|
|
233
249
|
*/
|
|
@@ -492,14 +508,14 @@ function buildContainerSelector(element: ElementNode, allElements: NodeMap): str
|
|
|
492
508
|
return matchCount === 1 ? selector : null;
|
|
493
509
|
}
|
|
494
510
|
|
|
495
|
-
function pathToXPath(treePath: string): string {
|
|
511
|
+
export function pathToXPath(treePath: string): string {
|
|
496
512
|
const parts = treePath.split('/');
|
|
497
513
|
const bodyIdx = parts.findIndex((p) => p.startsWith('body'));
|
|
498
514
|
if (bodyIdx === -1) return `//${parts.join('/')}`;
|
|
499
515
|
return `//body/${parts.slice(bodyIdx + 1).join('/')}`;
|
|
500
516
|
}
|
|
501
517
|
|
|
502
|
-
function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifiedMap: NodeMap): { path: string; selector: string } {
|
|
518
|
+
function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifiedMap: NodeMap, pageSize: number): { path: string; selector: string } {
|
|
503
519
|
const segments = topLevelPath.split('/');
|
|
504
520
|
|
|
505
521
|
for (let i = segments.length - 2; i >= 1; i--) {
|
|
@@ -508,6 +524,7 @@ function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifie
|
|
|
508
524
|
if (!originalMap.has(candidatePath) || !modifiedMap.has(candidatePath)) continue;
|
|
509
525
|
|
|
510
526
|
const element = modifiedMap.get(candidatePath)!;
|
|
527
|
+
if (pageSize > 0 && serializeNode(element).length >= SHELL_RATIO * pageSize) break;
|
|
511
528
|
const css = buildContainerSelector(element, modifiedMap);
|
|
512
529
|
if (css) return { path: candidatePath, selector: css };
|
|
513
530
|
return { path: candidatePath, selector: pathToXPath(candidatePath) };
|
|
@@ -516,7 +533,29 @@ function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifie
|
|
|
516
533
|
return { path: 'html[1]/body[1]', selector: 'body' };
|
|
517
534
|
}
|
|
518
535
|
|
|
519
|
-
|
|
536
|
+
function dominantChild(element: ElementNode): ElementNode | null {
|
|
537
|
+
const children = (element.childNodes ?? []).filter((child): child is ElementNode => 'tagName' in child && !!child.tagName);
|
|
538
|
+
if (children.length === 0) return null;
|
|
539
|
+
if (children.length === 1) return children[0];
|
|
540
|
+
const parentSize = serializeNode(element).length;
|
|
541
|
+
if (!parentSize) return null;
|
|
542
|
+
for (const child of children) {
|
|
543
|
+
if (serializeNode(child).length >= ROOT_CONTENT_RATIO * parentSize) return child;
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function semanticSelectorFor(element: ElementNode, allElements: NodeMap): string | undefined {
|
|
549
|
+
let current: ElementNode | null = element;
|
|
550
|
+
while (current) {
|
|
551
|
+
const selector = buildContainerSelector(current, allElements);
|
|
552
|
+
if (selector) return selector;
|
|
553
|
+
current = dominantChild(current);
|
|
554
|
+
}
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap, pageSize: number): Promise<HtmlDiffPart[]> {
|
|
520
559
|
const addedPaths: string[] = [];
|
|
521
560
|
const changedPaths: string[] = [];
|
|
522
561
|
|
|
@@ -546,7 +585,7 @@ async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap): Promi
|
|
|
546
585
|
const grouped = new Map<string, { selector: string; paths: string[] }>();
|
|
547
586
|
|
|
548
587
|
for (const path of allTopLevel) {
|
|
549
|
-
const { path: containerPath, selector } = findStableContainer(path, originalMap, modifiedMap);
|
|
588
|
+
const { path: containerPath, selector } = findStableContainer(path, originalMap, modifiedMap, pageSize);
|
|
550
589
|
const existing = grouped.get(containerPath);
|
|
551
590
|
if (existing) {
|
|
552
591
|
existing.paths.push(path);
|
|
@@ -592,10 +631,26 @@ async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap): Promi
|
|
|
592
631
|
const subtree = serialized ? await minifyHtml(serialized) : '';
|
|
593
632
|
if (!subtree) continue;
|
|
594
633
|
|
|
595
|
-
const
|
|
634
|
+
const appearedPaths = paths.filter((p) => addedTopLevel.includes(p));
|
|
635
|
+
const sizeOf = (p: string) => {
|
|
636
|
+
const node = modifiedMap.get(p);
|
|
637
|
+
if (!node) return 0;
|
|
638
|
+
return serializeNode(node).length;
|
|
639
|
+
};
|
|
640
|
+
appearedPaths.sort((a, b) => sizeOf(b) - sizeOf(a));
|
|
641
|
+
const addedLines = appearedPaths.map((p) => `ELEMENT:${p}`);
|
|
596
642
|
const removedLines: string[] = [];
|
|
597
643
|
|
|
598
|
-
|
|
644
|
+
const part: HtmlDiffPart = { container: selector, subtree, rawSize: serialized.length, added: addedLines, removed: removedLines };
|
|
645
|
+
const appearedPath = appearedPaths[0];
|
|
646
|
+
if (appearedPath) {
|
|
647
|
+
const appearedElement = modifiedMap.get(appearedPath);
|
|
648
|
+
if (appearedElement) {
|
|
649
|
+
const appearedSelector = semanticSelectorFor(appearedElement, modifiedMap);
|
|
650
|
+
if (appearedSelector) part.appearedSelector = appearedSelector;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
parts.push(part);
|
|
599
654
|
}
|
|
600
655
|
|
|
601
656
|
return parts;
|