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
@@ -1,20 +1,18 @@
1
- import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { unlinkSync } from 'node:fs';
2
2
  import dedent from 'dedent';
3
- import matter from 'gray-matter';
3
+ import { type Tokens, marked } from 'marked';
4
4
  import { z } from 'zod';
5
- import type { ExperienceTracker } from '../experience-tracker.js';
5
+ import { type ExperienceFile, type ExperienceTracker, RECENT_WINDOW_DAYS } from '../experience-tracker.js';
6
6
  import { Observability } from '../observability.js';
7
7
  import { createDebug, log, tag } from '../utils/logger.js';
8
+ import { mdq } from '../utils/markdown-query.js';
9
+ import { generalizeUrl, hasDynamicUrlSegment } from '../utils/url-matcher.js';
8
10
  import type { Agent } from './agent.js';
9
11
  import type { Provider } from './provider.js';
10
12
 
11
13
  const debugLog = createDebug('explorbot:experience-compactor');
12
14
 
13
- interface ExperienceFile {
14
- filePath: string;
15
- data: { url?: string; title?: string; [key: string]: any };
16
- content: string;
17
- }
15
+ export type { ExperienceFile };
18
16
 
19
17
  interface MergeGroup {
20
18
  pattern: string;
@@ -33,11 +31,12 @@ export class ExperienceCompactor implements Agent {
33
31
  }
34
32
 
35
33
  async compactExperience(experience: string): Promise<string> {
36
- if (experience.length < this.MAX_LENGTH) {
37
- return experience;
34
+ const stripped = this.stripNonUsefulEntries(experience);
35
+ if (stripped.length < this.MAX_LENGTH) {
36
+ return stripped;
38
37
  }
39
38
 
40
- const prompt = this.buildCompactionPrompt(experience);
39
+ const prompt = this.buildCompactionPrompt(stripped);
41
40
  const model = this.provider.getModelForAgent('experience-compactor');
42
41
  const response = await this.provider.chat(
43
42
  [
@@ -50,38 +49,174 @@ export class ExperienceCompactor implements Agent {
50
49
  return response.text;
51
50
  }
52
51
 
53
- async compactAllExperiences(): Promise<number> {
54
- return Observability.run('experience-compactor.compactAll', { tags: ['experience-compactor'] }, async () => {
55
- await this.mergeSimilarExperiences();
52
+ stripNonUsefulEntries(content: string): string {
53
+ let result = dropNonReusableSections(content);
54
+ result = dropEmptySections(result);
55
+ return result;
56
+ }
56
57
 
57
- const experienceFiles = this.experienceTracker.getAllExperience();
58
- let compactedCount = 0;
59
-
60
- for (const experience of experienceFiles) {
61
- const prevContent = experience.content;
62
- const frontmatter = experience.data;
63
- const compactedContent = await this.compactExperienceFile(experience.filePath);
64
-
65
- if (prevContent !== compactedContent) {
66
- const stateHash = experience.filePath.split('/').pop()?.replace('.md', '') || '';
67
- this.experienceTracker.writeExperienceFile(stateHash, compactedContent, frontmatter);
68
- debugLog('Experience file compacted:', experience.filePath);
69
- compactedCount++;
70
- }
58
+ isRecent(file: ExperienceFile): boolean {
59
+ const ageDays = (Date.now() - file.mtime.getTime()) / (1000 * 60 * 60 * 24);
60
+ return ageDays <= RECENT_WINDOW_DAYS;
61
+ }
62
+
63
+ async reviewExperienceQuality(content: string, context: { url?: string; title?: string }): Promise<string> {
64
+ const sections = listSections(content);
65
+ if (sections.length === 0) return content;
66
+
67
+ const prompt = this.buildReviewPrompt(sections, context);
68
+ const schema = z.object({
69
+ sections: z.array(
70
+ z.object({
71
+ index: z.number().int(),
72
+ keep: z.boolean(),
73
+ reason: z.string(),
74
+ })
75
+ ),
76
+ });
77
+
78
+ try {
79
+ const model = this.provider.getModelForAgent('experience-compactor');
80
+ const response = await this.provider.generateObject(
81
+ [
82
+ { role: 'user', content: this.getSystemPrompt() },
83
+ { role: 'user', content: prompt },
84
+ ],
85
+ schema,
86
+ model,
87
+ { telemetryFunctionId: 'experience.reviewQuality' }
88
+ );
89
+
90
+ const decisions = response?.object?.sections || [];
91
+ let result = content;
92
+ for (const decision of decisions) {
93
+ if (decision.keep) continue;
94
+ const target = sections[decision.index];
95
+ if (!target) continue;
96
+ debugLog('Dropping section: %s (reason: %s)', target.title, decision.reason);
97
+ result = result.replace(target.raw, '');
71
98
  }
99
+ return result;
100
+ } catch (error) {
101
+ debugLog('AI quality review failed, keeping content unchanged: %s', error);
102
+ return content;
103
+ }
104
+ }
105
+
106
+ async compactAllExperiences(): Promise<{ merged: number; compacted: number }> {
107
+ return Observability.run('experience-compactor.compactAll', { tags: ['experience-compactor'] }, async () => {
108
+ const aiMerged = await this.mergeSimilarExperiences();
109
+ const generalized = this.generalizeDynamicUrls();
110
+ const compacted = await this.compactFiles(this.experienceTracker.getAllExperience());
111
+ return { merged: aiMerged + generalized, compacted };
112
+ });
113
+ }
72
114
 
73
- return compactedCount;
115
+ async autocompact(): Promise<{ merged: number; compacted: number }> {
116
+ return Observability.run('experience-compactor.autocompact', { tags: ['experience-compactor'] }, async () => {
117
+ const aiMerged = await this.mergeSimilarExperiences({ onlyDynamic: true });
118
+ const generalized = this.generalizeDynamicUrls();
119
+ const compacted = await this.compactFiles(this.experienceTracker.getAllExperience(), { skipSmall: true });
120
+ return { merged: aiMerged + generalized, compacted };
74
121
  });
75
122
  }
76
123
 
77
- async mergeSimilarExperiences(): Promise<number> {
124
+ generalizeDynamicUrls(): number {
125
+ const files = this.experienceTracker.getAllExperience();
126
+ let count = 0;
127
+ for (const file of files) {
128
+ const url = file.data.url as string | undefined;
129
+ if (!url || url.startsWith('~')) continue;
130
+ const generalized = generalizeUrl(url);
131
+ if (generalized === url) continue;
132
+ const stateHash = file.filePath.split('/').pop()?.replace('.md', '') || '';
133
+ const newData = { ...file.data, url: `~${generalized}~`, mergedFrom: [url] };
134
+ this.experienceTracker.writeExperienceFile(stateHash, file.content, newData);
135
+ tag('substep').log(`Generalized URL: ${url} → ~${generalized}~`);
136
+ count++;
137
+ }
138
+ return count;
139
+ }
140
+
141
+ async compactFiles(files: ExperienceFile[], options?: { skipSmall?: boolean }): Promise<number> {
142
+ const workingSet = options?.skipSmall ? files.filter((f) => f.content.length >= this.MAX_LENGTH) : files;
143
+
144
+ let compactedCount = 0;
145
+ const total = workingSet.length;
146
+ const aiReviewCount = workingSet.filter((f) => this.isRecent(f)).length;
147
+
148
+ if (total > 1) {
149
+ tag('info').log(`Processing ${total} experience file${total === 1 ? '' : 's'} (${aiReviewCount} will get AI review — ~${aiReviewCount * 3}s minimum)…`);
150
+ }
151
+
152
+ for (let i = 0; i < workingSet.length; i++) {
153
+ const experience = workingSet[i];
154
+ const shortName = experience.filePath.split('/').pop() || experience.filePath;
155
+ const willReview = this.isRecent(experience);
156
+
157
+ if (total > 1 && willReview) {
158
+ tag('substep').log(`[${i + 1}/${total}] reviewing ${shortName}`);
159
+ }
160
+
161
+ let content = this.stripNonUsefulEntries(experience.content);
162
+
163
+ if (willReview) {
164
+ content = await this.reviewExperienceQuality(content, experience.data);
165
+ }
166
+
167
+ if (content.length >= this.MAX_LENGTH) {
168
+ if (total > 1) tag('substep').log(`[${i + 1}/${total}] compacting ${shortName} (over ${this.MAX_LENGTH} chars)`);
169
+ const prompt = this.buildCompactionPrompt(content);
170
+ const model = this.provider.getModelForAgent('experience-compactor');
171
+ const response = await this.provider.chat(
172
+ [
173
+ { role: 'user', content: this.getSystemPrompt() },
174
+ { role: 'user', content: prompt },
175
+ ],
176
+ model,
177
+ { telemetryFunctionId: 'experience.compact' }
178
+ );
179
+ content = response.text;
180
+ }
181
+
182
+ if (content === experience.content) continue;
183
+
184
+ const stateHash = experience.filePath.split('/').pop()?.replace('.md', '') || '';
185
+ this.experienceTracker.writeExperienceFile(stateHash, content, experience.data);
186
+ debugLog('Experience file compacted:', experience.filePath);
187
+ compactedCount++;
188
+ }
189
+
190
+ return compactedCount;
191
+ }
192
+
193
+ async mergeSimilarExperiences(options?: { onlyDynamic?: boolean }): Promise<number> {
78
194
  return Observability.run('experience-compactor.merge', { tags: ['experience-compactor'] }, async () => {
79
195
  const experienceFiles = this.experienceTracker.getAllExperience();
80
196
  if (experienceFiles.length < 2) {
81
197
  return 0;
82
198
  }
83
199
 
84
- const mergeGroups = await this.identifyMergeGroups(experienceFiles);
200
+ let candidates = experienceFiles.filter((f) => f.data.url && !f.data.url.startsWith('~'));
201
+ if (options?.onlyDynamic) {
202
+ candidates = candidates.filter((f) => hasDynamicUrlSegment(f.data.url as string));
203
+ }
204
+
205
+ if (candidates.length < 2) {
206
+ debugLog('No mergeable URL patterns — skipping merge.');
207
+ return 0;
208
+ }
209
+
210
+ tag('info').log(`Experience compaction: checking ${candidates.length} file${candidates.length === 1 ? '' : 's'} for mergeable URL patterns…`);
211
+
212
+ const mergeGroups = await this.identifyMergeGroups(candidates);
213
+
214
+ if (mergeGroups.length === 0) {
215
+ tag('info').log('No URL groups to merge.');
216
+ return 0;
217
+ }
218
+
219
+ tag('info').log(`Merging ${mergeGroups.length} URL group${mergeGroups.length === 1 ? '' : 's'}…`);
85
220
  let mergedCount = 0;
86
221
 
87
222
  for (const group of mergeGroups) {
@@ -110,7 +245,7 @@ export class ExperienceCompactor implements Agent {
110
245
  const usedFiles = new Set<string>();
111
246
 
112
247
  for (const decision of mergeDecisions) {
113
- const matchingFiles = filesWithUrl.filter((f) => decision.urls.includes(f.data.url as string) && !usedFiles.has(f.filePath));
248
+ const matchingFiles = decision.urls.map((url) => filesWithUrl.find((f) => f.data.url === url && !usedFiles.has(f.filePath))).filter((f): f is ExperienceFile => Boolean(f));
114
249
 
115
250
  if (matchingFiles.length >= 2) {
116
251
  groups.push({
@@ -200,28 +335,6 @@ export class ExperienceCompactor implements Agent {
200
335
  }
201
336
  }
202
337
 
203
- async compactExperienceFile(filePath: string): Promise<string> {
204
- try {
205
- const fileContent = readFileSync(filePath, 'utf8');
206
- const parsed = matter(fileContent);
207
-
208
- if (parsed.content.length < this.MAX_LENGTH) {
209
- return parsed.content;
210
- }
211
- debugLog('Experience file to compact:', filePath);
212
-
213
- const text = await this.compactExperience(parsed.content);
214
-
215
- tag('substep').log('Experience file compacted:', filePath);
216
- debugLog('Experience file compacted:', text);
217
-
218
- return text;
219
- } catch (error) {
220
- debugLog('Error compacting experience file:', error);
221
- return '';
222
- }
223
- }
224
-
225
338
  private getSystemPrompt(): string {
226
339
  const customPrompt = this.provider.getSystemPromptForAgent('experience-compactor', '*');
227
340
  return dedent`
@@ -235,7 +348,7 @@ export class ExperienceCompactor implements Agent {
235
348
  private buildCompactionPrompt(content: string): string {
236
349
  return dedent`
237
350
  <rules>
238
- - Use markdown headers only (##, ###) - NO XML tags or wrappers in output
351
+ - Use markdown h2 headers only (##) - NO XML tags or wrappers in output
239
352
  - Merge similar flows to remove duplicates
240
353
  - Keep output under ${this.MAX_LENGTH} characters
241
354
  - Be explicit and short - no proposals or explanations
@@ -255,20 +368,18 @@ export class ExperienceCompactor implements Agent {
255
368
  </rules>
256
369
 
257
370
  <output_format>
258
- Use this markdown structure:
371
+ Use this markdown structure. Titles must be imperative verb phrases, lowercase-first (e.g. "create a new user"):
259
372
 
260
- ## Flows
373
+ ## FLOW: <multi-step imperative title>
261
374
 
262
- For each unique positive flow (merge duplicates):
263
- - Purpose: what was accomplished
375
+ * <step message>
264
376
  \`\`\`js
265
377
  // working code
266
378
  \`\`\`
379
+ ---
267
380
 
268
- ## Reusable Actions
381
+ ## ACTION: <single-step imperative title>
269
382
 
270
- For each reusable interaction pattern not covered by flows:
271
- - Purpose: what was accomplished
272
383
  \`\`\`js
273
384
  // working code
274
385
  \`\`\`
@@ -278,7 +389,103 @@ export class ExperienceCompactor implements Agent {
278
389
  ${content}
279
390
  </context>
280
391
 
281
- Compact this experience data following the format above.
392
+ Compact this experience data following the format above. Every section must be either a multi-step FLOW or a single-step ACTION.
393
+ `;
394
+ }
395
+
396
+ private buildReviewPrompt(sections: Array<{ title: string; raw: string }>, context: { url?: string; title?: string }): string {
397
+ const url = context.url || 'unknown';
398
+ const title = context.title || 'unknown';
399
+ const sectionsList = sections
400
+ .map((s, i) => {
401
+ return `Section ${i}: ${s.title}\n${s.raw.trim()}`;
402
+ })
403
+ .join('\n\n---\n\n');
404
+
405
+ return dedent`
406
+ Review each experience section stored for the page below. For each, decide whether to keep it for future test automation use.
407
+
408
+ <page>
409
+ url: ${url}
410
+ title: ${title}
411
+ </page>
412
+
413
+ <mental_model>
414
+ Every section answers the question "HOW to <title>?". If the title does not complete that question naturally, the section has no value. A FLOW teaches a multi-step procedure; an ACTION teaches a single atomic step. Verifications and one-off recoveries are out of scope.
415
+ </mental_model>
416
+
417
+ <drop_if>
418
+ - Title does not read as a reusable instruction — it describes a transient recovery, retry, or navigation step. Any title starting with "attempt", "try", "retry", "need to", "ensure", "go back", "return (to)", or ending with "(RESET)" is not a teaching.
419
+ - Title is a verification / assertion rather than an action. Any "verify", "verification", "see that", "check that", "expect", "assert", or "and verify" phrasing means the teaching is about asserting state, not doing something — drop.
420
+ - ACTION describes more than one atomic step. Multiple verb phrases, "and", "then", or comma-joined actions mean it is actually a FLOW, not an ACTION — drop so the flow gets re-captured correctly.
421
+ - Locator is dynamic / brittle: ember IDs, random UUIDs, numeric data-testid, positional XPaths like div[3].
422
+ - Too generic to reuse: "click button", "fill input" with no specific target.
423
+ - Duplicates another section on this same page.
424
+ - Body has no executable CodeceptJS code block.
425
+ </drop_if>
426
+
427
+ <keep_if>
428
+ Everything else. Prefer keeping sections — only drop when clearly low value.
429
+ </keep_if>
430
+
431
+ <sections>
432
+ ${sectionsList}
433
+ </sections>
434
+
435
+ Return a decision per section: { index, keep (boolean), reason (short) }.
282
436
  `;
283
437
  }
284
438
  }
439
+
440
+ function listSections(content: string): { title: string; raw: string }[] {
441
+ const tokens = marked.lexer(content);
442
+ const sections: { title: string; raw: string }[] = [];
443
+
444
+ let currentHeading: string | null = null;
445
+ let currentRaw = '';
446
+
447
+ const flush = () => {
448
+ if (currentHeading !== null) sections.push({ title: currentHeading, raw: currentRaw });
449
+ };
450
+
451
+ for (const token of tokens) {
452
+ const raw = (token as any).raw || '';
453
+ if (token.type === 'heading' && (token as Tokens.Heading).depth === 2) {
454
+ flush();
455
+ currentHeading = (token as Tokens.Heading).text.trim();
456
+ currentRaw = raw;
457
+ continue;
458
+ }
459
+ if (currentHeading !== null) currentRaw += raw;
460
+ }
461
+ flush();
462
+ return sections;
463
+ }
464
+
465
+ function dropEmptySections(content: string): string {
466
+ let result = content;
467
+ const sections = [...mdq(result).query('section2(~"FLOW:")').each(), ...mdq(result).query('section2(~"ACTION:")').each()];
468
+
469
+ for (const section of sections) {
470
+ const raw = section.text();
471
+ const hasCode = mdq(raw).query('code').count() > 0;
472
+ const hasList = mdq(raw).query('list').count() > 0;
473
+ if (hasCode || hasList) continue;
474
+ result = result.replace(raw, '');
475
+ }
476
+
477
+ return result;
478
+ }
479
+
480
+ function dropNonReusableSections(content: string): string {
481
+ let result = content;
482
+ const sections = [...mdq(result).query('section2(~"FLOW:")').each(), ...mdq(result).query('section2(~"ACTION:")').each()];
483
+
484
+ for (const section of sections) {
485
+ const raw = section.text();
486
+ if (!/\bI\.clickXY\s*\(/.test(raw)) continue;
487
+ result = result.replace(raw, '');
488
+ }
489
+
490
+ return result;
491
+ }
@@ -0,0 +1,130 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ActionResult } from '../../action-result.ts';
4
+ import { ConfigParser } from '../../config.ts';
5
+ import { KnowledgeTracker } from '../../knowledge-tracker.ts';
6
+ import type { Plan } from '../../test-plan.ts';
7
+ import { tag } from '../../utils/logger.ts';
8
+ import { relativeToCwd } from '../../utils/next-steps.ts';
9
+ import type { Conversation } from '../conversation.ts';
10
+ import { ASSERTION_TOOLS, CODECEPT_TOOLS } from '../tools.ts';
11
+ import type { Constructor } from './mixin.ts';
12
+ import { escapeString, getExecutionLabel, isNonReusableCode, stripComments } from './utils.ts';
13
+
14
+ export interface CodeceptJSMethods {
15
+ toCode(conversation: Conversation, scenario: string): string;
16
+ saveCodeceptPlanToFile(plan: Plan): string;
17
+ }
18
+
19
+ export function WithCodeceptJS<T extends Constructor>(Base: T) {
20
+ return class extends Base {
21
+ declare savedFiles: Set<string>;
22
+
23
+ toCode(conversation: Conversation, scenario: string): string {
24
+ const toolExecutions = conversation.getToolExecutions();
25
+ const TRACKABLE_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
26
+ const successfulSteps = toolExecutions.filter((exec) => exec.wasSuccessful && TRACKABLE_TOOLS.includes(exec.toolName as any) && exec.output?.code);
27
+
28
+ if (successfulSteps.length === 0) {
29
+ return '';
30
+ }
31
+
32
+ const lines: string[] = [];
33
+ lines.push(`Scenario('${escapeString(scenario)}', ({ I }) => {`);
34
+
35
+ for (const exec of successfulSteps) {
36
+ if (isNonReusableCode(exec.output.code)) continue;
37
+ const explanation = getExecutionLabel(exec);
38
+ if (explanation) {
39
+ lines.push('');
40
+ lines.push(` Section('${escapeString(explanation)}');`);
41
+ }
42
+ const code = stripComments(exec.output.code);
43
+ const codeLines = code.includes('\n') ? code.split('\n') : code.split('; ');
44
+ for (const codeLine of codeLines) {
45
+ const trimmed = codeLine.trim();
46
+ if (trimmed) {
47
+ lines.push(` ${trimmed}`);
48
+ }
49
+ }
50
+ }
51
+
52
+ lines.push('});');
53
+ return lines.join('\n');
54
+ }
55
+
56
+ saveCodeceptPlanToFile(plan: Plan): string {
57
+ const lines: string[] = [];
58
+
59
+ lines.push(`import step, { Section } from 'codeceptjs/steps';`);
60
+ lines.push('');
61
+ lines.push(`Feature('${escapeString(plan.title)}')`);
62
+ lines.push('');
63
+
64
+ const startUrl = plan.url || plan.tests[0]?.startUrl;
65
+ if (startUrl) {
66
+ lines.push('Before(({ I }) => {');
67
+ lines.push(` I.amOnPage('${escapeString(startUrl)}');`);
68
+ lines.push(...this.getKnowledgeLines(startUrl));
69
+ lines.push('});');
70
+ lines.push('');
71
+ }
72
+
73
+ for (const test of plan.tests) {
74
+ if (test.generatedCode) {
75
+ if (test.isSuccessful) {
76
+ lines.push(test.generatedCode);
77
+ } else {
78
+ lines.push(`// FAILED: ${test.scenario}`);
79
+ lines.push(test.generatedCode.replace(/Scenario\(/, 'Scenario.skip('));
80
+ }
81
+ lines.push('');
82
+ continue;
83
+ }
84
+
85
+ lines.push(`Scenario.todo('${escapeString(test.scenario)}', ({ I }) => {`);
86
+ if (test.plannedSteps.length > 0) {
87
+ for (const step of test.plannedSteps) {
88
+ lines.push(` // ${step}`);
89
+ }
90
+ } else {
91
+ lines.push(` // ${test.scenario}`);
92
+ }
93
+ lines.push('});');
94
+ lines.push('');
95
+ }
96
+
97
+ const testsDir = ConfigParser.getInstance().getTestsDir();
98
+ mkdirSync(testsDir, { recursive: true });
99
+
100
+ const filename = plan.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
101
+ const filePath = join(testsDir, `${filename}.js`);
102
+ writeFileSync(filePath, lines.join('\n'));
103
+ this.savedFiles.add(filePath);
104
+
105
+ tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
106
+ return filePath;
107
+ }
108
+
109
+ private getKnowledgeLines(url: string, indent = ' '): string[] {
110
+ const knowledgeTracker = new KnowledgeTracker();
111
+ const state = new ActionResult({ url });
112
+ const { wait, waitForElement, code } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement', 'code']);
113
+
114
+ const lines: string[] = [];
115
+ if (wait !== undefined) {
116
+ lines.push(`${indent}I.wait(${wait});`);
117
+ }
118
+ if (waitForElement) {
119
+ lines.push(`${indent}I.waitForElement(${JSON.stringify(waitForElement)});`);
120
+ }
121
+ if (code) {
122
+ for (const codeLine of code.split('\n')) {
123
+ const trimmed = codeLine.trim();
124
+ if (trimmed) lines.push(`${indent}${trimmed}`);
125
+ }
126
+ }
127
+ return lines;
128
+ }
129
+ };
130
+ }