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
package/src/explorbot.ts CHANGED
@@ -53,6 +53,7 @@ export class ExplorBot {
53
53
  private currentPlan?: Plan;
54
54
  private planFeature?: string;
55
55
  lastPlanError: Error | null = null;
56
+ lastSavedPlanPath: string | null = null;
56
57
  private agents: Record<string, any> = {};
57
58
 
58
59
  constructor(options: ExplorBotOptions = {}) {
@@ -258,10 +259,13 @@ export class ExplorBot {
258
259
  }
259
260
 
260
261
  agentHistorian(): Historian {
261
- return (this.agents.historian ||= this.createAgent(({ ai, explorer }) => {
262
+ return (this.agents.historian ||= this.createAgent(({ ai, explorer, config }) => {
262
263
  const experienceTracker = explorer.getStateManager().getExperienceTracker();
263
264
  const reporter = explorer.getReporter();
264
- return new Historian(ai, experienceTracker, reporter, explorer.getStateManager());
265
+ return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, {
266
+ recorder: explorer.getPlaywrightRecorder(),
267
+ helper: explorer.playwrightHelper,
268
+ });
265
269
  }));
266
270
  }
267
271
 
@@ -369,12 +373,7 @@ export class ExplorBot {
369
373
  return this.currentPlan;
370
374
  }
371
375
 
372
- const savedPath = this.savePlan();
373
- if (savedPath) {
374
- const relativePath = path.relative(process.cwd(), savedPath);
375
- tag('info').log(`Plan saved to: ${relativePath}`);
376
- tag('info').log(`Edit the plan file and run /plan:load ${relativePath} to reload it`);
377
- }
376
+ this.savePlan();
378
377
 
379
378
  return this.currentPlan;
380
379
  }
@@ -400,6 +399,7 @@ export class ExplorBot {
400
399
  const planFilename = filename || this.generatePlanFilename();
401
400
  const planPath = path.join(plansDir, planFilename);
402
401
  Plan.saveMultipleToMarkdown(plans, planPath);
402
+ this.lastSavedPlanPath = planPath;
403
403
  return planPath;
404
404
  }
405
405
 
package/src/explorer.ts CHANGED
@@ -15,6 +15,7 @@ import type { ExplorbotConfig } from './config.js';
15
15
  import { ConfigParser, outputPath } from './config.js';
16
16
  import type { UserResolveFunction } from './explorbot.js';
17
17
  import { KnowledgeTracker } from './knowledge-tracker.js';
18
+ import { PlaywrightRecorder } from './playwright-recorder.ts';
18
19
  import { Reporter } from './reporter.ts';
19
20
  import { StateManager } from './state-manager.js';
20
21
  import { Test } from './test-plan.ts';
@@ -60,6 +61,7 @@ class Explorer {
60
61
  private _activeTest: Test | null = null;
61
62
  private xhrCapture: XhrCapture | null = null;
62
63
  private requestStore: RequestStore | null = null;
64
+ private playwrightRecorder: PlaywrightRecorder = new PlaywrightRecorder();
63
65
 
64
66
  constructor(config: ExplorbotConfig, aiProvider: AIProvider, options?: { show?: boolean; headless?: boolean; incognito?: boolean; session?: string }) {
65
67
  this.config = config;
@@ -123,7 +125,7 @@ class Explorer {
123
125
  tag('substep').log(debugInfo);
124
126
  }
125
127
  const PlaywrightConfig = {
126
- timeout: 1000,
128
+ timeout: 3000,
127
129
  highlightElement: true,
128
130
  waitForAction: 500,
129
131
  ...playwrightConfig,
@@ -237,6 +239,7 @@ class Explorer {
237
239
  const hasSession = this.options?.session && existsSync(this.options.session);
238
240
  const contextOptions = hasSession ? { storageState: this.options!.session } : undefined;
239
241
  await this.playwrightHelper._createContextPage(contextOptions);
242
+ await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
240
243
  this.setupXhrCapture();
241
244
  if (hasSession) {
242
245
  tag('info').log(`Session restored from ${path.relative(process.cwd(), this.options!.session!)}`);
@@ -273,7 +276,11 @@ class Explorer {
273
276
  }
274
277
 
275
278
  createAction() {
276
- return new Action(this.actor, this.stateManager);
279
+ return new Action(this.actor, this.stateManager, this.playwrightRecorder);
280
+ }
281
+
282
+ getPlaywrightRecorder(): PlaywrightRecorder {
283
+ return this.playwrightRecorder;
277
284
  }
278
285
 
279
286
  async visit(url: string) {
@@ -489,6 +496,8 @@ class Explorer {
489
496
  this.xhrCapture.detach(this.playwrightHelper.page);
490
497
  }
491
498
 
499
+ await this.playwrightRecorder.stop();
500
+
492
501
  if (this.options?.session && this.playwrightHelper?.browserContext) {
493
502
  const dir = path.dirname(this.options.session);
494
503
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
@@ -705,6 +714,7 @@ function toCodeceptjsTest(test: Test): any {
705
714
  codeceptjsTest.fullTitle = () => `${parent.title} ${test.scenario}`;
706
715
  codeceptjsTest.state = 'pending';
707
716
  codeceptjsTest.notes = test.getPrintableNotes();
717
+ codeceptjsTest._explorbotTest = test;
708
718
  return codeceptjsTest;
709
719
  }
710
720
 
@@ -724,7 +734,7 @@ function parseAriaRefs(ariaSnapshot: string): Array<{ role: string; name: string
724
734
  }
725
735
 
726
736
  export async function annotatePageElements(page: any): Promise<{ ariaSnapshot: string; elements: WebElement[] }> {
727
- const ariaSnapshot: string = await page.locator('body').ariaSnapshot({ forAI: true });
737
+ const ariaSnapshot: string = await page.locator('body').ariaSnapshot({ mode: 'ai' });
728
738
  const refEntries = parseAriaRefs(ariaSnapshot);
729
739
 
730
740
  const byRole = new Map<string, Array<{ name: string; ref: string }>>();
@@ -1,121 +1,46 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { type Span, context, trace } from '@opentelemetry/api';
1
+ import { type Span, trace } from '@opentelemetry/api';
3
2
 
4
3
  type TelemetryMetadata = Record<string, unknown>;
5
4
 
6
5
  type TelemetryState = {
7
6
  metadata: TelemetryMetadata;
8
- traceId: string;
9
- updateParent: boolean;
10
7
  name: string;
11
8
  span?: Span;
12
9
  };
13
10
 
14
11
  let current: TelemetryState | null = null;
15
- let depth = 0;
16
12
 
17
13
  export const Observability = {
18
14
  async run<T>(name: string, metadata: TelemetryMetadata, fn: () => Promise<T>): Promise<T> {
19
- const started = Observability.startTrace(name, metadata);
15
+ const tracer = trace.getTracer('ai');
20
16
 
21
- try {
22
- if (!started) {
23
- const parentSpan = current?.span;
24
- if (!parentSpan || !current) return await fn();
25
-
26
- const tracer = trace.getTracer('ai');
27
- const childSpan = tracer.startSpan(name, undefined, trace.setSpan(context.active(), parentSpan));
28
- const savedSpan = current.span;
29
- const savedName = current.name;
30
- current.span = childSpan;
31
- current.name = name;
32
- return await context.with(trace.setSpan(context.active(), childSpan), async () => {
33
- try {
34
- return await fn();
35
- } finally {
36
- childSpan.end();
37
- current!.span = savedSpan;
38
- current!.name = savedName;
39
- }
40
- });
41
- }
42
-
43
- const tracer = trace.getTracer('ai');
44
- const spanContext = {
45
- traceId: current?.traceId || randomBytes(16).toString('hex'),
46
- spanId: randomBytes(8).toString('hex'),
47
- traceFlags: 1,
48
- };
49
- const rootContext = trace.setSpanContext(context.active(), spanContext);
50
-
51
- const initSpan = tracer.startSpan(name, undefined, rootContext);
52
- initSpan.setAttribute('langfuse.trace.name', name);
53
- initSpan.setAttribute('langfuse.trace.id', current?.traceId || '');
54
- if (current?.metadata?.sessionId) {
55
- initSpan.setAttribute('langfuse.trace.session_id', String(current.metadata.sessionId));
56
- }
57
- if (current?.metadata?.userId) {
58
- initSpan.setAttribute('langfuse.trace.user_id', String(current.metadata.userId));
59
- }
60
- if (current?.metadata?.tags && Array.isArray(current.metadata.tags)) {
61
- initSpan.setAttribute('langfuse.trace.tags', current.metadata.tags);
62
- }
63
- if (current?.metadata?.input) {
64
- initSpan.setAttribute('langfuse.trace.input', JSON.stringify(current.metadata.input));
65
- }
66
- initSpan.end();
67
-
68
- const span = tracer.startSpan(name, undefined, rootContext);
69
- current.span = span;
70
-
71
- return await context.with(trace.setSpan(rootContext, span), async () => {
17
+ if (current) {
18
+ return await tracer.startActiveSpan(name, {}, async (span) => {
19
+ const saved = current!;
20
+ current = {
21
+ metadata: { ...saved.metadata, ...metadata },
22
+ name,
23
+ span,
24
+ };
72
25
  try {
73
26
  return await fn();
74
27
  } finally {
75
28
  span.end();
76
- current.span = undefined;
29
+ current = saved;
77
30
  }
78
31
  });
79
- } finally {
80
- Observability.endTrace(started);
81
- }
82
- },
83
-
84
- startTrace(name: string, metadata: TelemetryMetadata) {
85
- if (current) {
86
- depth += 1;
87
- return false;
88
32
  }
89
33
 
90
- const langfuseTraceId = metadata.langfuseTraceId || randomBytes(16).toString('hex');
91
- current = {
92
- metadata: {
93
- ...metadata,
94
- langfuseTraceId,
95
- },
96
- traceId: langfuseTraceId,
97
- updateParent: true,
98
- name,
99
- };
100
- depth = 1;
101
- return true;
102
- },
103
-
104
- endTrace(started: boolean) {
105
- if (!current) {
106
- return;
107
- }
108
-
109
- if (!started) {
110
- depth -= 1;
111
- return;
112
- }
113
-
114
- depth -= 1;
115
- if (depth <= 0) {
116
- current = null;
117
- depth = 0;
118
- }
34
+ const attributes = buildRootSpanAttributes(name, metadata);
35
+ return await tracer.startActiveSpan(name, { attributes }, async (span) => {
36
+ current = { metadata, name, span };
37
+ try {
38
+ return await fn();
39
+ } finally {
40
+ span.end();
41
+ current = null;
42
+ }
43
+ });
119
44
  },
120
45
 
121
46
  getTelemetry() {
@@ -123,21 +48,16 @@ export const Observability = {
123
48
  return undefined;
124
49
  }
125
50
 
126
- const telemetry = {
51
+ const metadata: Record<string, unknown> = {};
52
+ if (current.metadata.sessionId) metadata.sessionId = current.metadata.sessionId;
53
+ if (current.metadata.userId) metadata.userId = current.metadata.userId;
54
+ if (Array.isArray(current.metadata.tags)) metadata.tags = current.metadata.tags;
55
+
56
+ return {
127
57
  isEnabled: true,
128
58
  functionId: current.name,
129
- metadata: {
130
- ...current.metadata,
131
- langfuseTraceId: current.traceId,
132
- langfuseUpdateParent: current.updateParent,
133
- },
59
+ metadata,
134
60
  };
135
-
136
- if (current.updateParent) {
137
- current.updateParent = false;
138
- }
139
-
140
- return telemetry;
141
61
  },
142
62
 
143
63
  isTracing() {
@@ -145,6 +65,27 @@ export const Observability = {
145
65
  },
146
66
 
147
67
  getSpan() {
148
- return current?.span;
68
+ return current?.span ?? trace.getActiveSpan();
149
69
  },
150
70
  };
71
+
72
+ function buildRootSpanAttributes(name: string, metadata: TelemetryMetadata): Record<string, any> {
73
+ const attributes: Record<string, any> = {
74
+ 'langfuse.trace.name': name,
75
+ };
76
+
77
+ if (metadata.sessionId) {
78
+ attributes['session.id'] = String(metadata.sessionId);
79
+ }
80
+ if (metadata.userId) {
81
+ attributes['user.id'] = String(metadata.userId);
82
+ }
83
+ if (Array.isArray(metadata.tags)) {
84
+ attributes['langfuse.trace.tags'] = metadata.tags as string[];
85
+ }
86
+ if (metadata.input !== undefined) {
87
+ attributes['langfuse.trace.input'] = JSON.stringify(metadata.input);
88
+ }
89
+
90
+ return attributes;
91
+ }
@@ -0,0 +1,305 @@
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.ts';
5
+
6
+ const debugLog = createDebug('explorbot:playwright-recorder');
7
+
8
+ const RECORDABLE: Record<string, Set<string>> = {
9
+ Frame: new Set(['click', 'dblclick', 'fill', 'selectOption', 'press', 'type', 'check', 'uncheck', 'hover', 'tap', 'focus', 'setInputFiles', 'scrollIntoViewIfNeeded', 'dragTo', 'goto', 'setContent']),
10
+ Page: new Set(['goBack', 'goForward', 'reload', 'keyboardPress', 'keyboardType', 'keyboardDown', 'keyboardUp', 'keyboardInsertText', 'mouseClick', 'mouseDblclick', 'mouseMove', 'mouseDown', 'mouseUp', 'mouseWheel']),
11
+ };
12
+
13
+ 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.";
14
+
15
+ function getAsLocator(): (lang: string, selector: string) => string {
16
+ const fn = (playwrightUtils as any)?.asLocator;
17
+ if (typeof fn !== 'function') throw new Error(PLAYWRIGHT_INCOMPATIBLE);
18
+ return fn;
19
+ }
20
+
21
+ export interface TraceCall {
22
+ class: string;
23
+ method: string;
24
+ params: Record<string, any>;
25
+ }
26
+
27
+ export interface VerificationStep {
28
+ name: string;
29
+ args: any[];
30
+ }
31
+
32
+ export class PlaywrightRecorder {
33
+ private context: any = null;
34
+ private tracing: any = null;
35
+ private active = false;
36
+ private nextGroupId = 0;
37
+ private verifications: VerificationStep[] = [];
38
+
39
+ recordVerification(steps: VerificationStep[]): void {
40
+ if (!steps?.length) return;
41
+ const seen = new Set(this.verifications.map((s) => `${s.name}:${JSON.stringify(s.args)}`));
42
+ for (const step of steps) {
43
+ const key = `${step.name}:${JSON.stringify(step.args)}`;
44
+ if (seen.has(key)) continue;
45
+ seen.add(key);
46
+ this.verifications.push(step);
47
+ }
48
+ }
49
+
50
+ drainVerifications(): VerificationStep[] {
51
+ const drained = this.verifications;
52
+ this.verifications = [];
53
+ return drained;
54
+ }
55
+
56
+ async start(browserContext: any): Promise<void> {
57
+ if (this.active) return;
58
+ if (!browserContext?.tracing) {
59
+ debugLog('start: no tracing on browserContext, recorder inactive');
60
+ return;
61
+ }
62
+ this.context = browserContext;
63
+ this.tracing = browserContext.tracing;
64
+ try {
65
+ await this.tracing.start({});
66
+ this.active = true;
67
+ debugLog('tracing started');
68
+ } catch (err) {
69
+ debugLog('tracing.start failed:', err);
70
+ }
71
+ }
72
+
73
+ async beginAction(title: string): Promise<string | null> {
74
+ if (!this.active) return null;
75
+ const safe = title.replace(/\s+/g, ' ').slice(0, 80);
76
+ const groupId = `explorbot#${++this.nextGroupId}:${safe}`;
77
+ try {
78
+ await this.tracing.group(groupId);
79
+ return groupId;
80
+ } catch (err) {
81
+ debugLog('tracing.group failed:', err);
82
+ return null;
83
+ }
84
+ }
85
+
86
+ async endAction(): Promise<void> {
87
+ if (!this.active) return;
88
+ try {
89
+ await this.tracing.groupEnd();
90
+ } catch (err) {
91
+ debugLog('tracing.groupEnd failed:', err);
92
+ }
93
+ }
94
+
95
+ async exportChunk(): Promise<Map<string, TraceCall[]>> {
96
+ if (!this.active) return new Map();
97
+ const channel = this.tracing._channel;
98
+ if (!channel?.tracingStopChunk || !channel.tracingStartChunk) {
99
+ debugLog('exportChunk: no _channel access, returning empty');
100
+ return new Map();
101
+ }
102
+
103
+ let entries: Array<{ name: string; value: string }> = [];
104
+ try {
105
+ const result = await channel.tracingStopChunk({ mode: 'entries' });
106
+ entries = result?.entries || [];
107
+ } catch (err) {
108
+ debugLog('tracingStopChunk failed:', err);
109
+ return new Map();
110
+ }
111
+
112
+ const traceEntry = entries.find((e) => e.name === 'trace.trace');
113
+
114
+ let groups = new Map<string, TraceCall[]>();
115
+ if (traceEntry) {
116
+ try {
117
+ const ndjson = await readFile(traceEntry.value, 'utf8');
118
+ groups = parseTrace(ndjson);
119
+ } catch (err) {
120
+ debugLog('reading trace.trace failed:', err);
121
+ }
122
+ }
123
+
124
+ try {
125
+ await channel.tracingStartChunk({});
126
+ } catch (err) {
127
+ debugLog('tracingStartChunk failed after export:', err);
128
+ this.active = false;
129
+ }
130
+
131
+ return groups;
132
+ }
133
+
134
+ async stop(): Promise<void> {
135
+ if (!this.active) return;
136
+ try {
137
+ await this.tracing.stop({});
138
+ } catch (err) {
139
+ debugLog('tracing.stop failed:', err);
140
+ }
141
+ this.active = false;
142
+ }
143
+
144
+ isActive(): boolean {
145
+ return this.active;
146
+ }
147
+ }
148
+
149
+ interface ParsedBefore {
150
+ callId: string;
151
+ class: string;
152
+ method: string;
153
+ params: Record<string, any>;
154
+ parentId?: string;
155
+ title?: string;
156
+ failed: boolean;
157
+ }
158
+
159
+ function parseTrace(ndjson: string): Map<string, TraceCall[]> {
160
+ const befores = new Map<string, ParsedBefore>();
161
+ const groupTitleByCallId = new Map<string, string>();
162
+
163
+ for (const line of ndjson.split('\n')) {
164
+ if (!line) continue;
165
+ let evt: any;
166
+ try {
167
+ evt = JSON.parse(line);
168
+ } catch {
169
+ continue;
170
+ }
171
+ if (evt.type === 'before') {
172
+ befores.set(evt.callId, {
173
+ callId: evt.callId,
174
+ class: evt.class,
175
+ method: evt.method,
176
+ params: evt.params || {},
177
+ parentId: evt.parentId,
178
+ title: evt.title,
179
+ failed: false,
180
+ });
181
+ if (evt.class === 'Tracing' && evt.method === 'tracingGroup' && typeof evt.title === 'string') {
182
+ groupTitleByCallId.set(evt.callId, evt.title);
183
+ }
184
+ continue;
185
+ }
186
+ if (evt.type === 'after') {
187
+ const rec = befores.get(evt.callId);
188
+ if (rec && evt.error) rec.failed = true;
189
+ }
190
+ }
191
+
192
+ const groups = new Map<string, TraceCall[]>();
193
+ for (const title of groupTitleByCallId.values()) {
194
+ groups.set(title, []);
195
+ }
196
+
197
+ for (const rec of befores.values()) {
198
+ if (rec.failed) continue;
199
+ if (rec.class === 'Tracing') continue;
200
+ if (!rec.parentId) continue;
201
+ const groupTitle = groupTitleByCallId.get(rec.parentId);
202
+ if (!groupTitle) continue;
203
+ const allowed = RECORDABLE[rec.class];
204
+ if (!allowed?.has(rec.method)) continue;
205
+ groups.get(groupTitle)!.push({ class: rec.class, method: rec.method, params: rec.params });
206
+ }
207
+
208
+ return groups;
209
+ }
210
+
211
+ export function renderCall(call: TraceCall): string {
212
+ const asLocator = getAsLocator();
213
+ const { class: cls, method, params } = call;
214
+
215
+ if (cls === 'Frame') {
216
+ if (method === 'goto') return `await page.goto(${quote(params.url)});`;
217
+ if (method === 'setContent') return `await page.setContent(${quote(params.html)});`;
218
+ const locator = `page.${asLocator('javascript', params.selector || '')}`;
219
+ if (method === 'click') return `await ${locator}.click();`;
220
+ if (method === 'dblclick') return `await ${locator}.dblclick();`;
221
+ if (method === 'fill') return `await ${locator}.fill(${quote(params.value ?? '')});`;
222
+ if (method === 'press') return `await ${locator}.press(${quote(params.key ?? '')});`;
223
+ if (method === 'type') return `await ${locator}.type(${quote(params.text ?? '')});`;
224
+ if (method === 'check') return `await ${locator}.check();`;
225
+ if (method === 'uncheck') return `await ${locator}.uncheck();`;
226
+ if (method === 'hover') return `await ${locator}.hover();`;
227
+ if (method === 'tap') return `await ${locator}.tap();`;
228
+ if (method === 'focus') return `await ${locator}.focus();`;
229
+ if (method === 'scrollIntoViewIfNeeded') return `await ${locator}.scrollIntoViewIfNeeded();`;
230
+ if (method === 'setInputFiles') return `await ${locator}.setInputFiles(${formatFiles(params.localPaths ?? params.files)});`;
231
+ if (method === 'selectOption') return `await ${locator}.selectOption(${formatSelectOption(params.options)});`;
232
+ if (method === 'dragTo') return `await ${locator}.dragTo(page.locator(${quote(params.targetSelector ?? '')}));`;
233
+ }
234
+
235
+ if (cls === 'Page') {
236
+ if (method === 'goBack') return 'await page.goBack();';
237
+ if (method === 'goForward') return 'await page.goForward();';
238
+ if (method === 'reload') return 'await page.reload();';
239
+ if (method === 'keyboardPress') return `await page.keyboard.press(${quote(params.key ?? '')});`;
240
+ if (method === 'keyboardType') return `await page.keyboard.type(${quote(params.text ?? '')});`;
241
+ if (method === 'keyboardDown') return `await page.keyboard.down(${quote(params.key ?? '')});`;
242
+ if (method === 'keyboardUp') return `await page.keyboard.up(${quote(params.key ?? '')});`;
243
+ if (method === 'keyboardInsertText') return `await page.keyboard.insertText(${quote(params.text ?? '')});`;
244
+ if (method === 'mouseClick') return `await page.mouse.click(${params.x ?? 0}, ${params.y ?? 0});`;
245
+ if (method === 'mouseDblclick') return `await page.mouse.dblclick(${params.x ?? 0}, ${params.y ?? 0});`;
246
+ if (method === 'mouseMove') return `await page.mouse.move(${params.x ?? 0}, ${params.y ?? 0});`;
247
+ if (method === 'mouseDown') return 'await page.mouse.down();';
248
+ if (method === 'mouseUp') return 'await page.mouse.up();';
249
+ if (method === 'mouseWheel') return `await page.mouse.wheel(${params.deltaX ?? 0}, ${params.deltaY ?? 0});`;
250
+ }
251
+
252
+ return `// TODO(playwright): ${cls}.${method}(${JSON.stringify(params)})`;
253
+ }
254
+
255
+ function quote(value: any): string {
256
+ return JSON.stringify(String(value ?? ''));
257
+ }
258
+
259
+ function formatFiles(files: any): string {
260
+ if (!files) return '[]';
261
+ if (Array.isArray(files)) {
262
+ if (files.length === 1) return quote(files[0]);
263
+ return `[${files.map((f) => quote(f)).join(', ')}]`;
264
+ }
265
+ return quote(files);
266
+ }
267
+
268
+ function formatSelectOption(options: any): string {
269
+ if (!options) return `''`;
270
+ const list = Array.isArray(options) ? options : [options];
271
+ const values = list.map((o) => o?.valueOrLabel ?? o?.value ?? o?.label ?? '');
272
+ if (values.length === 1) return quote(values[0]);
273
+ return `[${values.map((v) => quote(v)).join(', ')}]`;
274
+ }
275
+
276
+ export function renderAssertion(assertion: { name: string; args: any[] }): string {
277
+ const args = assertion.args;
278
+ if (assertion.name === 'see' && typeof args[0] === 'string') {
279
+ return `await expect(page).toContainText(${JSON.stringify(args[0])});`;
280
+ }
281
+ if (assertion.name === 'dontSee' && typeof args[0] === 'string') {
282
+ return `await expect(page).not.toContainText(${JSON.stringify(args[0])});`;
283
+ }
284
+ if (assertion.name === 'seeElement' && typeof args[0] === 'string') {
285
+ return `await expect(page.locator(${JSON.stringify(args[0])})).toBeVisible();`;
286
+ }
287
+ if (assertion.name === 'dontSeeElement' && typeof args[0] === 'string') {
288
+ return `await expect(page.locator(${JSON.stringify(args[0])})).toBeHidden();`;
289
+ }
290
+ if (assertion.name === 'seeInField' && typeof args[0] === 'string' && args[1] !== undefined) {
291
+ return `await expect(page.locator(${JSON.stringify(args[0])})).toHaveValue(${JSON.stringify(String(args[1]))});`;
292
+ }
293
+ if (assertion.name === 'dontSeeInField' && typeof args[0] === 'string' && args[1] !== undefined) {
294
+ return `await expect(page.locator(${JSON.stringify(args[0])})).not.toHaveValue(${JSON.stringify(String(args[1]))});`;
295
+ }
296
+ if (assertion.name === 'seeInCurrentUrl' && typeof args[0] === 'string') {
297
+ return `await expect(page).toHaveURL(new RegExp(${JSON.stringify(args[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))}));`;
298
+ }
299
+ if (assertion.name === 'dontSeeInCurrentUrl' && typeof args[0] === 'string') {
300
+ return `await expect(page).not.toHaveURL(new RegExp(${JSON.stringify(args[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))}));`;
301
+ }
302
+ return `// TODO(playwright): ${assertion.name}(${assertion.args.map((a) => JSON.stringify(a)).join(', ')})`;
303
+ }
304
+
305
+ export { parseTrace };
package/src/reporter.ts CHANGED
@@ -104,6 +104,7 @@ export class Reporter {
104
104
  const noteEntries = Object.entries(test.notes)
105
105
  .map(([timestampKey, note]) => ({
106
106
  startTime: note.startTime,
107
+ endTime: note.endTime,
107
108
  message: note.message,
108
109
  status: note.status,
109
110
  screenshot: note.screenshot,
@@ -135,7 +136,7 @@ export class Reporter {
135
136
  const step: Step = {
136
137
  category: 'user',
137
138
  title: noteEntry.message,
138
- duration: 0,
139
+ duration: Math.max(0, Math.round(noteEntry.endTime - noteEntry.startTime)),
139
140
  status: noteEntry.status || 'none',
140
141
  steps: noteSteps.length > 0 ? noteSteps : undefined,
141
142
  };
@@ -182,6 +183,7 @@ export class Reporter {
182
183
  }
183
184
 
184
185
  const steps = this.combineStepsAndNotes(test, screenshotFile);
186
+ const durationMs = test.getDurationMs();
185
187
 
186
188
  const testData = {
187
189
  rid: test.id,
@@ -197,6 +199,7 @@ export class Reporter {
197
199
  files: Object.values(test.artifacts) || [],
198
200
  message: test.summary || this.extractLastNoteMessage(test) || '',
199
201
  meta,
202
+ time: durationMs != null ? Math.round(durationMs) : 0,
200
203
  };
201
204
 
202
205
  debugLog(testData);
package/src/test-plan.ts CHANGED
@@ -160,6 +160,11 @@ export class Task {
160
160
  .map((item) => `${item.type === 'step' ? ' ' : ''}${item.content}`)
161
161
  .join('\n');
162
162
  }
163
+
164
+ getRunResult(): 'success' | 'partial' | 'failed' {
165
+ const hasPassedNotes = Object.values(this.notes).some((n) => n.status === TestResult.PASSED);
166
+ return hasPassedNotes ? 'partial' : 'failed';
167
+ }
163
168
  }
164
169
 
165
170
  export class Test extends Task {
@@ -179,6 +184,7 @@ export class Test extends Task {
179
184
  enabled = true;
180
185
  startTime?: number;
181
186
  endTime?: number;
187
+ resetCount = 0;
182
188
 
183
189
  constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps: string[] = []) {
184
190
  super(scenario, startUrl);
@@ -238,6 +244,12 @@ export class Test extends Task {
238
244
  });
239
245
  }
240
246
 
247
+ getRunResult(): 'success' | 'partial' | 'failed' {
248
+ if (this.isSuccessful) return 'success';
249
+ if (this.hasAchievedAny()) return 'partial';
250
+ return super.getRunResult();
251
+ }
252
+
241
253
  hasAchievedAll(): boolean {
242
254
  return this.expected.every((expectation) => {
243
255
  return Object.values(this.notes).some((note) => note.message === expectation && note.status === TestResult.PASSED);