explorbot 0.2.0 → 0.2.1
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 +5 -2
- package/boat/doc-collector/src/ai/tools.ts +12 -11
- package/boat/doc-collector/src/cli.ts +1 -6
- package/boat/doc-collector/src/docbot.ts +3 -3
- package/boat/doc-collector/src/screenshots.ts +2 -2
- 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 +4 -2
- package/dist/boat/doc-collector/src/ai/tools.js +11 -11
- package/dist/boat/doc-collector/src/cli.js +1 -5
- package/dist/boat/doc-collector/src/docbot.js +3 -3
- package/dist/boat/doc-collector/src/screenshots.js +2 -2
- package/dist/models.json +30 -0
- package/dist/package.json +5 -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 +66 -91
- package/dist/src/explorer.js +326 -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 +5 -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 +389 -628
- 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,195 @@ 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
|
-
constructor(config,
|
|
38
|
+
constructor(config, options, deps) {
|
|
47
39
|
this.config = config;
|
|
48
|
-
this.aiProvider = aiProvider;
|
|
49
40
|
this.options = options;
|
|
41
|
+
this.stateManager = deps.stateManager;
|
|
42
|
+
this.knowledgeTracker = deps.knowledgeTracker;
|
|
43
|
+
this.reporter = deps.reporter;
|
|
44
|
+
this.requestStore = deps.requestStore;
|
|
45
|
+
this.playwrightRecorder = deps.playwrightRecorder;
|
|
50
46
|
this.initializeContainer();
|
|
51
|
-
this.knowledgeTracker = knowledgeTracker;
|
|
52
|
-
this.stateManager = new StateManager(experienceTracker, knowledgeTracker);
|
|
53
|
-
this.reporter = new Reporter(config.reporter, this.stateManager);
|
|
54
47
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
48
|
+
get actor() {
|
|
49
|
+
return this._actor;
|
|
50
|
+
}
|
|
51
|
+
get page() {
|
|
52
|
+
const page = this.playwrightHelper?.page;
|
|
53
|
+
if (!page || page.isClosed?.())
|
|
54
|
+
return null;
|
|
55
|
+
return page;
|
|
56
|
+
}
|
|
57
|
+
get activeTest() {
|
|
58
|
+
return this._activeTest;
|
|
59
|
+
}
|
|
60
|
+
async start() {
|
|
61
|
+
if (this.started)
|
|
62
|
+
return;
|
|
63
|
+
await codeceptjs.recorder.start();
|
|
64
|
+
await codeceptjs.container.started(null);
|
|
65
|
+
storeListener();
|
|
66
|
+
stepsListener();
|
|
67
|
+
codeceptjs.recorder.retry({
|
|
68
|
+
retries: this.config.action?.retries || 3,
|
|
69
|
+
when: (err) => !!err?.message?.includes?.('context'),
|
|
70
|
+
});
|
|
71
|
+
this.playwrightHelper = codeceptjs.container.helpers('Playwright');
|
|
72
|
+
if (!this.playwrightHelper) {
|
|
73
|
+
throw new Error('Playwright helper not available');
|
|
65
74
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
await this.connectOrLaunchBrowser();
|
|
76
|
+
const hasSession = this.options?.session && existsSync(this.options.session);
|
|
77
|
+
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
78
|
+
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
79
|
+
this.attachXhrCapture();
|
|
80
|
+
if (hasSession) {
|
|
81
|
+
tag('info').log(`Session restored from ${path.relative(process.cwd(), this.options.session)}`);
|
|
82
|
+
}
|
|
83
|
+
this._actor = codeceptjs.container.support('I');
|
|
84
|
+
this.started = true;
|
|
85
|
+
this.listenToStateChanged();
|
|
86
|
+
codeceptjs.event.dispatcher.emit('global.before');
|
|
87
|
+
tag('success').log('Browser started, ready to explore');
|
|
88
|
+
}
|
|
89
|
+
async stop() {
|
|
90
|
+
if (!this.started)
|
|
91
|
+
return;
|
|
92
|
+
this.started = false;
|
|
93
|
+
await this.stopCaptures();
|
|
94
|
+
if (this.options?.session && this.playwrightHelper?.browserContext) {
|
|
95
|
+
const dir = path.dirname(this.options.session);
|
|
96
|
+
if (!existsSync(dir))
|
|
97
|
+
mkdirSync(dir, { recursive: true });
|
|
98
|
+
await this.playwrightHelper.browserContext.storageState({ path: this.options.session });
|
|
99
|
+
debugLog(`Session saved to ${path.relative(process.cwd(), this.options.session)}`);
|
|
100
|
+
}
|
|
101
|
+
codeceptjs.event.dispatcher.emit('global.after');
|
|
102
|
+
codeceptjs.event.dispatcher.emit('global.result');
|
|
103
|
+
if (!this.isSharedBrowser) {
|
|
104
|
+
await Promise.all([this.reporter.finishRun(), this.playwrightHelper._stopBrowser(), codeceptjs.recorder.stop()]);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
tag('info').log('Closing browser context (persistent browser stays running)');
|
|
108
|
+
await this.closeBrowserContext();
|
|
109
|
+
this.playwrightHelper.browser = null;
|
|
110
|
+
this.playwrightHelper.isRunning = false;
|
|
111
|
+
await Promise.all([this.reporter.finishRun(), codeceptjs.recorder.stop()]);
|
|
112
|
+
}
|
|
113
|
+
action() {
|
|
114
|
+
return new Action(this._actor, this.stateManager, this.playwrightRecorder, (fn) => this.runWithRecovery('action', fn));
|
|
115
|
+
}
|
|
116
|
+
async visit(url, opts = {}) {
|
|
117
|
+
return this.runWithRecovery('visit', async () => {
|
|
118
|
+
const action = await this.visitOnce(url);
|
|
119
|
+
if (opts.screenshot)
|
|
120
|
+
return action.capturePageState({ includeScreenshot: true });
|
|
121
|
+
return action.getActionResult() ?? action.capturePageState();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async capture(opts = {}) {
|
|
125
|
+
return this.action().capturePageState({ includeScreenshot: opts.screenshot });
|
|
126
|
+
}
|
|
127
|
+
async withPage(fn) {
|
|
128
|
+
return this.runWithRecovery('page operation', () => fn(this.playwrightHelper.page));
|
|
129
|
+
}
|
|
130
|
+
async recover(error) {
|
|
131
|
+
if (error)
|
|
132
|
+
return this.recoverFromExecutionError(error);
|
|
133
|
+
if (this.page) {
|
|
134
|
+
this.watchActiveTestPage();
|
|
135
|
+
return { ok: true, action: 'continue', message: 'Page is available' };
|
|
136
|
+
}
|
|
137
|
+
if (!(await this.recoverOrRestart())) {
|
|
138
|
+
return { ok: false, action: 'stop', message: 'Browser page could not be recovered' };
|
|
139
|
+
}
|
|
140
|
+
this.watchActiveTestPage();
|
|
141
|
+
return { ok: true, action: 'continue', recovered: true, message: 'Browser page was recovered' };
|
|
142
|
+
}
|
|
143
|
+
async beginTest(test) {
|
|
144
|
+
if (!this.page && !(await this.recoverOrRestart())) {
|
|
145
|
+
return { started: false, stop: async () => { } };
|
|
69
146
|
}
|
|
147
|
+
this._activeTest = test;
|
|
148
|
+
test.start();
|
|
149
|
+
await this.reporter.reportTestStart(test);
|
|
150
|
+
await this.closeOtherTabs();
|
|
151
|
+
this.stateManager.otherTabs = [];
|
|
152
|
+
const codeceptjsTest = toCodeceptjsTest(test);
|
|
153
|
+
const stepHandler = (step, status, error, log) => {
|
|
154
|
+
if (!step.toCode)
|
|
155
|
+
return;
|
|
156
|
+
if (step?.name?.startsWith('grab'))
|
|
157
|
+
return;
|
|
158
|
+
if (step?.name?.startsWith('save'))
|
|
159
|
+
return;
|
|
160
|
+
test.addStep(step.toCode(), step.duration, status, error, log);
|
|
161
|
+
if (!this.stateManager.getCurrentState())
|
|
162
|
+
return;
|
|
163
|
+
const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()).screenshotFile;
|
|
164
|
+
test.setActiveNoteScreenshot(lastScreenshot);
|
|
165
|
+
};
|
|
166
|
+
this.watchActiveTestPage();
|
|
167
|
+
const onStepPassed = (step) => stepHandler(step, 'passed');
|
|
168
|
+
const onStepFailed = (step, error) => {
|
|
169
|
+
stepHandler(step, 'failed', error?.message || String(error), error?.stack);
|
|
170
|
+
};
|
|
171
|
+
const onTestAfter = () => {
|
|
172
|
+
codeceptjs.event.dispatcher.off('step.passed', onStepPassed);
|
|
173
|
+
codeceptjs.event.dispatcher.off('step.failed', onStepFailed);
|
|
174
|
+
codeceptjs.event.dispatcher.off('test.after', onTestAfter);
|
|
175
|
+
this.unwatchActiveTestPages();
|
|
176
|
+
};
|
|
177
|
+
codeceptjs.event.dispatcher.emit('test.before', codeceptjsTest);
|
|
178
|
+
codeceptjs.event.dispatcher.emit('test.start', codeceptjsTest);
|
|
179
|
+
codeceptjs.event.dispatcher.on('step.passed', onStepPassed);
|
|
180
|
+
codeceptjs.event.dispatcher.on('step.failed', onStepFailed);
|
|
181
|
+
codeceptjs.event.dispatcher.on('test.after', onTestAfter);
|
|
182
|
+
return { started: true, stop: (meta) => this.finishTest(test, meta) };
|
|
183
|
+
}
|
|
184
|
+
async openTab() {
|
|
185
|
+
const oldPage = this.playwrightHelper?.page;
|
|
186
|
+
if (!oldPage)
|
|
187
|
+
return;
|
|
188
|
+
await this.activateNewPage(oldPage.context());
|
|
189
|
+
await oldPage.close();
|
|
190
|
+
this.stateManager.otherTabs = [];
|
|
191
|
+
debugLog('Opened fresh tab, closed previous tab');
|
|
192
|
+
}
|
|
193
|
+
initializeContainer() {
|
|
194
|
+
const configParser = ConfigParser.getInstance();
|
|
195
|
+
const projectRoot = configParser.getProjectRoot();
|
|
196
|
+
global.output_dir = configParser.getStatesDir();
|
|
197
|
+
global.codecept_dir = projectRoot;
|
|
198
|
+
configParser.validateConfig(this.config);
|
|
199
|
+
codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
|
|
70
200
|
}
|
|
71
201
|
convertToCodeceptConfig(config) {
|
|
72
202
|
const playwrightConfig = { ...config.playwright };
|
|
@@ -115,86 +245,6 @@ class Explorer {
|
|
|
115
245
|
}
|
|
116
246
|
return codeceptConfig;
|
|
117
247
|
}
|
|
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
248
|
async connectOrLaunchBrowser() {
|
|
199
249
|
const { getAliveEndpoint } = await import('./browser-server.js');
|
|
200
250
|
const endpoint = await getAliveEndpoint();
|
|
@@ -223,11 +273,12 @@ class Explorer {
|
|
|
223
273
|
contextOptions.storageState = this.options.session;
|
|
224
274
|
return contextOptions;
|
|
225
275
|
}
|
|
226
|
-
|
|
227
|
-
|
|
276
|
+
attachXhrCapture() {
|
|
277
|
+
this.xhrCapture = new XhrCapture(this.requestStore, this.config.playwright.url);
|
|
278
|
+
this.xhrCapture.attach(this.playwrightHelper.page);
|
|
228
279
|
}
|
|
229
|
-
async
|
|
230
|
-
if (!(await this.
|
|
280
|
+
async runWithRecovery(label, operation) {
|
|
281
|
+
if (!this.page && !(await this.recoverOrRestart())) {
|
|
231
282
|
throw new Error(`Browser page is unavailable before ${label}`);
|
|
232
283
|
}
|
|
233
284
|
try {
|
|
@@ -242,18 +293,15 @@ class Explorer {
|
|
|
242
293
|
return await operation();
|
|
243
294
|
}
|
|
244
295
|
catch (retryError) {
|
|
245
|
-
if (!isNavigationTransitionError(retryError) && !
|
|
296
|
+
if (!isNavigationTransitionError(retryError) && !isFatalBrowserError(retryError))
|
|
246
297
|
throw retryError;
|
|
247
298
|
recoveryError = retryError;
|
|
248
299
|
}
|
|
249
300
|
}
|
|
250
|
-
if (!
|
|
301
|
+
if (!isFatalBrowserError(recoveryError))
|
|
251
302
|
throw recoveryError;
|
|
252
303
|
tag('warning').log(`${label}: browser page is unavailable, recovering...`);
|
|
253
|
-
|
|
254
|
-
if (!recovered)
|
|
255
|
-
recovered = await this.restartBrowser();
|
|
256
|
-
if (!recovered)
|
|
304
|
+
if (!(await this.recoverOrRestart()))
|
|
257
305
|
throw new BrowserRecoveryError(label, recoveryError, false);
|
|
258
306
|
if (!(await this.waitForPageReadiness()))
|
|
259
307
|
throw new BrowserRecoveryError(label, recoveryError, true);
|
|
@@ -261,38 +309,20 @@ class Explorer {
|
|
|
261
309
|
return await operation();
|
|
262
310
|
}
|
|
263
311
|
catch (retryError) {
|
|
264
|
-
if (
|
|
312
|
+
if (isFatalBrowserError(retryError)) {
|
|
265
313
|
throw new BrowserRecoveryError(label, retryError, true);
|
|
266
314
|
}
|
|
267
315
|
throw retryError;
|
|
268
316
|
}
|
|
269
317
|
}
|
|
270
318
|
}
|
|
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
319
|
async visitOnce(url) {
|
|
290
320
|
await this.closeOtherTabs();
|
|
291
321
|
const serializedUrl = JSON.stringify(url);
|
|
292
322
|
const currentState = this.stateManager.getCurrentState();
|
|
293
323
|
const actionResult = currentState ? ActionResult.fromState(currentState) : new ActionResult({ url });
|
|
294
324
|
const { statePush = false, wait, waitForElement, code } = this.knowledgeTracker.getStateParameters(actionResult, ['statePush', 'wait', 'waitForElement', 'code']);
|
|
295
|
-
const action = this.
|
|
325
|
+
const action = new Action(this._actor, this.stateManager, this.playwrightRecorder);
|
|
296
326
|
if (statePush) {
|
|
297
327
|
await action.execute(`I.executeScript(() => { window.history.pushState({}, '', ${serializedUrl}); window.dispatchEvent(new PopStateEvent('popstate')); })`);
|
|
298
328
|
}
|
|
@@ -322,93 +352,59 @@ class Explorer {
|
|
|
322
352
|
}
|
|
323
353
|
return action;
|
|
324
354
|
}
|
|
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;
|
|
355
|
+
async recoverFromExecutionError(error) {
|
|
356
|
+
const message = browserErrorMessage(error);
|
|
357
|
+
tag('error').log(`Browser execution error: ${message}`);
|
|
358
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
359
|
+
return { ok: false, action: 'stop', message };
|
|
368
360
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (!url)
|
|
372
|
-
return null;
|
|
373
|
-
try {
|
|
374
|
-
return new URL(url).toString();
|
|
361
|
+
if (error instanceof BrowserRecoveryError) {
|
|
362
|
+
return { ok: false, action: 'stop', recovered: error.recovered, message: error.message };
|
|
375
363
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
364
|
+
if (!isFatalBrowserError(error)) {
|
|
365
|
+
return {
|
|
366
|
+
ok: true,
|
|
367
|
+
action: 'continue',
|
|
368
|
+
message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
|
|
369
|
+
};
|
|
382
370
|
}
|
|
383
|
-
|
|
384
|
-
return
|
|
371
|
+
if (!(await this.recoverOrRestart())) {
|
|
372
|
+
return { ok: false, action: 'stop', recovered: false, message: `Browser could not be recovered after fatal error: ${message}` };
|
|
385
373
|
}
|
|
374
|
+
this.watchActiveTestPage();
|
|
375
|
+
return {
|
|
376
|
+
ok: true,
|
|
377
|
+
action: 'continue',
|
|
378
|
+
recovered: true,
|
|
379
|
+
message: dedent `
|
|
380
|
+
Browser was recovered after a fatal page error.
|
|
381
|
+
Continue from the restored page.
|
|
382
|
+
The interrupted browser action is not product evidence.
|
|
383
|
+
Inspect the restored page and retry the current step when it is still required.
|
|
384
|
+
`,
|
|
385
|
+
};
|
|
386
386
|
}
|
|
387
|
-
|
|
388
|
-
|
|
387
|
+
async recoverOrRestart() {
|
|
388
|
+
if (await this.recoverPage())
|
|
389
|
+
return true;
|
|
390
|
+
return this.restartBrowser();
|
|
389
391
|
}
|
|
390
|
-
async
|
|
392
|
+
async recoverPage() {
|
|
391
393
|
try {
|
|
392
|
-
if (!this.
|
|
394
|
+
if (!this.page) {
|
|
393
395
|
const context = this.playwrightHelper?.browserContext;
|
|
394
396
|
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
|
-
}
|
|
397
|
+
return false;
|
|
398
|
+
await this.activateNewPage(context);
|
|
403
399
|
}
|
|
404
400
|
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
405
401
|
if (url) {
|
|
406
402
|
tag('warning').log(`Browser error detected, recovering by navigating to ${url}`);
|
|
407
|
-
await this.playwrightHelper.page.goto(url,
|
|
403
|
+
await this.playwrightHelper.page.goto(url, RECOVERY_NAVIGATION);
|
|
408
404
|
return this.waitForPageReadiness();
|
|
409
405
|
}
|
|
410
406
|
tag('warning').log('Browser error detected, reloading page');
|
|
411
|
-
await this.playwrightHelper.page.reload(
|
|
407
|
+
await this.playwrightHelper.page.reload(RECOVERY_NAVIGATION);
|
|
412
408
|
return this.waitForPageReadiness();
|
|
413
409
|
}
|
|
414
410
|
catch (err) {
|
|
@@ -416,21 +412,34 @@ class Explorer {
|
|
|
416
412
|
return false;
|
|
417
413
|
}
|
|
418
414
|
}
|
|
415
|
+
async activateNewPage(context) {
|
|
416
|
+
const page = await context.newPage();
|
|
417
|
+
await page.bringToFront();
|
|
418
|
+
await this.playwrightHelper._setPage(page);
|
|
419
|
+
this.bindFrameNavigated(page);
|
|
420
|
+
this.xhrCapture?.attach(page);
|
|
421
|
+
}
|
|
422
|
+
async stopCaptures() {
|
|
423
|
+
if (this.xhrCapture && this.playwrightHelper?.page) {
|
|
424
|
+
this.xhrCapture.detach(this.playwrightHelper.page);
|
|
425
|
+
}
|
|
426
|
+
await this.playwrightRecorder.stop();
|
|
427
|
+
}
|
|
428
|
+
async closeBrowserContext() {
|
|
429
|
+
if (!this.playwrightHelper.browserContext)
|
|
430
|
+
return;
|
|
431
|
+
await this.playwrightHelper.browserContext.close().catch((err) => {
|
|
432
|
+
debugLog('Failed to close browser context:', err);
|
|
433
|
+
});
|
|
434
|
+
this.playwrightHelper.browserContext = null;
|
|
435
|
+
}
|
|
419
436
|
async restartBrowser() {
|
|
420
437
|
if (!this.playwrightHelper)
|
|
421
438
|
return false;
|
|
422
439
|
const url = this.resolveBrowserUrl(this.stateManager.getCurrentState()?.url);
|
|
423
440
|
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
|
-
}
|
|
441
|
+
await this.stopCaptures();
|
|
442
|
+
await this.closeBrowserContext();
|
|
434
443
|
if (!this.isSharedBrowser) {
|
|
435
444
|
await this.playwrightHelper._stopBrowser().catch((err) => {
|
|
436
445
|
debugLog('Failed to stop browser before restart:', err);
|
|
@@ -439,10 +448,10 @@ class Explorer {
|
|
|
439
448
|
await this.connectOrLaunchBrowser();
|
|
440
449
|
await this.playwrightHelper._createContextPage(this.createBrowserContextOptions());
|
|
441
450
|
await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
|
|
442
|
-
this.
|
|
451
|
+
this.attachXhrCapture();
|
|
443
452
|
this.listenToStateChanged();
|
|
444
453
|
if (url) {
|
|
445
|
-
await this.playwrightHelper.page.goto(url,
|
|
454
|
+
await this.playwrightHelper.page.goto(url, RECOVERY_NAVIGATION);
|
|
446
455
|
if (!(await this.waitForPageReadiness()))
|
|
447
456
|
return false;
|
|
448
457
|
}
|
|
@@ -454,10 +463,21 @@ class Explorer {
|
|
|
454
463
|
return false;
|
|
455
464
|
}
|
|
456
465
|
}
|
|
457
|
-
|
|
458
|
-
if (
|
|
459
|
-
|
|
460
|
-
|
|
466
|
+
resolveBrowserUrl(url) {
|
|
467
|
+
if (!url)
|
|
468
|
+
return null;
|
|
469
|
+
try {
|
|
470
|
+
return new URL(url).toString();
|
|
471
|
+
}
|
|
472
|
+
catch { }
|
|
473
|
+
const baseUrl = this.config.playwright?.url || this.config.web?.url;
|
|
474
|
+
if (!baseUrl)
|
|
475
|
+
return null;
|
|
476
|
+
try {
|
|
477
|
+
return new URL(url, baseUrl).toString();
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return null;
|
|
461
481
|
}
|
|
462
482
|
}
|
|
463
483
|
async waitForPageReadiness() {
|
|
@@ -470,213 +490,96 @@ class Explorer {
|
|
|
470
490
|
});
|
|
471
491
|
return true;
|
|
472
492
|
}
|
|
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
493
|
listenToStateChanged() {
|
|
505
|
-
|
|
506
|
-
|
|
494
|
+
const page = this.playwrightHelper?.page;
|
|
495
|
+
if (!page) {
|
|
496
|
+
debugLog('Playwright page not available for state monitoring');
|
|
507
497
|
return;
|
|
508
498
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
499
|
+
const initialPage = page;
|
|
500
|
+
const context = page.context();
|
|
501
|
+
context.on('page', async (newPage) => {
|
|
502
|
+
if (newPage === initialPage)
|
|
513
503
|
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
504
|
try {
|
|
566
|
-
if (
|
|
567
|
-
await
|
|
568
|
-
this.playwrightHelper.browserContext = null;
|
|
505
|
+
if (newPage.url() === 'about:blank') {
|
|
506
|
+
await newPage.waitForURL(/^(?!about:blank$)/, { timeout: 5000 }).catch(() => { });
|
|
569
507
|
}
|
|
508
|
+
await newPage.waitForLoadState('domcontentloaded', { timeout: 5000 });
|
|
509
|
+
const url = await newPage.url();
|
|
510
|
+
const title = await newPage.title().catch(() => 'Unknown');
|
|
511
|
+
this.stateManager.otherTabs.push({ url, title });
|
|
512
|
+
if (url !== 'about:blank') {
|
|
513
|
+
tag('info').log(`New browser tab opened: ${url}`);
|
|
514
|
+
}
|
|
515
|
+
debugLog(`New tab detected: ${url} - ${title}`);
|
|
570
516
|
}
|
|
571
|
-
catch (
|
|
572
|
-
debugLog('Failed to
|
|
517
|
+
catch (error) {
|
|
518
|
+
debugLog('Failed to get new tab info:', error);
|
|
519
|
+
this.stateManager.otherTabs.push({ url: 'unknown', title: 'unknown' });
|
|
573
520
|
}
|
|
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;
|
|
521
|
+
});
|
|
522
|
+
this.bindFrameNavigated(page);
|
|
523
|
+
debugLog('Listening for automatic state changes');
|
|
584
524
|
}
|
|
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())
|
|
525
|
+
bindFrameNavigated(page) {
|
|
526
|
+
page.on('framenavigated', async (frame) => {
|
|
527
|
+
if (frame !== page.mainFrame())
|
|
603
528
|
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;
|
|
529
|
+
const newUrl = await frame.url();
|
|
530
|
+
let newTitle = '';
|
|
531
|
+
try {
|
|
532
|
+
newTitle = await frame.title();
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
debugLog('Failed to get page title:', error);
|
|
536
|
+
}
|
|
537
|
+
this.stateManager.updateStateFromBasic(newUrl, newTitle, 'navigation');
|
|
538
|
+
await sleep(500);
|
|
539
|
+
});
|
|
624
540
|
}
|
|
625
|
-
async
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
541
|
+
async closeOtherTabs() {
|
|
542
|
+
if (!this.playwrightHelper)
|
|
543
|
+
return;
|
|
544
|
+
const context = this.playwrightHelper.page.context();
|
|
545
|
+
const pages = context.pages();
|
|
546
|
+
if (pages.length <= 1)
|
|
547
|
+
return;
|
|
548
|
+
debugLog(`Found ${pages.length} tabs, cleaning up to keep only the first one`);
|
|
549
|
+
const firstPage = pages[0];
|
|
550
|
+
const tabsToClose = pages.slice(1);
|
|
551
|
+
for (const page of tabsToClose) {
|
|
552
|
+
await page.close();
|
|
553
|
+
debugLog(`Closed extra tab: ${await page.url()}`);
|
|
630
554
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
this.watchActiveTestPage();
|
|
635
|
-
return true;
|
|
555
|
+
await firstPage.bringToFront();
|
|
556
|
+
await this.playwrightHelper._setPage(firstPage);
|
|
557
|
+
debugLog(`Cleaned up tabs, now focused on: ${await firstPage.url()}`);
|
|
636
558
|
}
|
|
637
|
-
async
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
};
|
|
559
|
+
async finishTest(test, meta) {
|
|
560
|
+
this.unwatchActiveTestPages();
|
|
561
|
+
this._activeTest = null;
|
|
562
|
+
const lastScreenshot = this.stateManager.getCurrentState()?.screenshotFile;
|
|
563
|
+
if (lastScreenshot) {
|
|
564
|
+
meta ||= {};
|
|
565
|
+
meta.screenshotFile = lastScreenshot;
|
|
645
566
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
};
|
|
567
|
+
await this.reporter.reportTest(test, meta);
|
|
568
|
+
const codeceptjsTest = toCodeceptjsTest(test);
|
|
569
|
+
if (test.isSuccessful) {
|
|
570
|
+
codeceptjsTest.state = 'passed';
|
|
571
|
+
codeceptjs.event.dispatcher.emit('test.passed', codeceptjsTest);
|
|
652
572
|
}
|
|
653
|
-
if (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
message: `Previous execution error: ${message}. Investigate the current state and choose a different approach.`,
|
|
657
|
-
};
|
|
573
|
+
else if (test.isSkipped) {
|
|
574
|
+
codeceptjsTest.state = 'skipped';
|
|
575
|
+
codeceptjs.event.dispatcher.emit('test.skipped', codeceptjsTest);
|
|
658
576
|
}
|
|
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
|
-
};
|
|
577
|
+
else {
|
|
578
|
+
codeceptjsTest.state = 'failed';
|
|
579
|
+
codeceptjs.event.dispatcher.emit('test.failed', codeceptjsTest);
|
|
668
580
|
}
|
|
669
|
-
|
|
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
|
-
};
|
|
581
|
+
codeceptjs.event.dispatcher.emit('test.finish', codeceptjsTest);
|
|
582
|
+
codeceptjs.event.dispatcher.emit('test.after', codeceptjsTest);
|
|
680
583
|
}
|
|
681
584
|
watchActiveTestPage(page = this.playwrightHelper?.page) {
|
|
682
585
|
if (!this._activeTest)
|
|
@@ -703,31 +606,6 @@ class Explorer {
|
|
|
703
606
|
page.on('dialog', this.testDialogHandler);
|
|
704
607
|
this.observedTestPages.add(page);
|
|
705
608
|
}
|
|
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
609
|
unwatchActiveTestPages() {
|
|
732
610
|
for (const page of this.observedTestPages) {
|
|
733
611
|
if (this.testPageErrorHandler)
|
|
@@ -739,71 +617,6 @@ class Explorer {
|
|
|
739
617
|
}
|
|
740
618
|
this.observedTestPages.clear();
|
|
741
619
|
}
|
|
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
620
|
}
|
|
808
621
|
function toCodeceptjsTest(test) {
|
|
809
622
|
const parent = {
|
|
@@ -818,58 +631,4 @@ function toCodeceptjsTest(test) {
|
|
|
818
631
|
codeceptjsTest._explorbotTest = test;
|
|
819
632
|
return codeceptjsTest;
|
|
820
633
|
}
|
|
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
634
|
export default Explorer;
|