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
|
@@ -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
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
import { Client } from '@testomatio/reporter';
|
|
2
2
|
import { outputPath } from './config.js';
|
|
3
|
+
import { Stats } from './stats.js';
|
|
3
4
|
import { createDebug } from './utils/logger.js';
|
|
4
5
|
const debugLog = createDebug('explorbot:reporter');
|
|
5
6
|
export class Reporter {
|
|
6
7
|
client;
|
|
7
8
|
isRunStarted = false;
|
|
8
9
|
reporterEnabled;
|
|
9
|
-
|
|
10
|
+
stateManager;
|
|
11
|
+
constructor(config, stateManager) {
|
|
10
12
|
this.reporterEnabled = Reporter.resolveEnabled(config);
|
|
13
|
+
this.stateManager = stateManager;
|
|
11
14
|
if (this.reporterEnabled && (!process.env.TESTOMATIO || config?.html)) {
|
|
12
15
|
this.configureHtmlPipe();
|
|
13
16
|
}
|
|
14
|
-
this.client = new Client({ apiKey: process.env.TESTOMATIO || '' });
|
|
15
17
|
const pipe = process.env.TESTOMATIO && config?.html ? 'both' : process.env.TESTOMATIO ? 'testomatio' : 'html';
|
|
16
18
|
debugLog('Reporter initialized', { enabled: this.reporterEnabled, pipe });
|
|
17
19
|
}
|
|
20
|
+
buildTitle() {
|
|
21
|
+
if (process.env.TESTOMATIO_TITLE)
|
|
22
|
+
return process.env.TESTOMATIO_TITLE;
|
|
23
|
+
const url = this.stateManager?.getCurrentState()?.url;
|
|
24
|
+
const parts = ['Explorbot session'];
|
|
25
|
+
if (url)
|
|
26
|
+
parts.push(url);
|
|
27
|
+
if (Stats.focus)
|
|
28
|
+
parts.push(`focus: "${Stats.focus}"`);
|
|
29
|
+
parts.push(`at ${new Date().toISOString().slice(0, 16)}`);
|
|
30
|
+
return parts.join(' ');
|
|
31
|
+
}
|
|
18
32
|
static resolveEnabled(config) {
|
|
19
33
|
if (config?.enabled === true)
|
|
20
34
|
return true;
|
|
@@ -35,6 +49,7 @@ export class Reporter {
|
|
|
35
49
|
return;
|
|
36
50
|
}
|
|
37
51
|
try {
|
|
52
|
+
this.client = new Client({ apiKey: process.env.TESTOMATIO || '', title: this.buildTitle() });
|
|
38
53
|
const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
|
|
39
54
|
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
|
|
40
55
|
const result = await Promise.race([this.client.createRun().then(() => 'success'), timeoutPromise]);
|
package/dist/src/stats.js
CHANGED
package/dist/src/suite.js
CHANGED
|
@@ -3,8 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { Reflection } from '@codeceptjs/reflection';
|
|
4
4
|
import { ConfigParser } from "./config.js";
|
|
5
5
|
import { normalizeUrl } from "./state-manager.js";
|
|
6
|
-
import { parsePlanFromMarkdown } from "./utils/test-plan-markdown.js";
|
|
7
6
|
import { createDebug } from "./utils/logger.js";
|
|
7
|
+
import { parsePlanFromMarkdown } from "./utils/test-plan-markdown.js";
|
|
8
8
|
const debugLog = createDebug('explorbot:suite');
|
|
9
9
|
export class Suite {
|
|
10
10
|
url;
|
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,18 +1,41 @@
|
|
|
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';
|
|
31
|
+
}
|
|
32
|
+
export class ErrorPageError extends Error {
|
|
33
|
+
url;
|
|
34
|
+
title;
|
|
35
|
+
constructor(url, title) {
|
|
36
|
+
super(`Error page detected at ${url}${title ? ` (${title})` : ''}`);
|
|
37
|
+
this.url = url;
|
|
38
|
+
this.title = title;
|
|
39
|
+
this.name = 'ErrorPageError';
|
|
40
|
+
}
|
|
18
41
|
}
|
package/dist/src/utils/logger.js
CHANGED
|
@@ -4,9 +4,9 @@ import { context, trace } from '@opentelemetry/api';
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import debug from 'debug';
|
|
6
6
|
import dedent from 'dedent';
|
|
7
|
+
import stripAnsi from 'strip-ansi';
|
|
7
8
|
import { ConfigParser } from '../config.js';
|
|
8
9
|
import { Observability } from "../observability.js";
|
|
9
|
-
import stripAnsi from 'strip-ansi';
|
|
10
10
|
import { parseMarkdownToTerminal } from "./markdown-terminal.js";
|
|
11
11
|
class DebugFilter {
|
|
12
12
|
patterns = [];
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { tag } from "./logger.js";
|
|
@@ -3,9 +3,9 @@ import path from 'node:path';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { highlight } from 'cli-highlight';
|
|
5
5
|
import * as codeceptjs from 'codeceptjs';
|
|
6
|
-
import store from 'codeceptjs/lib/store';
|
|
7
6
|
import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
8
7
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
8
|
+
import store from 'codeceptjs/lib/store';
|
|
9
9
|
import figureSet from 'figures';
|
|
10
10
|
import { ConfigParser } from "../config.js";
|
|
11
11
|
export function loadTestSuites(testsDir) {
|
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
import micromatch from 'micromatch';
|
|
2
|
+
import { ConfigParser } from '../config.js';
|
|
3
|
+
export function isDynamicSegment(segment) {
|
|
4
|
+
try {
|
|
5
|
+
const configRegex = ConfigParser.getInstance().getConfig().dynamicPageRegex;
|
|
6
|
+
if (configRegex)
|
|
7
|
+
return new RegExp(configRegex, 'i').test(segment);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
/* config not loaded yet */
|
|
11
|
+
}
|
|
12
|
+
// numeric: /users/123
|
|
13
|
+
if (/^\d+$/.test(segment))
|
|
14
|
+
return true;
|
|
15
|
+
// UUID: /items/550e8400-e29b-41d4-a716-446655440000
|
|
16
|
+
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment))
|
|
17
|
+
return true;
|
|
18
|
+
// ULID: /items/01ARZ3NDEKTSV4RRFFQ69G5FAV
|
|
19
|
+
if (/^[0-9A-HJKMNP-TV-Z]{26}$/.test(segment))
|
|
20
|
+
return true;
|
|
21
|
+
// hex ID (4+ chars): /suite/70dae98a
|
|
22
|
+
if (/^[a-f0-9]{4,}$/i.test(segment))
|
|
23
|
+
return true;
|
|
24
|
+
// hex-prefixed slug (8+ hex before dash): /suite/95ef0c94-mobile
|
|
25
|
+
if (/^[a-f0-9]{8,}-/i.test(segment))
|
|
26
|
+
return true;
|
|
27
|
+
// short mixed alphanumeric (digits + letters, ≤8 chars, no dash): /item/x7f2
|
|
28
|
+
if (segment.length <= 8 && !segment.includes('-') && /\d/.test(segment) && /[a-z]/i.test(segment))
|
|
29
|
+
return true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
export function hasDynamicUrlSegment(url) {
|
|
33
|
+
return url.split('/').some((seg) => seg.length > 0 && isDynamicSegment(seg));
|
|
34
|
+
}
|
|
35
|
+
export function generalizeSegment(segment) {
|
|
36
|
+
if (/^\d+$/.test(segment))
|
|
37
|
+
return '\\d+';
|
|
38
|
+
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment))
|
|
39
|
+
return '[a-f0-9-]+';
|
|
40
|
+
if (/^[0-9A-HJKMNP-TV-Z]{26}$/.test(segment))
|
|
41
|
+
return '[0-9A-HJKMNP-TV-Z]+';
|
|
42
|
+
if (/^[a-f0-9]+$/i.test(segment))
|
|
43
|
+
return '[a-f0-9]+';
|
|
44
|
+
return '[^/]+';
|
|
45
|
+
}
|
|
46
|
+
export function generalizeUrl(url) {
|
|
47
|
+
return url
|
|
48
|
+
.split('/')
|
|
49
|
+
.map((seg) => (seg.length > 0 && isDynamicSegment(seg) ? generalizeSegment(seg) : seg))
|
|
50
|
+
.join('/');
|
|
51
|
+
}
|
|
2
52
|
export function matchesUrl(pattern, path) {
|
|
3
53
|
if (pattern === '*')
|
|
4
54
|
return true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"axe-core": "^4.11.1",
|
|
84
84
|
"bash-tool": "^1.3.15",
|
|
85
85
|
"cli-highlight": "^2.1.11",
|
|
86
|
-
"codeceptjs": "4.0.0-rc.
|
|
86
|
+
"codeceptjs": "4.0.0-rc.16",
|
|
87
87
|
"commander": "^14.0.1",
|
|
88
88
|
"debug": "^4.4.3",
|
|
89
89
|
"dedent": "^1.6.0",
|
|
@@ -13,6 +13,10 @@ In <explanation> write only one line without heading or bullet list or any other
|
|
|
13
13
|
Check previous solutions, if there is already successful solution, use it!
|
|
14
14
|
CodeceptJS code must start with "I."
|
|
15
15
|
All lines of code must be CodeceptJS code and start with "I."
|
|
16
|
+
|
|
17
|
+
Do not mix form filling with navigation in the same code block.
|
|
18
|
+
If the code block fills a form and clicks a submit/confirm control, stop there — do not append I.amOnPage afterwards. Submitting the form already triggers navigation on the server side, and a follow-up I.amOnPage cancels that in-flight navigation and discards the just-submitted state (session, cookies, redirect target).
|
|
19
|
+
If the action does not cause navigation on its own and a separate page visit is required to reach the target, put I.amOnPage in its own code block as a distinct step, not glued onto the form submission block.
|
|
16
20
|
</rules>
|
|
17
21
|
|
|
18
22
|
<output>
|
|
@@ -42,6 +46,11 @@ Use only locators from HTML PAGE that was passed in <page> context.
|
|
|
42
46
|
<example_output>
|
|
43
47
|
Trying to fill the form on the page
|
|
44
48
|
|
|
49
|
+
```js
|
|
50
|
+
I.fillField({ "role": "textbox", "text": "Name" }, 'Value');
|
|
51
|
+
I.click({ "role": "button", "text": "Submit" });
|
|
52
|
+
```
|
|
53
|
+
|
|
45
54
|
```js
|
|
46
55
|
I.fillField('Name', 'Value');
|
|
47
56
|
I.click('Submit');
|