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,320 @@
1
+ import dedent from 'dedent';
2
+ import { z } from 'zod';
3
+ import { ActionResult } from "../../action-result.js";
4
+ import { Test } from "../../test-plan.js";
5
+ import { tag } from "../../utils/logger.js";
6
+ import { extractStatePath } from "../../utils/url-matcher.js";
7
+ import { CODECEPT_TOOLS } from "../tools.js";
8
+ import { debugLog } from "./mixin.js";
9
+ import { getExecutionLabel, isNonReusableCode, stripComments } from "./utils.js";
10
+ export function WithExperience(Base) {
11
+ return class extends Base {
12
+ async saveSession(task, initialState, conversation) {
13
+ debugLog('Saving session experience');
14
+ const result = task.getRunResult();
15
+ const toolExecutions = conversation.getToolExecutions();
16
+ if (task instanceof Test) {
17
+ task.generatedCode = this.isPlaywrightFramework() ? await this.toPlaywrightCode(conversation, task.description) : this.toCode(conversation, task.description);
18
+ }
19
+ const steps = await this.extractSteps(toolExecutions);
20
+ const skipExperience = result === 'failed' || (task instanceof Test && (task.hasFailed || task.isSkipped));
21
+ if (!skipExperience) {
22
+ await this.detectRetryPatterns(toolExecutions, initialState);
23
+ const body = await this.curateFlow(steps, task, initialState);
24
+ if (body.trim()) {
25
+ const relatedUrls = this.extractVisitedUrls(toolExecutions, initialState.url || '');
26
+ this.experienceTracker.writeFlow(initialState, body, relatedUrls);
27
+ }
28
+ }
29
+ if (task instanceof Test && result !== 'failed') {
30
+ await this.reportSession(task, steps);
31
+ }
32
+ tag('substep').log(`Historian saved session for: ${task.description}`);
33
+ }
34
+ async reportSession(test, steps) {
35
+ if (!this.reporter)
36
+ return;
37
+ const reporterSteps = steps.map((step) => ({
38
+ title: step.message,
39
+ status: step.status === 'passed' ? 'passed' : 'failed',
40
+ code: step.code ? step.code.split('\n').filter((l) => l.trim()) : [],
41
+ discovery: step.discovery,
42
+ }));
43
+ await this.reporter.reportSteps(test, reporterSteps);
44
+ }
45
+ async extractSteps(toolExecutions) {
46
+ const stepsWithDiffs = [];
47
+ for (const exec of toolExecutions) {
48
+ if (!CODECEPT_TOOLS.includes(exec.toolName))
49
+ continue;
50
+ if (!exec.output?.code)
51
+ continue;
52
+ if (!exec.wasSuccessful)
53
+ continue;
54
+ if (isNonReusableCode(exec.output.code))
55
+ continue;
56
+ const step = {
57
+ message: getExecutionLabel(exec, `Executed ${exec.toolName}`),
58
+ status: 'passed',
59
+ tool: exec.toolName,
60
+ code: stripComments(exec.output.code),
61
+ };
62
+ stepsWithDiffs.push({ step, ariaDiff: exec.output?.pageDiff?.ariaChanges || null });
63
+ }
64
+ await this.analyzeDiscoveries(stepsWithDiffs);
65
+ return stepsWithDiffs.map((s) => s.step);
66
+ }
67
+ async curateFlow(steps, task, initialState) {
68
+ if (steps.length === 0)
69
+ return '';
70
+ const existingExperience = this.experienceTracker
71
+ .getRelevantExperience(initialState)
72
+ .map((e) => e.content)
73
+ .filter(Boolean)
74
+ .join('\n');
75
+ const existingSummary = existingExperience.length > 2000 ? existingExperience.substring(0, 2000) : existingExperience;
76
+ const stepsBlock = steps
77
+ .map((s, i) => {
78
+ const lines = [`Step ${i + 1}: ${s.message}`];
79
+ if (s.code) {
80
+ lines.push('```js');
81
+ lines.push(s.code);
82
+ lines.push('```');
83
+ }
84
+ if (s.discovery) {
85
+ for (const d of s.discovery.split('\n').filter((line) => line.trim())) {
86
+ lines.push(`> ${d.trim()}`);
87
+ }
88
+ }
89
+ return lines.join('\n');
90
+ })
91
+ .join('\n\n');
92
+ const expected = task instanceof Test && task.expected.length > 0 ? task.expected.map((e) => `- ${e}`).join('\n') : '';
93
+ const notes = task.notesToString();
94
+ const prompt = dedent `
95
+ You are curating a how-to recipe from a recorded test run. Decide whether the run produced
96
+ anything reusable, and if so, output a single \`## FLOW: ...\` markdown block. Otherwise output
97
+ an empty response (no text at all).
98
+
99
+ <original_scenario>
100
+ ${task.description}
101
+ </original_scenario>
102
+
103
+ ${expected ? `<expected_outcomes>\n${expected}\n</expected_outcomes>` : ''}
104
+
105
+ ${notes ? `<run_notes>\n${notes}\n</run_notes>` : ''}
106
+
107
+ <recorded_steps>
108
+ ${stepsBlock}
109
+ </recorded_steps>
110
+
111
+ ${existingSummary ? `<existing_experience_for_this_page>\n${existingSummary}\n</existing_experience_for_this_page>` : ''}
112
+
113
+ Output a FLOW block in EXACTLY this format:
114
+
115
+ ## FLOW: <imperative how-to that matches what the steps actually accomplished>
116
+
117
+ * <action description>
118
+
119
+ \`\`\`js
120
+ <code from input>
121
+ \`\`\`
122
+
123
+ > <relevant element or observation worth remembering>
124
+
125
+ * <next action>
126
+
127
+ \`\`\`js
128
+ <code from input>
129
+ \`\`\`
130
+
131
+ ---
132
+
133
+ Rules:
134
+ - Title is an imperative phrase answering "how do I X". It must describe what the steps
135
+ ACTUALLY accomplished, not the original scenario if the run drifted off course.
136
+ - Drop steps that wandered onto unrelated pages or did not contribute to a reusable recipe.
137
+ - Drop discoveries that are noise (loading states, timestamps, repeated buttons).
138
+ - Code blocks may only contain code that appears verbatim in <recorded_steps>. Do not invent
139
+ CodeceptJS calls.
140
+ - Lowercase the first letter of the title. No trailing punctuation.
141
+
142
+ Return an EMPTY response (no markdown, no explanation) if any of:
143
+ - The original scenario is a negative test (verifying an error, validation rejection, blocked
144
+ or forbidden action, "should fail" expectation).
145
+ - The surviving steps do not accomplish anything reusable.
146
+ - The recipe duplicates a recipe already present in <existing_experience_for_this_page>.
147
+ `;
148
+ try {
149
+ const response = await this.provider.chat([
150
+ { role: 'system', content: 'Curate reusable how-to recipes from recorded test runs. Be selective — only emit a FLOW when the steps demonstrate a coherent, reusable, positive recipe. Otherwise return nothing.' },
151
+ { role: 'user', content: prompt },
152
+ ], this.provider.getModelForAgent('historian'), { agentName: 'historian', telemetryFunctionId: 'historian.curateFlow' });
153
+ const body = (response?.text || '').trim();
154
+ if (!body) {
155
+ debugLog('curateFlow returned empty — skipping flow write');
156
+ return '';
157
+ }
158
+ if (!body.includes('## FLOW:')) {
159
+ debugLog('curateFlow output missing ## FLOW: heading — skipping');
160
+ return '';
161
+ }
162
+ return `${body}\n`;
163
+ }
164
+ catch (error) {
165
+ debugLog('curateFlow failed, skipping flow write: %s', error.message);
166
+ return '';
167
+ }
168
+ }
169
+ async detectRetryPatterns(toolExecutions, initialState) {
170
+ if (!this.experienceTracker || !this.stateManager)
171
+ return;
172
+ const failedByTool = new Map();
173
+ const candidates = [];
174
+ for (const exec of toolExecutions) {
175
+ if (!CODECEPT_TOOLS.includes(exec.toolName))
176
+ continue;
177
+ if (!exec.output?.code)
178
+ continue;
179
+ if (!exec.wasSuccessful) {
180
+ const bucket = failedByTool.get(exec.toolName) || [];
181
+ bucket.push(exec);
182
+ failedByTool.set(exec.toolName, bucket);
183
+ continue;
184
+ }
185
+ const failed = failedByTool.get(exec.toolName);
186
+ if (failed?.length) {
187
+ candidates.push({ failed: [...failed], success: exec });
188
+ failedByTool.set(exec.toolName, []);
189
+ }
190
+ }
191
+ if (candidates.length === 0)
192
+ return;
193
+ const prompt = dedent `
194
+ Analyze these retry patterns where a tool failed multiple times before succeeding.
195
+ For each candidate, determine which failed attempts were trying to do the same thing as the success.
196
+
197
+ ${candidates
198
+ .map((c, i) => dedent `
199
+ Candidate ${i}:
200
+ Failed attempts:
201
+ ${c.failed.map((f, j) => ` ${j}: ${getExecutionLabel(f, f.toolName)} → code: ${f.output?.code}`).join('\n')}
202
+ Succeeded:
203
+ ${getExecutionLabel(c.success, c.success.toolName)} → code: ${c.success.output.code}
204
+ `)
205
+ .join('\n\n')}
206
+
207
+ For each candidate where failures share the same intent as the success:
208
+ - candidateIndex: index of the candidate
209
+ - failedIndices: which failed attempts share the same intent
210
+ - intent: business-focused description of what was being done
211
+ - explanation: actionable tip explaining which element works and what to avoid
212
+ `;
213
+ const schema = z.object({
214
+ retryPatterns: z.array(z.object({
215
+ candidateIndex: z.number(),
216
+ failedIndices: z.array(z.number()),
217
+ intent: z.string(),
218
+ explanation: z.string(),
219
+ })),
220
+ });
221
+ try {
222
+ const response = await this.provider.generateObject([
223
+ { role: 'system', content: 'Analyze retry patterns in web testing tool executions. Identify when failed attempts share the same intent as a successful one.' },
224
+ { role: 'user', content: prompt },
225
+ ], schema);
226
+ for (const pattern of response?.object?.retryPatterns || []) {
227
+ const candidate = candidates[pattern.candidateIndex];
228
+ if (!candidate)
229
+ continue;
230
+ const url = candidate.success.output?.pageDiff?.currentUrl;
231
+ let state = initialState;
232
+ if (url && url !== initialState.url) {
233
+ const transition = this.stateManager.getLastVisitToPath(url);
234
+ if (transition) {
235
+ state = ActionResult.fromState(transition.toState);
236
+ }
237
+ }
238
+ if (isNonReusableCode(candidate.success.output.code))
239
+ continue;
240
+ this.experienceTracker.writeAction(state, { title: pattern.intent, code: candidate.success.output.code, explanation: pattern.explanation });
241
+ }
242
+ debugLog('Detected %d retry patterns', response?.object?.retryPatterns?.length || 0);
243
+ }
244
+ catch (error) {
245
+ debugLog('Failed to detect retry patterns: %s', error.message);
246
+ }
247
+ }
248
+ async analyzeDiscoveries(stepsWithDiffs) {
249
+ if (!stepsWithDiffs.some((s) => s.ariaDiff))
250
+ return;
251
+ const prompt = this.buildDiscoveryPrompt(stepsWithDiffs);
252
+ const schema = z.object({
253
+ discoveries: z.array(z.object({
254
+ stepNumber: z.number(),
255
+ discoveries: z.array(z.string()),
256
+ })),
257
+ });
258
+ try {
259
+ const response = await this.provider.generateObject([
260
+ { role: 'system', content: 'Analyze test execution steps and identify valuable UI discoveries. Return multiple discoveries per step when multiple new elements appear. Return no discoveries for steps with no meaningful changes.' },
261
+ { role: 'user', content: prompt },
262
+ ], schema);
263
+ for (const { stepNumber, discoveries } of response?.object?.discoveries || []) {
264
+ const stepIndex = stepNumber - 1;
265
+ if (!stepsWithDiffs[stepIndex])
266
+ continue;
267
+ if (discoveries.length === 0)
268
+ continue;
269
+ stepsWithDiffs[stepIndex].step.discovery = discoveries.join('\n');
270
+ }
271
+ }
272
+ catch (error) {
273
+ debugLog('Failed to analyze discoveries: %s', error.message);
274
+ }
275
+ }
276
+ buildDiscoveryPrompt(stepsWithDiffs) {
277
+ const stepsBlock = stepsWithDiffs
278
+ .map(({ step, ariaDiff }, i) => {
279
+ const lines = [`Step ${i + 1}: ${step.message}`];
280
+ if (ariaDiff)
281
+ lines.push(ariaDiff);
282
+ return lines.join('\n');
283
+ })
284
+ .join('\n\n');
285
+ return dedent `
286
+ Review these test steps and their ARIA diffs. Identify new UI elements that appeared
287
+ which could be valuable for deeper testing of this feature or related features that can
288
+ be triggered from this flow.
289
+
290
+ Return MULTIPLE discoveries per step when multiple new elements appear (buttons, inputs,
291
+ links, errors, warnings — list them all). Return an empty array for a step with no new
292
+ elements or only generic changes (loading spinners, timestamps).
293
+
294
+ <steps>
295
+ ${stepsBlock}
296
+ </steps>
297
+
298
+ Format:
299
+ - stepNumber: which step revealed these elements
300
+ - discoveries: array of brief descriptions, e.g. ["A new button appeared: Publish To Twitter", "A new input field appeared: Description"]
301
+
302
+ Only return actionable elements that could lead to new test scenarios.
303
+ `;
304
+ }
305
+ extractVisitedUrls(toolExecutions, initialUrl) {
306
+ const urls = new Set();
307
+ const initialPath = extractStatePath(initialUrl);
308
+ for (const exec of toolExecutions) {
309
+ const currentUrl = exec.output?.pageDiff?.currentUrl;
310
+ if (!currentUrl)
311
+ continue;
312
+ const relativePath = extractStatePath(currentUrl);
313
+ if (relativePath && relativePath !== initialPath) {
314
+ urls.add(relativePath);
315
+ }
316
+ }
317
+ return [...urls];
318
+ }
319
+ };
320
+ }
@@ -0,0 +1,2 @@
1
+ import { createDebug } from '../../utils/logger.js';
2
+ export const debugLog = createDebug('explorbot:historian');
@@ -0,0 +1,145 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ActionResult } from "../../action-result.js";
4
+ import { ConfigParser } from "../../config.js";
5
+ import { KnowledgeTracker } from "../../knowledge-tracker.js";
6
+ import { renderAssertion, renderCall } from "../../playwright-recorder.js";
7
+ import { tag } from "../../utils/logger.js";
8
+ import { relativeToCwd } from "../../utils/next-steps.js";
9
+ import { ASSERTION_TOOLS, CODECEPT_TOOLS } from "../tools.js";
10
+ import { escapeString, getExecutionLabel } from "./utils.js";
11
+ const PLAYWRIGHT_EMITTED_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
12
+ export function WithPlaywright(Base) {
13
+ return class extends Base {
14
+ async toPlaywrightCode(conversation, scenario) {
15
+ const toolExecutions = conversation.getToolExecutions();
16
+ const successfulSteps = toolExecutions.filter((exec) => exec.wasSuccessful && PLAYWRIGHT_EMITTED_TOOLS.includes(exec.toolName));
17
+ const callsByGroup = this.recorder ? await this.recorder.exportChunk() : new Map();
18
+ const stepLines = [];
19
+ for (const exec of successfulSteps) {
20
+ const explanation = getExecutionLabel(exec);
21
+ const execLines = [];
22
+ const groupId = exec.output?.playwrightGroupId;
23
+ const calls = groupId ? callsByGroup.get(groupId) || [] : [];
24
+ for (const call of calls) {
25
+ execLines.push(renderCall(call));
26
+ }
27
+ const assertions = exec.output?.assertionSteps || [];
28
+ for (const assertion of assertions) {
29
+ const line = renderAssertion(assertion);
30
+ if (line)
31
+ execLines.push(line);
32
+ }
33
+ if (execLines.length === 0)
34
+ continue;
35
+ stepLines.push('');
36
+ if (explanation) {
37
+ stepLines.push(` await test.step('${escapeString(explanation)}', async () => {`);
38
+ for (const line of execLines) {
39
+ stepLines.push(` ${line}`);
40
+ }
41
+ stepLines.push(' });');
42
+ }
43
+ else {
44
+ for (const line of execLines) {
45
+ stepLines.push(` ${line}`);
46
+ }
47
+ }
48
+ }
49
+ const pilotVerifications = this.recorder ? this.recorder.drainVerifications() : [];
50
+ if (pilotVerifications.length > 0) {
51
+ const assertionLines = [];
52
+ for (const step of pilotVerifications) {
53
+ const line = renderAssertion(step);
54
+ if (line)
55
+ assertionLines.push(line);
56
+ }
57
+ if (assertionLines.length > 0) {
58
+ stepLines.push('');
59
+ stepLines.push(` await test.step('Verification', async () => {`);
60
+ for (const line of assertionLines) {
61
+ stepLines.push(` ${line}`);
62
+ }
63
+ stepLines.push(' });');
64
+ }
65
+ }
66
+ if (stepLines.length === 0) {
67
+ return '';
68
+ }
69
+ const lines = [];
70
+ lines.push(`test('${escapeString(scenario)}', async ({ page }) => {`);
71
+ lines.push(...stepLines);
72
+ lines.push('});');
73
+ return lines.join('\n');
74
+ }
75
+ savePlaywrightPlanToFile(plan) {
76
+ const lines = [];
77
+ lines.push(`import { test, expect } from '@playwright/test';`);
78
+ lines.push('');
79
+ lines.push(`test.describe('${escapeString(plan.title)}', () => {`);
80
+ const startUrl = plan.url || plan.tests[0]?.startUrl;
81
+ if (startUrl) {
82
+ lines.push(' test.beforeEach(async ({ page }) => {');
83
+ lines.push(` await page.goto('${escapeString(startUrl)}');`);
84
+ for (const line of this.getPlaywrightKnowledgeLines(startUrl, ' ')) {
85
+ lines.push(line);
86
+ }
87
+ lines.push(' });');
88
+ lines.push('');
89
+ }
90
+ for (const test of plan.tests) {
91
+ if (test.generatedCode) {
92
+ const indented = indentBlock(test.generatedCode, ' ');
93
+ if (test.isSuccessful) {
94
+ lines.push(indented);
95
+ }
96
+ else {
97
+ lines.push(` // FAILED: ${escapeString(test.scenario)}`);
98
+ lines.push(indented.replace(/test\(/, 'test.skip('));
99
+ }
100
+ lines.push('');
101
+ continue;
102
+ }
103
+ lines.push(` test.fixme('${escapeString(test.scenario)}', async ({ page }) => {`);
104
+ if (test.plannedSteps.length > 0) {
105
+ for (const step of test.plannedSteps) {
106
+ lines.push(` // ${step}`);
107
+ }
108
+ }
109
+ else {
110
+ lines.push(` // ${test.scenario}`);
111
+ }
112
+ lines.push(' });');
113
+ lines.push('');
114
+ }
115
+ lines.push('});');
116
+ const testsDir = ConfigParser.getInstance().getTestsDir();
117
+ mkdirSync(testsDir, { recursive: true });
118
+ const filename = plan.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
119
+ const filePath = join(testsDir, `${filename}.spec.ts`);
120
+ writeFileSync(filePath, lines.join('\n'));
121
+ this.savedFiles.add(filePath);
122
+ tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
123
+ return filePath;
124
+ }
125
+ getPlaywrightKnowledgeLines(url, indent = ' ') {
126
+ const knowledgeTracker = new KnowledgeTracker();
127
+ const state = new ActionResult({ url });
128
+ const { wait, waitForElement } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement']);
129
+ const lines = [];
130
+ if (wait !== undefined) {
131
+ lines.push(`${indent}await page.waitForTimeout(${Number(wait) * 1000});`);
132
+ }
133
+ if (waitForElement) {
134
+ lines.push(`${indent}await page.locator(${JSON.stringify(waitForElement)}).waitFor();`);
135
+ }
136
+ return lines;
137
+ }
138
+ };
139
+ }
140
+ function indentBlock(block, indent) {
141
+ return block
142
+ .split('\n')
143
+ .map((line) => (line ? indent + line : line))
144
+ .join('\n');
145
+ }
@@ -0,0 +1,18 @@
1
+ export function isNonReusableCode(code) {
2
+ return /\bI\.clickXY\s*\(/.test(code);
3
+ }
4
+ export function escapeString(str) {
5
+ return str.replace(/'/g, "\\'").replace(/\n/g, ' ');
6
+ }
7
+ export function stripComments(code) {
8
+ return code
9
+ .split('\n')
10
+ .filter((line) => {
11
+ const trimmed = line.trim();
12
+ return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
13
+ })
14
+ .join('\n');
15
+ }
16
+ export function getExecutionLabel(exec, fallback) {
17
+ return exec.input?.explanation || exec.input?.assertion || exec.input?.note || fallback || '';
18
+ }