explorbot 0.5.10 → 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 (151) hide show
  1. package/bin/explorbot-cli.ts +494 -482
  2. package/boat/api-tester/src/ai/curler.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +4 -4
  4. package/boat/doc-collector/src/screenshots.ts +1 -1
  5. package/dist/bin/explorbot-cli.js +455 -444
  6. package/dist/boat/api-tester/src/ai/curler.js +1 -1
  7. package/dist/boat/api-tester/src/cli.js +4 -4
  8. package/dist/models.json +1 -1
  9. package/dist/package.json +2 -1
  10. package/dist/rules/navigator/output.md +2 -8
  11. package/dist/rules/navigator/verification-actions.md +6 -2
  12. package/dist/rules/planner/ignored-pages.md +1 -0
  13. package/dist/src/action-result.js +1 -1
  14. package/dist/src/action.d.ts +0 -1
  15. package/dist/src/action.js +0 -4
  16. package/dist/src/ai/captain/idle-mode.js +42 -26
  17. package/dist/src/ai/captain/mixin.d.ts +2 -0
  18. package/dist/src/ai/captain/web-mode.js +11 -0
  19. package/dist/src/ai/captain.d.ts +6 -4
  20. package/dist/src/ai/captain.js +25 -1
  21. package/dist/src/ai/driller.js +1 -1
  22. package/dist/src/ai/experience-compactor.js +2 -2
  23. package/dist/src/ai/judge.d.ts +7 -0
  24. package/dist/src/ai/judge.js +8 -1
  25. package/dist/src/ai/navigator.d.ts +1 -0
  26. package/dist/src/ai/navigator.js +11 -7
  27. package/dist/src/ai/planner/subpages.js +1 -1
  28. package/dist/src/ai/planner.d.ts +12 -5
  29. package/dist/src/ai/planner.js +138 -86
  30. package/dist/src/ai/provider.d.ts +1 -1
  31. package/dist/src/ai/provider.js +16 -5
  32. package/dist/src/ai/rerunner.js +1 -1
  33. package/dist/src/ai/researcher/cache.js +14 -6
  34. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
  35. package/dist/src/ai/researcher/deep-analysis.js +7 -2
  36. package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
  37. package/dist/src/ai/researcher.js +44 -24
  38. package/dist/src/ai/rules.js +6 -7
  39. package/dist/src/ai/scout.d.ts +12 -2
  40. package/dist/src/ai/scout.js +103 -8
  41. package/dist/src/ai/tester.d.ts +9 -5
  42. package/dist/src/ai/tester.js +22 -42
  43. package/dist/src/ai/tools.js +10 -8
  44. package/dist/src/application-spec.d.ts +2 -0
  45. package/dist/src/application-spec.js +17 -12
  46. package/dist/src/commands/context-experience-command.js +0 -1
  47. package/dist/src/commands/context-knowledge-command.js +0 -1
  48. package/dist/src/commands/explore-command.d.ts +3 -2
  49. package/dist/src/commands/explore-command.js +35 -23
  50. package/dist/src/commands/help-json-command.js +3 -1
  51. package/dist/src/commands/init-command.js +4 -4
  52. package/dist/src/commands/learn-command.js +0 -2
  53. package/dist/src/commands/plan-command.d.ts +3 -0
  54. package/dist/src/commands/plan-command.js +43 -17
  55. package/dist/src/commands/plan-save-command.js +6 -17
  56. package/dist/src/commands/plans-command.js +1 -1
  57. package/dist/src/commands/test-command.js +12 -2
  58. package/dist/src/components/App.d.ts +1 -2
  59. package/dist/src/components/App.js +1 -3
  60. package/dist/src/components/InputPane.js +0 -5
  61. package/dist/src/config.d.ts +3 -2
  62. package/dist/src/config.js +2 -12
  63. package/dist/src/explorbot.d.ts +3 -2
  64. package/dist/src/explorbot.js +32 -10
  65. package/dist/src/explorer.js +6 -7
  66. package/dist/src/knowledge-tracker.d.ts +2 -2
  67. package/dist/src/knowledge-tracker.js +18 -6
  68. package/dist/src/playwright-recorder.d.ts +0 -1
  69. package/dist/src/playwright-recorder.js +0 -2
  70. package/dist/src/reporter.d.ts +1 -1
  71. package/dist/src/reporter.js +3 -3
  72. package/dist/src/test-plan.js +1 -1
  73. package/dist/src/utils/error-page.d.ts +17 -0
  74. package/dist/src/utils/error-page.js +53 -2
  75. package/dist/src/utils/html-diff.d.ts +1 -2
  76. package/dist/src/utils/html-diff.js +4 -44
  77. package/dist/src/utils/logger.js +27 -10
  78. package/dist/src/utils/markdown-terminal.js +1 -2
  79. package/dist/src/utils/next-steps.js +20 -16
  80. package/dist/src/utils/page-readiness.d.ts +2 -1
  81. package/dist/src/utils/page-readiness.js +24 -5
  82. package/dist/src/utils/test-plan-markdown.d.ts +3 -0
  83. package/dist/src/utils/test-plan-markdown.js +35 -4
  84. package/dist/src/utils/url-matcher.d.ts +1 -0
  85. package/dist/src/utils/url-matcher.js +5 -0
  86. package/docs/basics/providers.md +1 -1
  87. package/docs/contributing/regression-tests.md +19 -4
  88. package/docs/reference/commands.md +6 -2
  89. package/docs/reference/configuration.md +1 -1
  90. package/docs/web-testing/agents.md +8 -0
  91. package/docs/web-testing/customization.md +2 -0
  92. package/docs/web-testing/researcher.md +1 -1
  93. package/models.json +1 -1
  94. package/package.json +2 -1
  95. package/rules/navigator/output.md +2 -8
  96. package/rules/navigator/verification-actions.md +6 -2
  97. package/rules/planner/ignored-pages.md +1 -0
  98. package/src/action-result.ts +1 -2
  99. package/src/action.ts +0 -5
  100. package/src/ai/captain/idle-mode.ts +38 -26
  101. package/src/ai/captain/mixin.ts +2 -1
  102. package/src/ai/captain/web-mode.ts +12 -0
  103. package/src/ai/captain.ts +25 -7
  104. package/src/ai/driller.ts +1 -2
  105. package/src/ai/experience-compactor.ts +2 -2
  106. package/src/ai/judge.ts +10 -1
  107. package/src/ai/navigator.ts +12 -7
  108. package/src/ai/planner/subpages.ts +1 -2
  109. package/src/ai/planner.ts +140 -92
  110. package/src/ai/provider.ts +15 -6
  111. package/src/ai/rerunner.ts +1 -2
  112. package/src/ai/researcher/cache.ts +16 -7
  113. package/src/ai/researcher/deep-analysis.ts +7 -2
  114. package/src/ai/researcher/fingerprint-worker.ts +5 -5
  115. package/src/ai/researcher.ts +44 -28
  116. package/src/ai/rules.ts +6 -7
  117. package/src/ai/scout.ts +98 -10
  118. package/src/ai/tester.ts +29 -47
  119. package/src/ai/tools.ts +11 -9
  120. package/src/application-spec.ts +19 -13
  121. package/src/commands/clean-command.ts +1 -1
  122. package/src/commands/context-command.ts +0 -1
  123. package/src/commands/context-experience-command.ts +0 -1
  124. package/src/commands/context-knowledge-command.ts +0 -1
  125. package/src/commands/explore-command.ts +36 -23
  126. package/src/commands/help-json-command.ts +8 -6
  127. package/src/commands/init-command.ts +5 -3
  128. package/src/commands/learn-command.ts +0 -2
  129. package/src/commands/plan-command.ts +44 -17
  130. package/src/commands/plan-save-command.ts +6 -17
  131. package/src/commands/plans-command.ts +1 -1
  132. package/src/commands/test-command.ts +11 -2
  133. package/src/components/App.tsx +2 -6
  134. package/src/components/InputPane.tsx +0 -5
  135. package/src/config.ts +4 -16
  136. package/src/experience-tracker.ts +1 -1
  137. package/src/explorbot.ts +31 -11
  138. package/src/explorer.ts +6 -7
  139. package/src/knowledge-tracker.ts +18 -7
  140. package/src/playwright-recorder.ts +0 -2
  141. package/src/reporter.ts +3 -4
  142. package/src/test-plan.ts +1 -2
  143. package/src/utils/error-page.ts +64 -2
  144. package/src/utils/html-diff.ts +4 -56
  145. package/src/utils/html.ts +0 -1
  146. package/src/utils/logger.ts +30 -12
  147. package/src/utils/markdown-terminal.ts +1 -2
  148. package/src/utils/next-steps.ts +19 -17
  149. package/src/utils/page-readiness.ts +33 -7
  150. package/src/utils/test-plan-markdown.ts +36 -4
  151. package/src/utils/url-matcher.ts +6 -0
