mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-18 15:42:18 +02:00
refactor: cleanup
This commit is contained in:
+63
-50
@@ -91,6 +91,30 @@ export class AppSession {
|
||||
return path.join(this.root, "donut");
|
||||
}
|
||||
|
||||
/** Where this session's app looks for the Wayfern terms marker. */
|
||||
get wayfernTermsFile() {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(
|
||||
this.root,
|
||||
"home",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(
|
||||
this.root,
|
||||
"windows",
|
||||
"roaming",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
return path.join(this.root, "xdg", "config", "Wayfern", "license-accepted");
|
||||
}
|
||||
|
||||
async start() {
|
||||
await Promise.all([
|
||||
mkdir(path.join(this.root, "home"), { recursive: true }),
|
||||
@@ -126,31 +150,7 @@ export class AppSession {
|
||||
});
|
||||
}
|
||||
if (this.wayfernTermsAccepted) {
|
||||
const termsFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(
|
||||
this.root,
|
||||
"home",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
)
|
||||
: process.platform === "win32"
|
||||
? path.join(
|
||||
this.root,
|
||||
"windows",
|
||||
"roaming",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
)
|
||||
: path.join(
|
||||
this.root,
|
||||
"xdg",
|
||||
"config",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
const termsFile = this.wayfernTermsFile;
|
||||
await mkdir(path.dirname(termsFile), { recursive: true });
|
||||
await writeFile(termsFile, `${Math.floor(Date.now() / 1000)}\n`, {
|
||||
flag: "wx",
|
||||
@@ -244,6 +244,14 @@ export class AppSession {
|
||||
DONUT_E2E_GEOIP_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip.mmdb`,
|
||||
}
|
||||
: {}),
|
||||
// The city database has no organisation for an address; the ASN
|
||||
// one does, and it is what a proxy check reports as the exit's
|
||||
// ISP. Seeded separately so the suite can assert a real value.
|
||||
...(process.env.DONUT_E2E_GEOIP_ASN_FIXTURE_READY === "1"
|
||||
? {
|
||||
DONUT_E2E_GEOIP_ASN_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip-asn.mmdb`,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(this.token ? { WAYFERN_TEST_TOKEN: this.token } : {}),
|
||||
@@ -255,6 +263,11 @@ export class AppSession {
|
||||
env,
|
||||
cwd: this.cwd,
|
||||
startupTimeout: 120_000,
|
||||
// Set by run.mjs for every suite. The driver keeps the Donut window off
|
||||
// the user's screen (on macOS transparent, click-through and never key,
|
||||
// with the app as an accessory; hidden elsewhere), so a suite never
|
||||
// pops a window or steals focus.
|
||||
headless: process.env.DONUT_E2E_HEADLESS === "1",
|
||||
});
|
||||
await this.session.setTimeouts();
|
||||
await this.waitFor(
|
||||
@@ -369,13 +382,19 @@ export class AppSession {
|
||||
});
|
||||
}
|
||||
|
||||
async clickElement(element, description = "element") {
|
||||
async clickElement(target, description = "element") {
|
||||
let element;
|
||||
await this.waitFor(
|
||||
() =>
|
||||
this.execute(
|
||||
async () => {
|
||||
// Event-backed tables may replace a cell while its data is loading.
|
||||
// Resolve the current control on each attempt, as a browser locator does.
|
||||
element = typeof target === "function" ? await target() : target;
|
||||
if (!element) return false;
|
||||
return this.execute(
|
||||
`
|
||||
const node = arguments[0];
|
||||
if (!(node instanceof Element) || !node.isConnected) return false;
|
||||
if (node.matches(":disabled") || node.getAttribute("aria-disabled") === "true") return false;
|
||||
node.scrollIntoView({ block: "center", inline: "center" });
|
||||
const rect = node.getBoundingClientRect();
|
||||
const x = Math.floor(rect.left + rect.width / 2);
|
||||
@@ -384,7 +403,8 @@ export class AppSession {
|
||||
return Boolean(hit && (hit === node || node.contains(hit)));
|
||||
`,
|
||||
[element],
|
||||
),
|
||||
);
|
||||
},
|
||||
{ description: `pointer-interactable ${description}` },
|
||||
);
|
||||
await this.session.click(element);
|
||||
@@ -394,8 +414,9 @@ export class AppSession {
|
||||
text,
|
||||
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||
) {
|
||||
const element = await this.execute(
|
||||
`
|
||||
const findElement = () =>
|
||||
this.execute(
|
||||
`
|
||||
const wanted = arguments[0];
|
||||
const exact = arguments[1];
|
||||
const roles = new Set(arguments[2]);
|
||||
@@ -412,13 +433,9 @@ export class AppSession {
|
||||
return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted));
|
||||
}) ?? null;
|
||||
`,
|
||||
[text, exact, roles],
|
||||
);
|
||||
assert.ok(
|
||||
element,
|
||||
`No visible interactive element matched ${JSON.stringify(text)}`,
|
||||
);
|
||||
await this.clickElement(element, JSON.stringify(text));
|
||||
[text, exact, roles],
|
||||
);
|
||||
await this.clickElement(findElement, JSON.stringify(text));
|
||||
}
|
||||
|
||||
async clickTextIn(
|
||||
@@ -426,8 +443,9 @@ export class AppSession {
|
||||
text,
|
||||
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||
) {
|
||||
const element = await this.execute(
|
||||
`
|
||||
const findElement = () =>
|
||||
this.execute(
|
||||
`
|
||||
const containers = [...document.querySelectorAll(arguments[0])];
|
||||
const wanted = arguments[1];
|
||||
const exact = arguments[2];
|
||||
@@ -450,20 +468,16 @@ export class AppSession {
|
||||
}
|
||||
return null;
|
||||
`,
|
||||
[containerSelector, text, exact, roles],
|
||||
);
|
||||
assert.ok(
|
||||
element,
|
||||
`No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`,
|
||||
);
|
||||
[containerSelector, text, exact, roles],
|
||||
);
|
||||
await this.clickElement(
|
||||
element,
|
||||
findElement,
|
||||
`${JSON.stringify(text)} inside ${containerSelector}`,
|
||||
);
|
||||
}
|
||||
|
||||
async clickSelector(selector) {
|
||||
const element = await this.waitFor(
|
||||
await this.clickElement(
|
||||
() =>
|
||||
this.execute(
|
||||
`
|
||||
@@ -476,9 +490,8 @@ export class AppSession {
|
||||
`,
|
||||
[selector],
|
||||
),
|
||||
{ description: `visible selector ${selector}` },
|
||||
selector,
|
||||
);
|
||||
await this.clickElement(element, selector);
|
||||
}
|
||||
|
||||
async fillSelector(selector, value) {
|
||||
|
||||
+186
-25
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
@@ -15,6 +15,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { crc32 } from "node:zlib";
|
||||
import {
|
||||
WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS,
|
||||
WAYFERN_DOWNLOAD_TIMEOUT_MS,
|
||||
} from "./limits.mjs";
|
||||
|
||||
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
||||
|
||||
@@ -31,6 +35,43 @@ export function defaultWayfernPath(projectRoot) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the cache fixture records which PUBLISHED version it was installed for.
|
||||
*
|
||||
* The bundle's own `CFBundleShortVersionString` cannot answer that question: a
|
||||
* published version and the version stamped inside the bundle it serves do not
|
||||
* always agree, and the app keys everything (download registry, profile
|
||||
* `version`, release types) off the PUBLISHED string. Comparing the bundle's
|
||||
* own version against the published one would therefore call an up-to-date
|
||||
* fixture stale and re-download 1 GB on every single run.
|
||||
*/
|
||||
function fixtureStampPath(projectRoot) {
|
||||
return path.join(
|
||||
path.dirname(defaultWayfernPath(projectRoot)),
|
||||
"published-version.txt",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The published version the cache fixture stands for, or `null` when there is
|
||||
* no fixture.
|
||||
*
|
||||
* Falls back to the bundle's own version when no stamp is present, which is
|
||||
* what a hand-installed fixture looks like: it is only right when the two
|
||||
* agree, and when they do not the fixture is replaced, which is the safe way
|
||||
* to be wrong.
|
||||
*/
|
||||
export function cachedFixtureVersion(projectRoot) {
|
||||
const bundle = defaultWayfernPath(projectRoot);
|
||||
if (!existsSync(bundle)) return null;
|
||||
const stamp = fixtureStampPath(projectRoot);
|
||||
if (existsSync(stamp)) {
|
||||
const recorded = readFileSync(stamp, "utf8").trim();
|
||||
if (recorded) return recorded;
|
||||
}
|
||||
return inspectWayfern(bundle).version;
|
||||
}
|
||||
|
||||
export function wayfernExecutable(bundlePath) {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(bundlePath, "Contents", "MacOS", "Wayfern");
|
||||
@@ -80,10 +121,57 @@ async function cloneAppBundle(source, destination) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the app itself resolves the current Wayfern build (api_client.rs). */
|
||||
const WAYFERN_RELEASE_URL = "https://donutbrowser.com/wayfern.json";
|
||||
|
||||
/**
|
||||
* The newest published Wayfern version, read from the same manifest the app
|
||||
* reads.
|
||||
*
|
||||
* Deliberately NOT asked of a running app session. Seeding a browser into a
|
||||
* session's data root only works before that session starts: a running app
|
||||
* runs `cleanup_unused_binaries`, which deletes any binary directory no
|
||||
* profile references, and a just-seeded fixture is exactly that. Resolving the
|
||||
* version over plain HTTP keeps the seed ahead of app startup.
|
||||
*/
|
||||
async function publishedWayfernVersion() {
|
||||
const response = await fetch(WAYFERN_RELEASE_URL, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
assert.ok(
|
||||
response.ok,
|
||||
`Could not read ${WAYFERN_RELEASE_URL}: HTTP ${response.status}`,
|
||||
);
|
||||
const manifest = await response.json();
|
||||
assert.ok(
|
||||
typeof manifest.version === "string" && manifest.version,
|
||||
`No Wayfern version published at ${WAYFERN_RELEASE_URL}`,
|
||||
);
|
||||
return manifest.version;
|
||||
}
|
||||
|
||||
async function downloadWayfern(app, version) {
|
||||
await app.session.setTimeouts({ script: WAYFERN_DOWNLOAD_TIMEOUT_MS });
|
||||
try {
|
||||
await app.invoke(
|
||||
"download_browser",
|
||||
{ browserStr: "wayfern", version },
|
||||
WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
await app.session.setTimeouts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the build this session just downloaded into the cache fixture, in place
|
||||
* of whatever build the cache held before. The swap goes through a staging
|
||||
* copy and renames, so a suite that dies mid-copy leaves the old fixture or
|
||||
* the new one on disk, never a half-written bundle.
|
||||
*/
|
||||
async function cacheDownloadedWayfern(app, projectRoot, version) {
|
||||
if (process.env.DONUT_E2E_WAYFERN_PATH) return;
|
||||
const destination = defaultWayfernPath(projectRoot);
|
||||
if (existsSync(destination)) return;
|
||||
|
||||
const installDir = path.join(
|
||||
app.dataRoot,
|
||||
@@ -100,7 +188,9 @@ async function cacheDownloadedWayfern(app, projectRoot, version) {
|
||||
process.platform === "win32" ? "wayfern.exe" : "wayfern",
|
||||
);
|
||||
const staging = `${destination}.tmp-${process.pid}`;
|
||||
const retired = `${destination}.stale-${process.pid}`;
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
await rm(retired, { recursive: true, force: true });
|
||||
try {
|
||||
if (process.platform === "darwin") {
|
||||
await cloneAppBundle(source, staging);
|
||||
@@ -109,10 +199,25 @@ async function cacheDownloadedWayfern(app, projectRoot, version) {
|
||||
await copyFile(source, staging);
|
||||
if (process.platform !== "win32") await chmod(staging, 0o755);
|
||||
}
|
||||
if (existsSync(destination)) await rename(destination, retired);
|
||||
await rename(staging, destination);
|
||||
// Stamped only after the bundle is in place, so an interrupted swap can
|
||||
// never leave a stamp claiming a version the fixture does not hold.
|
||||
await writeFile(fixtureStampPath(projectRoot), `${version}\n`);
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
if (!existsSync(destination) && existsSync(retired)) {
|
||||
await rename(retired, destination);
|
||||
}
|
||||
if (!existsSync(destination)) throw error;
|
||||
// The session itself runs the build it downloaded; only the cache is
|
||||
// behind, and the next run resolves the published version again and
|
||||
// replaces it then.
|
||||
console.warn(
|
||||
`[donut-e2e] Could not refresh the Wayfern fixture cache: ${error}`,
|
||||
);
|
||||
} finally {
|
||||
await rm(retired, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,36 +265,46 @@ export async function seedWayfern(dataRoot, wayfern) {
|
||||
return installDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the newest published Wayfern available to `app` and report the version
|
||||
* it will run.
|
||||
*
|
||||
* `DONUT_E2E_WAYFERN_PATH` pins an explicit bundle and is used as given: that
|
||||
* is how a locally built browser gets under test. Without it the suite runs
|
||||
* the build the product would offer today, always. The ignored cache fixture
|
||||
* only ever saves the download: it is used when it holds exactly that build
|
||||
* and replaced when it holds any other, so a cache filled months ago can never
|
||||
* quietly keep an old browser under test.
|
||||
*/
|
||||
export async function prepareWayfern(app, projectRoot) {
|
||||
const localBundle = defaultWayfernPath(projectRoot);
|
||||
if (existsSync(localBundle)) {
|
||||
if (process.env.DONUT_E2E_WAYFERN_PATH) {
|
||||
const wayfern = inspectWayfern(localBundle);
|
||||
await seedWayfern(app.dataRoot, wayfern);
|
||||
return { version: wayfern.version, source: "local fixture" };
|
||||
return { version: wayfern.version, source: "pinned fixture" };
|
||||
}
|
||||
|
||||
const version = await publishedWayfernVersion();
|
||||
const cachedVersion = cachedFixtureVersion(projectRoot);
|
||||
if (cachedVersion === version) {
|
||||
// Seeded under the PUBLISHED version, not the bundle's own, because that
|
||||
// is the string the app itself would have registered had it downloaded
|
||||
// this build, and what every later `version` assertion compares against.
|
||||
// Seeded BEFORE the app starts, or its unused-binary cleanup deletes it.
|
||||
await seedWayfern(app.dataRoot, {
|
||||
...inspectWayfern(localBundle),
|
||||
version,
|
||||
});
|
||||
return { version, source: "cached fixture" };
|
||||
}
|
||||
if (cachedVersion) {
|
||||
console.log(
|
||||
`[donut-e2e] Cached Wayfern fixture ${cachedVersion} is not the published ${version}; replacing it`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!app.session) await app.start();
|
||||
const current = await app.invoke("fetch_browser_versions_with_count", {
|
||||
browserStr: "wayfern",
|
||||
});
|
||||
assert.ok(
|
||||
current.versions.length > 0,
|
||||
"No Wayfern build is published for this platform",
|
||||
);
|
||||
const version = current.versions[0];
|
||||
await app.session.setTimeouts({ script: 600_000 });
|
||||
try {
|
||||
await app.invoke(
|
||||
"download_browser",
|
||||
{
|
||||
browserStr: "wayfern",
|
||||
version,
|
||||
},
|
||||
620_000,
|
||||
);
|
||||
} finally {
|
||||
await app.session.setTimeouts();
|
||||
}
|
||||
await downloadWayfern(app, version);
|
||||
await cacheDownloadedWayfern(app, projectRoot, version);
|
||||
return { version, source: "published download" };
|
||||
}
|
||||
@@ -526,3 +641,49 @@ export function writeChromiumHistory(dbPath, urls) {
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
/** The name and version the CRX fixture's own manifest declares. */
|
||||
export const CRX_EXTENSION_NAME = "Donut E2E Web Extension";
|
||||
export const CRX_EXTENSION_VERSION = "3.2.1";
|
||||
|
||||
/**
|
||||
* Wrap `zip` in a CRX3 container, the shape the Chrome Web Store actually
|
||||
* serves: `Cr24`, a little-endian format version of 3, a little-endian header
|
||||
* length, that many bytes of signature header, and only then the ZIP.
|
||||
*
|
||||
* The header bytes are filler — nothing in Donut verifies the signature, and a
|
||||
* real one would need a packing key. What a test built on this proves is that
|
||||
* the importer reads the ZIP at the offset the header declares instead of
|
||||
* scanning the file for a `PK` marker, which is the bug the format invites.
|
||||
*/
|
||||
export function buildCrx3(zip, headerBytes = 137) {
|
||||
const prefix = Buffer.alloc(12);
|
||||
prefix.write("Cr24", 0, "ascii");
|
||||
prefix.writeUInt32LE(3, 4);
|
||||
prefix.writeUInt32LE(headerBytes, 8);
|
||||
return Buffer.concat([prefix, Buffer.alloc(headerBytes, 0x42), zip]);
|
||||
}
|
||||
|
||||
/** A CRX3 whose payload is a real Manifest V3 archive. */
|
||||
export function extensionCrx3({
|
||||
name = CRX_EXTENSION_NAME,
|
||||
version = CRX_EXTENSION_VERSION,
|
||||
} = {}) {
|
||||
return buildCrx3(
|
||||
buildStoredZip([
|
||||
{
|
||||
name: "manifest.json",
|
||||
data: `${JSON.stringify(
|
||||
{
|
||||
manifest_version: 3,
|
||||
name,
|
||||
version,
|
||||
description: "Isolated test extension served over a link",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The longest command the harness ever waits on: `download_browser` pulling a
|
||||
* published Wayfern build of about 1 GB, which a slow link needs the better
|
||||
* part of half an hour for.
|
||||
*
|
||||
* Every clock around that command is derived from this one number so they can
|
||||
* never disagree again. The session script timeout is this value; the client
|
||||
* gives up a little later; the driver's outer per-command bound
|
||||
* (`--command-timeout`) later still. Ordered that way, a download that is
|
||||
* genuinely too slow surfaces as the driver's own script-timeout error rather
|
||||
* than as a torn connection somewhere in between.
|
||||
*/
|
||||
export const WAYFERN_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
/** How long the client waits on a download command before it gives up. */
|
||||
export const WAYFERN_DOWNLOAD_CLIENT_TIMEOUT_MS =
|
||||
WAYFERN_DOWNLOAD_TIMEOUT_MS + 20_000;
|
||||
|
||||
/** The driver's outer per-command bound, in the whole seconds its flag takes. */
|
||||
export const DRIVER_COMMAND_TIMEOUT_SECONDS =
|
||||
Math.ceil(WAYFERN_DOWNLOAD_TIMEOUT_MS / 1000) + 60;
|
||||
+67
-14
@@ -1,9 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
|
||||
export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
||||
|
||||
function abortAfter(timeoutMs) {
|
||||
return AbortSignal.timeout(timeoutMs);
|
||||
/**
|
||||
* One HTTP exchange with the driver, over `node:http` rather than `fetch`.
|
||||
*
|
||||
* `fetch` is undici, and undici gives every request a 300 s headers timeout
|
||||
* of its own. A long `execute/async` sends no headers until the script
|
||||
* completes, so a `download_browser` that pulls a 1 GB Wayfern build over a
|
||||
* slow link died at 300 s whatever `timeoutMs` asked for. `node:http` has no
|
||||
* such default, which leaves `timeoutMs` as the only clock.
|
||||
*/
|
||||
function exchange(method, url, body, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const request = http.request(
|
||||
url,
|
||||
{
|
||||
method,
|
||||
headers:
|
||||
payload === undefined
|
||||
? {}
|
||||
: {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on("data", (chunk) => chunks.push(chunk));
|
||||
response.on("error", reject);
|
||||
response.on("end", () =>
|
||||
resolve({
|
||||
status: response.statusCode ?? 0,
|
||||
text: Buffer.concat(chunks).toString("utf8"),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
request.on("error", (error) => {
|
||||
const timedOut =
|
||||
error?.name === "AbortError" || error?.name === "TimeoutError";
|
||||
reject(
|
||||
timedOut
|
||||
? new Error(
|
||||
`WebDriver ${method} ${url} gave no response within ${timeoutMs}ms`,
|
||||
{ cause: error },
|
||||
)
|
||||
: error,
|
||||
);
|
||||
});
|
||||
request.end(payload);
|
||||
});
|
||||
}
|
||||
|
||||
export class WebDriverClient {
|
||||
@@ -12,30 +62,27 @@ export class WebDriverClient {
|
||||
}
|
||||
|
||||
async request(method, pathname, body, timeoutMs = 330_000) {
|
||||
const response = await fetch(`${this.baseUrl}${pathname}`, {
|
||||
const { status, text } = await exchange(
|
||||
method,
|
||||
headers:
|
||||
body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: abortAfter(timeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
`${this.baseUrl}${pathname}`,
|
||||
body,
|
||||
timeoutMs,
|
||||
);
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${response.status}: ${text.slice(0, 500)}`,
|
||||
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${status}: ${text.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const error = payload?.value?.error;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload?.value?.message ?? text ?? `HTTP ${response.status}`;
|
||||
if (status < 200 || status >= 300) {
|
||||
const message = payload?.value?.message ?? text ?? `HTTP ${status}`;
|
||||
throw new Error(
|
||||
`WebDriver ${method} ${pathname} failed (${error ?? response.status}): ${message}`,
|
||||
`WebDriver ${method} ${pathname} failed (${error ?? status}): ${message}`,
|
||||
);
|
||||
}
|
||||
return payload?.value;
|
||||
@@ -51,11 +98,17 @@ export class WebDriverClient {
|
||||
env = {},
|
||||
cwd,
|
||||
startupTimeout = 90_000,
|
||||
headless = false,
|
||||
}) {
|
||||
const options = { application, args, env, startupTimeout };
|
||||
if (cwd) {
|
||||
options.cwd = cwd;
|
||||
}
|
||||
// Only sent when asked, so a driver build without the capability is not
|
||||
// handed an option it would reject.
|
||||
if (headless) {
|
||||
options.headless = true;
|
||||
}
|
||||
const value = await this.request(
|
||||
"POST",
|
||||
"/session",
|
||||
|
||||
Reference in New Issue
Block a user