mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-10 21:20:24 +02:00
322 lines
10 KiB
JavaScript
322 lines
10 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import {
|
|
chmod,
|
|
copyFile,
|
|
cp,
|
|
mkdir,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
|
|
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 fixtureRoot = path.join(projectRoot, ".cache", "e2e-wayfern-fixture");
|
|
return process.platform === "darwin"
|
|
? path.join(fixtureRoot, "Wayfern.app")
|
|
: path.join(
|
|
fixtureRoot,
|
|
process.platform === "win32" ? "Wayfern.exe" : "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 cloneAppBundle(source, destination) {
|
|
await mkdir(path.dirname(destination), { recursive: true });
|
|
try {
|
|
execFileSync("/bin/cp", ["-cR", source, destination]);
|
|
} catch (_error) {
|
|
await cp(source, destination, {
|
|
recursive: true,
|
|
preserveTimestamps: true,
|
|
errorOnExist: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
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,
|
|
"data",
|
|
"binaries",
|
|
"wayfern",
|
|
version,
|
|
);
|
|
const source =
|
|
process.platform === "darwin"
|
|
? path.join(installDir, "Wayfern.app")
|
|
: path.join(
|
|
installDir,
|
|
process.platform === "win32" ? "wayfern.exe" : "wayfern",
|
|
);
|
|
const staging = `${destination}.tmp-${process.pid}`;
|
|
await rm(staging, { recursive: true, force: true });
|
|
try {
|
|
if (process.platform === "darwin") {
|
|
await cloneAppBundle(source, staging);
|
|
} else {
|
|
await mkdir(path.dirname(staging), { recursive: true });
|
|
await copyFile(source, staging);
|
|
if (process.platform !== "win32") await chmod(staging, 0o755);
|
|
}
|
|
await rename(staging, destination);
|
|
} catch (error) {
|
|
await rm(staging, { recursive: true, force: true });
|
|
if (!existsSync(destination)) 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 cloneAppBundle(
|
|
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 async function prepareWayfern(app, projectRoot) {
|
|
const localBundle = defaultWayfernPath(projectRoot);
|
|
if (existsSync(localBundle)) {
|
|
const wayfern = inspectWayfern(localBundle);
|
|
await seedWayfern(app.dataRoot, wayfern);
|
|
return { version: wayfern.version, source: "local fixture" };
|
|
}
|
|
|
|
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 cacheDownloadedWayfern(app, projectRoot, version);
|
|
return { version, source: "published download" };
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
/**
|
|
* Write a Chromium cookie store at schema version 24 with plaintext values.
|
|
*
|
|
* Plaintext is deliberate: it is what a store looks like when the source
|
|
* browser could not reach its keyring, and it lets the suite assert that
|
|
* import seals every row with the target profile's key. Chromium reads a row
|
|
* whose `encrypted_value` is empty, and drops any row where both columns are
|
|
* set, so "value cleared and encrypted_value populated" is the only shape that
|
|
* actually loads.
|
|
*/
|
|
export function writeChromiumCookies(dbPath, cookies) {
|
|
const db = new DatabaseSync(dbPath);
|
|
db.exec(`
|
|
CREATE TABLE cookies(
|
|
creation_utc INTEGER NOT NULL,
|
|
host_key TEXT NOT NULL,
|
|
top_frame_site_key TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
encrypted_value BLOB NOT NULL DEFAULT '',
|
|
path TEXT NOT NULL,
|
|
expires_utc INTEGER NOT NULL,
|
|
is_secure INTEGER NOT NULL,
|
|
is_httponly INTEGER NOT NULL,
|
|
last_access_utc INTEGER NOT NULL,
|
|
has_expires INTEGER NOT NULL DEFAULT 1,
|
|
is_persistent INTEGER NOT NULL DEFAULT 1,
|
|
priority INTEGER NOT NULL DEFAULT 1,
|
|
samesite INTEGER NOT NULL DEFAULT -1,
|
|
source_scheme INTEGER NOT NULL DEFAULT 0,
|
|
source_port INTEGER NOT NULL DEFAULT -1,
|
|
last_update_utc INTEGER NOT NULL DEFAULT 0,
|
|
source_type INTEGER NOT NULL DEFAULT 0,
|
|
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE UNIQUE INDEX cookies_unique_index
|
|
ON cookies(host_key, top_frame_site_key, name, path);
|
|
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
|
|
INSERT INTO meta VALUES('version', '24');
|
|
INSERT INTO meta VALUES('last_compatible_version', '24');
|
|
`);
|
|
const insert = db.prepare(
|
|
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
|
|
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
|
|
VALUES(?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0)`,
|
|
);
|
|
// `encrypted` cookies are written the way Chromium's v23->v24 migration
|
|
// does: BindString into a BLOB column, which leaves the storage class as
|
|
// TEXT. Reading that as a strict blob returns empty and silently blanks the
|
|
// cookie, so the suite has to reproduce it rather than only binding blobs.
|
|
const insertAsText = db.prepare(
|
|
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
|
|
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
|
|
VALUES(?, ?, '', ?, '', CAST(? AS TEXT), '/', 0, 0, 0, 0)`,
|
|
);
|
|
let creation = 13000000000000000;
|
|
for (const cookie of cookies) {
|
|
if (cookie.encryptedValueText === undefined) {
|
|
insert.run(creation++, cookie.host, cookie.name, cookie.value, "");
|
|
} else {
|
|
insertAsText.run(
|
|
creation++,
|
|
cookie.host,
|
|
cookie.name,
|
|
cookie.encryptedValueText,
|
|
);
|
|
}
|
|
}
|
|
db.close();
|
|
}
|
|
|
|
/** Write a Chromium History database holding the given URLs. */
|
|
export function writeChromiumHistory(dbPath, urls) {
|
|
const db = new DatabaseSync(dbPath);
|
|
db.exec(`
|
|
CREATE TABLE urls(
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
url LONGVARCHAR,
|
|
title LONGVARCHAR,
|
|
visit_count INTEGER DEFAULT 0 NOT NULL,
|
|
typed_count INTEGER DEFAULT 0 NOT NULL,
|
|
last_visit_time INTEGER NOT NULL,
|
|
hidden INTEGER DEFAULT 0 NOT NULL
|
|
);
|
|
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
|
|
INSERT INTO meta VALUES('version', '69');
|
|
INSERT INTO meta VALUES('last_compatible_version', '16');
|
|
`);
|
|
const insert = db.prepare(
|
|
"INSERT INTO urls(url, title, visit_count, typed_count, last_visit_time, hidden) VALUES(?, ?, 1, 0, ?, 0)",
|
|
);
|
|
let visit = 13000000000000000;
|
|
for (const url of urls) {
|
|
insert.run(url, url, visit++);
|
|
}
|
|
db.close();
|
|
}
|