explorbot 0.0.5 → 0.1.1

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 (85) hide show
  1. package/bin/explorbot-cli.ts +97 -39
  2. package/dist/bin/explorbot-cli.js +75 -19
  3. package/dist/rules/rerunner/healing-approach.md +19 -0
  4. package/dist/src/action.js +8 -7
  5. package/dist/src/ai/historian.js +34 -3
  6. package/dist/src/ai/navigator.js +35 -28
  7. package/dist/src/ai/pilot.js +33 -9
  8. package/dist/src/ai/planner/subpages.js +42 -6
  9. package/dist/src/ai/planner.js +44 -13
  10. package/dist/src/ai/rerunner.js +472 -0
  11. package/dist/src/ai/researcher/cache.js +13 -8
  12. package/dist/src/ai/researcher/coordinates.js +4 -2
  13. package/dist/src/ai/researcher/deep-analysis.js +16 -19
  14. package/dist/src/ai/researcher/locators.js +1 -1
  15. package/dist/src/ai/researcher/parser.js +4 -3
  16. package/dist/src/ai/researcher/research-result.js +2 -0
  17. package/dist/src/ai/researcher.js +3 -3
  18. package/dist/src/ai/rules.js +2 -2
  19. package/dist/src/ai/tools.js +6 -2
  20. package/dist/src/commands/add-rule-command.js +1 -2
  21. package/dist/src/commands/base-command.js +12 -0
  22. package/dist/src/commands/context-command.js +10 -3
  23. package/dist/src/commands/drill-command.js +0 -1
  24. package/dist/src/commands/explore-command.js +21 -6
  25. package/dist/src/commands/freesail-command.js +8 -22
  26. package/dist/src/commands/index.js +4 -0
  27. package/dist/src/commands/init-command.js +7 -5
  28. package/dist/src/commands/path-command.js +2 -1
  29. package/dist/src/commands/plan-command.js +38 -11
  30. package/dist/src/commands/rerun-command.js +42 -0
  31. package/dist/src/commands/research-command.js +10 -4
  32. package/dist/src/commands/runs-command.js +22 -0
  33. package/dist/src/commands/start-command.js +0 -1
  34. package/dist/src/commands/test-command.js +3 -3
  35. package/dist/src/components/App.js +8 -0
  36. package/dist/src/config.js +3 -0
  37. package/dist/src/explorbot.js +20 -1
  38. package/dist/src/explorer.js +59 -16
  39. package/dist/src/suite.js +115 -0
  40. package/dist/src/utils/html.js +2 -5
  41. package/dist/src/utils/rules-loader.js +33 -17
  42. package/dist/src/utils/test-files.js +103 -0
  43. package/dist/src/utils/web-element.js +6 -4
  44. package/package.json +3 -2
  45. package/rules/rerunner/healing-approach.md +19 -0
  46. package/src/action.ts +8 -6
  47. package/src/ai/historian.ts +37 -3
  48. package/src/ai/navigator.ts +35 -28
  49. package/src/ai/pilot.ts +33 -9
  50. package/src/ai/planner/subpages.ts +37 -7
  51. package/src/ai/planner.ts +44 -12
  52. package/src/ai/rerunner.ts +532 -0
  53. package/src/ai/researcher/cache.ts +14 -8
  54. package/src/ai/researcher/coordinates.ts +8 -7
  55. package/src/ai/researcher/deep-analysis.ts +18 -21
  56. package/src/ai/researcher/locators.ts +3 -3
  57. package/src/ai/researcher/parser.ts +4 -4
  58. package/src/ai/researcher/research-result.ts +1 -0
  59. package/src/ai/researcher.ts +3 -3
  60. package/src/ai/rules.ts +2 -2
  61. package/src/ai/tools.ts +7 -2
  62. package/src/commands/add-rule-command.ts +1 -2
  63. package/src/commands/base-command.ts +13 -0
  64. package/src/commands/context-command.ts +10 -3
  65. package/src/commands/drill-command.ts +0 -1
  66. package/src/commands/explore-command.ts +22 -6
  67. package/src/commands/freesail-command.ts +6 -23
  68. package/src/commands/index.ts +4 -0
  69. package/src/commands/init-command.ts +8 -5
  70. package/src/commands/path-command.ts +2 -1
  71. package/src/commands/plan-command.ts +46 -12
  72. package/src/commands/rerun-command.ts +46 -0
  73. package/src/commands/research-command.ts +10 -4
  74. package/src/commands/runs-command.ts +27 -0
  75. package/src/commands/start-command.ts +0 -1
  76. package/src/commands/test-command.ts +3 -3
  77. package/src/components/App.tsx +8 -0
  78. package/src/config.ts +24 -0
  79. package/src/explorbot.ts +22 -1
  80. package/src/explorer.ts +68 -20
  81. package/src/suite.ts +135 -0
  82. package/src/utils/html.ts +1 -5
  83. package/src/utils/rules-loader.ts +35 -17
  84. package/src/utils/test-files.ts +122 -0
  85. package/src/utils/web-element.ts +12 -10
