explorbot 0.5.10 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +494 -482
- package/boat/api-tester/src/ai/curler.ts +1 -1
- package/boat/api-tester/src/cli.ts +4 -4
- package/boat/doc-collector/src/screenshots.ts +1 -1
- package/dist/bin/explorbot-cli.js +455 -444
- package/dist/boat/api-tester/src/ai/curler.js +1 -1
- package/dist/boat/api-tester/src/cli.js +4 -4
- package/dist/models.json +1 -1
- package/dist/package.json +2 -1
- package/dist/rules/navigator/output.md +2 -8
- package/dist/rules/navigator/verification-actions.md +6 -2
- package/dist/rules/planner/ignored-pages.md +1 -0
- package/dist/src/action-result.js +1 -1
- package/dist/src/action.d.ts +0 -1
- package/dist/src/action.js +0 -4
- package/dist/src/ai/captain/idle-mode.js +42 -26
- package/dist/src/ai/captain/mixin.d.ts +2 -0
- package/dist/src/ai/captain/web-mode.js +11 -0
- package/dist/src/ai/captain.d.ts +6 -4
- package/dist/src/ai/captain.js +25 -1
- package/dist/src/ai/driller.js +1 -1
- package/dist/src/ai/experience-compactor.js +2 -2
- package/dist/src/ai/judge.d.ts +7 -0
- package/dist/src/ai/judge.js +8 -1
- package/dist/src/ai/navigator.d.ts +1 -0
- package/dist/src/ai/navigator.js +11 -7
- package/dist/src/ai/planner/subpages.js +1 -1
- package/dist/src/ai/planner.d.ts +12 -5
- package/dist/src/ai/planner.js +138 -86
- package/dist/src/ai/provider.d.ts +1 -1
- package/dist/src/ai/provider.js +16 -5
- package/dist/src/ai/rerunner.js +1 -1
- package/dist/src/ai/researcher/cache.js +14 -6
- package/dist/src/ai/researcher/deep-analysis.d.ts +2 -1
- package/dist/src/ai/researcher/deep-analysis.js +7 -2
- package/dist/src/ai/researcher/fingerprint-worker.js +4 -4
- package/dist/src/ai/researcher.js +44 -24
- package/dist/src/ai/rules.js +6 -7
- package/dist/src/ai/scout.d.ts +12 -2
- package/dist/src/ai/scout.js +103 -8
- package/dist/src/ai/tester.d.ts +9 -5
- package/dist/src/ai/tester.js +22 -42
- package/dist/src/ai/tools.js +10 -8
- package/dist/src/application-spec.d.ts +2 -0
- package/dist/src/application-spec.js +17 -12
- package/dist/src/commands/context-experience-command.js +0 -1
- package/dist/src/commands/context-knowledge-command.js +0 -1
- package/dist/src/commands/explore-command.d.ts +3 -2
- package/dist/src/commands/explore-command.js +35 -23
- package/dist/src/commands/help-json-command.js +3 -1
- package/dist/src/commands/init-command.js +4 -4
- package/dist/src/commands/learn-command.js +0 -2
- package/dist/src/commands/plan-command.d.ts +3 -0
- package/dist/src/commands/plan-command.js +43 -17
- package/dist/src/commands/plan-save-command.js +6 -17
- package/dist/src/commands/plans-command.js +1 -1
- package/dist/src/commands/test-command.js +12 -2
- package/dist/src/components/App.d.ts +1 -2
- package/dist/src/components/App.js +1 -3
- package/dist/src/components/InputPane.js +0 -5
- package/dist/src/config.d.ts +3 -2
- package/dist/src/config.js +2 -12
- package/dist/src/explorbot.d.ts +3 -2
- package/dist/src/explorbot.js +32 -10
- package/dist/src/explorer.js +6 -7
- package/dist/src/knowledge-tracker.d.ts +2 -2
- package/dist/src/knowledge-tracker.js +18 -6
- package/dist/src/playwright-recorder.d.ts +0 -1
- package/dist/src/playwright-recorder.js +0 -2
- package/dist/src/reporter.d.ts +1 -1
- package/dist/src/reporter.js +3 -3
- package/dist/src/test-plan.js +1 -1
- package/dist/src/utils/error-page.d.ts +17 -0
- package/dist/src/utils/error-page.js +53 -2
- package/dist/src/utils/html-diff.d.ts +1 -2
- package/dist/src/utils/html-diff.js +4 -44
- package/dist/src/utils/logger.js +27 -10
- package/dist/src/utils/markdown-terminal.js +1 -2
- package/dist/src/utils/next-steps.js +20 -16
- package/dist/src/utils/page-readiness.d.ts +2 -1
- package/dist/src/utils/page-readiness.js +24 -5
- package/dist/src/utils/test-plan-markdown.d.ts +3 -0
- package/dist/src/utils/test-plan-markdown.js +35 -4
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +5 -0
- package/docs/basics/providers.md +1 -1
- package/docs/contributing/regression-tests.md +19 -4
- package/docs/reference/commands.md +6 -2
- package/docs/reference/configuration.md +1 -1
- package/docs/web-testing/agents.md +8 -0
- package/docs/web-testing/customization.md +2 -0
- package/docs/web-testing/researcher.md +1 -1
- package/models.json +1 -1
- package/package.json +2 -1
- package/rules/navigator/output.md +2 -8
- package/rules/navigator/verification-actions.md +6 -2
- package/rules/planner/ignored-pages.md +1 -0
- package/src/action-result.ts +1 -2
- package/src/action.ts +0 -5
- package/src/ai/captain/idle-mode.ts +38 -26
- package/src/ai/captain/mixin.ts +2 -1
- package/src/ai/captain/web-mode.ts +12 -0
- package/src/ai/captain.ts +25 -7
- package/src/ai/driller.ts +1 -2
- package/src/ai/experience-compactor.ts +2 -2
- package/src/ai/judge.ts +10 -1
- package/src/ai/navigator.ts +12 -7
- package/src/ai/planner/subpages.ts +1 -2
- package/src/ai/planner.ts +140 -92
- package/src/ai/provider.ts +15 -6
- package/src/ai/rerunner.ts +1 -2
- package/src/ai/researcher/cache.ts +16 -7
- package/src/ai/researcher/deep-analysis.ts +7 -2
- package/src/ai/researcher/fingerprint-worker.ts +5 -5
- package/src/ai/researcher.ts +44 -28
- package/src/ai/rules.ts +6 -7
- package/src/ai/scout.ts +98 -10
- package/src/ai/tester.ts +29 -47
- package/src/ai/tools.ts +11 -9
- package/src/application-spec.ts +19 -13
- package/src/commands/clean-command.ts +1 -1
- package/src/commands/context-command.ts +0 -1
- package/src/commands/context-experience-command.ts +0 -1
- package/src/commands/context-knowledge-command.ts +0 -1
- package/src/commands/explore-command.ts +36 -23
- package/src/commands/help-json-command.ts +8 -6
- package/src/commands/init-command.ts +5 -3
- package/src/commands/learn-command.ts +0 -2
- package/src/commands/plan-command.ts +44 -17
- package/src/commands/plan-save-command.ts +6 -17
- package/src/commands/plans-command.ts +1 -1
- package/src/commands/test-command.ts +11 -2
- package/src/components/App.tsx +2 -6
- package/src/components/InputPane.tsx +0 -5
- package/src/config.ts +4 -16
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +31 -11
- package/src/explorer.ts +6 -7
- package/src/knowledge-tracker.ts +18 -7
- package/src/playwright-recorder.ts +0 -2
- package/src/reporter.ts +3 -4
- package/src/test-plan.ts +1 -2
- package/src/utils/error-page.ts +64 -2
- package/src/utils/html-diff.ts +4 -56
- package/src/utils/html.ts +0 -1
- package/src/utils/logger.ts +30 -12
- package/src/utils/markdown-terminal.ts +1 -2
- package/src/utils/next-steps.ts +19 -17
- package/src/utils/page-readiness.ts +33 -7
- package/src/utils/test-plan-markdown.ts +36 -4
- package/src/utils/url-matcher.ts +6 -0
|
@@ -10,8 +10,10 @@ import { ErrorPageError, detectPageCondition } from "../utils/error-page.js";
|
|
|
10
10
|
import { isBodyEmpty } from "../utils/html.js";
|
|
11
11
|
import { createDebug, tag } from '../utils/logger.js';
|
|
12
12
|
import { mdq } from "../utils/markdown-query.js";
|
|
13
|
+
import { waitForDomChange, waitForPageReadiness } from "../utils/page-readiness.js";
|
|
13
14
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
14
15
|
import { annotatePageElements } from "../utils/web-annotate.js";
|
|
16
|
+
import { UNDECIDED, pageEvidence } from "./judge.js";
|
|
15
17
|
import { ContextLengthError } from './provider.js';
|
|
16
18
|
import { findSimilarResearch, getCachedResearch, getPreviousResearch, reportResearch, saveResearch } from "./researcher/cache.js";
|
|
17
19
|
import { WithCoordinates } from "./researcher/coordinates.js";
|
|
@@ -25,6 +27,7 @@ import { WithSections } from "./researcher/sections.js";
|
|
|
25
27
|
import { locatorRule as generalLocatorRuleText } from './rules.js';
|
|
26
28
|
import { TaskAgent } from "./task-agent.js";
|
|
27
29
|
const debugLog = createDebug('explorbot:researcher');
|
|
30
|
+
const HIDDEN_CONTENT_STATEMENT = 'The page has collapsed or hidden content behind controls that open or expand it.';
|
|
28
31
|
export const POSSIBLE_SECTIONS = {
|
|
29
32
|
overlay: 'dialog, modal, drawer, popup, or active form overlay',
|
|
30
33
|
list: 'list area (items collection, table, cards, or list view)',
|
|
@@ -106,7 +109,9 @@ export class Researcher extends ResearcherBase {
|
|
|
106
109
|
if (light)
|
|
107
110
|
tag('info').log('Light research: single pass, locator verification skipped');
|
|
108
111
|
setActivity(`${this.emoji} Researching...`, 'action');
|
|
109
|
-
|
|
112
|
+
const retrying = opts._retriesLeft !== undefined;
|
|
113
|
+
if (!retrying)
|
|
114
|
+
await this.ensureNavigated(displayUrl, screenshot && this.provider.hasVision());
|
|
110
115
|
await this.hooksRunner.runBeforeHook('researcher', state.url);
|
|
111
116
|
const { elements: annotatedElements } = await this.explorer.withPage(annotatePageElements);
|
|
112
117
|
debugLog(`Annotated ${annotatedElements.length} interactive elements with eidx`);
|
|
@@ -116,7 +121,7 @@ export class Researcher extends ResearcherBase {
|
|
|
116
121
|
tag('warning').log(`Detected error page at ${state.url}`);
|
|
117
122
|
throw new ErrorPageError(state.url, this.actionResult.title, this.actionResult.httpStatus);
|
|
118
123
|
}
|
|
119
|
-
if (condition === 'loading') {
|
|
124
|
+
if (condition === 'loading' && !retrying) {
|
|
120
125
|
const settled = await this.waitUntilSettled(screenshot);
|
|
121
126
|
if (!settled) {
|
|
122
127
|
tag('warning').log(`Page at ${state.url} did not finish loading within timeout, continuing with best-effort research`);
|
|
@@ -243,7 +248,7 @@ export class Researcher extends ResearcherBase {
|
|
|
243
248
|
if (fallback)
|
|
244
249
|
markSectionAsFocused(result, fallback);
|
|
245
250
|
}
|
|
246
|
-
if (!interrupted() && deep) {
|
|
251
|
+
if (!interrupted() && deep && (await this.hasHiddenContent())) {
|
|
247
252
|
try {
|
|
248
253
|
await this.performDeepAnalysis(researchState, result);
|
|
249
254
|
}
|
|
@@ -307,34 +312,49 @@ export class Researcher extends ResearcherBase {
|
|
|
307
312
|
}
|
|
308
313
|
async waitUntilSettled(screenshot) {
|
|
309
314
|
const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
try {
|
|
314
|
-
await this.explorer.withPage((page) => page.waitForLoadState('networkidle', { timeout: errorPageTimeout * 1000 }));
|
|
315
|
-
}
|
|
316
|
-
catch { }
|
|
317
|
-
await this.explorer.withPage(annotatePageElements);
|
|
318
|
-
this.actionResult = await this.explorer.capture({ screenshot: includeScreenshot });
|
|
319
|
-
let condition = detectPageCondition(this.actionResult);
|
|
320
|
-
if (condition === 'error') {
|
|
321
|
-
throw new ErrorPageError(this.actionResult.url, this.actionResult.title, this.actionResult.httpStatus);
|
|
322
|
-
}
|
|
323
|
-
if (condition === 'ok')
|
|
324
|
-
return true;
|
|
325
|
-
for (let i = 0; i < 3; i++) {
|
|
326
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
315
|
+
const deadline = Date.now() + errorPageTimeout * 1000;
|
|
316
|
+
for (let remaining = errorPageTimeout * 1000; remaining > 0; remaining = deadline - Date.now()) {
|
|
317
|
+
const rendered = await this.explorer.withPage((page) => waitForPageReadiness(page, { timeout: remaining, spinnerSelectors: this.config.playwright.spinnerSelectors }));
|
|
327
318
|
await this.explorer.withPage(annotatePageElements);
|
|
328
|
-
this.actionResult = await this.explorer.capture({ screenshot:
|
|
329
|
-
condition =
|
|
330
|
-
if (condition === 'error')
|
|
319
|
+
this.actionResult = await this.explorer.capture({ screenshot: screenshot && this.provider.hasVision() });
|
|
320
|
+
const condition = await this.pageCondition(rendered);
|
|
321
|
+
if (condition === 'error')
|
|
331
322
|
throw new ErrorPageError(this.actionResult.url, this.actionResult.title, this.actionResult.httpStatus);
|
|
332
|
-
}
|
|
333
323
|
if (condition === 'ok')
|
|
334
324
|
return true;
|
|
325
|
+
tag('step').log('Page is still loading, waiting for content...');
|
|
326
|
+
if (!(await this.explorer.withPage((page) => waitForDomChange(page, deadline - Date.now()))))
|
|
327
|
+
return false;
|
|
335
328
|
}
|
|
336
329
|
return false;
|
|
337
330
|
}
|
|
331
|
+
async pageCondition(rendered) {
|
|
332
|
+
const condition = detectPageCondition(this.actionResult);
|
|
333
|
+
if (condition !== 'loading')
|
|
334
|
+
return condition;
|
|
335
|
+
if (!this.judge && rendered)
|
|
336
|
+
return 'ok';
|
|
337
|
+
if (!this.judge)
|
|
338
|
+
return condition;
|
|
339
|
+
const conditions = {
|
|
340
|
+
'still loading: blank, splash screen, skeleton or loading placeholder': 'loading',
|
|
341
|
+
'error page: shows an error instead of the requested content': 'error',
|
|
342
|
+
'loaded: the main content is rendered': 'ok',
|
|
343
|
+
};
|
|
344
|
+
const decision = await this.judge.decide('The page was blank or barely rendered when it opened. What state is it in now?', [...Object.keys(conditions), UNDECIDED], pageEvidence(this.actionResult));
|
|
345
|
+
return conditions[decision.value ?? ''] || 'ok';
|
|
346
|
+
}
|
|
347
|
+
async hasHiddenContent() {
|
|
348
|
+
const captured = await this.explorer.capture({ screenshot: true });
|
|
349
|
+
const hidden = await this.provider.oracle(HIDDEN_CONTENT_STATEMENT, pageEvidence(captured), { agentName: 'researcher', screenshot: captured.screenshot, telemetry: { functionId: 'researcher.hasHiddenContent' } });
|
|
350
|
+
if (hidden === null) {
|
|
351
|
+
tag('warning').log('Could not check the page for hidden content, running deep analysis');
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
if (!hidden)
|
|
355
|
+
tag('step').log('Nothing to expand on this page, skipping deep analysis');
|
|
356
|
+
return hidden;
|
|
357
|
+
}
|
|
338
358
|
getConfiguredSections() {
|
|
339
359
|
const configSections = this.settings.sections;
|
|
340
360
|
if (!configSections?.length)
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -41,7 +41,7 @@ const locatorStrategyRule = dedent `
|
|
|
41
41
|
3. Use ARIA snapshot to identify element state (focused, visible, selected, expanded, etc.)
|
|
42
42
|
4. Match semantic proximity - elements near fields you just interacted with
|
|
43
43
|
|
|
44
|
-
Once the correct element is identified pick the best unique locator following priority: ARIA → CSS → XPath
|
|
44
|
+
Once the correct element is identified pick the best unique locator following priority: ARIA → text → CSS → XPath
|
|
45
45
|
</disambiguation>
|
|
46
46
|
|
|
47
47
|
ARIA locators must specify role. Specify locator type as JSON string with role and text keys.
|
|
@@ -97,7 +97,7 @@ const locatorStrategyRule = dedent `
|
|
|
97
97
|
<xpath_rules>
|
|
98
98
|
XPath locators must start with //.
|
|
99
99
|
XPath may use positional indices [1], [2] and contains(., "text") as a last-resort disambiguator when attribute/text strategies are exhausted.
|
|
100
|
-
XPath should rely less on class names — prefer element hierarchy,
|
|
100
|
+
XPath should rely less on class names — prefer element hierarchy, attributes, and text content.
|
|
101
101
|
XPath and CSS MUST provide different strategies for finding the same element.
|
|
102
102
|
</xpath_rules>
|
|
103
103
|
|
|
@@ -114,7 +114,7 @@ const locatorStrategyRule = dedent `
|
|
|
114
114
|
|
|
115
115
|
<bad locator example>
|
|
116
116
|
'a.filter-tab:nth-of-type(1)' // WRONG: positional in CSS, use :has-text("Active") instead
|
|
117
|
-
'//a[contains(@class,"filter-tab") and contains(@class,"active")]' // WRONG: XPath repeats CSS approach, provide a different strategy e.g. //a[
|
|
117
|
+
'//a[contains(@class,"filter-tab") and contains(@class,"active")]' // WRONG: XPath repeats CSS approach, provide a different strategy e.g. //a[normalize-space()="Active"]
|
|
118
118
|
'//table//tbody/tr[1]//button[contains(@onclick,'fn()')]' // onclick is not semantic attribute
|
|
119
119
|
'//html/body/vue-button-123' // vue-framework specific locator
|
|
120
120
|
'link "New Item"' // WRONG: malformed string, use {"role":"link","text":"New Item"}
|
|
@@ -212,7 +212,7 @@ export const focusedElementRule = dedent `
|
|
|
212
212
|
When a text input element is focused (textbox, combobox, contenteditable):
|
|
213
213
|
|
|
214
214
|
To CLEAR the field before typing:
|
|
215
|
-
- I.pressKey(['
|
|
215
|
+
- I.pressKey(['CommandOrControl', 'a']); // Select all
|
|
216
216
|
- I.pressKey('Backspace'); // Delete selection
|
|
217
217
|
|
|
218
218
|
To TYPE into the focused element:
|
|
@@ -402,8 +402,7 @@ export const actionRule = dedent `
|
|
|
402
402
|
I.pressKey('Escape'); // Press Escape key
|
|
403
403
|
I.pressKey('Tab'); // Press Tab key
|
|
404
404
|
I.pressKey('Backspace'); // Press Backspace key
|
|
405
|
-
I.pressKey(['
|
|
406
|
-
I.pressKey(['Meta', 'a']); // Select all on Mac (Cmd+A)
|
|
405
|
+
I.pressKey(['CommandOrControl', 'a']); // Select all
|
|
407
406
|
I.pressKey(['Shift', 'Tab']); // Shift+Tab to go back
|
|
408
407
|
I.pressKey('ArrowDown'); // Navigate dropdown options
|
|
409
408
|
</example>
|
|
@@ -500,7 +499,7 @@ export const actionRule = dedent `
|
|
|
500
499
|
I.seeClipboardEquals('ABC-123');
|
|
501
500
|
</example>
|
|
502
501
|
|
|
503
|
-
[
|
|
502
|
+
[Prefer the commands listed here; use other CodeceptJS I.* commands when none of these fits]
|
|
504
503
|
[INTERACT ONLY WITH ELEMENTS THAT ARE ON THE PAGE HTML]
|
|
505
504
|
[DO NOT USE WAIT FUNCTIONS]
|
|
506
505
|
|
package/dist/src/ai/scout.d.ts
CHANGED
|
@@ -12,10 +12,20 @@ export declare class Scout implements Agent {
|
|
|
12
12
|
runSession(corpus: ScoutCorpus, query: ScoutQuery): Promise<string | null>;
|
|
13
13
|
buildSystemPrompt(toolNames: string[], corpus: ScoutCorpus, query: ScoutQuery, scanner: 'rg' | 'grep'): string;
|
|
14
14
|
buildTaskPrompt(query: ScoutQuery): string;
|
|
15
|
+
saveDigest(query: ScoutQuery, digest: string): string;
|
|
16
|
+
digestPath(query: ScoutQuery): string;
|
|
17
|
+
loadCachedDigest(query: ScoutQuery): string | null;
|
|
15
18
|
}
|
|
16
|
-
export
|
|
19
|
+
export declare class ScoutQuery {
|
|
17
20
|
url?: string;
|
|
18
21
|
title?: string;
|
|
19
22
|
feature?: string;
|
|
20
|
-
excludeUrls
|
|
23
|
+
excludeUrls?: string[];
|
|
24
|
+
constructor(init?: Partial<ScoutQuery>);
|
|
25
|
+
static fromState(state: {
|
|
26
|
+
url?: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
}): ScoutQuery;
|
|
29
|
+
get exclude(): string[] | undefined;
|
|
30
|
+
set exclude(value: string[] | undefined);
|
|
21
31
|
}
|
package/dist/src/ai/scout.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
1
4
|
import dedent from 'dedent';
|
|
5
|
+
import matter from 'gray-matter';
|
|
6
|
+
import { outputPath } from "../config.js";
|
|
2
7
|
import { tag } from "../utils/logger.js";
|
|
3
8
|
import { loop } from "../utils/loop.js";
|
|
9
|
+
import { sanitizeFilename } from "../utils/strings.js";
|
|
4
10
|
import { createScoutTools, excludeCorpusUrls } from "./scout/tools.js";
|
|
5
11
|
const MAX_ITERATIONS = 3;
|
|
6
12
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
7
13
|
const CACHE_LIMIT = 40;
|
|
8
14
|
const URL_LISTING_LIMIT = 40;
|
|
15
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
9
16
|
export class Scout {
|
|
10
17
|
provider;
|
|
11
18
|
corpus;
|
|
@@ -21,11 +28,18 @@ export class Scout {
|
|
|
21
28
|
async collectDocs(query) {
|
|
22
29
|
if (!this.isAvailable())
|
|
23
30
|
return '';
|
|
24
|
-
const cacheKey = `${query.url || ''}|${query.feature || ''}|${query.excludeUrls.join(',')}`;
|
|
31
|
+
const cacheKey = `${query.url || ''}|${query.feature || ''}|${(query.excludeUrls || []).join(',')}`;
|
|
25
32
|
const cached = this.cache.get(cacheKey);
|
|
26
33
|
if (cached !== undefined)
|
|
27
34
|
return cached;
|
|
28
|
-
const
|
|
35
|
+
const fileCached = this.loadCachedDigest(query);
|
|
36
|
+
if (fileCached !== null) {
|
|
37
|
+
if (this.cache.size > CACHE_LIMIT)
|
|
38
|
+
this.cache.clear();
|
|
39
|
+
this.cache.set(cacheKey, fileCached);
|
|
40
|
+
return fileCached;
|
|
41
|
+
}
|
|
42
|
+
const corpus = excludeCorpusUrls(this.corpus, query.excludeUrls || []);
|
|
29
43
|
if (corpus.files.length === 0)
|
|
30
44
|
return '';
|
|
31
45
|
const result = await this.runSession(corpus, query);
|
|
@@ -76,9 +90,14 @@ export class Scout {
|
|
|
76
90
|
return null;
|
|
77
91
|
const digest = getResult();
|
|
78
92
|
if (digest) {
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
tag('info').log(`Scout
|
|
93
|
+
const filePath = this.saveDigest(query, digest);
|
|
94
|
+
const words = digest.split(/\s+/).filter(Boolean).length;
|
|
95
|
+
tag('info').log(`Scout found relevant docs: ${words} words → ${filePath}`);
|
|
96
|
+
const lines = digest.split('\n').slice(0, 5).join('\n');
|
|
97
|
+
tag('substep').log(lines);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
tag('info').log(`Scout found no relevant docs for ${query.feature || query.url || 'the current page'}`);
|
|
82
101
|
}
|
|
83
102
|
return digest;
|
|
84
103
|
}
|
|
@@ -114,7 +133,7 @@ export class Scout {
|
|
|
114
133
|
${pagesListing}
|
|
115
134
|
|
|
116
135
|
These pages are already provided to the planner in full — do not re-report them:
|
|
117
|
-
${query.excludeUrls.map((url) => `- ${url}`).join('\n') || '- none'}
|
|
136
|
+
${(query.excludeUrls || []).map((url) => `- ${url}`).join('\n') || '- none'}
|
|
118
137
|
|
|
119
138
|
AVAILABLE TOOLS:
|
|
120
139
|
${toolNames.join(', ')}.
|
|
@@ -129,12 +148,32 @@ export class Scout {
|
|
|
129
148
|
2. Read the files whose hits look most relevant
|
|
130
149
|
3. Report the digest as your final message — no tool call is needed to finish
|
|
131
150
|
|
|
132
|
-
|
|
151
|
+
<rules>
|
|
133
152
|
- Report only what the documentation states. Never fill gaps with assumptions about the application
|
|
134
153
|
- Keep verified capabilities and unverified possibilities distinguishable, the way the documentation marks them
|
|
135
154
|
- Name the page URL each item belongs to, so scenarios anchor to real routes
|
|
136
|
-
- Explore briefly: a few scans and reads are enough, then report
|
|
137
155
|
- A short accurate digest beats a long loose one; reporting that nothing relevant exists is a valid answer
|
|
156
|
+
- For vague terms such as valid, supported, recent, all, or available, seek exact definitions and provide example of valid/invalid values.
|
|
157
|
+
- Follow relevant references for missing rules, shared policies, exceptions, and conflicting claims.
|
|
158
|
+
</rules>
|
|
159
|
+
|
|
160
|
+
Look for facts we need to collect for testing the related feature:
|
|
161
|
+
|
|
162
|
+
<facts>
|
|
163
|
+
- **Actors/access:** Find exact roles, permissions, ownership, scope, and denial behavior. Distinguish hidden, disabled, forbidden, and redirected.
|
|
164
|
+
- **Prerequisites:** Find required data, settings, integrations, plans, flags, and dependencies.
|
|
165
|
+
- **Actions/navigation:** Find entry points, available operations, destinations, confirmations, and cancellation behavior.
|
|
166
|
+
- **Inputs/validation:** Find accepted values, formats, defaults, required fields, exact limits/units, normalization, and error messages. Distinguish omitted, empty, whitespace, and unchanged.
|
|
167
|
+
- **Duplicates:** Find identity rules, uniqueness scope, and whether matches are rejected, reused, merged, or updated.
|
|
168
|
+
- **Conditions/states:** Find guards, exceptions, state transitions, prohibited actions, and intentional no-ops.
|
|
169
|
+
- **Outcomes:** Find visible feedback, persistent changes, side effects, and subsequent places where results appear.
|
|
170
|
+
- **Failures:** Find retained changes, rollback, partial success, recovery, and retry behavior. Never assume an error means nothing changed.
|
|
171
|
+
- **Async/time:** Find start versus completion, progress indicators, refresh behavior, delays, expiry, schedules, and retry limits.
|
|
172
|
+
- **Lists/bulk actions:** Find search/filter semantics, sorting, pagination, count definitions, selection scope, and skipped-item behavior.
|
|
173
|
+
- **Relationships/deletion:** Find inheritance, cascades, copy versus reference, unlink versus delete, restoration, and irreversible effects.
|
|
174
|
+
- **Files/integrations:** Find supported formats, mappings, processing limits, sync direction, overwrite precedence, and unavailable-service behavior.
|
|
175
|
+
- **Variants:** Find differences by role, plan, configuration, deployment, browser, or channel. Keep browser and API behavior separate.
|
|
176
|
+
</facts>
|
|
138
177
|
`;
|
|
139
178
|
const customPrompt = this.provider.getSystemPromptForAgent('scout');
|
|
140
179
|
if (customPrompt)
|
|
@@ -150,7 +189,63 @@ export class Scout {
|
|
|
150
189
|
Report the documented capabilities, states and transitions a test planner could turn into scenarios.
|
|
151
190
|
`;
|
|
152
191
|
}
|
|
192
|
+
saveDigest(query, digest) {
|
|
193
|
+
const filePath = this.digestPath(query);
|
|
194
|
+
const dir = outputPath('scout');
|
|
195
|
+
if (!existsSync(dir))
|
|
196
|
+
mkdirSync(dir, { recursive: true });
|
|
197
|
+
const frontmatter = {};
|
|
198
|
+
if (query.url)
|
|
199
|
+
frontmatter.url = query.url;
|
|
200
|
+
if (query.feature)
|
|
201
|
+
frontmatter.feature = query.feature;
|
|
202
|
+
if (query.excludeUrls?.length)
|
|
203
|
+
frontmatter.excludeUrls = query.excludeUrls;
|
|
204
|
+
frontmatter.cachedAt = new Date().toISOString();
|
|
205
|
+
const content = matter.stringify(digest, frontmatter);
|
|
206
|
+
writeFileSync(filePath, content, 'utf8');
|
|
207
|
+
tag('data').log('scout', { path: filePath, content: digest });
|
|
208
|
+
return filePath;
|
|
209
|
+
}
|
|
210
|
+
digestPath(query) {
|
|
211
|
+
const cacheKey = `${query.url || ''}|${query.feature || ''}|${(query.excludeUrls || []).join(',')}`;
|
|
212
|
+
const hash = createHash('sha1').update(cacheKey).digest('hex').slice(0, 8);
|
|
213
|
+
const raw = query.feature || query.url || 'general';
|
|
214
|
+
const name = sanitizeFilename(raw) || 'general';
|
|
215
|
+
return join(outputPath('scout'), `digest-${name}-${hash}.md`);
|
|
216
|
+
}
|
|
217
|
+
loadCachedDigest(query) {
|
|
218
|
+
const filePath = this.digestPath(query);
|
|
219
|
+
if (!existsSync(filePath))
|
|
220
|
+
return null;
|
|
221
|
+
if (Date.now() - statSync(filePath).mtimeMs > CACHE_TTL_MS)
|
|
222
|
+
return null;
|
|
223
|
+
const content = matter(readFileSync(filePath, 'utf8')).content;
|
|
224
|
+
const words = content.split(/\s+/).filter(Boolean).length;
|
|
225
|
+
tag('info').log(`Scout found previous spec digest in ${relative(process.cwd(), filePath)}`);
|
|
226
|
+
tag('multiline').log(content);
|
|
227
|
+
return content;
|
|
228
|
+
}
|
|
153
229
|
}
|
|
154
230
|
function toPosix(path) {
|
|
155
231
|
return path.split('\\').join('/');
|
|
156
232
|
}
|
|
233
|
+
export class ScoutQuery {
|
|
234
|
+
url;
|
|
235
|
+
title;
|
|
236
|
+
feature;
|
|
237
|
+
excludeUrls;
|
|
238
|
+
constructor(init) {
|
|
239
|
+
if (init)
|
|
240
|
+
Object.assign(this, init);
|
|
241
|
+
}
|
|
242
|
+
static fromState(state) {
|
|
243
|
+
return new ScoutQuery({ url: state.url, title: state.title });
|
|
244
|
+
}
|
|
245
|
+
get exclude() {
|
|
246
|
+
return this.excludeUrls;
|
|
247
|
+
}
|
|
248
|
+
set exclude(value) {
|
|
249
|
+
this.excludeUrls = value;
|
|
250
|
+
}
|
|
251
|
+
}
|
package/dist/src/ai/tester.d.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { RequestStore } from '../api/request-store.js';
|
|
|
3
3
|
import type { TestRun } from '../explorer.js';
|
|
4
4
|
import { type Test } from '../test-plan.js';
|
|
5
5
|
import type { Agent, AgentDeps } from './agent.js';
|
|
6
|
-
import type { Captain } from './captain.js';
|
|
7
6
|
import type { Conversation } from './conversation.js';
|
|
8
7
|
import { Navigator } from './navigator.js';
|
|
9
8
|
import type { Pilot } from './pilot.js';
|
|
@@ -18,7 +17,7 @@ export declare class Tester extends TaskAgent implements Agent {
|
|
|
18
17
|
testRun: TestRun | null;
|
|
19
18
|
currentConversation: Conversation | null;
|
|
20
19
|
pilot: Pilot | null;
|
|
21
|
-
|
|
20
|
+
supervisor: Supervisor | null;
|
|
22
21
|
MAX_ITERATIONS: number;
|
|
23
22
|
MAX_EXTENSIONS: number;
|
|
24
23
|
ASSERTION_TOOLS: string[];
|
|
@@ -40,7 +39,7 @@ export declare class Tester extends TaskAgent implements Agent {
|
|
|
40
39
|
constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
|
|
41
40
|
getNavigator(): Navigator;
|
|
42
41
|
setPilot(pilot: Pilot): void;
|
|
43
|
-
|
|
42
|
+
setSupervisor(supervisor: Supervisor): void;
|
|
44
43
|
getCurrentState(): ActionResult;
|
|
45
44
|
get progressCheckInterval(): number;
|
|
46
45
|
getConversation(): Conversation | null;
|
|
@@ -65,7 +64,7 @@ export declare class Tester extends TaskAgent implements Agent {
|
|
|
65
64
|
getDeletableSessionNames(task: Test): string[];
|
|
66
65
|
buildAvailableFiles(): string;
|
|
67
66
|
buildDeletionScope(task: Test): string;
|
|
68
|
-
createTestFlowTools(task: Test,
|
|
67
|
+
createTestFlowTools(task: Test, conversation: Conversation): {
|
|
69
68
|
reset: import("@ai-sdk/provider-utils").ExecutableTool<import("ai").Tool<{
|
|
70
69
|
reason?: string;
|
|
71
70
|
}, any, import("@ai-sdk/provider-utils").Context>>;
|
|
@@ -92,12 +91,17 @@ export declare class Tester extends TaskAgent implements Agent {
|
|
|
92
91
|
}, import("@ai-sdk/provider-utils").Context>>;
|
|
93
92
|
};
|
|
94
93
|
handleLoopError(task: Test, error: unknown): Promise<'continue' | 'stop'>;
|
|
95
|
-
|
|
94
|
+
shouldStopAfterStall(task: Test, note: string): boolean;
|
|
96
95
|
cleanupStartedTest(task: Test): Promise<void>;
|
|
97
96
|
}
|
|
98
97
|
interface TestSessionHandlers {
|
|
99
98
|
offFailedRequest?: () => void;
|
|
100
99
|
}
|
|
100
|
+
export type Supervisor = (input: string, test: Test) => Promise<SupervisorAction>;
|
|
101
|
+
export interface SupervisorAction {
|
|
102
|
+
action: 'inject' | 'stop' | 'pass' | 'skip';
|
|
103
|
+
message: string;
|
|
104
|
+
}
|
|
101
105
|
export interface TestOptions {
|
|
102
106
|
startOnCurrentPage?: boolean;
|
|
103
107
|
deadline?: number;
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -9,10 +9,12 @@ import { Observability } from "../observability.js";
|
|
|
9
9
|
import { normalizeUrl } from "../state-manager.js";
|
|
10
10
|
import { Stats } from "../stats.js";
|
|
11
11
|
import { TestResult } from "../test-plan.js";
|
|
12
|
-
import { ErrorPageError, isErrorPage } from "../utils/error-page.js";
|
|
12
|
+
import { ErrorPageError, TestStartBlockedError, isErrorPage } from "../utils/error-page.js";
|
|
13
13
|
import { createDebug, tag } from "../utils/logger.js";
|
|
14
14
|
import { loop } from "../utils/loop.js";
|
|
15
15
|
import { compactErrorMessage } from "../utils/strings.js";
|
|
16
|
+
import { isSamePageIgnoringQuery } from "../utils/url-matcher.js";
|
|
17
|
+
import { pageEvidence } from "./judge.js";
|
|
16
18
|
import { actionRule, capabilityGroundingRule, dataProtectionRules, focusedElementRule, formRequirementsRule, locatorRule, multipleTabsRule, sectionContextRule } from "./rules.js";
|
|
17
19
|
import { TaskAgent } from "./task-agent.js";
|
|
18
20
|
import { createCodeceptJSTools, createIframeTools, withdrawVisionTools } from "./tools.js";
|
|
@@ -36,7 +38,7 @@ export class Tester extends TaskAgent {
|
|
|
36
38
|
testRun = null;
|
|
37
39
|
currentConversation = null;
|
|
38
40
|
pilot = null;
|
|
39
|
-
|
|
41
|
+
supervisor = null;
|
|
40
42
|
MAX_ITERATIONS = 30;
|
|
41
43
|
MAX_EXTENSIONS = 2;
|
|
42
44
|
ASSERTION_TOOLS = ['verify'];
|
|
@@ -73,8 +75,8 @@ export class Tester extends TaskAgent {
|
|
|
73
75
|
setPilot(pilot) {
|
|
74
76
|
this.pilot = pilot;
|
|
75
77
|
}
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
+
setSupervisor(supervisor) {
|
|
79
|
+
this.supervisor = supervisor;
|
|
78
80
|
}
|
|
79
81
|
getCurrentState() {
|
|
80
82
|
return ActionResult.fromState(this.stateManager.getCurrentState());
|
|
@@ -102,7 +104,6 @@ export class Tester extends TaskAgent {
|
|
|
102
104
|
this.previousRegionPresent = null;
|
|
103
105
|
this.regionTransitioned = false;
|
|
104
106
|
this.stateManager.clearHistory();
|
|
105
|
-
this.resetFailureCount();
|
|
106
107
|
this.pilot?.reset();
|
|
107
108
|
const requestStore = this.requestStore;
|
|
108
109
|
requestStore.clear();
|
|
@@ -132,6 +133,11 @@ export class Tester extends TaskAgent {
|
|
|
132
133
|
offFailedRequest?.();
|
|
133
134
|
return await this.abortStartedTestOnErrorPage(task, initialState);
|
|
134
135
|
}
|
|
136
|
+
const startEvidence = { ...pageEvidence(initialState), scenario: task.scenario, expected: task.expected, plannedSteps: task.plannedSteps, notes: task.notesToString() };
|
|
137
|
+
if ((await this.judge?.assert('This is the right page to start the test', startEvidence)) === false) {
|
|
138
|
+
offFailedRequest?.();
|
|
139
|
+
throw new TestStartBlockedError(task.scenario, task.startUrl, initialState.url || '');
|
|
140
|
+
}
|
|
135
141
|
const conversation = this.provider.startConversation(this.getSystemMessage(), 'tester');
|
|
136
142
|
conversation.markLastMessageCacheable();
|
|
137
143
|
this.currentConversation = conversation;
|
|
@@ -225,7 +231,7 @@ export class Tester extends TaskAgent {
|
|
|
225
231
|
let shouldContinue = true;
|
|
226
232
|
while (shouldContinue) {
|
|
227
233
|
shouldContinue = false;
|
|
228
|
-
await loop(async ({ stop,
|
|
234
|
+
await loop(async ({ stop, iteration, userInput }) => {
|
|
229
235
|
if (opts.deadline != null && Date.now() >= opts.deadline) {
|
|
230
236
|
deadlineReached = true;
|
|
231
237
|
task.addNote('Time budget reached. Stopped');
|
|
@@ -233,16 +239,10 @@ export class Tester extends TaskAgent {
|
|
|
233
239
|
return;
|
|
234
240
|
}
|
|
235
241
|
debugLog('iteration', iteration);
|
|
236
|
-
if (!(await this.explorer.recover()).ok) {
|
|
237
|
-
task.addNote('Browser page is unavailable');
|
|
238
|
-
task.finish(TestResult.FAILED);
|
|
239
|
-
stop();
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
242
|
const currentState = this.getCurrentState();
|
|
243
243
|
const tools = {
|
|
244
244
|
...codeceptjsTools,
|
|
245
|
-
...this.createTestFlowTools(task,
|
|
245
|
+
...this.createTestFlowTools(task, conversation),
|
|
246
246
|
...this.agentTools,
|
|
247
247
|
};
|
|
248
248
|
if (currentState.isInsideIframe) {
|
|
@@ -275,7 +275,7 @@ export class Tester extends TaskAgent {
|
|
|
275
275
|
conversation.cleanupTag('page_ui_map_overlay', '...cleaned UI overlay...', 1);
|
|
276
276
|
conversation.compactToolResults(2);
|
|
277
277
|
if (iteration > 1) {
|
|
278
|
-
const isNewPage = this.previousUrl !== null && this.previousUrl
|
|
278
|
+
const isNewPage = this.previousUrl !== null && !isSamePageIgnoringQuery(this.previousUrl, currentState.url);
|
|
279
279
|
let nextStep = '';
|
|
280
280
|
nextStep += await this.reinjectContextIfNeeded(iteration, currentState);
|
|
281
281
|
nextStep += await this.prepareInstructionsForNextStep(task);
|
|
@@ -294,7 +294,6 @@ export class Tester extends TaskAgent {
|
|
|
294
294
|
}
|
|
295
295
|
if (guidance)
|
|
296
296
|
nextStep += `\n\n${guidance}`;
|
|
297
|
-
this.consecutiveFailures = 0;
|
|
298
297
|
this.lastAnalyzedStateHash = currentState.hash;
|
|
299
298
|
}
|
|
300
299
|
conversation.addUserText(nextStep);
|
|
@@ -315,12 +314,6 @@ export class Tester extends TaskAgent {
|
|
|
315
314
|
const actionPerformed = !!allToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
|
|
316
315
|
assertionPerformed = !!successfulToolNames.find((toolName) => this.ASSERTION_TOOLS.includes(toolName));
|
|
317
316
|
const wasSuccessful = result?.toolExecutions?.every((execution) => execution.wasSuccessful);
|
|
318
|
-
this.trackToolExecutions(result?.toolExecutions || []);
|
|
319
|
-
if (this.consecutiveEmptyResults >= 5) {
|
|
320
|
-
task.addNote('AI model is not responding with actions. Stopped');
|
|
321
|
-
stop();
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
317
|
if (actionPerformed && !wasSuccessful) {
|
|
325
318
|
const noted = new Set(Object.values(task.notes).map((note) => note.message));
|
|
326
319
|
result?.toolExecutions
|
|
@@ -372,11 +365,11 @@ export class Tester extends TaskAgent {
|
|
|
372
365
|
}, {
|
|
373
366
|
maxAttempts: this.MAX_ITERATIONS,
|
|
374
367
|
interruptPrompt: 'Test interrupted. Enter new instruction (or "stop" to cancel):',
|
|
375
|
-
onInterrupt: this.
|
|
368
|
+
onInterrupt: this.supervisor
|
|
376
369
|
? async (userInput, context) => {
|
|
377
370
|
if (!userInput)
|
|
378
371
|
return;
|
|
379
|
-
const result = await this.
|
|
372
|
+
const result = await this.supervisor(userInput, task);
|
|
380
373
|
tag('info').log(`🧑✈️ Supervisor: ${result.action} — ${result.message}`);
|
|
381
374
|
const terminalResults = {
|
|
382
375
|
stop: TestResult.FAILED,
|
|
@@ -450,11 +443,9 @@ export class Tester extends TaskAgent {
|
|
|
450
443
|
return true;
|
|
451
444
|
}
|
|
452
445
|
get isStruggling() {
|
|
453
|
-
return this.
|
|
446
|
+
return this.stalledIterations >= this.MAX_STALLED_ITERATIONS - 1;
|
|
454
447
|
}
|
|
455
448
|
shouldStopForStalledExecution(task, previousState, toolExecutions) {
|
|
456
|
-
if (task.hasFinished)
|
|
457
|
-
return false;
|
|
458
449
|
const currentState = this.getCurrentState();
|
|
459
450
|
const stateChanged = previousState.url !== currentState.url || previousState.hash !== currentState.hash;
|
|
460
451
|
const actionTools = [...this.ACTION_TOOLS, ...this.DELEGATED_ACTION_TOOLS, ...this.SPECIAL_CONTEXT_ACTION_TOOLS];
|
|
@@ -464,14 +455,7 @@ export class Tester extends TaskAgent {
|
|
|
464
455
|
this.stalledIterations = 0;
|
|
465
456
|
return false;
|
|
466
457
|
}
|
|
467
|
-
|
|
468
|
-
if (!hasNoBrowserProgress)
|
|
469
|
-
return false;
|
|
470
|
-
this.stalledIterations++;
|
|
471
|
-
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
|
|
472
|
-
return false;
|
|
473
|
-
task.addNote('No further browser progress on unchanged page; requesting final review');
|
|
474
|
-
return true;
|
|
458
|
+
return this.shouldStopAfterStall(task, 'No further browser progress on unchanged page; requesting final review');
|
|
475
459
|
}
|
|
476
460
|
async prepareInstructionsForNextStep(task) {
|
|
477
461
|
let outcomeStatus = dedent `
|
|
@@ -871,9 +855,8 @@ export class Tester extends TaskAgent {
|
|
|
871
855
|
}
|
|
872
856
|
return '';
|
|
873
857
|
}
|
|
874
|
-
createTestFlowTools(task,
|
|
858
|
+
createTestFlowTools(task, conversation) {
|
|
875
859
|
const resetUrl = task.startUrl;
|
|
876
|
-
const visitedUrls = task.getVisitedUrls();
|
|
877
860
|
return {
|
|
878
861
|
reset: tool({
|
|
879
862
|
description: dedent `
|
|
@@ -1070,8 +1053,6 @@ export class Tester extends TaskAgent {
|
|
|
1070
1053
|
task.finish(hasPassed ? TestResult.PASSED : TestResult.FAILED);
|
|
1071
1054
|
}
|
|
1072
1055
|
}
|
|
1073
|
-
const remainingExpectations = task.getRemainingExpectations();
|
|
1074
|
-
// const suggestion = input.status !== null && remainingExpectations.length > 0 ? `Continue testing to check the remaining expected outcomes: ${remainingExpectations.join(', ')}` : 'Continue with your testing strategy based on these findings.';
|
|
1075
1056
|
return {
|
|
1076
1057
|
success: true,
|
|
1077
1058
|
action: 'record',
|
|
@@ -1095,24 +1076,23 @@ export class Tester extends TaskAgent {
|
|
|
1095
1076
|
return 'stop';
|
|
1096
1077
|
}
|
|
1097
1078
|
if (result.recovered) {
|
|
1098
|
-
this.resetFailureCount();
|
|
1099
1079
|
this.previousUrl = null;
|
|
1100
1080
|
this.previousStateHash = null;
|
|
1101
1081
|
this.stalledIterations = 0;
|
|
1102
1082
|
}
|
|
1103
|
-
else if (this.
|
|
1083
|
+
else if (this.shouldStopAfterStall(task, 'No browser progress after repeated execution errors; requesting final review')) {
|
|
1104
1084
|
return 'stop';
|
|
1105
1085
|
}
|
|
1106
1086
|
this.currentConversation?.addUserText(result.message);
|
|
1107
1087
|
return 'continue';
|
|
1108
1088
|
}
|
|
1109
|
-
|
|
1089
|
+
shouldStopAfterStall(task, note) {
|
|
1110
1090
|
if (task.hasFinished)
|
|
1111
1091
|
return false;
|
|
1112
1092
|
this.stalledIterations++;
|
|
1113
1093
|
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
|
|
1114
1094
|
return false;
|
|
1115
|
-
task.addNote(
|
|
1095
|
+
task.addNote(note);
|
|
1116
1096
|
return true;
|
|
1117
1097
|
}
|
|
1118
1098
|
async cleanupStartedTest(task) {
|