explorbot 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -1
- package/bin/explorbot-cli.ts +86 -15
- package/boat/api-tester/src/ai/curler-tools.ts +3 -3
- package/boat/api-tester/src/ai/curler.ts +1 -1
- package/boat/api-tester/src/apibot.ts +2 -2
- package/boat/api-tester/src/config.ts +1 -1
- package/dist/bin/explorbot-cli.js +85 -14
- package/dist/boat/api-tester/src/ai/curler-tools.js +2 -2
- package/dist/boat/api-tester/src/apibot.js +2 -2
- package/dist/package.json +2 -2
- package/dist/rules/navigator/output.md +9 -0
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action-result.js +23 -1
- package/dist/src/action.js +46 -38
- package/dist/src/ai/bosun.js +16 -2
- package/dist/src/ai/conversation.js +39 -0
- package/dist/src/ai/experience-compactor.js +235 -50
- package/dist/src/ai/historian/codeceptjs.js +109 -0
- package/dist/src/ai/historian/experience.js +320 -0
- package/dist/src/ai/historian/mixin.js +2 -0
- package/dist/src/ai/historian/playwright.js +145 -0
- package/dist/src/ai/historian/utils.js +18 -0
- package/dist/src/ai/historian.js +19 -398
- package/dist/src/ai/navigator.js +133 -80
- package/dist/src/ai/pilot.js +254 -13
- package/dist/src/ai/planner/subpages.js +1 -30
- package/dist/src/ai/planner.js +33 -13
- package/dist/src/ai/provider.js +55 -18
- package/dist/src/ai/rerunner.js +3 -3
- package/dist/src/ai/researcher/deep-analysis.js +1 -1
- package/dist/src/ai/researcher/fingerprint-worker.js +1 -1
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/sections.js +8 -1
- package/dist/src/ai/researcher.js +43 -41
- package/dist/src/ai/rules.js +26 -14
- package/dist/src/ai/tester.js +90 -26
- package/dist/src/ai/tools.js +18 -10
- package/dist/src/api/request-store.js +20 -0
- package/dist/src/api/xhr-capture.js +19 -3
- package/dist/src/browser-server.js +16 -3
- package/dist/src/command-handler.js +1 -1
- package/dist/src/commands/add-rule-command.js +12 -9
- package/dist/src/commands/base-command.js +20 -0
- package/dist/src/commands/clean-command.js +3 -2
- package/dist/src/commands/compact-command.js +138 -0
- package/dist/src/commands/context-command.js +7 -1
- package/dist/src/commands/drill-command.js +4 -1
- package/dist/src/commands/experience-command.js +104 -0
- package/dist/src/commands/explore-command.js +54 -19
- package/dist/src/commands/freesail-command.js +2 -0
- package/dist/src/commands/index.js +7 -3
- package/dist/src/commands/init-command.js +11 -10
- package/dist/src/commands/learn-command.js +1 -1
- package/dist/src/commands/navigate-command.js +4 -1
- package/dist/src/commands/plan-clear-command.js +4 -1
- package/dist/src/commands/plan-command.js +43 -4
- package/dist/src/commands/plan-edit-command.js +1 -1
- package/dist/src/commands/plan-load-command.js +4 -1
- package/dist/src/commands/plan-reload-command.js +4 -1
- package/dist/src/commands/plan-save-command.js +20 -8
- package/dist/src/commands/rerun-command.js +4 -0
- package/dist/src/commands/research-command.js +5 -2
- package/dist/src/commands/start-command.js +5 -1
- package/dist/src/commands/test-command.js +7 -1
- package/dist/src/components/App.js +15 -5
- package/dist/src/execution-controller.js +13 -2
- package/dist/src/experience-tracker.js +174 -83
- package/dist/src/explorbot.js +31 -22
- package/dist/src/explorer.js +12 -5
- package/dist/src/observability.js +50 -99
- package/dist/src/playwright-recorder.js +309 -0
- package/dist/src/reporter.js +17 -2
- package/dist/src/stats.js +2 -0
- package/dist/src/suite.js +1 -1
- package/dist/src/test-plan.js +12 -0
- package/dist/src/utils/aria.js +37 -1
- package/dist/src/utils/error-page.js +30 -7
- package/dist/src/utils/logger.js +1 -1
- package/dist/src/utils/next-steps.js +37 -0
- package/dist/src/utils/rules-loader.js +1 -1
- package/dist/src/utils/test-files.js +1 -1
- package/dist/src/utils/url-matcher.js +50 -0
- package/package.json +2 -2
- package/rules/navigator/output.md +9 -0
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action-result.ts +26 -1
- package/src/action.ts +44 -37
- package/src/ai/bosun.ts +16 -2
- package/src/ai/conversation.ts +37 -0
- package/src/ai/experience-compactor.ts +270 -63
- package/src/ai/historian/codeceptjs.ts +130 -0
- package/src/ai/historian/experience.ts +383 -0
- package/src/ai/historian/mixin.ts +4 -0
- package/src/ai/historian/playwright.ts +169 -0
- package/src/ai/historian/utils.ts +23 -0
- package/src/ai/historian.ts +35 -468
- package/src/ai/navigator.ts +140 -85
- package/src/ai/pilot.ts +259 -14
- package/src/ai/planner/subpages.ts +1 -24
- package/src/ai/planner.ts +34 -14
- package/src/ai/provider.ts +52 -18
- package/src/ai/rerunner.ts +3 -3
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +1 -1
- package/src/ai/researcher/locators.ts +2 -2
- package/src/ai/researcher/sections.ts +7 -1
- package/src/ai/researcher.ts +47 -42
- package/src/ai/rules.ts +27 -14
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +94 -26
- package/src/ai/tools.ts +53 -29
- package/src/api/request-store.ts +22 -0
- package/src/api/xhr-capture.ts +21 -3
- package/src/browser-server.ts +17 -3
- package/src/command-handler.ts +1 -1
- package/src/commands/add-rule-command.ts +13 -9
- package/src/commands/base-command.ts +26 -1
- package/src/commands/clean-command.ts +4 -3
- package/src/commands/compact-command.ts +156 -0
- package/src/commands/context-command.ts +8 -2
- package/src/commands/drill-command.ts +5 -2
- package/src/commands/experience-command.ts +125 -0
- package/src/commands/explore-command.ts +58 -21
- package/src/commands/freesail-command.ts +2 -0
- package/src/commands/index.ts +7 -3
- package/src/commands/init-command.ts +11 -10
- package/src/commands/learn-command.ts +2 -2
- package/src/commands/navigate-command.ts +5 -2
- package/src/commands/plan-clear-command.ts +5 -2
- package/src/commands/plan-command.ts +47 -5
- package/src/commands/plan-edit-command.ts +2 -2
- package/src/commands/plan-load-command.ts +5 -2
- package/src/commands/plan-reload-command.ts +5 -2
- package/src/commands/plan-save-command.ts +20 -9
- package/src/commands/rerun-command.ts +5 -0
- package/src/commands/research-command.ts +6 -3
- package/src/commands/start-command.ts +6 -2
- package/src/commands/test-command.ts +8 -2
- package/src/components/App.tsx +16 -5
- package/src/config.ts +6 -1
- package/src/execution-controller.ts +14 -3
- package/src/experience-tracker.ts +198 -100
- package/src/explorbot.ts +33 -23
- package/src/explorer.ts +14 -5
- package/src/observability.ts +50 -109
- package/src/playwright-recorder.ts +305 -0
- package/src/reporter.ts +17 -3
- package/src/stats.ts +4 -0
- package/src/suite.ts +1 -1
- package/src/test-plan.ts +12 -0
- package/src/utils/aria.ts +38 -1
- package/src/utils/error-page.ts +32 -7
- package/src/utils/logger.ts +1 -1
- package/src/utils/next-steps.ts +51 -0
- package/src/utils/rules-loader.ts +1 -1
- package/src/utils/test-files.ts +1 -1
- package/src/utils/url-matcher.ts +43 -0
package/src/observability.ts
CHANGED
|
@@ -1,121 +1,46 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { type Span, context, trace } from '@opentelemetry/api';
|
|
1
|
+
import { type Span, trace } from '@opentelemetry/api';
|
|
3
2
|
|
|
4
3
|
type TelemetryMetadata = Record<string, unknown>;
|
|
5
4
|
|
|
6
5
|
type TelemetryState = {
|
|
7
6
|
metadata: TelemetryMetadata;
|
|
8
|
-
traceId: string;
|
|
9
|
-
updateParent: boolean;
|
|
10
7
|
name: string;
|
|
11
8
|
span?: Span;
|
|
12
9
|
};
|
|
13
10
|
|
|
14
11
|
let current: TelemetryState | null = null;
|
|
15
|
-
let depth = 0;
|
|
16
12
|
|
|
17
13
|
export const Observability = {
|
|
18
14
|
async run<T>(name: string, metadata: TelemetryMetadata, fn: () => Promise<T>): Promise<T> {
|
|
19
|
-
const
|
|
15
|
+
const tracer = trace.getTracer('ai');
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const savedName = current.name;
|
|
30
|
-
current.span = childSpan;
|
|
31
|
-
current.name = name;
|
|
32
|
-
return await context.with(trace.setSpan(context.active(), childSpan), async () => {
|
|
33
|
-
try {
|
|
34
|
-
return await fn();
|
|
35
|
-
} finally {
|
|
36
|
-
childSpan.end();
|
|
37
|
-
current!.span = savedSpan;
|
|
38
|
-
current!.name = savedName;
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const tracer = trace.getTracer('ai');
|
|
44
|
-
const spanContext = {
|
|
45
|
-
traceId: current?.traceId || randomBytes(16).toString('hex'),
|
|
46
|
-
spanId: randomBytes(8).toString('hex'),
|
|
47
|
-
traceFlags: 1,
|
|
48
|
-
};
|
|
49
|
-
const rootContext = trace.setSpanContext(context.active(), spanContext);
|
|
50
|
-
|
|
51
|
-
const initSpan = tracer.startSpan(name, undefined, rootContext);
|
|
52
|
-
initSpan.setAttribute('langfuse.trace.name', name);
|
|
53
|
-
initSpan.setAttribute('langfuse.trace.id', current?.traceId || '');
|
|
54
|
-
if (current?.metadata?.sessionId) {
|
|
55
|
-
initSpan.setAttribute('langfuse.trace.session_id', String(current.metadata.sessionId));
|
|
56
|
-
}
|
|
57
|
-
if (current?.metadata?.userId) {
|
|
58
|
-
initSpan.setAttribute('langfuse.trace.user_id', String(current.metadata.userId));
|
|
59
|
-
}
|
|
60
|
-
if (current?.metadata?.tags && Array.isArray(current.metadata.tags)) {
|
|
61
|
-
initSpan.setAttribute('langfuse.trace.tags', current.metadata.tags);
|
|
62
|
-
}
|
|
63
|
-
if (current?.metadata?.input) {
|
|
64
|
-
initSpan.setAttribute('langfuse.trace.input', JSON.stringify(current.metadata.input));
|
|
65
|
-
}
|
|
66
|
-
initSpan.end();
|
|
67
|
-
|
|
68
|
-
const span = tracer.startSpan(name, undefined, rootContext);
|
|
69
|
-
current.span = span;
|
|
70
|
-
|
|
71
|
-
return await context.with(trace.setSpan(rootContext, span), async () => {
|
|
17
|
+
if (current) {
|
|
18
|
+
return await tracer.startActiveSpan(name, {}, async (span) => {
|
|
19
|
+
const saved = current!;
|
|
20
|
+
current = {
|
|
21
|
+
metadata: { ...saved.metadata, ...metadata },
|
|
22
|
+
name,
|
|
23
|
+
span,
|
|
24
|
+
};
|
|
72
25
|
try {
|
|
73
26
|
return await fn();
|
|
74
27
|
} finally {
|
|
75
28
|
span.end();
|
|
76
|
-
current
|
|
29
|
+
current = saved;
|
|
77
30
|
}
|
|
78
31
|
});
|
|
79
|
-
} finally {
|
|
80
|
-
Observability.endTrace(started);
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
startTrace(name: string, metadata: TelemetryMetadata) {
|
|
85
|
-
if (current) {
|
|
86
|
-
depth += 1;
|
|
87
|
-
return false;
|
|
88
32
|
}
|
|
89
33
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
metadata
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
};
|
|
100
|
-
depth = 1;
|
|
101
|
-
return true;
|
|
102
|
-
},
|
|
103
|
-
|
|
104
|
-
endTrace(started: boolean) {
|
|
105
|
-
if (!current) {
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (!started) {
|
|
110
|
-
depth -= 1;
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
depth -= 1;
|
|
115
|
-
if (depth <= 0) {
|
|
116
|
-
current = null;
|
|
117
|
-
depth = 0;
|
|
118
|
-
}
|
|
34
|
+
const attributes = buildRootSpanAttributes(name, metadata);
|
|
35
|
+
return await tracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
36
|
+
current = { metadata, name, span };
|
|
37
|
+
try {
|
|
38
|
+
return await fn();
|
|
39
|
+
} finally {
|
|
40
|
+
span.end();
|
|
41
|
+
current = null;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
119
44
|
},
|
|
120
45
|
|
|
121
46
|
getTelemetry() {
|
|
@@ -123,21 +48,16 @@ export const Observability = {
|
|
|
123
48
|
return undefined;
|
|
124
49
|
}
|
|
125
50
|
|
|
126
|
-
const
|
|
51
|
+
const metadata: Record<string, unknown> = {};
|
|
52
|
+
if (current.metadata.sessionId) metadata.sessionId = current.metadata.sessionId;
|
|
53
|
+
if (current.metadata.userId) metadata.userId = current.metadata.userId;
|
|
54
|
+
if (Array.isArray(current.metadata.tags)) metadata.tags = current.metadata.tags;
|
|
55
|
+
|
|
56
|
+
return {
|
|
127
57
|
isEnabled: true,
|
|
128
58
|
functionId: current.name,
|
|
129
|
-
metadata
|
|
130
|
-
...current.metadata,
|
|
131
|
-
langfuseTraceId: current.traceId,
|
|
132
|
-
langfuseUpdateParent: current.updateParent,
|
|
133
|
-
},
|
|
59
|
+
metadata,
|
|
134
60
|
};
|
|
135
|
-
|
|
136
|
-
if (current.updateParent) {
|
|
137
|
-
current.updateParent = false;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return telemetry;
|
|
141
61
|
},
|
|
142
62
|
|
|
143
63
|
isTracing() {
|
|
@@ -145,6 +65,27 @@ export const Observability = {
|
|
|
145
65
|
},
|
|
146
66
|
|
|
147
67
|
getSpan() {
|
|
148
|
-
return current?.span;
|
|
68
|
+
return current?.span ?? trace.getActiveSpan();
|
|
149
69
|
},
|
|
150
70
|
};
|
|
71
|
+
|
|
72
|
+
function buildRootSpanAttributes(name: string, metadata: TelemetryMetadata): Record<string, any> {
|
|
73
|
+
const attributes: Record<string, any> = {
|
|
74
|
+
'langfuse.trace.name': name,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (metadata.sessionId) {
|
|
78
|
+
attributes['session.id'] = String(metadata.sessionId);
|
|
79
|
+
}
|
|
80
|
+
if (metadata.userId) {
|
|
81
|
+
attributes['user.id'] = String(metadata.userId);
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(metadata.tags)) {
|
|
84
|
+
attributes['langfuse.trace.tags'] = metadata.tags as string[];
|
|
85
|
+
}
|
|
86
|
+
if (metadata.input !== undefined) {
|
|
87
|
+
attributes['langfuse.trace.input'] = JSON.stringify(metadata.input);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return attributes;
|
|
91
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
// @ts-ignore — package ships a .js re-export without typings for this sub-path
|
|
3
|
+
import * as playwrightUtils from 'playwright-core/lib/utils';
|
|
4
|
+
import { createDebug } from './utils/logger.ts';
|
|
5
|
+
|
|
6
|
+
const debugLog = createDebug('explorbot:playwright-recorder');
|
|
7
|
+
|
|
8
|
+
const RECORDABLE: Record<string, Set<string>> = {
|
|
9
|
+
Frame: new Set(['click', 'dblclick', 'fill', 'selectOption', 'press', 'type', 'check', 'uncheck', 'hover', 'tap', 'focus', 'setInputFiles', 'scrollIntoViewIfNeeded', 'dragTo', 'goto', 'setContent']),
|
|
10
|
+
Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const PLAYWRIGHT_INCOMPATIBLE = "Playwright output is not compatible with this Playwright version (playwright-core/lib/utils does not expose asLocator). Use output.framework: 'codeceptjs' instead, or pin Playwright to a version shipping lib/utils/isomorphic/locatorGenerators.js.";
|
|
14
|
+
|
|
15
|
+
function getAsLocator(): (lang: string, selector: string) => string {
|
|
16
|
+
const fn = (playwrightUtils as any)?.asLocator;
|
|
17
|
+
if (typeof fn !== 'function') throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
18
|
+
return fn;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TraceCall {
|
|
22
|
+
class: string;
|
|
23
|
+
method: string;
|
|
24
|
+
params: Record<string, any>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface VerificationStep {
|
|
28
|
+
name: string;
|
|
29
|
+
args: any[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class PlaywrightRecorder {
|
|
33
|
+
private context: any = null;
|
|
34
|
+
private tracing: any = null;
|
|
35
|
+
private active = false;
|
|
36
|
+
private nextGroupId = 0;
|
|
37
|
+
private verifications: VerificationStep[] = [];
|
|
38
|
+
|
|
39
|
+
recordVerification(steps: VerificationStep[]): void {
|
|
40
|
+
if (!steps?.length) return;
|
|
41
|
+
const seen = new Set(this.verifications.map((s) => `${s.name}:${JSON.stringify(s.args)}`));
|
|
42
|
+
for (const step of steps) {
|
|
43
|
+
const key = `${step.name}:${JSON.stringify(step.args)}`;
|
|
44
|
+
if (seen.has(key)) continue;
|
|
45
|
+
seen.add(key);
|
|
46
|
+
this.verifications.push(step);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
drainVerifications(): VerificationStep[] {
|
|
51
|
+
const drained = this.verifications;
|
|
52
|
+
this.verifications = [];
|
|
53
|
+
return drained;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async start(browserContext: any): Promise<void> {
|
|
57
|
+
if (this.active) return;
|
|
58
|
+
if (!browserContext?.tracing) {
|
|
59
|
+
debugLog('start: no tracing on browserContext, recorder inactive');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.context = browserContext;
|
|
63
|
+
this.tracing = browserContext.tracing;
|
|
64
|
+
try {
|
|
65
|
+
await this.tracing.start({});
|
|
66
|
+
this.active = true;
|
|
67
|
+
debugLog('tracing started');
|
|
68
|
+
} catch (err) {
|
|
69
|
+
debugLog('tracing.start failed:', err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async beginAction(title: string): Promise<string | null> {
|
|
74
|
+
if (!this.active) return null;
|
|
75
|
+
const safe = title.replace(/\s+/g, ' ').slice(0, 80);
|
|
76
|
+
const groupId = `explorbot#${++this.nextGroupId}:${safe}`;
|
|
77
|
+
try {
|
|
78
|
+
await this.tracing.group(groupId);
|
|
79
|
+
return groupId;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
debugLog('tracing.group failed:', err);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async endAction(): Promise<void> {
|
|
87
|
+
if (!this.active) return;
|
|
88
|
+
try {
|
|
89
|
+
await this.tracing.groupEnd();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
debugLog('tracing.groupEnd failed:', err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async exportChunk(): Promise<Map<string, TraceCall[]>> {
|
|
96
|
+
if (!this.active) return new Map();
|
|
97
|
+
const channel = this.tracing._channel;
|
|
98
|
+
if (!channel?.tracingStopChunk || !channel.tracingStartChunk) {
|
|
99
|
+
debugLog('exportChunk: no _channel access, returning empty');
|
|
100
|
+
return new Map();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let entries: Array<{ name: string; value: string }> = [];
|
|
104
|
+
try {
|
|
105
|
+
const result = await channel.tracingStopChunk({ mode: 'entries' });
|
|
106
|
+
entries = result?.entries || [];
|
|
107
|
+
} catch (err) {
|
|
108
|
+
debugLog('tracingStopChunk failed:', err);
|
|
109
|
+
return new Map();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const traceEntry = entries.find((e) => e.name === 'trace.trace');
|
|
113
|
+
|
|
114
|
+
let groups = new Map<string, TraceCall[]>();
|
|
115
|
+
if (traceEntry) {
|
|
116
|
+
try {
|
|
117
|
+
const ndjson = await readFile(traceEntry.value, 'utf8');
|
|
118
|
+
groups = parseTrace(ndjson);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
debugLog('reading trace.trace failed:', err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await channel.tracingStartChunk({});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
debugLog('tracingStartChunk failed after export:', err);
|
|
128
|
+
this.active = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return groups;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async stop(): Promise<void> {
|
|
135
|
+
if (!this.active) return;
|
|
136
|
+
try {
|
|
137
|
+
await this.tracing.stop({});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
debugLog('tracing.stop failed:', err);
|
|
140
|
+
}
|
|
141
|
+
this.active = false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
isActive(): boolean {
|
|
145
|
+
return this.active;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface ParsedBefore {
|
|
150
|
+
callId: string;
|
|
151
|
+
class: string;
|
|
152
|
+
method: string;
|
|
153
|
+
params: Record<string, any>;
|
|
154
|
+
parentId?: string;
|
|
155
|
+
title?: string;
|
|
156
|
+
failed: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseTrace(ndjson: string): Map<string, TraceCall[]> {
|
|
160
|
+
const befores = new Map<string, ParsedBefore>();
|
|
161
|
+
const groupTitleByCallId = new Map<string, string>();
|
|
162
|
+
|
|
163
|
+
for (const line of ndjson.split('\n')) {
|
|
164
|
+
if (!line) continue;
|
|
165
|
+
let evt: any;
|
|
166
|
+
try {
|
|
167
|
+
evt = JSON.parse(line);
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (evt.type === 'before') {
|
|
172
|
+
befores.set(evt.callId, {
|
|
173
|
+
callId: evt.callId,
|
|
174
|
+
class: evt.class,
|
|
175
|
+
method: evt.method,
|
|
176
|
+
params: evt.params || {},
|
|
177
|
+
parentId: evt.parentId,
|
|
178
|
+
title: evt.title,
|
|
179
|
+
failed: false,
|
|
180
|
+
});
|
|
181
|
+
if (evt.class === 'Tracing' && evt.method === 'tracingGroup' && typeof evt.title === 'string') {
|
|
182
|
+
groupTitleByCallId.set(evt.callId, evt.title);
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (evt.type === 'after') {
|
|
187
|
+
const rec = befores.get(evt.callId);
|
|
188
|
+
if (rec && evt.error) rec.failed = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const groups = new Map<string, TraceCall[]>();
|
|
193
|
+
for (const title of groupTitleByCallId.values()) {
|
|
194
|
+
groups.set(title, []);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const rec of befores.values()) {
|
|
198
|
+
if (rec.failed) continue;
|
|
199
|
+
if (rec.class === 'Tracing') continue;
|
|
200
|
+
if (!rec.parentId) continue;
|
|
201
|
+
const groupTitle = groupTitleByCallId.get(rec.parentId);
|
|
202
|
+
if (!groupTitle) continue;
|
|
203
|
+
const allowed = RECORDABLE[rec.class];
|
|
204
|
+
if (!allowed?.has(rec.method)) continue;
|
|
205
|
+
groups.get(groupTitle)!.push({ class: rec.class, method: rec.method, params: rec.params });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return groups;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function renderCall(call: TraceCall): string {
|
|
212
|
+
const asLocator = getAsLocator();
|
|
213
|
+
const { class: cls, method, params } = call;
|
|
214
|
+
|
|
215
|
+
if (cls === 'Frame') {
|
|
216
|
+
if (method === 'goto') return `await page.goto(${quote(params.url)});`;
|
|
217
|
+
if (method === 'setContent') return `await page.setContent(${quote(params.html)});`;
|
|
218
|
+
const locator = `page.${asLocator('javascript', params.selector || '')}`;
|
|
219
|
+
if (method === 'click') return `await ${locator}.click();`;
|
|
220
|
+
if (method === 'dblclick') return `await ${locator}.dblclick();`;
|
|
221
|
+
if (method === 'fill') return `await ${locator}.fill(${quote(params.value ?? '')});`;
|
|
222
|
+
if (method === 'press') return `await ${locator}.press(${quote(params.key ?? '')});`;
|
|
223
|
+
if (method === 'type') return `await ${locator}.type(${quote(params.text ?? '')});`;
|
|
224
|
+
if (method === 'check') return `await ${locator}.check();`;
|
|
225
|
+
if (method === 'uncheck') return `await ${locator}.uncheck();`;
|
|
226
|
+
if (method === 'hover') return `await ${locator}.hover();`;
|
|
227
|
+
if (method === 'tap') return `await ${locator}.tap();`;
|
|
228
|
+
if (method === 'focus') return `await ${locator}.focus();`;
|
|
229
|
+
if (method === 'scrollIntoViewIfNeeded') return `await ${locator}.scrollIntoViewIfNeeded();`;
|
|
230
|
+
if (method === 'setInputFiles') return `await ${locator}.setInputFiles(${formatFiles(params.localPaths ?? params.files)});`;
|
|
231
|
+
if (method === 'selectOption') return `await ${locator}.selectOption(${formatSelectOption(params.options)});`;
|
|
232
|
+
if (method === 'dragTo') return `await ${locator}.dragTo(page.locator(${quote(params.targetSelector ?? '')}));`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (cls === 'Page') {
|
|
236
|
+
if (method === 'goBack') return 'await page.goBack();';
|
|
237
|
+
if (method === 'goForward') return 'await page.goForward();';
|
|
238
|
+
if (method === 'reload') return 'await page.reload();';
|
|
239
|
+
if (method === 'keyboardPress') return `await page.keyboard.press(${quote(params.key ?? '')});`;
|
|
240
|
+
if (method === 'keyboardType') return `await page.keyboard.type(${quote(params.text ?? '')});`;
|
|
241
|
+
if (method === 'keyboardDown') return `await page.keyboard.down(${quote(params.key ?? '')});`;
|
|
242
|
+
if (method === 'keyboardUp') return `await page.keyboard.up(${quote(params.key ?? '')});`;
|
|
243
|
+
if (method === 'keyboardInsertText') return `await page.keyboard.insertText(${quote(params.text ?? '')});`;
|
|
244
|
+
if (method === 'mouseClick') return `await page.mouse.click(${params.x ?? 0}, ${params.y ?? 0});`;
|
|
245
|
+
if (method === 'mouseDblclick') return `await page.mouse.dblclick(${params.x ?? 0}, ${params.y ?? 0});`;
|
|
246
|
+
if (method === 'mouseMove') return `await page.mouse.move(${params.x ?? 0}, ${params.y ?? 0});`;
|
|
247
|
+
if (method === 'mouseDown') return 'await page.mouse.down();';
|
|
248
|
+
if (method === 'mouseUp') return 'await page.mouse.up();';
|
|
249
|
+
if (method === 'mouseWheel') return `await page.mouse.wheel(${params.deltaX ?? 0}, ${params.deltaY ?? 0});`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return `// TODO(playwright): ${cls}.${method}(${JSON.stringify(params)})`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function quote(value: any): string {
|
|
256
|
+
return JSON.stringify(String(value ?? ''));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function formatFiles(files: any): string {
|
|
260
|
+
if (!files) return '[]';
|
|
261
|
+
if (Array.isArray(files)) {
|
|
262
|
+
if (files.length === 1) return quote(files[0]);
|
|
263
|
+
return `[${files.map((f) => quote(f)).join(', ')}]`;
|
|
264
|
+
}
|
|
265
|
+
return quote(files);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function formatSelectOption(options: any): string {
|
|
269
|
+
if (!options) return `''`;
|
|
270
|
+
const list = Array.isArray(options) ? options : [options];
|
|
271
|
+
const values = list.map((o) => o?.valueOrLabel ?? o?.value ?? o?.label ?? '');
|
|
272
|
+
if (values.length === 1) return quote(values[0]);
|
|
273
|
+
return `[${values.map((v) => quote(v)).join(', ')}]`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function renderAssertion(assertion: { name: string; args: any[] }): string {
|
|
277
|
+
const args = assertion.args;
|
|
278
|
+
if (assertion.name === 'see' && typeof args[0] === 'string') {
|
|
279
|
+
return `await expect(page).toContainText(${JSON.stringify(args[0])});`;
|
|
280
|
+
}
|
|
281
|
+
if (assertion.name === 'dontSee' && typeof args[0] === 'string') {
|
|
282
|
+
return `await expect(page).not.toContainText(${JSON.stringify(args[0])});`;
|
|
283
|
+
}
|
|
284
|
+
if (assertion.name === 'seeElement' && typeof args[0] === 'string') {
|
|
285
|
+
return `await expect(page.locator(${JSON.stringify(args[0])})).toBeVisible();`;
|
|
286
|
+
}
|
|
287
|
+
if (assertion.name === 'dontSeeElement' && typeof args[0] === 'string') {
|
|
288
|
+
return `await expect(page.locator(${JSON.stringify(args[0])})).toBeHidden();`;
|
|
289
|
+
}
|
|
290
|
+
if (assertion.name === 'seeInField' && typeof args[0] === 'string' && args[1] !== undefined) {
|
|
291
|
+
return `await expect(page.locator(${JSON.stringify(args[0])})).toHaveValue(${JSON.stringify(String(args[1]))});`;
|
|
292
|
+
}
|
|
293
|
+
if (assertion.name === 'dontSeeInField' && typeof args[0] === 'string' && args[1] !== undefined) {
|
|
294
|
+
return `await expect(page.locator(${JSON.stringify(args[0])})).not.toHaveValue(${JSON.stringify(String(args[1]))});`;
|
|
295
|
+
}
|
|
296
|
+
if (assertion.name === 'seeInCurrentUrl' && typeof args[0] === 'string') {
|
|
297
|
+
return `await expect(page).toHaveURL(new RegExp(${JSON.stringify(args[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))}));`;
|
|
298
|
+
}
|
|
299
|
+
if (assertion.name === 'dontSeeInCurrentUrl' && typeof args[0] === 'string') {
|
|
300
|
+
return `await expect(page).not.toHaveURL(new RegExp(${JSON.stringify(args[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))}));`;
|
|
301
|
+
}
|
|
302
|
+
return `// TODO(playwright): ${assertion.name}(${assertion.args.map((a) => JSON.stringify(a)).join(', ')})`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export { parseTrace };
|
package/src/reporter.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Client } from '@testomatio/reporter';
|
|
|
3
3
|
import type { Step } from '@testomatio/reporter/types/types.js';
|
|
4
4
|
import { ConfigParser, outputPath } from './config.js';
|
|
5
5
|
import type { ReporterConfig } from './config.js';
|
|
6
|
+
import type { StateManager } from './state-manager.js';
|
|
7
|
+
import { Stats } from './stats.js';
|
|
6
8
|
import { Test } from './test-plan.js';
|
|
7
9
|
import { createDebug } from './utils/logger.js';
|
|
8
10
|
|
|
@@ -18,22 +20,33 @@ export interface ReporterStep {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export class Reporter {
|
|
21
|
-
private client
|
|
23
|
+
private client!: Client;
|
|
22
24
|
private isRunStarted = false;
|
|
23
25
|
private reporterEnabled: boolean;
|
|
26
|
+
private stateManager?: StateManager;
|
|
24
27
|
|
|
25
|
-
constructor(config?: ReporterConfig) {
|
|
28
|
+
constructor(config?: ReporterConfig, stateManager?: StateManager) {
|
|
26
29
|
this.reporterEnabled = Reporter.resolveEnabled(config);
|
|
30
|
+
this.stateManager = stateManager;
|
|
27
31
|
|
|
28
32
|
if (this.reporterEnabled && (!process.env.TESTOMATIO || config?.html)) {
|
|
29
33
|
this.configureHtmlPipe();
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
this.client = new Client({ apiKey: process.env.TESTOMATIO || '' });
|
|
33
36
|
const pipe = process.env.TESTOMATIO && config?.html ? 'both' : process.env.TESTOMATIO ? 'testomatio' : 'html';
|
|
34
37
|
debugLog('Reporter initialized', { enabled: this.reporterEnabled, pipe });
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
private buildTitle(): string {
|
|
41
|
+
if (process.env.TESTOMATIO_TITLE) return process.env.TESTOMATIO_TITLE;
|
|
42
|
+
const url = this.stateManager?.getCurrentState()?.url;
|
|
43
|
+
const parts = ['Explorbot session'];
|
|
44
|
+
if (url) parts.push(url);
|
|
45
|
+
if (Stats.focus) parts.push(`focus: "${Stats.focus}"`);
|
|
46
|
+
parts.push(`at ${new Date().toISOString().slice(0, 16)}`);
|
|
47
|
+
return parts.join(' ');
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
static resolveEnabled(config?: ReporterConfig): boolean {
|
|
38
51
|
if (config?.enabled === true) return true;
|
|
39
52
|
if (config?.enabled === false) return false;
|
|
@@ -56,6 +69,7 @@ export class Reporter {
|
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
try {
|
|
72
|
+
this.client = new Client({ apiKey: process.env.TESTOMATIO || '', title: this.buildTitle() });
|
|
59
73
|
const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
|
|
60
74
|
const timeoutPromise = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
|
|
61
75
|
|
package/src/stats.ts
CHANGED
|
@@ -4,11 +4,15 @@ interface TokenUsage {
|
|
|
4
4
|
total: number;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
export type ExplorbotMode = 'explore' | 'test' | 'freesail' | 'tui';
|
|
8
|
+
|
|
7
9
|
export class Stats {
|
|
8
10
|
static startTime = Date.now();
|
|
9
11
|
static researches = 0;
|
|
10
12
|
static tests = 0;
|
|
11
13
|
static plans = 0;
|
|
14
|
+
static mode?: ExplorbotMode;
|
|
15
|
+
static focus?: string;
|
|
12
16
|
static models: Record<string, TokenUsage> = {};
|
|
13
17
|
|
|
14
18
|
static recordTokens(_agent: string, model: string, usage: TokenUsage): void {
|
package/src/suite.ts
CHANGED
|
@@ -3,8 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { Reflection } from '@codeceptjs/reflection';
|
|
4
4
|
import { ConfigParser } from './config.ts';
|
|
5
5
|
import { normalizeUrl } from './state-manager.ts';
|
|
6
|
-
import { parsePlanFromMarkdown } from './utils/test-plan-markdown.ts';
|
|
7
6
|
import { createDebug } from './utils/logger.ts';
|
|
7
|
+
import { parsePlanFromMarkdown } from './utils/test-plan-markdown.ts';
|
|
8
8
|
|
|
9
9
|
const debugLog = createDebug('explorbot:suite');
|
|
10
10
|
|
package/src/test-plan.ts
CHANGED
|
@@ -160,6 +160,11 @@ export class Task {
|
|
|
160
160
|
.map((item) => `${item.type === 'step' ? ' ' : ''}${item.content}`)
|
|
161
161
|
.join('\n');
|
|
162
162
|
}
|
|
163
|
+
|
|
164
|
+
getRunResult(): 'success' | 'partial' | 'failed' {
|
|
165
|
+
const hasPassedNotes = Object.values(this.notes).some((n) => n.status === TestResult.PASSED);
|
|
166
|
+
return hasPassedNotes ? 'partial' : 'failed';
|
|
167
|
+
}
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
export class Test extends Task {
|
|
@@ -179,6 +184,7 @@ export class Test extends Task {
|
|
|
179
184
|
enabled = true;
|
|
180
185
|
startTime?: number;
|
|
181
186
|
endTime?: number;
|
|
187
|
+
resetCount = 0;
|
|
182
188
|
|
|
183
189
|
constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps: string[] = []) {
|
|
184
190
|
super(scenario, startUrl);
|
|
@@ -238,6 +244,12 @@ export class Test extends Task {
|
|
|
238
244
|
});
|
|
239
245
|
}
|
|
240
246
|
|
|
247
|
+
getRunResult(): 'success' | 'partial' | 'failed' {
|
|
248
|
+
if (this.isSuccessful) return 'success';
|
|
249
|
+
if (this.hasAchievedAny()) return 'partial';
|
|
250
|
+
return super.getRunResult();
|
|
251
|
+
}
|
|
252
|
+
|
|
241
253
|
hasAchievedAll(): boolean {
|
|
242
254
|
return this.expected.every((expectation) => {
|
|
243
255
|
return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
|
package/src/utils/aria.ts
CHANGED
|
@@ -644,9 +644,18 @@ const resolveDisplayName = (node: AriaNode): string | undefined => {
|
|
|
644
644
|
return undefined;
|
|
645
645
|
};
|
|
646
646
|
|
|
647
|
+
const SIBLING_COLLAPSE_THRESHOLD = 50;
|
|
648
|
+
const SIBLING_COLLAPSE_KEEP_EACH_SIDE = 5;
|
|
649
|
+
|
|
647
650
|
const serializeAriaNodes = (nodes: AriaNode[], depth = 0): string => {
|
|
648
651
|
const lines: string[] = [];
|
|
649
|
-
|
|
652
|
+
const collapsed = collapseSimilarSiblingRuns(nodes, depth);
|
|
653
|
+
for (const entry of collapsed) {
|
|
654
|
+
if (entry.placeholder) {
|
|
655
|
+
lines.push(entry.placeholder);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const node = entry.node!;
|
|
650
659
|
const indent = ' '.repeat(depth);
|
|
651
660
|
let line = `${indent}- ${renderNodeLine(node.role, resolveDisplayName(node), node.attributes, node.value)}`;
|
|
652
661
|
if (node.children.length > 0) {
|
|
@@ -660,6 +669,34 @@ const serializeAriaNodes = (nodes: AriaNode[], depth = 0): string => {
|
|
|
660
669
|
return lines.join('\n');
|
|
661
670
|
};
|
|
662
671
|
|
|
672
|
+
type SerializeEntry = { node?: AriaNode; placeholder?: string };
|
|
673
|
+
|
|
674
|
+
const collapseSimilarSiblingRuns = (nodes: AriaNode[], depth: number): SerializeEntry[] => {
|
|
675
|
+
const result: SerializeEntry[] = [];
|
|
676
|
+
let i = 0;
|
|
677
|
+
while (i < nodes.length) {
|
|
678
|
+
const role = nodes[i].role;
|
|
679
|
+
let j = i;
|
|
680
|
+
while (j < nodes.length && nodes[j].role === role) j++;
|
|
681
|
+
const runLength = j - i;
|
|
682
|
+
if (runLength > SIBLING_COLLAPSE_THRESHOLD) {
|
|
683
|
+
for (let k = i; k < i + SIBLING_COLLAPSE_KEEP_EACH_SIDE; k++) {
|
|
684
|
+
result.push({ node: nodes[k] });
|
|
685
|
+
}
|
|
686
|
+
const omitted = runLength - SIBLING_COLLAPSE_KEEP_EACH_SIDE * 2;
|
|
687
|
+
const indent = ' '.repeat(depth);
|
|
688
|
+
result.push({ placeholder: `${indent}- ...${omitted} similar "${role}" items omitted...` });
|
|
689
|
+
for (let k = j - SIBLING_COLLAPSE_KEEP_EACH_SIDE; k < j; k++) {
|
|
690
|
+
result.push({ node: nodes[k] });
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
for (let k = i; k < j; k++) result.push({ node: nodes[k] });
|
|
694
|
+
}
|
|
695
|
+
i = j;
|
|
696
|
+
}
|
|
697
|
+
return result;
|
|
698
|
+
};
|
|
699
|
+
|
|
663
700
|
export const compactAriaSnapshot = (snapshot: string | null, keepNamed = false): string => {
|
|
664
701
|
if (!snapshot) return '';
|
|
665
702
|
const nodes = parseAriaSnapshot(snapshot, keepNamed);
|