@@ -1,6 +1,7 @@
1
+ import dedent from 'dedent';
1
2
  import { ActionResult } from '../action-result.js';
2
3
  import type { WebPageState } from '../state-manager.js';
3
- import { isBodyEmpty } from './html.js';
4
+ import { isBodyEmpty, sanitizeHtmlString } from './html.js';
4
5
  import { extractStatePath } from './url-matcher.js';
5
6
 
6
7
  const HTTP_ERRORS = ['400 Bad Request', '401 Unauthorized', '403 Forbidden', '404 Not Found', '405 Method Not Allowed', '408 Request Timeout', '500 Internal Server Error', '502 Bad Gateway', '503 Service Unavailable', '504 Gateway Timeout'];
@@ -33,7 +34,7 @@ export function detectPageCondition(actionResult: ActionResult): PageCondition {
33
34
 
34
35
  if (!actionResult.html || isBodyEmpty(actionResult.html)) return 'loading';
35
36
 
36
- const bodyMatch = actionResult.html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
37
+ const bodyMatch = sanitizeHtmlString(actionResult.html).match(/<body[^>]*>([\s\S]*?)<\/body>/i);
37
38
  if (bodyMatch && bodyMatch[1].trim().length < SMALL_PAGE_THRESHOLD) return 'loading';
38
39
 
39
40
  return 'ok';
@@ -67,3 +68,64 @@ export class ErrorPageError extends Error {
67
68
  this.name = 'ErrorPageError';
68
69
  }
69
70
  }
