explorbot 0.5.9 → 0.6.0

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 (167) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +495 -482
  3. package/boat/api-tester/src/ai/curler.ts +1 -1
  4. package/boat/api-tester/src/cli.ts +4 -4
  5. package/boat/api-tester/src/commands/init-command.ts +2 -0
  6. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -1
  7. package/boat/doc-collector/src/screenshots.ts +1 -1
  8. package/boat/prima/bin/prima-cli.ts +2 -1
  9. package/dist/bin/explorbot-cli.js +456 -444
  10. package/dist/boat/api-tester/bin/apibot-cli.js +2 -1
  11. package/dist/boat/api-tester/src/ai/curler.js +1 -1
  12. package/dist/boat/api-tester/src/cli.js +4 -4
  13. package/dist/boat/api-tester/src/commands/init-command.js +2 -0
  14. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -1
  15. package/dist/boat/prima/bin/prima-cli.js +2 -1
  16. package/dist/models.json +1 -1
  17. package/dist/package.json +2 -1
  18. package/dist/rules/navigator/output.md +2 -8
  19. package/dist/rules/navigator/verification-actions.md +6 -2
  20. package/dist/rules/planner/ignored-pages.md +1 -0
  21. package/dist/src/action-result.js +1 -1
  22. package/dist/src/action.d.ts +0 -1
  23. package/dist/src/action.js +0 -4
  24. package/dist/src/ai/captain/idle-mode.js +42 -26
  25. package/dist/src/ai/captain/mixin.d.ts +2 -0
  26. package/dist/src/ai/captain/web-mode.js +11 -0
  27. package/dist/src/ai/captain.d.ts +6 -4
  28. package/dist/src/ai/captain.js +25 -1
  29. package/dist/src/ai/driller.js +1 -1
  30. package/dist/src/ai/experience-compactor.js +2 -2
  31. package/dist/src/ai/judge.d.ts +7 -0
  32. package/dist/src/ai/judge.js +8 -1
  33. package/dist/src/ai/navigator.d.ts +1 -0
  34. package/dist/src/ai/navigator.js +11 -7
  35. package/dist/src/ai/planner/subpages.js +1 -1
  36. package/dist/src/ai/planner.d.ts +12 -5
  37. package/dist/src/ai/planner.js +138 -86
  38. package/dist/src/ai/provider.d.ts +1 -1
  39. package/dist/src/ai/provider.js +16 -5
  40. package/dist/src/ai/rerunner.js +1 -1
  41. package/dist/src/ai/researcher/cache.js +14 -6
  42. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
  43. package/dist/src/ai/researcher/deep-analysis.js +7 -2
  44. package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
  45. package/dist/src/ai/researcher.js +44 -24
  46. package/dist/src/ai/rules.js +6 -7
  47. package/dist/src/ai/scout.d.ts +12 -2
  48. package/dist/src/ai/scout.js +103 -8
  49. package/dist/src/ai/tester.d.ts +9 -5
  50. package/dist/src/ai/tester.js +22 -42
  51. package/dist/src/ai/tools.js +10 -8
  52. package/dist/src/application-spec.d.ts +2 -0
  53. package/dist/src/application-spec.js +17 -12
  54. package/dist/src/commands/context-experience-command.js +0 -1
  55. package/dist/src/commands/context-knowledge-command.js +0 -1
  56. package/dist/src/commands/explore-command.d.ts +3 -2
  57. package/dist/src/commands/explore-command.js +35 -23
  58. package/dist/src/commands/help-json-command.js +3 -1
  59. package/dist/src/commands/init-command.js +8 -4
  60. package/dist/src/commands/learn-command.js +0 -2
  61. package/dist/src/commands/options/auth-option.d.ts +6 -0
  62. package/dist/src/commands/options/auth-option.js +14 -0
  63. package/dist/src/commands/options/index.d.ts +2 -0
  64. package/dist/src/commands/options/index.js +2 -0
  65. package/dist/src/commands/plan-command.d.ts +3 -0
  66. package/dist/src/commands/plan-command.js +43 -17
  67. package/dist/src/commands/plan-save-command.js +6 -17
  68. package/dist/src/commands/plans-command.js +1 -1
  69. package/dist/src/commands/test-command.js +12 -2
  70. package/dist/src/components/App.d.ts +1 -2
  71. package/dist/src/components/App.js +1 -3
  72. package/dist/src/components/InputPane.js +0 -5
  73. package/dist/src/config.d.ts +3 -2
  74. package/dist/src/config.js +3 -12
  75. package/dist/src/explorbot.d.ts +3 -2
  76. package/dist/src/explorbot.js +32 -10
  77. package/dist/src/explorer.js +6 -7
  78. package/dist/src/knowledge-tracker.d.ts +2 -2
  79. package/dist/src/knowledge-tracker.js +18 -6
  80. package/dist/src/playwright-recorder.d.ts +0 -1
  81. package/dist/src/playwright-recorder.js +0 -2
  82. package/dist/src/reporter.d.ts +1 -1
  83. package/dist/src/reporter.js +3 -3
  84. package/dist/src/test-plan.js +1 -1
  85. package/dist/src/utils/error-page.d.ts +17 -0
  86. package/dist/src/utils/error-page.js +53 -2
  87. package/dist/src/utils/html-diff.d.ts +1 -2
  88. package/dist/src/utils/html-diff.js +4 -44
  89. package/dist/src/utils/logger.js +27 -10
  90. package/dist/src/utils/markdown-terminal.js +1 -2
  91. package/dist/src/utils/next-steps.js +20 -16
  92. package/dist/src/utils/page-readiness.d.ts +2 -1
  93. package/dist/src/utils/page-readiness.js +24 -5
  94. package/dist/src/utils/test-plan-markdown.d.ts +3 -0
  95. package/dist/src/utils/test-plan-markdown.js +35 -4
  96. package/dist/src/utils/url-matcher.d.ts +1 -0
  97. package/dist/src/utils/url-matcher.js +5 -0
  98. package/docs/basics/providers.md +1 -1
  99. package/docs/contributing/regression-tests.md +19 -4
  100. package/docs/reference/commands.md +18 -2
  101. package/docs/reference/configuration.md +1 -1
  102. package/docs/web-testing/agents.md +8 -0
  103. package/docs/web-testing/customization.md +2 -0
  104. package/docs/web-testing/researcher.md +1 -1
  105. package/docs/workflow/agentic-usage.md +7 -0
  106. package/docs/workflow/knowledge.md +6 -0
  107. package/models.json +1 -1
  108. package/package.json +2 -1
  109. package/rules/navigator/output.md +2 -8
  110. package/rules/navigator/verification-actions.md +6 -2
  111. package/rules/planner/ignored-pages.md +1 -0
  112. package/src/action-result.ts +1 -2
  113. package/src/action.ts +0 -5
  114. package/src/ai/captain/idle-mode.ts +38 -26
  115. package/src/ai/captain/mixin.ts +2 -1
  116. package/src/ai/captain/web-mode.ts +12 -0
  117. package/src/ai/captain.ts +25 -7
  118. package/src/ai/driller.ts +1 -2
  119. package/src/ai/experience-compactor.ts +2 -2
  120. package/src/ai/judge.ts +10 -1
  121. package/src/ai/navigator.ts +12 -7
  122. package/src/ai/planner/subpages.ts +1 -2
  123. package/src/ai/planner.ts +140 -92
  124. package/src/ai/provider.ts +15 -6
  125. package/src/ai/rerunner.ts +1 -2
  126. package/src/ai/researcher/cache.ts +16 -7
  127. package/src/ai/researcher/deep-analysis.ts +7 -2
  128. package/src/ai/researcher/fingerprint-worker.ts +5 -5
  129. package/src/ai/researcher.ts +44 -28
  130. package/src/ai/rules.ts +6 -7
  131. package/src/ai/scout.ts +98 -10
  132. package/src/ai/tester.ts +29 -47
  133. package/src/ai/tools.ts +11 -9
  134. package/src/application-spec.ts +19 -13
  135. package/src/commands/clean-command.ts +1 -1
  136. package/src/commands/context-command.ts +0 -1
  137. package/src/commands/context-experience-command.ts +0 -1
  138. package/src/commands/context-knowledge-command.ts +0 -1
  139. package/src/commands/explore-command.ts +36 -23
  140. package/src/commands/help-json-command.ts +8 -6
  141. package/src/commands/init-command.ts +10 -3
  142. package/src/commands/learn-command.ts +0 -2
  143. package/src/commands/options/auth-option.ts +15 -0
  144. package/src/commands/options/index.ts +2 -0
  145. package/src/commands/plan-command.ts +44 -17
  146. package/src/commands/plan-save-command.ts +6 -17
  147. package/src/commands/plans-command.ts +1 -1
  148. package/src/commands/test-command.ts +11 -2
  149. package/src/components/App.tsx +2 -6
  150. package/src/components/InputPane.tsx +0 -5
  151. package/src/config.ts +5 -16
  152. package/src/experience-tracker.ts +1 -1
  153. package/src/explorbot.ts +31 -11
  154. package/src/explorer.ts +6 -7
  155. package/src/knowledge-tracker.ts +18 -7
  156. package/src/playwright-recorder.ts +0 -2
  157. package/src/reporter.ts +3 -4
  158. package/src/test-plan.ts +1 -2
  159. package/src/utils/error-page.ts +64 -2
  160. package/src/utils/html-diff.ts +4 -56
  161. package/src/utils/html.ts +0 -1
  162. package/src/utils/logger.ts +30 -12
  163. package/src/utils/markdown-terminal.ts +1 -2
  164. package/src/utils/next-steps.ts +19 -17
  165. package/src/utils/page-readiness.ts +33 -7
  166. package/src/utils/test-plan-markdown.ts +36 -4
  167. package/src/utils/url-matcher.ts +6 -0
