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.
Files changed (157) hide show
  1. package/README.md +27 -1
  2. package/bin/explorbot-cli.ts +86 -15
  3. package/boat/api-tester/src/ai/curler-tools.ts +3 -3
  4. package/boat/api-tester/src/ai/curler.ts +1 -1
  5. package/boat/api-tester/src/apibot.ts +2 -2
  6. package/boat/api-tester/src/config.ts +1 -1
  7. package/dist/bin/explorbot-cli.js +85 -14
  8. package/dist/boat/api-tester/src/ai/curler-tools.js +2 -2
  9. package/dist/boat/api-tester/src/apibot.js +2 -2
  10. package/dist/package.json +2 -2
  11. package/dist/rules/navigator/output.md +9 -0
  12. package/dist/rules/navigator/verification-actions.md +2 -0
  13. package/dist/src/action-result.js +23 -1
  14. package/dist/src/action.js +46 -38
  15. package/dist/src/ai/bosun.js +16 -2
  16. package/dist/src/ai/conversation.js +39 -0
  17. package/dist/src/ai/experience-compactor.js +235 -50
  18. package/dist/src/ai/historian/codeceptjs.js +109 -0
  19. package/dist/src/ai/historian/experience.js +320 -0
  20. package/dist/src/ai/historian/mixin.js +2 -0
  21. package/dist/src/ai/historian/playwright.js +145 -0
  22. package/dist/src/ai/historian/utils.js +18 -0
  23. package/dist/src/ai/historian.js +19 -398
  24. package/dist/src/ai/navigator.js +133 -80
  25. package/dist/src/ai/pilot.js +254 -13
  26. package/dist/src/ai/planner/subpages.js +1 -30
  27. package/dist/src/ai/planner.js +33 -13
  28. package/dist/src/ai/provider.js +55 -18
  29. package/dist/src/ai/rerunner.js +3 -3
  30. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  31. package/dist/src/ai/researcher/fingerprint-worker.js +1 -1
  32. package/dist/src/ai/researcher/locators.js +1 -1
  33. package/dist/src/ai/researcher/sections.js +8 -1
  34. package/dist/src/ai/researcher.js +43 -41
  35. package/dist/src/ai/rules.js +26 -14
  36. package/dist/src/ai/tester.js +90 -26
  37. package/dist/src/ai/tools.js +18 -10
  38. package/dist/src/api/request-store.js +20 -0
  39. package/dist/src/api/xhr-capture.js +19 -3
  40. package/dist/src/browser-server.js +16 -3
  41. package/dist/src/command-handler.js +1 -1
  42. package/dist/src/commands/add-rule-command.js +12 -9
  43. package/dist/src/commands/base-command.js +20 -0
  44. package/dist/src/commands/clean-command.js +3 -2
  45. package/dist/src/commands/compact-command.js +138 -0
  46. package/dist/src/commands/context-command.js +7 -1
  47. package/dist/src/commands/drill-command.js +4 -1
  48. package/dist/src/commands/experience-command.js +104 -0
  49. package/dist/src/commands/explore-command.js +54 -19
  50. package/dist/src/commands/freesail-command.js +2 -0
  51. package/dist/src/commands/index.js +7 -3
  52. package/dist/src/commands/init-command.js +11 -10
  53. package/dist/src/commands/learn-command.js +1 -1
  54. package/dist/src/commands/navigate-command.js +4 -1
  55. package/dist/src/commands/plan-clear-command.js +4 -1
  56. package/dist/src/commands/plan-command.js +43 -4
  57. package/dist/src/commands/plan-edit-command.js +1 -1
  58. package/dist/src/commands/plan-load-command.js +4 -1
  59. package/dist/src/commands/plan-reload-command.js +4 -1
  60. package/dist/src/commands/plan-save-command.js +20 -8
  61. package/dist/src/commands/rerun-command.js +4 -0
  62. package/dist/src/commands/research-command.js +5 -2
  63. package/dist/src/commands/start-command.js +5 -1
  64. package/dist/src/commands/test-command.js +7 -1
  65. package/dist/src/components/App.js +15 -5
  66. package/dist/src/execution-controller.js +13 -2
  67. package/dist/src/experience-tracker.js +174 -83
  68. package/dist/src/explorbot.js +31 -22
  69. package/dist/src/explorer.js +12 -5
  70. package/dist/src/observability.js +50 -99
  71. package/dist/src/playwright-recorder.js +309 -0
  72. package/dist/src/reporter.js +17 -2
  73. package/dist/src/stats.js +2 -0
  74. package/dist/src/suite.js +1 -1
  75. package/dist/src/test-plan.js +12 -0
  76. package/dist/src/utils/aria.js +37 -1
  77. package/dist/src/utils/error-page.js +30 -7
  78. package/dist/src/utils/logger.js +1 -1
  79. package/dist/src/utils/next-steps.js +37 -0
  80. package/dist/src/utils/rules-loader.js +1 -1
  81. package/dist/src/utils/test-files.js +1 -1
  82. package/dist/src/utils/url-matcher.js +50 -0
  83. package/package.json +2 -2
  84. package/rules/navigator/output.md +9 -0
  85. package/rules/navigator/verification-actions.md +2 -0
  86. package/src/action-result.ts +26 -1
  87. package/src/action.ts +44 -37
  88. package/src/ai/bosun.ts +16 -2
  89. package/src/ai/conversation.ts +37 -0
  90. package/src/ai/experience-compactor.ts +270 -63
  91. package/src/ai/historian/codeceptjs.ts +130 -0
  92. package/src/ai/historian/experience.ts +383 -0
  93. package/src/ai/historian/mixin.ts +4 -0
  94. package/src/ai/historian/playwright.ts +169 -0
  95. package/src/ai/historian/utils.ts +23 -0
  96. package/src/ai/historian.ts +35 -468
  97. package/src/ai/navigator.ts +140 -85
  98. package/src/ai/pilot.ts +259 -14
  99. package/src/ai/planner/subpages.ts +1 -24
  100. package/src/ai/planner.ts +34 -14
  101. package/src/ai/provider.ts +52 -18
  102. package/src/ai/rerunner.ts +3 -3
  103. package/src/ai/researcher/deep-analysis.ts +1 -1
  104. package/src/ai/researcher/fingerprint-worker.ts +1 -1
  105. package/src/ai/researcher/locators.ts +2 -2
  106. package/src/ai/researcher/sections.ts +7 -1
  107. package/src/ai/researcher.ts +47 -42
  108. package/src/ai/rules.ts +27 -14
  109. package/src/ai/task-agent.ts +1 -1
  110. package/src/ai/tester.ts +94 -26
  111. package/src/ai/tools.ts +53 -29
  112. package/src/api/request-store.ts +22 -0
  113. package/src/api/xhr-capture.ts +21 -3
  114. package/src/browser-server.ts +17 -3
  115. package/src/command-handler.ts +1 -1
  116. package/src/commands/add-rule-command.ts +13 -9
  117. package/src/commands/base-command.ts +26 -1
  118. package/src/commands/clean-command.ts +4 -3
  119. package/src/commands/compact-command.ts +156 -0
  120. package/src/commands/context-command.ts +8 -2
  121. package/src/commands/drill-command.ts +5 -2
  122. package/src/commands/experience-command.ts +125 -0
  123. package/src/commands/explore-command.ts +58 -21
  124. package/src/commands/freesail-command.ts +2 -0
  125. package/src/commands/index.ts +7 -3
  126. package/src/commands/init-command.ts +11 -10
  127. package/src/commands/learn-command.ts +2 -2
  128. package/src/commands/navigate-command.ts +5 -2
  129. package/src/commands/plan-clear-command.ts +5 -2
  130. package/src/commands/plan-command.ts +47 -5
  131. package/src/commands/plan-edit-command.ts +2 -2
  132. package/src/commands/plan-load-command.ts +5 -2
  133. package/src/commands/plan-reload-command.ts +5 -2
  134. package/src/commands/plan-save-command.ts +20 -9
  135. package/src/commands/rerun-command.ts +5 -0
  136. package/src/commands/research-command.ts +6 -3
  137. package/src/commands/start-command.ts +6 -2
  138. package/src/commands/test-command.ts +8 -2
  139. package/src/components/App.tsx +16 -5
  140. package/src/config.ts +6 -1
  141. package/src/execution-controller.ts +14 -3
  142. package/src/experience-tracker.ts +198 -100
  143. package/src/explorbot.ts +33 -23
  144. package/src/explorer.ts +14 -5
  145. package/src/observability.ts +50 -109
  146. package/src/playwright-recorder.ts +305 -0
  147. package/src/reporter.ts +17 -3
  148. package/src/stats.ts +4 -0
  149. package/src/suite.ts +1 -1
  150. package/src/test-plan.ts +12 -0
  151. package/src/utils/aria.ts +38 -1
  152. package/src/utils/error-page.ts +32 -7
  153. package/src/utils/logger.ts +1 -1
  154. package/src/utils/next-steps.ts +51 -0
  155. package/src/utils/rules-loader.ts +1 -1
  156. package/src/utils/test-files.ts +1 -1
  157. 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 };
@@ -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
- constructor(config) {
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
@@ -3,6 +3,8 @@ export class Stats {
3
3
  static researches = 0;
4
4
  static tests = 0;
5
5
  static plans = 0;
6
+ static mode;
7
+ static focus;
6
8
  static models = {};
7
9
  static recordTokens(_agent, model, usage) {
8
10
  if (!Stats.models[model]) {
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;
@@ -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);
@@ -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
- for (const node of nodes) {
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
- export function isErrorPage(actionResult) {
5
- const checkFields = [actionResult.title, actionResult.h1, actionResult.h2].filter(Boolean);
6
- for (const field of checkFields) {
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 true;
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 true;
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 true;
17
- return false;
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
  }
@@ -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, readdirSync, readFileSync, writeFileSync } from 'node:fs';
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.9",
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.11",
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');