mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-25 05:40:50 +02:00
chore: add cross-platform webdriver tests
This commit is contained in:
+383
@@ -0,0 +1,383 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { WebDriverClient } from "./webdriver.mjs";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function isolatedEnvironment(root, extra = {}) {
|
||||
const home = path.join(root, "home");
|
||||
const temp = path.join(root, "tmp");
|
||||
return {
|
||||
DONUTBROWSER_DATA_ROOT: path.join(root, "donut"),
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
...(process.platform === "darwin" ? { CFFIXED_USER_HOME: home } : {}),
|
||||
TMPDIR: temp,
|
||||
TMP: temp,
|
||||
TEMP: temp,
|
||||
XDG_CONFIG_HOME: path.join(root, "xdg", "config"),
|
||||
XDG_CACHE_HOME: path.join(root, "xdg", "cache"),
|
||||
XDG_DATA_HOME: path.join(root, "xdg", "data"),
|
||||
APPDATA: path.join(root, "windows", "roaming"),
|
||||
LOCALAPPDATA: path.join(root, "windows", "local"),
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
NO_PROXY: "127.0.0.1,localhost",
|
||||
no_proxy: "127.0.0.1,localhost",
|
||||
HTTP_PROXY: "",
|
||||
HTTPS_PROXY: "",
|
||||
ALL_PROXY: "",
|
||||
http_proxy: "",
|
||||
https_proxy: "",
|
||||
all_proxy: "",
|
||||
RUST_BACKTRACE: "1",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export class AppSession {
|
||||
constructor({
|
||||
name,
|
||||
root,
|
||||
application,
|
||||
driverUrl,
|
||||
cwd,
|
||||
token,
|
||||
extraEnv = {},
|
||||
args = [],
|
||||
seedVersionCache = true,
|
||||
}) {
|
||||
this.name = name;
|
||||
this.root = root;
|
||||
this.application = application;
|
||||
this.driver = new WebDriverClient(driverUrl);
|
||||
this.cwd = cwd;
|
||||
this.token = token;
|
||||
this.extraEnv = extraEnv;
|
||||
this.args = args;
|
||||
this.seedVersionCache = seedVersionCache;
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
get dataRoot() {
|
||||
return path.join(this.root, "donut");
|
||||
}
|
||||
|
||||
async start() {
|
||||
await Promise.all([
|
||||
mkdir(path.join(this.root, "home"), { recursive: true }),
|
||||
mkdir(path.join(this.root, "tmp"), { recursive: true }),
|
||||
mkdir(path.join(this.root, "artifacts"), { recursive: true }),
|
||||
]);
|
||||
if (this.seedVersionCache) {
|
||||
const versionCache = path.join(
|
||||
this.root,
|
||||
"donut",
|
||||
"cache",
|
||||
"version_cache",
|
||||
"wayfern_versions.json",
|
||||
);
|
||||
await mkdir(path.dirname(versionCache), { recursive: true });
|
||||
await writeFile(
|
||||
versionCache,
|
||||
`${JSON.stringify({
|
||||
releases: [{ version: "150.0.7871.100", date: "2026-07-01" }],
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
})}\n`,
|
||||
{ flag: "wx" },
|
||||
).catch((error) => {
|
||||
if (error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
const env = isolatedEnvironment(this.root, {
|
||||
DONUT_E2E_DISABLE_STARTUP_NETWORK: "1",
|
||||
...(process.env.DONUT_E2E_FIXTURE_URL
|
||||
? {
|
||||
DONUT_E2E_DNS_BLOCKLIST_BASE_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/dns`,
|
||||
...(process.env.DONUT_E2E_GEOIP_FIXTURE_READY === "1"
|
||||
? {
|
||||
DONUT_E2E_GEOIP_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip.mmdb`,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(this.token ? { WAYFERN_TEST_TOKEN: this.token } : {}),
|
||||
...this.extraEnv,
|
||||
});
|
||||
this.session = await this.driver.createSession({
|
||||
application: this.application,
|
||||
args: this.args,
|
||||
env,
|
||||
cwd: this.cwd,
|
||||
startupTimeout: 120_000,
|
||||
});
|
||||
await this.session.setTimeouts();
|
||||
await this.waitFor(
|
||||
async () => {
|
||||
const ready = await this.execute(
|
||||
"return document.readyState === 'complete' && Boolean(window.__TAURI_INTERNALS__);",
|
||||
);
|
||||
return ready === true;
|
||||
},
|
||||
{
|
||||
description: `${this.name} frontend and Tauri bridge`,
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
async restart() {
|
||||
await this.close();
|
||||
return this.start();
|
||||
}
|
||||
|
||||
async execute(script, args = []) {
|
||||
assert.ok(this.session, `${this.name} is not started`);
|
||||
return this.session.execute(script, args);
|
||||
}
|
||||
|
||||
async invoke(command, args = {}) {
|
||||
assert.ok(this.session, `${this.name} is not started`);
|
||||
const result = await this.session.executeAsync(
|
||||
`
|
||||
const done = arguments[arguments.length - 1];
|
||||
const command = arguments[0];
|
||||
const args = arguments[1];
|
||||
window.__TAURI_INTERNALS__.invoke(command, args)
|
||||
.then((value) => done({ ok: true, value }))
|
||||
.catch((error) => done({
|
||||
ok: false,
|
||||
error: typeof error === "string" ? error : (error?.message ?? JSON.stringify(error))
|
||||
}));
|
||||
`,
|
||||
[command, args],
|
||||
);
|
||||
if (!result?.ok) {
|
||||
throw new Error(
|
||||
`Tauri command ${command} failed: ${result?.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async invokeError(command, args = {}) {
|
||||
try {
|
||||
await this.invoke(command, args);
|
||||
} catch (error) {
|
||||
return String(error);
|
||||
}
|
||||
throw new Error(`Expected Tauri command ${command} to fail`);
|
||||
}
|
||||
|
||||
async bodyText() {
|
||||
return this.execute("return document.body?.innerText ?? '';");
|
||||
}
|
||||
|
||||
async html() {
|
||||
return this.execute("return document.documentElement?.outerHTML ?? '';");
|
||||
}
|
||||
|
||||
async visibleTextIncludes(text) {
|
||||
return this.execute(
|
||||
`
|
||||
const wanted = arguments[0];
|
||||
return [...document.querySelectorAll("body *")].some((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||
rect.width > 0 && rect.height > 0 &&
|
||||
(node.innerText ?? "").trim().includes(wanted);
|
||||
});
|
||||
`,
|
||||
[text],
|
||||
);
|
||||
}
|
||||
|
||||
async waitFor(
|
||||
check,
|
||||
{ timeoutMs = 20_000, intervalMs = 100, description = "condition" } = {},
|
||||
) {
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const value = await check();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out after ${timeoutMs}ms waiting for ${description}${lastError ? `: ${lastError}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForText(text, timeoutMs = 20_000) {
|
||||
return this.waitFor(() => this.visibleTextIncludes(text), {
|
||||
timeoutMs,
|
||||
description: `visible text ${JSON.stringify(text)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async clickText(
|
||||
text,
|
||||
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||
) {
|
||||
const element = await this.execute(
|
||||
`
|
||||
const wanted = arguments[0];
|
||||
const exact = arguments[1];
|
||||
const roles = new Set(arguments[2]);
|
||||
const candidates = [...document.querySelectorAll("button, a, [role], [data-slot='button']")];
|
||||
const visible = (node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||
rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
return candidates.find((node) => {
|
||||
const role = node.getAttribute("role") || (node.tagName === "A" ? "link" : "button");
|
||||
const label = (node.getAttribute("aria-label") || node.innerText || node.textContent || "").trim();
|
||||
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.session.click(element);
|
||||
}
|
||||
|
||||
async clickSelector(selector) {
|
||||
const element = await this.waitFor(
|
||||
() =>
|
||||
this.execute(
|
||||
`
|
||||
const node = document.querySelector(arguments[0]);
|
||||
if (!node) return null;
|
||||
const style = getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||
rect.width > 0 && rect.height > 0 ? node : null;
|
||||
`,
|
||||
[selector],
|
||||
),
|
||||
{ description: `visible selector ${selector}` },
|
||||
);
|
||||
await this.session.click(element);
|
||||
}
|
||||
|
||||
async fillSelector(selector, value) {
|
||||
const element = await this.waitFor(
|
||||
() =>
|
||||
this.execute("return document.querySelector(arguments[0]);", [
|
||||
selector,
|
||||
]),
|
||||
{ description: `selector ${selector}` },
|
||||
);
|
||||
await this.session.clear(element);
|
||||
await this.session.sendKeys(element, value);
|
||||
}
|
||||
|
||||
async pressShortcut({
|
||||
key,
|
||||
meta = false,
|
||||
ctrl = false,
|
||||
alt = false,
|
||||
shift = false,
|
||||
}) {
|
||||
await this.execute(
|
||||
`
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: arguments[0],
|
||||
code: arguments[1],
|
||||
metaKey: arguments[2],
|
||||
ctrlKey: arguments[3],
|
||||
altKey: arguments[4],
|
||||
shiftKey: arguments[5],
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
`,
|
||||
[
|
||||
key,
|
||||
key.length === 1 ? `Key${key.toUpperCase()}` : key,
|
||||
meta,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async capture(label) {
|
||||
if (!this.session) {
|
||||
return;
|
||||
}
|
||||
const safe = label.replace(/[^a-z0-9_.-]+/gi, "-");
|
||||
try {
|
||||
const png = await this.session.screenshot();
|
||||
await writeFile(
|
||||
path.join(this.root, "artifacts", `${safe}.png`),
|
||||
Buffer.from(png, "base64"),
|
||||
);
|
||||
} catch {
|
||||
// Best-effort diagnostics must never hide the original test failure.
|
||||
}
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(this.root, "artifacts", `${safe}.html`),
|
||||
await this.html(),
|
||||
);
|
||||
} catch {
|
||||
// Best-effort diagnostics must never hide the original test failure.
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.session) {
|
||||
return;
|
||||
}
|
||||
const session = this.session;
|
||||
this.session = null;
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function appFromEnvironment(name, options = {}) {
|
||||
const runRoot = process.env.DONUT_E2E_RUN_ROOT;
|
||||
assert.ok(runRoot, "DONUT_E2E_RUN_ROOT is required");
|
||||
return new AppSession({
|
||||
name,
|
||||
root: options.root ?? path.join(runRoot, "sessions", name),
|
||||
application: process.env.DONUT_E2E_APP,
|
||||
driverUrl: process.env.DONUT_E2E_DRIVER_URL,
|
||||
cwd: process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
token: process.env.WAYFERN_TEST_TOKEN,
|
||||
extraEnv: options.extraEnv,
|
||||
args: options.args,
|
||||
seedVersionCache: options.seedVersionCache,
|
||||
});
|
||||
}
|
||||
|
||||
export async function withApp(name, callback, options = {}) {
|
||||
const app = appFromEnvironment(name, options);
|
||||
try {
|
||||
await app.start();
|
||||
return await callback(app);
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class CdpClient {
|
||||
constructor(socket) {
|
||||
this.socket = socket;
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.id === undefined) return;
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(
|
||||
new Error(
|
||||
`CDP ${pending.method} failed: ${JSON.stringify(message.error)}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
pending.resolve(message.result ?? {});
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(
|
||||
new Error(`CDP socket closed while waiting for ${pending.method}`),
|
||||
);
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(port, { timeoutMs = 30_000 } = {}) {
|
||||
assert.equal(
|
||||
typeof WebSocket,
|
||||
"function",
|
||||
"This E2E suite requires Node.js 22+ WebSocket",
|
||||
);
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json`, {
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const targets = await response.json();
|
||||
const target = targets.find(
|
||||
(item) => item.type === "page" && item.webSocketDebuggerUrl,
|
||||
);
|
||||
if (!target) throw new Error("no debuggable page target");
|
||||
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("CDP WebSocket open timed out")),
|
||||
5_000,
|
||||
);
|
||||
socket.addEventListener(
|
||||
"open",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
socket.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("CDP WebSocket failed to open"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return new CdpClient(socket);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out connecting to Wayfern CDP on ${port}: ${lastError}`,
|
||||
);
|
||||
}
|
||||
|
||||
command(method, params = {}) {
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject, method });
|
||||
this.socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async evaluate(expression) {
|
||||
const result = await this.command("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
userGesture: true,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error(
|
||||
`CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`,
|
||||
);
|
||||
}
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
async waitFor(
|
||||
expression,
|
||||
{ timeoutMs = 20_000, description = expression } = {},
|
||||
) {
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const value = await this.evaluate(expression);
|
||||
if (value) return value;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for ${description}${lastError ? `: ${lastError}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, copyFile, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
||||
|
||||
export function defaultWayfernPath(projectRoot) {
|
||||
if (process.env.DONUT_E2E_WAYFERN_PATH) {
|
||||
return path.resolve(process.env.DONUT_E2E_WAYFERN_PATH);
|
||||
}
|
||||
const sibling = path.resolve(
|
||||
projectRoot,
|
||||
"../wayfern-test/test_extracted_app",
|
||||
);
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(sibling, "Wayfern.app");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(sibling, "Wayfern.exe");
|
||||
}
|
||||
return path.join(sibling, "wayfern");
|
||||
}
|
||||
|
||||
export function wayfernExecutable(bundlePath) {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(bundlePath, "Contents", "MacOS", "Wayfern");
|
||||
}
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
export function inspectWayfern(bundlePath) {
|
||||
const executable = wayfernExecutable(bundlePath);
|
||||
assert.ok(
|
||||
existsSync(executable),
|
||||
`Wayfern executable is missing: ${executable}`,
|
||||
);
|
||||
const output =
|
||||
process.platform === "darwin"
|
||||
? execFileSync(
|
||||
"/usr/bin/plutil",
|
||||
[
|
||||
"-extract",
|
||||
"CFBundleShortVersionString",
|
||||
"raw",
|
||||
"-o",
|
||||
"-",
|
||||
path.join(bundlePath, "Contents", "Info.plist"),
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
).trim()
|
||||
: execFileSync(executable, ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
}).trim();
|
||||
const match = output.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||
assert.ok(match, `Could not parse Wayfern version from: ${output}`);
|
||||
return { bundlePath, executable, version: match[1], output };
|
||||
}
|
||||
|
||||
async function linkOrCopy(source, destination) {
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
try {
|
||||
await symlink(
|
||||
source,
|
||||
destination,
|
||||
process.platform === "win32" ? "junction" : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedWayfern(dataRoot, wayfern) {
|
||||
const installDir = path.join(
|
||||
dataRoot,
|
||||
"data",
|
||||
"binaries",
|
||||
"wayfern",
|
||||
wayfern.version,
|
||||
);
|
||||
await mkdir(installDir, { recursive: true });
|
||||
if (process.platform === "darwin") {
|
||||
await linkOrCopy(wayfern.bundlePath, path.join(installDir, "Wayfern.app"));
|
||||
} else {
|
||||
const name = process.platform === "win32" ? "wayfern.exe" : "wayfern";
|
||||
const destination = path.join(installDir, name);
|
||||
await copyFile(wayfern.executable, destination);
|
||||
if (process.platform !== "win32") {
|
||||
await chmod(destination, 0o755);
|
||||
}
|
||||
}
|
||||
const registry = {
|
||||
browsers: {
|
||||
wayfern: {
|
||||
[wayfern.version]: {
|
||||
browser: "wayfern",
|
||||
version: wayfern.version,
|
||||
file_path: installDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const registryPath = path.join(
|
||||
dataRoot,
|
||||
"data",
|
||||
"data",
|
||||
"downloaded_browsers.json",
|
||||
);
|
||||
await mkdir(path.dirname(registryPath), { recursive: true });
|
||||
await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
|
||||
return installDir;
|
||||
}
|
||||
|
||||
export function wireGuardFixture() {
|
||||
return [
|
||||
"[Interface]",
|
||||
"PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"Address = 10.88.0.2/32",
|
||||
"DNS = 1.1.1.1",
|
||||
"",
|
||||
"[Peer]",
|
||||
"PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
|
||||
"Endpoint = 127.0.0.1:51820",
|
||||
"AllowedIPs = 0.0.0.0/0",
|
||||
"PersistentKeepalive = 25",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function extensionZipBase64() {
|
||||
// A deterministic Manifest V3 ZIP containing only manifest.json. Generated
|
||||
// once and kept inline so the suite has no archiver dependency.
|
||||
return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA";
|
||||
}
|
||||
|
||||
export function currentHostOs() {
|
||||
return os.platform() === "darwin"
|
||||
? "macos"
|
||||
: os.platform() === "win32"
|
||||
? "windows"
|
||||
: "linux";
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
||||
|
||||
function abortAfter(timeoutMs) {
|
||||
return AbortSignal.timeout(timeoutMs);
|
||||
}
|
||||
|
||||
export class WebDriverClient {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
async request(method, pathname, body, timeoutMs = 330_000) {
|
||||
const response = await fetch(`${this.baseUrl}${pathname}`, {
|
||||
method,
|
||||
headers:
|
||||
body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: abortAfter(timeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const error = payload?.value?.error;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload?.value?.message ?? text ?? `HTTP ${response.status}`;
|
||||
throw new Error(
|
||||
`WebDriver ${method} ${pathname} failed (${error ?? response.status}): ${message}`,
|
||||
);
|
||||
}
|
||||
return payload?.value;
|
||||
}
|
||||
|
||||
async status() {
|
||||
return this.request("GET", "/status");
|
||||
}
|
||||
|
||||
async createSession({
|
||||
application,
|
||||
args = [],
|
||||
env = {},
|
||||
cwd,
|
||||
startupTimeout = 90_000,
|
||||
}) {
|
||||
const options = { application, args, env, startupTimeout };
|
||||
if (cwd) {
|
||||
options.cwd = cwd;
|
||||
}
|
||||
const value = await this.request(
|
||||
"POST",
|
||||
"/session",
|
||||
{
|
||||
capabilities: {
|
||||
alwaysMatch: {
|
||||
"tauri:options": options,
|
||||
},
|
||||
},
|
||||
},
|
||||
startupTimeout + 10_000,
|
||||
);
|
||||
assert.ok(value?.sessionId, "WebDriver did not return a session id");
|
||||
return new WebDriverSession(
|
||||
this,
|
||||
value.sessionId,
|
||||
value.capabilities ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebDriverSession {
|
||||
constructor(client, id, capabilities) {
|
||||
this.client = client;
|
||||
this.id = id;
|
||||
this.capabilities = capabilities;
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
path(suffix = "") {
|
||||
return `/session/${encodeURIComponent(this.id)}${suffix}`;
|
||||
}
|
||||
|
||||
async command(method, suffix, body, timeoutMs) {
|
||||
return this.client.request(method, this.path(suffix), body, timeoutMs);
|
||||
}
|
||||
|
||||
async execute(script, args = []) {
|
||||
return this.command("POST", "/execute/sync", { script, args });
|
||||
}
|
||||
|
||||
async executeAsync(script, args = [], timeoutMs = 330_000) {
|
||||
return this.command("POST", "/execute/async", { script, args }, timeoutMs);
|
||||
}
|
||||
|
||||
async setTimeouts({
|
||||
implicit = 0,
|
||||
pageLoad = 300_000,
|
||||
script = 300_000,
|
||||
} = {}) {
|
||||
await this.command("POST", "/timeouts", { implicit, pageLoad, script });
|
||||
}
|
||||
|
||||
async find(using, value) {
|
||||
const element = await this.command("POST", "/element", { using, value });
|
||||
assert.ok(
|
||||
element?.[ELEMENT_KEY],
|
||||
`Element not found using ${using}: ${value}`,
|
||||
);
|
||||
return element;
|
||||
}
|
||||
|
||||
async findCss(selector) {
|
||||
return this.find("css selector", selector);
|
||||
}
|
||||
|
||||
async findXpath(xpath) {
|
||||
return this.find("xpath", xpath);
|
||||
}
|
||||
|
||||
async click(element) {
|
||||
await this.command(
|
||||
"POST",
|
||||
`/element/${encodeURIComponent(element[ELEMENT_KEY])}/click`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
async sendKeys(element, text) {
|
||||
const chars = [...String(text)];
|
||||
await this.command(
|
||||
"POST",
|
||||
`/element/${encodeURIComponent(element[ELEMENT_KEY])}/value`,
|
||||
{
|
||||
text: String(text),
|
||||
value: chars,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async clear(element) {
|
||||
await this.command(
|
||||
"POST",
|
||||
`/element/${encodeURIComponent(element[ELEMENT_KEY])}/clear`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
async title() {
|
||||
return this.command("GET", "/title");
|
||||
}
|
||||
|
||||
async screenshot() {
|
||||
return this.command("GET", "/screenshot");
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
try {
|
||||
await this.command("DELETE", "");
|
||||
} catch (error) {
|
||||
if (!String(error).includes("invalid session id")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user