explorbot 0.4.9 → 0.5.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/README.md +4 -1
- package/bin/mdq.ts +18 -0
- package/boat/api-tester/src/ai/chief.ts +72 -0
- package/boat/api-tester/src/api-client.ts +37 -0
- package/boat/prima/src/prima.ts +41 -2
- package/dist/bin/mdq.js +19 -0
- package/dist/boat/api-tester/src/ai/chief.js +69 -0
- package/dist/boat/api-tester/src/api-client.js +26 -0
- package/dist/boat/prima/src/prima.js +42 -2
- package/dist/package.json +3 -2
- package/dist/src/action.js +4 -2
- package/dist/src/ai/agent.d.ts +3 -1
- package/dist/src/ai/judge-provider.d.ts +17 -0
- package/dist/src/ai/judge-provider.js +56 -0
- package/dist/src/ai/judge-tool.d.ts +2 -0
- package/dist/src/ai/judge-tool.js +33 -0
- package/dist/src/ai/judge.d.ts +28 -0
- package/dist/src/ai/judge.js +71 -0
- package/dist/src/ai/navigator.d.ts +3 -1
- package/dist/src/ai/navigator.js +24 -28
- package/dist/src/ai/pilot.d.ts +4 -0
- package/dist/src/ai/pilot.js +67 -9
- package/dist/src/ai/planner.js +9 -6
- package/dist/src/ai/provider.d.ts +4 -1
- package/dist/src/ai/provider.js +52 -7
- package/dist/src/ai/rerunner.js +7 -0
- package/dist/src/ai/researcher/deep-analysis.js +2 -2
- package/dist/src/ai/researcher/locators.js +2 -2
- package/dist/src/ai/researcher/pagination.js +1 -1
- package/dist/src/ai/researcher/research-result.js +2 -2
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/task-agent.d.ts +2 -0
- package/dist/src/ai/task-agent.js +3 -1
- package/dist/src/ai/tester.js +19 -15
- package/dist/src/ai/tools.d.ts +4 -3
- package/dist/src/ai/tools.js +35 -7
- package/dist/src/api/request-result.js +2 -1
- package/dist/src/api/xhr-capture.js +2 -1
- package/dist/src/command-handler.d.ts +1 -0
- package/dist/src/command-handler.js +24 -3
- package/dist/src/commands/base-command.d.ts +5 -0
- package/dist/src/commands/base-command.js +3 -0
- package/dist/src/commands/explore-command.d.ts +2 -1
- package/dist/src/commands/explore-command.js +12 -1
- package/dist/src/commands/freesail-command.js +8 -2
- package/dist/src/commands/init-command.js +1 -1
- package/dist/src/commands/navigate-command.d.ts +2 -1
- package/dist/src/commands/navigate-command.js +6 -0
- package/dist/src/commands/plan-load-command.d.ts +2 -1
- package/dist/src/commands/plan-load-command.js +4 -0
- package/dist/src/commands/plans-command.d.ts +3 -9
- package/dist/src/commands/plans-command.js +11 -21
- package/dist/src/commands/rerun-command.d.ts +2 -1
- package/dist/src/commands/rerun-command.js +5 -1
- package/dist/src/commands/research-command.d.ts +2 -1
- package/dist/src/commands/research-command.js +6 -0
- package/dist/src/commands/test-command.d.ts +2 -1
- package/dist/src/commands/test-command.js +4 -1
- package/dist/src/components/Autocomplete.js +26 -12
- package/dist/src/components/InputReadline.js +10 -1
- package/dist/src/config.d.ts +6 -0
- package/dist/src/experience-tracker.js +4 -3
- package/dist/src/explorbot.d.ts +3 -0
- package/dist/src/explorbot.js +8 -0
- package/dist/src/explorer.js +2 -3
- package/dist/src/knowledge-tracker.js +1 -1
- package/dist/src/reporter.js +8 -4
- package/dist/src/state-manager.d.ts +2 -0
- package/dist/src/state-manager.js +16 -0
- package/dist/src/test-plan.d.ts +11 -0
- package/dist/src/test-plan.js +54 -2
- package/dist/src/utils/aria-ref.js +1 -1
- package/dist/src/utils/logger.js +9 -3
- package/dist/src/utils/markdown-query.d.ts +1 -48
- package/dist/src/utils/markdown-query.js +1 -444
- package/dist/src/utils/mdq/cli.d.ts +6 -0
- package/dist/src/utils/mdq/cli.js +122 -0
- package/dist/src/utils/mdq/edit.d.ts +24 -0
- package/dist/src/utils/mdq/edit.js +147 -0
- package/dist/src/utils/mdq/query.d.ts +118 -0
- package/dist/src/utils/mdq/query.js +451 -0
- package/dist/src/utils/step-analyzer.d.ts +3 -0
- package/dist/src/utils/step-analyzer.js +7 -0
- package/dist/src/utils/strings.d.ts +1 -0
- package/dist/src/utils/strings.js +7 -0
- package/dist/src/utils/test-files.d.ts +1 -0
- package/dist/src/utils/test-files.js +5 -2
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +7 -0
- package/docs/api-testing/planning.md +1 -1
- package/docs/superpowers/plans/2026-09-15-mdq-package.md +130 -94
- package/docs/superpowers/specs/2026-09-18-judge-decision-model-design.md +79 -0
- package/package.json +3 -2
- package/src/action.ts +4 -2
- package/src/ai/agent.ts +3 -1
- package/src/ai/judge-provider.ts +62 -0
- package/src/ai/judge-tool.ts +35 -0
- package/src/ai/judge.ts +75 -0
- package/src/ai/navigator.ts +24 -27
- package/src/ai/pilot.ts +68 -9
- package/src/ai/planner.ts +9 -6
- package/src/ai/provider.ts +51 -7
- package/src/ai/rerunner.ts +4 -0
- package/src/ai/researcher/deep-analysis.ts +2 -2
- package/src/ai/researcher/locators.ts +2 -2
- package/src/ai/researcher/pagination.ts +1 -1
- package/src/ai/researcher/research-result.ts +2 -2
- package/src/ai/researcher.ts +1 -1
- package/src/ai/task-agent.ts +4 -1
- package/src/ai/tester.ts +19 -16
- package/src/ai/tools.ts +42 -7
- package/src/api/request-result.ts +2 -1
- package/src/api/xhr-capture.ts +2 -1
- package/src/command-handler.ts +28 -3
- package/src/commands/base-command.ts +9 -0
- package/src/commands/explore-command.ts +15 -2
- package/src/commands/freesail-command.ts +9 -2
- package/src/commands/init-command.ts +1 -1
- package/src/commands/navigate-command.ts +8 -1
- package/src/commands/plan-load-command.ts +6 -1
- package/src/commands/plans-command.ts +13 -29
- package/src/commands/rerun-command.ts +7 -2
- package/src/commands/research-command.ts +8 -1
- package/src/commands/test-command.ts +6 -2
- package/src/components/Autocomplete.tsx +39 -10
- package/src/components/InputReadline.tsx +10 -1
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +4 -3
- package/src/explorbot.ts +8 -0
- package/src/explorer.ts +2 -2
- package/src/knowledge-tracker.ts +1 -1
- package/src/reporter.ts +8 -4
- package/src/state-manager.ts +16 -0
- package/src/test-plan.ts +67 -2
- package/src/utils/aria-ref.ts +1 -1
- package/src/utils/logger.ts +7 -2
- package/src/utils/markdown-query.ts +1 -519
- package/src/utils/mdq/cli.ts +118 -0
- package/src/utils/mdq/edit.ts +158 -0
- package/src/utils/mdq/query.ts +556 -0
- package/src/utils/step-analyzer.ts +8 -0
- package/src/utils/strings.ts +7 -0
- package/src/utils/test-files.ts +5 -2
- package/src/utils/url-matcher.ts +7 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { type Token, type Tokens, marked } from 'marked';
|
|
2
|
+
import { MarkdownEditor } from './edit.ts';
|
|
3
|
+
|
|
4
|
+
export { MarkdownEditor };
|
|
5
|
+
|
|
6
|
+
export class MarkdownQuery {
|
|
7
|
+
private source: string;
|
|
8
|
+
private matches: MatchedRange[];
|
|
9
|
+
|
|
10
|
+
constructor(source: string, matches?: MatchedRange[]) {
|
|
11
|
+
this.source = source;
|
|
12
|
+
this.matches = matches || buildTokenIndex(source);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
query(selector: string, matcher?: Matcher): MarkdownQuery {
|
|
16
|
+
const segments = parseQuery(selector);
|
|
17
|
+
if (matcher !== undefined && segments.length > 0) segments[segments.length - 1].textMatch = this.valueMatcher(matcher);
|
|
18
|
+
return new MarkdownQuery(this.source, this.run(this.expandSections(this.matches), segments));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
section(matcher?: Matcher, options?: SelectorOptions): MarkdownQuery {
|
|
22
|
+
return this.query(`section${options?.depth || ''}`, matcher);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
heading(matcher?: Matcher, options?: SelectorOptions): MarkdownQuery {
|
|
26
|
+
if (options?.depth) return this.query(`h${options.depth}`, matcher);
|
|
27
|
+
return this.query('heading', matcher);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
paragraph(matcher?: Matcher): MarkdownQuery {
|
|
31
|
+
return this.query('paragraph', matcher);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
table(matcher?: Matcher): MarkdownQuery {
|
|
35
|
+
return this.query('table', matcher);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
list(matcher?: Matcher): MarkdownQuery {
|
|
39
|
+
return this.query('list', matcher);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
item(matcher?: Matcher): MarkdownQuery {
|
|
43
|
+
return this.query('item', matcher);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
code(matcher?: Matcher): MarkdownQuery {
|
|
47
|
+
return this.query('code', matcher);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
blockquote(matcher?: Matcher): MarkdownQuery {
|
|
51
|
+
return this.query('blockquote', matcher);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
comment(matcher?: Matcher): MarkdownQuery {
|
|
55
|
+
return this.query('comment', matcher);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
html(matcher?: Matcher): MarkdownQuery {
|
|
59
|
+
return this.query('html', matcher);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hr(): MarkdownQuery {
|
|
63
|
+
return this.query('hr');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
text(): string {
|
|
67
|
+
return this.matches.map((range) => this.source.slice(range.start, range.start + range.length)).join('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
nodes(): NodeInfo[] {
|
|
71
|
+
return this.matches.map((range) => {
|
|
72
|
+
const token = range.token as any;
|
|
73
|
+
if (token.type !== 'heading') return { type: token.type, depth: null, text: this.tokenText(range.token) };
|
|
74
|
+
return { type: token.type, depth: token.depth, text: this.tokenText(range.token) };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
rows(): Record<string, string>[] {
|
|
79
|
+
const results: Record<string, string>[] = [];
|
|
80
|
+
|
|
81
|
+
for (const range of this.matches) {
|
|
82
|
+
if (range.token.type !== 'table') continue;
|
|
83
|
+
|
|
84
|
+
const table = range.token as Tokens.Table;
|
|
85
|
+
const headers = table.header.map((cell) => cell.text);
|
|
86
|
+
for (const row of table.rows) {
|
|
87
|
+
const entry: Record<string, string> = {};
|
|
88
|
+
for (let i = 0; i < headers.length; i++) {
|
|
89
|
+
entry[headers[i]] = row[i]?.text ?? '';
|
|
90
|
+
}
|
|
91
|
+
results.push(entry);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
entries(): Record<string, string> {
|
|
99
|
+
const entries: Record<string, string> = {};
|
|
100
|
+
|
|
101
|
+
for (const range of this.matches) {
|
|
102
|
+
for (const line of this.tokenText(range.token).split('\n')) {
|
|
103
|
+
const separator = line.indexOf(':');
|
|
104
|
+
if (separator < 1) continue;
|
|
105
|
+
const value = line.slice(separator + 1).trim();
|
|
106
|
+
if (value) entries[line.slice(0, separator).trim().toLowerCase()] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
frontmatter(): Record<string, unknown> {
|
|
114
|
+
return this.editor().frontmatter();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
count(): number {
|
|
118
|
+
return this.matches.length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
exists(): boolean {
|
|
122
|
+
return this.matches.length > 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
at(index: number): MarkdownQuery {
|
|
126
|
+
let resolved = index;
|
|
127
|
+
if (resolved < 0) resolved = this.matches.length + resolved;
|
|
128
|
+
if (resolved < 0 || resolved >= this.matches.length) return new MarkdownQuery(this.source, []);
|
|
129
|
+
return new MarkdownQuery(this.source, [this.matches[resolved]]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
slice(from?: number, to?: number): MarkdownQuery {
|
|
133
|
+
return new MarkdownQuery(this.source, this.matches.slice(from, to));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
first(): MarkdownQuery {
|
|
137
|
+
return this.slice(0, 1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
last(): MarkdownQuery {
|
|
141
|
+
return this.slice(-1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
each(): MarkdownQuery[] {
|
|
145
|
+
return this.matches.map((range) => new MarkdownQuery(this.source, [range]));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
preceding(): MarkdownQuery {
|
|
149
|
+
if (this.matches.length === 0) return new MarkdownQuery(this.source, []);
|
|
150
|
+
const cutoff = this.matches[0].start;
|
|
151
|
+
return new MarkdownQuery(
|
|
152
|
+
this.source,
|
|
153
|
+
buildTokenIndex(this.source).filter((range) => range.start + range.length <= cutoff)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
following(): MarkdownQuery {
|
|
158
|
+
if (this.matches.length === 0) return new MarkdownQuery(this.source, []);
|
|
159
|
+
const last = this.matches[this.matches.length - 1];
|
|
160
|
+
const cutoff = last.start + last.length;
|
|
161
|
+
return new MarkdownQuery(
|
|
162
|
+
this.source,
|
|
163
|
+
buildTokenIndex(this.source).filter((range) => range.start >= cutoff)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
replace(content: Markdown): MarkdownQuery {
|
|
168
|
+
return this.replaceEach(() => content);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
replaceEach(replacer: (match: MarkdownQuery, index: number) => Markdown): MarkdownQuery {
|
|
172
|
+
return new MarkdownQuery(this.editor().replace(this.matches, (range, index) => String(replacer(new MarkdownQuery(this.source, [range]), index))));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
remove(): MarkdownQuery {
|
|
176
|
+
return new MarkdownQuery(this.editor().remove(this.matches));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
insertBefore(markdown: Markdown): MarkdownQuery {
|
|
180
|
+
return this.insertAll((range) => range.start, markdown);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
insertAfter(markdown: Markdown): MarkdownQuery {
|
|
184
|
+
return this.insertAll((range) => MarkdownEditor.blockEnd(range), markdown);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
prepend(markdown: Markdown): MarkdownQuery {
|
|
188
|
+
return this.insertAll((range) => this.containerStart(range), markdown);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
append(markdown: Markdown): MarkdownQuery {
|
|
192
|
+
return this.insertAll((range) => this.containerEnd(range), markdown);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
addRow(row: Record<string, string>): MarkdownQuery {
|
|
196
|
+
return new MarkdownQuery(
|
|
197
|
+
this.editor().replace(this.matches, (range) => {
|
|
198
|
+
if (range.token.type !== 'table') throw new MdqOperationError(`addRow needs a table, got ${range.token.type}`);
|
|
199
|
+
const table = range.token as Tokens.Table;
|
|
200
|
+
const headers = table.header.map((cell) => cell.text);
|
|
201
|
+
const existing = table.rows.map((cells) => headers.map((_, index) => cells[index]?.text || ''));
|
|
202
|
+
return MarkdownEditor.table(headers, [...existing, headers.map((header) => row[header] || '')], table.align);
|
|
203
|
+
})
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
addItem(text: string): MarkdownQuery {
|
|
208
|
+
return new MarkdownQuery(
|
|
209
|
+
this.editor().replace(this.matches, (range) => {
|
|
210
|
+
if (range.token.type !== 'list') throw new MdqOperationError(`addItem needs a list, got ${range.token.type}`);
|
|
211
|
+
const raw = (((range.token as any).raw as string) || '').trimEnd();
|
|
212
|
+
return `${raw}\n${MarkdownEditor.item(raw, text)}\n`;
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
setEntry(key: string, value: string | null): MarkdownQuery {
|
|
218
|
+
return this.replaceEach((match) => {
|
|
219
|
+
const token = match.matches[0].token;
|
|
220
|
+
return MarkdownEditor.entries(this.tokenText(token), key, value, token.type === 'blockquote');
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
setFrontmatter(key: string, value: unknown): MarkdownQuery {
|
|
225
|
+
return new MarkdownQuery(this.editor().setFrontmatter(key, value));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
toString(): string {
|
|
229
|
+
return this.source;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
valueOf(): string {
|
|
233
|
+
return this.source;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Compatibility aliases. Each delegates to the canonical name above; prefer those. */
|
|
237
|
+
get(): string {
|
|
238
|
+
return this.text();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
meta(): NodeInfo[] {
|
|
242
|
+
return this.nodes();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
toJson(): Record<string, string>[] {
|
|
246
|
+
return this.rows();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
keyValue(): Record<string, string> {
|
|
250
|
+
return this.entries();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
setKeyValue(key: string, value: string | null): MarkdownQuery {
|
|
254
|
+
return this.setEntry(key, value);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
before(): MarkdownQuery {
|
|
258
|
+
return this.preceding();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
after(): MarkdownQuery {
|
|
262
|
+
return this.following();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private editor(): MarkdownEditor {
|
|
266
|
+
return new MarkdownEditor(this.source);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private run(candidates: MatchedRange[], segments: QuerySegment[]): MatchedRange[] {
|
|
270
|
+
if (segments.length === 0) return candidates;
|
|
271
|
+
|
|
272
|
+
const segment = segments[0];
|
|
273
|
+
const remaining = segments.slice(1);
|
|
274
|
+
|
|
275
|
+
if (/^section[1-6]?$/.test(segment.selector)) {
|
|
276
|
+
const sections = this.narrow(this.sectionsOf(candidates, segment), segment);
|
|
277
|
+
if (remaining.length === 0) return sections;
|
|
278
|
+
|
|
279
|
+
const results: MatchedRange[] = [];
|
|
280
|
+
for (const section of sections) results.push(...this.run(section.innerTokens || [], remaining));
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (segment.selector === 'comment') {
|
|
285
|
+
const comments = candidates.filter((range) => this.isComment(range.token));
|
|
286
|
+
return this.run(this.narrow(this.filterByText(comments, segment), segment), remaining);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (segment.selector === 'item') {
|
|
290
|
+
return this.run(this.narrow(this.filterByText(this.itemsOf(candidates), segment), segment), remaining);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const depth = segment.selector.match(/^h([1-6])$/);
|
|
294
|
+
let type = TOKEN_ALIASES[segment.selector] || segment.selector;
|
|
295
|
+
if (depth) type = 'heading';
|
|
296
|
+
|
|
297
|
+
let matches = candidates.filter((range) => range.token.type === type);
|
|
298
|
+
if (depth) matches = matches.filter((range) => (range.token as any).depth === Number.parseInt(depth[1], 10));
|
|
299
|
+
|
|
300
|
+
return this.run(this.narrow(this.filterByText(matches, segment), segment), remaining);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private sectionsOf(candidates: MatchedRange[], segment: QuerySegment): MatchedRange[] {
|
|
304
|
+
const wanted = segment.selector.match(/^section([1-6])$/);
|
|
305
|
+
const sections: MatchedRange[] = [];
|
|
306
|
+
|
|
307
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
308
|
+
const range = candidates[i];
|
|
309
|
+
if (range.token.type !== 'heading') continue;
|
|
310
|
+
|
|
311
|
+
const heading = range.token as Tokens.Heading;
|
|
312
|
+
if (wanted && heading.depth !== Number.parseInt(wanted[1], 10)) continue;
|
|
313
|
+
if (segment.textMatch && !this.matchText(heading.text, segment.textMatch)) continue;
|
|
314
|
+
|
|
315
|
+
const innerTokens: MatchedRange[] = [];
|
|
316
|
+
let end = range.start + range.length;
|
|
317
|
+
|
|
318
|
+
for (let j = i + 1; j < candidates.length; j++) {
|
|
319
|
+
const next = candidates[j];
|
|
320
|
+
if (next.token.type === 'heading' && (next.token as Tokens.Heading).depth <= heading.depth) break;
|
|
321
|
+
innerTokens.push(next);
|
|
322
|
+
end = MarkdownEditor.blockEnd(next);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
sections.push({ token: range.token, start: range.start, length: end - range.start, innerTokens });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return sections;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private itemsOf(candidates: MatchedRange[]): MatchedRange[] {
|
|
332
|
+
const items: MatchedRange[] = [];
|
|
333
|
+
|
|
334
|
+
for (const range of candidates) {
|
|
335
|
+
if (range.token.type !== 'list') continue;
|
|
336
|
+
|
|
337
|
+
const raw = (range.token as any).raw as string;
|
|
338
|
+
let cursor = 0;
|
|
339
|
+
|
|
340
|
+
for (const item of (range.token as Tokens.List).items) {
|
|
341
|
+
const itemRaw = (item as any).raw as string;
|
|
342
|
+
const at = raw.indexOf(itemRaw, cursor);
|
|
343
|
+
if (at === -1) continue;
|
|
344
|
+
items.push({ token: item as unknown as Token, start: range.start + at, length: itemRaw.length });
|
|
345
|
+
cursor = at + itemRaw.length;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return items;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private expandSections(matches: MatchedRange[]): MatchedRange[] {
|
|
353
|
+
if (!matches.some((range) => range.innerTokens)) return matches;
|
|
354
|
+
|
|
355
|
+
const expanded: MatchedRange[] = [];
|
|
356
|
+
for (const range of matches) {
|
|
357
|
+
if (!range.innerTokens) {
|
|
358
|
+
expanded.push(range);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
expanded.push({ token: range.token, start: range.start, length: ((range.token as any).raw || '').length });
|
|
362
|
+
expanded.push(...range.innerTokens);
|
|
363
|
+
}
|
|
364
|
+
return expanded;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private narrow(matches: MatchedRange[], segment: QuerySegment): MatchedRange[] {
|
|
368
|
+
if (segment.index !== null) {
|
|
369
|
+
let index = segment.index;
|
|
370
|
+
if (index < 0) index = matches.length + index;
|
|
371
|
+
if (index < 0 || index >= matches.length) return [];
|
|
372
|
+
return [matches[index]];
|
|
373
|
+
}
|
|
374
|
+
if (segment.slice) return matches.slice(segment.slice.from, segment.slice.to);
|
|
375
|
+
return matches;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private filterByText(matches: MatchedRange[], segment: QuerySegment): MatchedRange[] {
|
|
379
|
+
if (!segment.textMatch) return matches;
|
|
380
|
+
return matches.filter((range) => this.matchText(this.tokenText(range.token), segment.textMatch!));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private matchText(text: string, matcher: TextMatcher): boolean {
|
|
384
|
+
let result = false;
|
|
385
|
+
if (matcher.mode === 'exact') result = text === matcher.value;
|
|
386
|
+
if (matcher.mode === 'contains') result = text.includes(matcher.value);
|
|
387
|
+
if (matcher.mode === 'regex') result = new RegExp(matcher.value, matcher.flags || '').test(text);
|
|
388
|
+
if (matcher.mode === 'predicate') result = matcher.predicate!(text);
|
|
389
|
+
if (matcher.negated) return !result;
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private valueMatcher(matcher: Matcher): TextMatcher {
|
|
394
|
+
if (typeof matcher === 'function') return { mode: 'predicate', value: '', negated: false, predicate: matcher };
|
|
395
|
+
if (matcher instanceof RegExp) return { mode: 'regex', value: matcher.source, negated: false, flags: matcher.flags };
|
|
396
|
+
return { mode: 'exact', value: matcher, negated: false };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private tokenText(token: Token): string {
|
|
400
|
+
const value = token as any;
|
|
401
|
+
if (this.isComment(token)) return (value.raw as string).trim().replace(/^<!--/, '').replace(/-->$/, '').trim();
|
|
402
|
+
if (token.type === 'html') return value.raw || '';
|
|
403
|
+
if (token.type === 'table') return [...(value.header || []).map((cell: any) => cell.text), ...(value.rows || []).flatMap((row: any) => row.map((cell: any) => cell.text))].join(', ');
|
|
404
|
+
return value.text || '';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private isComment(token: Token): boolean {
|
|
408
|
+
if (token.type !== 'html') return false;
|
|
409
|
+
return (((token as any).raw as string) || '').trimStart().startsWith('<!--');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private insertAll(offsetOf: (range: MatchedRange) => number, markdown: Markdown): MarkdownQuery {
|
|
413
|
+
return new MarkdownQuery(this.editor().insert(this.matches.map(offsetOf), String(markdown)));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private containerStart(range: MatchedRange): number {
|
|
417
|
+
if (!range.innerTokens) throw new MdqOperationError(`prepend needs a section or list, got ${range.token.type}`);
|
|
418
|
+
return range.start + (((range.token as any).raw as string) || '').length;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private containerEnd(range: MatchedRange): number {
|
|
422
|
+
if (!range.innerTokens) throw new MdqOperationError(`append needs a section or list, got ${range.token.type}`);
|
|
423
|
+
const last = range.innerTokens[range.innerTokens.length - 1];
|
|
424
|
+
if (!last) return this.containerStart(range);
|
|
425
|
+
return MarkdownEditor.blockEnd(last);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export class MdqError extends Error {}
|
|
430
|
+
|
|
431
|
+
export class MdqSelectorError extends MdqError {
|
|
432
|
+
index: number;
|
|
433
|
+
|
|
434
|
+
constructor(message: string, index: number) {
|
|
435
|
+
super(message);
|
|
436
|
+
this.name = 'MdqSelectorError';
|
|
437
|
+
this.index = index;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export class MdqOperationError extends MdqError {
|
|
442
|
+
constructor(message: string) {
|
|
443
|
+
super(message);
|
|
444
|
+
this.name = 'MdqOperationError';
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function mdq(source: Markdown): MarkdownQuery {
|
|
449
|
+
return new MarkdownQuery(String(source));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function parseQuery(input: string): QuerySegment[] {
|
|
453
|
+
const segments: QuerySegment[] = [];
|
|
454
|
+
SEGMENT.lastIndex = 0;
|
|
455
|
+
|
|
456
|
+
while (SEGMENT.lastIndex < input.length) {
|
|
457
|
+
const at = SEGMENT.lastIndex;
|
|
458
|
+
const match = SEGMENT.exec(input);
|
|
459
|
+
if (!match) throw new MdqSelectorError(`Unexpected character "${input[at]}" in selector`, at);
|
|
460
|
+
|
|
461
|
+
const [, prefix, name, negated, contains, quoted, pattern, flags, brackets] = match;
|
|
462
|
+
if (!SELECTOR_NAMES.has(name) && !/^(?:h|section)[1-6]$/.test(name)) throw new MdqSelectorError(`Unknown selector "${name}"`, at + prefix.length);
|
|
463
|
+
|
|
464
|
+
const segment: QuerySegment = { selector: name as SelectorType, index: null, slice: null };
|
|
465
|
+
|
|
466
|
+
if (quoted !== undefined) {
|
|
467
|
+
let mode: TextMatcher['mode'] = 'exact';
|
|
468
|
+
if (contains) mode = 'contains';
|
|
469
|
+
segment.textMatch = { mode, value: quoted.replace(/\\(.)/g, '$1'), negated: Boolean(negated) };
|
|
470
|
+
}
|
|
471
|
+
if (pattern !== undefined) {
|
|
472
|
+
segment.textMatch = { mode: 'regex', value: pattern, negated: Boolean(negated), flags };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
for (const [, bounds] of brackets.matchAll(/\[([^\]]*)\]/g)) {
|
|
476
|
+
if (!bounds || !/^-?\d*(?::-?\d*)?$/.test(bounds)) continue;
|
|
477
|
+
if (!bounds.includes(':')) {
|
|
478
|
+
segment.index = Number.parseInt(bounds, 10);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const [from, to] = bounds.split(':');
|
|
482
|
+
segment.slice = {};
|
|
483
|
+
if (from) segment.slice.from = Number.parseInt(from, 10);
|
|
484
|
+
if (to) segment.slice.to = Number.parseInt(to, 10);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
segments.push(segment);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return segments;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function buildTokenIndex(source: string): MatchedRange[] {
|
|
494
|
+
const { body, offset } = MarkdownEditor.splitFrontmatter(source);
|
|
495
|
+
const ranges: MatchedRange[] = [];
|
|
496
|
+
let cursor = offset;
|
|
497
|
+
|
|
498
|
+
for (const token of marked.lexer(body)) {
|
|
499
|
+
const raw = (token as any).raw || '';
|
|
500
|
+
if (token.type === 'space') {
|
|
501
|
+
const previous = ranges[ranges.length - 1];
|
|
502
|
+
if (previous) previous.trailing = { start: cursor, length: raw.length };
|
|
503
|
+
cursor += raw.length;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
ranges.push({ token, start: cursor, length: raw.length });
|
|
507
|
+
cursor += raw.length;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return ranges;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const SELECTOR_NAMES = new Set(['section', 'heading', 'paragraph', 'table', 'list', 'item', 'code', 'blockquote', 'hr', 'html', 'comment']);
|
|
514
|
+
|
|
515
|
+
const TOKEN_ALIASES: Record<string, string> = { item: 'list_item' };
|
|
516
|
+
|
|
517
|
+
const SEGMENT = /(\s*\.?)([A-Za-z]\w*)(?:\((!?)(~?)(?:"((?:[^"\\]|\\.)*)"|\/((?:[^/\\]|\\.)*)\/([a-z]*))\))?((?:\[[^\]]*\])*)\s*/y;
|
|
518
|
+
|
|
519
|
+
export type Markdown = string | MarkdownQuery;
|
|
520
|
+
|
|
521
|
+
export type Matcher = string | RegExp | ((text: string) => boolean);
|
|
522
|
+
|
|
523
|
+
export type SelectorType = 'comment' | 'html' | 'section' | 'section1' | 'section2' | 'section3' | 'section4' | 'section5' | 'section6' | 'table' | 'heading' | 'paragraph' | 'list' | 'item' | 'code' | 'blockquote' | 'hr' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
524
|
+
|
|
525
|
+
export interface SelectorOptions {
|
|
526
|
+
depth?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export interface TextMatcher {
|
|
530
|
+
mode: 'exact' | 'contains' | 'regex' | 'predicate';
|
|
531
|
+
value: string;
|
|
532
|
+
negated: boolean;
|
|
533
|
+
flags?: string;
|
|
534
|
+
predicate?: (text: string) => boolean;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export interface QuerySegment {
|
|
538
|
+
selector: SelectorType;
|
|
539
|
+
textMatch?: TextMatcher;
|
|
540
|
+
index: number | null;
|
|
541
|
+
slice: { from?: number; to?: number } | null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export interface MatchedRange {
|
|
545
|
+
token: Token;
|
|
546
|
+
start: number;
|
|
547
|
+
length: number;
|
|
548
|
+
trailing?: { start: number; length: number };
|
|
549
|
+
innerTokens?: MatchedRange[];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export interface NodeInfo {
|
|
553
|
+
type: string;
|
|
554
|
+
depth: number | null;
|
|
555
|
+
text: string;
|
|
556
|
+
}
|
|
@@ -5,6 +5,8 @@ import { isDynamicId } from './xpath.ts';
|
|
|
5
5
|
export const CODECEPT_TOOLS = ['click', 'hover', 'pressKey', 'form'] as const;
|
|
6
6
|
export type CodeceptToolName = (typeof CODECEPT_TOOLS)[number];
|
|
7
7
|
|
|
8
|
+
const INTERNAL_STEP_PREFIXES = ['grab', 'save'];
|
|
9
|
+
|
|
8
10
|
const CODECEPT_FORM_COMMANDS: readonly string[] = ['I.fillField', 'I.type', 'I.selectOption', 'I.attachFile', 'I.checkOption', 'I.uncheckOption'];
|
|
9
11
|
|
|
10
12
|
export function isCodeceptToolName(toolName: string): toolName is CodeceptToolName {
|
|
@@ -71,3 +73,9 @@ export function mergeUniqueStepsByCode(primary: SessionStep[], secondary: Sessio
|
|
|
71
73
|
}
|
|
72
74
|
return merged;
|
|
73
75
|
}
|
|
76
|
+
|
|
77
|
+
export function isInternalStep(step: { title?: string }): boolean {
|
|
78
|
+
const title = step?.title;
|
|
79
|
+
if (!title) return false;
|
|
80
|
+
return INTERNAL_STEP_PREFIXES.some((prefix) => title.startsWith(prefix));
|
|
81
|
+
}
|
package/src/utils/strings.ts
CHANGED
|
@@ -47,6 +47,13 @@ export function truncate(text: string, max: number): string {
|
|
|
47
47
|
return `${text.slice(0, max - 3)}...`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export function truncateMiddle(text: string, max: number): string {
|
|
51
|
+
if (text.length <= max) return text;
|
|
52
|
+
const head = Math.ceil((max - 3) / 2);
|
|
53
|
+
const tail = max - 3 - head;
|
|
54
|
+
return `${text.slice(0, head)}...${text.slice(text.length - tail)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
const MAX_COMPACT_ERROR = 400;
|
|
51
58
|
|
|
52
59
|
export function compactErrorMessage(error: unknown): string {
|
package/src/utils/test-files.ts
CHANGED
|
@@ -9,13 +9,16 @@ import store from 'codeceptjs/lib/store';
|
|
|
9
9
|
import figureSet from 'figures';
|
|
10
10
|
import { ConfigParser } from '../config.ts';
|
|
11
11
|
|
|
12
|
-
export function
|
|
12
|
+
export function listTestFiles(testsDir: string): string[] {
|
|
13
13
|
if (!existsSync(testsDir)) return [];
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
return readdirSync(testsDir)
|
|
16
16
|
.filter((f) => f.endsWith('.js'))
|
|
17
17
|
.map((f) => path.resolve(testsDir, f));
|
|
18
|
+
}
|
|
18
19
|
|
|
20
|
+
export function loadTestSuites(testsDir: string): any[] {
|
|
21
|
+
const jsFiles = listTestFiles(testsDir);
|
|
19
22
|
if (jsFiles.length === 0) return [];
|
|
20
23
|
|
|
21
24
|
codeceptjs.container.createMocha();
|
package/src/utils/url-matcher.ts
CHANGED
|
@@ -132,3 +132,10 @@ export function matchesNavigationUrl(expected: string, current: string): boolean
|
|
|
132
132
|
.filter(Boolean);
|
|
133
133
|
return recordSegments.length > 0 && recordSegments.every(isDynamicSegment);
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
export function isSameHostFamily(urlA: string, urlB: string): boolean {
|
|
137
|
+
const hostA = URL.parse(urlA)?.host.toLowerCase();
|
|
138
|
+
const hostB = URL.parse(urlB)?.host.toLowerCase();
|
|
139
|
+
if (!hostA || !hostB) return false;
|
|
140
|
+
return hostA === hostB || hostA.endsWith(`.${hostB}`) || hostB.endsWith(`.${hostA}`);
|
|
141
|
+
}
|