explorbot 0.3.4 → 0.4.0
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 +18 -13
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +3 -1
- package/boat/prima/src/cli.ts +21 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +19 -13
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +3 -1
- package/dist/boat/prima/src/cli.js +19 -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/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/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/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +15 -1
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.js +47 -4
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.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/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +27 -2
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.js +1 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +23 -36
- 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/reference/commands.md +8 -1
- 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 +1 -0
- package/docs/workflow/ci.md +1 -0
- package/package.json +2 -2
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- 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/pilot.ts +11 -1
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +48 -4
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.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/commands/explore-command.ts +25 -2
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +20 -36
- 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
|
@@ -79,15 +79,15 @@ export class PlansCommand extends BaseCommand {
|
|
|
79
79
|
return file;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const
|
|
83
|
-
if (!
|
|
84
|
-
throw new Error(`Plan file not found: ${
|
|
82
|
+
const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir());
|
|
83
|
+
if (!plan?.filePath) {
|
|
84
|
+
throw new Error(`Plan file not found: ${target}`);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
return {
|
|
88
|
-
name: path.basename(
|
|
89
|
-
path:
|
|
90
|
-
modifiedAt: statSync(
|
|
88
|
+
name: path.basename(plan.filePath),
|
|
89
|
+
path: plan.filePath,
|
|
90
|
+
modifiedAt: statSync(plan.filePath).mtimeMs,
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
}
|
package/src/config.ts
CHANGED
|
@@ -273,6 +273,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
|
|
|
273
273
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
274
274
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
275
275
|
{ name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
|
|
276
|
+
{ name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
|
|
276
277
|
];
|
|
277
278
|
|
|
278
279
|
export type {
|
|
@@ -123,10 +123,14 @@ export class ExperienceTracker {
|
|
|
123
123
|
const filePath = this.getExperienceFilePath(stateHash);
|
|
124
124
|
|
|
125
125
|
if (!existsSync(filePath)) {
|
|
126
|
-
const frontmatter = {
|
|
126
|
+
const frontmatter: Record<string, unknown> = {
|
|
127
127
|
url: state.url ? extractStatePath(state.url) : '',
|
|
128
128
|
title: state.title,
|
|
129
129
|
};
|
|
130
|
+
if (state.overlay.present && state.overlay.name) {
|
|
131
|
+
frontmatter.region = state.overlay.name;
|
|
132
|
+
if (state.overlay.root) frontmatter.root = state.overlay.root;
|
|
133
|
+
}
|
|
130
134
|
this.writeExperienceFile(stateHash, '', frontmatter);
|
|
131
135
|
}
|
|
132
136
|
|
package/src/explorbot.ts
CHANGED
|
@@ -346,14 +346,22 @@ export class ExplorBot {
|
|
|
346
346
|
}
|
|
347
347
|
};
|
|
348
348
|
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
349
|
+
const browserHeaderProvider = async (): Promise<Record<string, string>> => {
|
|
350
|
+
const session = await this.explorer
|
|
351
|
+
.withPage(async (page) => ({
|
|
352
|
+
cookies: await page.context().cookies(baseEndpoint),
|
|
353
|
+
csrf: await page.evaluate(() => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '').catch(() => ''),
|
|
354
|
+
}))
|
|
355
|
+
.catch(() => ({ cookies: [] as any[], csrf: '' }));
|
|
356
|
+
|
|
357
|
+
const headers: Record<string, string> = {};
|
|
358
|
+
if (session.cookies.length) headers.Cookie = session.cookies.map((c: any) => `${c.name}=${c.value}`).join('; ');
|
|
359
|
+
if (session.csrf) headers['x-csrf-token'] = session.csrf;
|
|
360
|
+
return headers;
|
|
353
361
|
};
|
|
354
362
|
|
|
355
363
|
this.agents.fisherman = this.createAgent(({ ai }) => {
|
|
356
|
-
return new Fisherman(ai, apiClient, requestStore, specLoader, baseEndpoint,
|
|
364
|
+
return new Fisherman(ai, apiClient, requestStore, specLoader, baseEndpoint, browserHeaderProvider, configHeaders, hasApiConfig);
|
|
357
365
|
});
|
|
358
366
|
}
|
|
359
367
|
return this.agents.fisherman;
|
|
@@ -449,41 +457,17 @@ export class ExplorBot {
|
|
|
449
457
|
return urlPart + featurePart.slice(0, maxFeatureLen) + suffix;
|
|
450
458
|
}
|
|
451
459
|
|
|
452
|
-
resolvePlanPath(filename: string): string {
|
|
453
|
-
let planPath = filename;
|
|
454
|
-
|
|
455
|
-
if (path.isAbsolute(filename)) {
|
|
456
|
-
if (!existsSync(planPath) && !filename.endsWith('.md')) {
|
|
457
|
-
planPath = `${filename}.md`;
|
|
458
|
-
}
|
|
459
|
-
} else if (existsSync(filename) || existsSync(`${filename}.md`)) {
|
|
460
|
-
planPath = existsSync(filename) ? filename : `${filename}.md`;
|
|
461
|
-
} else {
|
|
462
|
-
const plansDir = this.getPlansDir();
|
|
463
|
-
planPath = path.join(plansDir, filename);
|
|
464
|
-
if (!existsSync(planPath) && !filename.endsWith('.md')) {
|
|
465
|
-
planPath = path.join(plansDir, `${filename}.md`);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
return planPath;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
460
|
loadPlan(filename: string): Plan {
|
|
473
|
-
const
|
|
474
|
-
if (!
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
this.setCurrentPlan(Plan.fromMarkdown(planPath));
|
|
478
|
-
return this.currentPlan!;
|
|
461
|
+
const plan = Plan.loadFromFile(filename, this.getPlansDir());
|
|
462
|
+
if (!plan) throw new Error(`Plan file not found: ${filename}`);
|
|
463
|
+
this.setCurrentPlan(plan);
|
|
464
|
+
return plan;
|
|
479
465
|
}
|
|
480
466
|
|
|
481
467
|
loadPlans(filename: string): Plan[] {
|
|
482
|
-
const
|
|
483
|
-
if (!
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
return parsePlansFromMarkdown(planPath);
|
|
468
|
+
const plan = Plan.loadFromFile(filename, this.getPlansDir());
|
|
469
|
+
if (!plan?.filePath) throw new Error(`Plan file not found: ${filename}`);
|
|
470
|
+
return parsePlansFromMarkdown(plan.filePath);
|
|
487
471
|
}
|
|
488
472
|
|
|
489
473
|
setCurrentPlan(plan?: Plan): void {
|
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;
|
package/src/utils/html.ts
CHANGED
|
@@ -101,17 +101,12 @@ export const HTML_SELECTORS = {
|
|
|
101
101
|
} as const;
|
|
102
102
|
|
|
103
103
|
export const HTML_VISIBILITY_LIMITS = {
|
|
104
|
-
maxViewportOverlayRatio: 0.95,
|
|
105
104
|
minOpacity: 0.1,
|
|
106
|
-
minOverlayHeight: 40,
|
|
107
|
-
minOverlayWidth: 80,
|
|
108
105
|
} as const;
|
|
109
106
|
|
|
110
107
|
export const HTML_EXTRACTION_LIMITS = {
|
|
111
108
|
componentScopeHtmlLength: 8000,
|
|
112
|
-
maxOverlayCount: 3,
|
|
113
109
|
maxScopeInteractiveCount: 16,
|
|
114
|
-
overlayHtmlLength: 6000,
|
|
115
110
|
} as const;
|
|
116
111
|
|
|
117
112
|
export const CODE_EDITOR_MARKERS = ['monaco', 'codemirror', 'ace', 'ace_editor', 'code'] as const;
|
|
@@ -158,14 +153,6 @@ export const ELEMENT_EXTRACTION_CONFIG = {
|
|
|
158
153
|
|
|
159
154
|
export type ElementExtractionConfig = typeof ELEMENT_EXTRACTION_CONFIG;
|
|
160
155
|
export type RawElementData = NonNullable<ReturnType<typeof extractElementData>>;
|
|
161
|
-
export type VisibleOverlayExtractionConfig = {
|
|
162
|
-
interactiveContentSelector: string;
|
|
163
|
-
limits: typeof HTML_EXTRACTION_LIMITS;
|
|
164
|
-
overlaySelectors: readonly string[];
|
|
165
|
-
overlaySemanticSelector: string;
|
|
166
|
-
visibilityLimits: typeof HTML_VISIBILITY_LIMITS;
|
|
167
|
-
geometryFallback?: boolean;
|
|
168
|
-
};
|
|
169
156
|
export type ComponentScopeExtractionConfig = {
|
|
170
157
|
eidxAttr: string;
|
|
171
158
|
interactiveControlSelector: string;
|
|
@@ -449,80 +436,6 @@ export function getElementDataExtractorSource(): string {
|
|
|
449
436
|
return extractElementData.toString();
|
|
450
437
|
}
|
|
451
438
|
|
|
452
|
-
export function extractVisibleOverlayHtml(config: VisibleOverlayExtractionConfig): string {
|
|
453
|
-
function isVisible(element: Element): boolean {
|
|
454
|
-
const html = element as HTMLElement;
|
|
455
|
-
const style = window.getComputedStyle(html);
|
|
456
|
-
const rect = html.getBoundingClientRect();
|
|
457
|
-
if (rect.width === 0 && rect.height === 0) return false;
|
|
458
|
-
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
|
459
|
-
if (Number.parseFloat(style.opacity || '1') < config.visibilityLimits.minOpacity) return false;
|
|
460
|
-
return true;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function getUsefulContent(element: Element): { interactiveCount: number; text: string } {
|
|
464
|
-
const text = (element.textContent || '').replace(/\s+/g, ' ').trim();
|
|
465
|
-
const interactiveCount = element.querySelectorAll(config.interactiveContentSelector).length;
|
|
466
|
-
return { interactiveCount, text };
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function isLikelyFloatingOverlay(element: Element): boolean {
|
|
470
|
-
const html = element as HTMLElement;
|
|
471
|
-
const style = window.getComputedStyle(html);
|
|
472
|
-
const rect = html.getBoundingClientRect();
|
|
473
|
-
const zIndex = Number.parseInt(style.zIndex || '0', 10);
|
|
474
|
-
const isFloating = style.position === 'fixed' || style.position === 'absolute' || style.position === 'sticky' || zIndex > 0;
|
|
475
|
-
if (!isFloating) return false;
|
|
476
|
-
if (rect.width < config.visibilityLimits.minOverlayWidth || rect.height < config.visibilityLimits.minOverlayHeight) return false;
|
|
477
|
-
if (rect.bottom < 0 || rect.right < 0 || rect.top > window.innerHeight || rect.left > window.innerWidth) return false;
|
|
478
|
-
if (rect.width >= window.innerWidth * config.visibilityLimits.maxViewportOverlayRatio && rect.height >= window.innerHeight * config.visibilityLimits.maxViewportOverlayRatio) return false;
|
|
479
|
-
const { interactiveCount, text } = getUsefulContent(element);
|
|
480
|
-
return interactiveCount > 0 || text.length > 0;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function isFloatingOverlay(element: Element): boolean {
|
|
484
|
-
const style = window.getComputedStyle(element as HTMLElement);
|
|
485
|
-
return style.position === 'fixed' || style.position === 'absolute' || Number.parseInt(style.zIndex || '0', 10) > 0;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const seen = new Set<Element>();
|
|
489
|
-
const collected: Element[] = [];
|
|
490
|
-
for (const selector of config.overlaySelectors) {
|
|
491
|
-
for (const element of Array.from(document.querySelectorAll(selector))) {
|
|
492
|
-
if (seen.has(element)) continue;
|
|
493
|
-
seen.add(element);
|
|
494
|
-
if (!isVisible(element)) continue;
|
|
495
|
-
if (!element.matches(config.overlaySemanticSelector) && !isFloatingOverlay(element)) continue;
|
|
496
|
-
const { interactiveCount, text } = getUsefulContent(element);
|
|
497
|
-
if (interactiveCount === 0 && text.length === 0) continue;
|
|
498
|
-
collected.push(element);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
const overlays = collected.filter((element) => !collected.some((other) => other !== element && element.contains(other))).map((element) => (element as HTMLElement).outerHTML.slice(0, config.limits.overlayHtmlLength));
|
|
503
|
-
|
|
504
|
-
if (overlays.length === 0 && config.geometryFallback !== false) {
|
|
505
|
-
const floatingCandidates = Array.from(document.body.querySelectorAll('*'))
|
|
506
|
-
.filter((element) => !seen.has(element) && isVisible(element) && isLikelyFloatingOverlay(element))
|
|
507
|
-
.sort((left, right) => {
|
|
508
|
-
const leftStyle = window.getComputedStyle(left as HTMLElement);
|
|
509
|
-
const rightStyle = window.getComputedStyle(right as HTMLElement);
|
|
510
|
-
const leftZ = Number.parseInt(leftStyle.zIndex || '0', 10) || 0;
|
|
511
|
-
const rightZ = Number.parseInt(rightStyle.zIndex || '0', 10) || 0;
|
|
512
|
-
if (leftZ !== rightZ) return rightZ - leftZ;
|
|
513
|
-
const leftRect = (left as HTMLElement).getBoundingClientRect();
|
|
514
|
-
const rightRect = (right as HTMLElement).getBoundingClientRect();
|
|
515
|
-
return leftRect.width * leftRect.height - rightRect.width * rightRect.height;
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
for (const element of floatingCandidates.slice(0, config.limits.maxOverlayCount)) {
|
|
519
|
-
overlays.push((element as HTMLElement).outerHTML.slice(0, config.limits.overlayHtmlLength));
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
return overlays.slice(0, config.limits.maxOverlayCount).join('\n\n--- overlay ---\n\n');
|
|
524
|
-
}
|
|
525
|
-
|
|
526
439
|
export function extractComponentScopeHtml(eidx: string, config: ComponentScopeExtractionConfig): string {
|
|
527
440
|
const element = document.querySelector(`[${config.eidxAttr}="${eidx}"]`);
|
|
528
441
|
if (!element) return '';
|
|
@@ -544,10 +457,6 @@ export function extractComponentScopeHtml(eidx: string, config: ComponentScopeEx
|
|
|
544
457
|
return '';
|
|
545
458
|
}
|
|
546
459
|
|
|
547
|
-
export function getVisibleOverlayHtmlExtractorSource(): string {
|
|
548
|
-
return extractVisibleOverlayHtml.toString();
|
|
549
|
-
}
|
|
550
|
-
|
|
551
460
|
export function getComponentScopeHtmlExtractorSource(): string {
|
|
552
461
|
return extractComponentScopeHtml.toString();
|
|
553
462
|
}
|
|
@@ -1020,6 +929,19 @@ export function htmlCombinedSnapshot(html: string, htmlConfig?: HtmlConfig['comb
|
|
|
1020
929
|
return serialize(document);
|
|
1021
930
|
}
|
|
1022
931
|
|
|
932
|
+
/**
|
|
933
|
+
* Cleans a small HTML snippet for AI consumption: keeps structure and text,
|
|
934
|
+
* drops noisy attributes and generated class names
|
|
935
|
+
*/
|
|
936
|
+
export function cleanHtmlSnippet(html: string): string {
|
|
937
|
+
const fragment = parseFragment(html);
|
|
938
|
+
for (const node of fragment.childNodes) {
|
|
939
|
+
if (!('tagName' in node)) continue;
|
|
940
|
+
cleanAllElements(node as parse5TreeAdapter.Element);
|
|
941
|
+
}
|
|
942
|
+
return serialize(fragment);
|
|
943
|
+
}
|
|
944
|
+
|
|
1023
945
|
/**
|
|
1024
946
|
* Creates text-only snapshot with markdown formatting
|
|
1025
947
|
*/
|
|
@@ -1526,6 +1448,7 @@ function cleanElement(element: parse5TreeAdapter.Element): void {
|
|
|
1526
1448
|
if (attr.name === 'class') {
|
|
1527
1449
|
attr.value = attr.value
|
|
1528
1450
|
.split(/\s+/)
|
|
1451
|
+
.filter(Boolean)
|
|
1529
1452
|
.filter((className) => !/\d/.test(className))
|
|
1530
1453
|
.filter((className) => !className.includes(':'))
|
|
1531
1454
|
.filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
|