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