71
+
72
+ export class BlockedError extends Error {
73
+ constructor(
74
+ message: string,
75
+ public readonly url: string,
76
+ public readonly blocker: string,
77
+ public readonly request: string
78
+ ) {
79
+ super(message);
80
+ this.name = 'BlockedError';
81
+ }
82
+ }
83
+
84
+ export class PlanningBlockedError extends BlockedError {
85
+ constructor(
86
+ url: string,
87
+ prerequisite: string,
88
+ public readonly feature?: string,
89
+ public readonly style?: string
90
+ ) {
91
+ let focus = '';
92
+ if (feature) focus = ` focused on "${feature}"`;
93
+ let styleNote = '';
94
+ if (style) styleNote = ` in ${style} style`;
95
+ super(
96
+ `Planner could not plan ${url}: ${prerequisite}`,
97
+ url,
98
+ prerequisite,
99
+ dedent`
100
+ Planner was asked to plan tests for ${url}${focus}${styleNote} but stopped: ${prerequisite}.
101
+ Look at the current page to find out what blocks planning, resolve it, then call plan() to re-plan this page. If it cannot be resolved, finish without planning.
102
+ `
103
+ );
104
+ this.name = 'PlanningBlockedError';
105
+ }
106
+ }
107
+
108
+ export class NavigationBlockedError extends BlockedError {
109
+ constructor(url: string, currentUrl: string, reason: string, tried: string[]) {
110
+ const lines = [`Navigator was asked to open ${url} but stopped on ${currentUrl}: ${reason}.`];
111
+ if (tried.length) lines.push(`Already tried: ${tried.join('; ')}.`);
112
+ lines.push(`Look at the current page to find out what keeps it from ${url}, resolve it, then open ${url}. If it cannot be resolved, finish without opening it.`);
113
+ super(`Navigation to ${url} failed: ${reason}`, url, reason, lines.join('\n'));
114
+ this.name = 'NavigationBlockedError';
115
+ }
116
+ }
117
+
118
+ export class TestStartBlockedError extends BlockedError {
119
+ constructor(scenario: string, url: string, currentUrl: string) {
120
+ super(
121
+ `Test "${scenario}" could not start: ${currentUrl} is not the page it starts on`,
122
+ url,
123
+ 'not the start page of this test',
124
+ dedent`
125
+ Tester was asked to start the test "${scenario}" at ${url}, but the page open at ${currentUrl} is not where this test can start.
126
+ Look at the current page to find out what keeps the test from starting, resolve it, then open ${url}. When you finish, the test runs again from ${url}. If it cannot be resolved, finish and the test is skipped.
127
+ `
128
+ );
129
+ this.name = 'TestStartBlockedError';
130
+ }
131
+ }
@@ -1,6 +1,5 @@
1
1
  import { parse, serialize } from 'parse5';