@@ -25,10 +25,10 @@ export class TestCommand extends BaseCommand {
25
25
  } else if (args === '*' || args === 'all') {
26
26
  toExecute.push(...requirePlan().getPendingTests());
27
27
  } else if (args.match(/^[\d,\-\s]+$/)) {
28
- const pending = requirePlan().getPendingTests();
29
- const indices = parseTestIndices(args, pending.length);
28
+ const visible = requirePlan().tests.filter((t) => t.enabled);
29
+ const indices = parseTestIndices(args, visible.length);
30
30
  for (const idx of indices) {
31
- toExecute.push(pending[idx]);
31
+ toExecute.push(visible[idx]);
32
32
  }
33
33
  } else {
34
34
  const matching = plan?.getPendingTests().filter((test) => test.scenario.toLowerCase().includes(args.toLowerCase())) || [];
@@ -242,6 +242,14 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
242
242
  setShowPlanEditor(true);
243
243
  return;
244
244
  }
245
+ if (key.upArrow) {
246
+ setTaskScrollOffset((prev) => Math.max(0, prev - 1));
247
+ return;
248
+ }
249
+ if (key.downArrow) {
250
+ setTaskScrollOffset((prev) => prev + 1);
251
+ return;
252
+ }
245
253
  }
246
254
 
