explorbot 0.2.5 → 0.3.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/boat/prima/README.md +96 -0
- package/boat/prima/package.json +14 -10
- package/boat/prima/src/cli.ts +5 -0
- package/boat/prima/src/prima.ts +17 -4
- package/dist/boat/prima/src/cli.js +7 -0
- package/dist/boat/prima/src/prima.js +18 -4
- package/dist/models.json +4 -4
- package/dist/package.json +6 -2
- package/dist/src/action-result.d.ts +13 -0
- package/dist/src/action-result.js +46 -15
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +48 -17
- package/dist/src/ai/captain/web-mode.js +1 -2
- package/dist/src/ai/captain.d.ts +20 -0
- package/dist/src/ai/captain.js +10 -1
- package/dist/src/ai/conversation.d.ts +1 -0
- package/dist/src/ai/conversation.js +3 -0
- package/dist/src/ai/driller.js +6 -2
- package/dist/src/ai/fisherman-tools.d.ts +40 -1
- package/dist/src/ai/fisherman-tools.js +39 -0
- package/dist/src/ai/fisherman.js +3 -2
- package/dist/src/ai/navigator.d.ts +2 -1
- package/dist/src/ai/navigator.js +5 -9
- package/dist/src/ai/pilot.js +51 -29
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.d.ts +3 -0
- package/dist/src/ai/provider.js +80 -17
- package/dist/src/ai/researcher/cache.d.ts +8 -3
- package/dist/src/ai/researcher/cache.js +13 -8
- package/dist/src/ai/researcher/deep-analysis.js +1 -1
- package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
- package/dist/src/ai/researcher.js +4 -3
- package/dist/src/ai/rules.js +1 -5
- package/dist/src/ai/tester.d.ts +1 -1
- package/dist/src/ai/tester.js +34 -26
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +79 -56
- package/dist/src/commands/explore-command.js +22 -17
- package/dist/src/commands/init-command.js +13 -20
- package/dist/src/config.d.ts +2 -1
- package/dist/src/config.js +3 -1
- package/dist/src/experience-tracker.d.ts +2 -0
- package/dist/src/experience-tracker.js +12 -0
- package/dist/src/explorbot.js +1 -1
- package/dist/src/playwright-recorder.js +6 -12
- package/dist/src/test-plan.d.ts +8 -0
- package/dist/src/test-plan.js +11 -0
- package/dist/src/utils/html-diff.d.ts +5 -0
- package/dist/src/utils/html-diff.js +65 -6
- package/dist/src/utils/strings.d.ts +2 -0
- package/dist/src/utils/strings.js +32 -0
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +31 -2
- package/docs/basics/getting-started.md +33 -10
- package/docs/basics/providers.md +6 -4
- package/docs/contributing/npm-package.md +73 -4
- package/models.json +4 -4
- package/package.json +6 -2
- package/src/action-result.ts +61 -16
- package/src/action.ts +51 -17
- package/src/ai/captain/web-mode.ts +1 -2
- package/src/ai/captain.ts +9 -1
- package/src/ai/conversation.ts +3 -0
- package/src/ai/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +3 -2
- package/src/ai/navigator.ts +6 -10
- package/src/ai/pilot.ts +54 -32
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +111 -41
- package/src/ai/researcher/cache.ts +17 -9
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +23 -5
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +1 -5
- package/src/ai/tester.ts +32 -27
- package/src/ai/tools.ts +84 -60
- package/src/commands/explore-command.ts +17 -14
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +4 -2
- package/src/experience-tracker.ts +13 -0
- package/src/explorbot.ts +1 -1
- package/src/playwright-recorder.ts +6 -11
- package/src/test-plan.ts +18 -0
- package/src/utils/html-diff.ts +72 -7
- package/src/utils/strings.ts +36 -0
- package/src/utils/url-matcher.ts +27 -2
|
@@ -8,15 +8,10 @@ import { getCliName } from '../utils/cli-name.ts';
|
|
|
8
8
|
import { log, tag } from '../utils/logger.js';
|
|
9
9
|
import { relativeToCwd } from '../utils/next-steps.ts';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
// Bring your own provider or use OpenRouter (one API key, many providers).
|
|
16
|
-
// https://github.com/testomatio/explorbot/blob/main/docs/providers.md
|
|
17
|
-
const openrouter = createOpenRouter({
|
|
18
|
-
apiKey: process.env.OPENROUTER_API_KEY,
|
|
19
|
-
});
|
|
11
|
+
function defaultConfigTemplate(): string {
|
|
12
|
+
return `// 'provider/model-id' uses a bundled provider.
|
|
13
|
+
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
14
|
+
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
20
15
|
|
|
21
16
|
const config = {
|
|
22
17
|
web: {
|
|
@@ -25,12 +20,7 @@ const config = {
|
|
|
25
20
|
},
|
|
26
21
|
|
|
27
22
|
ai: {
|
|
28
|
-
|
|
29
|
-
model: openrouter('openai/gpt-oss-20b:nitro'),
|
|
30
|
-
// vision model for screenshot analysis
|
|
31
|
-
visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
|
|
32
|
-
// agentic model for decision making
|
|
33
|
-
agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
|
|
23
|
+
${modelLines('openrouter')}
|
|
34
24
|
},
|
|
35
25
|
|
|
36
26
|
reporter: {
|
|
@@ -45,6 +35,7 @@ const config = {
|
|
|
45
35
|
|
|
46
36
|
export default config;
|
|
47
37
|
`;
|
|
38
|
+
}
|
|
48
39
|
|
|
49
40
|
const DEFAULT_ENV_TEMPLATE = dedent`
|
|
50
41
|
# AI provider API keys
|
|
@@ -141,7 +132,7 @@ export function runInitCommand(options: InitCommandOptions): void {
|
|
|
141
132
|
process.exit(1);
|
|
142
133
|
}
|
|
143
134
|
|
|
144
|
-
writeFileSync(outPath,
|
|
135
|
+
writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
|
|
145
136
|
log(`Created config file: ${relativeToCwd(outPath)}`);
|
|
146
137
|
|
|
147
138
|
const envPath = resolve(process.cwd(), '.env');
|
|
@@ -222,8 +213,7 @@ async function renderInitWizard(mode: 'choose' | 'global'): Promise<'local' | 'g
|
|
|
222
213
|
});
|
|
223
214
|
}
|
|
224
215
|
|
|
225
|
-
function
|
|
226
|
-
const { envKey } = PROVIDERS[provider];
|
|
216
|
+
function modelLines(provider: string): string {
|
|
227
217
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
228
218
|
const roles: Array<[ModelRoleName, string]> = [
|
|
229
219
|
['model', 'fast model with tool calling capabilities'],
|
|
@@ -231,7 +221,11 @@ function globalConfigTemplate(provider: string): string {
|
|
|
231
221
|
['agenticModel', 'agentic model for decision making'],
|
|
232
222
|
];
|
|
233
223
|
|
|
234
|
-
|
|
224
|
+
return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function globalConfigTemplate(provider: string): string {
|
|
228
|
+
const { envKey } = PROVIDERS[provider];
|
|
235
229
|
|
|
236
230
|
return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
|
|
237
231
|
// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
@@ -240,7 +234,7 @@ function globalConfigTemplate(provider: string): string {
|
|
|
240
234
|
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
241
235
|
const config = {
|
|
242
236
|
ai: {
|
|
243
|
-
${
|
|
237
|
+
${modelLines(provider)}
|
|
244
238
|
},
|
|
245
239
|
|
|
246
240
|
reporter: {
|
package/src/config.ts
CHANGED
|
@@ -175,8 +175,9 @@ interface AIConfig {
|
|
|
175
175
|
vision?: boolean;
|
|
176
176
|
visionModel?: any;
|
|
177
177
|
agenticModel?: any;
|
|
178
|
-
|
|
178
|
+
retryAttempts?: number;
|
|
179
179
|
retryDelay?: number;
|
|
180
|
+
maxParallelRequests?: number;
|
|
180
181
|
agents?: AgentsConfig;
|
|
181
182
|
}
|
|
182
183
|
|
|
@@ -433,7 +434,7 @@ export class ConfigParser {
|
|
|
433
434
|
public getOutputDir(): string {
|
|
434
435
|
const config = this.getConfig();
|
|
435
436
|
if (!this.configPath) throw new Error('Config path not found');
|
|
436
|
-
return
|
|
437
|
+
return this.resolveProjectDir(config.dirs?.output || 'output');
|
|
437
438
|
}
|
|
438
439
|
|
|
439
440
|
public getProjectRoot(): string {
|
|
@@ -444,6 +445,7 @@ export class ConfigParser {
|
|
|
444
445
|
}
|
|
445
446
|
|
|
446
447
|
public resolveProjectDir(relativeDir: string): string {
|
|
448
|
+
if (path.isAbsolute(relativeDir)) return relativeDir;
|
|
447
449
|
if (!this.configPath) return relativeDir;
|
|
448
450
|
return path.join(this.getProjectRoot(), relativeDir);
|
|
449
451
|
}
|
|
@@ -304,6 +304,14 @@ export class ExperienceTracker {
|
|
|
304
304
|
return this.buildToc(sorted);
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
renderExperienceFor(state: ActionResult): string {
|
|
308
|
+
const successful = this.getSuccessfulExperience(state);
|
|
309
|
+
if (!successful.length) return '';
|
|
310
|
+
|
|
311
|
+
tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${state.url}`);
|
|
312
|
+
return renderExperienceRecipes(successful);
|
|
313
|
+
}
|
|
314
|
+
|
|
307
315
|
renderExperienceTocFor(state: ActionResult): string {
|
|
308
316
|
const toc = this.getExperienceTableOfContents(state);
|
|
309
317
|
if (toc.length === 0) return '';
|
|
@@ -437,6 +445,11 @@ function indexToLetters(index: number): string {
|
|
|
437
445
|
return result;
|
|
438
446
|
}
|
|
439
447
|
|
|
448
|
+
export function renderExperienceRecipes(recipes: string[]): string {
|
|
449
|
+
if (recipes.length === 0) return '';
|
|
450
|
+
return `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${recipes.join('\n\n')}\n</experience>`;
|
|
451
|
+
}
|
|
452
|
+
|
|
440
453
|
export function renderExperienceToc(toc: ExperienceTocEntry[]): string {
|
|
441
454
|
if (toc.length === 0) return '';
|
|
442
455
|
|
package/src/explorbot.ts
CHANGED
|
@@ -248,7 +248,7 @@ export class ExplorBot {
|
|
|
248
248
|
this.agents.tester = this.createAgent((deps) => {
|
|
249
249
|
const researcher = this.agentResearcher();
|
|
250
250
|
const navigator = this.agentNavigator();
|
|
251
|
-
const tools = createAgentTools({ ...deps, researcher, navigator });
|
|
251
|
+
const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
|
|
252
252
|
return new Tester(deps, researcher, navigator, tools);
|
|
253
253
|
});
|
|
254
254
|
|
|
@@ -9,7 +9,7 @@ const RECORDABLE: Record<string, Set<string>> = {
|
|
|
9
9
|
Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
const PLAYWRIGHT_INCOMPATIBLE = "Playwright output
|
|
12
|
+
const PLAYWRIGHT_INCOMPATIBLE = "Playwright output requires playwright-core 1.62 or newer (lib/coreBundle does not expose iso.asLocator). Use output.framework: 'codeceptjs' instead.";
|
|
13
13
|
|
|
14
14
|
let cachedAsLocator: ((lang: string, selector: string) => string) | null = null;
|
|
15
15
|
let asLocatorLoadAttempted = false;
|
|
@@ -20,16 +20,11 @@ function getAsLocator(): (lang: string, selector: string) => string {
|
|
|
20
20
|
if (asLocatorLoadAttempted) throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
21
21
|
|
|
22
22
|
asLocatorLoadAttempted = true;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
} catch {
|
|
30
|
-
// Module not exported or not found
|
|
31
|
-
}
|
|
32
|
-
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
23
|
+
const asLocator = nodeRequire('playwright-core/lib/coreBundle')?.iso?.asLocator;
|
|
24
|
+
if (typeof asLocator !== 'function') throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
25
|
+
|
|
26
|
+
cachedAsLocator = asLocator;
|
|
27
|
+
return cachedAsLocator;
|
|
33
28
|
}
|
|
34
29
|
|
|
35
30
|
export interface TraceCall {
|
package/src/test-plan.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import figures from 'figures';
|
|
3
|
+
import type { ActionResult } from './action-result.ts';
|
|
3
4
|
import { WebPageState } from './state-manager.ts';
|
|
4
5
|
import { tag } from './utils/logger.ts';
|
|
5
6
|
import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts';
|
|
@@ -236,6 +237,7 @@ export class Test extends Task {
|
|
|
236
237
|
startTime?: number;
|
|
237
238
|
endTime?: number;
|
|
238
239
|
resetCount = 0;
|
|
240
|
+
appliedExperience: AppliedExperience[] = [];
|
|
239
241
|
|
|
240
242
|
constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps: string[] = []) {
|
|
241
243
|
super(scenario, startUrl);
|
|
@@ -257,6 +259,17 @@ export class Test extends Task {
|
|
|
257
259
|
return [...new Set([this.startUrl, ...this.states.map((s) => s.url)].filter((value): value is string => Boolean(value) && value.trim() !== ''))];
|
|
258
260
|
}
|
|
259
261
|
|
|
262
|
+
applyExperience(recipes: AppliedExperience[]): void {
|
|
263
|
+
for (const recipe of recipes) {
|
|
264
|
+
if (this.appliedExperience.some((applied) => applied.content === recipe.content)) continue;
|
|
265
|
+
this.appliedExperience.push(recipe);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getAppliedExperience(state: ActionResult): string[] {
|
|
270
|
+
return this.appliedExperience.filter((recipe) => state.isRelevantExperienceRecord({ url: recipe.url })).map((recipe) => recipe.content);
|
|
271
|
+
}
|
|
272
|
+
|
|
260
273
|
addArtifact(artifact?: string): void {
|
|
261
274
|
if (!artifact) return;
|
|
262
275
|
const timestamp = `${performance.now()}_${this.timestampCounter++}`;
|
|
@@ -530,3 +543,8 @@ interface UrlNoteState {
|
|
|
530
543
|
h2?: string;
|
|
531
544
|
screenshotFile?: string;
|
|
532
545
|
}
|
|
546
|
+
|
|
547
|
+
interface AppliedExperience {
|
|
548
|
+
url: string;
|
|
549
|
+
content: string;
|
|
550
|
+
}
|
package/src/utils/html-diff.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface HtmlDiffResult {
|
|
|
17
17
|
removed: string[];
|
|
18
18
|
similarity: number;
|
|
19
19
|
summary: string;
|
|
20
|
+
messages: string[];
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
interface HtmlNode {
|
|
@@ -29,6 +30,11 @@ interface HtmlNode {
|
|
|
29
30
|
|
|
30
31
|
const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
|
|
31
32
|
|
|
33
|
+
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
|
|
34
|
+
const TEXT_LINE_PREFIX = 'TEXT:';
|
|
35
|
+
const MESSAGE_MAX_LENGTH = 200;
|
|
36
|
+
const MESSAGE_LIMIT = 8;
|
|
37
|
+
|
|
32
38
|
type DocumentNode = parse5TreeAdapter.Document;
|
|
33
39
|
type ElementNode = parse5TreeAdapter.Element;
|
|
34
40
|
type ParentNode = parse5TreeAdapter.Document | parse5TreeAdapter.Element;
|
|
@@ -203,7 +209,9 @@ export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlC
|
|
|
203
209
|
const similarity = calculateSimilarity(originalLines, modifiedLines);
|
|
204
210
|
const { added, removed } = findDifferences(originalLines, modifiedLines);
|
|
205
211
|
|
|
206
|
-
const
|
|
212
|
+
const originalMap = collectElementMap(originalDocument);
|
|
213
|
+
const modifiedMap = collectElementMap(modifiedDocument);
|
|
214
|
+
const parts = await buildDiffParts(originalMap, modifiedMap);
|
|
207
215
|
|
|
208
216
|
const structuralAdditions = parts.flatMap((p) => p.added.filter((a) => a.startsWith('ELEMENT:')));
|
|
209
217
|
const allAdded = [...added, ...structuralAdditions];
|
|
@@ -216,9 +224,69 @@ export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlC
|
|
|
216
224
|
removed,
|
|
217
225
|
similarity,
|
|
218
226
|
summary,
|
|
227
|
+
messages: collectMessages(originalMap, modifiedMap, allAdded),
|
|
219
228
|
};
|
|
220
229
|
}
|
|
221
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Text the app announced while the page stayed the same: live region content first, then any other text that appeared.
|
|
233
|
+
*/
|
|
234
|
+
function collectMessages(originalMap: NodeMap, modifiedMap: NodeMap, added: string[]): string[] {
|
|
235
|
+
const appearedText = added.filter((line) => line.startsWith(TEXT_LINE_PREFIX)).map((line) => line.slice(TEXT_LINE_PREFIX.length));
|
|
236
|
+
|
|
237
|
+
return limitMessages([...collectLiveRegionTexts(originalMap, modifiedMap), ...appearedText]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
|
|
242
|
+
*/
|
|
243
|
+
export function liveRegionMessages(originalHtml: string, modifiedHtml: string): string[] {
|
|
244
|
+
const originalMap = collectElementMap(parseDocument(originalHtml));
|
|
245
|
+
const modifiedMap = collectElementMap(parseDocument(modifiedHtml));
|
|
246
|
+
|
|
247
|
+
return limitMessages(collectLiveRegionTexts(originalMap, modifiedMap));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function limitMessages(candidates: string[]): string[] {
|
|
251
|
+
const messages: string[] = [];
|
|
252
|
+
|
|
253
|
+
for (const candidate of candidates) {
|
|
254
|
+
const text = candidate.replace(/\s+/g, ' ').trim().slice(0, MESSAGE_MAX_LENGTH);
|
|
255
|
+
if (!text) continue;
|
|
256
|
+
if (messages.some((message) => message.includes(text))) continue;
|
|
257
|
+
messages.push(text);
|
|
258
|
+
if (messages.length === MESSAGE_LIMIT) break;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return messages;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function collectLiveRegionTexts(originalMap: NodeMap, modifiedMap: NodeMap): string[] {
|
|
265
|
+
const texts: string[] = [];
|
|
266
|
+
|
|
267
|
+
for (const [path, element] of modifiedMap) {
|
|
268
|
+
if (!isLiveRegion(element)) continue;
|
|
269
|
+
const text = getTextContent(element).trim();
|
|
270
|
+
if (!text) continue;
|
|
271
|
+
const previous = originalMap.get(path);
|
|
272
|
+
if (previous && getTextContent(previous).trim() === text) continue;
|
|
273
|
+
texts.push(text);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return texts;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isLiveRegion(element: ElementNode): boolean {
|
|
280
|
+
if (element.tagName?.toLowerCase() === 'output') return true;
|
|
281
|
+
|
|
282
|
+
const attrs = element.attrs ?? [];
|
|
283
|
+
const role = attrs.find((attr) => attr.name === 'role')?.value.toLowerCase();
|
|
284
|
+
if (role && LIVE_REGION_ROLES.has(role)) return true;
|
|
285
|
+
|
|
286
|
+
const live = attrs.find((attr) => attr.name === 'aria-live')?.value.toLowerCase();
|
|
287
|
+
return live === 'polite' || live === 'assertive';
|
|
288
|
+
}
|
|
289
|
+
|
|
222
290
|
/**
|
|
223
291
|
* Parse HTML into a document, wrapping fragments with html/body for consistency.
|
|
224
292
|
* Uses custom sanitization that removes iframes for diff purposes.
|
|
@@ -448,10 +516,7 @@ function findStableContainer(topLevelPath: string, originalMap: NodeMap, modifie
|
|
|
448
516
|
return { path: 'html[1]/body[1]', selector: 'body' };
|
|
449
517
|
}
|
|
450
518
|
|
|
451
|
-
async function buildDiffParts(
|
|
452
|
-
const originalMap = collectElementMap(originalDocument);
|
|
453
|
-
const modifiedMap = collectElementMap(modifiedDocument);
|
|
454
|
-
|
|
519
|
+
async function buildDiffParts(originalMap: NodeMap, modifiedMap: NodeMap): Promise<HtmlDiffPart[]> {
|
|
455
520
|
const addedPaths: string[] = [];
|
|
456
521
|
const changedPaths: string[] = [];
|
|
457
522
|
|
|
@@ -774,7 +839,7 @@ function flattenHtml(node: HtmlNode): string[] {
|
|
|
774
839
|
function process(n: HtmlNode): void {
|
|
775
840
|
if (n.type === 'text' && n.content) {
|
|
776
841
|
if (n.content.length >= 5) {
|
|
777
|
-
lines.push(
|
|
842
|
+
lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
|
|
778
843
|
}
|
|
779
844
|
return;
|
|
780
845
|
}
|
|
@@ -795,7 +860,7 @@ function flattenHtml(node: HtmlNode): string[] {
|
|
|
795
860
|
}
|
|
796
861
|
|
|
797
862
|
if (n.content && n.content.length >= 5) {
|
|
798
|
-
lines.push(
|
|
863
|
+
lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
|
|
799
864
|
}
|
|
800
865
|
|
|
801
866
|
if (n.children) {
|
package/src/utils/strings.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import stripAnsi from 'strip-ansi';
|
|
2
3
|
|
|
3
4
|
export function truncateJson(input: any): string {
|
|
4
5
|
if (!input) return '';
|
|
@@ -40,3 +41,38 @@ export function safeFilename(name: string, ext = '', maxBytes = 240): string {
|
|
|
40
41
|
}
|
|
41
42
|
return truncated + suffix + ext;
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
export function truncate(text: string, max: number): string {
|
|
46
|
+
if (text.length <= max) return text;
|
|
47
|
+
return `${text.slice(0, max - 3)}...`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MAX_COMPACT_ERROR = 400;
|
|
51
|
+
|
|
52
|
+
export function compactErrorMessage(error: unknown): string {
|
|
53
|
+
let text = stripAnsi(String(error));
|
|
54
|
+
for (const strip of STRIP_STRATEGIES) {
|
|
55
|
+
text = strip(text);
|
|
56
|
+
}
|
|
57
|
+
return truncate(text, MAX_COMPACT_ERROR);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stripCallLog(text: string): string {
|
|
61
|
+
const CALL_LOG = 'Call log:';
|
|
62
|
+
const NOISE = ['attempting', 'retrying', 'waiting'];
|
|
63
|
+
|
|
64
|
+
const [headline, ...log] = text.split(CALL_LOG);
|
|
65
|
+
if (!log.length) return text;
|
|
66
|
+
|
|
67
|
+
const lines = new Set<string>();
|
|
68
|
+
for (const line of log.join(CALL_LOG).split('\n')) {
|
|
69
|
+
const cleaned = normalizeInlineText(line);
|
|
70
|
+
if (!cleaned) continue;
|
|
71
|
+
if (NOISE.some((noise) => cleaned.includes(noise))) continue;
|
|
72
|
+
lines.add(cleaned);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [headline.trim(), ...lines].join(' ');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const STRIP_STRATEGIES = [stripCallLog];
|
package/src/utils/url-matcher.ts
CHANGED
|
@@ -28,6 +28,21 @@ export function hasDynamicUrlSegment(url: string): boolean {
|
|
|
28
28
|
return url.split('/').some((seg) => seg.length > 0 && isDynamicSegment(seg));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export function isSamePageFamily(urlA: string, urlB: string): boolean {
|
|
32
|
+
const partsA = new URL(urlA, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
|
|
33
|
+
const partsB = new URL(urlB, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
|
|
34
|
+
if (partsA.length !== partsB.length) return false;
|
|
35
|
+
|
|
36
|
+
let diffCount = 0;
|
|
37
|
+
for (let i = 0; i < partsA.length; i++) {
|
|
38
|
+
if (partsA[i] === partsB[i]) continue;
|
|
39
|
+
diffCount++;
|
|
40
|
+
if (diffCount > 1) return false;
|
|
41
|
+
if (!isDynamicSegment(partsA[i]) || !isDynamicSegment(partsB[i])) return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
export function generalizeSegment(segment: string): string {
|
|
32
47
|
if (/^\d+$/.test(segment)) return '\\d+';
|
|
33
48
|
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment)) return '[a-f0-9-]+';
|
|
@@ -103,6 +118,16 @@ export function matchesNavigationUrl(expected: string, current: string): boolean
|
|
|
103
118
|
if (!expectedPath.includes('?')) {
|
|
104
119
|
currentPath = currentPath.split('?')[0];
|
|
105
120
|
}
|
|
106
|
-
const normalize = (value: string) => value.replace(/^\/+|\/+$/g, '')
|
|
107
|
-
|
|
121
|
+
const normalize = (value: string) => value.replace(/^\/+|\/+$/g, '');
|
|
122
|
+
const expectedNormalized = normalize(expectedPath);
|
|
123
|
+
const currentNormalized = normalize(currentPath);
|
|
124
|
+
const expectedKey = expectedNormalized.toLowerCase();
|
|
125
|
+
const currentKey = currentNormalized.toLowerCase();
|
|
126
|
+
if (expectedKey === currentKey) return true;
|
|
127
|
+
if (!currentKey.startsWith(`${expectedKey}/`)) return false;
|
|
128
|
+
const recordSegments = currentNormalized
|
|
129
|
+
.slice(expectedKey.length + 1)
|
|
130
|
+
.split('/')
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
|
|
108
133
|
}
|