explorbot 0.2.5 → 0.3.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/boat/prima/README.md +96 -0
- package/boat/prima/package.json +14 -10
- package/boat/prima/src/cli.ts +5 -0
- package/boat/prima/src/prima.ts +17 -4
- package/dist/boat/prima/src/cli.js +7 -0
- package/dist/boat/prima/src/prima.js +18 -4
- package/dist/models.json +4 -4
- package/dist/package.json +6 -2
- package/dist/src/action-result.d.ts +13 -0
- package/dist/src/action-result.js +46 -15
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +48 -17
- package/dist/src/ai/captain/web-mode.js +1 -2
- package/dist/src/ai/captain.d.ts +20 -0
- package/dist/src/ai/captain.js +10 -1
- package/dist/src/ai/driller.js +6 -2
- package/dist/src/ai/fisherman-tools.d.ts +40 -1
- package/dist/src/ai/fisherman-tools.js +39 -0
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/navigator.d.ts +2 -1
- package/dist/src/ai/navigator.js +5 -9
- package/dist/src/ai/pilot.js +39 -22
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.js +16 -1
- package/dist/src/ai/researcher/cache.d.ts +8 -3
- package/dist/src/ai/researcher/cache.js +13 -8
- package/dist/src/ai/researcher/deep-analysis.js +1 -1
- package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
- package/dist/src/ai/researcher.js +4 -3
- package/dist/src/ai/rules.js +1 -5
- package/dist/src/ai/tester.d.ts +1 -1
- package/dist/src/ai/tester.js +13 -22
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +79 -56
- package/dist/src/commands/init-command.js +13 -20
- package/dist/src/config.js +3 -1
- package/dist/src/experience-tracker.d.ts +2 -0
- package/dist/src/experience-tracker.js +12 -0
- package/dist/src/explorbot.js +1 -1
- package/dist/src/playwright-recorder.js +6 -12
- package/dist/src/test-plan.d.ts +8 -0
- package/dist/src/test-plan.js +11 -0
- package/dist/src/utils/html-diff.d.ts +5 -0
- package/dist/src/utils/html-diff.js +65 -6
- package/dist/src/utils/strings.d.ts +2 -0
- package/dist/src/utils/strings.js +32 -0
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +31 -2
- package/docs/basics/getting-started.md +33 -10
- package/docs/basics/providers.md +6 -4
- package/docs/contributing/npm-package.md +73 -4
- package/models.json +4 -4
- package/package.json +6 -2
- package/src/action-result.ts +61 -16
- package/src/action.ts +51 -17
- package/src/ai/captain/web-mode.ts +1 -2
- package/src/ai/captain.ts +9 -1
- package/src/ai/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/navigator.ts +6 -10
- package/src/ai/pilot.ts +41 -24
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +17 -1
- package/src/ai/researcher/cache.ts +17 -9
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +23 -5
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +1 -5
- package/src/ai/tester.ts +13 -22
- package/src/ai/tools.ts +84 -60
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +2 -1
- package/src/experience-tracker.ts +13 -0
- package/src/explorbot.ts +1 -1
- package/src/playwright-recorder.ts +6 -11
- package/src/test-plan.ts +18 -0
- package/src/utils/html-diff.ts +72 -7
- package/src/utils/strings.ts +36 -0
- package/src/utils/url-matcher.ts +27 -2
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parentPort } from 'node:worker_threads';
|
|
4
4
|
import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
|
|
5
|
+
import { isSamePageFamily } from '../../utils/url-matcher.ts';
|
|
5
6
|
|
|
6
7
|
function diceSimilarity(a: Set<string>, b: Set<string>): number {
|
|
7
8
|
let intersection = 0;
|
|
@@ -13,8 +14,8 @@ function diceSimilarity(a: Set<string>, b: Set<string>): number {
|
|
|
13
14
|
return Math.round(((2 * intersection) / total) * 100);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
parentPort!.on('message', (data:
|
|
17
|
-
const { html, statesDir, maxAgeMs, threshold } = data;
|
|
17
|
+
parentPort!.on('message', (data: FingerprintRequest) => {
|
|
18
|
+
const { html, statesDir, maxAgeMs, threshold, url } = data;
|
|
18
19
|
|
|
19
20
|
if (!existsSync(statesDir)) {
|
|
20
21
|
parentPort!.postMessage({ matchHash: null, similarity: 0 });
|
|
@@ -32,22 +33,39 @@ parentPort!.on('message', (data: { html: string; statesDir: string; maxAgeMs: nu
|
|
|
32
33
|
|
|
33
34
|
let bestHash: string | null = null;
|
|
34
35
|
let bestSimilarity = 0;
|
|
36
|
+
let bestUrl: string | undefined;
|
|
35
37
|
|
|
36
38
|
for (const file of files) {
|
|
37
39
|
const filePath = join(statesDir, file);
|
|
38
40
|
const mtime = statSync(filePath).mtimeMs;
|
|
39
41
|
if (now - mtime > maxAgeMs) continue;
|
|
40
42
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
+
const record = readFingerprint(filePath);
|
|
44
|
+
if (url && record.url && !isSamePageFamily(url, record.url)) continue;
|
|
45
|
+
const storedFingerprint = new Set(record.entries);
|
|
43
46
|
const similarity = diceSimilarity(currentFingerprint, storedFingerprint);
|
|
44
47
|
|
|
45
48
|
if (similarity > bestSimilarity) {
|
|
46
49
|
bestSimilarity = similarity;
|
|
47
50
|
bestHash = file.replace('.fingerprint', '');
|
|
51
|
+
bestUrl = record.url;
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
const matched = bestSimilarity >= threshold;
|
|
52
|
-
parentPort!.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity });
|
|
56
|
+
parentPort!.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity, url: matched ? bestUrl : undefined });
|
|
53
57
|
});
|
|
58
|
+
|
|
59
|
+
function readFingerprint(filePath: string): FingerprintRecord {
|
|
60
|
+
const content = readFileSync(filePath, 'utf8');
|
|
61
|
+
try {
|
|
62
|
+
const record = JSON.parse(content);
|
|
63
|
+
if (Array.isArray(record.entries)) return record;
|
|
64
|
+
} catch {
|
|
65
|
+
return { entries: content.split('\n').filter(Boolean) };
|
|
66
|
+
}
|
|
67
|
+
return { entries: content.split('\n').filter(Boolean) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type FingerprintRecord = { entries: string[]; url?: string };
|
|
71
|
+
type FingerprintRequest = { html: string; statesDir: string; maxAgeMs: number; threshold: number; url?: string };
|
package/src/ai/researcher.ts
CHANGED
|
@@ -97,6 +97,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
97
97
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
98
98
|
this.actionResult = ActionResult.fromState(state);
|
|
99
99
|
const stateHash = state.hash || this.actionResult.getStateHash();
|
|
100
|
+
const researchState = { ...state, hash: stateHash };
|
|
100
101
|
|
|
101
102
|
if (!force && stateHash) {
|
|
102
103
|
const cached = getCachedResearch(stateHash);
|
|
@@ -139,10 +140,10 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
139
140
|
const combinedHtml = await this.actionResult!.combinedHtml();
|
|
140
141
|
|
|
141
142
|
if (!deep && !force) {
|
|
142
|
-
const similar = await findSimilarResearch(combinedHtml);
|
|
143
|
+
const similar = await findSimilarResearch(combinedHtml, state.url);
|
|
143
144
|
if (similar) {
|
|
144
145
|
tag('operation').log('Similar research found, reusing cached result');
|
|
145
|
-
if (stateHash) saveResearch(
|
|
146
|
+
if (stateHash) saveResearch(researchState, similar, combinedHtml);
|
|
146
147
|
tag('multiline').log(formatResearchSummary(similar));
|
|
147
148
|
tag('success').log('Research complete (reused)');
|
|
148
149
|
await this.hooksRunner.runAfterHook('researcher', state.url);
|
|
@@ -286,7 +287,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
286
287
|
|
|
287
288
|
let researchFile: string | null = null;
|
|
288
289
|
if (stateHash) {
|
|
289
|
-
researchFile = saveResearch(
|
|
290
|
+
researchFile = saveResearch(researchState, result.text, combinedHtml);
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
const summaryText = mdq(result.text).query('section2(/^summary/)').query('paragraph[0]').text().trim();
|
package/src/ai/rules.ts
CHANGED
|
@@ -4,10 +4,6 @@ export const recommendedCodeceptCommands = ['I.click', 'I.type', 'I.fillField',
|
|
|
4
4
|
|
|
5
5
|
const locatorPriorityRule = dedent`
|
|
6
6
|
<locator_priority>
|
|
7
|
-
When the page context shows the element a ref, such as [ref=e14], there is no locator to select: click it with clickRef
|
|
8
|
-
and that ref. A ref names one exact element, so it never matches the wrong one and never has to be narrowed. Everything
|
|
9
|
-
below is for elements the context gives no ref for.
|
|
10
|
-
|
|
11
7
|
Use the following priority when selecting locators:
|
|
12
8
|
|
|
13
9
|
1. ARIA locators (first choice) - target browser's accessibility tree, most reliable
|
|
@@ -80,7 +76,7 @@ const locatorStrategyRule = dedent`
|
|
|
80
76
|
|
|
81
77
|
NEVER include \`eidx\` attribute in any locator (ARIA, CSS, XPath). It is an internal annotation.
|
|
82
78
|
|
|
83
|
-
If
|
|
79
|
+
If the element is not found in the ARIA snapshot, fall back to CSS/XPath locators from page HTML.
|
|
84
80
|
|
|
85
81
|
Stick to semantic attributes like role, aria-*, id, class, name, data-id, etc.
|
|
86
82
|
Avoid IDs that follow framework auto-generation patterns (these change on every page load):
|
package/src/ai/tester.ts
CHANGED
|
@@ -11,10 +11,11 @@ import { Observability } from '../observability.ts';
|
|
|
11
11
|
import type { StateTransition } from '../state-manager.ts';
|
|
12
12
|
import { Stats } from '../stats.ts';
|
|
13
13
|
import { type Test, TestResult, type TestResultType } from '../test-plan.ts';
|
|
14
|
-
import {
|
|
14
|
+
import { detectFocusArea } from '../utils/aria.ts';
|
|
15
15
|
import { ErrorPageError, isErrorPage } from '../utils/error-page.ts';
|
|
16
16
|
import { createDebug, tag } from '../utils/logger.ts';
|
|
17
17
|
import { loop } from '../utils/loop.ts';
|
|
18
|
+
import { compactErrorMessage } from '../utils/strings.ts';
|
|
18
19
|
import type { Agent, AgentDeps } from './agent.ts';
|
|
19
20
|
import type { Captain } from './captain.ts';
|
|
20
21
|
import type { Conversation } from './conversation.ts';
|
|
@@ -41,6 +42,7 @@ const SAMPLE_FILES: Record<string, string> = {
|
|
|
41
42
|
|
|
42
43
|
export class Tester extends TaskAgent implements Agent {
|
|
43
44
|
protected readonly ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
|
|
45
|
+
protected readonly DELEGATED_ACTION_TOOLS = ['interact'];
|
|
44
46
|
protected readonly SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
|
|
45
47
|
emoji = '🧪';
|
|
46
48
|
private requestStore: RequestStore;
|
|
@@ -275,8 +277,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
275
277
|
}
|
|
276
278
|
|
|
277
279
|
conversation.cleanupTag('page_aria', '...cleaned aria snapshot...', 1);
|
|
278
|
-
conversation.cleanupTag('page_html', '...cleaned HTML snapshot...', 1);
|
|
279
|
-
conversation.cleanupTag('experience', '...cleaned experience...', 1);
|
|
280
280
|
conversation.cleanupTag('applied_experience', '...cleaned past experience...', 1);
|
|
281
281
|
conversation.cleanupTag('page_ui_map', '...cleaned UI map...', 1);
|
|
282
282
|
conversation.cleanupTag('page_ui_map_overlay', '...cleaned UI overlay...', 1);
|
|
@@ -414,8 +414,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
414
414
|
this.stalledIterations = 0;
|
|
415
415
|
tag('info').log(`Pilot extending test (${extensions}/${this.MAX_EXTENSIONS})`);
|
|
416
416
|
conversation.cleanupTag('page_aria', '...trimmed...', 1);
|
|
417
|
-
conversation.cleanupTag('page_html', '...trimmed...', 0);
|
|
418
|
-
conversation.cleanupTag('experience', '...trimmed...', 0);
|
|
419
417
|
conversation.cleanupTag('page_ui_map', '...trimmed...', 0);
|
|
420
418
|
conversation.cleanupTag('page_ui_map_overlay', '...trimmed...', 0);
|
|
421
419
|
conversation.compactToolResults(1);
|
|
@@ -455,7 +453,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
455
453
|
|
|
456
454
|
const currentState = this.getCurrentState();
|
|
457
455
|
const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
|
|
458
|
-
const actionTools = [...this.ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
|
|
456
|
+
const actionTools = [...this.ACTION_TOOLS, ...this.DELEGATED_ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
|
|
459
457
|
const hasSuccessfulAction = toolExecutions.some((execution) => execution.wasSuccessful && actionTools.includes(execution.toolName));
|
|
460
458
|
const hasSuccessfulAssertion = toolExecutions.some((execution) => execution.wasSuccessful && this.ASSERTION_TOOLS.includes(execution.toolName));
|
|
461
459
|
|
|
@@ -482,6 +480,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
482
480
|
|
|
483
481
|
<rules>
|
|
484
482
|
Use tools ${this.ACTION_TOOLS.join(', ')} to interact with the page.
|
|
483
|
+
Fall back to interact() when those fail, when the step needs a sequence of actions, or when your context is not enough to locate the element.
|
|
485
484
|
Use tool names exactly as listed in this prompt. Do not invent combined tool names, aliases, or names with channel markers such as "commentary".
|
|
486
485
|
Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
|
|
487
486
|
Do not do unsuccesful clicks again.
|
|
@@ -602,13 +601,13 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
602
601
|
</page>
|
|
603
602
|
|
|
604
603
|
<page_aria>
|
|
605
|
-
${
|
|
604
|
+
${currentState.getInteractiveARIA()}
|
|
606
605
|
</page_aria>
|
|
607
606
|
${uiMapSection}
|
|
608
607
|
|
|
609
608
|
Use <page_ui_map> to understand the page structure and its main elements.
|
|
610
|
-
However, <page_ui_map> is not always up to date, use <page_aria>
|
|
611
|
-
Do not interact with elements that are not listed in <page_aria>
|
|
609
|
+
However, <page_ui_map> is not always up to date, use <page_aria> to understand the ACTUAL state of the page
|
|
610
|
+
Do not interact with elements that are not listed in <page_aria> or in HTML returned by tools
|
|
612
611
|
Refer to information on page sections in <page_ui_map> and use container CSS locators to interact with elements inside sections
|
|
613
612
|
`;
|
|
614
613
|
return context;
|
|
@@ -639,17 +638,11 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
639
638
|
</page>
|
|
640
639
|
|
|
641
640
|
<page_aria>
|
|
642
|
-
${
|
|
641
|
+
${currentState.getInteractiveARIA()}
|
|
643
642
|
</page_aria>
|
|
644
643
|
`;
|
|
645
644
|
}
|
|
646
645
|
|
|
647
|
-
private async interactiveAriaWithRefs(state: ActionResult): Promise<string> {
|
|
648
|
-
const withRefs = await Promise.resolve(this.explorer?.withPage?.((page: any) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
|
|
649
|
-
if (!withRefs) return state.getInteractiveARIA();
|
|
650
|
-
return compactAriaSnapshot(withRefs, false);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
646
|
private finishTest(task: Test): void {
|
|
654
647
|
if (!task.result) {
|
|
655
648
|
if (task.hasAchievedAll()) task.finish(TestResult.PASSED);
|
|
@@ -719,7 +712,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
719
712
|
|
|
720
713
|
<rules>
|
|
721
714
|
- Refer to UI Map from <page_ui_map> to understand the page structure and its main elements
|
|
722
|
-
- Use only elements that exist in
|
|
715
|
+
- Use only elements that exist in <page_aria> or in HTML returned by tools
|
|
723
716
|
- Use tool input schemas exactly as documented. Do not invent parameter names or add fields not listed by the tool schema.
|
|
724
717
|
- Use click() for buttons, links, and clickable elements ONLY - do NOT include I.fillField() or I.type() commands in click() tool
|
|
725
718
|
- click() commands array is for FALLBACK LOCATORS of the SAME element, NOT for clicking different elements in sequence. If you need to click two different elements, make two separate click() calls.
|
|
@@ -742,7 +735,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
742
735
|
- Check for error messages to understand if there are issues
|
|
743
736
|
- Verify if data was correctly saved and changes are reflected on the page
|
|
744
737
|
- By default, you receive accessibility tree data which shows interactive elements and page structure
|
|
745
|
-
-
|
|
738
|
+
- Full page HTML is never injected automatically. When ARIA is not enough, delegate to the tools that read it: verify() to assert, interact() to act
|
|
739
|
+
- Understand current context by following <page_aria> and <page_ui_map>
|
|
746
740
|
- Before submitting form, check all inputs were filled in correctly using see() tool
|
|
747
741
|
- When you interact with form with inputs, ensure that you click corresponding button to save its data
|
|
748
742
|
- Follow <locator_priority> rules when selecting locators for all tools
|
|
@@ -794,7 +788,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
794
788
|
|
|
795
789
|
private buildScenarioBlock(task: Test, actionResult: ActionResult): string {
|
|
796
790
|
const knowledge = this.getKnowledge(actionResult);
|
|
797
|
-
const experience = this.getExperience(actionResult);
|
|
798
791
|
|
|
799
792
|
return dedent`
|
|
800
793
|
<task>
|
|
@@ -824,8 +817,6 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
824
817
|
${this.buildAvailableFiles()}
|
|
825
818
|
|
|
826
819
|
${knowledge}
|
|
827
|
-
|
|
828
|
-
${experience}
|
|
829
820
|
`;
|
|
830
821
|
}
|
|
831
822
|
|
|
@@ -947,7 +938,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
947
938
|
};
|
|
948
939
|
|
|
949
940
|
if (resetAction.lastError) {
|
|
950
|
-
result.error = resetAction.lastError
|
|
941
|
+
result.error = compactErrorMessage(resetAction.lastError);
|
|
951
942
|
}
|
|
952
943
|
|
|
953
944
|
return result;
|
package/src/ai/tools.ts
CHANGED
|
@@ -2,12 +2,13 @@ import { tool } from 'ai';
|
|
|
2
2
|
import dedent from 'dedent';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ActionResult, type PageDiff, type ToolResultMetadata } from '../action-result.ts';
|
|
5
|
-
import type
|
|
5
|
+
import { type ExperienceTracker, renderExperienceRecipes } from '../experience-tracker.ts';
|
|
6
6
|
import { Stats } from '../stats.ts';
|
|
7
7
|
import { type Task, TestResult } from '../test-plan.js';
|
|
8
8
|
import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
|
|
9
9
|
import { isFatalBrowserError } from '../utils/browser-errors.ts';
|
|
10
10
|
import { createDebug, tag } from '../utils/logger.js';
|
|
11
|
+
import { compactErrorMessage } from '../utils/strings.ts';
|
|
11
12
|
import { pause } from '../utils/loop.js';
|
|
12
13
|
import { WebElement } from '../utils/web-element.ts';
|
|
13
14
|
import type { ToolDeps } from './agent.ts';
|
|
@@ -34,10 +35,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
34
35
|
description: dedent`
|
|
35
36
|
Click an element by trying multiple CodeceptJS commands in order until one succeeds.
|
|
36
37
|
|
|
37
|
-
Use this only for elements the page context gives you no ref for. When the element shows a ref such as [ref=e14],
|
|
38
|
-
call clickRef with that ref instead — composing a locator for an element that already has a ref is wasted work,
|
|
39
|
-
and a locator can match several elements where a ref cannot.
|
|
40
|
-
|
|
41
38
|
Follow <locator_priority> from system prompt for locator selection.
|
|
42
39
|
|
|
43
40
|
I.click(locator) - click element matching locator
|
|
@@ -98,7 +95,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
98
95
|
const success = await action.attempt(command, explanation);
|
|
99
96
|
|
|
100
97
|
const attempt: { command: string; success: boolean; error?: string } = { command, success };
|
|
101
|
-
if (action.lastError) attempt.error = action.lastError
|
|
98
|
+
if (action.lastError) attempt.error = errorText(action.lastError);
|
|
102
99
|
attempts.push(attempt);
|
|
103
100
|
|
|
104
101
|
if (success) {
|
|
@@ -124,7 +121,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
124
121
|
|
|
125
122
|
for (const retryCmd of retryCommands) {
|
|
126
123
|
if (!(await action.attempt(retryCmd, explanation))) {
|
|
127
|
-
attempts.push({ command: retryCmd, success: false, error: action.lastError
|
|
124
|
+
attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
|
|
128
125
|
continue;
|
|
129
126
|
}
|
|
130
127
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, retryCmd);
|
|
@@ -151,41 +148,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
151
148
|
},
|
|
152
149
|
}),
|
|
153
150
|
|
|
154
|
-
clickRef: tool({
|
|
155
|
-
description: dedent`
|
|
156
|
-
Click an element by the ref the page context gave it, e.g. [ref=e14].
|
|
157
|
-
|
|
158
|
-
Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
|
|
159
|
-
cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
|
|
160
|
-
Only pass a ref that appears in the page context you were given. Never invent or guess one.
|
|
161
|
-
If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
|
|
162
|
-
`,
|
|
163
|
-
inputSchema: z.object({
|
|
164
|
-
ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
|
|
165
|
-
element: z.string().describe('Role and name of the element you are clicking, for the record'),
|
|
166
|
-
}),
|
|
167
|
-
execute: async ({ ref, element }) => {
|
|
168
|
-
const activeNote = task.startNote(`Click ${element}`);
|
|
169
|
-
const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
170
|
-
const action = explorer.action();
|
|
171
|
-
const named = await describeRef(explorer, ref);
|
|
172
|
-
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
|
|
173
|
-
|
|
174
|
-
if (!(await action.attempt(run, `Click ${element}`))) {
|
|
175
|
-
activeNote.commit(TestResult.FAILED);
|
|
176
|
-
return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
|
|
177
|
-
suggestion: 'The ref may belong to an older version of the page. Get fresh context and use the ref it gives, or fall back to click() with a locator.',
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// a ref belongs to this session only, so the run is reported as the locator a later test can replay
|
|
182
|
-
const code = named ? `I.click(${JSON.stringify(named)})` : run;
|
|
183
|
-
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, code);
|
|
184
|
-
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
185
|
-
return successToolResult('clickRef', { ...toolResult, code }, action);
|
|
186
|
-
},
|
|
187
|
-
}),
|
|
188
|
-
|
|
189
151
|
hover: tool({
|
|
190
152
|
description: dedent`
|
|
191
153
|
Move the mouse cursor to an element to reveal hover-only controls.
|
|
@@ -237,7 +199,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
237
199
|
for (const command of commands) {
|
|
238
200
|
const success = await action.attempt(command, explanation);
|
|
239
201
|
const attempt: { command: string; success: boolean; error?: string } = { command, success };
|
|
240
|
-
if (action.lastError) attempt.error = action.lastError
|
|
202
|
+
if (action.lastError) attempt.error = errorText(action.lastError);
|
|
241
203
|
attempts.push(attempt);
|
|
242
204
|
|
|
243
205
|
if (!success) continue;
|
|
@@ -320,7 +282,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
320
282
|
);
|
|
321
283
|
}
|
|
322
284
|
|
|
323
|
-
const errorMsg = `pressKey fallback to type() failed: ${action.lastError
|
|
285
|
+
const errorMsg = `pressKey fallback to type() failed: ${errorText(action.lastError)}`;
|
|
324
286
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
325
287
|
return failedToolResult('pressKey', errorMsg, {
|
|
326
288
|
...toolResult,
|
|
@@ -366,7 +328,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
366
328
|
);
|
|
367
329
|
}
|
|
368
330
|
|
|
369
|
-
const errorMsg = `pressKey() failed: ${action.lastError
|
|
331
|
+
const errorMsg = `pressKey() failed: ${errorText(action.lastError)}`;
|
|
370
332
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
371
333
|
return failedToolResult('pressKey', errorMsg, {
|
|
372
334
|
...toolResult,
|
|
@@ -387,8 +349,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
387
349
|
Execute raw CodeceptJS code block with multiple commands.
|
|
388
350
|
USE THIS TOOL for typing text into fields: I.fillField, I.type
|
|
389
351
|
|
|
390
|
-
Do not put a click on a ref-bearing element in here — clickRef with its ref is cheaper and cannot mis-target.
|
|
391
|
-
|
|
392
352
|
Follow <actions> from system prompt for available commands.
|
|
393
353
|
Follow <locator_priority> from system prompt for locator selection.
|
|
394
354
|
|
|
@@ -448,7 +408,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
448
408
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, formLocator);
|
|
449
409
|
|
|
450
410
|
if (action.lastError) {
|
|
451
|
-
const message =
|
|
411
|
+
const message = errorText(action.lastError);
|
|
452
412
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
453
413
|
|
|
454
414
|
let formSuggestion = 'Look into error message and identify which commands passed and which failed. Continue execution using step-by-step approach using click() and form() tools.';
|
|
@@ -502,6 +462,45 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
502
462
|
};
|
|
503
463
|
}
|
|
504
464
|
|
|
465
|
+
export function createRefTools({ explorer, stateManager }: ToolDeps, task: Task) {
|
|
466
|
+
return {
|
|
467
|
+
clickRef: tool({
|
|
468
|
+
description: dedent`
|
|
469
|
+
Click an element by the ref the page context gave it, e.g. [ref=e14].
|
|
470
|
+
|
|
471
|
+
Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
|
|
472
|
+
cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
|
|
473
|
+
Only pass a ref that appears in the page context you were given. Never invent or guess one.
|
|
474
|
+
If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
|
|
475
|
+
`,
|
|
476
|
+
inputSchema: z.object({
|
|
477
|
+
ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
|
|
478
|
+
element: z.string().describe('Role and name of the element you are clicking, for the record'),
|
|
479
|
+
}),
|
|
480
|
+
execute: async ({ ref, element }) => {
|
|
481
|
+
const activeNote = task.startNote(`Click ${element}`);
|
|
482
|
+
const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
483
|
+
const action = explorer.action();
|
|
484
|
+
const named = await describeRef(explorer, ref);
|
|
485
|
+
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
|
|
486
|
+
|
|
487
|
+
if (!(await action.attempt(run, `Click ${element}`))) {
|
|
488
|
+
activeNote.commit(TestResult.FAILED);
|
|
489
|
+
return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
|
|
490
|
+
suggestion: 'The ref may belong to an older version of the page. Get fresh context and use the ref it gives, or fall back to click() with a locator.',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// a ref belongs to this session only, so the run is reported as the locator a later test can replay
|
|
495
|
+
const code = named ? `I.click(${JSON.stringify(named)})` : run;
|
|
496
|
+
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, code);
|
|
497
|
+
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
498
|
+
return successToolResult('clickRef', { ...toolResult, code }, action);
|
|
499
|
+
},
|
|
500
|
+
}),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
505
504
|
export function createIframeTools({ explorer, stateManager }: ToolDeps) {
|
|
506
505
|
return {
|
|
507
506
|
exitIframe: tool({
|
|
@@ -627,7 +626,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
627
626
|
|
|
628
627
|
DO NOT call this if:
|
|
629
628
|
- You just performed an action (pageDiff already provided in response)
|
|
630
|
-
- You already have recent <
|
|
629
|
+
- You already have a recent <page_aria> snapshot in context
|
|
631
630
|
- You're about to perform an action (you'll get pageDiff after)
|
|
632
631
|
|
|
633
632
|
Call ONLY when:
|
|
@@ -782,12 +781,18 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
782
781
|
|
|
783
782
|
interact: tool({
|
|
784
783
|
description: dedent`
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
784
|
+
Delegate one step to the Navigator, which reads the full page HTML and tries multiple CodeceptJS strategies.
|
|
785
|
+
Slower than the direct action tools — use it as a fallback, not as the default.
|
|
786
|
+
|
|
787
|
+
Use when:
|
|
788
|
+
- direct action tools failed and you have no better locator to try
|
|
789
|
+
- the step needs a sequence of actions to complete
|
|
790
|
+
- the element is not in the context you have
|
|
791
|
+
|
|
792
|
+
Describe the outcome to reach, not the locator to use.
|
|
788
793
|
`,
|
|
789
794
|
inputSchema: z.object({
|
|
790
|
-
instruction: z.string().describe('
|
|
795
|
+
instruction: z.string().describe('The step to perform on the page, described by its intent'),
|
|
791
796
|
}),
|
|
792
797
|
execute: async ({ instruction }) => {
|
|
793
798
|
try {
|
|
@@ -799,7 +804,8 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
799
804
|
|
|
800
805
|
const previousState = ActionResult.fromState(currentState);
|
|
801
806
|
const actionResult = ActionResult.fromState(currentState);
|
|
802
|
-
const
|
|
807
|
+
const experience = renderExperienceRecipes(explorer.activeTest?.getAppliedExperience(actionResult) ?? []);
|
|
808
|
+
const success = await navigator.resolveState(instruction, actionResult, { experience });
|
|
803
809
|
|
|
804
810
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, instruction);
|
|
805
811
|
|
|
@@ -952,7 +958,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
952
958
|
}
|
|
953
959
|
|
|
954
960
|
const failData: Record<string, any> = { suggestion: 'Try reset() to return to the starting page.' };
|
|
955
|
-
if (action.lastError) failData.error = action.lastError
|
|
961
|
+
if (action.lastError) failData.error = errorText(action.lastError);
|
|
956
962
|
return failedToolResult('back', `Failed to navigate back to ${targetUrl}`, failData);
|
|
957
963
|
},
|
|
958
964
|
}),
|
|
@@ -1015,7 +1021,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
1015
1021
|
|
|
1016
1022
|
if (result.totalFound === 0) {
|
|
1017
1023
|
return failedToolResult('xpathCheck', `No elements matched XPath: ${xpath}`, {
|
|
1018
|
-
suggestion: '
|
|
1024
|
+
suggestion: 'Do not guess another expression. Narrow down from what you know about the target: its role, its visible text, its nearest labelled ancestor. Add one constraint at a time.',
|
|
1019
1025
|
});
|
|
1020
1026
|
}
|
|
1021
1027
|
|
|
@@ -1112,7 +1118,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
1112
1118
|
return tools;
|
|
1113
1119
|
}
|
|
1114
1120
|
|
|
1115
|
-
const PAGE_DIFF_SUGGESTION =
|
|
1121
|
+
const PAGE_DIFF_SUGGESTION =
|
|
1122
|
+
'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff. messages holds text the app showed in response, requests the calls it made and consoleErrors what it logged.';
|
|
1123
|
+
|
|
1124
|
+
const FAILED_REQUEST_SUGGESTION = 'The server rejected a request made by this action (see requests). The UI accepted the interaction but the operation did not complete — read messages and consoleErrors for the reason and report it instead of repeating the action.';
|
|
1125
|
+
|
|
1126
|
+
const NAVIGATED_SUGGESTION = 'The action left the page. Elements are never compared across pages, so this diff carries the move itself and what the app announced in transit — an empty element diff does not mean nothing happened.';
|
|
1116
1127
|
|
|
1117
1128
|
const ARIA_OUTPUT_CAP = 4000;
|
|
1118
1129
|
const HTML_OUTPUT_CAP = 6000;
|
|
@@ -1154,7 +1165,7 @@ function transformContainsCommand(command: string): string {
|
|
|
1154
1165
|
}
|
|
1155
1166
|
|
|
1156
1167
|
function errorText(error: unknown): string {
|
|
1157
|
-
if (error instanceof Error) return error
|
|
1168
|
+
if (error instanceof Error) return compactErrorMessage(error);
|
|
1158
1169
|
return 'Unknown error occurred';
|
|
1159
1170
|
}
|
|
1160
1171
|
|
|
@@ -1197,7 +1208,11 @@ export function successToolResult(action: string, data?: Record<string, any>, so
|
|
|
1197
1208
|
const ariaChanges = data.pageDiff.ariaChanges || '';
|
|
1198
1209
|
const urlChanged = data.pageDiff.urlChanged === true;
|
|
1199
1210
|
const hasHtmlParts = Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
|
|
1200
|
-
if (
|
|
1211
|
+
if (hasFailedRequest(data.pageDiff)) {
|
|
1212
|
+
suggestion = `${FAILED_REQUEST_SUGGESTION} ${suggestion}`;
|
|
1213
|
+
} else if (urlChanged) {
|
|
1214
|
+
suggestion = `${NAVIGATED_SUGGESTION} ${suggestion}`;
|
|
1215
|
+
} else if (isMajorPageChange(data.pageDiff)) {
|
|
1201
1216
|
suggestion = `MAJOR PAGE CHANGE. Page entered a different mode. Check htmlParts and iframes in pageDiff before next action. ${suggestion}`;
|
|
1202
1217
|
} else if (!urlChanged && !ariaChanges && !hasHtmlParts) {
|
|
1203
1218
|
suggestion = 'Action ran without error but produced no observable change (URL, ARIA and HTML all unchanged). The locator likely matched a non-interactive ancestor or an element outside the intended control. Re-locate via xpathCheck() or verify with see() before treating this as success.';
|
|
@@ -1213,10 +1228,15 @@ export function isMajorPageChange(pageDiff: PageDiff): boolean {
|
|
|
1213
1228
|
return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
|
|
1214
1229
|
}
|
|
1215
1230
|
|
|
1231
|
+
export function hasFailedRequest(pageDiff: PageDiff): boolean {
|
|
1232
|
+
return (pageDiff.requests ?? []).some((request) => request.status >= 400);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1216
1235
|
function hasObservablePageChange(data?: Record<string, any>): boolean {
|
|
1217
1236
|
if (!data?.pageDiff) return false;
|
|
1218
1237
|
if (data.pageDiff.urlChanged === true) return true;
|
|
1219
1238
|
if (data.pageDiff.ariaChanges) return true;
|
|
1239
|
+
if (data.pageDiff.messages?.length) return true;
|
|
1220
1240
|
return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
|
|
1221
1241
|
}
|
|
1222
1242
|
|
|
@@ -1278,6 +1298,10 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
|
|
|
1278
1298
|
return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
|
|
1279
1299
|
}
|
|
1280
1300
|
|
|
1301
|
+
if (errors.some((e) => e.includes('SyntaxError'))) {
|
|
1302
|
+
return 'The command string never parsed as JavaScript — quotes or brackets do not match. No element was looked up, so this tells you nothing about the page. Re-emit the same intent as valid CodeceptJS.';
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1281
1305
|
const notFound = errors.filter((e) => e.includes('was not found'));
|
|
1282
1306
|
|
|
1283
1307
|
if (notFound.length && notFound.every((e) => e.includes('was not found inside element'))) {
|
|
@@ -1372,7 +1396,7 @@ function getNotFoundSuggestion(errorMessage: string): string | null {
|
|
|
1372
1396
|
Element was not found. The locator does not exist on this page.
|
|
1373
1397
|
1. Use see() to visually analyze what elements are actually on the page
|
|
1374
1398
|
2. Use context() to get fresh HTML and ARIA snapshot
|
|
1375
|
-
3. Use ONLY locators from <page_aria> or
|
|
1399
|
+
3. Use ONLY locators from <page_aria> or from HTML returned by context()
|
|
1376
1400
|
4. Prefer ARIA locators: { "role": "button", "text": "visible text" }
|
|
1377
1401
|
`;
|
|
1378
1402
|
}
|
|
@@ -8,15 +8,10 @@ import { getCliName } from '../utils/cli-name.ts';
|
|
|
8
8
|
import { log, tag } from '../utils/logger.js';
|
|
9
9
|
import { relativeToCwd } from '../utils/next-steps.ts';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
// Bring your own provider or use OpenRouter (one API key, many providers).
|
|
16
|
-
// https://github.com/testomatio/explorbot/blob/main/docs/providers.md
|
|
17
|
-
const openrouter = createOpenRouter({
|
|
18
|
-
apiKey: process.env.OPENROUTER_API_KEY,
|
|
19
|
-
});
|
|
11
|
+
function defaultConfigTemplate(): string {
|
|
12
|
+
return `// 'provider/model-id' uses a bundled provider.
|
|
13
|
+
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
14
|
+
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
20
15
|
|
|
21
16
|
const config = {
|
|
22
17
|
web: {
|
|
@@ -25,12 +20,7 @@ const config = {
|
|
|
25
20
|
},
|
|
26
21
|
|
|
27
22
|
ai: {
|
|
28
|
-
|
|
29
|
-
model: openrouter('openai/gpt-oss-20b:nitro'),
|
|
30
|
-
// vision model for screenshot analysis
|
|
31
|
-
visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
|
|
32
|
-
// agentic model for decision making
|
|
33
|
-
agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
|
|
23
|
+
${modelLines('openrouter')}
|
|
34
24
|
},
|
|
35
25
|
|
|
36
26
|
reporter: {
|
|
@@ -45,6 +35,7 @@ const config = {
|
|
|
45
35
|
|
|
46
36
|
export default config;
|
|
47
37
|
`;
|
|
38
|
+
}
|
|
48
39
|
|
|
49
40
|
const DEFAULT_ENV_TEMPLATE = dedent`
|
|
50
41
|
# AI provider API keys
|
|
@@ -141,7 +132,7 @@ export function runInitCommand(options: InitCommandOptions): void {
|
|
|
141
132
|
process.exit(1);
|
|
142
133
|
}
|
|
143
134
|
|
|
144
|
-
writeFileSync(outPath,
|
|
135
|
+
writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
|
|
145
136
|
log(`Created config file: ${relativeToCwd(outPath)}`);
|
|
146
137
|
|
|
147
138
|
const envPath = resolve(process.cwd(), '.env');
|
|
@@ -222,8 +213,7 @@ async function renderInitWizard(mode: 'choose' | 'global'): Promise<'local' | 'g
|
|
|
222
213
|
});
|
|
223
214
|
}
|
|
224
215
|
|
|
225
|
-
function
|
|
226
|
-
const { envKey } = PROVIDERS[provider];
|
|
216
|
+
function modelLines(provider: string): string {
|
|
227
217
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
228
218
|
const roles: Array<[ModelRoleName, string]> = [
|
|
229
219
|
['model', 'fast model with tool calling capabilities'],
|
|
@@ -231,7 +221,11 @@ function globalConfigTemplate(provider: string): string {
|
|
|
231
221
|
['agenticModel', 'agentic model for decision making'],
|
|
232
222
|
];
|
|
233
223
|
|
|
234
|
-
|
|
224
|
+
return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function globalConfigTemplate(provider: string): string {
|
|
228
|
+
const { envKey } = PROVIDERS[provider];
|
|
235
229
|
|
|
236
230
|
return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
|
|
237
231
|
// Models are written as 'provider/model-id' so they resolve without a local node_modules.
|
|
@@ -240,7 +234,7 @@ function globalConfigTemplate(provider: string): string {
|
|
|
240
234
|
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
241
235
|
const config = {
|
|
242
236
|
ai: {
|
|
243
|
-
${
|
|
237
|
+
${modelLines(provider)}
|
|
244
238
|
},
|
|
245
239
|
|
|
246
240
|
reporter: {
|
package/src/config.ts
CHANGED
|
@@ -433,7 +433,7 @@ export class ConfigParser {
|
|
|
433
433
|
public getOutputDir(): string {
|
|
434
434
|
const config = this.getConfig();
|
|
435
435
|
if (!this.configPath) throw new Error('Config path not found');
|
|
436
|
-
return
|
|
436
|
+
return this.resolveProjectDir(config.dirs?.output || 'output');
|
|
437
437
|
}
|
|
438
438
|
|
|
439
439
|
public getProjectRoot(): string {
|
|
@@ -444,6 +444,7 @@ export class ConfigParser {
|
|
|
444
444
|
}
|
|
445
445
|
|
|
446
446
|
public resolveProjectDir(relativeDir: string): string {
|
|
447
|
+
if (path.isAbsolute(relativeDir)) return relativeDir;
|
|
447
448
|
if (!this.configPath) return relativeDir;
|
|
448
449
|
return path.join(this.getProjectRoot(), relativeDir);
|
|
449
450
|
}
|