refactor: cleanup

This commit is contained in:
zhom
2026-09-09 10:09:14 +04:00
parent 598d3bd513
commit dd42d46753
249 changed files with 67417 additions and 6659 deletions
+63 -50
View File
@@ -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
View File
@@ -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`,
},
]),
);
}
+21
View File
@@ -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
View File
@@ -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",