explorbot 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +37 -1
  2. package/bin/explorbot-cli.ts +27 -18
  3. package/dist/bin/explorbot-cli.js +26 -18
  4. package/dist/package.json +3 -3
  5. package/dist/rules/navigator/output.md +9 -0
  6. package/dist/rules/navigator/verification-actions.md +2 -0
  7. package/dist/src/action-result.js +23 -1
  8. package/dist/src/action.js +51 -42
  9. package/dist/src/ai/bosun.js +11 -1
  10. package/dist/src/ai/conversation.js +39 -0
  11. package/dist/src/ai/historian/codeceptjs.js +109 -0
  12. package/dist/src/ai/historian/experience.js +321 -0
  13. package/dist/src/ai/historian/mixin.js +2 -0
  14. package/dist/src/ai/historian/playwright.js +145 -0
  15. package/dist/src/ai/historian/screencast.js +121 -0
  16. package/dist/src/ai/historian/utils.js +18 -0
  17. package/dist/src/ai/historian.js +21 -405
  18. package/dist/src/ai/navigator.js +82 -29
  19. package/dist/src/ai/pilot.js +232 -13
  20. package/dist/src/ai/planner.js +29 -9
  21. package/dist/src/ai/provider.js +54 -17
  22. package/dist/src/ai/researcher.js +41 -32
  23. package/dist/src/ai/rules.js +26 -14
  24. package/dist/src/ai/tester.js +90 -26
  25. package/dist/src/ai/tools.js +13 -7
  26. package/dist/src/browser-server.js +16 -3
  27. package/dist/src/commands/add-rule-command.js +11 -8
  28. package/dist/src/commands/clean-command.js +2 -1
  29. package/dist/src/commands/explore-command.js +43 -15
  30. package/dist/src/commands/init-command.js +9 -8
  31. package/dist/src/commands/plan-command.js +32 -0
  32. package/dist/src/commands/plan-save-command.js +19 -7
  33. package/dist/src/commands/rerun-command.js +4 -0
  34. package/dist/src/components/App.js +15 -5
  35. package/dist/src/execution-controller.js +13 -2
  36. package/dist/src/experience-tracker.js +20 -64
  37. package/dist/src/explorbot.js +8 -8
  38. package/dist/src/explorer.js +11 -3
  39. package/dist/src/observability.js +50 -99
  40. package/dist/src/playwright-recorder.js +309 -0
  41. package/dist/src/reporter.js +4 -1
  42. package/dist/src/test-plan.js +12 -0
  43. package/dist/src/utils/aria.js +37 -1
  44. package/dist/src/utils/error-page.js +20 -7
  45. package/dist/src/utils/next-steps.js +37 -0
  46. package/dist/src/utils/strings.js +15 -0
  47. package/package.json +3 -3
  48. package/rules/navigator/output.md +9 -0
  49. package/rules/navigator/verification-actions.md +2 -0
  50. package/src/action-result.ts +26 -1
  51. package/src/action.ts +49 -41
  52. package/src/ai/bosun.ts +11 -1
  53. package/src/ai/conversation.ts +37 -0
  54. package/src/ai/historian/codeceptjs.ts +130 -0
  55. package/src/ai/historian/experience.ts +384 -0
  56. package/src/ai/historian/mixin.ts +4 -0
  57. package/src/ai/historian/playwright.ts +169 -0
  58. package/src/ai/historian/screencast.ts +133 -0
  59. package/src/ai/historian/utils.ts +23 -0
  60. package/src/ai/historian.ts +37 -473
  61. package/src/ai/navigator.ts +82 -29
  62. package/src/ai/pilot.ts +237 -14
  63. package/src/ai/planner.ts +29 -9
  64. package/src/ai/provider.ts +51 -17
  65. package/src/ai/researcher.ts +45 -33
  66. package/src/ai/rules.ts +27 -14
  67. package/src/ai/tester.ts +94 -26
  68. package/src/ai/tools.ts +47 -25
  69. package/src/browser-server.ts +17 -3
  70. package/src/commands/add-rule-command.ts +11 -7
  71. package/src/commands/clean-command.ts +2 -1
  72. package/src/commands/explore-command.ts +46 -14
  73. package/src/commands/init-command.ts +9 -8
  74. package/src/commands/plan-command.ts +35 -0
  75. package/src/commands/plan-save-command.ts +18 -7
  76. package/src/commands/rerun-command.ts +5 -0
  77. package/src/components/App.tsx +16 -5
  78. package/src/config.ts +12 -1
  79. package/src/execution-controller.ts +14 -3
  80. package/src/experience-tracker.ts +21 -72
  81. package/src/explorbot.ts +8 -8
  82. package/src/explorer.ts +13 -3
  83. package/src/observability.ts +50 -109
  84. package/src/playwright-recorder.ts +305 -0
  85. package/src/reporter.ts +4 -1
  86. package/src/test-plan.ts +12 -0
  87. package/src/utils/aria.ts +38 -1
  88. package/src/utils/error-page.ts +22 -7
  89. package/src/utils/next-steps.ts +51 -0
  90. package/src/utils/strings.ts +17 -0