@@ -4,8 +4,8 @@ import { type Span, context, trace } from '@opentelemetry/api';
4
4
  import chalk from 'chalk';
5
5
  import debug from 'debug';
6
6
  import dedent from 'dedent';
7
- import { marked } from 'marked';
8
7
  import stripAnsi from 'strip-ansi';
8
+ import wrapAnsi from 'wrap-ansi';
9
9
  import { ConfigParser } from '../config.js';
10
10
  import { Observability } from '../observability.ts';
11
11
  import { RecentStepFilter } from './log-filters.ts';
@@ -81,6 +81,11 @@ class ConsoleDestination implements LogDestination {
81
81
  private forceEnabled = false;
82
82
  private quiet = false;
83
83
  private recentSteps = new RecentStepFilter();
84
+ private width: number;
85
+
86
+ constructor() {
87
+ this.width = process.stdout.columns || 80;
88
+ }
84
89
 
85
90
  isEnabled(): boolean {
86
91
  if (this.quiet) return false;
@@ -113,8 +118,8 @@ class ConsoleDestination implements LogDestination {
113
118
  if (entry.type === 'multiline' || entry.type === 'details') {
114
119
  const cleaned = stripAnsi(dedent(entry.content));
115
120
  const parsed = parseMarkdownToTerminal(cleaned);
116
- content = parsed;
117
- content = `${chalk.gray(content)}\n`;
121
+
122
+ content = `${chalk.dim('\n ┌'.padEnd(this.width, '─'))}\n${this.prefixLines(parsed, ' │ ', chalk.dim, false)}\n`;
118
123
  } else if (entry.type === 'success') {
119
124
  content = `${chalk.green(content)}\n`;
120
125
  } else if (entry.type === 'error') {
@@ -122,14 +127,32 @@ class ConsoleDestination implements LogDestination {
122
127
  } else if (entry.type === 'warning') {
123
128
  content = chalk.yellow(content);
124
129
  } else if (entry.type === 'step') {
125
- content = chalk.gray(` ${content}`);
130
+ content = this.prefixLines(content, ' ', chalk.gray);
126
131
  } else if (entry.type === 'operation') {
127
- content = chalk.gray(` · ${content}`);
132
+ content = this.prefixLines(content, ' · ', chalk.gray);
128
133
  } else if (entry.type === 'substep') {
129
- content = chalk.gray(` > ${content}`);
134
+ content = this.prefixLines(content, ' > ', chalk.gray);
130
135
  }
131
136
  console.log(content);
132
137
  }
138
+
139
+ private prefixLines(content: string, prefix: string, style: (s: string) => string, strip = true): string {
140
+ const prefixWidth = stripAnsi(prefix).length;
141
+ const available = Math.max(20, this.width - prefixWidth);
142
+ const lines = content
143
+ .split('\n')
144
+ .filter((line) => stripAnsi(line).trim() !== '')
145
+ .flatMap((line) => {
146
+ const text = strip ? stripAnsi(line) : line;
147
+ const wrapped = wrapAnsi(text, available, { hard: true });
148
+ return wrapped.split('\n').map((part) => style(`${prefix}${part}`));
149
+ });
150
+ if (lines.length > 10) {
151
+ const remaining = lines.length - 10;
152
+ return [...lines.slice(0, 10), chalk.gray(` │ --- +${remaining} lines ---`)].join('\n');
153
+ }
154
+ return lines.join('\n');
155
+ }
133
156
  }
134
157
 
135
158
  class DebugDestination implements LogDestination {
@@ -160,16 +183,11 @@ class DebugDestination implements LogDestination {
160
183
  class FileDestination implements LogDestination {
161
184
  private initialized = false;
162
185
  private logFilePath: string | null = null;
163
- private verboseMode = true;
164
186
 
165
187
  isEnabled(): boolean {
166
188
  return true;
167
189
  }
168
190
 
169
- setVerboseMode(enabled: boolean): void {
170
- this.verboseMode = enabled;
171
- }
172
-
173
191
  write(entry: TaggedLogEntry): void {
174
192
  if (entry.type === 'html') return;
175
193
  if (entry.type === 'multiline') return;
@@ -404,7 +422,7 @@ class Logger {
404
422
  private truncateTagContent(content: string, tagName: string): string {
405
423
  const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
406
424
  const tagRegex = new RegExp(`<${escapedTag}>([\\s\\S]*?)<\\/${escapedTag}>`, 'g');
407
- return content.replace(tagRegex, (match, innerContent) => {
425
+ return content.replace(tagRegex, (_, innerContent) => {
408
426
  const trimmed = innerContent.trim();
409
427
  const totalLength = trimmed.length;
410
428
  if (totalLength <= 100) {
@@ -24,10 +24,9 @@ if (renderer) {
24
24
  const originalListitem = renderer.listitem;
25
25
  renderer.listitem = function (text: any) {
26
26
  if (typeof text === 'object') {
27
- const item = text;
28
27
  const parser = this.parser;
29
28
  const originalParse = parser.parse.bind(parser);
30
- parser.parse = (tokens: any, loose?: boolean) => parser.parseInline(tokens);
29
+ parser.parse = (tokens: any) => parser.parseInline(tokens);
31
30
  const result = originalListitem.call(this, text);
32
31
  parser.parse = originalParse;
33
32
  return result;
@@ -1,4 +1,6 @@
1
1
  import path from 'node:path';
2
+ import chalk from 'chalk';
3
+ import figures from 'figures';
2
4
  import { tag } from './logger.js';
3
5
 
4
6
  export interface NextStepCommand {
@@ -20,27 +22,27 @@ export function relativeToCwd(absPath: string): string {
20
22
  export function printNextSteps(sections: NextStepSection[]): void {
21
23
  if (sections.length === 0) return;
22
24
 
23
- const blocks: string[] = [];
25
+ const lines: string[] = [''];
26
+ let isDefault = true;
24
27
  for (const section of sections) {
25
- const lines: string[] = [];
26
28
  const headerPath = section.path ? relativeToCwd(section.path) : '';
27
- lines.push(headerPath ? `${section.label}: ${headerPath}` : section.label);
28
-
29
- const commands = section.commands || [];
30
- if (commands.length > 0) {
31
- const labeled = commands.filter((c) => c.label);
32
- const maxLabel = labeled.length > 0 ? Math.max(...labeled.map((c) => c.label.length)) : 0;
33
- for (const cmd of commands) {
34
- if (!cmd.label) {
35
- lines.push(` ${cmd.command}`);
36
- continue;
37
- }
38
- const padded = `${cmd.label}:`.padEnd(maxLabel + 2);
39
- lines.push(` ${padded} ${cmd.command}`);
29
+ lines.push(headerPath ? `${chalk.bold(section.label)} ${chalk.gray(headerPath)}` : `${chalk.bold(section.label)}`);
30
+
31
+ for (const cmd of section.commands || []) {
32
+ if (cmd.label) lines.push(BLANK);
33
+ if (isDefault) {
34
+ if (cmd.label) lines.push(chalk.bold.yellow(`${figures.pointer} ${cmd.label}`));
35
+ lines.push(chalk.bold(` ${cmd.command}`));
36
+ isDefault = false;
37
+ continue;
40
38
  }
39
+ if (cmd.label) lines.push(chalk.dim(` ${cmd.label}`));
40
+ lines.push(` ${cmd.command}`);
41
41
  }
42
- blocks.push(lines.join('\n'));
42
+ lines.push(BLANK);
43
43
  }
44
44
 
45
- tag('multiline').log(blocks.join('\n\n'), { maxLines: 18 });
45
+ tag('info').log(lines.join('\n'));
46
46
  }
47
+
48
+ const BLANK = ' ';
@@ -1,11 +1,33 @@
1
- export async function waitForPageReadiness(page: any, options: PageReadinessOptions = {}): Promise<void> {
2
- if (!page) return;
1
+ export async function waitForPageReadiness(page: any, options: PageReadinessOptions = {}): Promise<boolean> {
2
+ if (!page) return false;
3
3
 
4
4
  const timeout = options.timeout ?? 6000;
5
5
  await page.waitForLoadState?.('domcontentloaded', { timeout })?.catch(() => {});
6
6
 
7
7
  await Promise.race([waitForNetworkIdle(page, timeout), waitForDomQuiet(page, timeout), waitForVisibleSpinnersHidden(page, options.spinnerSelectors || [], timeout), sleep(timeout)]).catch(() => {});
8
- await waitForPageBodyContent(page, timeout);
8
+ return waitForPageBodyContent(page, timeout);
9
+ }
10
+
11
+ export function waitForDomChange(page: any, timeout: number): Promise<boolean> {
12
+ if (!page?.evaluate) return Promise.resolve(false);
13
+
14
+ return page
15
+ .evaluate(
16
+ (ms: number) =>
17
+ new Promise<boolean>((resolve) => {
18
+ const observer = new MutationObserver(() => {
19
+ observer.disconnect();
20
+ resolve(true);
21
+ });
22
+ observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
23
+ setTimeout(() => {
24
+ observer.disconnect();
25
+ resolve(false);
26
+ }, ms);
27
+ }),
28
+ timeout
29
+ )
30
+ .catch(() => true);
9
31
  }
10
32
 
11
33
  const DOM_QUIET_MS = 350;
@@ -56,20 +78,24 @@ async function waitForVisibleSpinnersHidden(page: any, selectors: string[], time
56
78
  return Promise.all(visibleSpinners.map((locator) => locator.waitFor({ state: 'hidden', timeout }).catch(() => {}))).then(() => {});
57
79
  }
58
80
 
59
- function waitForPageBodyContent(page: any, timeout: number): Promise<void> {
60
- if (!page?.waitForFunction) return Promise.resolve();
81
+ function waitForPageBodyContent(page: any, timeout: number): Promise<boolean> {
82
+ if (!page?.waitForFunction) return Promise.resolve(true);
61
83
 
62
84
  return page
63
85
  .waitForFunction(
64
86
  () => {
65
87
  const body = document.body;
66
88
  if (!body) return false;
67
- return body.children.length > 0 || body.textContent?.trim().length > 0;
89
+ if (body.innerText.trim().length > 0) return true;
90
+ return !!body.querySelector('input, button, select, textarea, img, svg, canvas, iframe, video');
68
91
  },
69
92
  undefined,
70
93
  { timeout }
71
94
  )
72
- .catch(() => {});
95
+ .then(
96
+ () => true,
97
+ () => false
98
+ );
73
99
  }
74
100
 
75
101
  export function sleep(ms: number): Promise<void> {
@@ -3,6 +3,7 @@ import { type Note, Plan, Test } from '../test-plan.ts';
3
3
  import { mdq } from './markdown-query.ts';
4
4
 
5
5
  const NOISE_PREFIXES = ['Test started', 'Finish requested:', 'Session name:'];
6
+ const CURRENT_PAGE = 'Current page';
6
7
 
7
8
  function isNoiseNote(message: string): boolean {
8
9
  return NOISE_PREFIXES.some((prefix) => message.startsWith(prefix));
@@ -118,7 +119,10 @@ export function parsePlanFromMarkdown(filePath: string): Plan {
118
119
  }
119
120
 
120
121
  export function parsePlansFromMarkdown(filePath: string): Plan[] {
121
- const content = readFileSync(filePath, 'utf-8');
122
+ return parsePlansFromMarkdownText(readFileSync(filePath, 'utf-8'));
123
+ }
124
+
125
+ export function parsePlansFromMarkdownText(content: string): Plan[] {
122
126
  const lines = content.split('\n');
123
127
 
124
128
  const plans: Plan[] = [];
@@ -193,7 +197,7 @@ export function parsePlansFromMarkdown(filePath: string): Plan[] {
193
197
  }
194
198
 
195
199
  if (currentTest && inRequirements && line && !line.startsWith('##')) {
196
- currentTest.startUrl = line;
200
+ if (line !== CURRENT_PAGE) currentTest.startUrl = line;
197
201
  continue;
198
202
  }
199
203
 
@@ -217,7 +221,7 @@ export function parsePlansFromMarkdown(filePath: string): Plan[] {
217
221
  if (suiteStart === -1) continue;
218
222
  const nextSuite = content.indexOf('<!-- suite -->', suiteStart + 1);
219
223
  const suiteContent = nextSuite === -1 ? content.slice(suiteStart) : content.slice(suiteStart, nextSuite);
220
- const firstItem = mdq(suiteContent).query('section[0] item[0]').text().trim();
224
+ const firstItem = mdq(suiteContent).query('section[0] item[0]').nodes()[0]?.text.trim() || '';
221
225
  const urlMatch = firstItem.match(/^URL:\s*(.+)/);
222
226
  if (urlMatch) {
223
227
  plan.url = urlMatch[1].replace(/\*\*|`|\*|_|~~?/g, '').trim();
@@ -230,6 +234,34 @@ export function parsePlansFromMarkdown(filePath: string): Plan[] {
230
234
  return plans;
231
235
  }
232
236
 
237
+ export function findPlanMarkdownProblems(markdown: string): string[] {
238
+ const doc = mdq(markdown);
239
+ const problems: string[] = [];
240
+ const headings = doc.query('h1').nodes();
241
+ if (!doc.query('comment(/^suite/)').exists() || headings.length === 0) {
242
+ problems.push('Missing <!-- suite --> comment followed by the plan name heading');
243
+ }
244
+
245
+ const scenarioCount = Math.max(headings.length - 1, 0);
246
+ const testComments = doc.query('comment(/^test/)').count();
247
+ if (testComments !== scenarioCount) {
248
+ problems.push(`Found ${scenarioCount} scenario headings but ${testComments} <!-- test --> comments`);
249
+ }
250
+
251
+ for (const section of doc.query('section1').each().slice(1)) {
252
+ const title = section.query('heading').nodes()[0]?.text;
253
+ if (!section.query('section2("Steps") item').exists()) problems.push(`Scenario "${title}" has no ## Steps items`);
254
+ if (!section.query('section2("Expected") item').exists()) problems.push(`Scenario "${title}" has no ## Expected items`);
255
+ }
256
+ return problems;
257
+ }
258
+
259
+ export function readPlanMarkdownComment(markdown: string, key: string): string | null {
260
+ const body = mdq(markdown).query(`comment(/^${key}:/)`).nodes()[0]?.text;
261
+ if (!body) return null;
262
+ return body.slice(key.length + 1).trim() || null;
263
+ }
264
+
233
265
  function formatPlanSuite(plan: Plan): string {
234
266
  let content = `<!-- suite -->\n# ${plan.title}\n\n`;
235
267
 
@@ -245,7 +277,7 @@ function formatPlanSuite(plan: Plan): string {
245
277
  content += `<!-- test\npriority: ${test.priority}\n-->\n`;
246
278
  content += `# ${test.scenario}\n\n`;
247
279
  content += '## Requirements\n';
248
- content += `${test.startUrl || 'Current page'}\n\n`;
280
+ content += `${test.startUrl || CURRENT_PAGE}\n\n`;
249
281
 
250
282
  if (test.plannedSteps.length > 0) {
251
283
  content += '## Steps\n';
@@ -44,6 +44,12 @@ export function isSamePageFamily(urlA: string, urlB: string): boolean {
44
44
  return true;
45
45
  }
46
46
 
47
+ export function isSamePageIgnoringQuery(urlA: string, urlB: string): boolean {
48
+ const a = new URL(urlA, 'http://localhost');
49
+ const b = new URL(urlB, 'http://localhost');
50
+ return a.pathname === b.pathname && a.hash === b.hash;
51
+ }
52
+
47
53
  export function generalizeSegment(segment: string): string {
48
54
  if (/^\d+$/.test(segment)) return '\\d+';
49
55
  if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment)) return '[a-f0-9-]+';