2
2
  import type * as parse5TreeAdapter from 'parse5/lib/tree-adapters/default';
3
- import type { HtmlConfig } from '../config.ts';
4
3
  import { isTailwindClass, isTrashClass, minifyHtml } from './html.ts';
5
4
  import { isDynamicId, isGenericClass } from './xpath.ts';
6
5
 
@@ -203,9 +202,9 @@ export function computeHtmlFingerprint(html: string): string[] {
203
202
  /**
204
203
  * Compares two HTML documents and returns differences along with a diff subtree.
205
204
  */
206
- export async function htmlDiff(originalHtml: string, modifiedHtml: string, htmlConfig?: HtmlConfig): Promise<HtmlDiffResult> {
207
- const originalDocument = parseDocument(originalHtml, htmlConfig);
208
- const modifiedDocument = parseDocument(modifiedHtml, htmlConfig);
205
+ export async function htmlDiff(originalHtml: string, modifiedHtml: string): Promise<HtmlDiffResult> {
206
+ const originalDocument = parseDocument(originalHtml);
207
+ const modifiedDocument = parseDocument(modifiedHtml);
209
208
 
210
209
  const originalRoot = getRootNodeForFlatten(originalDocument);
211
210
  const modifiedRoot = getRootNodeForFlatten(modifiedDocument);
@@ -308,7 +307,7 @@ function isLiveRegion(element: ElementNode): boolean {
308
307
  * Parse HTML into a document, wrapping fragments with html/body for consistency.
309
308
  * Uses custom sanitization that removes iframes for diff purposes.
310
309
  */
311
- function parseDocument(html: string, htmlConfig?: HtmlConfig): DocumentNode {
310
+ function parseDocument(html: string): DocumentNode {
312
311
  const document = parse(html);
313
312
  const documentTitle = getDocumentTitle(document);
314
313
  sanitizeDocumentTreeForDiff(document);
@@ -432,42 +431,6 @@ const attributesDiffer = (current: ElementNode, previous: ElementNode): boolean
432
431
  return false;
433
432
  };
434
433
 
435
- const getDirectTextValues = (element: ElementNode): string[] => {
436
- if (!element.childNodes || element.childNodes.length === 0) {
437
- return [];
438
- }
439
-
440
- const values: string[] = [];
441
-
442
- for (const child of element.childNodes) {
443
- if (child.nodeName === '#text') {
444
- const value = (child as parse5TreeAdapter.TextNode).value.trim();
445
- if (value.length > 0) {
446
- values.push(value);
447
- }
448
- }
449
- }
450
-
451
- return values;
452
- };
453
-
454
- const directTextDiffer = (current: ElementNode, previous: ElementNode): boolean => {
455
- const currentText = getDirectTextValues(current);
456
- const previousText = getDirectTextValues(previous);
457
-
458
- if (currentText.length !== previousText.length) {
459
- return true;
460
- }
461
-
462
- for (let i = 0; i < currentText.length; i++) {
463
- if (currentText[i] !== previousText[i]) {
464
- return true;
465
- }
466
- }
467
-
468
- return false;
469
- };
470
-
471
434
  function filterContainerClasses(classes: string[]): string[] {
472
435
  return classes
473
436
  .filter((cls) => !/\d/.test(cls))
@@ -792,21 +755,6 @@ function findHtmlElement(document: DocumentNode): ElementNode | null {
792
755
  return null;
793
756
  }
794
757
 
795
- function findHeadElement(document: DocumentNode): ElementNode | null {
796
- const html = findHtmlElement(document);
797
- if (!html || !html.childNodes) {
798
- return null;
799
- }
800
-
801
- for (const node of html.childNodes) {
802
- if ('tagName' in node && node.tagName && node.tagName.toLowerCase() === 'head') {
803
- return node as ElementNode;
804
- }
805
- }
806
-
807
- return null;
808
- }
809
-
810
758
  function findBodyElement(document: DocumentNode): ElementNode | null {
811
759
  const html = findHtmlElement(document);
812
760
  if (!html || !html.childNodes) {
package/src/utils/html.ts CHANGED
@@ -1,4 +1,3 @@
1
- import dedent from 'dedent';
2
1
  import { minify } from 'html-minifier-next';
3
2
  import { parse, parseFragment, serialize } from 'parse5';
4
3
  import type * as parse5TreeAdapter from 'parse5/lib/tree-adapters/default';
@@ -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-]+';