@leera.io/qa-runner 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +64 -0
- package/README.md +333 -0
- package/dist/src/appium/drivers.js +60 -0
- package/dist/src/appium/home.js +60 -0
- package/dist/src/appium/server.js +237 -0
- package/dist/src/builds/cache.js +133 -0
- package/dist/src/builds/cleanup.js +208 -0
- package/dist/src/builds/download.js +64 -0
- package/dist/src/builds/install.js +75 -0
- package/dist/src/builds/ios-app.js +99 -0
- package/dist/src/builds/unpack.js +347 -0
- package/dist/src/capabilities.js +93 -0
- package/dist/src/ci/builds.js +68 -0
- package/dist/src/ci/client.js +90 -0
- package/dist/src/ci/env.js +118 -0
- package/dist/src/ci/junit.js +79 -0
- package/dist/src/ci/run.js +320 -0
- package/dist/src/ci/summary.js +126 -0
- package/dist/src/cli/commands/builds.js +104 -0
- package/dist/src/cli/commands/ci.js +167 -0
- package/dist/src/cli/commands/config.js +83 -0
- package/dist/src/cli/commands/connect.js +70 -0
- package/dist/src/cli/commands/doctor.js +57 -0
- package/dist/src/cli/commands/service.js +165 -0
- package/dist/src/cli/commands/setup.js +193 -0
- package/dist/src/cli/commands/start.js +94 -0
- package/dist/src/cli/commands/update.js +189 -0
- package/dist/src/cli/commands/version.js +28 -0
- package/dist/src/cli/main.js +59 -0
- package/dist/src/cli/output.js +67 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/client.js +146 -0
- package/dist/src/config.js +260 -0
- package/dist/src/debug.js +137 -0
- package/dist/src/devices/android/adb.js +251 -0
- package/dist/src/devices/android/avd.js +95 -0
- package/dist/src/devices/android/emulator.js +153 -0
- package/dist/src/devices/android/index.js +133 -0
- package/dist/src/devices/android/logcat.js +205 -0
- package/dist/src/devices/android/prepare.js +40 -0
- package/dist/src/devices/android/setup.js +162 -0
- package/dist/src/devices/android/system-dialog.js +78 -0
- package/dist/src/devices/desktop.js +145 -0
- package/dist/src/devices/ios/devicectl.js +87 -0
- package/dist/src/devices/ios/index.js +130 -0
- package/dist/src/devices/ios/prepare.js +39 -0
- package/dist/src/devices/ios/record.js +122 -0
- package/dist/src/devices/ios/settings.js +55 -0
- package/dist/src/devices/ios/setup.js +186 -0
- package/dist/src/devices/ios/simctl.js +201 -0
- package/dist/src/devices/ios/syslog.js +185 -0
- package/dist/src/devices/ios/wda.js +108 -0
- package/dist/src/devices/macos.js +248 -0
- package/dist/src/devices/manager.js +206 -0
- package/dist/src/devices/screen-record.js +95 -0
- package/dist/src/devices/tauri.js +189 -0
- package/dist/src/devices/types.js +6 -0
- package/dist/src/devices/windows.js +362 -0
- package/dist/src/doctor.js +394 -0
- package/dist/src/download.js +39 -0
- package/dist/src/drivers/android-appium.js +525 -0
- package/dist/src/drivers/common.js +64 -0
- package/dist/src/drivers/desktop-appium.js +282 -0
- package/dist/src/drivers/driver.js +15 -0
- package/dist/src/drivers/electron-playwright.js +566 -0
- package/dist/src/drivers/ios-appium.js +535 -0
- package/dist/src/drivers/mac2-appium.js +313 -0
- package/dist/src/drivers/registry.js +47 -0
- package/dist/src/drivers/snapshot/aria.js +351 -0
- package/dist/src/drivers/snapshot/format.js +106 -0
- package/dist/src/drivers/tauri-webdriver.js +678 -0
- package/dist/src/drivers/web-playwright.js +403 -0
- package/dist/src/drivers/webdriver/actions.js +328 -0
- package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
- package/dist/src/drivers/webdriver/dom-locate.js +113 -0
- package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
- package/dist/src/drivers/webdriver/dom-tree.js +118 -0
- package/dist/src/drivers/webdriver/dom.js +213 -0
- package/dist/src/drivers/webdriver/ios-actions.js +299 -0
- package/dist/src/drivers/webdriver/ios-locate.js +121 -0
- package/dist/src/drivers/webdriver/locate.js +136 -0
- package/dist/src/drivers/webdriver/native-actions.js +175 -0
- package/dist/src/drivers/webdriver/native-locate.js +273 -0
- package/dist/src/drivers/webdriver/session.js +105 -0
- package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
- package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
- package/dist/src/drivers/webdriver/xml-tree.js +393 -0
- package/dist/src/drivers/windows-appium.js +319 -0
- package/dist/src/errors.js +21 -0
- package/dist/src/executor.js +189 -0
- package/dist/src/home.js +76 -0
- package/dist/src/index.js +22 -0
- package/dist/src/log.js +69 -0
- package/dist/src/runner.js +498 -0
- package/dist/src/service/index.js +64 -0
- package/dist/src/service/launchd.js +85 -0
- package/dist/src/service/names.js +2 -0
- package/dist/src/service/schtasks.js +128 -0
- package/dist/src/service/systemd.js +65 -0
- package/dist/src/session/commands.js +165 -0
- package/dist/src/session/execute.js +232 -0
- package/dist/src/session/loop.js +148 -0
- package/dist/src/template.js +62 -0
- package/dist/src/types.js +7 -0
- package/dist/src/update/apply.js +223 -0
- package/dist/src/update/check.js +59 -0
- package/dist/src/update/manifest.js +93 -0
- package/dist/src/update/verify.js +65 -0
- package/dist/src/version.js +42 -0
- package/package.json +44 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
7
|
+
import { chromium } from 'playwright-core';
|
|
8
|
+
import { DebugCollector } from '../debug.js';
|
|
9
|
+
import { BROWSER_DEVICE_ID } from '../devices/types.js';
|
|
10
|
+
import { resolveUrl } from '../template.js';
|
|
11
|
+
import { clip, errorMessage, expectWithin, firstLine, matches, POLL_MS, render, requireTarget, verb, } from './common.js';
|
|
12
|
+
import { Blocked, Unsupported, } from './driver.js';
|
|
13
|
+
import { ariaRefSelector, refFromElementSnapshot, webScreen, webSnapshot } from './snapshot/aria.js';
|
|
14
|
+
const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
15
|
+
/** Smaller than the viewport, which keeps a long case's video well under the upload limit. */
|
|
16
|
+
const VIDEO_SIZE = { width: 1024, height: 576 };
|
|
17
|
+
export const WEB_ACTIONS = [
|
|
18
|
+
'goto',
|
|
19
|
+
'click',
|
|
20
|
+
'hover',
|
|
21
|
+
'check',
|
|
22
|
+
'uncheck',
|
|
23
|
+
'fill',
|
|
24
|
+
'select_option',
|
|
25
|
+
'press',
|
|
26
|
+
'wait_for',
|
|
27
|
+
'wait',
|
|
28
|
+
'expect_visible',
|
|
29
|
+
'expect_hidden',
|
|
30
|
+
'expect_text',
|
|
31
|
+
'expect_url',
|
|
32
|
+
'expect_title',
|
|
33
|
+
'screenshot',
|
|
34
|
+
];
|
|
35
|
+
export function launchBrowser(options) {
|
|
36
|
+
return chromium.launch({
|
|
37
|
+
headless: options.headless,
|
|
38
|
+
...(options.proxy ? { proxy: { server: options.proxy } } : {}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Playwright's own ffmpeg, which `recordVideo` needs (`setup browsers` installs it with Chromium).
|
|
43
|
+
* Without it playwright-core 1.63 still opens the page, but every call on that page then waits
|
|
44
|
+
* forever, so video is only requested when ffmpeg is there. Null when it is not installed;
|
|
45
|
+
* undefined when this playwright-core no longer exposes its registry (video is then requested
|
|
46
|
+
* as before, and the unit test pinning this lookup fails on the upgrade).
|
|
47
|
+
*/
|
|
48
|
+
export function playwrightFfmpeg() {
|
|
49
|
+
let path;
|
|
50
|
+
try {
|
|
51
|
+
const core = createRequire(import.meta.url)('playwright-core/lib/coreBundle');
|
|
52
|
+
path = core.registry?.registry?.findExecutable?.('ffmpeg')?.executablePath?.('javascript');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
if (typeof path !== 'string' || !path)
|
|
58
|
+
return undefined;
|
|
59
|
+
return existsSync(path) ? path : null;
|
|
60
|
+
}
|
|
61
|
+
/** Warning logged when a job wants video but Playwright's ffmpeg is missing. */
|
|
62
|
+
export const NO_FFMPEG_VIDEO = "video is not recorded: Playwright's ffmpeg is not installed (run `leera-qa-runner setup browsers`)";
|
|
63
|
+
/** The Chromium executable Playwright would launch, or null when it is not installed. */
|
|
64
|
+
export function chromiumExecutable() {
|
|
65
|
+
try {
|
|
66
|
+
const path = chromium.executablePath();
|
|
67
|
+
return path && existsSync(path) ? path : null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** One browser shared by every web job of the process; relaunched when it disconnects. */
|
|
74
|
+
export class BrowserPool {
|
|
75
|
+
browser = null;
|
|
76
|
+
launching = null;
|
|
77
|
+
options;
|
|
78
|
+
constructor(options) {
|
|
79
|
+
this.options = options;
|
|
80
|
+
}
|
|
81
|
+
/** Wraps a browser someone else launched (tests). */
|
|
82
|
+
static of(browser) {
|
|
83
|
+
const pool = new BrowserPool({ headless: true });
|
|
84
|
+
pool.browser = browser;
|
|
85
|
+
return pool;
|
|
86
|
+
}
|
|
87
|
+
async get() {
|
|
88
|
+
if (this.browser?.isConnected())
|
|
89
|
+
return this.browser;
|
|
90
|
+
this.launching ??= launchBrowser(this.options)
|
|
91
|
+
.then((browser) => {
|
|
92
|
+
this.browser = browser;
|
|
93
|
+
return browser;
|
|
94
|
+
})
|
|
95
|
+
.finally(() => {
|
|
96
|
+
this.launching = null;
|
|
97
|
+
});
|
|
98
|
+
return this.launching;
|
|
99
|
+
}
|
|
100
|
+
async close() {
|
|
101
|
+
const browser = this.browser;
|
|
102
|
+
this.browser = null;
|
|
103
|
+
await browser?.close().catch(() => undefined);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export class WebPlaywrightDriver {
|
|
107
|
+
platform = 'web';
|
|
108
|
+
capabilities = {
|
|
109
|
+
actions: WEB_ACTIONS,
|
|
110
|
+
targets: ['role', 'label', 'placeholder', 'text', 'test_id', 'css'],
|
|
111
|
+
video: 'webm',
|
|
112
|
+
};
|
|
113
|
+
browser;
|
|
114
|
+
ctx;
|
|
115
|
+
context = null;
|
|
116
|
+
page = null;
|
|
117
|
+
collector = null;
|
|
118
|
+
video = null;
|
|
119
|
+
closed = false;
|
|
120
|
+
/** Set once a snapshot was taken, so `find` can report refs (they resolve against it). */
|
|
121
|
+
snapshotTaken = false;
|
|
122
|
+
constructor(browser, ctx) {
|
|
123
|
+
this.browser = browser;
|
|
124
|
+
this.ctx = ctx;
|
|
125
|
+
}
|
|
126
|
+
async launch() {
|
|
127
|
+
let recordVideo = this.ctx.video !== 'off';
|
|
128
|
+
if (recordVideo && playwrightFfmpeg() === null) {
|
|
129
|
+
this.ctx.log.warn(NO_FFMPEG_VIDEO);
|
|
130
|
+
recordVideo = false;
|
|
131
|
+
}
|
|
132
|
+
// A fresh context per job: no cookies, storage or cache carried over from the previous one.
|
|
133
|
+
this.context = await this.browser.newContext({
|
|
134
|
+
viewport: { width: 1366, height: 768 },
|
|
135
|
+
acceptDownloads: false,
|
|
136
|
+
...(recordVideo ? { recordVideo: { dir: await mkdtemp(join(tmpdir(), 'qa-video-')), size: VIDEO_SIZE } } : {}),
|
|
137
|
+
});
|
|
138
|
+
const page = await this.context.newPage();
|
|
139
|
+
this.page = page;
|
|
140
|
+
// The video starts with the page, so step offsets are measured from here.
|
|
141
|
+
this.collector = new DebugCollector(Date.now(), this.ctx.redact);
|
|
142
|
+
this.collector.attach(page);
|
|
143
|
+
this.video = page.video();
|
|
144
|
+
page.setDefaultTimeout(this.ctx.timeouts.actionMs);
|
|
145
|
+
page.setDefaultNavigationTimeout(Math.max(this.ctx.timeouts.actionMs, NAVIGATION_TIMEOUT_MS));
|
|
146
|
+
}
|
|
147
|
+
run(action) {
|
|
148
|
+
return runWebAction(this.requirePage(), action, this.ctx.template, this.ctx.timeouts.actionMs);
|
|
149
|
+
}
|
|
150
|
+
/** Counts matches; after a snapshot also names the refs of the first 20 matches. */
|
|
151
|
+
find(target) {
|
|
152
|
+
return findOnPage(this.requirePage(), target, this.ctx.template, this.snapshotTaken);
|
|
153
|
+
}
|
|
154
|
+
async snapshot(options = {}) {
|
|
155
|
+
const snapshot = await webSnapshot(this.requirePage(), options);
|
|
156
|
+
this.snapshotTaken = true;
|
|
157
|
+
return snapshot;
|
|
158
|
+
}
|
|
159
|
+
screen() {
|
|
160
|
+
return webScreen(this.requirePage());
|
|
161
|
+
}
|
|
162
|
+
async tapAt(x, y) {
|
|
163
|
+
await this.requirePage().mouse.click(x, y);
|
|
164
|
+
}
|
|
165
|
+
async back() {
|
|
166
|
+
await this.requirePage().goBack({ timeout: this.ctx.timeouts.actionMs });
|
|
167
|
+
}
|
|
168
|
+
async home() {
|
|
169
|
+
const base = this.ctx.template.baseUrl;
|
|
170
|
+
if (!base)
|
|
171
|
+
throw new Unsupported('the environment has no base URL to go home to');
|
|
172
|
+
await this.requirePage().goto(base, { waitUntil: 'load', timeout: Math.max(this.ctx.timeouts.actionMs, NAVIGATION_TIMEOUT_MS) });
|
|
173
|
+
}
|
|
174
|
+
screenshot() {
|
|
175
|
+
return this.requirePage().screenshot({ type: 'png' });
|
|
176
|
+
}
|
|
177
|
+
source() {
|
|
178
|
+
return this.requirePage().content();
|
|
179
|
+
}
|
|
180
|
+
beginStep(index) {
|
|
181
|
+
this.collector?.begin(index);
|
|
182
|
+
}
|
|
183
|
+
debug(index) {
|
|
184
|
+
return this.collector?.for(index);
|
|
185
|
+
}
|
|
186
|
+
async stopRecording() {
|
|
187
|
+
// The video file is only complete once the context has closed.
|
|
188
|
+
await this.closeContext();
|
|
189
|
+
return this.video ? await this.video.path().catch(() => undefined) : undefined;
|
|
190
|
+
}
|
|
191
|
+
async dispose() {
|
|
192
|
+
await this.closeContext();
|
|
193
|
+
}
|
|
194
|
+
async closeContext() {
|
|
195
|
+
if (this.closed || !this.context)
|
|
196
|
+
return;
|
|
197
|
+
this.closed = true;
|
|
198
|
+
await this.context.close().catch(() => undefined);
|
|
199
|
+
}
|
|
200
|
+
requirePage() {
|
|
201
|
+
if (!this.page)
|
|
202
|
+
throw new Error('the page is not open');
|
|
203
|
+
return this.page;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export const webPlaywrightFactory = {
|
|
207
|
+
platform: 'web',
|
|
208
|
+
async availability(_host) {
|
|
209
|
+
const executable = chromiumExecutable();
|
|
210
|
+
if (!executable) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
labels: [],
|
|
214
|
+
devices: [],
|
|
215
|
+
reasons: ['Chromium is not installed: run `leera-qa-runner setup browsers`'],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
labels: ['web-chromium'],
|
|
221
|
+
devices: [
|
|
222
|
+
{
|
|
223
|
+
id: BROWSER_DEVICE_ID,
|
|
224
|
+
platform: 'web',
|
|
225
|
+
kind: 'host',
|
|
226
|
+
name: 'Chromium',
|
|
227
|
+
os_version: null,
|
|
228
|
+
state: 'available',
|
|
229
|
+
labels: ['web-chromium'],
|
|
230
|
+
can_boot: false,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
reasons: [],
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
async create(ctx, shared) {
|
|
237
|
+
return new WebPlaywrightDriver(await shared.browsers.get(), ctx);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
/**
|
|
241
|
+
* Counts a target's matches on a page; after a snapshot also names the refs of the first 20
|
|
242
|
+
* matches (web and Electron windows).
|
|
243
|
+
*/
|
|
244
|
+
export async function findOnPage(page, target, ctx, snapshotTaken) {
|
|
245
|
+
const locator = locate(page, target, ctx);
|
|
246
|
+
const count = await locator.count();
|
|
247
|
+
if (!snapshotTaken || count === 0)
|
|
248
|
+
return { count };
|
|
249
|
+
const refs = [];
|
|
250
|
+
for (let i = 0; i < Math.min(count, 20); i += 1) {
|
|
251
|
+
const yaml = await locator
|
|
252
|
+
.nth(i)
|
|
253
|
+
.ariaSnapshot({ mode: 'ai', depth: 1, timeout: 2_000 })
|
|
254
|
+
.catch(() => '');
|
|
255
|
+
const ref = refFromElementSnapshot(yaml);
|
|
256
|
+
if (ref)
|
|
257
|
+
refs.push(ref);
|
|
258
|
+
}
|
|
259
|
+
// An element's own snapshot replaces the page's ref table; elements keep their refs, so re-read it.
|
|
260
|
+
await page.ariaSnapshot({ mode: 'ai' }).catch(() => undefined);
|
|
261
|
+
return { count, refs };
|
|
262
|
+
}
|
|
263
|
+
/** Performs one web DSL action on a page (shared by the web and Electron drivers). */
|
|
264
|
+
export async function runWebAction(page, action, ctx, defaultTimeout) {
|
|
265
|
+
const timeout = action.timeout_ms ?? defaultTimeout;
|
|
266
|
+
switch (action.type) {
|
|
267
|
+
case 'goto': {
|
|
268
|
+
const url = resolveUrl(render(action.url, ctx), ctx.baseUrl);
|
|
269
|
+
try {
|
|
270
|
+
await page.goto(url, { waitUntil: 'load', timeout: Math.max(timeout, NAVIGATION_TIMEOUT_MS) });
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (errorMessage(error).includes('net::ERR_')) {
|
|
274
|
+
throw new Blocked(`the environment could not be reached (${firstLine(error)})`);
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
case 'click':
|
|
281
|
+
await (await find(page, action, ctx, timeout)).click({ timeout });
|
|
282
|
+
return {};
|
|
283
|
+
case 'hover':
|
|
284
|
+
await (await find(page, action, ctx, timeout)).hover({ timeout });
|
|
285
|
+
return {};
|
|
286
|
+
case 'check':
|
|
287
|
+
await (await find(page, action, ctx, timeout)).check({ timeout });
|
|
288
|
+
return {};
|
|
289
|
+
case 'uncheck':
|
|
290
|
+
await (await find(page, action, ctx, timeout)).uncheck({ timeout });
|
|
291
|
+
return {};
|
|
292
|
+
case 'fill':
|
|
293
|
+
await (await find(page, action, ctx, timeout)).fill(render(action.value, ctx), { timeout });
|
|
294
|
+
return {};
|
|
295
|
+
case 'select_option':
|
|
296
|
+
await (await find(page, action, ctx, timeout)).selectOption(render(action.value, ctx), { timeout });
|
|
297
|
+
return {};
|
|
298
|
+
case 'press': {
|
|
299
|
+
const key = action.key ?? '';
|
|
300
|
+
if (action.target)
|
|
301
|
+
await (await find(page, action, ctx, timeout)).press(key, { timeout });
|
|
302
|
+
else
|
|
303
|
+
await page.keyboard.press(key);
|
|
304
|
+
return {};
|
|
305
|
+
}
|
|
306
|
+
case 'wait_for':
|
|
307
|
+
await (await find(page, action, ctx, timeout)).waitFor({ state: 'visible', timeout });
|
|
308
|
+
return {};
|
|
309
|
+
case 'wait':
|
|
310
|
+
await sleep(Math.min(action.ms ?? 0, 30_000));
|
|
311
|
+
return {};
|
|
312
|
+
case 'expect_visible': {
|
|
313
|
+
const locator = await find(page, action, ctx, timeout);
|
|
314
|
+
await expectWithin(timeout, () => locator.isVisible(), 'to be visible');
|
|
315
|
+
return {};
|
|
316
|
+
}
|
|
317
|
+
case 'expect_hidden': {
|
|
318
|
+
const locator = locate(page, requireTarget(action), ctx);
|
|
319
|
+
await expectWithin(timeout, async () => !(await locator.isVisible()), 'to be hidden');
|
|
320
|
+
return {};
|
|
321
|
+
}
|
|
322
|
+
case 'expect_text': {
|
|
323
|
+
const locator = await find(page, action, ctx, timeout);
|
|
324
|
+
const expected = render(action.value, ctx);
|
|
325
|
+
let actual = '';
|
|
326
|
+
await expectWithin(timeout, async () => {
|
|
327
|
+
actual = (await locator.innerText({ timeout: 1_000 })).trim();
|
|
328
|
+
return matches(actual, expected, action.match);
|
|
329
|
+
}, () => `text to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
|
|
330
|
+
return {};
|
|
331
|
+
}
|
|
332
|
+
case 'expect_url': {
|
|
333
|
+
const expected = render(action.value, ctx);
|
|
334
|
+
await expectWithin(timeout, async () => matches(page.url(), expected, action.match), () => `the URL to ${verb(action.match)} "${expected}", found "${clip(page.url())}"`);
|
|
335
|
+
return {};
|
|
336
|
+
}
|
|
337
|
+
case 'expect_title': {
|
|
338
|
+
const expected = render(action.value, ctx);
|
|
339
|
+
let actual = '';
|
|
340
|
+
await expectWithin(timeout, async () => {
|
|
341
|
+
actual = await page.title();
|
|
342
|
+
return matches(actual, expected, action.match);
|
|
343
|
+
}, () => `the title to ${verb(action.match)} "${expected}", found "${clip(actual)}"`);
|
|
344
|
+
return {};
|
|
345
|
+
}
|
|
346
|
+
case 'screenshot':
|
|
347
|
+
return { screenshot: await page.screenshot({ type: 'png' }) };
|
|
348
|
+
default:
|
|
349
|
+
throw new Error(`unsupported action '${action.type}'`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** The primary target, or the first fallback that appears on the page before the timeout. */
|
|
353
|
+
async function find(page, action, ctx, timeout) {
|
|
354
|
+
const target = requireTarget(action);
|
|
355
|
+
const primary = locate(page, target, ctx);
|
|
356
|
+
const candidates = [primary, ...(target.fallbacks ?? []).map((fallback) => locate(page, fallback, ctx))];
|
|
357
|
+
if (candidates.length === 1)
|
|
358
|
+
return primary;
|
|
359
|
+
const deadline = Date.now() + timeout;
|
|
360
|
+
for (;;) {
|
|
361
|
+
for (const candidate of candidates) {
|
|
362
|
+
if ((await candidate.count().catch(() => 0)) > 0)
|
|
363
|
+
return candidate;
|
|
364
|
+
}
|
|
365
|
+
if (Date.now() >= deadline)
|
|
366
|
+
return primary;
|
|
367
|
+
await sleep(POLL_MS);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function locate(page, target, ctx) {
|
|
371
|
+
const exact = target.exact;
|
|
372
|
+
let locator;
|
|
373
|
+
const ref = ariaRefSelector(target.locate);
|
|
374
|
+
if (ref) {
|
|
375
|
+
// A session ref: the element from the last snapshot itself.
|
|
376
|
+
return page.locator(ref);
|
|
377
|
+
}
|
|
378
|
+
if (target.role) {
|
|
379
|
+
locator = page.getByRole(target.role, {
|
|
380
|
+
...(target.name ? { name: render(target.name, ctx) } : {}),
|
|
381
|
+
...(exact === undefined ? {} : { exact }),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
else if (target.label) {
|
|
385
|
+
locator = page.getByLabel(render(target.label, ctx), { exact });
|
|
386
|
+
}
|
|
387
|
+
else if (target.placeholder) {
|
|
388
|
+
locator = page.getByPlaceholder(render(target.placeholder, ctx), { exact });
|
|
389
|
+
}
|
|
390
|
+
else if (target.text) {
|
|
391
|
+
locator = page.getByText(render(target.text, ctx), { exact });
|
|
392
|
+
}
|
|
393
|
+
else if (target.test_id) {
|
|
394
|
+
locator = page.getByTestId(render(target.test_id, ctx));
|
|
395
|
+
}
|
|
396
|
+
else if (target.css) {
|
|
397
|
+
locator = page.locator(render(target.css, ctx));
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
throw new Error('the target names no locator');
|
|
401
|
+
}
|
|
402
|
+
return target.nth === undefined ? locator : locator.nth(target.nth);
|
|
403
|
+
}
|