247
255
  if (!showInput && !showPlanEditor) {
package/src/config.ts CHANGED
@@ -85,6 +85,22 @@ interface NavigatorAgentConfig extends AgentConfig {
85
85
  maxAttempts?: number;
86
86
  }
87
87
 
88
+ type HealFn = (ctx: { I: any }) => Promise<void> | void;
89
+
90
+ interface HealRecipe {
91
+ priority?: number;
92
+ steps?: string[];
93
+ fn: (context: { step: any; error: Error; prevSteps?: any[] }) => HealFn | Promise<HealFn | null> | null;
94
+ }
95
+
96
+ interface RerunnerAgentConfig extends AgentConfig {
97
+ healLimit?: number;
98
+ ariaSnapshotLimit?: number;
99
+ retryFailedStep?: Record<string, any>;
100
+ screenshotOnFail?: Record<string, any>;
101
+ recipes?: Record<string, HealRecipe>;
102
+ }
103
+
88
104
  interface PlannerAgentConfig extends AgentConfig {
89
105
  styles?: string[];
90
106
  stylesDir?: string;
@@ -103,6 +119,7 @@ interface AgentsConfig {
103
119
  fisherman?: AgentConfig;
104
120
  chief?: AgentConfig;
105
121
  curler?: AgentConfig;
122
+ rerunner?: RerunnerAgentConfig;
106
123
  }
107
124
 
108
125
  interface AIConfig {
@@ -185,6 +202,7 @@ interface ExplorbotConfig {
185
202
  api?: ApiConfig;
186
203
  stepsFile?: string;
187
204
  files?: Record<string, string>;
205
+ dynamicPageRegex?: string;
188
206
  }
189
207
 
190
208
  const config: ExplorbotConfig = {
@@ -213,6 +231,8 @@ export type {
213
231
  ResearcherAgentConfig,
214
232
  NavigatorAgentConfig,
215
233
  PlannerAgentConfig,
234
+ RerunnerAgentConfig,
235
+ HealRecipe,
216
236
  Hook,
217
237
  HookConfig,
218
238
  HooksConfig,
@@ -327,6 +347,10 @@ export class ConfigParser {
327
347
  return outputPath('plans');
328
348
  }
329
349
 
350
+ public getTestsDir(): string {
351
+ return outputPath('tests');
352
+ }
353
+
330
354
  // For testing purposes only
331
355
  public static resetForTesting(): void {
332
356
  if (ConfigParser.instance) {
package/src/explorbot.ts CHANGED
@@ -14,11 +14,13 @@ import { Planner } from './ai/planner.ts';
14
14
  import { AIProvider } from './ai/provider.ts';
15
15
  import { Quartermaster } from './ai/quartermaster.ts';
16
16
  import { Researcher } from './ai/researcher.ts';
17
+ import { Rerunner } from './ai/rerunner.ts';
17
18
  import { Tester } from './ai/tester.ts';
18
19
  import { createAgentTools } from './ai/tools.ts';
19
20
  import type { ExplorbotConfig } from './config.js';
20
21
  import { ConfigParser } from './config.ts';
21
22
  import Explorer from './explorer.ts';
23
+ import type { Suite } from './suite.ts';
22
24
  import { KnowledgeTracker } from './knowledge-tracker.ts';
23
25
  import { WebPageState } from './state-manager.ts';
24
26
  import { Plan } from './test-plan.ts';
@@ -108,7 +110,7 @@ export class ExplorBot {
108
110
  }
109
111
 
110
112
  getCurrentState(): WebPageState | null {
111
- return this.explorer.getStateManager().getCurrentState();
113
+ return this.explorer?.getStateManager().getCurrentState() ?? null;
112
114
  }
113
115
 
114
116
  getExplorer(): Explorer {
@@ -245,6 +247,21 @@ export class ExplorBot {
245
247
  }));
246
248
  }
247
249
 
250
+ agentRerunner(): Rerunner {
251
+ if (!this.agents.rerunner) {
252
+ this.agents.rerunner = this.createAgent(({ ai, explorer }) => {
253
+ const researcher = this.agentResearcher();
254
+ const navigator = this.agentNavigator();
255
+ const tools = createAgentTools({ explorer, researcher, navigator });
256
+ return new Rerunner(explorer, ai, tools);
257
+ });
258
+ const qm = this.agentQuartermaster();
259
+ if (qm) this.agents.rerunner.setQuartermaster(qm);
260
+ this.agents.rerunner.setHistorian(this.agentHistorian());
261
+ }
262
+ return this.agents.rerunner;
263
+ }
264
+
248
265
  agentBosun(): Bosun {
249
266
  return (this.agents.bosun ||= this.createAgent(({ ai, explorer }) => {
250
267
  const researcher = this.agentResearcher();
@@ -291,6 +308,10 @@ export class ExplorBot {
291
308
  return this.currentPlan;
292
309
  }
293
310
 
311
+ getSuite(): Suite | null {
312
+ return this.agentPlanner().getSuite();
313
+ }
314
+
294
315
  getPlanFeature(): string | undefined {
295
316
  return this.planFeature;
296
317
  }
package/src/explorer.ts CHANGED
@@ -19,6 +19,7 @@ import { Test } from './test-plan.ts';
19
19
  import { RequestStore } from './api/request-store.ts';
20
20
  import { XhrCapture } from './api/xhr-capture.ts';
21
21
  import { createDebug, log, tag } from './utils/logger.js';
22
+ import { WebElement, extractElementData } from './utils/web-element.ts';
22
23
 
23
24
  declare global {
24
25
  namespace NodeJS {
@@ -308,35 +309,24 @@ class Explorer {
308
309
  return action;
309
310
  }
310
311
 
311
- async annotateElements(): Promise<number> {
312
- const page = this.playwrightHelper.page;
313
- const roles = ['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem'];
314
- let idx = 1;
315
- for (const role of roles) {
316
- const elements = await page.getByRole(role).all();
317
- for (const el of elements) {
318
- await el.evaluate((node: Element, i: number) => {
319
- node.setAttribute('data-explorbot-eidx', String(i));
320
- }, idx);
321
- idx++;
322
- }
323
- }
324
- return idx - 1;
312
+ async annotateElements(): Promise<WebElement[]> {
313
+ const { elements } = await annotatePageElements(this.playwrightHelper.page);
314
+ return elements;
325
315
  }
326
316
 
327
317
  async visuallyAnnotateElements(opts?: { containers?: Array<{ css: string; label: string }> }): Promise<number> {
328
318
  return visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []);
329
319
  }
330
320
 
331
- async getEidxInContainer(containerCss: string | null): Promise<number[]> {
321
+ async getEidxInContainer(containerCss: string | null): Promise<string[]> {
332
322
  const page = this.playwrightHelper.page;
333
323
  try {
334
324
  const selector = containerCss ? `${containerCss} [data-explorbot-eidx]` : '[data-explorbot-eidx]';
335
325
  const elements = await page.locator(selector).all();
336
- const result: number[] = [];
326
+ const result: string[] = [];
337
327
  for (const el of elements) {
338
328
  const attr = await el.getAttribute('data-explorbot-eidx');
339
- if (attr) result.push(Number.parseInt(attr, 10));
329
+ if (attr) result.push(attr);
340
330
  }
341
331
  return result;
342
332
  } catch (error) {
@@ -348,13 +338,12 @@ class Explorer {
348
338
  }
349
339
  }
350
340
 
351
- async getEidxByLocator(locator: string, container?: string | null): Promise<number | null> {
341
+ async getEidxByLocator(locator: string, container?: string | null): Promise<string | null> {
352
342
  try {
353
343
  const page = this.playwrightHelper.page;
354
344
  const base = container ? page.locator(container) : page;
355
345
  const el = locator.startsWith('//') ? base.locator(`xpath=${locator}`) : base.locator(locator);
356
- const eidx = await el.first().getAttribute('data-explorbot-eidx');
357
- return eidx ? Number.parseInt(eidx, 10) : null;
346
+ return await el.first().getAttribute('data-explorbot-eidx');
358
347
  } catch (error) {
359
348
  if (this.isFatalBrowserError(error)) {
360
349
  tag('warning').log(`getEidxByLocator: ${error instanceof Error ? error.message : error}`);
@@ -710,4 +699,63 @@ function toCodeceptjsTest(test: Test): any {
710
699
  return codeceptjsTest;
711
700
  }
712
701
 
702
+ const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
703
+
704
+ const ANNOTATABLE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
705
+
706
+ function parseAriaRefs(ariaSnapshot: string): Array<{ role: string; name: string; ref: string }> {
707
+ const entries: Array<{ role: string; name: string; ref: string }> = [];
708
+ for (const line of ariaSnapshot.split('\n')) {
709
+ const match = line.match(REF_LINE_PATTERN);
710
+ if (!match) continue;
711
+ if (!ANNOTATABLE_ROLES.has(match[2])) continue;
712
+ entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
713
+ }
714
+ return entries;
715
+ }
716
+
717
+ export async function annotatePageElements(page: any): Promise<{ ariaSnapshot: string; elements: WebElement[] }> {
718
+ const ariaSnapshot: string = await page.locator('body').ariaSnapshot({ forAI: true });
719
+ const refEntries = parseAriaRefs(ariaSnapshot);
720
+
721
+ const byRole = new Map<string, Array<{ name: string; ref: string }>>();
722
+ for (const { role, name, ref } of refEntries) {
723
+ let list = byRole.get(role);
724
+ if (!list) {
725
+ list = [];
726
+ byRole.set(role, list);
727
+ }
728
+ list.push({ name, ref });
729
+ }
730
+
731
+ const elements: WebElement[] = [];
732
+ for (const [role, entries] of byRole) {
733
+ try {
734
+ const rawList = await page.getByRole(role).evaluateAll(
735
+ (domElements: Element[], [data, extractFnStr]: [Array<{ name: string; ref: string }>, string]) => {
736
+ const extract = new Function(`return ${extractFnStr}`)() as (el: Element) => any;
737
+ const results: any[] = [];
738
+ let ariaIdx = 0;
739
+ for (const el of domElements) {
740
+ if (ariaIdx >= data.length) break;
741
+ el.setAttribute('data-explorbot-eidx', data[ariaIdx].ref);
742
+ const elData = extract(el);
743
+ if (elData) results.push(elData);
744
+ ariaIdx++;
745
+ }
746
+ return results;
747
+ },
748
+ [entries, extractElementData.toString()]
749
+ );
750
+ for (const raw of rawList) {
751
+ elements.push(WebElement.fromRawData(raw, role));
752
+ }
753
+ } catch {
754
+ debugLog(`Failed to annotate role=${role}`);
755
+ }
756
+ }
757
+
758
+ return { ariaSnapshot, elements };
759
+ }
760
+
713
761
  export default Explorer;
package/src/suite.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Reflection } from '@codeceptjs/reflection';
4
+ import { ConfigParser } from './config.ts';
5
+ import { normalizeUrl } from './state-manager.ts';
6
+ import { parsePlanFromMarkdown } from './utils/test-plan-markdown.ts';
7
+ import { createDebug } from './utils/logger.ts';
8
+
9
+ const debugLog = createDebug('explorbot:suite');
10
+
11
+ export class Suite {
12
+ readonly url: string;
13
+ private _automatedTests: AutomatedTest[] | null = null;
14
+ private _plannedScenarios: string[] | null = null;
15
+
16
+ constructor(url: string) {
17
+ this.url = url;
18
+ }
19
+
20
+ getAutomatedTests(): AutomatedTest[] {
21
+ if (this._automatedTests !== null) return this._automatedTests;
22
+ this._automatedTests = this.loadAutomatedTests();
23
+ return this._automatedTests;
24
+ }
25
+
26
+ getPlannedScenarios(): string[] {
27
+ if (this._plannedScenarios !== null) return this._plannedScenarios;
28
+ this._plannedScenarios = this.loadPlannedScenarios();
29
+ return this._plannedScenarios;
30
+ }
31
+
32
+ getActiveScenarioTitles(): Set<string> {
33
+ return new Set(
34
+ this.getAutomatedTests()
35
+ .filter((t) => !t.pending)
36
+ .map((t) => t.title.toLowerCase())
37
+ );
38
+ }
39
+
40
+ get automatedTestCount(): number {
41
+ return this.getAutomatedTests().filter((t) => !t.pending).length;
42
+ }
43
+
44
+ getAutomatedTestNames(): string[] {
45
+ return this.getAutomatedTests()
46
+ .filter((t) => !t.pending)
47
+ .map((t) => t.title);
48
+ }
49
+
50
+ getAutomatedTestFiles(): string[] {
51
+ return [...new Set(this.getAutomatedTests().map((t) => t.file))];
52
+ }
53
+
54
+ private loadAutomatedTests(): AutomatedTest[] {
55
+ const testsDir = ConfigParser.getInstance().getTestsDir();
56
+ if (!existsSync(testsDir)) return [];
57
+
58
+ const jsFiles = readdirSync(testsDir)
59
+ .filter((f) => f.endsWith('.js'))
60
+ .map((f) => path.resolve(testsDir, f));
61
+
62
+ const results: AutomatedTest[] = [];
63
+
64
+ for (const filePath of jsFiles) {
65
+ const parsed = this.parseTestFile(filePath);
66
+ if (!parsed) continue;
67
+ if (normalizeUrl(parsed.url) !== normalizeUrl(this.url)) continue;
68
+ results.push(...parsed.tests);
69
+ }
70
+
71
+ return results;
72
+ }
73
+
74
+ private parseTestFile(filePath: string): { url: string; tests: AutomatedTest[] } | null {
75
+ try {
76
+ const scanned = Reflection.scanFile(filePath);
77
+ if (!scanned.suites?.length) return null;
78
+
79
+ const content = readFileSync(filePath, 'utf-8');
80
+
81
+ const suiteRef = Reflection.forSuite(scanned.suites[0]);
82
+ const beforeHooks = suiteRef.findHook('Before');
83
+ if (!beforeHooks?.length) return null;
84
+
85
+ const hookBody = content.slice(beforeHooks[0].range.start, beforeHooks[0].range.end);
86
+ const match = hookBody.match(/I\.amOnPage\(['"]([^'"]+)['"]\)/);
87
+ if (!match) return null;
88
+
89
+ const lines = content.split('\n');
90
+ const tests = (scanned.tests || []).map((t: any) => {
91
+ const line = lines[t.line - 1] || '';
92
+ const pending = line.includes('Scenario.skip') || line.includes('Scenario.todo');
93
+ return { title: t.title, pending, file: filePath };
94
+ });
95
+
96
+ return { url: match[1], tests };
97
+ } catch (err: any) {
98
+ debugLog('Failed to parse test file %s: %s', filePath, err.message);
99
+ return null;
100
+ }
101
+ }
102
+
103
+ private loadPlannedScenarios(): string[] {
104
+ try {
105
+ const plansDir = ConfigParser.getInstance().getPlansDir();
106
+ if (!existsSync(plansDir)) return [];
107
+
108
+ const mdFiles = readdirSync(plansDir)
109
+ .filter((f) => f.endsWith('.md'))
110
+ .map((f) => path.resolve(plansDir, f));
111
+
112
+ const scenarios: string[] = [];
113
+
114
+ for (const filePath of mdFiles) {
115
+ const plan = parsePlanFromMarkdown(filePath);
116
+ if (!plan.url) continue;
117
+ if (normalizeUrl(plan.url) !== normalizeUrl(this.url)) continue;
118
+ for (const test of plan.tests) {
119
+ scenarios.push(test.scenario);
120
+ }
121
+ }
122
+
123
+ return scenarios;
124
+ } catch (err: any) {
125
+ debugLog('Failed to load planned scenarios: %s', err.message);
126
+ return [];
127
+ }
128
+ }
129
+ }
130
+
131
+ interface AutomatedTest {
132
+ title: string;
133
+ pending: boolean;
134
+ file: string;
135
+ }
package/src/utils/html.ts CHANGED
@@ -486,18 +486,14 @@ export function htmlMinimalUISnapshot(html: string, htmlConfig?: HtmlConfig['min
486
486
  node.attrs = node.attrs.filter((attr) => {
487
487
  const { name, value } = attr;
488
488
  if (name === 'class') {
489
- // Remove classes containing digits
490
489
  attr.value = value
491
490
  .split(' ')
492
- // remove classes containing digits/
493
491
  .filter((className) => !/\d/.test(className))
494
- // remove popular trash classes
495
492
  .filter((className) => !className.match(trashHtmlClasses))
496
- // remove classes with : and __ in them
497
493
  .filter((className) => !className.match(/(:|__)/))
498
- // remove tailwind utility classes
499
494
  .filter((className) => !TAILWIND_CLASS_PATTERNS.some((pattern) => pattern.test(className)))
500
495
  .join(' ');
496
+ if (attr.value === '') return false;
501
497
  }
502
498
 
503
499
  return allowedAttrs.includes(name) || name.startsWith('data-explorbot-');
@@ -65,29 +65,47 @@ export class RulesLoader {
65
65
  return { name, approach: styles[name] };
66
66
  }
67
67
 
68
- static extractStyles(agentName: string, targetDir: string): string[] {
69
- const sourceDir = join(BUILT_IN_DIR, agentName, 'styles');
70
- if (!existsSync(sourceDir)) throw new Error(`No built-in styles found for agent: ${agentName}`);
68
+ static extractRules(agentName: string, targetDir: string): string[] {
69
+ const sourceDir = join(BUILT_IN_DIR, agentName);
70
+ if (!existsSync(sourceDir)) throw new Error(`No built-in rules found for agent: ${agentName}`);
71
71
 
72
+ const extracted: string[] = [];
73
+ copyMarkdownTree(sourceDir, targetDir, '', extracted);
74
+ return extracted;
75
+ }
76
+ }
77
+
78
+ function copyMarkdownTree(sourceDir: string, targetDir: string, relative: string, extracted: string[]): void {
79
+ const entries = readdirSync(sourceDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
80
+
81
+ let dirCreated = false;
82
+ const ensureTargetDir = () => {
83
+ if (dirCreated) return;
72
84
  mkdirSync(targetDir, { recursive: true });
85
+ dirCreated = true;
86
+ };
73
87
 
74
- const files = readdirSync(sourceDir)
75
- .filter((f) => f.endsWith('.md'))
76
- .sort();
77
- const extracted: string[] = [];
88
+ for (const entry of entries) {
89
+ const sourcePath = join(sourceDir, entry.name);
90
+ const targetPath = join(targetDir, entry.name);
91
+ const relPath = relative ? `${relative}/${entry.name}` : entry.name;
78
92
 
79
- for (const file of files) {
80
- const target = join(targetDir, file);
81
- if (existsSync(target)) {
82
- tag('info').log(`Skipping ${file} (already exists)`);
83
- continue;
84
- }
85
- writeFileSync(target, readFileSync(join(sourceDir, file), 'utf8'));
86
- extracted.push(file);
87
- tag('success').log(`Extracted ${file}`);
93
+ if (entry.isDirectory()) {
94
+ copyMarkdownTree(sourcePath, targetPath, relPath, extracted);
95
+ continue;
88
96
  }
89
97
 
90
- return extracted;
98
+ if (!entry.name.endsWith('.md')) continue;
99
+
100
+ if (existsSync(targetPath)) {
101
+ tag('info').log(`Skipping ${relPath} (already exists)`);
102
+ continue;
103
+ }
104
+
105
+ ensureTargetDir();
106
+ writeFileSync(targetPath, readFileSync(sourcePath, 'utf8'));
107
+ extracted.push(relPath);
108
+ tag('success').log(`Extracted ${relPath}`);
91
109
  }
92
110
  }
93
111
 
@@ -0,0 +1,122 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { highlight } from 'cli-highlight';
5
+ import * as codeceptjs from 'codeceptjs';
6
+ import store from 'codeceptjs/lib/store';
7
+ import stepsListener from 'codeceptjs/lib/listener/steps';
8
+ import storeListener from 'codeceptjs/lib/listener/store';
9
+ import figureSet from 'figures';
10
+ import { ConfigParser } from '../config.ts';
11
+
12
+ export function loadTestSuites(testsDir: string): any[] {
13
+ if (!existsSync(testsDir)) return [];
14
+
15
+ const jsFiles = readdirSync(testsDir)
16
+ .filter((f) => f.endsWith('.js'))
17
+ .map((f) => path.resolve(testsDir, f));
18
+
19
+ if (jsFiles.length === 0) return [];
20
+
21
+ codeceptjs.container.createMocha();
22
+ const mocha = codeceptjs.container.mocha();
23
+ mocha.files = jsFiles;
24
+ mocha.loadFiles();
25
+
26
+ return mocha.suite.suites || [];
27
+ }
28
+
29
+ export function printTestList(suites: any[]): void {
30
+ if (suites.length === 0) {
31
+ console.log(chalk.yellow('No test files found. Run /explore first.'));
32
+ return;
33
+ }
34
+
35
+ let totalActive = 0;
36
+ let totalSkipped = 0;
37
+ let index = 0;
38
+
39
+ for (const suite of suites) {
40
+ const file = path.relative(process.cwd(), suite.file || '');
41
+ const active = suite.tests.filter((t: any) => !t.pending).length;
42
+ const skipped = suite.tests.filter((t: any) => t.pending).length;
43
+ totalActive += active;
44
+ totalSkipped += skipped;
45
+
46
+ console.log(`\n${chalk.bold.cyan(suite.title)}`);
47
+ console.log(chalk.gray(file));
48
+
49
+ for (const test of suite.tests) {
50
+ const idx = chalk.dim(`${++index}.`);
51
+ if (test.pending) {
52
+ console.log(chalk.gray(` ${idx} ${figureSet.line} ${test.title} (skipped)`));
53
+ } else {
54
+ console.log(` ${idx} ${chalk.green(figureSet.pointer)} ${test.title}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ console.log(`\n${chalk.bold(`${totalActive + totalSkipped}`)} scenarios (${chalk.green(`${totalActive} active`)}, ${chalk.gray(`${totalSkipped} skipped`)})`);
60
+ }
61
+
62
+ export async function dryRunTestFile(filePath: string): Promise<void> {
63
+ const absPath = path.resolve(filePath);
64
+ if (!existsSync(absPath)) {
65
+ console.log(chalk.yellow(`File not found: ${absPath}`));
66
+ return;
67
+ }
68
+
69
+ const config = ConfigParser.getInstance().getConfig();
70
+ const configPath = ConfigParser.getInstance().getConfigPath();
71
+ const projectRoot = configPath ? path.dirname(configPath) : process.cwd();
72
+
73
+ const codeceptConfig = {
74
+ helpers: {
75
+ Playwright: { browser: config.playwright.browser, url: config.playwright.url },
76
+ },
77
+ };
78
+
79
+ (global as any).output_dir = path.join(projectRoot, 'output', 'states');
80
+ (global as any).codecept_dir = projectRoot;
81
+
82
+ codeceptjs.container.create(codeceptConfig, {});
83
+ await codeceptjs.recorder.start();
84
+ await codeceptjs.container.started(null);
85
+
86
+ store.dryRun = true;
87
+ (global as any).container = codeceptjs.container;
88
+ storeListener();
89
+ stepsListener();
90
+
91
+ codeceptjs.container.createMocha();
92
+ const mocha = codeceptjs.container.mocha();
93
+ mocha.reporter(class {});
94
+ mocha.files = [absPath];
95
+ mocha.loadFiles();
96
+
97
+ let currentSuite = '';
98
+
99
+ codeceptjs.event.dispatcher.on('suite.before', (suite: any) => {
100
+ if (suite.title && suite.title !== currentSuite) {
101
+ currentSuite = suite.title;
102
+ console.log(`\n${chalk.bold.cyan(suite.title)}`);
103
+ console.log(chalk.gray(path.relative(process.cwd(), suite.file || absPath)));
104
+ }
105
+ });
106
+
107
+ codeceptjs.event.dispatcher.on('test.before', (t: any) => {
108
+ console.log(`\n ${chalk.green(figureSet.pointer)} ${chalk.bold(t.title)}`);
109
+ });
110
+
111
+ codeceptjs.event.dispatcher.on('step.start', (step: any) => {
112
+ const code = highlight(step.toCode(), { language: 'javascript' });
113
+ console.log(chalk.dim(` ${code}`));
114
+ });
115
+
116
+ await new Promise<void>((resolve) => {
117
+ const runner = mocha.run(() => resolve());
118
+ runner.on('pending', (t: any) => {
119
+ console.log(chalk.gray(` ${figureSet.line} ${t.title} (skipped)`));
120
+ });
121
+ });
122
+ }