explorbot 0.2.0 → 0.2.2
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 +21 -5
- package/bin/explorbot-cli.ts +29 -7
- package/boat/api-tester/src/config.ts +45 -3
- package/boat/doc-collector/src/ai/documentarian.ts +8 -2
- package/boat/doc-collector/src/ai/tools.ts +29 -16
- package/boat/doc-collector/src/cli.ts +2 -6
- package/boat/doc-collector/src/config.ts +2 -0
- package/boat/doc-collector/src/docbot.ts +42 -14
- package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
- package/boat/doc-collector/src/screenshots.ts +24 -16
- package/dist/bin/explorbot-cli.js +26 -6
- package/dist/boat/api-tester/src/config.js +43 -4
- package/dist/boat/doc-collector/src/ai/documentarian.js +7 -2
- package/dist/boat/doc-collector/src/ai/tools.js +21 -15
- package/dist/boat/doc-collector/src/cli.js +2 -5
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +39 -13
- package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
- package/dist/boat/doc-collector/src/screenshots.js +25 -16
- package/dist/models.json +30 -0
- package/dist/package.json +8 -2
- package/dist/src/action.d.ts +11 -3
- package/dist/src/action.js +22 -5
- package/dist/src/ai/agent.d.ts +17 -0
- package/dist/src/ai/captain/idle-mode.js +1 -1
- package/dist/src/ai/captain/test-mode.js +1 -1
- package/dist/src/ai/captain/web-mode.js +18 -22
- package/dist/src/ai/captain.js +13 -26
- package/dist/src/ai/driller.d.ts +2 -13
- package/dist/src/ai/driller.js +23 -38
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.d.ts +3 -2
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/historian/screencast.d.ts +2 -1
- package/dist/src/ai/historian/screencast.js +2 -2
- package/dist/src/ai/historian.d.ts +5 -4
- package/dist/src/ai/navigator.d.ts +6 -2
- package/dist/src/ai/navigator.js +26 -22
- package/dist/src/ai/pilot.d.ts +13 -6
- package/dist/src/ai/pilot.js +49 -44
- package/dist/src/ai/planner.d.ts +3 -2
- package/dist/src/ai/planner.js +19 -17
- package/dist/src/ai/provider.js +0 -1
- package/dist/src/ai/quartermaster.d.ts +3 -2
- package/dist/src/ai/quartermaster.js +4 -4
- package/dist/src/ai/rerunner.d.ts +2 -11
- package/dist/src/ai/rerunner.js +9 -22
- package/dist/src/ai/researcher/coordinates.js +4 -3
- package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
- package/dist/src/ai/researcher/deep-analysis.js +6 -6
- package/dist/src/ai/researcher/locators.d.ts +9 -2
- package/dist/src/ai/researcher/locators.js +65 -8
- package/dist/src/ai/researcher/sections.d.ts +2 -0
- package/dist/src/ai/researcher/sections.js +2 -2
- package/dist/src/ai/researcher.js +29 -54
- package/dist/src/ai/task-agent.d.ts +18 -5
- package/dist/src/ai/task-agent.js +35 -6
- package/dist/src/ai/tester.d.ts +6 -12
- package/dist/src/ai/tester.js +37 -51
- package/dist/src/ai/tools.d.ts +11 -10
- package/dist/src/ai/tools.js +24 -36
- package/dist/src/command-handler.js +1 -1
- package/dist/src/commands/context-aria-command.js +1 -1
- package/dist/src/commands/context-command.js +7 -5
- package/dist/src/commands/context-data-command.js +2 -2
- package/dist/src/commands/context-experience-command.js +2 -2
- package/dist/src/commands/context-html-command.js +2 -2
- package/dist/src/commands/context-knowledge-command.js +1 -1
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.js +4 -3
- package/dist/src/commands/freesail-command.js +2 -2
- package/dist/src/commands/learn-command.js +4 -4
- package/dist/src/commands/path-command.js +1 -1
- package/dist/src/commands/research-command.js +1 -1
- package/dist/src/commands/test-command.js +1 -1
- package/dist/src/components/App.js +1 -1
- package/dist/src/config.d.ts +17 -0
- package/dist/src/config.js +144 -10
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.d.ts +17 -8
- package/dist/src/explorbot.js +74 -36
- package/dist/src/explorer.d.ts +67 -91
- package/dist/src/explorer.js +327 -567
- package/dist/src/state-manager.d.ts +6 -1
- package/dist/src/state-manager.js +1 -0
- package/dist/src/utils/hooks-runner.js +2 -4
- package/dist/src/utils/test-files.js +1 -1
- package/dist/src/utils/web-annotate.d.ts +5 -0
- package/dist/src/utils/web-annotate.js +58 -0
- package/dist/src/utils/web-eidx.d.ts +2 -0
- package/dist/src/utils/web-eidx.js +20 -0
- package/dist/src/utils/web-element.d.ts +1 -0
- package/dist/src/utils/web-element.js +29 -0
- package/models.json +30 -0
- package/package.json +8 -2
- package/src/action.ts +25 -5
- package/src/ai/agent.ts +20 -0
- package/src/ai/captain/idle-mode.ts +1 -1
- package/src/ai/captain/test-mode.ts +1 -1
- package/src/ai/captain/web-mode.ts +18 -23
- package/src/ai/captain.ts +12 -25
- package/src/ai/driller.ts +50 -67
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +4 -3
- package/src/ai/historian/screencast.ts +4 -3
- package/src/ai/historian.ts +3 -2
- package/src/ai/navigator.ts +30 -25
- package/src/ai/pilot.ts +55 -47
- package/src/ai/planner.ts +22 -20
- package/src/ai/provider.ts +0 -1
- package/src/ai/quartermaster.ts +5 -4
- package/src/ai/rerunner.ts +10 -29
- package/src/ai/researcher/coordinates.ts +4 -3
- package/src/ai/researcher/deep-analysis.ts +8 -6
- package/src/ai/researcher/locators.ts +66 -9
- package/src/ai/researcher/sections.ts +4 -2
- package/src/ai/researcher.ts +32 -61
- package/src/ai/task-agent.ts +45 -11
- package/src/ai/tester.ts +40 -57
- package/src/ai/tools.ts +32 -53
- package/src/command-handler.ts +1 -1
- package/src/commands/context-aria-command.ts +1 -1
- package/src/commands/context-command.ts +7 -5
- package/src/commands/context-data-command.ts +2 -2
- package/src/commands/context-experience-command.ts +2 -2
- package/src/commands/context-html-command.ts +2 -2
- package/src/commands/context-knowledge-command.ts +1 -1
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +4 -3
- package/src/commands/freesail-command.ts +2 -2
- package/src/commands/learn-command.ts +4 -4
- package/src/commands/path-command.ts +1 -1
- package/src/commands/research-command.ts +1 -1
- package/src/commands/test-command.ts +1 -1
- package/src/components/App.tsx +1 -1
- package/src/config.ts +173 -11
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +78 -39
- package/src/explorer.ts +388 -626
- package/src/state-manager.ts +7 -1
- package/src/utils/hooks-runner.ts +2 -4
- package/src/utils/test-files.ts +1 -1
- package/src/utils/web-annotate.ts +64 -0
- package/src/utils/web-eidx.ts +21 -0
- package/src/utils/web-element.ts +30 -0
package/dist/src/explorer.js
CHANGED
|
@@ -8,65 +8,196 @@ import { createTest } from 'codeceptjs/lib/mocha/test';
|
|
|
8
8
|
import dedent from 'dedent';
|
|
9
9
|
import { ActionResult } from "./action-result.js";
|
|
10
10
|
import Action from './action.js';
|
|
11
|
-
import { visuallyAnnotateContainers } from "./ai/researcher/coordinates.js";
|
|
12
|
-
import { RequestStore } from "./api/request-store.js";
|
|
13
11
|
import { XhrCapture } from "./api/xhr-capture.js";
|
|
14
12
|
import { ConfigParser } from './config.js';
|
|
15
|
-
import { PlaywrightRecorder } from "./playwright-recorder.js";
|
|
16
|
-
import { Reporter } from "./reporter.js";
|
|
17
|
-
import { StateManager } from './state-manager.js';
|
|
18
13
|
import { TestResult } from "./test-plan.js";
|
|
19
14
|
import { BrowserRecoveryError, browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
|
|
20
|
-
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./utils/html.js";
|
|
21
15
|
import { createDebug, log, tag } from './utils/logger.js';
|
|
22
16
|
import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
|
|
23
|
-
import { WebElement } from "./utils/web-element.js";
|
|
24
17
|
const debugLog = createDebug('explorbot:explorer');
|
|
25
18
|
const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
|
|
19
|
+
const RECOVERY_NAVIGATION = { waitUntil: 'domcontentloaded', timeout: 10000 };
|
|
26
20
|
class Explorer {
|
|
27
|
-
aiProvider;
|
|
28
|
-
playwrightHelper;
|
|
29
|
-
isStarted = false;
|
|
30
|
-
isSharedBrowser = false;
|
|
31
|
-
actor;
|
|
32
|
-
stateManager;
|
|
33
|
-
knowledgeTracker;
|
|
34
21
|
config;
|
|
35
22
|
options;
|
|
23
|
+
stateManager;
|
|
24
|
+
knowledgeTracker;
|
|
36
25
|
reporter;
|
|
37
|
-
|
|
38
|
-
|
|
26
|
+
requestStore;
|
|
27
|
+
playwrightRecorder;
|
|
28
|
+
playwrightHelper;
|
|
29
|
+
_actor;
|
|
30
|
+
started = false;
|
|
31
|
+
isSharedBrowser = false;
|
|
39
32
|
xhrCapture = null;
|
|
40
|
-
|
|
41
|
-
playwrightRecorder = new PlaywrightRecorder();
|
|
33
|
+
_activeTest = null;
|
|
42
34
|
observedTestPages = new Set();
|
|
43
35
|
testPageErrorHandler = null;
|
|
44
36
|
testConsoleHandler = null;
|
|
45
37
|
testDialogHandler = null;
|
|
46
|
-
|
|
38
|
+
eventDispatcher = codeceptjs.event.dispatcher;
|
|
39
|
+
constructor(config, options, deps) {
|
|
47
40
|
this.config = config;
|
|
48
|
-
this.aiProvider = aiProvider;
|
|
49
41
|
this.options = options;
|
|
42
|
+
this.stateManager = deps.stateManager;
|
|
43
|
+
this.knowledgeTracker = deps.knowledgeTracker;
|
|
44
|
+
this.reporter = deps.reporter;
|
|
45
|
+
this.requestStore = deps.requestStore;
|
|
46
|
+
this.playwrightRecorder = deps.playwrightRecorder;
|
|
50
47
|
this.initializeContainer();
|
|
51
|
-
this.knowledgeTracker = knowledgeTracker;
|
|
52
|
-
this.stateManager = new StateManager(experienceTracker, knowledgeTracker);
|
|
53
|
-
this.reporter = new Reporter(config.reporter, this.stateManager);
|
|
54
48
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
49
|
+
get actor() {
|
|
50
|
+
return this._actor;
|
|
51
|
+
}
|
|
52
|
+
get page() {
|
|
53
|
+
const page = this.playwrightHelper?.page;
|
|
54
|
+
if (!page || page.isClosed?.())
|
|
55
|
+
return null;
|
|
56
|
+
return page;
|
|
57
|
+
}
|
|
58
|
+
get activeTest() {
|
|
59
|
+
return this._activeTest;
|
|
60
|
+
}
|
|
61
|
+
async start() {
|
|
62
|
+
if (this.started)
|
|
63
|
+
return;
|
|
64
|
+
await codeceptjs.recorder.start();
|
|
65
|
+
await codeceptjs.container.started(null);
|
|
66
|
+
storeListener();
|
|
67
|
+
stepsListener();
|
|
68
|
+
codeceptjs.recorder.retry({
|
|
69
|
+
retries: this.config.action?.retries || 3,
|
|
70
|
+
when: (err) => !!err?.message?.includes?.('context'),
|
|
71
|
+
});
|
|
72
|
+
this.playwrightHelper = codeceptjs.container.helpers('Playwright');
|
|
73
|
+
if (!this.playwrightHelper) {
|
|
74
|
+
throw new Error('Playwright helper not available');
|
|
65
75
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
await this.connectOrLaunchBrowser();
|
|
77
|
+
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
78
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
79
|
+
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
80
|
+
this.attachXhrCapture();
|
|
81
|
+
if (hasSession) {
|
|
82
|
+
tag('info').log(`Session restored from ${path.relative(process.cwd(), this.options.session)}`);
|
|
83
|
+
}
|
|
84
|
+
this._actor = codeceptjs.container.support('I');
|
|
85
|
+
this.started = true;
|
|
86
|
+
this.listenToStateChanged();
|
|
87
|
+
codeceptjs.event.dispatcher.emit('global.before');
|
|
88
|
+
tag('success').log('Browser started, ready to explore');
|
|
89
|
+
}
|
|
90
|
+
async stop() {
|
|
91
|
+
if (!this.started)
|
|
92
|
+
return;
|
|
93
|
+
this.started = false;
|
|
94
|
+
await this.stopCaptures();
|
|
95
|
+
if (this.options?.session && this.playwrightHelper?.browserContext) {
|
|
96
|
+
const dir = path.dirname(this.options.session);
|
|
97
|
+
if (!existsSync(dir))
|
|
98
|
+
mkdirSync(dir, { recursive: true });
|
|
99
|
+
await this.playwrightHelper.browserContext.storageState({ path: this.options.session });
|
|
100
|
+
debugLog(`Session saved to ${path.relative(process.cwd(), this.options.session)}`);
|
|
69
101
|
}
|
|
102
|
+
codeceptjs.event.dispatcher.emit('global.after');
|
|
103
|
+
codeceptjs.event.dispatcher.emit('global.result');
|
|
104
|
+
if (!this.isSharedBrowser) {
|
|
105
|
+
await Promise.all([this.reporter.finishRun(), this.playwrightHelper._stopBrowser(), codeceptjs.recorder.stop()]);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
tag('info').log('Closing browser context (persistent browser stays running)');
|
|
109
|
+
await this.closeBrowserContext();
|
|
110
|
+
this.playwrightHelper.browser = null;
|
|
111
|
+
this.playwrightHelper.isRunning = false;
|
|
112
|
+
await Promise.all([this.reporter.finishRun(), codeceptjs.recorder.stop()]);
|
|
113
|
+
}
|
|
114
|
+
action() {
|
|
115
|
+
return new Action(this._actor, this.stateManager, this.playwrightRecorder, (fn) => this.runWithRecovery('action', fn));
|
|
116
|
+
}
|
|
117
|
+
async visit(url, opts = {}) {
|
|
118
|
+
return this.runWithRecovery('visit', async () => {
|
|
119
|
+
const action = await this.visitOnce(url);
|
|
120
|
+
if (opts.screenshot)
|
|
121
|
+
return action.capturePageState({ includeScreenshot: true });
|
|
122
|
+
return action.getActionResult() ?? action.capturePageState();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async capture(opts = {}) {
|
|
126
|
+
return this.action().capturePageState({ includeScreenshot: opts.screenshot });
|
|
127
|
+
}
|
|
128
|
+
async withPage(fn) {
|
|
129
|
+
return this.runWithRecovery('page operation', () => fn(this.playwrightHelper.page));
|
|
130
|
+
}
|
|
131
|
+
async recover(error) {
|
|
132
|
+
if (error)
|
|
133
|
+
return this.recoverFromExecutionError(error);
|
|
134
|
+
if (this.page) {
|
|
135
|
+
this.watchActiveTestPage();
|
|
136
|
+
return { ok: true, action: 'continue', message: 'Page is available' };
|
|
137
|
+
}
|
|
138
|
+
if (!(await this.recoverOrRestart())) {
|
|
139
|
+
return { ok: false, action: 'stop', message: 'Browser page could not be recovered' };
|
|
140
|
+
}
|
|
141
|
+
this.watchActiveTestPage();
|
|
142
|
+
return { ok: true, action: 'continue', recovered: true, message: 'Browser page was recovered' };
|
|
143
|
+
}
|
|
144
|
+
async beginTest(test) {
|
|
145
|
+
if (!this.page && !(await this.recoverOrRestart())) {
|
|
146
|
+
return { started: false, stop: async () => { } };
|
|
147
|
+
}
|
|
148
|
+
this._activeTest = test;
|
|
149
|
+
test.start();
|
|
150
|
+
await this.reporter.reportTestStart(test);
|
|
151
|
+
await this.closeOtherTabs();
|
|
152
|
+
this.stateManager.otherTabs = [];
|
|
153
|
+
const codeceptjsTest = toCodeceptjsTest(test);
|
|
154
|
+
const stepHandler = (step, status, error, log) => {
|
|
155
|
+
if (!step.toCode)
|
|
156
|
+
return;
|
|
157
|
+
if (step?.name?.startsWith('grab'))
|
|
158
|
+
return;
|
|
159
|
+
if (step?.name?.startsWith('save'))
|
|
160
|
+
return;
|
|
161
|
+
test.addStep(step.toCode(), step.duration, status, error, log);
|
|
162
|
+
if (!this.stateManager.getCurrentState())
|
|
163
|
+
return;
|
|
164
|
+
const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()).screenshotFile;
|
|
165
|
+
test.setActiveNoteScreenshot(lastScreenshot);
|
|
166
|
+
};
|
|
167
|
+
this.watchActiveTestPage();
|
|
168
|
+
const onStepPassed = (step) => stepHandler(step, 'passed');
|
|
169
|
+
const onStepFailed = (step, error) => {
|
|
170
|
+
stepHandler(step, 'failed', error?.message || String(error), error?.stack);
|
|
171
|
+
};
|
|
172
|
+
const onTestAfter = () => {
|
|
173
|
+
this.eventDispatcher.off('step.passed', onStepPassed);
|
|
174
|
+
this.eventDispatcher.off('step.failed', onStepFailed);
|
|
175
|
+
this.eventDispatcher.off('test.after', onTestAfter);
|
|
176
|
+
this.unwatchActiveTestPages();
|
|
177
|
+
};
|
|
178
|
+
this.eventDispatcher.emit('test.before', codeceptjsTest);
|
|
179
|
+
this.eventDispatcher.emit('test.start', codeceptjsTest);
|
|
180
|
+
this.eventDispatcher.on('step.passed', onStepPassed);
|
|
181
|
+
this.eventDispatcher.on('step.failed', onStepFailed);
|
|
182
|
+
this.eventDispatcher.on('test.after', onTestAfter);
|
|
183
|
+
return { started: true, stop: (meta) => this.finishTest(test, meta) };
|
|
184
|
+
}
|
|
185
|
+
async openTab() {
|
|
186
|
+
const oldPage = this.playwrightHelper?.page;
|
|
187
|
+
if (!oldPage)
|
|
188
|
+
return;
|
|
189
|
+
await this.activateNewPage(oldPage.context());
|
|
190
|
+
await oldPage.close();
|
|
191
|
+
this.stateManager.otherTabs = [];
|
|
192
|
+
debugLog('Opened fresh tab, closed previous tab');
|
|
193
|
+
}
|
|
194
|
+
initializeContainer() {
|
|
195
|
+
const configParser = ConfigParser.getInstance();
|
|
196
|
+
const projectRoot = configParser.getProjectRoot();
|
|
197
|
+
global.output_dir = configParser.getStatesDir();
|
|
198
|
+
global.codecept_dir = projectRoot;
|
|
199
|
+
configParser.validateConfig(this.config);
|
|
200
|
+
codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
|
|
70
201
|
}
|
|
71
202
|
convertToCodeceptConfig(config) {
|
|
72
203
|
const playwrightConfig = { ...config.playwright };
|
|
@@ -115,86 +246,6 @@ class Explorer {
|
|
|
115
246
|
}
|
|
116
247
|
return codeceptConfig;
|
|
117
248
|
}
|
|
118
|
-
getConfig() {
|
|
119
|
-
return this.config;
|
|
120
|
-
}
|
|
121
|
-
getAIProvider() {
|
|
122
|
-
return this.aiProvider;
|
|
123
|
-
}
|
|
124
|
-
getStateManager() {
|
|
125
|
-
return this.stateManager;
|
|
126
|
-
}
|
|
127
|
-
getKnowledgeTracker() {
|
|
128
|
-
return this.knowledgeTracker;
|
|
129
|
-
}
|
|
130
|
-
getReporter() {
|
|
131
|
-
return this.reporter;
|
|
132
|
-
}
|
|
133
|
-
getRequestStore() {
|
|
134
|
-
return this.requestStore;
|
|
135
|
-
}
|
|
136
|
-
async extractCookies() {
|
|
137
|
-
if (!this.playwrightHelper?.browserContext)
|
|
138
|
-
return {};
|
|
139
|
-
try {
|
|
140
|
-
const cookies = await this.playwrightHelper.browserContext.cookies();
|
|
141
|
-
if (!cookies.length)
|
|
142
|
-
return {};
|
|
143
|
-
const cookieString = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
144
|
-
return { Cookie: cookieString };
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
return {};
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
setupXhrCapture(reuseRequestStore = false) {
|
|
151
|
-
const configParser = ConfigParser.getInstance();
|
|
152
|
-
const outputDir = configParser.getOutputDir();
|
|
153
|
-
if (!reuseRequestStore || !this.requestStore) {
|
|
154
|
-
this.requestStore = new RequestStore(outputDir);
|
|
155
|
-
}
|
|
156
|
-
const baseUrl = this.config.playwright.url;
|
|
157
|
-
this.xhrCapture = new XhrCapture(this.requestStore, baseUrl);
|
|
158
|
-
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
159
|
-
}
|
|
160
|
-
async start() {
|
|
161
|
-
if (this.isStarted) {
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
await codeceptjs.recorder.start();
|
|
165
|
-
await codeceptjs.container.started(null);
|
|
166
|
-
storeListener();
|
|
167
|
-
stepsListener();
|
|
168
|
-
codeceptjs.recorder.retry({
|
|
169
|
-
retries: this.config.action?.retries || 3,
|
|
170
|
-
when: (err) => {
|
|
171
|
-
if (!err || typeof err.message !== 'string') {
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
// ignore context errors
|
|
175
|
-
return err.message.includes('context');
|
|
176
|
-
},
|
|
177
|
-
});
|
|
178
|
-
this.playwrightHelper = codeceptjs.container.helpers('Playwright');
|
|
179
|
-
if (!this.playwrightHelper) {
|
|
180
|
-
throw new Error('Playwright helper not available');
|
|
181
|
-
}
|
|
182
|
-
await this.connectOrLaunchBrowser();
|
|
183
|
-
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
184
|
-
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
185
|
-
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
186
|
-
this.setupXhrCapture();
|
|
187
|
-
if (hasSession) {
|
|
188
|
-
tag('info').log(`Session restored from ${path.relative(process.cwd(), this.options.session)}`);
|
|
189
|
-
}
|
|
190
|
-
const I = codeceptjs.container.support('I');
|
|
191
|
-
this.actor = I;
|
|
192
|
-
this.isStarted = true;
|
|
193
|
-
this.listenToStateChanged();
|
|
194
|
-
codeceptjs.event.dispatcher.emit('global.before');
|
|
195
|
-
tag('success').log('Browser started, ready to explore');
|
|
196
|
-
return I;
|
|
197
|
-
}
|
|
198
249
|
async connectOrLaunchBrowser() {
|
|
199
250
|
const { getAliveEndpoint } = await import('./browser-server.js');
|
|
200
251
|
const endpoint = await getAliveEndpoint();
|
|
@@ -223,11 +274,12 @@ class Explorer {
|
|
|
223
274
|
contextOptions.storageState = this.options.session;
|
|
224
275
|
return contextOptions;
|
|
225
276
|
}
|
|
226
|
-
|
|
227
|
-
|
|
277
|
+
attachXhrCapture() {
|
|
278
|
+
this.xhrCapture = new XhrCapture(this.requestStore, this.config.playwright.url);
|
|
279
|
+
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
228
280
|
}
|
|
229
|
-
async
|
|
230
|
-
if (!(await this.
|
|
281
|
+
async runWithRecovery(label, operation) {
|
|
282
|
+
if (!this.page && !(await this.recoverOrRestart())) {
|
|
231
283
|
throw new Error(`Browser page is unavailable before ${label}`);
|
|
232
284
|
}
|
|
233
285
|
try {
|
|
@@ -242,18 +294,15 @@ class Explorer {
|
|
|
242
294
|
return await operation();
|
|
243
295
|
}
|
|
244
296
|
catch (retryError) {
|
|
245
|
-
if (!isNavigationTransitionError(retryError) && !
|
|
297
|
+
if (!isNavigationTransitionError(retryError) && !isFatalBrowserError(retryError))
|
|
246
298
|
throw retryError;
|
|
247
299
|
recoveryError = retryError;
|
|
248
300
|
}
|
|
249
301
|
}
|
|
250
|
-
if (!
|
|
302
|
+
if (!isFatalBrowserError(recoveryError))
|
|
251
303
|
throw recoveryError;
|
|
252
304
|
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
253
|
-
|
|
254
|
-
if (!recovered)
|
|
255
|
-
recovered = await this.restartBrowser();
|
|
256
|
-
if (!recovered)
|
|
305
|
+
if (!(await this.recoverOrRestart()))
|
|
257
306
|
throw new BrowserRecoveryError(label, recoveryError, false);
|
|
258
307
|
if (!(await this.waitForPageReadiness()))
|
|
259
308
|
throw new BrowserRecoveryError(label, recoveryError, true);
|
|
@@ -261,38 +310,20 @@ class Explorer {
|
|
|
261
310
|
return await operation();
|
|
262
311
|
}
|
|
263
312
|
catch (retryError) {
|
|
264
|
-
if (
|
|
313
|
+
if (isFatalBrowserError(retryError)) {
|
|
265
314
|
throw new BrowserRecoveryError(label, retryError, true);
|
|
266
315
|
}
|
|
267
316
|
throw retryError;
|
|
268
317
|
}
|
|
269
318
|
}
|
|
270
319
|
}
|
|
271
|
-
async capturePageState(opts = {}) {
|
|
272
|
-
return this.runWithBrowserRecovery('capturePageState', () => this.createAction().capturePageState(opts));
|
|
273
|
-
}
|
|
274
|
-
async capturePageWithScreenshot() {
|
|
275
|
-
return this.capturePageState({ includeScreenshot: true });
|
|
276
|
-
}
|
|
277
|
-
async executeAction(code) {
|
|
278
|
-
return this.runWithBrowserRecovery('executeAction', () => this.createAction().execute(code));
|
|
279
|
-
}
|
|
280
|
-
async attemptAction(code, originalMessage) {
|
|
281
|
-
return this.runWithBrowserRecovery('attemptAction', () => this.createAction().attempt(code, originalMessage));
|
|
282
|
-
}
|
|
283
|
-
getPlaywrightRecorder() {
|
|
284
|
-
return this.playwrightRecorder;
|
|
285
|
-
}
|
|
286
|
-
async visit(url) {
|
|
287
|
-
return this.runWithBrowserRecovery('visit', () => this.visitOnce(url));
|
|
288
|
-
}
|
|
289
320
|
async visitOnce(url) {
|
|
290
321
|
await this.closeOtherTabs();
|
|
291
322
|
const serializedUrl = JSON.stringify(url);
|
|
292
323
|
const currentState = this.stateManager.getCurrentState();
|
|
293
324
|
const actionResult = currentState ? ActionResult.fromState(currentState) : new ActionResult({ url });
|
|
294
325
|
const { statePush = false, wait, waitForElement, code } = this.knowledgeTracker.getStateParameters(actionResult, ['statePush', 'wait', 'waitForElement', 'code']);
|
|
295
|
-
const action = this.
|
|
326
|
+
const action = new Action(this._actor, this.stateManager, this.playwrightRecorder);
|
|
296
327
|
if (statePush) {
|
|
297
328
|
await action.execute(`I.executeScript(() => { window.history.pushState({}, '', ${serializedUrl}); window.dispatchEvent(new PopStateEvent('popstate')); })`);
|
|
298
329
|
}
|
|
@@ -322,93 +353,59 @@ class Explorer {
|
|
|
322
353
|
}
|
|
323
354
|
return action;
|
|
324
355
|
}
|
|
325
|
-
async
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
async visuallyAnnotateElements(opts) {
|
|
332
|
-
return this.runWithBrowserRecovery('visuallyAnnotateElements', () => visuallyAnnotateContainers(this.playwrightHelper.page, opts?.containers || []));
|
|
333
|
-
}
|
|
334
|
-
async getEidxInContainer(containerCss) {
|
|
335
|
-
const page = this.playwrightHelper.page;
|
|
336
|
-
try {
|
|
337
|
-
const selector = containerCss ? `${containerCss} [${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]` : `[${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]`;
|
|
338
|
-
const elements = await page.locator(selector).all();
|
|
339
|
-
const result = [];
|
|
340
|
-
for (const el of elements) {
|
|
341
|
-
const attr = await el.getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
|
|
342
|
-
if (attr)
|
|
343
|
-
result.push(attr);
|
|
344
|
-
}
|
|
345
|
-
return result;
|
|
346
|
-
}
|
|
347
|
-
catch (error) {
|
|
348
|
-
if (this.isFatalBrowserError(error)) {
|
|
349
|
-
tag('warning').log(`getEidxInContainer: ${browserErrorMessage(error)}`);
|
|
350
|
-
await this.recoverFromBrowserError();
|
|
351
|
-
}
|
|
352
|
-
return [];
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
async getEidxByLocator(locator, container) {
|
|
356
|
-
try {
|
|
357
|
-
const page = this.playwrightHelper.page;
|
|
358
|
-
const base = container ? page.locator(container) : page;
|
|
359
|
-
const el = locator.startsWith('//') ? base.locator(`xpath=${locator}`) : base.locator(locator);
|
|
360
|
-
return await el.first().getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
|
|
361
|
-
}
|
|
362
|
-
catch (error) {
|
|
363
|
-
if (this.isFatalBrowserError(error)) {
|
|
364
|
-
tag('warning').log(`getEidxByLocator: ${browserErrorMessage(error)}`);
|
|
365
|
-
await this.recoverFromBrowserError();
|
|
366
|
-
}
|
|
367
|
-
return null;
|
|
356
|
+
async recoverFromExecutionError(error) {
|
|
357
|
+
const message = browserErrorMessage(error);
|
|
358
|
+
tag('error').log(`Browser execution error: ${message}`);
|
|
359
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
360
|
+
return { ok: false, action: 'stop', message };
|
|
368
361
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (!url)
|
|
372
|
-
return null;
|
|
373
|
-
try {
|
|
374
|
-
return new URL(url).toString();
|
|
362
|
+
if (error instanceof BrowserRecoveryError) {
|
|
363
|
+
return { ok: false, action: 'stop', recovered: error.recovered, message: error.message };
|
|
375
364
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
365
|
+
if (!isFatalBrowserError(error)) {
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
action: 'continue',
|
|
369
|
+
message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
|
|
370
|
+
};
|
|
382
371
|
}
|
|
383
|
-
|
|
384
|
-
return
|
|
372
|
+
if (!(await this.recoverOrRestart())) {
|
|
373
|
+
return { ok: false, action: 'stop', recovered: false, message: `Browser could not be recovered after fatal error: ${message}` };
|
|
385
374
|
}
|
|
375
|
+
this.watchActiveTestPage();
|
|
376
|
+
return {
|
|
377
|
+
ok: true,
|
|
378
|
+
action: 'continue',
|
|
379
|
+
recovered: true,
|
|
380
|
+
message: dedent `
|
|
381
|
+
Browser was recovered after a fatal page error.
|
|
382
|
+
Continue from the restored page.
|
|
383
|
+
The interrupted browser action is not product evidence.
|
|
384
|
+
Inspect the restored page and retry the current step when it is still required.
|
|
385
|
+
`,
|
|
386
|
+
};
|
|
386
387
|
}
|
|
387
|
-
|
|
388
|
-
|
|
388
|
+
async recoverOrRestart() {
|
|
389
|
+
if (await this.recoverPage())
|
|
390
|
+
return true;
|
|
391
|
+
return this.restartBrowser();
|
|
389
392
|
}
|
|
390
|
-
async
|
|
393
|
+
async recoverPage() {
|
|
391
394
|
try {
|
|
392
|
-
if (!this.
|
|
395
|
+
if (!this.page) {
|
|
393
396
|
const context = this.playwrightHelper?.browserContext;
|
|
394
397
|
if (!context)
|
|
395
|
-
return
|
|
396
|
-
|
|
397
|
-
await page.bringToFront();
|
|
398
|
-
await this.playwrightHelper._setPage(page);
|
|
399
|
-
this.bindFrameNavigated(page);
|
|
400
|
-
if (this.xhrCapture) {
|
|
401
|
-
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
402
|
-
}
|
|
398
|
+
return false;
|
|
399
|
+
await this.activateNewPage(context);
|
|
403
400
|
}
|
|
404
401
|
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
405
402
|
if (url) {
|
|
406
403
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
407
|
-
await this.playwrightHelper.page.goto(url,
|
|
404
|
+
await this.playwrightHelper.page.goto(url, RECOVERY_NAVIGATION);
|
|
408
405
|
return this.waitForPageReadiness();
|
|
409
406
|
}
|
|
410
407
|
tag('warning').log('Browser error detected, reloading page');
|
|
411
|
-
await this.playwrightHelper.page.reload(
|
|
408
|
+
await this.playwrightHelper.page.reload(RECOVERY_NAVIGATION);
|
|
412
409
|
return this.waitForPageReadiness();
|
|
413
410
|
}
|
|
414
411
|
catch (err) {
|
|
@@ -416,21 +413,34 @@ class Explorer {
|
|
|
416
413
|
return false;
|
|
417
414
|
}
|
|
418
415
|
}
|
|
416
|
+
async activateNewPage(context) {
|
|
417
|
+
const page = await context.newPage();
|
|
418
|
+
await page.bringToFront();
|
|
419
|
+
await this.playwrightHelper._setPage(page);
|
|
420
|
+
this.bindFrameNavigated(page);
|
|
421
|
+
this.xhrCapture?.attach(page);
|
|
422
|
+
}
|
|
423
|
+
async stopCaptures() {
|
|
424
|
+
if (this.xhrCapture && this.playwrightHelper?.page) {
|
|
425
|
+
this.xhrCapture.detach(this.playwrightHelper.page);
|
|
426
|
+
}
|
|
427
|
+
await this.playwrightRecorder.stop();
|
|
428
|
+
}
|
|
429
|
+
async closeBrowserContext() {
|
|
430
|
+
if (!this.playwrightHelper.browserContext)
|
|
431
|
+
return;
|
|
432
|
+
await this.playwrightHelper.browserContext.close().catch((err) => {
|
|
433
|
+
debugLog('Failed to close browser context:', err);
|
|
434
|
+
});
|
|
435
|
+
this.playwrightHelper.browserContext = null;
|
|
436
|
+
}
|
|
419
437
|
async restartBrowser() {
|
|
420
438
|
if (!this.playwrightHelper)
|
|
421
439
|
return false;
|
|
422
440
|
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
423
441
|
try {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
await this.playwrightRecorder.stop();
|
|
428
|
-
if (this.playwrightHelper.browserContext) {
|
|
429
|
-
await this.playwrightHelper.browserContext.close().catch((err) => {
|
|
430
|
-
debugLog('Failed to close browser context before restart:', err);
|
|
431
|
-
});
|
|
432
|
-
this.playwrightHelper.browserContext = null;
|
|
433
|
-
}
|
|
442
|
+
await this.stopCaptures();
|
|
443
|
+
await this.closeBrowserContext();
|
|
434
444
|
if (!this.isSharedBrowser) {
|
|
435
445
|
await this.playwrightHelper._stopBrowser().catch((err) => {
|
|
436
446
|
debugLog('Failed to stop browser before restart:', err);
|
|
@@ -439,10 +449,10 @@ class Explorer {
|
|
|
439
449
|
await this.connectOrLaunchBrowser();
|
|
440
450
|
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
441
451
|
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
442
|
-
this.
|
|
452
|
+
this.attachXhrCapture();
|
|
443
453
|
this.listenToStateChanged();
|
|
444
454
|
if (url) {
|
|
445
|
-
await this.playwrightHelper.page.goto(url,
|
|
455
|
+
await this.playwrightHelper.page.goto(url, RECOVERY_NAVIGATION);
|
|
446
456
|
if (!(await this.waitForPageReadiness()))
|
|
447
457
|
return false;
|
|
448
458
|
}
|
|
@@ -454,10 +464,21 @@ class Explorer {
|
|
|
454
464
|
return false;
|
|
455
465
|
}
|
|
456
466
|
}
|
|
457
|
-
|
|
458
|
-
if (
|
|
459
|
-
|
|
460
|
-
|
|
467
|
+
resolveBrowserUrl(url) {
|
|
468
|
+
if (!url)
|
|
469
|
+
return null;
|
|
470
|
+
try {
|
|
471
|
+
return new URL(url).toString();
|
|
472
|
+
}
|
|
473
|
+
catch { }
|
|
474
|
+
const baseUrl = this.config.playwright?.url || this.config.web?.url;
|
|
475
|
+
if (!baseUrl)
|
|
476
|
+
return null;
|
|
477
|
+
try {
|
|
478
|
+
return new URL(url, baseUrl).toString();
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return null;
|
|
461
482
|
}
|
|
462
483
|
}
|
|
463
484
|
async waitForPageReadiness() {
|
|
@@ -470,213 +491,96 @@ class Explorer {
|
|
|
470
491
|
});
|
|
471
492
|
return true;
|
|
472
493
|
}
|
|
473
|
-
async isInsideIframe() {
|
|
474
|
-
if (this.playwrightHelper.frame)
|
|
475
|
-
return true;
|
|
476
|
-
try {
|
|
477
|
-
const page = this.playwrightHelper.page;
|
|
478
|
-
if (!page)
|
|
479
|
-
return false;
|
|
480
|
-
return await page.evaluate(() => window.top !== window.self);
|
|
481
|
-
}
|
|
482
|
-
catch (error) {
|
|
483
|
-
if (this.isFatalBrowserError(error)) {
|
|
484
|
-
tag('warning').log(`isInsideIframe: ${browserErrorMessage(error)}`);
|
|
485
|
-
await this.recoverFromBrowserError();
|
|
486
|
-
}
|
|
487
|
-
return false;
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
getCurrentIframeInfo() {
|
|
491
|
-
if (!this.playwrightHelper?.frame)
|
|
492
|
-
return null;
|
|
493
|
-
return 'iframe context active';
|
|
494
|
-
}
|
|
495
|
-
hasOtherTabs() {
|
|
496
|
-
return this.otherTabs.length > 0;
|
|
497
|
-
}
|
|
498
|
-
getOtherTabsInfo() {
|
|
499
|
-
return [...this.otherTabs];
|
|
500
|
-
}
|
|
501
|
-
clearOtherTabsInfo() {
|
|
502
|
-
this.otherTabs = [];
|
|
503
|
-
}
|
|
504
494
|
listenToStateChanged() {
|
|
505
|
-
|
|
506
|
-
|
|
495
|
+
const page = this.playwrightHelper?.page;
|
|
496
|
+
if (!page) {
|
|
497
|
+
debugLog('Playwright page not available for state monitoring');
|
|
507
498
|
return;
|
|
508
499
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
500
|
+
const initialPage = page;
|
|
501
|
+
const context = page.context();
|
|
502
|
+
context.on('page', async (newPage) => {
|
|
503
|
+
if (newPage === initialPage)
|
|
513
504
|
return;
|
|
514
|
-
}
|
|
515
|
-
const initialPage = page;
|
|
516
|
-
const context = this.playwrightHelper.page.context();
|
|
517
|
-
context.on('page', async (newPage) => {
|
|
518
|
-
if (newPage === initialPage) {
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
try {
|
|
522
|
-
if (newPage.url() === 'about:blank') {
|
|
523
|
-
await newPage.waitForURL(/^(?!about:blank$)/, { timeout: 5000 }).catch(() => { });
|
|
524
|
-
}
|
|
525
|
-
await newPage.waitForLoadState('domcontentloaded', { timeout: 5000 });
|
|
526
|
-
const url = await newPage.url();
|
|
527
|
-
const title = await newPage.title().catch(() => 'Unknown');
|
|
528
|
-
this.otherTabs.push({ url, title });
|
|
529
|
-
if (url !== 'about:blank') {
|
|
530
|
-
tag('info').log(`New browser tab opened: ${url}`);
|
|
531
|
-
}
|
|
532
|
-
debugLog(`New tab detected: ${url} - ${title}`);
|
|
533
|
-
}
|
|
534
|
-
catch (error) {
|
|
535
|
-
debugLog('Failed to get new tab info:', error);
|
|
536
|
-
this.otherTabs.push({ url: 'unknown', title: 'unknown' });
|
|
537
|
-
}
|
|
538
|
-
});
|
|
539
|
-
this.bindFrameNavigated(page);
|
|
540
|
-
debugLog('Listening for automatic state changes');
|
|
541
|
-
}
|
|
542
|
-
catch (error) {
|
|
543
|
-
debugLog('Failed to set up state change monitoring:', error);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
async stop() {
|
|
547
|
-
if (!this.isStarted) {
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
if (this.xhrCapture && this.playwrightHelper?.page) {
|
|
551
|
-
this.xhrCapture.detach(this.playwrightHelper.page);
|
|
552
|
-
}
|
|
553
|
-
await this.playwrightRecorder.stop();
|
|
554
|
-
if (this.options?.session && this.playwrightHelper?.browserContext) {
|
|
555
|
-
const dir = path.dirname(this.options.session);
|
|
556
|
-
if (!existsSync(dir))
|
|
557
|
-
mkdirSync(dir, { recursive: true });
|
|
558
|
-
await this.playwrightHelper.browserContext.storageState({ path: this.options.session });
|
|
559
|
-
debugLog(`Session saved to ${path.relative(process.cwd(), this.options.session)}`);
|
|
560
|
-
}
|
|
561
|
-
codeceptjs.event.dispatcher.emit('global.after');
|
|
562
|
-
codeceptjs.event.dispatcher.emit('global.result');
|
|
563
|
-
if (this.isSharedBrowser) {
|
|
564
|
-
tag('info').log('Closing browser context (persistent browser stays running)');
|
|
565
505
|
try {
|
|
566
|
-
if (
|
|
567
|
-
await
|
|
568
|
-
this.playwrightHelper.browserContext = null;
|
|
506
|
+
if (newPage.url() === 'about:blank') {
|
|
507
|
+
await newPage.waitForURL(/^(?!about:blank$)/, { timeout: 5000 }).catch(() => { });
|
|
569
508
|
}
|
|
509
|
+
await newPage.waitForLoadState('domcontentloaded', { timeout: 5000 });
|
|
510
|
+
const url = await newPage.url();
|
|
511
|
+
const title = await newPage.title().catch(() => 'Unknown');
|
|
512
|
+
this.stateManager.otherTabs.push({ url, title });
|
|
513
|
+
if (url !== 'about:blank') {
|
|
514
|
+
tag('info').log(`New browser tab opened: ${url}`);
|
|
515
|
+
}
|
|
516
|
+
debugLog(`New tab detected: ${url} - ${title}`);
|
|
570
517
|
}
|
|
571
|
-
catch (
|
|
572
|
-
debugLog('Failed to
|
|
518
|
+
catch (error) {
|
|
519
|
+
debugLog('Failed to get new tab info:', error);
|
|
520
|
+
this.stateManager.otherTabs.push({ url: 'unknown', title: 'unknown' });
|
|
573
521
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
578
|
-
else {
|
|
579
|
-
await Promise.all([this.reporter.finishRun(), this.playwrightHelper._stopBrowser(), codeceptjs.recorder.stop()]);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
get activeTest() {
|
|
583
|
-
return this._activeTest;
|
|
522
|
+
});
|
|
523
|
+
this.bindFrameNavigated(page);
|
|
524
|
+
debugLog('Listening for automatic state changes');
|
|
584
525
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
await this.reporter.reportTestStart(test);
|
|
589
|
-
await this.closeOtherTabs();
|
|
590
|
-
this.otherTabs = [];
|
|
591
|
-
if (!(await this.ensurePageAvailable()))
|
|
592
|
-
return false;
|
|
593
|
-
const codeceptjsTest = toCodeceptjsTest(test);
|
|
594
|
-
const stepHandler = (step, status, error, log) => {
|
|
595
|
-
if (!step.toCode)
|
|
596
|
-
return;
|
|
597
|
-
if (step?.name?.startsWith('grab'))
|
|
598
|
-
return;
|
|
599
|
-
if (step?.name?.startsWith('save'))
|
|
600
|
-
return;
|
|
601
|
-
test.addStep(step.toCode(), step.duration, status, error, log);
|
|
602
|
-
if (!this.stateManager.getCurrentState())
|
|
526
|
+
bindFrameNavigated(page) {
|
|
527
|
+
page.on('framenavigated', async (frame) => {
|
|
528
|
+
if (frame !== page.mainFrame())
|
|
603
529
|
return;
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
codeceptjs.event.dispatcher.off('test.after', onTestAfter);
|
|
616
|
-
this.unwatchActiveTestPages();
|
|
617
|
-
};
|
|
618
|
-
codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
|
|
619
|
-
codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
|
|
620
|
-
codeceptjs.event.dispatcher.on('step.passed', onStepPassed);
|
|
621
|
-
codeceptjs.event.dispatcher.on('step.failed', onStepFailed);
|
|
622
|
-
codeceptjs.event.dispatcher.on('test.after', onTestAfter);
|
|
623
|
-
return true;
|
|
530
|
+
const newUrl = await frame.url();
|
|
531
|
+
let newTitle = '';
|
|
532
|
+
try {
|
|
533
|
+
newTitle = await frame.title();
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
debugLog('Failed to get page title:', error);
|
|
537
|
+
}
|
|
538
|
+
this.stateManager.updateStateFromBasic(newUrl, newTitle, 'navigation');
|
|
539
|
+
await sleep(500);
|
|
540
|
+
});
|
|
624
541
|
}
|
|
625
|
-
async
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
542
|
+
async closeOtherTabs() {
|
|
543
|
+
if (!this.playwrightHelper)
|
|
544
|
+
return;
|
|
545
|
+
const context = this.playwrightHelper.page.context();
|
|
546
|
+
const pages = context.pages();
|
|
547
|
+
if (pages.length <= 1)
|
|
548
|
+
return;
|
|
549
|
+
debugLog(`Found ${pages.length} tabs, cleaning up to keep only the first one`);
|
|
550
|
+
const firstPage = pages[0];
|
|
551
|
+
const tabsToClose = pages.slice(1);
|
|
552
|
+
for (const page of tabsToClose) {
|
|
553
|
+
await page.close();
|
|
554
|
+
debugLog(`Closed extra tab: ${await page.url()}`);
|
|
630
555
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
this.watchActiveTestPage();
|
|
635
|
-
return true;
|
|
556
|
+
await firstPage.bringToFront();
|
|
557
|
+
await this.playwrightHelper._setPage(firstPage);
|
|
558
|
+
debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
|
|
636
559
|
}
|
|
637
|
-
async
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
};
|
|
560
|
+
async finishTest(test, meta) {
|
|
561
|
+
this.unwatchActiveTestPages();
|
|
562
|
+
this._activeTest = null;
|
|
563
|
+
const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
|
|
564
|
+
if (lastScreenshot) {
|
|
565
|
+
meta ||= {};
|
|
566
|
+
meta.screenshotFile = lastScreenshot;
|
|
645
567
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
};
|
|
568
|
+
await this.reporter.reportTest(test, meta);
|
|
569
|
+
const codeceptjsTest = toCodeceptjsTest(test);
|
|
570
|
+
if (test.isSuccessful) {
|
|
571
|
+
codeceptjsTest.state = 'passed';
|
|
572
|
+
this.eventDispatcher.emit('test.passed', codeceptjsTest);
|
|
652
573
|
}
|
|
653
|
-
if (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
|
|
657
|
-
};
|
|
574
|
+
else if (test.isSkipped) {
|
|
575
|
+
codeceptjsTest.state = 'skipped';
|
|
576
|
+
this.eventDispatcher.emit('test.skipped', codeceptjsTest);
|
|
658
577
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (!recovered) {
|
|
663
|
-
return {
|
|
664
|
-
action: 'stop',
|
|
665
|
-
recovered: false,
|
|
666
|
-
message: `Browser could not be recovered after fatal error: ${message}`,
|
|
667
|
-
};
|
|
578
|
+
else {
|
|
579
|
+
codeceptjsTest.state = 'failed';
|
|
580
|
+
this.eventDispatcher.emit('test.failed', codeceptjsTest);
|
|
668
581
|
}
|
|
669
|
-
this.
|
|
670
|
-
|
|
671
|
-
action: 'continue',
|
|
672
|
-
recovered: true,
|
|
673
|
-
message: dedent `
|
|
674
|
-
Browser was recovered after a fatal page error.
|
|
675
|
-
Continue from the restored page.
|
|
676
|
-
The interrupted browser action is not product evidence.
|
|
677
|
-
Inspect the restored page and retry the current step when it is still required.
|
|
678
|
-
`,
|
|
679
|
-
};
|
|
582
|
+
this.eventDispatcher.emit('test.finish', codeceptjsTest);
|
|
583
|
+
this.eventDispatcher.emit('test.after', codeceptjsTest);
|
|
680
584
|
}
|
|
681
585
|
watchActiveTestPage(page = this.playwrightHelper?.page) {
|
|
682
586
|
if (!this._activeTest)
|
|
@@ -703,31 +607,6 @@ class Explorer {
|
|
|
703
607
|
page.on('dialog', this.testDialogHandler);
|
|
704
608
|
this.observedTestPages.add(page);
|
|
705
609
|
}
|
|
706
|
-
async stopTest(test, meta) {
|
|
707
|
-
this.unwatchActiveTestPages();
|
|
708
|
-
this._activeTest = null;
|
|
709
|
-
const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
|
|
710
|
-
if (lastScreenshot) {
|
|
711
|
-
meta ||= {};
|
|
712
|
-
meta.screenshotFile = lastScreenshot;
|
|
713
|
-
}
|
|
714
|
-
await this.reporter.reportTest(test, meta);
|
|
715
|
-
const codeceptjsTest = toCodeceptjsTest(test);
|
|
716
|
-
if (test.isSuccessful) {
|
|
717
|
-
codeceptjsTest.state = 'passed';
|
|
718
|
-
codeceptjs.event.dispatcher.emit('test.passed', codeceptjsTest);
|
|
719
|
-
}
|
|
720
|
-
else if (test.isSkipped) {
|
|
721
|
-
codeceptjsTest.state = 'skipped';
|
|
722
|
-
codeceptjs.event.dispatcher.emit('test.skipped', codeceptjsTest);
|
|
723
|
-
}
|
|
724
|
-
else {
|
|
725
|
-
codeceptjsTest.state = 'failed';
|
|
726
|
-
codeceptjs.event.dispatcher.emit('test.failed', codeceptjsTest);
|
|
727
|
-
}
|
|
728
|
-
codeceptjs.event.dispatcher.emit('test.finish', codeceptjsTest);
|
|
729
|
-
codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
|
|
730
|
-
}
|
|
731
610
|
unwatchActiveTestPages() {
|
|
732
611
|
for (const page of this.observedTestPages) {
|
|
733
612
|
if (this.testPageErrorHandler)
|
|
@@ -739,71 +618,6 @@ class Explorer {
|
|
|
739
618
|
}
|
|
740
619
|
this.observedTestPages.clear();
|
|
741
620
|
}
|
|
742
|
-
async playwrightLocatorCount(locatorFn) {
|
|
743
|
-
try {
|
|
744
|
-
const pwLocator = locatorFn(this.playwrightHelper.page);
|
|
745
|
-
return await pwLocator.count();
|
|
746
|
-
}
|
|
747
|
-
catch (error) {
|
|
748
|
-
if (this.isFatalBrowserError(error)) {
|
|
749
|
-
tag('warning').log(`playwrightLocatorCount: ${browserErrorMessage(error)}`);
|
|
750
|
-
await this.recoverFromBrowserError();
|
|
751
|
-
}
|
|
752
|
-
throw error;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
bindFrameNavigated(page) {
|
|
756
|
-
page.on('framenavigated', async (frame) => {
|
|
757
|
-
if (frame !== page.mainFrame())
|
|
758
|
-
return;
|
|
759
|
-
const newUrl = await frame.url();
|
|
760
|
-
let newTitle = '';
|
|
761
|
-
try {
|
|
762
|
-
newTitle = await frame.title();
|
|
763
|
-
}
|
|
764
|
-
catch (error) {
|
|
765
|
-
debugLog('Failed to get page title:', error);
|
|
766
|
-
}
|
|
767
|
-
this.stateManager.updateStateFromBasic(newUrl, newTitle, 'navigation');
|
|
768
|
-
await sleep(500);
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
async openFreshTab() {
|
|
772
|
-
if (!this.playwrightHelper?.page)
|
|
773
|
-
return;
|
|
774
|
-
const oldPage = this.playwrightHelper.page;
|
|
775
|
-
const context = oldPage.context();
|
|
776
|
-
const newPage = await context.newPage();
|
|
777
|
-
await oldPage.close();
|
|
778
|
-
await newPage.bringToFront();
|
|
779
|
-
await this.playwrightHelper._setPage(newPage);
|
|
780
|
-
this.otherTabs = [];
|
|
781
|
-
this.bindFrameNavigated(newPage);
|
|
782
|
-
if (this.xhrCapture) {
|
|
783
|
-
this.xhrCapture.attach(newPage);
|
|
784
|
-
}
|
|
785
|
-
debugLog('Opened fresh tab, closed previous tab');
|
|
786
|
-
}
|
|
787
|
-
async closeOtherTabs() {
|
|
788
|
-
if (!this.playwrightHelper) {
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
const context = this.playwrightHelper.page.context();
|
|
792
|
-
const pages = context.pages();
|
|
793
|
-
if (pages.length <= 1) {
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
debugLog(`Found ${pages.length} tabs, cleaning up to keep only the first one`);
|
|
797
|
-
const firstPage = pages[0];
|
|
798
|
-
const tabsToClose = pages.slice(1);
|
|
799
|
-
for (const page of tabsToClose) {
|
|
800
|
-
await page.close();
|
|
801
|
-
debugLog(`Closed extra tab: ${await page.url()}`);
|
|
802
|
-
}
|
|
803
|
-
await firstPage.bringToFront();
|
|
804
|
-
await this.playwrightHelper._setPage(firstPage);
|
|
805
|
-
debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
|
|
806
|
-
}
|
|
807
621
|
}
|
|
808
622
|
function toCodeceptjsTest(test) {
|
|
809
623
|
const parent = {
|
|
@@ -818,58 +632,4 @@ function toCodeceptjsTest(test) {
|
|
|
818
632
|
codeceptjsTest._explorbotTest = test;
|
|
819
633
|
return codeceptjsTest;
|
|
820
634
|
}
|
|
821
|
-
const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
|
|
822
|
-
const ANNOTATABLE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
|
|
823
|
-
function parseAriaRefs(ariaSnapshot) {
|
|
824
|
-
const entries = [];
|
|
825
|
-
for (const line of ariaSnapshot.split('\n')) {
|
|
826
|
-
const match = line.match(REF_LINE_PATTERN);
|
|
827
|
-
if (!match)
|
|
828
|
-
continue;
|
|
829
|
-
if (!ANNOTATABLE_ROLES.has(match[2]))
|
|
830
|
-
continue;
|
|
831
|
-
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
832
|
-
}
|
|
833
|
-
return entries;
|
|
834
|
-
}
|
|
835
|
-
export async function annotatePageElements(page) {
|
|
836
|
-
const ariaSnapshot = await page.locator('body').ariaSnapshot({ mode: 'ai' });
|
|
837
|
-
const refEntries = parseAriaRefs(ariaSnapshot);
|
|
838
|
-
const byRole = new Map();
|
|
839
|
-
for (const { role, name, ref } of refEntries) {
|
|
840
|
-
let list = byRole.get(role);
|
|
841
|
-
if (!list) {
|
|
842
|
-
list = [];
|
|
843
|
-
byRole.set(role, list);
|
|
844
|
-
}
|
|
845
|
-
list.push({ name, ref });
|
|
846
|
-
}
|
|
847
|
-
const elements = [];
|
|
848
|
-
for (const [role, entries] of byRole) {
|
|
849
|
-
try {
|
|
850
|
-
const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr, config]) => {
|
|
851
|
-
const extract = new Function(`return ${extractFnStr}`)();
|
|
852
|
-
const results = [];
|
|
853
|
-
let ariaIdx = 0;
|
|
854
|
-
for (const el of domElements) {
|
|
855
|
-
if (ariaIdx >= data.length)
|
|
856
|
-
break;
|
|
857
|
-
el.setAttribute(config.attrs.eidx, data[ariaIdx].ref);
|
|
858
|
-
const elData = extract(el, config);
|
|
859
|
-
if (elData)
|
|
860
|
-
results.push(elData);
|
|
861
|
-
ariaIdx++;
|
|
862
|
-
}
|
|
863
|
-
return results;
|
|
864
|
-
}, [entries, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]);
|
|
865
|
-
for (const raw of rawList) {
|
|
866
|
-
elements.push(WebElement.fromRawData(raw, role));
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
catch {
|
|
870
|
-
debugLog(`Failed to annotate role=${role}`);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
return { ariaSnapshot, elements };
|
|
874
|
-
}
|
|
875
635
|
export default Explorer;
|