explorbot 0.1.10 → 0.1.12
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 +37 -1
- package/bin/explorbot-cli.ts +27 -18
- package/dist/bin/explorbot-cli.js +26 -18
- package/dist/package.json +3 -3
- 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 +51 -42
- package/dist/src/ai/bosun.js +11 -1
- package/dist/src/ai/conversation.js +39 -0
- package/dist/src/ai/historian/codeceptjs.js +109 -0
- package/dist/src/ai/historian/experience.js +321 -0
- package/dist/src/ai/historian/mixin.js +2 -0
- package/dist/src/ai/historian/playwright.js +145 -0
- package/dist/src/ai/historian/screencast.js +121 -0
- package/dist/src/ai/historian/utils.js +18 -0
- package/dist/src/ai/historian.js +21 -405
- package/dist/src/ai/navigator.js +82 -29
- package/dist/src/ai/pilot.js +232 -13
- package/dist/src/ai/planner.js +29 -9
- package/dist/src/ai/provider.js +54 -17
- package/dist/src/ai/researcher.js +41 -32
- package/dist/src/ai/rules.js +26 -14
- package/dist/src/ai/tester.js +90 -26
- package/dist/src/ai/tools.js +13 -7
- package/dist/src/browser-server.js +16 -3
- package/dist/src/commands/add-rule-command.js +11 -8
- package/dist/src/commands/clean-command.js +2 -1
- package/dist/src/commands/explore-command.js +43 -15
- package/dist/src/commands/init-command.js +9 -8
- package/dist/src/commands/plan-command.js +32 -0
- package/dist/src/commands/plan-save-command.js +19 -7
- package/dist/src/commands/rerun-command.js +4 -0
- package/dist/src/components/App.js +15 -5
- package/dist/src/execution-controller.js +13 -2
- package/dist/src/experience-tracker.js +20 -64
- package/dist/src/explorbot.js +8 -8
- package/dist/src/explorer.js +11 -3
- package/dist/src/observability.js +50 -99
- package/dist/src/playwright-recorder.js +309 -0
- package/dist/src/reporter.js +4 -1
- package/dist/src/test-plan.js +12 -0
- package/dist/src/utils/aria.js +37 -1
- package/dist/src/utils/error-page.js +20 -7
- package/dist/src/utils/next-steps.js +37 -0
- package/dist/src/utils/strings.js +15 -0
- package/package.json +3 -3
- 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 +49 -41
- package/src/ai/bosun.ts +11 -1
- package/src/ai/conversation.ts +37 -0
- package/src/ai/historian/codeceptjs.ts +130 -0
- package/src/ai/historian/experience.ts +384 -0
- package/src/ai/historian/mixin.ts +4 -0
- package/src/ai/historian/playwright.ts +169 -0
- package/src/ai/historian/screencast.ts +133 -0
- package/src/ai/historian/utils.ts +23 -0
- package/src/ai/historian.ts +37 -473
- package/src/ai/navigator.ts +82 -29
- package/src/ai/pilot.ts +237 -14
- package/src/ai/planner.ts +29 -9
- package/src/ai/provider.ts +51 -17
- package/src/ai/researcher.ts +45 -33
- package/src/ai/rules.ts +27 -14
- package/src/ai/tester.ts +94 -26
- package/src/ai/tools.ts +47 -25
- package/src/browser-server.ts +17 -3
- package/src/commands/add-rule-command.ts +11 -7
- package/src/commands/clean-command.ts +2 -1
- package/src/commands/explore-command.ts +46 -14
- package/src/commands/init-command.ts +9 -8
- package/src/commands/plan-command.ts +35 -0
- package/src/commands/plan-save-command.ts +18 -7
- package/src/commands/rerun-command.ts +5 -0
- package/src/components/App.tsx +16 -5
- package/src/config.ts +12 -1
- package/src/execution-controller.ts +14 -3
- package/src/experience-tracker.ts +21 -72
- package/src/explorbot.ts +8 -8
- package/src/explorer.ts +13 -3
- package/src/observability.ts +50 -109
- package/src/playwright-recorder.ts +305 -0
- package/src/reporter.ts +4 -1
- package/src/test-plan.ts +12 -0
- package/src/utils/aria.ts +38 -1
- package/src/utils/error-page.ts +22 -7
- package/src/utils/next-steps.ts +51 -0
- package/src/utils/strings.ts +17 -0
|
@@ -1,125 +1,76 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { context, trace } from '@opentelemetry/api';
|
|
1
|
+
import { trace } from '@opentelemetry/api';
|
|
3
2
|
let current = null;
|
|
4
|
-
let depth = 0;
|
|
5
3
|
export const Observability = {
|
|
6
4
|
async run(name, metadata, fn) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const savedName = current.name;
|
|
17
|
-
current.span = childSpan;
|
|
18
|
-
current.name = name;
|
|
19
|
-
return await context.with(trace.setSpan(context.active(), childSpan), async () => {
|
|
20
|
-
try {
|
|
21
|
-
return await fn();
|
|
22
|
-
}
|
|
23
|
-
finally {
|
|
24
|
-
childSpan.end();
|
|
25
|
-
current.span = savedSpan;
|
|
26
|
-
current.name = savedName;
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
const tracer = trace.getTracer('ai');
|
|
31
|
-
const spanContext = {
|
|
32
|
-
traceId: current?.traceId || randomBytes(16).toString('hex'),
|
|
33
|
-
spanId: randomBytes(8).toString('hex'),
|
|
34
|
-
traceFlags: 1,
|
|
35
|
-
};
|
|
36
|
-
const rootContext = trace.setSpanContext(context.active(), spanContext);
|
|
37
|
-
const initSpan = tracer.startSpan(name, undefined, rootContext);
|
|
38
|
-
initSpan.setAttribute('langfuse.trace.name', name);
|
|
39
|
-
initSpan.setAttribute('langfuse.trace.id', current?.traceId || '');
|
|
40
|
-
if (current?.metadata?.sessionId) {
|
|
41
|
-
initSpan.setAttribute('langfuse.trace.session_id', String(current.metadata.sessionId));
|
|
42
|
-
}
|
|
43
|
-
if (current?.metadata?.userId) {
|
|
44
|
-
initSpan.setAttribute('langfuse.trace.user_id', String(current.metadata.userId));
|
|
45
|
-
}
|
|
46
|
-
if (current?.metadata?.tags && Array.isArray(current.metadata.tags)) {
|
|
47
|
-
initSpan.setAttribute('langfuse.trace.tags', current.metadata.tags);
|
|
48
|
-
}
|
|
49
|
-
if (current?.metadata?.input) {
|
|
50
|
-
initSpan.setAttribute('langfuse.trace.input', JSON.stringify(current.metadata.input));
|
|
51
|
-
}
|
|
52
|
-
initSpan.end();
|
|
53
|
-
const span = tracer.startSpan(name, undefined, rootContext);
|
|
54
|
-
current.span = span;
|
|
55
|
-
return await context.with(trace.setSpan(rootContext, span), async () => {
|
|
5
|
+
const tracer = trace.getTracer('ai');
|
|
6
|
+
if (current) {
|
|
7
|
+
return await tracer.startActiveSpan(name, {}, async (span) => {
|
|
8
|
+
const saved = current;
|
|
9
|
+
current = {
|
|
10
|
+
metadata: { ...saved.metadata, ...metadata },
|
|
11
|
+
name,
|
|
12
|
+
span,
|
|
13
|
+
};
|
|
56
14
|
try {
|
|
57
15
|
return await fn();
|
|
58
16
|
}
|
|
59
17
|
finally {
|
|
60
18
|
span.end();
|
|
61
|
-
current
|
|
19
|
+
current = saved;
|
|
62
20
|
}
|
|
63
21
|
});
|
|
64
22
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
metadata: {
|
|
77
|
-
...metadata,
|
|
78
|
-
langfuseTraceId,
|
|
79
|
-
},
|
|
80
|
-
traceId: langfuseTraceId,
|
|
81
|
-
updateParent: true,
|
|
82
|
-
name,
|
|
83
|
-
};
|
|
84
|
-
depth = 1;
|
|
85
|
-
return true;
|
|
86
|
-
},
|
|
87
|
-
endTrace(started) {
|
|
88
|
-
if (!current) {
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (!started) {
|
|
92
|
-
depth -= 1;
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
depth -= 1;
|
|
96
|
-
if (depth <= 0) {
|
|
97
|
-
current = null;
|
|
98
|
-
depth = 0;
|
|
99
|
-
}
|
|
23
|
+
const attributes = buildRootSpanAttributes(name, metadata);
|
|
24
|
+
return await tracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
25
|
+
current = { metadata, name, span };
|
|
26
|
+
try {
|
|
27
|
+
return await fn();
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
span.end();
|
|
31
|
+
current = null;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
100
34
|
},
|
|
101
35
|
getTelemetry() {
|
|
102
36
|
if (!current) {
|
|
103
37
|
return undefined;
|
|
104
38
|
}
|
|
105
|
-
const
|
|
39
|
+
const metadata = {};
|
|
40
|
+
if (current.metadata.sessionId)
|
|
41
|
+
metadata.sessionId = current.metadata.sessionId;
|
|
42
|
+
if (current.metadata.userId)
|
|
43
|
+
metadata.userId = current.metadata.userId;
|
|
44
|
+
if (Array.isArray(current.metadata.tags))
|
|
45
|
+
metadata.tags = current.metadata.tags;
|
|
46
|
+
return {
|
|
106
47
|
isEnabled: true,
|
|
107
48
|
functionId: current.name,
|
|
108
|
-
metadata
|
|
109
|
-
...current.metadata,
|
|
110
|
-
langfuseTraceId: current.traceId,
|
|
111
|
-
langfuseUpdateParent: current.updateParent,
|
|
112
|
-
},
|
|
49
|
+
metadata,
|
|
113
50
|
};
|
|
114
|
-
if (current.updateParent) {
|
|
115
|
-
current.updateParent = false;
|
|
116
|
-
}
|
|
117
|
-
return telemetry;
|
|
118
51
|
},
|
|
119
52
|
isTracing() {
|
|
120
53
|
return Boolean(current);
|
|
121
54
|
},
|
|
122
55
|
getSpan() {
|
|
123
|
-
return current?.span;
|
|
56
|
+
return current?.span ?? trace.getActiveSpan();
|
|
124
57
|
},
|
|
125
58
|
};
|
|
59
|
+
function buildRootSpanAttributes(name, metadata) {
|
|
60
|
+
const attributes = {
|
|
61
|
+
'langfuse.trace.name': name,
|
|
62
|
+
};
|
|
63
|
+
if (metadata.sessionId) {
|
|
64
|
+
attributes['session.id'] = String(metadata.sessionId);
|
|
65
|
+
}
|
|
66
|
+
if (metadata.userId) {
|
|
67
|
+
attributes['user.id'] = String(metadata.userId);
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(metadata.tags)) {
|
|
70
|
+
attributes['langfuse.trace.tags'] = metadata.tags;
|
|
71
|
+
}
|
|
72
|
+
if (metadata.input !== undefined) {
|
|
73
|
+
attributes['langfuse.trace.input'] = JSON.stringify(metadata.input);
|
|
74
|
+
}
|
|
75
|
+
return attributes;
|
|
76
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
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.js";
|
|
5
|
+
const debugLog = createDebug('explorbot:playwright-recorder');
|
|
6
|
+
const RECORDABLE = {
|
|
7
|
+
Frame: new Set(['click', 'dblclick', 'fill', 'selectOption', 'press', 'type', 'check', 'uncheck', 'hover', 'tap', 'focus', 'setInputFiles', 'scrollIntoViewIfNeeded', 'dragTo', 'goto', 'setContent']),
|
|
8
|
+
Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
|
|
9
|
+
};
|
|
10
|
+
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.";
|
|
11
|
+
function getAsLocator() {
|
|
12
|
+
const fn = playwrightUtils?.asLocator;
|
|
13
|
+
if (typeof fn !== 'function')
|
|
14
|
+
throw new Error(PLAYWRIGHT_INCOMPATIBLE);
|
|
15
|
+
return fn;
|
|
16
|
+
}
|
|
17
|
+
export class PlaywrightRecorder {
|
|
18
|
+
context = null;
|
|
19
|
+
tracing = null;
|
|
20
|
+
active = false;
|
|
21
|
+
nextGroupId = 0;
|
|
22
|
+
verifications = [];
|
|
23
|
+
recordVerification(steps) {
|
|
24
|
+
if (!steps?.length)
|
|
25
|
+
return;
|
|
26
|
+
const seen = new Set(this.verifications.map((s) => `${s.name}:${JSON.stringify(s.args)}`));
|
|
27
|
+
for (const step of steps) {
|
|
28
|
+
const key = `${step.name}:${JSON.stringify(step.args)}`;
|
|
29
|
+
if (seen.has(key))
|
|
30
|
+
continue;
|
|
31
|
+
seen.add(key);
|
|
32
|
+
this.verifications.push(step);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
drainVerifications() {
|
|
36
|
+
const drained = this.verifications;
|
|
37
|
+
this.verifications = [];
|
|
38
|
+
return drained;
|
|
39
|
+
}
|
|
40
|
+
async start(browserContext) {
|
|
41
|
+
if (this.active)
|
|
42
|
+
return;
|
|
43
|
+
if (!browserContext?.tracing) {
|
|
44
|
+
debugLog('start: no tracing on browserContext, recorder inactive');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.context = browserContext;
|
|
48
|
+
this.tracing = browserContext.tracing;
|
|
49
|
+
try {
|
|
50
|
+
await this.tracing.start({});
|
|
51
|
+
this.active = true;
|
|
52
|
+
debugLog('tracing started');
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
debugLog('tracing.start failed:', err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async beginAction(title) {
|
|
59
|
+
if (!this.active)
|
|
60
|
+
return null;
|
|
61
|
+
const safe = title.replace(/\s+/g, ' ').slice(0, 80);
|
|
62
|
+
const groupId = `explorbot#${++this.nextGroupId}:${safe}`;
|
|
63
|
+
try {
|
|
64
|
+
await this.tracing.group(groupId);
|
|
65
|
+
return groupId;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
debugLog('tracing.group failed:', err);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async endAction() {
|
|
73
|
+
if (!this.active)
|
|
74
|
+
return;
|
|
75
|
+
try {
|
|
76
|
+
await this.tracing.groupEnd();
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
debugLog('tracing.groupEnd failed:', err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async exportChunk() {
|
|
83
|
+
if (!this.active)
|
|
84
|
+
return new Map();
|
|
85
|
+
const channel = this.tracing._channel;
|
|
86
|
+
if (!channel?.tracingStopChunk || !channel.tracingStartChunk) {
|
|
87
|
+
debugLog('exportChunk: no _channel access, returning empty');
|
|
88
|
+
return new Map();
|
|
89
|
+
}
|
|
90
|
+
let entries = [];
|
|
91
|
+
try {
|
|
92
|
+
const result = await channel.tracingStopChunk({ mode: 'entries' });
|
|
93
|
+
entries = result?.entries || [];
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
debugLog('tracingStopChunk failed:', err);
|
|
97
|
+
return new Map();
|
|
98
|
+
}
|
|
99
|
+
const traceEntry = entries.find((e) => e.name === 'trace.trace');
|
|
100
|
+
let groups = new Map();
|
|
101
|
+
if (traceEntry) {
|
|
102
|
+
try {
|
|
103
|
+
const ndjson = await readFile(traceEntry.value, 'utf8');
|
|
104
|
+
groups = parseTrace(ndjson);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
debugLog('reading trace.trace failed:', err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await channel.tracingStartChunk({});
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
debugLog('tracingStartChunk failed after export:', err);
|
|
115
|
+
this.active = false;
|
|
116
|
+
}
|
|
117
|
+
return groups;
|
|
118
|
+
}
|
|
119
|
+
async stop() {
|
|
120
|
+
if (!this.active)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
await this.tracing.stop({});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
debugLog('tracing.stop failed:', err);
|
|
127
|
+
}
|
|
128
|
+
this.active = false;
|
|
129
|
+
}
|
|
130
|
+
isActive() {
|
|
131
|
+
return this.active;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function parseTrace(ndjson) {
|
|
135
|
+
const befores = new Map();
|
|
136
|
+
const groupTitleByCallId = new Map();
|
|
137
|
+
for (const line of ndjson.split('\n')) {
|
|
138
|
+
if (!line)
|
|
139
|
+
continue;
|
|
140
|
+
let evt;
|
|
141
|
+
try {
|
|
142
|
+
evt = JSON.parse(line);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (evt.type === 'before') {
|
|
148
|
+
befores.set(evt.callId, {
|
|
149
|
+
callId: evt.callId,
|
|
150
|
+
class: evt.class,
|
|
151
|
+
method: evt.method,
|
|
152
|
+
params: evt.params || {},
|
|
153
|
+
parentId: evt.parentId,
|
|
154
|
+
title: evt.title,
|
|
155
|
+
failed: false,
|
|
156
|
+
});
|
|
157
|
+
if (evt.class === 'Tracing' && evt.method === 'tracingGroup' && typeof evt.title === 'string') {
|
|
158
|
+
groupTitleByCallId.set(evt.callId, evt.title);
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (evt.type === 'after') {
|
|
163
|
+
const rec = befores.get(evt.callId);
|
|
164
|
+
if (rec && evt.error)
|
|
165
|
+
rec.failed = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const groups = new Map();
|
|
169
|
+
for (const title of groupTitleByCallId.values()) {
|
|
170
|
+
groups.set(title, []);
|
|
171
|
+
}
|
|
172
|
+
for (const rec of befores.values()) {
|
|
173
|
+
if (rec.failed)
|
|
174
|
+
continue;
|
|
175
|
+
if (rec.class === 'Tracing')
|
|
176
|
+
continue;
|
|
177
|
+
if (!rec.parentId)
|
|
178
|
+
continue;
|
|
179
|
+
const groupTitle = groupTitleByCallId.get(rec.parentId);
|
|
180
|
+
if (!groupTitle)
|
|
181
|
+
continue;
|
|
182
|
+
const allowed = RECORDABLE[rec.class];
|
|
183
|
+
if (!allowed?.has(rec.method))
|
|
184
|
+
continue;
|
|
185
|
+
groups.get(groupTitle).push({ class: rec.class, method: rec.method, params: rec.params });
|
|
186
|
+
}
|
|
187
|
+
return groups;
|
|
188
|
+
}
|
|
189
|
+
export function renderCall(call) {
|
|
190
|
+
const asLocator = getAsLocator();
|
|
191
|
+
const { class: cls, method, params } = call;
|
|
192
|
+
if (cls === 'Frame') {
|
|
193
|
+
if (method === 'goto')
|
|
194
|
+
return `await page.goto(${quote(params.url)});`;
|
|
195
|
+
if (method === 'setContent')
|
|
196
|
+
return `await page.setContent(${quote(params.html)});`;
|
|
197
|
+
const locator = `page.${asLocator('javascript', params.selector || '')}`;
|
|
198
|
+
if (method === 'click')
|
|
199
|
+
return `await ${locator}.click();`;
|
|
200
|
+
if (method === 'dblclick')
|
|
201
|
+
return `await ${locator}.dblclick();`;
|
|
202
|
+
if (method === 'fill')
|
|
203
|
+
return `await ${locator}.fill(${quote(params.value ?? '')});`;
|
|
204
|
+
if (method === 'press')
|
|
205
|
+
return `await ${locator}.press(${quote(params.key ?? '')});`;
|
|
206
|
+
if (method === 'type')
|
|
207
|
+
return `await ${locator}.type(${quote(params.text ?? '')});`;
|
|
208
|
+
if (method === 'check')
|
|
209
|
+
return `await ${locator}.check();`;
|
|
210
|
+
if (method === 'uncheck')
|
|
211
|
+
return `await ${locator}.uncheck();`;
|
|
212
|
+
if (method === 'hover')
|
|
213
|
+
return `await ${locator}.hover();`;
|
|
214
|
+
if (method === 'tap')
|
|
215
|
+
return `await ${locator}.tap();`;
|
|
216
|
+
if (method === 'focus')
|
|
217
|
+
return `await ${locator}.focus();`;
|
|
218
|
+
if (method === 'scrollIntoViewIfNeeded')
|
|
219
|
+
return `await ${locator}.scrollIntoViewIfNeeded();`;
|
|
220
|
+
if (method === 'setInputFiles')
|
|
221
|
+
return `await ${locator}.setInputFiles(${formatFiles(params.localPaths ?? params.files)});`;
|
|
222
|
+
if (method === 'selectOption')
|
|
223
|
+
return `await ${locator}.selectOption(${formatSelectOption(params.options)});`;
|
|
224
|
+
if (method === 'dragTo')
|
|
225
|
+
return `await ${locator}.dragTo(page.locator(${quote(params.targetSelector ?? '')}));`;
|
|
226
|
+
}
|
|
227
|
+
if (cls === 'Page') {
|
|
228
|
+
if (method === 'goBack')
|
|
229
|
+
return 'await page.goBack();';
|
|
230
|
+
if (method === 'goForward')
|
|
231
|
+
return 'await page.goForward();';
|
|
232
|
+
if (method === 'reload')
|
|
233
|
+
return 'await page.reload();';
|
|
234
|
+
if (method === 'keyboardPress')
|
|
235
|
+
return `await page.keyboard.press(${quote(params.key ?? '')});`;
|
|
236
|
+
if (method === 'keyboardType')
|
|
237
|
+
return `await page.keyboard.type(${quote(params.text ?? '')});`;
|
|
238
|
+
if (method === 'keyboardDown')
|
|
239
|
+
return `await page.keyboard.down(${quote(params.key ?? '')});`;
|
|
240
|
+
if (method === 'keyboardUp')
|
|
241
|
+
return `await page.keyboard.up(${quote(params.key ?? '')});`;
|
|
242
|
+
if (method === 'keyboardInsertText')
|
|
243
|
+
return `await page.keyboard.insertText(${quote(params.text ?? '')});`;
|
|
244
|
+
if (method === 'mouseClick')
|
|
245
|
+
return `await page.mouse.click(${params.x ?? 0}, ${params.y ?? 0});`;
|
|
246
|
+
if (method === 'mouseDblclick')
|
|
247
|
+
return `await page.mouse.dblclick(${params.x ?? 0}, ${params.y ?? 0});`;
|
|
248
|
+
if (method === 'mouseMove')
|
|
249
|
+
return `await page.mouse.move(${params.x ?? 0}, ${params.y ?? 0});`;
|
|
250
|
+
if (method === 'mouseDown')
|
|
251
|
+
return 'await page.mouse.down();';
|
|
252
|
+
if (method === 'mouseUp')
|
|
253
|
+
return 'await page.mouse.up();';
|
|
254
|
+
if (method === 'mouseWheel')
|
|
255
|
+
return `await page.mouse.wheel(${params.deltaX ?? 0}, ${params.deltaY ?? 0});`;
|
|
256
|
+
}
|
|
257
|
+
return `// TODO(playwright): ${cls}.${method}(${JSON.stringify(params)})`;
|
|
258
|
+
}
|
|
259
|
+
function quote(value) {
|
|
260
|
+
return JSON.stringify(String(value ?? ''));
|
|
261
|
+
}
|
|
262
|
+
function formatFiles(files) {
|
|
263
|
+
if (!files)
|
|
264
|
+
return '[]';
|
|
265
|
+
if (Array.isArray(files)) {
|
|
266
|
+
if (files.length === 1)
|
|
267
|
+
return quote(files[0]);
|
|
268
|
+
return `[${files.map((f) => quote(f)).join(', ')}]`;
|
|
269
|
+
}
|
|
270
|
+
return quote(files);
|
|
271
|
+
}
|
|
272
|
+
function formatSelectOption(options) {
|
|
273
|
+
if (!options)
|
|
274
|
+
return `''`;
|
|
275
|
+
const list = Array.isArray(options) ? options : [options];
|
|
276
|
+
const values = list.map((o) => o?.valueOrLabel ?? o?.value ?? o?.label ?? '');
|
|
277
|
+
if (values.length === 1)
|
|
278
|
+
return quote(values[0]);
|
|
279
|
+
return `[${values.map((v) => quote(v)).join(', ')}]`;
|
|
280
|
+
}
|
|
281
|
+
export function renderAssertion(assertion) {
|
|
282
|
+
const args = assertion.args;
|
|
283
|
+
if (assertion.name === 'see' && typeof args[0] === 'string') {
|
|
284
|
+
return `await expect(page).toContainText(${JSON.stringify(args[0])});`;
|
|
285
|
+
}
|
|
286
|
+
if (assertion.name === 'dontSee' && typeof args[0] === 'string') {
|
|
287
|
+
return `await expect(page).not.toContainText(${JSON.stringify(args[0])});`;
|
|
288
|
+
}
|
|
289
|
+
if (assertion.name === 'seeElement' && typeof args[0] === 'string') {
|
|
290
|
+
return `await expect(page.locator(${JSON.stringify(args[0])})).toBeVisible();`;
|
|
291
|
+
}
|
|
292
|
+
if (assertion.name === 'dontSeeElement' && typeof args[0] === 'string') {
|
|
293
|
+
return `await expect(page.locator(${JSON.stringify(args[0])})).toBeHidden();`;
|
|
294
|
+
}
|
|
295
|
+
if (assertion.name === 'seeInField' && typeof args[0] === 'string' && args[1] !== undefined) {
|
|
296
|
+
return `await expect(page.locator(${JSON.stringify(args[0])})).toHaveValue(${JSON.stringify(String(args[1]))});`;
|
|
297
|
+
}
|
|
298
|
+
if (assertion.name === 'dontSeeInField' && typeof args[0] === 'string' && args[1] !== undefined) {
|
|
299
|
+
return `await expect(page.locator(${JSON.stringify(args[0])})).not.toHaveValue(${JSON.stringify(String(args[1]))});`;
|
|
300
|
+
}
|
|
301
|
+
if (assertion.name === 'seeInCurrentUrl' && typeof args[0] === 'string') {
|
|
302
|
+
return `await expect(page).toHaveURL(new RegExp(${JSON.stringify(args[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))}));`;
|
|
303
|
+
}
|
|
304
|
+
if (assertion.name === 'dontSeeInCurrentUrl' && typeof args[0] === 'string') {
|
|
305
|
+
return `await expect(page).not.toHaveURL(new RegExp(${JSON.stringify(args[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))}));`;
|
|
306
|
+
}
|
|
307
|
+
return `// TODO(playwright): ${assertion.name}(${assertion.args.map((a) => JSON.stringify(a)).join(', ')})`;
|
|
308
|
+
}
|
|
309
|
+
export { parseTrace };
|
package/dist/src/reporter.js
CHANGED
|
@@ -78,6 +78,7 @@ export class Reporter {
|
|
|
78
78
|
const noteEntries = Object.entries(test.notes)
|
|
79
79
|
.map(([timestampKey, note]) => ({
|
|
80
80
|
startTime: note.startTime,
|
|
81
|
+
endTime: note.endTime,
|
|
81
82
|
message: note.message,
|
|
82
83
|
status: note.status,
|
|
83
84
|
screenshot: note.screenshot,
|
|
@@ -105,7 +106,7 @@ export class Reporter {
|
|
|
105
106
|
const step = {
|
|
106
107
|
category: 'user',
|
|
107
108
|
title: noteEntry.message,
|
|
108
|
-
duration: 0,
|
|
109
|
+
duration: Math.max(0, Math.round(noteEntry.endTime - noteEntry.startTime)),
|
|
109
110
|
status: noteEntry.status || 'none',
|
|
110
111
|
steps: noteSteps.length > 0 ? noteSteps : undefined,
|
|
111
112
|
};
|
|
@@ -148,6 +149,7 @@ export class Reporter {
|
|
|
148
149
|
meta = Object.fromEntries(Object.entries(meta).filter(([, v]) => v));
|
|
149
150
|
}
|
|
150
151
|
const steps = this.combineStepsAndNotes(test, screenshotFile);
|
|
152
|
+
const durationMs = test.getDurationMs();
|
|
151
153
|
const testData = {
|
|
152
154
|
rid: test.id,
|
|
153
155
|
title: test.scenario,
|
|
@@ -162,6 +164,7 @@ export class Reporter {
|
|
|
162
164
|
files: Object.values(test.artifacts) || [],
|
|
163
165
|
message: test.summary || this.extractLastNoteMessage(test) || '',
|
|
164
166
|
meta,
|
|
167
|
+
time: durationMs != null ? Math.round(durationMs) : 0,
|
|
165
168
|
};
|
|
166
169
|
debugLog(testData);
|
|
167
170
|
await this.client.addTestRun(status, testData);
|
package/dist/src/test-plan.js
CHANGED
|
@@ -115,6 +115,10 @@ export class Task {
|
|
|
115
115
|
.map((item) => `${item.type === 'step' ? ' ' : ''}${item.content}`)
|
|
116
116
|
.join('\n');
|
|
117
117
|
}
|
|
118
|
+
getRunResult() {
|
|
119
|
+
const hasPassedNotes = Object.values(this.notes).some((n) => n.status === TestResult.PASSED);
|
|
120
|
+
return hasPassedNotes ? 'partial' : 'failed';
|
|
121
|
+
}
|
|
118
122
|
}
|
|
119
123
|
export class Test extends Task {
|
|
120
124
|
scenario;
|
|
@@ -133,6 +137,7 @@ export class Test extends Task {
|
|
|
133
137
|
enabled = true;
|
|
134
138
|
startTime;
|
|
135
139
|
endTime;
|
|
140
|
+
resetCount = 0;
|
|
136
141
|
constructor(scenario, priority, expectedOutcome, startUrl, plannedSteps = []) {
|
|
137
142
|
super(scenario, startUrl);
|
|
138
143
|
this.scenario = scenario;
|
|
@@ -182,6 +187,13 @@ export class Test extends Task {
|
|
|
182
187
|
return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
|
|
183
188
|
});
|
|
184
189
|
}
|
|
190
|
+
getRunResult() {
|
|
191
|
+
if (this.isSuccessful)
|
|
192
|
+
return 'success';
|
|
193
|
+
if (this.hasAchievedAny())
|
|
194
|
+
return 'partial';
|
|
195
|
+
return super.getRunResult();
|
|
196
|
+
}
|
|
185
197
|
hasAchievedAll() {
|
|
186
198
|
return this.expected.every((expectation) => {
|
|
187
199
|
return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);
|
package/dist/src/utils/aria.js
CHANGED
|
@@ -601,9 +601,17 @@ const resolveDisplayName = (node) => {
|
|
|
601
601
|
return `{${childContent}}`;
|
|
602
602
|
return undefined;
|
|
603
603
|
};
|
|
604
|
+
const SIBLING_COLLAPSE_THRESHOLD = 50;
|
|
605
|
+
const SIBLING_COLLAPSE_KEEP_EACH_SIDE = 5;
|
|
604
606
|
const serializeAriaNodes = (nodes, depth = 0) => {
|
|
605
607
|
const lines = [];
|
|
606
|
-
|
|
608
|
+
const collapsed = collapseSimilarSiblingRuns(nodes, depth);
|
|
609
|
+
for (const entry of collapsed) {
|
|
610
|
+
if (entry.placeholder) {
|
|
611
|
+
lines.push(entry.placeholder);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const node = entry.node;
|
|
607
615
|
const indent = ' '.repeat(depth);
|
|
608
616
|
let line = `${indent}- ${renderNodeLine(node.role, resolveDisplayName(node), node.attributes, node.value)}`;
|
|
609
617
|
if (node.children.length > 0) {
|
|
@@ -616,6 +624,34 @@ const serializeAriaNodes = (nodes, depth = 0) => {
|
|
|
616
624
|
}
|
|
617
625
|
return lines.join('\n');
|
|
618
626
|
};
|
|
627
|
+
const collapseSimilarSiblingRuns = (nodes, depth) => {
|
|
628
|
+
const result = [];
|
|
629
|
+
let i = 0;
|
|
630
|
+
while (i < nodes.length) {
|
|
631
|
+
const role = nodes[i].role;
|
|
632
|
+
let j = i;
|
|
633
|
+
while (j < nodes.length && nodes[j].role === role)
|
|
634
|
+
j++;
|
|
635
|
+
const runLength = j - i;
|
|
636
|
+
if (runLength > SIBLING_COLLAPSE_THRESHOLD) {
|
|
637
|
+
for (let k = i; k < i + SIBLING_COLLAPSE_KEEP_EACH_SIDE; k++) {
|
|
638
|
+
result.push({ node: nodes[k] });
|
|
639
|
+
}
|
|
640
|
+
const omitted = runLength - SIBLING_COLLAPSE_KEEP_EACH_SIDE * 2;
|
|
641
|
+
const indent = ' '.repeat(depth);
|
|
642
|
+
result.push({ placeholder: `${indent}- ...${omitted} similar "${role}" items omitted...` });
|
|
643
|
+
for (let k = j - SIBLING_COLLAPSE_KEEP_EACH_SIDE; k < j; k++) {
|
|
644
|
+
result.push({ node: nodes[k] });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
for (let k = i; k < j; k++)
|
|
649
|
+
result.push({ node: nodes[k] });
|
|
650
|
+
}
|
|
651
|
+
i = j;
|
|
652
|
+
}
|
|
653
|
+
return result;
|
|
654
|
+
};
|
|
619
655
|
export const compactAriaSnapshot = (snapshot, keepNamed = false) => {
|
|
620
656
|
if (!snapshot)
|
|
621
657
|
return '';
|
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
import { isBodyEmpty } from './html.js';
|
|
2
2
|
const HTTP_ERRORS = ['400 Bad Request', '401 Unauthorized', '403 Forbidden', '404 Not Found', '405 Method Not Allowed', '408 Request Timeout', '500 Internal Server Error', '502 Bad Gateway', '503 Service Unavailable', '504 Gateway Timeout'];
|
|
3
3
|
const SMALL_PAGE_THRESHOLD = 500;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const LOADING_WORD = /\bloading\b/i;
|
|
5
|
+
export function detectPageCondition(actionResult) {
|
|
6
|
+
const headingFields = [actionResult.title, actionResult.h1, actionResult.h2].filter(Boolean);
|
|
7
|
+
for (const field of headingFields) {
|
|
7
8
|
for (const error of HTTP_ERRORS) {
|
|
8
9
|
if (field.toLowerCase().includes(error.toLowerCase()))
|
|
9
|
-
return
|
|
10
|
+
return 'error';
|
|
10
11
|
}
|
|
11
12
|
}
|
|
13
|
+
const aria = actionResult.ariaSnapshot || '';
|
|
14
|
+
if (/\bprogressbar\b/i.test(aria))
|
|
15
|
+
return 'loading';
|
|
16
|
+
if (/\[busy\]/.test(aria))
|
|
17
|
+
return 'loading';
|
|
18
|
+
for (const field of headingFields) {
|
|
19
|
+
if (LOADING_WORD.test(field))
|
|
20
|
+
return 'loading';
|
|
21
|
+
}
|
|
12
22
|
if (!actionResult.html || isBodyEmpty(actionResult.html))
|
|
13
|
-
return
|
|
23
|
+
return 'loading';
|
|
14
24
|
const bodyMatch = actionResult.html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
15
25
|
if (bodyMatch && bodyMatch[1].trim().length < SMALL_PAGE_THRESHOLD)
|
|
16
|
-
return
|
|
17
|
-
return
|
|
26
|
+
return 'loading';
|
|
27
|
+
return 'ok';
|
|
28
|
+
}
|
|
29
|
+
export function isErrorPage(actionResult) {
|
|
30
|
+
return detectPageCondition(actionResult) === 'error';
|
|
18
31
|
}
|
|
19
32
|
export class ErrorPageError extends Error {
|
|
20
33
|
url;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { tag } from './logger.js';
|
|
3
|
+
export function relativeToCwd(absPath) {
|
|
4
|
+
const rel = path.relative(process.cwd(), absPath);
|
|
5
|
+
return rel || '.';
|
|
6
|
+
}
|
|
7
|
+
export function printNextSteps(sections) {
|
|
8
|
+
if (sections.length === 0)
|
|
9
|
+
return;
|
|
10
|
+
const blocks = [];
|
|
11
|
+
for (const section of sections) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
const headerPath = section.path ? relativeToCwd(section.path) : '';
|
|
14
|
+
lines.push(headerPath ? `${section.label}: ${headerPath}` : section.label);
|
|
15
|
+
const commands = section.commands || [];
|
|
16
|
+
if (commands.length > 0) {
|
|
17
|
+
const labeled = commands.filter((c) => c.label);
|
|
18
|
+
const maxLabel = labeled.length > 0 ? Math.max(...labeled.map((c) => c.label.length)) : 0;
|
|
19
|
+
for (const cmd of commands) {
|
|
20
|
+
if (!cmd.label) {
|
|
21
|
+
lines.push(` ${cmd.command}`);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const padded = `${cmd.label}:`.padEnd(maxLabel + 2);
|
|
25
|
+
lines.push(` ${padded} ${cmd.command}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
blocks.push(lines.join('\n'));
|
|
29
|
+
}
|
|
30
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
31
|
+
if (i > 0)
|
|
32
|
+
tag('info').log('');
|
|
33
|
+
for (const line of blocks[i].split('\n')) {
|
|
34
|
+
tag('info').log(line);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|