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.
- package/bin/explorbot-cli.ts +494 -482
- package/boat/api-tester/src/ai/curler.ts +1 -1
- package/boat/api-tester/src/cli.ts +4 -4
- package/boat/doc-collector/src/screenshots.ts +1 -1
- package/dist/bin/explorbot-cli.js +455 -444
- package/dist/boat/api-tester/src/ai/curler.js +1 -1
- package/dist/boat/api-tester/src/cli.js +4 -4
- package/dist/models.json +1 -1
- package/dist/package.json +2 -1
- package/dist/rules/navigator/output.md +2 -8
- package/dist/rules/navigator/verification-actions.md +6 -2
- package/dist/rules/planner/ignored-pages.md +1 -0
- package/dist/src/action-result.js +1 -1
- package/dist/src/action.d.ts +0 -1
- package/dist/src/action.js +0 -4
- package/dist/src/ai/captain/idle-mode.js +42 -26
- package/dist/src/ai/captain/mixin.d.ts +2 -0
- package/dist/src/ai/captain/web-mode.js +11 -0
- package/dist/src/ai/captain.d.ts +6 -4
- package/dist/src/ai/captain.js +25 -1
- package/dist/src/ai/driller.js +1 -1
- package/dist/src/ai/experience-compactor.js +2 -2
- package/dist/src/ai/judge.d.ts +7 -0
- package/dist/src/ai/judge.js +8 -1
- package/dist/src/ai/navigator.d.ts +1 -0
- package/dist/src/ai/navigator.js +11 -7
- package/dist/src/ai/planner/subpages.js +1 -1
- package/dist/src/ai/planner.d.ts +12 -5
- package/dist/src/ai/planner.js +138 -86
- package/dist/src/ai/provider.d.ts +1 -1
- package/dist/src/ai/provider.js +16 -5
- package/dist/src/ai/rerunner.js +1 -1
- package/dist/src/ai/researcher/cache.js +14 -6
- package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
- package/dist/src/ai/researcher/deep-analysis.js +7 -2
- package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
- package/dist/src/ai/researcher.js +44 -24
- package/dist/src/ai/rules.js +6 -7
- package/dist/src/ai/scout.d.ts +12 -2
- package/dist/src/ai/scout.js +103 -8
- package/dist/src/ai/tester.d.ts +9 -5
- package/dist/src/ai/tester.js +22 -42
- package/dist/src/ai/tools.js +10 -8
- package/dist/src/application-spec.d.ts +2 -0
- package/dist/src/application-spec.js +17 -12
- package/dist/src/commands/context-experience-command.js +0 -1
- package/dist/src/commands/context-knowledge-command.js +0 -1
- package/dist/src/commands/explore-command.d.ts +3 -2
- package/dist/src/commands/explore-command.js +35 -23
- package/dist/src/commands/help-json-command.js +3 -1
- package/dist/src/commands/init-command.js +4 -4
- package/dist/src/commands/learn-command.js +0 -2
- package/dist/src/commands/plan-command.d.ts +3 -0
- package/dist/src/commands/plan-command.js +43 -17
- package/dist/src/commands/plan-save-command.js +6 -17
- package/dist/src/commands/plans-command.js +1 -1
- package/dist/src/commands/test-command.js +12 -2
- package/dist/src/components/App.d.ts +1 -2
- package/dist/src/components/App.js +1 -3
- package/dist/src/components/InputPane.js +0 -5
- package/dist/src/config.d.ts +3 -2
- package/dist/src/config.js +2 -12
- package/dist/src/explorbot.d.ts +3 -2
- package/dist/src/explorbot.js +32 -10
- package/dist/src/explorer.js +6 -7
- package/dist/src/knowledge-tracker.d.ts +2 -2
- package/dist/src/knowledge-tracker.js +18 -6
- package/dist/src/playwright-recorder.d.ts +0 -1
- package/dist/src/playwright-recorder.js +0 -2
- package/dist/src/reporter.d.ts +1 -1
- package/dist/src/reporter.js +3 -3
- package/dist/src/test-plan.js +1 -1
- package/dist/src/utils/error-page.d.ts +17 -0
- package/dist/src/utils/error-page.js +53 -2
- package/dist/src/utils/html-diff.d.ts +1 -2
- package/dist/src/utils/html-diff.js +4 -44
- package/dist/src/utils/logger.js +27 -10
- package/dist/src/utils/markdown-terminal.js +1 -2
- package/dist/src/utils/next-steps.js +20 -16
- package/dist/src/utils/page-readiness.d.ts +2 -1
- package/dist/src/utils/page-readiness.js +24 -5
- package/dist/src/utils/test-plan-markdown.d.ts +3 -0
- package/dist/src/utils/test-plan-markdown.js +35 -4
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +5 -0
- package/docs/basics/providers.md +1 -1
- package/docs/contributing/regression-tests.md +19 -4
- package/docs/reference/commands.md +6 -2
- package/docs/reference/configuration.md +1 -1
- package/docs/web-testing/agents.md +8 -0
- package/docs/web-testing/customization.md +2 -0
- package/docs/web-testing/researcher.md +1 -1
- package/models.json +1 -1
- package/package.json +2 -1
- package/rules/navigator/output.md +2 -8
- package/rules/navigator/verification-actions.md +6 -2
- package/rules/planner/ignored-pages.md +1 -0
- package/src/action-result.ts +1 -2
- package/src/action.ts +0 -5
- package/src/ai/captain/idle-mode.ts +38 -26
- package/src/ai/captain/mixin.ts +2 -1
- package/src/ai/captain/web-mode.ts +12 -0
- package/src/ai/captain.ts +25 -7
- package/src/ai/driller.ts +1 -2
- package/src/ai/experience-compactor.ts +2 -2
- package/src/ai/judge.ts +10 -1
- package/src/ai/navigator.ts +12 -7
- package/src/ai/planner/subpages.ts +1 -2
- package/src/ai/planner.ts +140 -92
- package/src/ai/provider.ts +15 -6
- package/src/ai/rerunner.ts +1 -2
- package/src/ai/researcher/cache.ts +16 -7
- package/src/ai/researcher/deep-analysis.ts +7 -2
- package/src/ai/researcher/fingerprint-worker.ts +5 -5
- package/src/ai/researcher.ts +44 -28
- package/src/ai/rules.ts +6 -7
- package/src/ai/scout.ts +98 -10
- package/src/ai/tester.ts +29 -47
- package/src/ai/tools.ts +11 -9
- package/src/application-spec.ts +19 -13
- package/src/commands/clean-command.ts +1 -1
- package/src/commands/context-command.ts +0 -1
- package/src/commands/context-experience-command.ts +0 -1
- package/src/commands/context-knowledge-command.ts +0 -1
- package/src/commands/explore-command.ts +36 -23
- package/src/commands/help-json-command.ts +8 -6
- package/src/commands/init-command.ts +5 -3
- package/src/commands/learn-command.ts +0 -2
- package/src/commands/plan-command.ts +44 -17
- package/src/commands/plan-save-command.ts +6 -17
- package/src/commands/plans-command.ts +1 -1
- package/src/commands/test-command.ts +11 -2
- package/src/components/App.tsx +2 -6
- package/src/components/InputPane.tsx +0 -5
- package/src/config.ts +4 -16
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +31 -11
- package/src/explorer.ts +6 -7
- package/src/knowledge-tracker.ts +18 -7
- package/src/playwright-recorder.ts +0 -2
- package/src/reporter.ts +3 -4
- package/src/test-plan.ts +1 -2
- package/src/utils/error-page.ts +64 -2
- package/src/utils/html-diff.ts +4 -56
- package/src/utils/html.ts +0 -1
- package/src/utils/logger.ts +30 -12
- package/src/utils/markdown-terminal.ts +1 -2
- package/src/utils/next-steps.ts +19 -17
- package/src/utils/page-readiness.ts +33 -7
- package/src/utils/test-plan-markdown.ts +36 -4
- package/src/utils/url-matcher.ts +6 -0
package/src/utils/error-page.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/html-diff.ts
CHANGED
|
@@ -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
|
|
207
|
-
const originalDocument = parseDocument(originalHtml
|
|
208
|
-
const modifiedDocument = parseDocument(modifiedHtml
|
|
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
|
|
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
package/src/utils/logger.ts
CHANGED
|
@@ -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
|
-
|
|
117
|
-
content = `${chalk.
|
|
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
|
|
130
|
+
content = this.prefixLines(content, ' ', chalk.gray);
|
|
126
131
|
} else if (entry.type === 'operation') {
|
|
127
|
-
content =
|
|
132
|
+
content = this.prefixLines(content, ' · ', chalk.gray);
|
|
128
133
|
} else if (entry.type === 'substep') {
|
|
129
|
-
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, (
|
|
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
|
|
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;
|
package/src/utils/next-steps.ts
CHANGED
|
@@ -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
|
|
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}
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
42
|
+
lines.push(BLANK);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
tag('
|
|
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<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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]').
|
|
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 ||
|
|
280
|
+
content += `${test.startUrl || CURRENT_PAGE}\n\n`;
|
|
249
281
|
|
|
250
282
|
if (test.plannedSteps.length > 0) {
|
|
251
283
|
content += '## Steps\n';
|
package/src/utils/url-matcher.ts
CHANGED
|
@@ -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-]+';
|