@@ -1,125 +1,76 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { context, trace } from '@opentelemetry/api';
1
+ import { trace } from '@opentelemetry/api';
3
2
  let current = null;
4
- let depth = 0;
5
3
  export const Observability = {
6
4
  async run(name, metadata, fn) {
7
- const started = Observability.startTrace(name, metadata);
8
- try {
9
- if (!started) {
10
- const parentSpan = current?.span;
11
- if (!parentSpan || !current)
12
- return await fn();
13
- const tracer = trace.getTracer('ai');
14
- const childSpan = tracer.startSpan(name, undefined, trace.setSpan(context.active(), parentSpan));
15
- const savedSpan = current.span;
16
- const savedName = current.name;
17
- current.span = childSpan;
18
- current.name = name;
19
- return await context.with(trace.setSpan(context.active(), childSpan), async () => {
20
- try {
21
- return await fn();
22
- }
23
- finally {
24
- childSpan.end();
25
- current.span = savedSpan;
26
- current.name = savedName;
27
- }
28
- });
29
- }
30
- const tracer = trace.getTracer('ai');
31
- const spanContext = {
32
- traceId: current?.traceId || randomBytes(16).toString('hex'),
33
- spanId: randomBytes(8).toString('hex'),
34
- traceFlags: 1,
35
- };
36
- const rootContext = trace.setSpanContext(context.active(), spanContext);
37
- const initSpan = tracer.startSpan(name, undefined, rootContext);
38
- initSpan.setAttribute('langfuse.trace.name', name);
39
- initSpan.setAttribute('langfuse.trace.id', current?.traceId || '');
40
- if (current?.metadata?.sessionId) {
41
- initSpan.setAttribute('langfuse.trace.session_id', String(current.metadata.sessionId));
42
- }
43
- if (current?.metadata?.userId) {
44
- initSpan.setAttribute('langfuse.trace.user_id', String(current.metadata.userId));
45
- }
46
- if (current?.metadata?.tags && Array.isArray(current.metadata.tags)) {
47
- initSpan.setAttribute('langfuse.trace.tags', current.metadata.tags);
48
- }
49
- if (current?.metadata?.input) {
50
- initSpan.setAttribute('langfuse.trace.input', JSON.stringify(current.metadata.input));
51
- }
52
- initSpan.end();
53
- const span = tracer.startSpan(name, undefined, rootContext);
54
- current.span = span;
55
- return await context.with(trace.setSpan(rootContext, span), async () => {
5
+ const tracer = trace.getTracer('ai');
6
+ if (current) {
7
+ return await tracer.startActiveSpan(name, {}, async (span) => {
8
+ const saved = current;
9
+ current = {
10
+ metadata: { ...saved.metadata, ...metadata },
11
+ name,
12
+ span,
13
+ };
56
14
  try {
57
15
  return await fn();
58
16
  }
59
17
  finally {
60
18
  span.end();
61
- current.span = undefined;
19
+ current = saved;
62
20
  }
63
21
  });
64
22
  }
65
- finally {
66
- Observability.endTrace(started);
67
- }
68
- },
69
- startTrace(name, metadata) {
70
- if (current) {
71
- depth += 1;
72
- return false;
73
- }
74
- const langfuseTraceId = metadata.langfuseTraceId || randomBytes(16).toString('hex');
75
- current = {
76
- metadata: {
77
- ...metadata,
78
- langfuseTraceId,
79
- },
80
- traceId: langfuseTraceId,
81
- updateParent: true,
82
- name,
83
- };
84
- depth = 1;
85
- return true;
86
- },
87
- endTrace(started) {
88
- if (!current) {
89
- return;
90
- }
91
- if (!started) {
92
- depth -= 1;
93
- return;
94
- }
95
- depth -= 1;
96
- if (depth <= 0) {
97
- current = null;
98
- depth = 0;
99
- }
23
+ const attributes = buildRootSpanAttributes(name, metadata);
24
+ return await tracer.startActiveSpan(name, { attributes }, async (span) => {
25
+ current = { metadata, name, span };
26
+ try {
27
+ return await fn();
28
+ }
29
+ finally {
30
+ span.end();
31
+ current = null;
32
+ }
33
+ });
100
34
  },
101
35
  getTelemetry() {
102
36
  if (!current) {
103
37
  return undefined;
104
38
  }
105
- const telemetry = {
39
+ const metadata = {};
40
+ if (current.metadata.sessionId)
41
+ metadata.sessionId = current.metadata.sessionId;
42
+ if (current.metadata.userId)
43
+ metadata.userId = current.metadata.userId;
44
+ if (Array.isArray(current.metadata.tags))
45
+ metadata.tags = current.metadata.tags;
46
+ return {
106
47
  isEnabled: true,
107
48
  functionId: current.name,
108
- metadata: {
109
- ...current.metadata,
110
- langfuseTraceId: current.traceId,
111
- langfuseUpdateParent: current.updateParent,
112
- },
49
+ metadata,
113
50
  };
114
- if (current.updateParent) {
115
- current.updateParent = false;
116
- }
117
- return telemetry;
118
51
  },
119
52
  isTracing() {
120
53
  return Boolean(current);
121
54
  },
122
55
  getSpan() {
123
- return current?.span;
56
+ return current?.span ?? trace.getActiveSpan();
124
57
  },
125
58
  };
59
+ function buildRootSpanAttributes(name, metadata) {
60
+ const attributes = {
61
+ 'langfuse.trace.name': name,
62
+ };
63
+ if (metadata.sessionId) {
64
+ attributes['session.id'] = String(metadata.sessionId);
65
+ }
66
+ if (metadata.userId) {
67
+ attributes['user.id'] = String(metadata.userId);
68
+ }
69
+ if (Array.isArray(metadata.tags)) {
70
+ attributes['langfuse.trace.tags'] = metadata.tags;
71
+ }
72
+ if (metadata.input !== undefined) {
73
+ attributes['langfuse.trace.input'] = JSON.stringify(metadata.input);
74
+ }
75
+ return attributes;
76
+ }
@@ -0,0 +1,309 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ // @ts-ignore — package ships a .js re-export without typings for this sub-path
3
+ import * as playwrightUtils from 'playwright-core/lib/utils';
4
+ import { createDebug } from "./utils/logger.js";
5
+ const debugLog = createDebug('explorbot:playwright-recorder');
6
+ const RECORDABLE = {
7
+ Frame: new Set(['click', 'dblclick', 'fill', 'selectOption', 'press', 'type', 'check', 'uncheck', 'hover', 'tap', 'focus', 'setInputFiles', 'scrollIntoViewIfNeeded', 'dragTo', 'goto', 'setContent']),
8
+ Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
9
+ };
10
+ const PLAYWRIGHT_INCOMPATIBLE = "Playwright output is not compatible with this Playwright version (playwright-core/lib/utils does not expose asLocator). Use output.framework: 'codeceptjs' instead, or pin Playwright to a version shipping lib/utils/isomorphic/locatorGenerators.js.";
11
+ function getAsLocator() {
12
+ const fn = playwrightUtils?.asLocator;
13
+ if (typeof fn !== 'function')
14
+ throw new Error(PLAYWRIGHT_INCOMPATIBLE);
15
+ return fn;
16
+ }
17
+ export class PlaywrightRecorder {
18
+ context = null;
19
+ tracing = null;
20
+ active = false;
21
+ nextGroupId = 0;
22
+ verifications = [];
23
+ recordVerification(steps) {
24
+ if (!steps?.length)
25
+ return;
26
+ const seen = new Set(this.verifications.map((s) => `${s.name}:${JSON.stringify(s.args)}`));
27
+ for (const step of steps) {
28
+ const key = `${step.name}:${JSON.stringify(step.args)}`;
29
+ if (seen.has(key))
30
+ continue;
31
+ seen.add(key);
32
+ this.verifications.push(step);
33
+ }
34
+ }
35
+ drainVerifications() {
36
+ const drained = this.verifications;
37
+ this.verifications = [];
38
+ return drained;
39
+ }
40
+ async start(browserContext) {
41
+ if (this.active)
42
+ return;
43
+ if (!browserContext?.tracing) {
44
+ debugLog('start: no tracing on browserContext, recorder inactive');
45
+ return;
46
+ }
47
+ this.context = browserContext;
48
+ this.tracing = browserContext.tracing;
49
+ try {
50
+ await this.tracing.start({});
51
+ this.active = true;
52
+ debugLog('tracing started');
53
+ }
54
+ catch (err) {
55
+ debugLog('tracing.start failed:', err);
56
+ }
57
+ }
58
+ async beginAction(title) {
59
+ if (!this.active)
60
+ return null;
61
+ const safe = title.replace(/\s+/g, ' ').slice(0, 80);
62
+ const groupId = `explorbot#${++this.nextGroupId}:${safe}`;
63
+ try {
64
+ await this.tracing.group(groupId);
65
+ return groupId;
66
+ }
67
+ catch (err) {
68
+ debugLog('tracing.group failed:', err);
69
+ return null;
70
+ }
71
+ }
72
+ async endAction() {
73
+ if (!this.active)
74
+ return;
75
+ try {
76
+ await this.tracing.groupEnd();
77
+ }
78
+ catch (err) {
79
+ debugLog('tracing.groupEnd failed:', err);
80
+ }
81
+ }
82
+ async exportChunk() {
83
+ if (!this.active)
84
+ return new Map();
85
+ const channel = this.tracing._channel;
86
+ if (!channel?.tracingStopChunk || !channel.tracingStartChunk) {
87
+ debugLog('exportChunk: no _channel access, returning empty');
88
+ return new Map();
89
+ }
90
+ let entries = [];
91
+ try {
92
+ const result = await channel.tracingStopChunk({ mode: 'entries' });
93
+ entries = result?.entries || [];
94
+ }
95
+ catch (err) {
96
+ debugLog('tracingStopChunk failed:', err);
97
+ return new Map();
98
+ }
99
+ const traceEntry = entries.find((e) => e.name === 'trace.trace');
100
+ let groups = new Map();
101
+ if (traceEntry) {
102
+ try {
103
+ const ndjson = await readFile(traceEntry.value, 'utf8');
104
+ groups = parseTrace(ndjson);
105
+ }
106
+ catch (err) {
107
+ debugLog('reading trace.trace failed:', err);
108
+ }
109
+ }
110
+ try {
111
+ await channel.tracingStartChunk({});
112
+ }
113
+ catch (err) {
114
+ debugLog('tracingStartChunk failed after export:', err);
115
+ this.active = false;
116
+ }
117
+ return groups;
118
+ }
119
+ async stop() {
120
+ if (!this.active)
121
+ return;
122
+ try {
123
+ await this.tracing.stop({});
124
+ }
125
+ catch (err) {
126
+ debugLog('tracing.stop failed:', err);
127
+ }
128
+ this.active = false;
129
+ }
130
+ isActive() {
131
+ return this.active;
132
+ }
133
+ }
134
+ function parseTrace(ndjson) {
135
+ const befores = new Map();
136
+ const groupTitleByCallId = new Map();
137
+ for (const line of ndjson.split('\n')) {
138
+ if (!line)
139
+ continue;
140
+ let evt;
141
+ try {
142
+ evt = JSON.parse(line);
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ if (evt.type === 'before') {
148
+ befores.set(evt.callId, {
149
+ callId: evt.callId,
150
+ class: evt.class,
151
+ method: evt.method,
152
+ params: evt.params || {},
153
+ parentId: evt.parentId,
154
+ title: evt.title,
155
+ failed: false,
156
+ });
157
+ if (evt.class === 'Tracing' && evt.method === 'tracingGroup' && typeof evt.title === 'string') {
158
+ groupTitleByCallId.set(evt.callId, evt.title);
159
+ }
160
+ continue;
161
+ }
162
+ if (evt.type === 'after') {
163
+ const rec = befores.get(evt.callId);
164
+ if (rec && evt.error)
165
+ rec.failed = true;
166
+ }
167
+ }
168
+ const groups = new Map();
169
+ for (const title of groupTitleByCallId.values()) {
170
+ groups.set(title, []);
171
+ }
172
+ for (const rec of befores.values()) {
173
+ if (rec.failed)
174
+ continue;
175
+ if (rec.class === 'Tracing')
176
+ continue;
177
+ if (!rec.parentId)
178
+ continue;
179
+ const groupTitle = groupTitleByCallId.get(rec.parentId);
180
+ if (!groupTitle)
181
+ continue;
182
+ const allowed = RECORDABLE[rec.class];
183
+ if (!allowed?.has(rec.method))
184
+ continue;
185
+ groups.get(groupTitle).push({ class: rec.class, method: rec.method, params: rec.params });
186
+ }
187
+ return groups;
188
+ }
189
+ export function renderCall(call) {
190
+ const asLocator = getAsLocator();
191
+ const { class: cls, method, params } = call;
192
+ if (cls === 'Frame') {
193
+ if (method === 'goto')
194
+ return `await page.goto(${quote(params.url)});`;
195
+ if (method === 'setContent')
196
+ return `await page.setContent(${quote(params.html)});`;
197
+ const locator = `page.${asLocator('javascript', params.selector || '')}`;
198
+ if (method === 'click')
199
+ return `await ${locator}.click();`;
200
+ if (method === 'dblclick')
201
+ return `await ${locator}.dblclick();`;
202
+ if (method === 'fill')
203
+ return `await ${locator}.fill(${quote(params.value ?? '')});`;
204
+ if (method === 'press')
205
+ return `await ${locator}.press(${quote(params.key ?? '')});`;
206
+ if (method === 'type')
207
+ return `await ${locator}.type(${quote(params.text ?? '')});`;
208
+ if (method === 'check')
209
+ return `await ${locator}.check();`;
210
+ if (method === 'uncheck')
211
+ return `await ${locator}.uncheck();`;
212
+ if (method === 'hover')
213
+ return `await ${locator}.hover();`;
214
+ if (method === 'tap')
215
+ return `await ${locator}.tap();`;
216
+ if (method === 'focus')
217
+ return `await ${locator}.focus();`;
218
+ if (method === 'scrollIntoViewIfNeeded')
219
+ return `await ${locator}.scrollIntoViewIfNeeded();`;
220
+ if (method === 'setInputFiles')
221
+ return `await ${locator}.setInputFiles(${formatFiles(params.localPaths ?? params.files)});`;
222
+ if (method === 'selectOption')
223
+ return `await ${locator}.selectOption(${formatSelectOption(params.options)});`;
224
+ if (method === 'dragTo')
225
+ return `await ${locator}.dragTo(page.locator(${quote(params.targetSelector ?? '')}));`;
226
+ }
227
+ if (cls === 'Page') {
228
+ if (method === 'goBack')
229
+ return 'await page.goBack();';
230
+ if (method === 'goForward')
231
+ return 'await page.goForward();';
232
+ if (method === 'reload')
233
+ return 'await page.reload();';
234
+ if (method === 'keyboardPress')
235
+ return `await page.keyboard.press(${quote(params.key ?? '')});`;
236
+ if (method === 'keyboardType')
237
+ return `await page.keyboard.type(${quote(params.text ?? '')});`;
238
+ if (method === 'keyboardDown')
239
+ return `await page.keyboard.down(${quote(params.key ?? '')});`;
240
+ if (method === 'keyboardUp')
241
+ return `await page.keyboard.up(${quote(params.key ?? '')});`;
242
+ if (method === 'keyboardInsertText')
243
+ return `await page.keyboard.insertText(${quote(params.text ?? '')});`;
244
+ if (method === 'mouseClick')
245
+ return `await page.mouse.click(${params.x ?? 0}, ${params.y ?? 0});`;
246
+ if (method === 'mouseDblclick')
247
+ return `await page.mouse.dblclick(${params.x ?? 0}, ${params.y ?? 0});`;
248
+ if (method === 'mouseMove')
249
+ return `await page.mouse.move(${params.x ?? 0}, ${params.y ?? 0});`;
250
+ if (method === 'mouseDown')
251
+ return 'await page.mouse.down();';
252
+ if (method === 'mouseUp')
253
+ return 'await page.mouse.up();';
254
+ if (method === 'mouseWheel')
255
+ return `await page.mouse.wheel(${params.deltaX ?? 0}, ${params.deltaY ?? 0});`;
256
+ }
257
+ return `// TODO(playwright): ${cls}.${method}(${JSON.stringify(params)})`;
258
+ }
259
+ function quote(value) {
260
+ return JSON.stringify(String(value ?? ''));
261
+ }
262
+ function formatFiles(files) {
263
+ if (!files)
264
+ return '[]';
265
+ if (Array.isArray(files)) {
266
+ if (files.length === 1)
267
+ return quote(files[0]);
268
+ return `[${files.map((f) => quote(f)).join(', ')}]`;
269
+ }
270
+ return quote(files);
271
+ }
272
+ function formatSelectOption(options) {
273
+ if (!options)
274
+ return `''`;
275
+ const list = Array.isArray(options) ? options : [options];
276
+ const values = list.map((o) => o?.valueOrLabel ?? o?.value ?? o?.label ?? '');
277
+ if (values.length === 1)
278
+ return quote(values[0]);
279
+ return `[${values.map((v) => quote(v)).join(', ')}]`;
280
+ }
281
+ export function renderAssertion(assertion) {
282
+ const args = assertion.args;
283
+ if (assertion.name === 'see' && typeof args[0] === 'string') {
284
+ return `await expect(page).toContainText(${JSON.stringify(args[0])});`;
285
+ }
286
+ if (assertion.name === 'dontSee' && typeof args[0] === 'string') {
287
+ return `await expect(page).not.toContainText(${JSON.stringify(args[0])});`;
288
+ }
289
+ if (assertion.name === 'seeElement' && typeof args[0] === 'string') {
290
+ return `await expect(page.locator(${JSON.stringify(args[0])})).toBeVisible();`;
291
+ }
292
+ if (assertion.name === 'dontSeeElement' && typeof args[0] === 'string') {
293
+ return `await expect(page.locator(${JSON.stringify(args[0])})).toBeHidden();`;
294
+ }
295
+ if (assertion.name === 'seeInField' && typeof args[0] === 'string' && args[1] !== undefined) {
296
+ return `await expect(page.locator(${JSON.stringify(args[0])})).toHaveValue(${JSON.stringify(String(args[1]))});`;
297
+ }
298
+ if (assertion.name === 'dontSeeInField' && typeof args[0] === 'string' && args[1] !== undefined) {
299
+ return `await expect(page.locator(${JSON.stringify(args[0])})).not.toHaveValue(${JSON.stringify(String(args[1]))});`;
300
+ }
301
+ if (assertion.name === 'seeInCurrentUrl' && typeof args[0] === 'string') {
302
+ return `await expect(page).toHaveURL(new RegExp(${JSON.stringify(args[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))}));`;
303
+ }
304
+ if (assertion.name === 'dontSeeInCurrentUrl' && typeof args[0] === 'string') {
305
+ return `await expect(page).not.toHaveURL(new RegExp(${JSON.stringify(args[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))}));`;
306
+ }
307
+ return `// TODO(playwright): ${assertion.name}(${assertion.args.map((a) => JSON.stringify(a)).join(', ')})`;
308
+ }
309
+ export { parseTrace };
@@ -78,6 +78,7 @@ export class Reporter {
78
78
  const noteEntries = Object.entries(test.notes)
79
79
  .map(([timestampKey, note]) => ({
80
80
  startTime: note.startTime,
81
+ endTime: note.endTime,
81
82
  message: note.message,
82
83
  status: note.status,
83
84
  screenshot: note.screenshot,
@@ -105,7 +106,7 @@ export class Reporter {
105
106
  const step = {
106
107
  category: 'user',
107
108
  title: noteEntry.message,
108
- duration: 0,
109
+ duration: Math.max(0, Math.round(noteEntry.endTime - noteEntry.startTime)),
109
110
  status: noteEntry.status || 'none',
110
111
  steps: noteSteps.length > 0 ? noteSteps : undefined,
111
112
  };
@@ -148,6 +149,7 @@ export class Reporter {
148
149
  meta = Object.fromEntries(Object.entries(meta).filter(([, v]) => v));
149
150
  }
150
151
  const steps = this.combineStepsAndNotes(test, screenshotFile);
152
+ const durationMs = test.getDurationMs();
151
153
  const testData = {
152
154
  rid: test.id,
153
155
  title: test.scenario,
@@ -162,6 +164,7 @@ export class Reporter {
162
164
  files: Object.values(test.artifacts) || [],
163
165
  message: test.summary || this.extractLastNoteMessage(test) || '',
164
166
  meta,
167
+ time: durationMs != null ? Math.round(durationMs) : 0,
165
168
  };
166
169
  debugLog(testData);
167
170
  await this.client.addTestRun(status, testData);
@@ -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,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
- 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';
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
+ }