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
|
@@ -17,7 +17,7 @@ export const DEADLINE_RESERVE_MS = 3 * 60_000;
|
|
|
17
17
|
export const DEADLINE_TEST_ALLOWANCE_MS = 5 * 60_000;
|
|
18
18
|
export class ExploreCommand extends BaseCommand {
|
|
19
19
|
name = 'explore';
|
|
20
|
-
description = 'Start web exploration';
|
|
20
|
+
description = 'Start web exploration on the current page, or on the URL given as the first argument';
|
|
21
21
|
options = [
|
|
22
22
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
23
23
|
{ flags: '--max-duration <minutes>', description: 'Wall-clock budget in minutes; wraps up the session before the limit is hit' },
|
|
@@ -55,6 +55,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
55
55
|
if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
|
|
56
56
|
this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
|
|
57
57
|
}
|
|
58
|
+
const [target] = remaining;
|
|
59
|
+
if (target?.startsWith('/') || target?.startsWith('http://') || target?.startsWith('https://')) {
|
|
60
|
+
remaining.shift();
|
|
61
|
+
await this.explorBot.visit(target);
|
|
62
|
+
}
|
|
58
63
|
const feature = opts.focus || remaining.join(' ') || undefined;
|
|
59
64
|
const cfg = this.parseConfigure(opts.configure);
|
|
60
65
|
if (cfg.priorities)
|
|
@@ -96,6 +101,12 @@ export class ExploreCommand extends BaseCommand {
|
|
|
96
101
|
await this.explorBot.printSessionAnalysis();
|
|
97
102
|
}
|
|
98
103
|
}
|
|
104
|
+
completeArguments() {
|
|
105
|
+
return this.explorBot
|
|
106
|
+
.stateManager()
|
|
107
|
+
.getKnownUrls()
|
|
108
|
+
.map((url) => ({ value: url }));
|
|
109
|
+
}
|
|
99
110
|
originLabel(test) {
|
|
100
111
|
return this.oldTestRefs.has(test) ? 'OLD' : 'NEW';
|
|
101
112
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Planner } from '../ai/planner.js';
|
|
2
2
|
import { Researcher } from '../ai/researcher.js';
|
|
3
|
+
import { normalizeUrl } from '../state-manager.js';
|
|
3
4
|
import { Stats } from '../stats.js';
|
|
4
5
|
import { tag } from '../utils/logger.js';
|
|
5
6
|
import { loop } from '../utils/loop.js';
|
|
@@ -65,11 +66,16 @@ export class FreesailCommand extends BaseCommand {
|
|
|
65
66
|
const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls });
|
|
66
67
|
if (!suggestion) {
|
|
67
68
|
tag('info').log('No navigation suggestion available');
|
|
68
|
-
|
|
69
|
+
ctx.stop();
|
|
69
70
|
}
|
|
70
71
|
if (scope && !suggestion.target.startsWith(scope)) {
|
|
71
72
|
tag('warning').log(`Suggestion ${suggestion.target} is outside scope ${scope}, skipping`);
|
|
72
|
-
|
|
73
|
+
ctx.stop();
|
|
74
|
+
}
|
|
75
|
+
const currentUrl = stateManager.getCurrentState()?.url;
|
|
76
|
+
if (currentUrl && normalizeUrl(suggestion.target) === normalizeUrl(currentUrl)) {
|
|
77
|
+
tag('info').log(`No new navigation target available after ${currentUrl}; stopping exploration`);
|
|
78
|
+
ctx.stop();
|
|
73
79
|
}
|
|
74
80
|
tag('info').log(`Navigating to: ${suggestion.target} - ${suggestion.reason}`);
|
|
75
81
|
await this.explorBot.openTab();
|
|
@@ -145,7 +145,7 @@ export function runInitCommand(options) {
|
|
|
145
145
|
log('2. Set AI models config file');
|
|
146
146
|
log('3. Set web application URL in the config file');
|
|
147
147
|
log('4. Add initial knowledge (how to authorize to the application, etc.)');
|
|
148
|
-
tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to authorize use these credentials: admin@example.com / secret123'`));
|
|
148
|
+
tag('substep').log(chalk.yellow(`${getCliName()} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
|
|
149
149
|
tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
|
|
150
150
|
log('5. Launch application on a relative URL');
|
|
151
151
|
tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { BaseCommand, type Suggestion } from './base-command.js';
|
|
1
|
+
import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
|
|
2
2
|
export declare class NavigateCommand extends BaseCommand {
|
|
3
3
|
name: string;
|
|
4
4
|
description: string;
|
|
5
5
|
suggestions: Suggestion[];
|
|
6
6
|
execute(args: string): Promise<void>;
|
|
7
|
+
completeArguments(): ArgumentCompletion[];
|
|
7
8
|
}
|
|
@@ -15,4 +15,10 @@ export class NavigateCommand extends BaseCommand {
|
|
|
15
15
|
await this.explorBot.visit(destination);
|
|
16
16
|
tag('success').log(`Navigation requested: ${destination}`);
|
|
17
17
|
}
|
|
18
|
+
completeArguments() {
|
|
19
|
+
return this.explorBot
|
|
20
|
+
.stateManager()
|
|
21
|
+
.getKnownUrls()
|
|
22
|
+
.map((url) => ({ value: url }));
|
|
23
|
+
}
|
|
18
24
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { BaseCommand, type Suggestion } from './base-command.js';
|
|
1
|
+
import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
|
|
2
2
|
export declare class PlanLoadCommand extends BaseCommand {
|
|
3
3
|
name: string;
|
|
4
4
|
description: string;
|
|
5
5
|
suggestions: Suggestion[];
|
|
6
6
|
execute(args: string): Promise<void>;
|
|
7
|
+
completeArguments(): ArgumentCompletion[];
|
|
7
8
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Plan } from '../test-plan.js';
|
|
1
2
|
import { tag } from '../utils/logger.js';
|
|
2
3
|
import { BaseCommand } from './base-command.js';
|
|
3
4
|
export class PlanLoadCommand extends BaseCommand {
|
|
@@ -15,4 +16,7 @@ export class PlanLoadCommand extends BaseCommand {
|
|
|
15
16
|
const plan = this.explorBot.loadPlan(filename);
|
|
16
17
|
tag('success').log(`Plan loaded: ${plan.title} with ${plan.tests.length} tests`);
|
|
17
18
|
}
|
|
19
|
+
completeArguments() {
|
|
20
|
+
return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: file.name, display: file.label }));
|
|
21
|
+
}
|
|
18
22
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Plan } from '../test-plan.js';
|
|
2
|
-
import { BaseCommand } from './base-command.js';
|
|
1
|
+
import { Plan, type PlanFile } from '../test-plan.js';
|
|
2
|
+
import { type ArgumentCompletion, BaseCommand } from './base-command.js';
|
|
3
3
|
export declare class PlansCommand extends BaseCommand {
|
|
4
4
|
name: string;
|
|
5
5
|
description: string;
|
|
@@ -8,7 +8,7 @@ export declare class PlansCommand extends BaseCommand {
|
|
|
8
8
|
description: string;
|
|
9
9
|
}[];
|
|
10
10
|
execute(args: string): Promise<void>;
|
|
11
|
-
|
|
11
|
+
completeArguments(): ArgumentCompletion[];
|
|
12
12
|
printPlans(files: PlanFile[]): void;
|
|
13
13
|
printPlanDetails(plan: Plan, file: PlanFile): void;
|
|
14
14
|
resolvePlanFile(target: string, files: PlanFile[]): {
|
|
@@ -16,9 +16,3 @@ export declare class PlansCommand extends BaseCommand {
|
|
|
16
16
|
file: PlanFile;
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
-
interface PlanFile {
|
|
20
|
-
name: string;
|
|
21
|
-
path: string;
|
|
22
|
-
modifiedAt: number;
|
|
23
|
-
}
|
|
24
|
-
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Plan } from '../test-plan.js';
|
|
4
4
|
import { getCliName } from '../utils/cli-name.js';
|
|
@@ -11,7 +11,7 @@ export class PlansCommand extends BaseCommand {
|
|
|
11
11
|
options = [{ flags: '--from-plan <file>', description: 'Plan file to show' }];
|
|
12
12
|
async execute(args) {
|
|
13
13
|
const { opts, args: remaining } = this.parseArgs(args);
|
|
14
|
-
const files = this.
|
|
14
|
+
const files = Plan.listFiles(this.explorBot.getPlansDir());
|
|
15
15
|
const target = String(opts.fromPlan || remaining[0] || '').trim();
|
|
16
16
|
if (!target) {
|
|
17
17
|
this.printPlans(files);
|
|
@@ -20,22 +20,8 @@ export class PlansCommand extends BaseCommand {
|
|
|
20
20
|
const { plan, file } = this.resolvePlanFile(target, files);
|
|
21
21
|
this.printPlanDetails(plan, file);
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (!existsSync(plansDir))
|
|
26
|
-
return [];
|
|
27
|
-
return readdirSync(plansDir)
|
|
28
|
-
.filter((file) => file.endsWith('.md'))
|
|
29
|
-
.map((file) => {
|
|
30
|
-
const filePath = path.join(plansDir, file);
|
|
31
|
-
const stat = statSync(filePath);
|
|
32
|
-
return {
|
|
33
|
-
name: file,
|
|
34
|
-
path: filePath,
|
|
35
|
-
modifiedAt: stat.mtimeMs,
|
|
36
|
-
};
|
|
37
|
-
})
|
|
38
|
-
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
23
|
+
completeArguments() {
|
|
24
|
+
return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: file.name, display: file.label }));
|
|
39
25
|
}
|
|
40
26
|
printPlans(files) {
|
|
41
27
|
if (files.length === 0) {
|
|
@@ -45,8 +31,7 @@ export class PlansCommand extends BaseCommand {
|
|
|
45
31
|
tag('info').log('Saved plans:');
|
|
46
32
|
for (let i = 0; i < files.length; i++) {
|
|
47
33
|
const file = files[i];
|
|
48
|
-
|
|
49
|
-
tag('info').log(`${i + 1}. ${plan.title} (${plan.tests.length} tests) - ${file.name}`);
|
|
34
|
+
tag('info').log(`${i + 1}. ${file.title} (${file.testCount} tests) - ${file.name}`);
|
|
50
35
|
}
|
|
51
36
|
tag('info').log('');
|
|
52
37
|
tag('info').log(`View plan tests: ${getCliName()} plans <number>`);
|
|
@@ -73,10 +58,15 @@ export class PlansCommand extends BaseCommand {
|
|
|
73
58
|
if (!plan?.filePath) {
|
|
74
59
|
throw new Error(`Plan file not found: ${target}`);
|
|
75
60
|
}
|
|
61
|
+
const name = path.basename(plan.filePath);
|
|
76
62
|
const file = {
|
|
77
|
-
name
|
|
63
|
+
name,
|
|
78
64
|
path: plan.filePath,
|
|
79
65
|
modifiedAt: statSync(plan.filePath).mtimeMs,
|
|
66
|
+
title: plan.title,
|
|
67
|
+
url: plan.startUrl || '',
|
|
68
|
+
testCount: plan.tests.length,
|
|
69
|
+
label: name,
|
|
80
70
|
};
|
|
81
71
|
return { plan, file };
|
|
82
72
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { BaseCommand } from './base-command.js';
|
|
1
|
+
import { type ArgumentCompletion, BaseCommand } from './base-command.js';
|
|
2
2
|
export declare class RerunCommand extends BaseCommand {
|
|
3
3
|
name: string;
|
|
4
4
|
description: string;
|
|
5
5
|
tuiEnabled: boolean;
|
|
6
6
|
execute(args: string): Promise<void>;
|
|
7
|
+
completeArguments(): ArgumentCompletion[];
|
|
7
8
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
3
|
import { ConfigParser } from "../config.js";
|
|
4
4
|
import { tag } from "../utils/logger.js";
|
|
5
|
+
import { listTestFiles } from "../utils/test-files.js";
|
|
5
6
|
import { BaseCommand } from './base-command.js';
|
|
6
7
|
export class RerunCommand extends BaseCommand {
|
|
7
8
|
name = 'rerun';
|
|
@@ -26,6 +27,9 @@ export class RerunCommand extends BaseCommand {
|
|
|
26
27
|
const testIndices = indexArg ? parseTestIndices(indexArg) : undefined;
|
|
27
28
|
await this.explorBot.agentRerunner().rerun(filePath, { testIndices });
|
|
28
29
|
}
|
|
30
|
+
completeArguments() {
|
|
31
|
+
return listTestFiles(ConfigParser.getInstance().getTestsDir()).map((file) => ({ value: basename(file) }));
|
|
32
|
+
}
|
|
29
33
|
}
|
|
30
34
|
function parseTestIndices(input) {
|
|
31
35
|
if (input === '*' || input === 'all')
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseCommand, type Suggestion } from './base-command.js';
|
|
1
|
+
import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
|
|
2
2
|
export declare class ResearchCommand extends BaseCommand {
|
|
3
3
|
name: string;
|
|
4
4
|
description: string;
|
|
@@ -8,4 +8,5 @@ export declare class ResearchCommand extends BaseCommand {
|
|
|
8
8
|
description: string;
|
|
9
9
|
}[];
|
|
10
10
|
execute(args: string): Promise<void>;
|
|
11
|
+
completeArguments(): ArgumentCompletion[];
|
|
11
12
|
}
|
|
@@ -43,4 +43,10 @@ export class ResearchCommand extends BaseCommand {
|
|
|
43
43
|
this.suggestions = [{ command: 'research <page> --deep', hint: 'analyze page for all expandable elements and interactions' }];
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
completeArguments() {
|
|
47
|
+
return this.explorBot
|
|
48
|
+
.stateManager()
|
|
49
|
+
.getKnownUrls()
|
|
50
|
+
.map((url) => ({ value: url }));
|
|
51
|
+
}
|
|
46
52
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseCommand, type Suggestion } from './base-command.js';
|
|
1
|
+
import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
|
|
2
2
|
export declare class TestCommand extends BaseCommand {
|
|
3
3
|
name: string;
|
|
4
4
|
description: string;
|
|
@@ -8,4 +8,5 @@ export declare class TestCommand extends BaseCommand {
|
|
|
8
8
|
}[];
|
|
9
9
|
suggestions: Suggestion[];
|
|
10
10
|
execute(args: string): Promise<void>;
|
|
11
|
+
completeArguments(): ArgumentCompletion[];
|
|
11
12
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Stats } from '../stats.js';
|
|
2
|
-
import { Test } from '../test-plan.js';
|
|
2
|
+
import { Plan, Test } from '../test-plan.js';
|
|
3
3
|
import { tag } from '../utils/logger.js';
|
|
4
4
|
import { BaseCommand } from './base-command.js';
|
|
5
5
|
export class TestCommand extends BaseCommand {
|
|
@@ -74,6 +74,9 @@ export class TestCommand extends BaseCommand {
|
|
|
74
74
|
}
|
|
75
75
|
tag('success').log('Test execution finished');
|
|
76
76
|
}
|
|
77
|
+
completeArguments() {
|
|
78
|
+
return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: `--from-plan ${file.name}`, display: file.label }));
|
|
79
|
+
}
|
|
77
80
|
}
|
|
78
81
|
function parseTestIndices(input, total) {
|
|
79
82
|
const indices = new Set();
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { Box, Text } from 'ink';
|
|
1
|
+
import { Box, Text, useStdout } from 'ink';
|
|
2
2
|
import React from 'react';
|
|
3
|
+
import { truncate } from '../utils/strings.js';
|
|
3
4
|
import { useAutocompleteState } from './autocomplete-store.js';
|
|
5
|
+
const MAX_HEIGHT = 7;
|
|
6
|
+
const COLUMN_WIDTH = 24;
|
|
4
7
|
const Autocomplete = () => {
|
|
5
8
|
const { suggestions, selectedIndex, visible, argumentHint } = useAutocompleteState();
|
|
9
|
+
const { stdout } = useStdout();
|
|
6
10
|
if (!visible) {
|
|
7
11
|
if (!argumentHint) {
|
|
8
12
|
return null;
|
|
@@ -13,19 +17,32 @@ const Autocomplete = () => {
|
|
|
13
17
|
React.createElement(Text, { dimColor: true }, argumentHint)));
|
|
14
18
|
}
|
|
15
19
|
const effectiveSelectedIndex = Math.min(selectedIndex, suggestions.length - 1);
|
|
16
|
-
const
|
|
17
|
-
const
|
|
20
|
+
const rowsPerColumn = Math.min(Math.max(1, MAX_HEIGHT - 2), suggestions.length);
|
|
21
|
+
const widest = Math.max(...suggestions.map((suggestion) => suggestion.display.length));
|
|
22
|
+
if (widest > COLUMN_WIDTH) {
|
|
23
|
+
const width = Math.max(COLUMN_WIDTH, (stdout?.columns || 80) - 4);
|
|
24
|
+
let firstRow = 0;
|
|
25
|
+
if (effectiveSelectedIndex >= rowsPerColumn) {
|
|
26
|
+
firstRow = effectiveSelectedIndex - rowsPerColumn + 1;
|
|
27
|
+
}
|
|
28
|
+
return (React.createElement(Box, { position: "absolute", top: 0, left: 0, width: "100%", maxHeight: MAX_HEIGHT, overflow: "hidden", paddingX: 1, paddingY: 1, backgroundColor: "#2a2a2a", flexDirection: "column" }, suggestions.slice(firstRow, firstRow + rowsPerColumn).map((suggestion, rowIndex) => {
|
|
29
|
+
const isSelected = firstRow + rowIndex === effectiveSelectedIndex;
|
|
30
|
+
let color = 'white';
|
|
31
|
+
let backgroundColor = '#2a2a2a';
|
|
32
|
+
if (isSelected) {
|
|
33
|
+
color = 'black';
|
|
34
|
+
backgroundColor = '#e6e6e6';
|
|
35
|
+
}
|
|
36
|
+
return (React.createElement(Text, { key: suggestion.value, color: color, backgroundColor: backgroundColor }, ` ${truncate(suggestion.display, width).padEnd(width)} `));
|
|
37
|
+
})));
|
|
38
|
+
}
|
|
18
39
|
const columns = [];
|
|
19
40
|
for (let index = 0; index < suggestions.length; index += rowsPerColumn) {
|
|
20
41
|
columns.push(suggestions.slice(index, index + rowsPerColumn));
|
|
21
42
|
}
|
|
22
|
-
return (React.createElement(Box, { position: "absolute", top: 0, left: 0, width: "100%", maxHeight:
|
|
43
|
+
return (React.createElement(Box, { position: "absolute", top: 0, left: 0, width: "100%", maxHeight: MAX_HEIGHT, overflow: "hidden", paddingX: 1, paddingY: 1, backgroundColor: "#2a2a2a", flexDirection: "row", columnGap: 2 }, columns.map((column, columnIndex) => (React.createElement(Box, { key: columnIndex, flexDirection: "column" }, column.map((suggestion, rowIndex) => {
|
|
23
44
|
const index = columnIndex * rowsPerColumn + rowIndex;
|
|
24
45
|
const isSelected = index === effectiveSelectedIndex;
|
|
25
|
-
let display = suggestion.display;
|
|
26
|
-
if (display.length > 24) {
|
|
27
|
-
display = `${display.slice(0, 21)}...`;
|
|
28
|
-
}
|
|
29
46
|
let color = 'white';
|
|
30
47
|
let backgroundColor = '#2a2a2a';
|
|
31
48
|
if (isSelected) {
|
|
@@ -33,10 +50,7 @@ const Autocomplete = () => {
|
|
|
33
50
|
backgroundColor = '#e6e6e6';
|
|
34
51
|
}
|
|
35
52
|
return (React.createElement(Box, { key: suggestion.display, marginBottom: 1 },
|
|
36
|
-
React.createElement(Text, { color: color, backgroundColor: backgroundColor },
|
|
37
|
-
' ',
|
|
38
|
-
display,
|
|
39
|
-
' ')));
|
|
53
|
+
React.createElement(Text, { color: color, backgroundColor: backgroundColor }, ` ${suggestion.display} `)));
|
|
40
54
|
}))))));
|
|
41
55
|
};
|
|
42
56
|
export default Autocomplete;
|
|
@@ -17,6 +17,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
17
17
|
const cursorRef = useRef(cursorPosition);
|
|
18
18
|
const showAutocompleteRef = useRef(showAutocomplete);
|
|
19
19
|
const selectedIndexRef = useRef(selectedIndex);
|
|
20
|
+
const navigatedRef = useRef(false);
|
|
20
21
|
const historyRef = useRef([]);
|
|
21
22
|
const historyIndexRef = useRef(-1);
|
|
22
23
|
const historyDraftRef = useRef('');
|
|
@@ -55,6 +56,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
55
56
|
cursorRef.current = 0;
|
|
56
57
|
showAutocompleteRef.current = false;
|
|
57
58
|
selectedIndexRef.current = 0;
|
|
59
|
+
navigatedRef.current = false;
|
|
58
60
|
historyIndexRef.current = -1;
|
|
59
61
|
historyDraftRef.current = '';
|
|
60
62
|
setInputState({ value: '', cursor: 0, showAutocomplete: false, selectedIndex: 0 });
|
|
@@ -101,6 +103,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
101
103
|
cursorRef.current = safeCursor;
|
|
102
104
|
showAutocompleteRef.current = nextShowAutocomplete;
|
|
103
105
|
selectedIndexRef.current = 0;
|
|
106
|
+
navigatedRef.current = false;
|
|
104
107
|
historyIndexRef.current = -1;
|
|
105
108
|
historyDraftRef.current = '';
|
|
106
109
|
if (isControlled) {
|
|
@@ -173,6 +176,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
173
176
|
if (showAutocompleteRef.current) {
|
|
174
177
|
showAutocompleteRef.current = false;
|
|
175
178
|
selectedIndexRef.current = 0;
|
|
179
|
+
navigatedRef.current = false;
|
|
176
180
|
setInputState((prev) => ({ ...prev, showAutocomplete: false, selectedIndex: 0 }));
|
|
177
181
|
}
|
|
178
182
|
return;
|
|
@@ -184,7 +188,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
184
188
|
}
|
|
185
189
|
if (commandHandler && showAutocompleteRef.current) {
|
|
186
190
|
const autocomplete = getAutocomplete(inputRef.current, cursorRef.current);
|
|
187
|
-
if (autocomplete.visible && autocomplete.suggestions.length > 0) {
|
|
191
|
+
if (autocomplete.visible && autocomplete.suggestions.length > 0 && (!autocomplete.completesArgument || navigatedRef.current)) {
|
|
188
192
|
const chosen = applyAutocomplete(selectedIndexRef.current);
|
|
189
193
|
if (chosen) {
|
|
190
194
|
handleSubmit(chosen);
|
|
@@ -321,6 +325,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
321
325
|
if (autocomplete.visible && autocomplete.suggestions.length > 0) {
|
|
322
326
|
const nextIndex = selectedIndexRef.current > 0 ? selectedIndexRef.current - 1 : autocomplete.suggestions.length - 1;
|
|
323
327
|
selectedIndexRef.current = nextIndex;
|
|
328
|
+
navigatedRef.current = true;
|
|
324
329
|
setInputState((prev) => ({ ...prev, selectedIndex: nextIndex }));
|
|
325
330
|
return;
|
|
326
331
|
}
|
|
@@ -330,6 +335,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
330
335
|
if (autocomplete.visible && autocomplete.suggestions.length > 0) {
|
|
331
336
|
const nextIndex = selectedIndexRef.current < autocomplete.suggestions.length - 1 ? selectedIndexRef.current + 1 : 0;
|
|
332
337
|
selectedIndexRef.current = nextIndex;
|
|
338
|
+
navigatedRef.current = true;
|
|
333
339
|
setInputState((prev) => ({ ...prev, selectedIndex: nextIndex }));
|
|
334
340
|
return;
|
|
335
341
|
}
|
|
@@ -388,6 +394,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
388
394
|
inputRef.current = nextValue;
|
|
389
395
|
cursorRef.current = nextValue.length;
|
|
390
396
|
selectedIndexRef.current = 0;
|
|
397
|
+
navigatedRef.current = false;
|
|
391
398
|
showAutocompleteRef.current = nextShowAutocomplete;
|
|
392
399
|
setInputState({ value: nextValue, cursor: nextValue.length, selectedIndex: 0, showAutocomplete: nextShowAutocomplete });
|
|
393
400
|
return;
|
|
@@ -410,6 +417,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
410
417
|
inputRef.current = nextValue;
|
|
411
418
|
cursorRef.current = nextValue.length;
|
|
412
419
|
selectedIndexRef.current = 0;
|
|
420
|
+
navigatedRef.current = false;
|
|
413
421
|
showAutocompleteRef.current = nextShowAutocomplete;
|
|
414
422
|
setInputState({ value: nextValue, cursor: nextValue.length, selectedIndex: 0, showAutocomplete: nextShowAutocomplete });
|
|
415
423
|
return;
|
|
@@ -421,6 +429,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
|
|
|
421
429
|
inputRef.current = nextValue;
|
|
422
430
|
cursorRef.current = nextValue.length;
|
|
423
431
|
selectedIndexRef.current = 0;
|
|
432
|
+
navigatedRef.current = false;
|
|
424
433
|
showAutocompleteRef.current = nextShowAutocomplete;
|
|
425
434
|
setInputState({ value: nextValue, cursor: nextValue.length, selectedIndex: 0, showAutocomplete: nextShowAutocomplete });
|
|
426
435
|
return;
|
package/dist/src/config.d.ts
CHANGED
|
@@ -157,6 +157,12 @@ interface AIConfig {
|
|
|
157
157
|
vision?: boolean;
|
|
158
158
|
visionModel?: any;
|
|
159
159
|
agenticModel?: any;
|
|
160
|
+
decisionModel?: {
|
|
161
|
+
provider: string;
|
|
162
|
+
model: string;
|
|
163
|
+
tool?: boolean;
|
|
164
|
+
direct?: boolean;
|
|
165
|
+
};
|
|
160
166
|
retryAttempts?: number;
|
|
161
167
|
retryDelay?: number;
|
|
162
168
|
maxParallelRequests?: number;
|
|
@@ -224,7 +224,7 @@ export class ExperienceTracker {
|
|
|
224
224
|
const sections = mdq(content).query('section2').each();
|
|
225
225
|
if (sections.length <= 1)
|
|
226
226
|
break;
|
|
227
|
-
content = sections[sections.length - 1].replace('');
|
|
227
|
+
content = sections[sections.length - 1].replace('').toString();
|
|
228
228
|
}
|
|
229
229
|
return { ...experience, content };
|
|
230
230
|
});
|
|
@@ -244,7 +244,7 @@ export class ExperienceTracker {
|
|
|
244
244
|
continue;
|
|
245
245
|
combined = renderAsHowTo(combined);
|
|
246
246
|
if (options?.stripCode) {
|
|
247
|
-
combined = mdq(combined).query('code').replace('');
|
|
247
|
+
combined = mdq(combined).query('code').replace('').toString();
|
|
248
248
|
}
|
|
249
249
|
if (combined.trim())
|
|
250
250
|
results.push(combined.trim());
|
|
@@ -459,5 +459,6 @@ function renderAsHowTo(content) {
|
|
|
459
459
|
if (text.startsWith('ACTION:'))
|
|
460
460
|
return `## HOW to ${text.slice(7).trim()} (single-step)\n\n`;
|
|
461
461
|
return heading.text();
|
|
462
|
-
})
|
|
462
|
+
})
|
|
463
|
+
.toString();
|
|
463
464
|
}
|
package/dist/src/explorbot.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { Driller } from './ai/driller.js';
|
|
|
5
5
|
import { ExperienceCompactor } from './ai/experience-compactor.js';
|
|
6
6
|
import { Fisherman } from './ai/fisherman.js';
|
|
7
7
|
import { Historian } from './ai/historian.js';
|
|
8
|
+
import { Judge } from './ai/judge.js';
|
|
8
9
|
import { Navigator } from './ai/navigator.js';
|
|
9
10
|
import { Pilot } from './ai/pilot.js';
|
|
10
11
|
import { Planner } from './ai/planner.js';
|
|
@@ -64,6 +65,7 @@ export declare class ExplorBot {
|
|
|
64
65
|
_reporter?: Reporter;
|
|
65
66
|
_requestStore?: RequestStore;
|
|
66
67
|
_playwrightRecorder?: PlaywrightRecorder;
|
|
68
|
+
judgeInstance: Judge | null | undefined;
|
|
67
69
|
constructor(options?: ExplorBotOptions);
|
|
68
70
|
get isExploring(): boolean;
|
|
69
71
|
setUserResolve(fn: UserResolveFunction): void;
|
|
@@ -83,6 +85,7 @@ export declare class ExplorBot {
|
|
|
83
85
|
reporter(): Reporter;
|
|
84
86
|
requestStore(): RequestStore;
|
|
85
87
|
playwrightRecorder(): PlaywrightRecorder;
|
|
88
|
+
judge(): Judge | null;
|
|
86
89
|
getConfig(): ExplorbotConfig;
|
|
87
90
|
getOptions(): ExplorBotOptions;
|
|
88
91
|
getProvider(): AIProvider;
|
package/dist/src/explorbot.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Driller } from "./ai/driller.js";
|
|
|
5
5
|
import { ExperienceCompactor } from "./ai/experience-compactor.js";
|
|
6
6
|
import { Fisherman } from "./ai/fisherman.js";
|
|
7
7
|
import { Historian } from "./ai/historian.js";
|
|
8
|
+
import { Judge } from "./ai/judge.js";
|
|
8
9
|
import { Navigator } from "./ai/navigator.js";
|
|
9
10
|
import { Pilot } from "./ai/pilot.js";
|
|
10
11
|
import { Planner } from "./ai/planner.js";
|
|
@@ -56,6 +57,7 @@ export class ExplorBot {
|
|
|
56
57
|
_reporter;
|
|
57
58
|
_requestStore;
|
|
58
59
|
_playwrightRecorder;
|
|
60
|
+
judgeInstance;
|
|
59
61
|
constructor(options = {}) {
|
|
60
62
|
this.options = options;
|
|
61
63
|
this.configParser = ConfigParser.getInstance();
|
|
@@ -152,6 +154,11 @@ export class ExplorBot {
|
|
|
152
154
|
playwrightRecorder() {
|
|
153
155
|
return (this._playwrightRecorder ||= new PlaywrightRecorder());
|
|
154
156
|
}
|
|
157
|
+
judge() {
|
|
158
|
+
if (this.judgeInstance === undefined)
|
|
159
|
+
this.judgeInstance = Judge.fromConfig(this.config.ai?.decisionModel);
|
|
160
|
+
return this.judgeInstance;
|
|
161
|
+
}
|
|
155
162
|
getConfig() {
|
|
156
163
|
return this.config;
|
|
157
164
|
}
|
|
@@ -170,6 +177,7 @@ export class ExplorBot {
|
|
|
170
177
|
knowledgeTracker: this.knowledgeTracker(),
|
|
171
178
|
requestStore: this.requestStore(),
|
|
172
179
|
playwrightRecorder: this.playwrightRecorder(),
|
|
180
|
+
judge: this.judge() || undefined,
|
|
173
181
|
});
|
|
174
182
|
const agentName = agent.constructor.name.toLowerCase();
|
|
175
183
|
tag('debug').log(`Created ${agentName} agent`);
|
package/dist/src/explorer.js
CHANGED
|
@@ -13,6 +13,7 @@ import { ConfigParser } from './config.js';
|
|
|
13
13
|
import { BrowserRecoveryError, browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
|
|
14
14
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
15
15
|
import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
|
|
16
|
+
import { isInternalStep } from "./utils/step-analyzer.js";
|
|
16
17
|
const debugLog = createDebug('explorbot:explorer');
|
|
17
18
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
18
19
|
const RECOVERY_NAVIGATION = { waitUntil: 'domcontentloaded', timeout: 10000 };
|
|
@@ -161,9 +162,7 @@ class Explorer {
|
|
|
161
162
|
const stepHandler = (step, status, error, log) => {
|
|
162
163
|
if (!step.toCode)
|
|
163
164
|
return;
|
|
164
|
-
if (step
|
|
165
|
-
return;
|
|
166
|
-
if (step?.name?.startsWith('save'))
|
|
165
|
+
if (isInternalStep(step))
|
|
167
166
|
return;
|
|
168
167
|
test.addStep(step.toCode(), step.duration, status, error, log);
|
|
169
168
|
if (!this.stateManager.getCurrentState())
|
|
@@ -171,7 +171,7 @@ export class KnowledgeTracker {
|
|
|
171
171
|
this.loadKnowledgeFiles();
|
|
172
172
|
return this.knowledgeFiles.map((knowledge) => {
|
|
173
173
|
const content = knowledge.content.trim();
|
|
174
|
-
const firstLine = mdq(content).
|
|
174
|
+
const firstLine = mdq(content).nodes()[0]?.text.split('\n')[0]?.trim() || '';
|
|
175
175
|
return {
|
|
176
176
|
url: knowledge.url || knowledge.endpoint || '',
|
|
177
177
|
firstLine,
|
package/dist/src/reporter.js
CHANGED
|
@@ -111,6 +111,7 @@ export class Reporter {
|
|
|
111
111
|
}
|
|
112
112
|
combineStepsAndNotes(test, lastScreenshotFile) {
|
|
113
113
|
const noteEntries = Object.entries(test.notes)
|
|
114
|
+
.filter(([, note]) => !note.observation)
|
|
114
115
|
.map(([timestampKey, note]) => ({
|
|
115
116
|
startTime: note.startTime,
|
|
116
117
|
endTime: note.endTime,
|
|
@@ -233,9 +234,12 @@ export class Reporter {
|
|
|
233
234
|
description: test.description,
|
|
234
235
|
code: test.generatedCode || '',
|
|
235
236
|
steps,
|
|
236
|
-
logs:
|
|
237
|
-
.map((stepData) => stepData.text)
|
|
238
|
-
.
|
|
237
|
+
logs: [
|
|
238
|
+
...Object.values(test.steps).map((stepData) => stepData.text),
|
|
239
|
+
...Object.values(test.notes)
|
|
240
|
+
.filter((note) => note.observation)
|
|
241
|
+
.map((note) => note.message),
|
|
242
|
+
].join('\n'),
|
|
239
243
|
files: Object.values(test.artifacts) || [],
|
|
240
244
|
message: test.summary || this.extractLastNoteMessage(test) || '',
|
|
241
245
|
meta,
|
|
@@ -300,7 +304,7 @@ export class Reporter {
|
|
|
300
304
|
}
|
|
301
305
|
}
|
|
302
306
|
extractLastNoteMessage(test) {
|
|
303
|
-
const notes = Object.values(test.notes);
|
|
307
|
+
const notes = Object.values(test.notes).filter((note) => !note.observation);
|
|
304
308
|
if (notes.length === 0)
|
|
305
309
|
return '';
|
|
306
310
|
return notes[notes.length - 1].message;
|
|
@@ -72,6 +72,7 @@ export declare class StateManager {
|
|
|
72
72
|
currentState: WebPageState | null;
|
|
73
73
|
stateHistory: StateTransition[];
|
|
74
74
|
allVisitedUrls: Set<string>;
|
|
75
|
+
experienceUrls?: string[];
|
|
75
76
|
stateChangeListeners: StateChangeListener[];
|
|
76
77
|
experienceTracker: ExperienceTracker;
|
|
77
78
|
knowledgeTracker: KnowledgeTracker;
|
|
@@ -137,6 +138,7 @@ export declare class StateManager {
|
|
|
137
138
|
*/
|
|
138
139
|
hasVisitedState(path: string): boolean;
|
|
139
140
|
getAllVisitedUrls(): Set<string>;
|
|
141
|
+
getKnownUrls(): string[];
|
|
140
142
|
/**
|
|
141
143
|
* Get how many times we've visited a specific path
|
|
142
144
|
*/
|
|
@@ -9,6 +9,7 @@ export class StateManager {
|
|
|
9
9
|
currentState = null;
|
|
10
10
|
stateHistory = [];
|
|
11
11
|
allVisitedUrls = new Set();
|
|
12
|
+
experienceUrls;
|
|
12
13
|
stateChangeListeners = [];
|
|
13
14
|
experienceTracker;
|
|
14
15
|
knowledgeTracker;
|
|
@@ -243,6 +244,21 @@ export class StateManager {
|
|
|
243
244
|
getAllVisitedUrls() {
|
|
244
245
|
return this.allVisitedUrls;
|
|
245
246
|
}
|
|
247
|
+
getKnownUrls() {
|
|
248
|
+
this.experienceUrls ||= this.experienceTracker
|
|
249
|
+
.getAllExperience()
|
|
250
|
+
.sort((left, right) => right.mtime.getTime() - left.mtime.getTime())
|
|
251
|
+
.flatMap((experience) => experience.data.url || []);
|
|
252
|
+
const sessionUrls = this.stateHistory.map((transition) => transition.toState.url).reverse();
|
|
253
|
+
const urls = new Map();
|
|
254
|
+
for (const url of [...sessionUrls, ...this.experienceUrls]) {
|
|
255
|
+
const key = normalizeUrl(url);
|
|
256
|
+
if (urls.has(key))
|
|
257
|
+
continue;
|
|
258
|
+
urls.set(key, url);
|
|
259
|
+
}
|
|
260
|
+
return [...urls.values()];
|
|
261
|
+
}
|
|
246
262
|
/**
|
|
247
263
|
* Get how many times we've visited a specific path
|
|
248
264
|
*/
|