explorbot 0.2.4 → 0.3.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 +19 -7
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/prima/README.md +96 -0
- package/boat/prima/package.json +14 -10
- package/boat/prima/src/cli.ts +29 -12
- package/boat/prima/src/envelope.ts +35 -13
- package/boat/prima/src/prima.ts +78 -45
- package/dist/bin/explorbot-cli.js +19 -7
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/prima/src/cli.js +26 -7
- package/dist/boat/prima/src/envelope.js +32 -8
- package/dist/boat/prima/src/prima.js +75 -43
- 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 +53 -18
- 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/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 +2 -1
- package/dist/src/ai/navigator.d.ts +28 -0
- package/dist/src/ai/navigator.js +223 -175
- package/dist/src/ai/pilot.d.ts +7 -4
- package/dist/src/ai/pilot.js +89 -30
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +28 -22
- package/dist/src/ai/researcher/cache.d.ts +10 -3
- package/dist/src/ai/researcher/cache.js +23 -10
- 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 +6 -4
- package/dist/src/ai/rules.js +1 -5
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/tester.d.ts +6 -3
- package/dist/src/ai/tester.js +30 -35
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +83 -57
- package/dist/src/commands/config-command.d.ts +51 -0
- package/dist/src/commands/config-command.js +117 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +13 -20
- package/dist/src/config.d.ts +8 -1
- package/dist/src/config.js +43 -1
- package/dist/src/experience-tracker.d.ts +2 -0
- package/dist/src/experience-tracker.js +12 -0
- package/dist/src/explorbot.js +5 -2
- package/dist/src/playwright-recorder.js +6 -12
- package/dist/src/remote.d.ts +3 -2
- package/dist/src/remote.js +8 -2
- package/dist/src/state-manager.d.ts +1 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/test-plan.d.ts +9 -0
- package/dist/src/test-plan.js +30 -0
- package/dist/src/utils/html-diff.d.ts +5 -0
- package/dist/src/utils/html-diff.js +65 -6
- package/dist/src/utils/logger.d.ts +1 -1
- package/dist/src/utils/logger.js +8 -0
- 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/docs/index.json +2 -1
- package/docs/reference/commands.md +3 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/models.json +4 -4
- package/package.json +6 -2
- package/src/action-result.ts +61 -16
- package/src/action.ts +56 -18
- package/src/ai/captain/web-mode.ts +1 -2
- package/src/ai/captain.ts +9 -1
- package/src/ai/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/navigator.ts +238 -179
- package/src/ai/pilot.ts +104 -36
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +29 -21
- package/src/ai/researcher/cache.ts +29 -11
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +23 -5
- package/src/ai/researcher.ts +6 -4
- package/src/ai/rules.ts +1 -5
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/tester.ts +33 -34
- package/src/ai/tools.ts +88 -61
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +47 -2
- package/src/experience-tracker.ts +13 -0
- package/src/explorbot.ts +4 -2
- package/src/playwright-recorder.ts +6 -11
- package/src/remote.ts +8 -2
- package/src/state-manager.ts +5 -2
- package/src/test-plan.ts +38 -0
- package/src/utils/html-diff.ts +72 -7
- package/src/utils/logger.ts +9 -1
- package/src/utils/strings.ts +36 -0
- package/src/utils/url-matcher.ts +27 -2
package/dist/src/config.js
CHANGED
|
@@ -157,7 +157,7 @@ export class ConfigParser {
|
|
|
157
157
|
const config = this.getConfig();
|
|
158
158
|
if (!this.configPath)
|
|
159
159
|
throw new Error('Config path not found');
|
|
160
|
-
return
|
|
160
|
+
return this.resolveProjectDir(config.dirs?.output || 'output');
|
|
161
161
|
}
|
|
162
162
|
getProjectRoot() {
|
|
163
163
|
if (this.site)
|
|
@@ -168,6 +168,8 @@ export class ConfigParser {
|
|
|
168
168
|
return process.cwd();
|
|
169
169
|
}
|
|
170
170
|
resolveProjectDir(relativeDir) {
|
|
171
|
+
if (path.isAbsolute(relativeDir))
|
|
172
|
+
return relativeDir;
|
|
171
173
|
if (!this.configPath)
|
|
172
174
|
return relativeDir;
|
|
173
175
|
return path.join(this.getProjectRoot(), relativeDir);
|
|
@@ -231,6 +233,7 @@ export class ConfigParser {
|
|
|
231
233
|
model: { modelId: 'test-model', provider: 'test' },
|
|
232
234
|
config: {},
|
|
233
235
|
vision: false,
|
|
236
|
+
langfuse: { enabled: false },
|
|
234
237
|
},
|
|
235
238
|
dirs: {
|
|
236
239
|
knowledge: join(testBaseDir, 'knowledge'),
|
|
@@ -358,6 +361,17 @@ export class ConfigParser {
|
|
|
358
361
|
config.playwright = config.playwright || { browser: 'chromium', url: '' };
|
|
359
362
|
config.playwright.url = options.baseUrl;
|
|
360
363
|
}
|
|
364
|
+
if (config.ai) {
|
|
365
|
+
const langfuse = config.ai.langfuse;
|
|
366
|
+
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
367
|
+
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
368
|
+
config.ai.langfuse = {
|
|
369
|
+
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
370
|
+
publicKey,
|
|
371
|
+
secretKey,
|
|
372
|
+
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
361
375
|
return config;
|
|
362
376
|
}
|
|
363
377
|
validateConfig(config) {
|
|
@@ -454,6 +468,34 @@ export function missingConfigMessage(configFile = 'explorbot.config.js') {
|
|
|
454
468
|
Providers: ${Object.keys(PROVIDERS).join(', ')}
|
|
455
469
|
`;
|
|
456
470
|
}
|
|
471
|
+
export function modelName(model) {
|
|
472
|
+
if (typeof model === 'string')
|
|
473
|
+
return model;
|
|
474
|
+
return model?.modelId || model?.model || 'unknown';
|
|
475
|
+
}
|
|
476
|
+
export function modelProvider(model) {
|
|
477
|
+
const provider = model?.provider;
|
|
478
|
+
if (typeof provider === 'string')
|
|
479
|
+
return provider.split('.')[0];
|
|
480
|
+
if (typeof model === 'string')
|
|
481
|
+
return model.split('/')[0];
|
|
482
|
+
return '';
|
|
483
|
+
}
|
|
484
|
+
export function configuredModels(ai) {
|
|
485
|
+
if (!ai?.model)
|
|
486
|
+
return {};
|
|
487
|
+
const describe = (model) => ({ name: modelName(model), provider: modelProvider(model) });
|
|
488
|
+
const models = { model: describe(ai.model) };
|
|
489
|
+
if (ai.agenticModel)
|
|
490
|
+
models.agenticModel = describe(ai.agenticModel);
|
|
491
|
+
if (ai.visionModel)
|
|
492
|
+
models.visionModel = describe(ai.visionModel);
|
|
493
|
+
for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
|
|
494
|
+
if (agentConfig?.model)
|
|
495
|
+
models[agent] = describe(agentConfig.model);
|
|
496
|
+
}
|
|
497
|
+
return models;
|
|
498
|
+
}
|
|
457
499
|
export async function resolveConfigModels(ai) {
|
|
458
500
|
if (!ai)
|
|
459
501
|
return;
|
|
@@ -53,6 +53,7 @@ export declare class ExperienceTracker {
|
|
|
53
53
|
getExperienceTableOfContents(state: ActionResult, options?: {
|
|
54
54
|
includeDescendantExperience?: boolean;
|
|
55
55
|
}): ExperienceTocEntry[];
|
|
56
|
+
renderExperienceFor(state: ActionResult): string;
|
|
56
57
|
renderExperienceTocFor(state: ActionResult): string;
|
|
57
58
|
getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: {
|
|
58
59
|
includeDescendantExperience?: boolean;
|
|
@@ -77,6 +78,7 @@ export declare class ExperienceTracker {
|
|
|
77
78
|
fileHash: string;
|
|
78
79
|
} | null;
|
|
79
80
|
}
|
|
81
|
+
export declare function renderExperienceRecipes(recipes: string[]): string;
|
|
80
82
|
export declare function renderExperienceToc(toc: ExperienceTocEntry[]): string;
|
|
81
83
|
export interface ExperienceFile {
|
|
82
84
|
filePath: string;
|
|
@@ -257,6 +257,13 @@ export class ExperienceTracker {
|
|
|
257
257
|
});
|
|
258
258
|
return this.buildToc(sorted);
|
|
259
259
|
}
|
|
260
|
+
renderExperienceFor(state) {
|
|
261
|
+
const successful = this.getSuccessfulExperience(state);
|
|
262
|
+
if (!successful.length)
|
|
263
|
+
return '';
|
|
264
|
+
tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${state.url}`);
|
|
265
|
+
return renderExperienceRecipes(successful);
|
|
266
|
+
}
|
|
260
267
|
renderExperienceTocFor(state) {
|
|
261
268
|
const toc = this.getExperienceTableOfContents(state);
|
|
262
269
|
if (toc.length === 0)
|
|
@@ -381,6 +388,11 @@ function indexToLetters(index) {
|
|
|
381
388
|
}
|
|
382
389
|
return result;
|
|
383
390
|
}
|
|
391
|
+
export function renderExperienceRecipes(recipes) {
|
|
392
|
+
if (recipes.length === 0)
|
|
393
|
+
return '';
|
|
394
|
+
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>`;
|
|
395
|
+
}
|
|
384
396
|
export function renderExperienceToc(toc) {
|
|
385
397
|
if (toc.length === 0)
|
|
386
398
|
return '';
|
package/dist/src/explorbot.js
CHANGED
|
@@ -199,7 +199,7 @@ export class ExplorBot {
|
|
|
199
199
|
this.agents.tester = this.createAgent((deps) => {
|
|
200
200
|
const researcher = this.agentResearcher();
|
|
201
201
|
const navigator = this.agentNavigator();
|
|
202
|
-
const tools = createAgentTools({ ...deps, researcher, navigator });
|
|
202
|
+
const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
|
|
203
203
|
return new Tester(deps, researcher, navigator, tools);
|
|
204
204
|
});
|
|
205
205
|
const qm = this.agentQuartermaster();
|
|
@@ -418,9 +418,12 @@ export class ExplorBot {
|
|
|
418
418
|
}
|
|
419
419
|
setCurrentPlan(plan) {
|
|
420
420
|
this.currentPlan = plan;
|
|
421
|
-
if (
|
|
421
|
+
if (!plan)
|
|
422
|
+
return;
|
|
423
|
+
if (!this.sessionPlans.includes(plan)) {
|
|
422
424
|
this.sessionPlans.push(plan);
|
|
423
425
|
}
|
|
426
|
+
plan.notifyChange();
|
|
424
427
|
}
|
|
425
428
|
getSessionTests() {
|
|
426
429
|
return this.sessionPlans.flatMap((p) => p.tests.filter((t) => t.startTime != null));
|
|
@@ -6,7 +6,7 @@ const RECORDABLE = {
|
|
|
6
6
|
Frame: new Set(['click', 'dblclick', 'fill', 'selectOption', 'press', 'type', 'check', 'uncheck', 'hover', 'tap', 'focus', 'setInputFiles', 'scrollIntoViewIfNeeded', 'dragTo', 'goto', 'setContent']),
|
|
7
7
|
Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
|
|
8
8
|
};
|
|
9
|
-
const PLAYWRIGHT_INCOMPATIBLE = "Playwright output
|
|
9
|
+
const PLAYWRIGHT_INCOMPATIBLE = "Playwright output requires playwright-core 1.62 or newer (lib/coreBundle does not expose iso.asLocator). Use output.framework: 'codeceptjs' instead.";
|
|
10
10
|
let cachedAsLocator = null;
|
|
11
11
|
let asLocatorLoadAttempted = false;
|
|
12
12
|
const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url);
|
|
@@ -16,17 +16,11 @@ function getAsLocator() {
|
|
|
16
16
|
if (asLocatorLoadAttempted)
|
|
17
17
|
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
18
18
|
asLocatorLoadAttempted = true;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// Module not exported or not found
|
|
28
|
-
}
|
|
29
|
-
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
19
|
+
const asLocator = nodeRequire('playwright-core/lib/coreBundle')?.iso?.asLocator;
|
|
20
|
+
if (typeof asLocator !== 'function')
|
|
21
|
+
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
22
|
+
cachedAsLocator = asLocator;
|
|
23
|
+
return cachedAsLocator;
|
|
30
24
|
}
|
|
31
25
|
export class PlaywrightRecorder {
|
|
32
26
|
context = null;
|
package/dist/src/remote.d.ts
CHANGED
|
@@ -6,8 +6,9 @@ import { type LogDestination, type TaggedLogEntry } from './utils/logger.js';
|
|
|
6
6
|
* process and a CI bot are the same case.
|
|
7
7
|
*
|
|
8
8
|
* It **is** a LogDestination — that is the whole integration on the logger's
|
|
9
|
-
* side — and it answers asks by installing itself
|
|
10
|
-
* controller's input callback. Nothing else in explorbot
|
|
9
|
+
* side, messages and `data` alike — and it answers asks by installing itself
|
|
10
|
+
* as the execution controller's input callback. Nothing else in explorbot
|
|
11
|
+
* knows it exists.
|
|
11
12
|
*/
|
|
12
13
|
export declare class Remote implements LogDestination {
|
|
13
14
|
url: string | null;
|
package/dist/src/remote.js
CHANGED
|
@@ -13,8 +13,9 @@ const FLUSH_TIMEOUT_MS = 3000;
|
|
|
13
13
|
* process and a CI bot are the same case.
|
|
14
14
|
*
|
|
15
15
|
* It **is** a LogDestination — that is the whole integration on the logger's
|
|
16
|
-
* side — and it answers asks by installing itself
|
|
17
|
-
* controller's input callback. Nothing else in explorbot
|
|
16
|
+
* side, messages and `data` alike — and it answers asks by installing itself
|
|
17
|
+
* as the execution controller's input callback. Nothing else in explorbot
|
|
18
|
+
* knows it exists.
|
|
18
19
|
*/
|
|
19
20
|
export class Remote {
|
|
20
21
|
url = null;
|
|
@@ -105,6 +106,11 @@ export class Remote {
|
|
|
105
106
|
write(entry) {
|
|
106
107
|
if (entry.type === 'html')
|
|
107
108
|
return;
|
|
109
|
+
if (entry.type === 'data') {
|
|
110
|
+
const [kind, payload] = entry.originalArgs || [];
|
|
111
|
+
this.send(String(kind), payload);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
108
114
|
let content = stripAnsi(entry.content ?? '');
|
|
109
115
|
if (content.length > CONTENT_CAP)
|
|
110
116
|
content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FocusedElement
|
|
1
|
+
import { ActionResult, type FocusedElement } from './action-result.js';
|
|
2
2
|
import type { ExperienceTracker } from './experience-tracker.js';
|
|
3
3
|
import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
|
|
4
4
|
export interface Link {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ActionResult } from './action-result.js';
|
|
2
2
|
import { detectFocusArea } from './utils/aria.js';
|
|
3
|
-
import { createDebug } from './utils/logger.js';
|
|
3
|
+
import { createDebug, tag } from './utils/logger.js';
|
|
4
4
|
import { slugify } from './utils/strings.js';
|
|
5
5
|
import { extractStatePath } from './utils/url-matcher.js';
|
|
6
6
|
const debugLog = createDebug('explorbot:state');
|
|
@@ -40,6 +40,8 @@ export class StateManager {
|
|
|
40
40
|
* Emit state change event to all listeners
|
|
41
41
|
*/
|
|
42
42
|
emitStateChange(event) {
|
|
43
|
+
const state = event.toState;
|
|
44
|
+
tag('data').log('state', { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 });
|
|
43
45
|
this.stateChangeListeners.forEach((listener) => {
|
|
44
46
|
try {
|
|
45
47
|
listener(event);
|
package/dist/src/test-plan.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ActionResult } from './action-result.js';
|
|
1
2
|
import { WebPageState } from './state-manager.js';
|
|
2
3
|
export declare const TestResult: {
|
|
3
4
|
readonly PASSED: "passed";
|
|
@@ -94,10 +95,13 @@ export declare class Test extends Task {
|
|
|
94
95
|
startTime?: number;
|
|
95
96
|
endTime?: number;
|
|
96
97
|
resetCount: number;
|
|
98
|
+
appliedExperience: AppliedExperience[];
|
|
97
99
|
constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps?: string[]);
|
|
98
100
|
getVisitedUrls({ localOnly }?: {
|
|
99
101
|
localOnly?: boolean;
|
|
100
102
|
}): string[];
|
|
103
|
+
applyExperience(recipes: AppliedExperience[]): void;
|
|
104
|
+
getAppliedExperience(state: ActionResult): string[];
|
|
101
105
|
addArtifact(artifact?: string): void;
|
|
102
106
|
get hasFinished(): boolean;
|
|
103
107
|
get isSuccessful(): boolean;
|
|
@@ -113,6 +117,7 @@ export declare class Test extends Task {
|
|
|
113
117
|
finish(result?: TestResultType): void;
|
|
114
118
|
getDurationMs(): number | null;
|
|
115
119
|
getRemainingExpectations(): string[];
|
|
120
|
+
reportStatus(): void;
|
|
116
121
|
getLog(): Array<{
|
|
117
122
|
type: 'step' | 'note' | 'artifact';
|
|
118
123
|
content: string;
|
|
@@ -165,4 +170,8 @@ interface UrlNoteState {
|
|
|
165
170
|
h2?: string;
|
|
166
171
|
screenshotFile?: string;
|
|
167
172
|
}
|
|
173
|
+
interface AppliedExperience {
|
|
174
|
+
url: string;
|
|
175
|
+
content: string;
|
|
176
|
+
}
|
|
168
177
|
export {};
|
package/dist/src/test-plan.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { tag } from "./utils/logger.js";
|
|
2
3
|
import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from "./utils/test-plan-markdown.js";
|
|
3
4
|
import { uniqSessionName } from "./utils/unique-names.js";
|
|
4
5
|
export const TestResult = {
|
|
@@ -189,6 +190,7 @@ export class Test extends Task {
|
|
|
189
190
|
startTime;
|
|
190
191
|
endTime;
|
|
191
192
|
resetCount = 0;
|
|
193
|
+
appliedExperience = [];
|
|
192
194
|
constructor(scenario, priority, expectedOutcome, startUrl, plannedSteps = []) {
|
|
193
195
|
super(scenario, startUrl);
|
|
194
196
|
this.scenario = scenario;
|
|
@@ -207,6 +209,16 @@ export class Test extends Task {
|
|
|
207
209
|
}
|
|
208
210
|
return [...new Set([this.startUrl, ...this.states.map((s) => s.url)].filter((value) => Boolean(value) && value.trim() !== ''))];
|
|
209
211
|
}
|
|
212
|
+
applyExperience(recipes) {
|
|
213
|
+
for (const recipe of recipes) {
|
|
214
|
+
if (this.appliedExperience.some((applied) => applied.content === recipe.content))
|
|
215
|
+
continue;
|
|
216
|
+
this.appliedExperience.push(recipe);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
getAppliedExperience(state) {
|
|
220
|
+
return this.appliedExperience.filter((recipe) => state.isRelevantExperienceRecord({ url: recipe.url })).map((recipe) => recipe.content);
|
|
221
|
+
}
|
|
210
222
|
addArtifact(artifact) {
|
|
211
223
|
if (!artifact)
|
|
212
224
|
return;
|
|
@@ -260,12 +272,14 @@ export class Test extends Task {
|
|
|
260
272
|
this.startTime = performance.now();
|
|
261
273
|
this.addNote(`Test started. Session name: ${this.sessionName}`);
|
|
262
274
|
this.plan?.notifyChange();
|
|
275
|
+
this.reportStatus();
|
|
263
276
|
}
|
|
264
277
|
finish(result = TestResult.FAILED) {
|
|
265
278
|
this.status = TestStatus.DONE;
|
|
266
279
|
this.result = result;
|
|
267
280
|
this.endTime = performance.now();
|
|
268
281
|
this.plan?.notifyChange();
|
|
282
|
+
this.reportStatus();
|
|
269
283
|
}
|
|
270
284
|
getDurationMs() {
|
|
271
285
|
if (this.startTime != null && this.endTime != null)
|
|
@@ -276,6 +290,17 @@ export class Test extends Task {
|
|
|
276
290
|
const achieved = this.getCheckedExpectations();
|
|
277
291
|
return this.expected.filter((e) => !achieved.includes(e));
|
|
278
292
|
}
|
|
293
|
+
reportStatus() {
|
|
294
|
+
tag('data').log('test', {
|
|
295
|
+
scenario: this.scenario,
|
|
296
|
+
status: this.status,
|
|
297
|
+
result: this.result,
|
|
298
|
+
priority: this.priority,
|
|
299
|
+
sessionName: this.sessionName,
|
|
300
|
+
url: this.startUrl,
|
|
301
|
+
plan: this.plan?.title,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
279
304
|
getLog() {
|
|
280
305
|
const merged = {};
|
|
281
306
|
for (const [key, stepData] of Object.entries(this.steps)) {
|
|
@@ -338,6 +363,11 @@ export class Plan {
|
|
|
338
363
|
for (const listener of this.changeListeners) {
|
|
339
364
|
listener(this.tests);
|
|
340
365
|
}
|
|
366
|
+
tag('data').log('plan', {
|
|
367
|
+
title: this.title,
|
|
368
|
+
url: this.url,
|
|
369
|
+
tests: this.tests.map((test) => ({ scenario: test.scenario, status: test.status, result: test.result, priority: test.priority })),
|
|
370
|
+
});
|
|
341
371
|
}
|
|
342
372
|
getAllTests() {
|
|
343
373
|
if (!this.parentPlan)
|
|
@@ -11,9 +11,14 @@ export interface HtmlDiffResult {
|
|
|
11
11
|
removed: string[];
|
|
12
12
|
similarity: number;
|
|
13
13
|
summary: string;
|
|
14
|
+
messages: string[];
|
|
14
15
|
}
|
|
15
16
|
export declare function computeHtmlFingerprint(html: string): string[];
|
|
16
17
|
/**
|
|
17
18
|
* Compares two HTML documents and returns differences along with a diff subtree.
|
|
18
19
|
*/
|
|
19
20
|
export declare function htmlDiff(originalHtml: string, modifiedHtml: string, htmlConfig?: HtmlConfig): Promise<HtmlDiffResult>;
|
|
21
|
+
/**
|
|
22
|
+
* Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
|
|
23
|
+
*/
|
|
24
|
+
export declare function liveRegionMessages(originalHtml: string, modifiedHtml: string): string[];
|
|
@@ -2,6 +2,10 @@ import { parse, serialize } from 'parse5';
|
|
|
2
2
|
import { TAILWIND_CLASS_PATTERNS, TRASH_HTML_CLASSES, minifyHtml } from "./html.js";
|
|
3
3
|
import { isDynamicId, isGenericClass } from "./xpath.js";
|
|
4
4
|
const IGNORED_PATHS = new Set(['html[1]', 'html[1]/head[1]', 'html[1]/body[1]']);
|
|
5
|
+
const LIVE_REGION_ROLES = new Set(['alert', 'alertdialog', 'status', 'log']);
|
|
6
|
+
const TEXT_LINE_PREFIX = 'TEXT:';
|
|
7
|
+
const MESSAGE_MAX_LENGTH = 200;
|
|
8
|
+
const MESSAGE_LIMIT = 8;
|
|
5
9
|
/**
|
|
6
10
|
* Get text content from an element node.
|
|
7
11
|
*/
|
|
@@ -141,7 +145,9 @@ export async function htmlDiff(originalHtml, modifiedHtml, htmlConfig) {
|
|
|
141
145
|
const modifiedLines = flattenHtml(modifiedRoot);
|
|
142
146
|
const similarity = calculateSimilarity(originalLines, modifiedLines);
|
|
143
147
|
const { added, removed } = findDifferences(originalLines, modifiedLines);
|
|
144
|
-
const
|
|
148
|
+
const originalMap = collectElementMap(originalDocument);
|
|
149
|
+
const modifiedMap = collectElementMap(modifiedDocument);
|
|
150
|
+
const parts = await buildDiffParts(originalMap, modifiedMap);
|
|
145
151
|
const structuralAdditions = parts.flatMap((p) => p.added.filter((a) => a.startsWith('ELEMENT:')));
|
|
146
152
|
const allAdded = [...added, ...structuralAdditions];
|
|
147
153
|
const totalChanges = allAdded.length + removed.length;
|
|
@@ -152,8 +158,63 @@ export async function htmlDiff(originalHtml, modifiedHtml, htmlConfig) {
|
|
|
152
158
|
removed,
|
|
153
159
|
similarity,
|
|
154
160
|
summary,
|
|
161
|
+
messages: collectMessages(originalMap, modifiedMap, allAdded),
|
|
155
162
|
};
|
|
156
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Text the app announced while the page stayed the same: live region content first, then any other text that appeared.
|
|
166
|
+
*/
|
|
167
|
+
function collectMessages(originalMap, modifiedMap, added) {
|
|
168
|
+
const appearedText = added.filter((line) => line.startsWith(TEXT_LINE_PREFIX)).map((line) => line.slice(TEXT_LINE_PREFIX.length));
|
|
169
|
+
return limitMessages([...collectLiveRegionTexts(originalMap, modifiedMap), ...appearedText]);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Text the app announced across a navigation. Only live regions: everything else on a new page is its content, not a message.
|
|
173
|
+
*/
|
|
174
|
+
export function liveRegionMessages(originalHtml, modifiedHtml) {
|
|
175
|
+
const originalMap = collectElementMap(parseDocument(originalHtml));
|
|
176
|
+
const modifiedMap = collectElementMap(parseDocument(modifiedHtml));
|
|
177
|
+
return limitMessages(collectLiveRegionTexts(originalMap, modifiedMap));
|
|
178
|
+
}
|
|
179
|
+
function limitMessages(candidates) {
|
|
180
|
+
const messages = [];
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
const text = candidate.replace(/\s+/g, ' ').trim().slice(0, MESSAGE_MAX_LENGTH);
|
|
183
|
+
if (!text)
|
|
184
|
+
continue;
|
|
185
|
+
if (messages.some((message) => message.includes(text)))
|
|
186
|
+
continue;
|
|
187
|
+
messages.push(text);
|
|
188
|
+
if (messages.length === MESSAGE_LIMIT)
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
return messages;
|
|
192
|
+
}
|
|
193
|
+
function collectLiveRegionTexts(originalMap, modifiedMap) {
|
|
194
|
+
const texts = [];
|
|
195
|
+
for (const [path, element] of modifiedMap) {
|
|
196
|
+
if (!isLiveRegion(element))
|
|
197
|
+
continue;
|
|
198
|
+
const text = getTextContent(element).trim();
|
|
199
|
+
if (!text)
|
|
200
|
+
continue;
|
|
201
|
+
const previous = originalMap.get(path);
|
|
202
|
+
if (previous && getTextContent(previous).trim() === text)
|
|
203
|
+
continue;
|
|
204
|
+
texts.push(text);
|
|
205
|
+
}
|
|
206
|
+
return texts;
|
|
207
|
+
}
|
|
208
|
+
function isLiveRegion(element) {
|
|
209
|
+
if (element.tagName?.toLowerCase() === 'output')
|
|
210
|
+
return true;
|
|
211
|
+
const attrs = element.attrs ?? [];
|
|
212
|
+
const role = attrs.find((attr) => attr.name === 'role')?.value.toLowerCase();
|
|
213
|
+
if (role && LIVE_REGION_ROLES.has(role))
|
|
214
|
+
return true;
|
|
215
|
+
const live = attrs.find((attr) => attr.name === 'aria-live')?.value.toLowerCase();
|
|
216
|
+
return live === 'polite' || live === 'assertive';
|
|
217
|
+
}
|
|
157
218
|
/**
|
|
158
219
|
* Parse HTML into a document, wrapping fragments with html/body for consistency.
|
|
159
220
|
* Uses custom sanitization that removes iframes for diff purposes.
|
|
@@ -354,9 +415,7 @@ function findStableContainer(topLevelPath, originalMap, modifiedMap) {
|
|
|
354
415
|
}
|
|
355
416
|
return { path: 'html[1]/body[1]', selector: 'body' };
|
|
356
417
|
}
|
|
357
|
-
async function buildDiffParts(
|
|
358
|
-
const originalMap = collectElementMap(originalDocument);
|
|
359
|
-
const modifiedMap = collectElementMap(modifiedDocument);
|
|
418
|
+
async function buildDiffParts(originalMap, modifiedMap) {
|
|
360
419
|
const addedPaths = [];
|
|
361
420
|
const changedPaths = [];
|
|
362
421
|
for (const [path, element] of modifiedMap.entries()) {
|
|
@@ -624,7 +683,7 @@ function flattenHtml(node) {
|
|
|
624
683
|
function process(n) {
|
|
625
684
|
if (n.type === 'text' && n.content) {
|
|
626
685
|
if (n.content.length >= 5) {
|
|
627
|
-
lines.push(
|
|
686
|
+
lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
|
|
628
687
|
}
|
|
629
688
|
return;
|
|
630
689
|
}
|
|
@@ -643,7 +702,7 @@ function flattenHtml(node) {
|
|
|
643
702
|
return;
|
|
644
703
|
}
|
|
645
704
|
if (n.content && n.content.length >= 5) {
|
|
646
|
-
lines.push(
|
|
705
|
+
lines.push(`${TEXT_LINE_PREFIX}${n.content}`);
|
|
647
706
|
}
|
|
648
707
|
if (n.children) {
|
|
649
708
|
n.children.forEach((child) => process(child));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Span } from '@opentelemetry/api';
|
|
2
|
-
export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input';
|
|
2
|
+
export type LogType = 'info' | 'success' | 'error' | 'warning' | 'debug' | 'substep' | 'operation' | 'step' | 'multiline' | 'details' | 'html' | 'input' | 'data';
|
|
3
3
|
export interface TaggedLogEntry {
|
|
4
4
|
type: LogType;
|
|
5
5
|
content: string;
|
package/dist/src/utils/logger.js
CHANGED
|
@@ -406,6 +406,14 @@ class Logger {
|
|
|
406
406
|
}
|
|
407
407
|
return;
|
|
408
408
|
}
|
|
409
|
+
if (type === 'data') {
|
|
410
|
+
const entry = { type, content: String(args[0]), timestamp: new Date(), originalArgs: args };
|
|
411
|
+
for (const destination of this.extra) {
|
|
412
|
+
if (destination.isEnabled())
|
|
413
|
+
destination.write(entry);
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
409
417
|
const options = this.extractLogOptions(type, args);
|
|
410
418
|
let content = this.processArgs(args);
|
|
411
419
|
if (type === 'step' && args[0]?.toCode) {
|
|
@@ -3,3 +3,5 @@ export declare function slugify(text: string): string;
|
|
|
3
3
|
export declare function normalizeInlineText(text: string): string;
|
|
4
4
|
export declare function sanitizeFilename(name: string): string;
|
|
5
5
|
export declare function safeFilename(name: string, ext?: string, maxBytes?: number): string;
|
|
6
|
+
export declare function truncate(text: string, max: number): string;
|
|
7
|
+
export declare function compactErrorMessage(error: unknown): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import stripAnsi from 'strip-ansi';
|
|
2
3
|
export function truncateJson(input) {
|
|
3
4
|
if (!input)
|
|
4
5
|
return '';
|
|
@@ -36,3 +37,34 @@ export function safeFilename(name, ext = '', maxBytes = 240) {
|
|
|
36
37
|
}
|
|
37
38
|
return truncated + suffix + ext;
|
|
38
39
|
}
|
|
40
|
+
export function truncate(text, max) {
|
|
41
|
+
if (text.length <= max)
|
|
42
|
+
return text;
|
|
43
|
+
return `${text.slice(0, max - 3)}...`;
|
|
44
|
+
}
|
|
45
|
+
const MAX_COMPACT_ERROR = 400;
|
|
46
|
+
export function compactErrorMessage(error) {
|
|
47
|
+
let text = stripAnsi(String(error));
|
|
48
|
+
for (const strip of STRIP_STRATEGIES) {
|
|
49
|
+
text = strip(text);
|
|
50
|
+
}
|
|
51
|
+
return truncate(text, MAX_COMPACT_ERROR);
|
|
52
|
+
}
|
|
53
|
+
function stripCallLog(text) {
|
|
54
|
+
const CALL_LOG = 'Call log:';
|
|
55
|
+
const NOISE = ['attempting', 'retrying', 'waiting'];
|
|
56
|
+
const [headline, ...log] = text.split(CALL_LOG);
|
|
57
|
+
if (!log.length)
|
|
58
|
+
return text;
|
|
59
|
+
const lines = new Set();
|
|
60
|
+
for (const line of log.join(CALL_LOG).split('\n')) {
|
|
61
|
+
const cleaned = normalizeInlineText(line);
|
|
62
|
+
if (!cleaned)
|
|
63
|
+
continue;
|
|
64
|
+
if (NOISE.some((noise) => cleaned.includes(noise)))
|
|
65
|
+
continue;
|
|
66
|
+
lines.add(cleaned);
|
|
67
|
+
}
|
|
68
|
+
return [headline.trim(), ...lines].join(' ');
|
|
69
|
+
}
|
|
70
|
+
const STRIP_STRATEGIES = [stripCallLog];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare function isDynamicSegment(segment: string): boolean;
|
|
2
2
|
export declare function hasDynamicUrlSegment(url: string): boolean;
|
|
3
|
+
export declare function isSamePageFamily(urlA: string, urlB: string): boolean;
|
|
3
4
|
export declare function generalizeSegment(segment: string): string;
|
|
4
5
|
export declare function generalizeUrl(url: string): string;
|
|
5
6
|
export declare function matchesUrl(pattern: string, path: string): boolean;
|
|
@@ -32,6 +32,23 @@ export function isDynamicSegment(segment) {
|
|
|
32
32
|
export function hasDynamicUrlSegment(url) {
|
|
33
33
|
return url.split('/').some((seg) => seg.length > 0 && isDynamicSegment(seg));
|
|
34
34
|
}
|
|
35
|
+
export function isSamePageFamily(urlA, urlB) {
|
|
36
|
+
const partsA = new URL(urlA, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
|
|
37
|
+
const partsB = new URL(urlB, 'http://localhost').pathname.toLowerCase().split('/').filter(Boolean);
|
|
38
|
+
if (partsA.length !== partsB.length)
|
|
39
|
+
return false;
|
|
40
|
+
let diffCount = 0;
|
|
41
|
+
for (let i = 0; i < partsA.length; i++) {
|
|
42
|
+
if (partsA[i] === partsB[i])
|
|
43
|
+
continue;
|
|
44
|
+
diffCount++;
|
|
45
|
+
if (diffCount > 1)
|
|
46
|
+
return false;
|
|
47
|
+
if (!isDynamicSegment(partsA[i]) || !isDynamicSegment(partsB[i]))
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
35
52
|
export function generalizeSegment(segment) {
|
|
36
53
|
if (/^\d+$/.test(segment))
|
|
37
54
|
return '\\d+';
|
|
@@ -111,6 +128,18 @@ export function matchesNavigationUrl(expected, current) {
|
|
|
111
128
|
if (!expectedPath.includes('?')) {
|
|
112
129
|
currentPath = currentPath.split('?')[0];
|
|
113
130
|
}
|
|
114
|
-
const normalize = (value) => value.replace(/^\/+|\/+$/g, '')
|
|
115
|
-
|
|
131
|
+
const normalize = (value) => value.replace(/^\/+|\/+$/g, '');
|
|
132
|
+
const expectedNormalized = normalize(expectedPath);
|
|
133
|
+
const currentNormalized = normalize(currentPath);
|
|
134
|
+
const expectedKey = expectedNormalized.toLowerCase();
|
|
135
|
+
const currentKey = currentNormalized.toLowerCase();
|
|
136
|
+
if (expectedKey === currentKey)
|
|
137
|
+
return true;
|
|
138
|
+
if (!currentKey.startsWith(`${expectedKey}/`))
|
|
139
|
+
return false;
|
|
140
|
+
const recordSegments = currentNormalized
|
|
141
|
+
.slice(expectedKey.length + 1)
|
|
142
|
+
.split('/')
|
|
143
|
+
.filter(Boolean);
|
|
144
|
+
return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
|
|
116
145
|
}
|