explorbot 0.1.10 → 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 +27 -18
- package/dist/bin/explorbot-cli.js +26 -18
- 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 +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 +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 -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 +27 -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 +5 -8
- package/dist/src/explorer.js +9 -2
- package/dist/src/observability.js +50 -99
- package/dist/src/playwright-recorder.js +309 -0
- 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/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 +11 -1
- package/src/ai/conversation.ts +37 -0
- 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 -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 +29 -15
- 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 +6 -1
- package/src/execution-controller.ts +14 -3
- package/src/experience-tracker.ts +21 -72
- package/src/explorbot.ts +5 -8
- package/src/explorer.ts +11 -2
- package/src/observability.ts +50 -109
- package/src/playwright-recorder.ts +305 -0
- 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
|
@@ -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/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
|
+
}
|
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');
|
|
@@ -113,6 +113,8 @@ For input field values, ALWAYS use I.seeInField() — never check value via CSS
|
|
|
113
113
|
Prefer text locators (label, name, placeholder) for form fields: I.seeInField('Search', 'value') over I.seeInField('input[name="search"]', 'value').
|
|
114
114
|
Only use locators that exist in the provided HTML or ARIA snapshot.
|
|
115
115
|
Verify exact conditions, not approximate matches.
|
|
116
|
+
NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking text inside an element is the job of I.see(text, context) — the `:has-text()` form duplicates that capability with a fragile selector.
|
|
117
|
+
NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
|
|
116
118
|
</verification_rules>
|
|
117
119
|
|
|
118
120
|
[DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
|
package/src/action-result.ts
CHANGED
|
@@ -611,7 +611,7 @@ export class ActionResult implements ActionResultData {
|
|
|
611
611
|
}
|
|
612
612
|
}
|
|
613
613
|
if (processedParts.length > 0) {
|
|
614
|
-
pageDiff.htmlParts = processedParts;
|
|
614
|
+
pageDiff.htmlParts = collapseHtmlParts(processedParts);
|
|
615
615
|
}
|
|
616
616
|
}
|
|
617
617
|
|
|
@@ -629,6 +629,31 @@ export class ActionResult implements ActionResultData {
|
|
|
629
629
|
}
|
|
630
630
|
}
|
|
631
631
|
|
|
632
|
+
const HTML_PARTS_TOTAL_BUDGET = 8000;
|
|
633
|
+
const HTML_PARTS_COUNT_LIMIT = 8;
|
|
634
|
+
const HTML_PART_SUBTREE_BUDGET = 2000;
|
|
635
|
+
|
|
636
|
+
function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
|
|
637
|
+
const total = parts.reduce((sum, p) => sum + p.subtree.length, 0);
|
|
638
|
+
const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
|
|
639
|
+
|
|
640
|
+
if (fullPageReRender) {
|
|
641
|
+
return parts.map((part) => ({
|
|
642
|
+
...part,
|
|
643
|
+
subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
|
|
644
|
+
}));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return parts.map((part) => {
|
|
648
|
+
if (part.subtree.length <= HTML_PART_SUBTREE_BUDGET) return part;
|
|
649
|
+
const head = part.subtree.slice(0, HTML_PART_SUBTREE_BUDGET);
|
|
650
|
+
return {
|
|
651
|
+
...part,
|
|
652
|
+
subtree: `${head}...<!-- truncated ${part.subtree.length - HTML_PART_SUBTREE_BUDGET} chars -->`,
|
|
653
|
+
};
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
632
657
|
export class Diff {
|
|
633
658
|
private _htmlDiffResult: HtmlDiffResult | null = null;
|
|
634
659
|
private _ariaDiffResult: string | null = null;
|