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,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  export function truncateJson(input) {
2
3
  if (!input)
3
4
  return '';
@@ -11,3 +12,17 @@ export function sanitizeFilename(name) {
11
12
  .replace(/^_+|_+$/g, '')
12
13
  .slice(0, 50);
13
14
  }
15
+ export function safeFilename(name, ext = '', maxBytes = 240) {
16
+ const sanitized = name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
17
+ const extBytes = Buffer.byteLength(ext, 'utf8');
18
+ const budget = maxBytes - extBytes;
19
+ if (Buffer.byteLength(sanitized, 'utf8') <= budget)
20
+ return sanitized + ext;
21
+ const hash = createHash('sha1').update(name).digest('hex').slice(0, 8);
22
+ const suffix = `_${hash}`;
23
+ let truncated = sanitized;
24
+ while (Buffer.byteLength(truncated + suffix, 'utf8') > budget && truncated.length > 0) {
25
+ truncated = truncated.slice(0, -1);
26
+ }
27
+ return truncated + suffix + ext;
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -83,7 +83,7 @@
83
83
  "axe-core": "^4.11.1",
84
84
  "bash-tool": "^1.3.15",
85
85
  "cli-highlight": "^2.1.11",
86
- "codeceptjs": "4.0.0-rc.11",
86
+ "codeceptjs": "4.0.0-rc.16",
87
87
  "commander": "^14.0.1",
88
88
  "debug": "^4.4.3",
89
89
  "dedent": "^1.6.0",
@@ -104,7 +104,7 @@
104
104
  "micromatch": "^4.0.8",
105
105
  "ora-classic": "^5.4.2",
106
106
  "parse5": "^8.0.0",
107
- "playwright": "^1.40.0",
107
+ "playwright": "^1.59.0",
108
108
  "react": "^19.1.1",
109
109
  "strip-ansi": "^7.1.2",
110
110
  "turndown": "^7.2.1",
@@ -13,6 +13,10 @@ In <explanation> write only one line without heading or bullet list or any other
13
13
  Check previous solutions, if there is already successful solution, use it!
14
14
  CodeceptJS code must start with "I."
15
15
  All lines of code must be CodeceptJS code and start with "I."
16
+
17
+ Do not mix form filling with navigation in the same code block.
18
+ If the code block fills a form and clicks a submit/confirm control, stop there — do not append I.amOnPage afterwards. Submitting the form already triggers navigation on the server side, and a follow-up I.amOnPage cancels that in-flight navigation and discards the just-submitted state (session, cookies, redirect target).
19
+ If the action does not cause navigation on its own and a separate page visit is required to reach the target, put I.amOnPage in its own code block as a distinct step, not glued onto the form submission block.
16
20
  </rules>
17
21
 
18
22
  <output>
@@ -42,6 +46,11 @@ Use only locators from HTML PAGE that was passed in <page> context.
42
46
  <example_output>
43
47
  Trying to fill the form on the page
44
48
 
49
+ ```js
50
+ I.fillField({ "role": "textbox", "text": "Name" }, 'Value');
51
+ I.click({ "role": "button", "text": "Submit" });
52
+ ```
53
+
45
54
  ```js
46
55
  I.fillField('Name', 'Value');
47
56
  I.click('Submit');
@@ -113,6 +113,8 @@ For input field values, ALWAYS use I.seeInField() — never check value via CSS
113
113
  Prefer text locators (label, name, placeholder) for form fields: I.seeInField('Search', 'value') over I.seeInField('input[name="search"]', 'value').
114
114
  Only use locators that exist in the provided HTML or ARIA snapshot.
115
115
  Verify exact conditions, not approximate matches.
116
+ NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking text inside an element is the job of I.see(text, context) — the `:has-text()` form duplicates that capability with a fragile selector.
117
+ NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
116
118
  </verification_rules>
117
119
 
118
120
  [DO NEVER USE OTHER CODECEPTJS COMMANDS THAN PROPOSED HERE]
@@ -611,7 +611,7 @@ export class ActionResult implements ActionResultData {
611
611
  }
612
612
  }
613
613
  if (processedParts.length > 0) {
614
- pageDiff.htmlParts = processedParts;
614
+ pageDiff.htmlParts = collapseHtmlParts(processedParts);
615
615
  }
616
616
  }
617
617
 
@@ -629,6 +629,31 @@ export class ActionResult implements ActionResultData {
629
629
  }
630
630
  }
631
631
 
632
+ const HTML_PARTS_TOTAL_BUDGET = 8000;
633
+ const HTML_PARTS_COUNT_LIMIT = 8;
634
+ const HTML_PART_SUBTREE_BUDGET = 2000;
635
+
636
+ function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
637
+ const total = parts.reduce((sum, p) => sum + p.subtree.length, 0);
638
+ const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
639
+
640
+ if (fullPageReRender) {
641
+ return parts.map((part) => ({
642
+ ...part,
643
+ subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
644
+ }));
645
+ }
646
+
647
+ return parts.map((part) => {
648
+ if (part.subtree.length <= HTML_PART_SUBTREE_BUDGET) return part;
649
+ const head = part.subtree.slice(0, HTML_PART_SUBTREE_BUDGET);
650
+ return {
651
+ ...part,
652
+ subtree: `${head}...<!-- truncated ${part.subtree.length - HTML_PART_SUBTREE_BUDGET} chars -->`,
653
+ };
654
+ });
655
+ }
656
+
632
657
  export class Diff {
633
658
  private _htmlDiffResult: HtmlDiffResult | null = null;
634
659
  private _ariaDiffResult: string | null = null;
package/src/action.ts CHANGED
@@ -16,10 +16,12 @@ import { ConfigParser, outputPath } from './config.js';
16
16
  import type { ExplorbotConfig } from './config.js';
17
17
  import type { UserResolveFunction } from './explorbot.ts';
18
18
  import { Observability } from './observability.ts';
19
+ import type { PlaywrightRecorder } from './playwright-recorder.ts';
19
20
  import type { StateManager } from './state-manager.js';
20
21
  import { extractCodeBlocks } from './utils/code-extractor.js';
21
22
  import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
22
23
  import { createDebug, log, setStepSpanParent, tag } from './utils/logger.js';
24
+ import { safeFilename } from './utils/strings.ts';
23
25
  import { throttle } from './utils/throttle.ts';
24
26
 
25
27
  const debugLog = createDebug('explorbot:action');
@@ -36,12 +38,16 @@ class Action {
36
38
  private expectation: string | null = null;
37
39
  public lastError: Error | null = null;
38
40
  public playwrightHelper: any;
41
+ public playwrightGroupId: string | null = null;
42
+ public assertionSteps: Array<{ name: string; args: any[] }> = [];
43
+ private recorder?: PlaywrightRecorder;
39
44
 
40
- constructor(actor: CodeceptJS.I, stateManager: StateManager) {
45
+ constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder) {
41
46
  this.actor = actor;
42
47
  this.stateManager = stateManager;
43
48
  this.config = ConfigParser.getInstance().getConfig();
44
49
  this.playwrightHelper = container.helpers('Playwright');
50
+ this.recorder = recorder;
45
51
  }
46
52
 
47
53
  async caputrePageWithScreenshot(): Promise<ActionResult> {
@@ -71,13 +77,20 @@ class Action {
71
77
  const timestamp = Date.now();
72
78
  const page = this.playwrightHelper.page;
73
79
  const frame = this.playwrightHelper.frame;
74
- const [html, title, browserLogs] = await Promise.all([(this.actor as any).grabSource(), (this.actor as any).grabTitle(), this.captureBrowserLogs()]);
80
+ await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
81
+ const grabAll = () => Promise.all([(this.actor as any).grabSource(), (this.actor as any).grabTitle(), this.captureBrowserLogs()]);
82
+ const [html, title, browserLogs] = await grabAll().catch(async (err: Error) => {
83
+ const msg = err instanceof Error ? err.message : String(err);
84
+ if (!/navigating and changing the content/i.test(msg)) throw err;
85
+ await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
86
+ return grabAll();
87
+ });
75
88
  const url = page?.url() || (await (this.actor as any).grabCurrentUrl?.());
76
89
 
77
90
  let screenshotFile: string | undefined = undefined;
78
91
 
79
92
  if (includeScreenshot) {
80
- const filename = `${stateHash}_${timestamp}.png`;
93
+ const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
81
94
  screenshotFile = await (this.actor as any)
82
95
  .saveScreenshot(filename)
83
96
  .then(() => filename)
@@ -90,13 +103,13 @@ class Action {
90
103
  // Save HTML to file
91
104
  const statesDir = outputPath('states');
92
105
  fs.mkdirSync(statesDir, { recursive: true });
93
- const htmlFile = `${stateHash}_${timestamp}.html`;
106
+ const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
94
107
  const htmlPath = join(statesDir, htmlFile);
95
108
  fs.writeFileSync(htmlPath, html, 'utf8');
96
109
 
97
110
  debugLog('Captured page state');
98
111
  // Save logs to file
99
- const logFile = `${stateHash}_${timestamp}.log`;
112
+ const logFile = safeFilename(`${stateHash}_${timestamp}`, '.log');
100
113
  const logPath = join(statesDir, logFile);
101
114
  const formattedLogs = browserLogs.map((log: any) => {
102
115
  const logTimestamp = new Date().toISOString();
@@ -122,7 +135,7 @@ class Action {
122
135
  }
123
136
 
124
137
  if (ariaSnapshot) {
125
- const ariaFileName = `${stateHash}_${timestamp}.aria.yaml`;
138
+ const ariaFileName = safeFilename(`${stateHash}_${timestamp}`, '.aria.yaml');
126
139
  const ariaPath = join(statesDir, ariaFileName);
127
140
  fs.writeFileSync(ariaPath, ariaSnapshot, 'utf8');
128
141
  ariaSnapshotFile = ariaFileName;
@@ -218,7 +231,10 @@ class Action {
218
231
  let codeString = code.replace(/^\(I\) => /, '').trim();
219
232
 
220
233
  const executedSteps: string[] = [];
221
- registerStepLogger(executedSteps);
234
+ const assertionSteps: Array<{ name: string; args: any[] }> = [];
235
+ const stepListener = attachStepLogger(executedSteps, assertionSteps);
236
+ const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
237
+ this.playwrightGroupId = groupId;
222
238
  const activeSpan = Observability.getSpan();
223
239
  const tracer = trace.getTracer('ai');
224
240
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
@@ -253,6 +269,7 @@ class Action {
253
269
  this.stateManager.updateState(pageState, codeString);
254
270
 
255
271
  this.actionResult = pageState;
272
+ this.assertionSteps = assertionSteps;
256
273
  } catch (err) {
257
274
  debugLog('Action error', errorToString(err));
258
275
  error = err as Error;
@@ -260,9 +277,11 @@ class Action {
260
277
  await recorder.reset();
261
278
  await recorder.start();
262
279
  }
280
+ this.assertionSteps = [];
263
281
  throw err;
264
282
  } finally {
265
- unregisterStepLogger();
283
+ if (groupId) await this.recorder!.endAction();
284
+ detachStepLogger(stepListener);
266
285
  if (stepSpan) {
267
286
  stepSpan.end();
268
287
  }
@@ -407,41 +426,30 @@ function sleep(ms: number) {
407
426
  return new Promise((resolve) => setTimeout(resolve, ms));
408
427
  }
409
428
 
410
- let stepLoggerRegistered = false;
411
- let stepLoggerTarget: string[] | null = null;
429
+ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeElement', 'seeInField', 'dontSeeInField', 'seeInCurrentUrl', 'dontSeeInCurrentUrl']);
412
430
 
413
- const stepLogger = (step: any, error?: any) => {
414
- if (!step?.toCode) {
415
- return;
416
- }
417
- if (step.name?.startsWith('grab')) return;
418
- const stepCode = step.toCode();
419
- if (stepLoggerTarget) {
420
- stepLoggerTarget.push(stepCode);
421
- }
422
- if (error) {
423
- tag('step').log(step, error);
424
- return;
425
- }
426
- tag('step').log(step);
427
- };
431
+ type StepListener = (step: any, error?: any) => void;
428
432
 
429
- const registerStepLogger = (target: string[]) => {
430
- stepLoggerTarget = target;
431
- if (stepLoggerRegistered) {
432
- return;
433
- }
434
- stepLoggerRegistered = true;
435
- codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, stepLogger);
436
- codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, stepLogger);
433
+ const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: string; args: any[] }>): StepListener => {
434
+ const listener: StepListener = (step, error) => {
435
+ if (!step?.toCode) return;
436
+ if (step.name?.startsWith('grab')) return;
437
+ target.push(step.toCode());
438
+ if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
439
+ assertionsTarget.push({ name: step.name, args: step.args || [] });
440
+ }
441
+ if (error) {
442
+ tag('step').log(step, error);
443
+ return;
444
+ }
445
+ tag('step').log(step);
446
+ };
447
+ codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
448
+ codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
449
+ return listener;
437
450
  };
438
451
 
439
- const unregisterStepLogger = () => {
440
- stepLoggerTarget = null;
441
- if (!stepLoggerRegistered) {
442
- return;
443
- }
444
- stepLoggerRegistered = false;
445
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, stepLogger);
446
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, stepLogger);
452
+ const detachStepLogger = (listener: StepListener) => {
453
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
454
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
447
455
  };
package/src/ai/bosun.ts CHANGED
@@ -9,9 +9,11 @@ import type { KnowledgeTracker } from '../knowledge-tracker.ts';
9
9
  import { Observability } from '../observability.ts';
10
10
  import { Plan, Task, Test, TestResult } from '../test-plan.ts';
11
11
  import { diffAriaSnapshots } from '../utils/aria.ts';
12
+ import { getCliName } from '../utils/cli-name.ts';
12
13
  import { HooksRunner } from '../utils/hooks-runner.ts';
13
14
  import { createDebug, tag } from '../utils/logger.ts';
14
15
  import { loop, pause } from '../utils/loop.ts';
16
+ import { type NextStepSection, printNextSteps } from '../utils/next-steps.ts';
15
17
  import type { Agent } from './agent.ts';
16
18
  import type { Conversation } from './conversation.ts';
17
19
  import type { Navigator } from './navigator.ts';
@@ -497,7 +499,15 @@ export class Bosun extends TaskAgent implements Agent {
497
499
  const content = this.generateKnowledgeContent(state, successfulInteractions);
498
500
  const result = knowledgeTracker.addKnowledge(knowledgePath, content);
499
501
 
500
- tag('success').log(`Knowledge saved to: ${result.filePath}`);
502
+ const cli = getCliName();
503
+ const sections: NextStepSection[] = [
504
+ {
505
+ label: 'Knowledge',
506
+ path: result.filePath,
507
+ commands: [{ label: 'View matches', command: `${cli} knows ${knowledgePath}` }],
508
+ },
509
+ ];
510
+ printNextSteps(sections);
501
511
  }
502
512
 
503
513
  private generateKnowledgeContent(state: any, interactions: InteractionResult[]): string {
@@ -11,6 +11,9 @@ export function toolExecutionLabel(input: Record<string, any> | undefined): stri
11
11
  return input?.explanation || input?.assertion || input?.reason || input?.request || '';
12
12
  }
13
13
 
14
+ const AUTO_COMPACT_ARIA_CHANGES_CUTOFF = 500;
15
+ const AUTO_COMPACT_TARGETED_HTML_CUTOFF = 500;
16
+
14
17
  export class Conversation {
15
18
  id: string;
16
19
  messages: ModelMessage[];
@@ -132,6 +135,40 @@ export class Conversation {
132
135
  this.autoTrimRules.set(tagName, maxLength);
133
136
  }
134
137
 
138
+ compactToolResults(keepLastN: number): void {
139
+ const toolMessageIndexes: number[] = [];
140
+ for (let i = 0; i < this.messages.length; i++) {
141
+ if (this.messages[i].role === 'tool') toolMessageIndexes.push(i);
142
+ }
143
+ const compactUpTo = toolMessageIndexes.length - Math.max(0, keepLastN);
144
+ for (let k = 0; k < compactUpTo; k++) {
145
+ const message = this.messages[toolMessageIndexes[k]];
146
+ if (!Array.isArray(message.content)) continue;
147
+ for (const part of message.content) {
148
+ if (part.type !== 'tool-result') continue;
149
+ const rawOutput = part.output as Record<string, any> | undefined;
150
+ if (!rawOutput || rawOutput.type !== 'json' || !rawOutput.value || typeof rawOutput.value !== 'object') continue;
151
+ const value = rawOutput.value as Record<string, any>;
152
+ if (value.pageDiff && typeof value.pageDiff === 'object') {
153
+ const pageDiff = value.pageDiff as Record<string, any>;
154
+ if (Array.isArray(pageDiff.htmlParts)) {
155
+ pageDiff.htmlParts = undefined;
156
+ pageDiff.compacted = true;
157
+ }
158
+ if (typeof pageDiff.ariaChanges === 'string' && pageDiff.ariaChanges.length > AUTO_COMPACT_ARIA_CHANGES_CUTOFF) {
159
+ pageDiff.ariaChanges = `${pageDiff.ariaChanges.slice(0, AUTO_COMPACT_ARIA_CHANGES_CUTOFF)}...`;
160
+ }
161
+ if (typeof pageDiff.iframes === 'string') {
162
+ pageDiff.iframes = undefined;
163
+ }
164
+ }
165
+ if (typeof value.targetedHtml === 'string' && value.targetedHtml.length > AUTO_COMPACT_TARGETED_HTML_CUTOFF) {
166
+ value.targetedHtml = `${value.targetedHtml.slice(0, AUTO_COMPACT_TARGETED_HTML_CUTOFF)}...`;
167
+ }
168
+ }
169
+ }
170
+ }
171
+
135
172
  hasTag(tagName: string, lastN?: number): boolean {
136
173
  const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
137
174
  const regex = new RegExp(`<${escapedTag}>`, 'g');
@@ -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 { safeFilename } from '../../utils/strings.ts';
10
+ import type { Conversation } from '../conversation.ts';
11
+ import { ASSERTION_TOOLS, CODECEPT_TOOLS } from '../tools.ts';
12
+ import type { Constructor } from './mixin.ts';
13
+ import { escapeString, getExecutionLabel, isNonReusableCode, stripComments } from './utils.ts';
14
+
15
+ export interface CodeceptJSMethods {
16
+ toCode(conversation: Conversation, scenario: string): string;
17
+ saveCodeceptPlanToFile(plan: Plan): string;
18
+ }
19
+
20
+ export function WithCodeceptJS<T extends Constructor>(Base: T) {
21
+ return class extends Base {
22
+ declare savedFiles: Set<string>;
23
+
24
+ toCode(conversation: Conversation, scenario: string): string {
25
+ const toolExecutions = conversation.getToolExecutions();
26
+ const TRACKABLE_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
27
+ const successfulSteps = toolExecutions.filter((exec) => exec.wasSuccessful && TRACKABLE_TOOLS.includes(exec.toolName as any) && exec.output?.code);
28
+
29
+ if (successfulSteps.length === 0) {
30
+ return '';
31
+ }
32
+
33
+ const lines: string[] = [];
34
+ lines.push(`Scenario('${escapeString(scenario)}', ({ I }) => {`);
35
+
36
+ for (const exec of successfulSteps) {
37
+ if (isNonReusableCode(exec.output.code)) continue;
38
+ const explanation = getExecutionLabel(exec);
39
+ if (explanation) {
40
+ lines.push('');
41
+ lines.push(` Section('${escapeString(explanation)}');`);
42
+ }
43
+ const code = stripComments(exec.output.code);
44
+ const codeLines = code.includes('\n') ? code.split('\n') : code.split('; ');
45
+ for (const codeLine of codeLines) {
46
+ const trimmed = codeLine.trim();
47
+ if (trimmed) {
48
+ lines.push(` ${trimmed}`);
49
+ }
50
+ }
51
+ }
52
+
53
+ lines.push('});');
54
+ return lines.join('\n');
55
+ }
56
+
57
+ saveCodeceptPlanToFile(plan: Plan): string {
58
+ const lines: string[] = [];
59
+
60
+ lines.push(`import step, { Section } from 'codeceptjs/steps';`);
61
+ lines.push('');
62
+ lines.push(`Feature('${escapeString(plan.title)}')`);
63
+ lines.push('');
64
+
65
+ const startUrl = plan.url || plan.tests[0]?.startUrl;
66
+ if (startUrl) {
67
+ lines.push('Before(({ I }) => {');
68
+ lines.push(` I.amOnPage('${escapeString(startUrl)}');`);
69
+ lines.push(...this.getKnowledgeLines(startUrl));
70
+ lines.push('});');
71
+ lines.push('');
72
+ }
73
+
74
+ for (const test of plan.tests) {
75
+ if (test.generatedCode) {
76
+ if (test.isSuccessful) {
77
+ lines.push(test.generatedCode);
78
+ } else {
79
+ lines.push(`// FAILED: ${test.scenario}`);
80
+ lines.push(test.generatedCode.replace(/Scenario\(/, 'Scenario.skip('));
81
+ }
82
+ lines.push('');
83
+ continue;
84
+ }
85
+
86
+ lines.push(`Scenario.todo('${escapeString(test.scenario)}', ({ I }) => {`);
87
+ if (test.plannedSteps.length > 0) {
88
+ for (const step of test.plannedSteps) {
89
+ lines.push(` // ${step}`);
90
+ }
91
+ } else {
92
+ lines.push(` // ${test.scenario}`);
93
+ }
94
+ lines.push('});');
95
+ lines.push('');
96
+ }
97
+
98
+ const testsDir = ConfigParser.getInstance().getTestsDir();
99
+ mkdirSync(testsDir, { recursive: true });
100
+
101
+ const filePath = join(testsDir, safeFilename(plan.title, '.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
+ }