explorbot 0.1.27 → 0.1.29
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 +83 -245
- package/bin/explorbot-cli.ts +1 -0
- package/dist/bin/explorbot-cli.js +1 -0
- package/dist/package.json +8 -6
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action-result.js +2 -0
- package/dist/src/action.js +88 -7
- package/dist/src/ai/captain/file-tools.js +100 -0
- package/dist/src/ai/captain/idle-mode.js +70 -6
- package/dist/src/ai/captain/web-mode.js +36 -6
- package/dist/src/ai/captain.js +87 -19
- package/dist/src/ai/fisherman.js +14 -3
- package/dist/src/ai/historian/screencast.js +11 -2
- package/dist/src/ai/navigator.js +5 -2
- package/dist/src/ai/pilot.js +52 -9
- package/dist/src/ai/planner.js +16 -5
- package/dist/src/ai/provider.js +53 -18
- package/dist/src/ai/researcher/coordinates.js +2 -3
- package/dist/src/ai/researcher/deep-analysis.js +3 -4
- package/dist/src/ai/researcher/locators.js +1 -2
- package/dist/src/ai/researcher.js +24 -19
- package/dist/src/ai/rules.js +44 -0
- package/dist/src/ai/task-agent.js +1 -0
- package/dist/src/ai/tester.js +161 -46
- package/dist/src/ai/tools.js +84 -8
- package/dist/src/commands/explore-command.js +6 -1
- package/dist/src/components/LogPane.js +4 -3
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +270 -35
- package/dist/src/stats.js +16 -0
- package/dist/src/utils/aria.js +66 -6
- package/dist/src/utils/browser-errors.js +23 -0
- package/dist/src/utils/error-page.js +17 -2
- package/dist/src/utils/logger.js +2 -2
- package/package.json +8 -6
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action-result.ts +2 -0
- package/src/action.ts +83 -7
- package/src/ai/captain/file-tools.ts +126 -0
- package/src/ai/captain/idle-mode.ts +72 -6
- package/src/ai/captain/mixin.ts +1 -1
- package/src/ai/captain/web-mode.ts +40 -5
- package/src/ai/captain.ts +94 -20
- package/src/ai/fisherman.ts +14 -3
- package/src/ai/historian/screencast.ts +11 -2
- package/src/ai/navigator.ts +6 -2
- package/src/ai/pilot.ts +53 -9
- package/src/ai/planner.ts +16 -5
- package/src/ai/provider.ts +51 -19
- package/src/ai/researcher/coordinates.ts +2 -3
- package/src/ai/researcher/deep-analysis.ts +3 -4
- package/src/ai/researcher/locators.ts +1 -2
- package/src/ai/researcher.ts +25 -19
- package/src/ai/rules.ts +46 -0
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +175 -48
- package/src/ai/tools.ts +97 -8
- package/src/commands/explore-command.ts +6 -1
- package/src/components/LogPane.tsx +4 -3
- package/src/config.ts +1 -0
- package/src/explorbot.ts +6 -2
- package/src/explorer.ts +295 -38
- package/src/state-manager.ts +2 -0
- package/src/stats.ts +18 -0
- package/src/utils/aria.ts +63 -6
- package/src/utils/browser-errors.ts +25 -0
- package/src/utils/error-page.ts +16 -3
- package/src/utils/logger.ts +3 -3
|
@@ -121,6 +121,7 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
|
|
|
121
121
|
case 'step':
|
|
122
122
|
return { color: 'cyan' as const, dimColor: true };
|
|
123
123
|
case 'multiline':
|
|
124
|
+
case 'details':
|
|
124
125
|
return { color: 'gray' as const, dimColor: true };
|
|
125
126
|
case 'html':
|
|
126
127
|
return { color: 'gray' as const };
|
|
@@ -143,16 +144,16 @@ const LogPane: React.FC<LogPaneProps> = React.memo(({ verboseMode }) => {
|
|
|
143
144
|
}
|
|
144
145
|
const styles = getLogStyles(log.type);
|
|
145
146
|
|
|
146
|
-
if (log.type === 'multiline') {
|
|
147
|
+
if (log.type === 'multiline' || log.type === 'details') {
|
|
147
148
|
const cleaned = stripAnsi(dedent(log.content));
|
|
148
149
|
const parsed = parseMarkdownToTerminal(cleaned);
|
|
149
150
|
const lines = parsed.split('\n');
|
|
150
151
|
const maxLines = log.maxLines || 16;
|
|
151
|
-
const
|
|
152
|
+
const content = log.type === 'details' ? parsed : lines.length > maxLines ? `${lines.slice(0, maxLines).join('\n')}\n... (${lines.length - maxLines} more lines)` : parsed;
|
|
152
153
|
return (
|
|
153
154
|
<Box key={index} borderStyle="classic" borderLeft={false} borderRight={false} marginY={1} padding={1} borderColor="dim" overflow="hidden">
|
|
154
155
|
<Text color="gray" dimColor>
|
|
155
|
-
{
|
|
156
|
+
{content}
|
|
156
157
|
</Text>
|
|
157
158
|
</Box>
|
|
158
159
|
);
|
package/src/config.ts
CHANGED
|
@@ -59,6 +59,7 @@ interface AgentConfig extends HooksConfig {
|
|
|
59
59
|
systemPrompt?: string;
|
|
60
60
|
rules?: RuleEntry[];
|
|
61
61
|
providerOptions?: Record<string, any>;
|
|
62
|
+
reasoning?: 'provider-default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
interface ResearcherAgentConfig extends AgentConfig {
|
package/src/explorbot.ts
CHANGED
|
@@ -25,12 +25,13 @@ import { ExperienceTracker } from './experience-tracker.ts';
|
|
|
25
25
|
import Explorer from './explorer.ts';
|
|
26
26
|
import { KnowledgeTracker } from './knowledge-tracker.ts';
|
|
27
27
|
import { WebPageState } from './state-manager.ts';
|
|
28
|
+
import { Stats } from './stats.ts';
|
|
28
29
|
import type { Suite } from './suite.ts';
|
|
29
30
|
import { Plan, type Test } from './test-plan.ts';
|
|
30
|
-
import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
|
|
31
31
|
import { setVerboseMode, tag } from './utils/logger.ts';
|
|
32
32
|
import { relativeToCwd } from './utils/next-steps.ts';
|
|
33
33
|
import { sanitizeFilename } from './utils/strings.ts';
|
|
34
|
+
import { parsePlansFromMarkdown } from './utils/test-plan-markdown.ts';
|
|
34
35
|
|
|
35
36
|
export interface ExplorBotOptions {
|
|
36
37
|
from?: string;
|
|
@@ -494,7 +495,10 @@ export class ExplorBot {
|
|
|
494
495
|
|
|
495
496
|
const reporter = this.explorer?.getReporter();
|
|
496
497
|
if (reporter?.isEnabled()) {
|
|
497
|
-
|
|
498
|
+
let description = markdown;
|
|
499
|
+
const modelsTable = Stats.modelsTable(this.provider.getConfiguredModels());
|
|
500
|
+
if (modelsTable) description = `${markdown}\n\n${modelsTable}`;
|
|
501
|
+
await reporter.setRunDescription(description);
|
|
498
502
|
}
|
|
499
503
|
|
|
500
504
|
this.lastReportedTestCount = tests.length;
|
package/src/explorer.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import path, { join } from 'node:path';
|
|
3
3
|
// @ts-ignore
|
|
4
4
|
import * as codeceptjs from 'codeceptjs';
|
|
5
|
+
import dedent from 'dedent';
|
|
5
6
|
import stepsListener from 'codeceptjs/lib/listener/steps';
|
|
6
7
|
import storeListener from 'codeceptjs/lib/listener/store';
|
|
7
8
|
import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
9
|
+
import type { BrowserContextOptions } from 'playwright';
|
|
8
10
|
import { ActionResult } from './action-result.ts';
|
|
9
11
|
import Action from './action.js';
|
|
10
12
|
import { AIProvider } from './ai/provider.js';
|
|
11
|
-
import type { BrowserContextOptions } from 'playwright';
|
|
12
13
|
import { visuallyAnnotateContainers } from './ai/researcher/coordinates.ts';
|
|
13
14
|
import { RequestStore } from './api/request-store.ts';
|
|
14
15
|
import { XhrCapture } from './api/xhr-capture.ts';
|
|
@@ -19,10 +20,11 @@ import { KnowledgeTracker } from './knowledge-tracker.js';
|
|
|
19
20
|
import { PlaywrightRecorder } from './playwright-recorder.ts';
|
|
20
21
|
import { Reporter } from './reporter.ts';
|
|
21
22
|
import { StateManager } from './state-manager.js';
|
|
22
|
-
import { Test } from './test-plan.ts';
|
|
23
|
+
import { Test, TestResult } from './test-plan.ts';
|
|
23
24
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from './utils/html.ts';
|
|
24
25
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
25
26
|
import { WebElement } from './utils/web-element.ts';
|
|
27
|
+
import { BrowserRecoveryError, isFatalBrowserError } from './utils/browser-errors.ts';
|
|
26
28
|
|
|
27
29
|
declare global {
|
|
28
30
|
namespace NodeJS {
|
|
@@ -39,7 +41,6 @@ declare namespace CodeceptJS {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
const debugLog = createDebug('explorbot:explorer');
|
|
42
|
-
const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
|
|
43
44
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
44
45
|
|
|
45
46
|
interface TabInfo {
|
|
@@ -47,6 +48,12 @@ interface TabInfo {
|
|
|
47
48
|
title: string;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
interface BrowserExecutionErrorResult {
|
|
52
|
+
action: 'continue' | 'stop';
|
|
53
|
+
message: string;
|
|
54
|
+
recovered?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
class Explorer {
|
|
51
58
|
private aiProvider: AIProvider;
|
|
52
59
|
playwrightHelper: any;
|
|
@@ -64,6 +71,10 @@ class Explorer {
|
|
|
64
71
|
private xhrCapture: XhrCapture | null = null;
|
|
65
72
|
private requestStore: RequestStore | null = null;
|
|
66
73
|
private playwrightRecorder: PlaywrightRecorder = new PlaywrightRecorder();
|
|
74
|
+
private observedTestPages = new Set<any>();
|
|
75
|
+
private testPageErrorHandler: ((error: Error) => void) | null = null;
|
|
76
|
+
private testConsoleHandler: ((message: any) => void) | null = null;
|
|
77
|
+
private testDialogHandler: ((dialog: any) => void) | null = null;
|
|
67
78
|
|
|
68
79
|
constructor(config: ExplorbotConfig, aiProvider: AIProvider, options?: { show?: boolean; headless?: boolean; incognito?: boolean; session?: string }) {
|
|
69
80
|
this.config = config;
|
|
@@ -199,12 +210,14 @@ class Explorer {
|
|
|
199
210
|
}
|
|
200
211
|
}
|
|
201
212
|
|
|
202
|
-
private setupXhrCapture(): void {
|
|
213
|
+
private setupXhrCapture(reuseRequestStore = false): void {
|
|
203
214
|
const configParser = ConfigParser.getInstance();
|
|
204
215
|
const outputDir = configParser.getOutputDir();
|
|
205
|
-
this.requestStore
|
|
216
|
+
if (!reuseRequestStore || !this.requestStore) {
|
|
217
|
+
this.requestStore = new RequestStore(outputDir);
|
|
218
|
+
}
|
|
206
219
|
const baseUrl = this.config.playwright.url;
|
|
207
|
-
this.xhrCapture = new XhrCapture(this.requestStore
|
|
220
|
+
this.xhrCapture = new XhrCapture(this.requestStore!, baseUrl);
|
|
208
221
|
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
209
222
|
}
|
|
210
223
|
|
|
@@ -239,18 +252,7 @@ class Explorer {
|
|
|
239
252
|
}
|
|
240
253
|
await this.connectOrLaunchBrowser();
|
|
241
254
|
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
242
|
-
|
|
243
|
-
// CodeceptJS skips _createContextPage when sessions/storageState are involved, so we
|
|
244
|
-
// build contextOptions ourselves. Most keys share a name with Playwright's
|
|
245
|
-
// BrowserContextOptions and are copied as-is; `emulate` must be flattened, `basicAuth`
|
|
246
|
-
// renamed to `httpCredentials`, and `storageState` comes from the --session flag.
|
|
247
|
-
const contextOptions: BrowserContextOptions = {
|
|
248
|
-
...helperOptions,
|
|
249
|
-
};
|
|
250
|
-
if (helperOptions.emulate) Object.assign(contextOptions, helperOptions.emulate);
|
|
251
|
-
if (helperOptions.basicAuth) contextOptions.httpCredentials = helperOptions.basicAuth;
|
|
252
|
-
if (hasSession) contextOptions.storageState = this.options!.session;
|
|
253
|
-
await this.playwrightHelper._createContextPage(contextOptions);
|
|
255
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
254
256
|
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
255
257
|
this.setupXhrCapture();
|
|
256
258
|
if (hasSession) {
|
|
@@ -287,15 +289,75 @@ class Explorer {
|
|
|
287
289
|
await this.playwrightHelper._startBrowser();
|
|
288
290
|
}
|
|
289
291
|
|
|
292
|
+
private createBrowserContextOptions(): BrowserContextOptions {
|
|
293
|
+
const helperOptions = this.playwrightHelper.options || {};
|
|
294
|
+
const contextOptions: BrowserContextOptions = {
|
|
295
|
+
...helperOptions,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
if (helperOptions.emulate) Object.assign(contextOptions, helperOptions.emulate);
|
|
299
|
+
if (helperOptions.basicAuth) contextOptions.httpCredentials = helperOptions.basicAuth;
|
|
300
|
+
if (this.options?.session && existsSync(this.options.session)) contextOptions.storageState = this.options.session;
|
|
301
|
+
|
|
302
|
+
return contextOptions;
|
|
303
|
+
}
|
|
304
|
+
|
|
290
305
|
createAction() {
|
|
291
306
|
return new Action(this.actor, this.stateManager, this.playwrightRecorder);
|
|
292
307
|
}
|
|
293
308
|
|
|
309
|
+
async runWithBrowserRecovery<T>(label: string, operation: () => Promise<T>): Promise<T> {
|
|
310
|
+
if (!(await this.ensurePageAvailable())) {
|
|
311
|
+
throw new Error(`Browser page is unavailable before ${label}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
return await operation();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (!this.isFatalBrowserError(error)) throw error;
|
|
318
|
+
|
|
319
|
+
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
320
|
+
let recovered = await this.recoverFromBrowserError();
|
|
321
|
+
if (!recovered) recovered = await this.restartBrowser();
|
|
322
|
+
if (!recovered) throw new BrowserRecoveryError(label, error, false);
|
|
323
|
+
if (!(await this.waitForUsablePageDom())) throw new BrowserRecoveryError(label, error, true);
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
return await operation();
|
|
327
|
+
} catch (retryError) {
|
|
328
|
+
if (this.isFatalBrowserError(retryError)) {
|
|
329
|
+
throw new BrowserRecoveryError(label, retryError, true);
|
|
330
|
+
}
|
|
331
|
+
throw retryError;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async capturePageState(opts: { includeScreenshot?: boolean } = {}): Promise<ActionResult> {
|
|
337
|
+
return this.runWithBrowserRecovery('capturePageState', () => this.createAction().capturePageState(opts));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async capturePageWithScreenshot(): Promise<ActionResult> {
|
|
341
|
+
return this.capturePageState({ includeScreenshot: true });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async executeAction(code: string): Promise<Action> {
|
|
345
|
+
return this.runWithBrowserRecovery('executeAction', () => this.createAction().execute(code));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async attemptAction(code: string, originalMessage?: string, experience = true): Promise<boolean> {
|
|
349
|
+
return this.runWithBrowserRecovery('attemptAction', () => this.createAction().attempt(code, originalMessage, experience));
|
|
350
|
+
}
|
|
351
|
+
|
|
294
352
|
getPlaywrightRecorder(): PlaywrightRecorder {
|
|
295
353
|
return this.playwrightRecorder;
|
|
296
354
|
}
|
|
297
355
|
|
|
298
356
|
async visit(url: string) {
|
|
357
|
+
return this.runWithBrowserRecovery('visit', () => this.visitOnce(url));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async visitOnce(url: string) {
|
|
299
361
|
await this.closeOtherTabs();
|
|
300
362
|
|
|
301
363
|
const serializedUrl = JSON.stringify(url);
|
|
@@ -338,12 +400,14 @@ class Explorer {
|
|
|
338
400
|
}
|
|
339
401
|
|
|
340
402
|
async annotateElements(): Promise<WebElement[]> {
|
|
341
|
-
|
|
342
|
-
|
|
403
|
+
return this.runWithBrowserRecovery('annotateElements', async () => {
|
|
404
|
+
const { elements } = await annotatePageElements(this.playwrightHelper.page);
|
|
405
|
+
return elements;
|
|
406
|
+
});
|
|
343
407
|
}
|
|
344
408
|
|
|
345
409
|
async visuallyAnnotateElements(opts?: { containers?: Array<{ css: string; label: string }> }): Promise<number> {
|
|
346
|
-
return visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []);
|
|
410
|
+
return this.runWithBrowserRecovery('visuallyAnnotateElements', () => visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []));
|
|
347
411
|
}
|
|
348
412
|
|
|
349
413
|
async getEidxInContainer(containerCss: string | null): Promise<string[]> {
|
|
@@ -386,28 +450,99 @@ class Explorer {
|
|
|
386
450
|
await this.playwrightHelper.page.reload();
|
|
387
451
|
}
|
|
388
452
|
|
|
453
|
+
private resolveBrowserUrl(url?: string): string | null {
|
|
454
|
+
if (!url) return null;
|
|
455
|
+
try {
|
|
456
|
+
return new URL(url).toString();
|
|
457
|
+
} catch {}
|
|
458
|
+
|
|
459
|
+
const baseUrl = this.config.playwright?.url || this.config.web?.url;
|
|
460
|
+
if (!baseUrl) return null;
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
return new URL(url, baseUrl).toString();
|
|
464
|
+
} catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
389
469
|
isFatalBrowserError(error: unknown): boolean {
|
|
390
|
-
|
|
391
|
-
return FATAL_BROWSER_ERRORS.test(msg);
|
|
470
|
+
return isFatalBrowserError(error);
|
|
392
471
|
}
|
|
393
472
|
|
|
394
473
|
async recoverFromBrowserError(): Promise<boolean> {
|
|
395
474
|
try {
|
|
396
|
-
|
|
475
|
+
if (!this.playwrightHelper?.page || this.playwrightHelper.page.isClosed?.()) {
|
|
476
|
+
const context = this.playwrightHelper?.browserContext;
|
|
477
|
+
if (!context) return await this.restartBrowser();
|
|
478
|
+
const page = await context.newPage();
|
|
479
|
+
await page.bringToFront();
|
|
480
|
+
await this.playwrightHelper._setPage(page);
|
|
481
|
+
this.bindFrameNavigated(page);
|
|
482
|
+
if (this.xhrCapture) {
|
|
483
|
+
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
397
488
|
if (url) {
|
|
398
489
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
399
490
|
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
400
|
-
return
|
|
491
|
+
return this.waitForUsablePageDom();
|
|
401
492
|
}
|
|
402
493
|
tag('warning').log('Browser error detected, reloading page');
|
|
403
494
|
await this.playwrightHelper.page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
404
|
-
return
|
|
495
|
+
return this.waitForUsablePageDom();
|
|
405
496
|
} catch (err) {
|
|
406
497
|
tag('error').log(`Browser recovery failed: ${err instanceof Error ? err.message : err}`);
|
|
407
498
|
return false;
|
|
408
499
|
}
|
|
409
500
|
}
|
|
410
501
|
|
|
502
|
+
async restartBrowser(): Promise<boolean> {
|
|
503
|
+
if (!this.playwrightHelper) return false;
|
|
504
|
+
|
|
505
|
+
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
if (this.xhrCapture && this.playwrightHelper.page) {
|
|
509
|
+
this.xhrCapture.detach(this.playwrightHelper.page);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
await this.playwrightRecorder.stop();
|
|
513
|
+
|
|
514
|
+
if (this.playwrightHelper.browserContext) {
|
|
515
|
+
await this.playwrightHelper.browserContext.close().catch((err: unknown) => {
|
|
516
|
+
debugLog('Failed to close browser context before restart:', err);
|
|
517
|
+
});
|
|
518
|
+
this.playwrightHelper.browserContext = null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!this.isSharedBrowser) {
|
|
522
|
+
await this.playwrightHelper._stopBrowser().catch((err: unknown) => {
|
|
523
|
+
debugLog('Failed to stop browser before restart:', err);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
await this.connectOrLaunchBrowser();
|
|
528
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
529
|
+
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
530
|
+
this.setupXhrCapture(true);
|
|
531
|
+
this.listenToStateChanged();
|
|
532
|
+
|
|
533
|
+
if (url) {
|
|
534
|
+
await this.playwrightHelper.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
535
|
+
if (!(await this.waitForUsablePageDom())) return false;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
tag('success').log('Browser restarted');
|
|
539
|
+
return true;
|
|
540
|
+
} catch (err) {
|
|
541
|
+
tag('error').log(`Browser restart failed: ${err instanceof Error ? err.message : err}`);
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
411
546
|
async switchToMainFrame() {
|
|
412
547
|
if (this.playwrightHelper.frame) {
|
|
413
548
|
debugLog('Switching to main frame');
|
|
@@ -415,6 +550,30 @@ class Explorer {
|
|
|
415
550
|
}
|
|
416
551
|
}
|
|
417
552
|
|
|
553
|
+
private async waitForUsablePageDom(): Promise<boolean> {
|
|
554
|
+
const page = this.playwrightHelper?.page;
|
|
555
|
+
if (!page) return false;
|
|
556
|
+
|
|
557
|
+
await page.waitForLoadState?.('domcontentloaded', { timeout: 5000 }).catch(() => {});
|
|
558
|
+
if (page.waitForFunction) {
|
|
559
|
+
const hasUsableDom = await page
|
|
560
|
+
.waitForFunction(
|
|
561
|
+
() => {
|
|
562
|
+
const body = document.body;
|
|
563
|
+
if (!body) return false;
|
|
564
|
+
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
565
|
+
},
|
|
566
|
+
undefined,
|
|
567
|
+
{ timeout: 5000 }
|
|
568
|
+
)
|
|
569
|
+
.then(() => true)
|
|
570
|
+
.catch(() => false);
|
|
571
|
+
if (!hasUsableDom) return false;
|
|
572
|
+
}
|
|
573
|
+
await page.waitForLoadState?.('networkidle', { timeout: 3000 }).catch(() => {});
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
418
577
|
async isInsideIframe(): Promise<boolean> {
|
|
419
578
|
if (this.playwrightHelper.frame) return true;
|
|
420
579
|
|
|
@@ -542,11 +701,13 @@ class Explorer {
|
|
|
542
701
|
return this._activeTest;
|
|
543
702
|
}
|
|
544
703
|
|
|
545
|
-
async startTest(test: Test) {
|
|
704
|
+
async startTest(test: Test): Promise<boolean> {
|
|
546
705
|
this._activeTest = test;
|
|
706
|
+
test.start();
|
|
547
707
|
await this.reporter.reportTestStart(test);
|
|
548
708
|
await this.closeOtherTabs();
|
|
549
709
|
this.otherTabs = [];
|
|
710
|
+
if (!(await this.ensurePageAvailable())) return false;
|
|
550
711
|
|
|
551
712
|
const codeceptjsTest = toCodeceptjsTest(test);
|
|
552
713
|
|
|
@@ -563,13 +724,7 @@ class Explorer {
|
|
|
563
724
|
test.setActiveNoteScreenshot(lastScreenshot);
|
|
564
725
|
};
|
|
565
726
|
|
|
566
|
-
|
|
567
|
-
const dialogType = dialog.type();
|
|
568
|
-
const dialogMessage = dialog.message();
|
|
569
|
-
test.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
this.playwrightHelper?.page?.on('dialog', dialogHandler);
|
|
727
|
+
this.watchActiveTestPage();
|
|
573
728
|
|
|
574
729
|
codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
|
|
575
730
|
codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
|
|
@@ -580,11 +735,105 @@ class Explorer {
|
|
|
580
735
|
codeceptjs.event.dispatcher.on('test.after', () => {
|
|
581
736
|
codeceptjs.event.dispatcher.off('step.passed', stepHandler);
|
|
582
737
|
codeceptjs.event.dispatcher.off('step.failed', stepHandler);
|
|
583
|
-
this.
|
|
738
|
+
this.unwatchActiveTestPages();
|
|
584
739
|
});
|
|
740
|
+
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async ensurePageAvailable(): Promise<boolean> {
|
|
745
|
+
const page = this.playwrightHelper?.page;
|
|
746
|
+
if (page && !page.isClosed?.()) {
|
|
747
|
+
this.watchActiveTestPage(page);
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const recovered = await this.recoverFromBrowserError();
|
|
752
|
+
if (!recovered) return false;
|
|
753
|
+
this.watchActiveTestPage();
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async ensureActiveTestPageAvailable(): Promise<boolean> {
|
|
758
|
+
return this.ensurePageAvailable();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async handleExecutionError(error: unknown): Promise<BrowserExecutionErrorResult> {
|
|
762
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
763
|
+
tag('error').log(`Browser execution error: ${message}`);
|
|
764
|
+
|
|
765
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
766
|
+
return {
|
|
767
|
+
action: 'stop',
|
|
768
|
+
message,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (error instanceof BrowserRecoveryError) {
|
|
773
|
+
return {
|
|
774
|
+
action: 'stop',
|
|
775
|
+
recovered: error.recovered,
|
|
776
|
+
message: error.message,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (!this.isFatalBrowserError(error)) {
|
|
781
|
+
return {
|
|
782
|
+
action: 'continue',
|
|
783
|
+
message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
let recovered = await this.recoverFromBrowserError();
|
|
788
|
+
if (!recovered) recovered = await this.restartBrowser();
|
|
789
|
+
|
|
790
|
+
if (!recovered) {
|
|
791
|
+
return {
|
|
792
|
+
action: 'stop',
|
|
793
|
+
recovered: false,
|
|
794
|
+
message: `Browser could not be recovered after fatal error: ${message}`,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
this.watchActiveTestPage();
|
|
799
|
+
return {
|
|
800
|
+
action: 'continue',
|
|
801
|
+
recovered: true,
|
|
802
|
+
message: dedent`
|
|
803
|
+
Browser was recovered after a fatal page error.
|
|
804
|
+
Continue from the restored page.
|
|
805
|
+
The interrupted browser action is not product evidence.
|
|
806
|
+
Inspect the restored page and retry the current step when it is still required.
|
|
807
|
+
`,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
watchActiveTestPage(page = this.playwrightHelper?.page): void {
|
|
812
|
+
if (!this._activeTest) return;
|
|
813
|
+
if (!page) return;
|
|
814
|
+
if (this.observedTestPages.has(page)) return;
|
|
815
|
+
|
|
816
|
+
this.testPageErrorHandler ||= (err: Error) => {
|
|
817
|
+
this._activeTest?.addNote(`Console error: ${err.message}`, TestResult.FAILED);
|
|
818
|
+
};
|
|
819
|
+
this.testConsoleHandler ||= (msg: any) => {
|
|
820
|
+
if (msg.type() !== 'error') return;
|
|
821
|
+
this._activeTest?.addNote(`Console error: ${msg.text()}`, TestResult.FAILED);
|
|
822
|
+
};
|
|
823
|
+
this.testDialogHandler ||= (dialog: any) => {
|
|
824
|
+
const dialogType = dialog.type();
|
|
825
|
+
const dialogMessage = dialog.message();
|
|
826
|
+
this._activeTest?.addNote(`Native dialog ${dialogType} appeared: ${dialogMessage}. Accepted automatically`);
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
page.on('pageerror', this.testPageErrorHandler);
|
|
830
|
+
page.on('console', this.testConsoleHandler);
|
|
831
|
+
page.on('dialog', this.testDialogHandler);
|
|
832
|
+
this.observedTestPages.add(page);
|
|
585
833
|
}
|
|
586
834
|
|
|
587
835
|
async stopTest(test: Test, meta?: Record<string, string>) {
|
|
836
|
+
this.unwatchActiveTestPages();
|
|
588
837
|
this._activeTest = null;
|
|
589
838
|
const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
|
|
590
839
|
if (lastScreenshot) {
|
|
@@ -609,6 +858,15 @@ class Explorer {
|
|
|
609
858
|
codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
|
|
610
859
|
}
|
|
611
860
|
|
|
861
|
+
private unwatchActiveTestPages(): void {
|
|
862
|
+
for (const page of this.observedTestPages) {
|
|
863
|
+
if (this.testPageErrorHandler) page.off('pageerror', this.testPageErrorHandler);
|
|
864
|
+
if (this.testConsoleHandler) page.off('console', this.testConsoleHandler);
|
|
865
|
+
if (this.testDialogHandler) page.off('dialog', this.testDialogHandler);
|
|
866
|
+
}
|
|
867
|
+
this.observedTestPages.clear();
|
|
868
|
+
}
|
|
869
|
+
|
|
612
870
|
async hasPlaywrightLocator(locatorFn: (page: any) => any, opts: { multiple?: boolean; contents?: boolean; success?: (locator: any) => Promise<void> | void } = {}): Promise<boolean> {
|
|
613
871
|
try {
|
|
614
872
|
const pwLocator = locatorFn(this.playwrightHelper.page);
|
|
@@ -671,7 +929,7 @@ class Explorer {
|
|
|
671
929
|
await oldPage.close();
|
|
672
930
|
await newPage.bringToFront();
|
|
673
931
|
|
|
674
|
-
this.playwrightHelper.
|
|
932
|
+
await this.playwrightHelper._setPage(newPage);
|
|
675
933
|
this.otherTabs = [];
|
|
676
934
|
|
|
677
935
|
this.bindFrameNavigated(newPage);
|
|
@@ -705,8 +963,7 @@ class Explorer {
|
|
|
705
963
|
}
|
|
706
964
|
|
|
707
965
|
await firstPage.bringToFront();
|
|
708
|
-
|
|
709
|
-
this.playwrightHelper.page = firstPage;
|
|
966
|
+
await this.playwrightHelper._setPage(firstPage);
|
|
710
967
|
|
|
711
968
|
debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
|
|
712
969
|
}
|
package/src/state-manager.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface WebPageState {
|
|
|
24
24
|
url: string;
|
|
25
25
|
/** Page title */
|
|
26
26
|
title?: string;
|
|
27
|
+
/** HTTP status of the main document navigation */
|
|
28
|
+
httpStatus?: number;
|
|
27
29
|
/** Full URL for reference */
|
|
28
30
|
fullUrl?: string;
|
|
29
31
|
/** Timestamp when state was captured */
|
package/src/stats.ts
CHANGED
|
@@ -54,6 +54,24 @@ export class Stats {
|
|
|
54
54
|
return String(num);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
static modelsTable(roleModels: Record<string, string>): string {
|
|
58
|
+
const usedModels = Object.entries(Stats.models).filter(([, tokens]) => tokens.total > 0);
|
|
59
|
+
if (usedModels.length === 0) return '';
|
|
60
|
+
|
|
61
|
+
const rolesByModel: Record<string, string[]> = {};
|
|
62
|
+
for (const [role, model] of Object.entries(roleModels)) {
|
|
63
|
+
if (!rolesByModel[model]) rolesByModel[model] = [];
|
|
64
|
+
rolesByModel[model].push(role);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rows = usedModels.map(([model, tokens]) => {
|
|
68
|
+
const roles = rolesByModel[model]?.join(', ') || '-';
|
|
69
|
+
return `| ${roles} | ${model} | ${Stats.humanizeTokens(tokens.total)} |`;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return ['## Models', '', '| Role | Model | Tokens |', '| --- | --- | --- |', ...rows].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
static hasActivity(): boolean {
|
|
58
76
|
if (Stats.tests > 0 || Stats.plans > 0 || Stats.researches > 0) return true;
|
|
59
77
|
const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);
|
package/src/utils/aria.ts
CHANGED
|
@@ -334,6 +334,54 @@ const detectRenames = (prev: FlatEntry[], curr: FlatEntry[], prevTotals: Map<str
|
|
|
334
334
|
return { added, removed };
|
|
335
335
|
};
|
|
336
336
|
|
|
337
|
+
// Interactive controls keep a stable role+name across a state flip; only an ARIA state
|
|
338
|
+
// attribute changes. Report those flips on their own line so the model always sees
|
|
339
|
+
// "now checked / now collapsed", in both directions, regardless of other page churn.
|
|
340
|
+
const STATE_WORDS: Record<string, { on: string; off: string }> = {
|
|
341
|
+
checked: { on: 'checked', off: 'unchecked' },
|
|
342
|
+
selected: { on: 'selected', off: 'unselected' },
|
|
343
|
+
pressed: { on: 'pressed', off: 'unpressed' },
|
|
344
|
+
expanded: { on: 'expanded', off: 'collapsed' },
|
|
345
|
+
};
|
|
346
|
+
const STATE_ATTRS = Object.keys(STATE_WORDS);
|
|
347
|
+
|
|
348
|
+
const stateWord = (attr: string, value: unknown): string => {
|
|
349
|
+
if (attr === 'checked' && value === 'mixed') return 'partially checked';
|
|
350
|
+
const words = STATE_WORDS[attr];
|
|
351
|
+
if (value === true || value === 'true') return words.on;
|
|
352
|
+
return words.off;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// Pair entries by path; when role and name match but a state attr differs, it's a toggle.
|
|
356
|
+
const detectToggles = (prev: FlatEntry[], curr: FlatEntry[]): { toggled: string[]; togglePaths: Set<string> } => {
|
|
357
|
+
const toggled: string[] = [];
|
|
358
|
+
const togglePaths = new Set<string>();
|
|
359
|
+
const currByPath = new Map(curr.map((e) => [e.path, e]));
|
|
360
|
+
|
|
361
|
+
for (const before of prev) {
|
|
362
|
+
const after = currByPath.get(before.path);
|
|
363
|
+
if (!after) continue;
|
|
364
|
+
if (before.entry.role !== after.entry.role) continue;
|
|
365
|
+
if (before.entry.name !== after.entry.name) continue;
|
|
366
|
+
|
|
367
|
+
const transitions: string[] = [];
|
|
368
|
+
for (const attr of STATE_ATTRS) {
|
|
369
|
+
const was = stateWord(attr, before.entry[attr]);
|
|
370
|
+
const now = stateWord(attr, after.entry[attr]);
|
|
371
|
+
if (was === now) continue;
|
|
372
|
+
transitions.push(`${was} -> ${now}`);
|
|
373
|
+
}
|
|
374
|
+
if (transitions.length === 0) continue;
|
|
375
|
+
|
|
376
|
+
togglePaths.add(before.path);
|
|
377
|
+
let label = String(after.entry.role);
|
|
378
|
+
const name = after.entry.name;
|
|
379
|
+
if (typeof name === 'string' && name.trim()) label += ` "${name.trim()}"`;
|
|
380
|
+
toggled.push(`${label}: ${transitions.join(', ')}`);
|
|
381
|
+
}
|
|
382
|
+
return { toggled, togglePaths };
|
|
383
|
+
};
|
|
384
|
+
|
|
337
385
|
const TOP_DIFF_ITEMS = 10;
|
|
338
386
|
|
|
339
387
|
const formatDiffSection = (label: string, items: string[]): string[] => {
|
|
@@ -358,9 +406,15 @@ const formatDiffSection = (label: string, items: string[]): string[] => {
|
|
|
358
406
|
return lines;
|
|
359
407
|
};
|
|
360
408
|
|
|
361
|
-
const formatDiff = (added: string[], removed: string[]): string | null => {
|
|
362
|
-
if (added.length === 0 && removed.length === 0) return null;
|
|
363
|
-
|
|
409
|
+
const formatDiff = (added: string[], removed: string[], toggled: string[]): string | null => {
|
|
410
|
+
if (added.length === 0 && removed.length === 0 && toggled.length === 0) return null;
|
|
411
|
+
const sections = ['ariaDiff:'];
|
|
412
|
+
if (toggled.length > 0) {
|
|
413
|
+
sections.push(' toggled:');
|
|
414
|
+
for (const line of toggled) sections.push(` - ${line}`);
|
|
415
|
+
}
|
|
416
|
+
sections.push(...formatDiffSection('added', added), ...formatDiffSection('removed', removed));
|
|
417
|
+
return sections.join('\n');
|
|
364
418
|
};
|
|
365
419
|
|
|
366
420
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -437,13 +491,16 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
|
|
|
437
491
|
tree = dropEmpty(tree);
|
|
438
492
|
return flatten(tree);
|
|
439
493
|
};
|
|
440
|
-
const
|
|
441
|
-
const
|
|
494
|
+
const prevAll = flat(previous);
|
|
495
|
+
const currAll = flat(current);
|
|
496
|
+
const { toggled, togglePaths } = detectToggles(prevAll, currAll);
|
|
497
|
+
const prev = prevAll.filter((e) => !togglePaths.has(e.path));
|
|
498
|
+
const curr = currAll.filter((e) => !togglePaths.has(e.path));
|
|
442
499
|
const prevTotals = countBy(prev.map((e) => e.summary));
|
|
443
500
|
const currTotals = countBy(curr.map((e) => e.summary));
|
|
444
501
|
const byCount = diffByCount(prevTotals, currTotals);
|
|
445
502
|
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
446
|
-
return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed]);
|
|
503
|
+
return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed], toggled);
|
|
447
504
|
};
|
|
448
505
|
|
|
449
506
|
export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
|