mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-18 00:47:19 +02:00
feat: extension export via api
This commit is contained in:
@@ -87,7 +87,9 @@ export const commandCoverage = {
|
||||
"list_extensions",
|
||||
"get_extension_icon",
|
||||
"add_extension",
|
||||
"add_unpacked_extension",
|
||||
"update_extension",
|
||||
"update_extension_from_path",
|
||||
"delete_extension",
|
||||
"list_extension_groups",
|
||||
"create_extension_group",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { crc32 } from "node:zlib";
|
||||
|
||||
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
||||
|
||||
@@ -214,6 +216,211 @@ export function extensionZipBase64() {
|
||||
return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA";
|
||||
}
|
||||
|
||||
// 1980-01-01 00:00, the earliest timestamp the ZIP format can carry. Fixed so
|
||||
// two calls with the same entries produce byte-identical archives.
|
||||
const DOS_TIME = 0;
|
||||
const DOS_DATE = 0x0021;
|
||||
|
||||
/**
|
||||
* Build a ZIP archive from `entries` (`{ name, data }`) with every member
|
||||
* stored, not deflated.
|
||||
*
|
||||
* Stored is what the inline fixture above already is, and it is load-bearing
|
||||
* for the oversized fixture below: the assertion is about a request body that
|
||||
* has to stay over the limit under test, so nothing in the archive may shrink
|
||||
* the padding back under it.
|
||||
*/
|
||||
export function buildStoredZip(entries) {
|
||||
const locals = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const { name, data } of entries) {
|
||||
const nameBytes = Buffer.from(name, "utf8");
|
||||
const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
const checksum = crc32(body);
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(DOS_TIME, 10);
|
||||
local.writeUInt16LE(DOS_DATE, 12);
|
||||
local.writeUInt32LE(checksum, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(body.length, 22);
|
||||
local.writeUInt16LE(nameBytes.length, 26);
|
||||
locals.push(local, nameBytes, body);
|
||||
|
||||
const entry = Buffer.alloc(46);
|
||||
entry.writeUInt32LE(0x02014b50, 0);
|
||||
entry.writeUInt16LE(20, 4);
|
||||
entry.writeUInt16LE(20, 6);
|
||||
entry.writeUInt16LE(DOS_TIME, 12);
|
||||
entry.writeUInt16LE(DOS_DATE, 14);
|
||||
entry.writeUInt32LE(checksum, 16);
|
||||
entry.writeUInt32LE(body.length, 20);
|
||||
entry.writeUInt32LE(body.length, 24);
|
||||
entry.writeUInt16LE(nameBytes.length, 28);
|
||||
entry.writeUInt32LE(offset, 42);
|
||||
central.push(entry, nameBytes);
|
||||
|
||||
offset += local.length + nameBytes.length + body.length;
|
||||
}
|
||||
|
||||
const directory = Buffer.concat(central);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(directory.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
|
||||
return Buffer.concat([...locals, directory, end]);
|
||||
}
|
||||
|
||||
export const OVERSIZED_EXTENSION_NAME = "Donut E2E Oversized Fixture";
|
||||
|
||||
/**
|
||||
* A valid Manifest V3 ZIP padded past the 2 MiB body limit axum applies by
|
||||
* default, so the raised limit on the extension routes is the only reason a
|
||||
* request carrying it can succeed.
|
||||
*
|
||||
* The padding is random bytes, and the archive stores rather than deflates
|
||||
* them, so neither the fixture nor the transport can quietly shrink the body
|
||||
* back under the limit and turn the assertion into a tautology.
|
||||
*/
|
||||
export function oversizedExtensionZipBase64(paddingBytes = 3 * 1024 * 1024) {
|
||||
return buildStoredZip([
|
||||
{
|
||||
name: "manifest.json",
|
||||
data: `${JSON.stringify(
|
||||
{
|
||||
manifest_version: 3,
|
||||
name: OVERSIZED_EXTENSION_NAME,
|
||||
version: "1.0.0",
|
||||
description: "Isolated oversized test extension",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
},
|
||||
{ name: "payload.bin", data: randomBytes(paddingBytes) },
|
||||
]).toString("base64");
|
||||
}
|
||||
|
||||
// What `_locales/<default_locale>/messages.json` resolves the manifest's
|
||||
// placeholders to. Deliberately free of the `__MSG_` marker so a test can
|
||||
// assert the stored record carries no placeholder anywhere.
|
||||
export const LOCALIZED_EXTENSION_MESSAGES = {
|
||||
extName: "Donut E2E Localized Blocker",
|
||||
extDescription: "Resolved from the default locale, not the manifest",
|
||||
extAuthor: "Donut E2E Localization",
|
||||
};
|
||||
|
||||
/**
|
||||
* A Manifest V3 ZIP shaped the way Chrome Web Store extensions actually ship:
|
||||
* `name`, `description` and `author` are `__MSG_key__` placeholders and the
|
||||
* real strings live in `_locales/<default_locale>/messages.json`. uBlock Origin
|
||||
* Lite is exactly this, which is why an importer that stores the manifest
|
||||
* verbatim shows users `__MSG_extName__`.
|
||||
*
|
||||
* Pass `messages: {}` for a locale file that resolves none of the placeholders,
|
||||
* or `messages: null` to omit the locale file entirely.
|
||||
*/
|
||||
export function localizedExtensionZipBase64({
|
||||
defaultLocale = "en",
|
||||
messages = LOCALIZED_EXTENSION_MESSAGES,
|
||||
} = {}) {
|
||||
const entries = [
|
||||
{
|
||||
name: "manifest.json",
|
||||
data: `${JSON.stringify(
|
||||
{
|
||||
manifest_version: 3,
|
||||
name: "__MSG_extName__",
|
||||
version: "2.4.0",
|
||||
description: "__MSG_extDescription__",
|
||||
author: "__MSG_extAuthor__",
|
||||
default_locale: defaultLocale,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
},
|
||||
];
|
||||
if (messages) {
|
||||
entries.push({
|
||||
name: `_locales/${defaultLocale}/messages.json`,
|
||||
data: `${JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(messages).map(([key, message]) => [key, { message }]),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
});
|
||||
}
|
||||
return buildStoredZip(entries).toString("base64");
|
||||
}
|
||||
|
||||
// A 1x1 PNG, inline for the same reason the ZIP above is: no encoder
|
||||
// dependency, and the exact bytes are what the icon assertions compare.
|
||||
const EXTENSION_ICON_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
export function extensionIconPngBase64() {
|
||||
return EXTENSION_ICON_PNG_BASE64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a real unpacked Manifest V3 extension at `directory` and return its
|
||||
* absolute path.
|
||||
*
|
||||
* Unlike the ZIP fixture this one declares `icons` and ships the file they
|
||||
* point at, so importing the folder exercises icon extraction for both import
|
||||
* modes: linking reads the icon straight out of the folder, copying reads it
|
||||
* back out of the ZIP the importer builds. The background service worker is
|
||||
* what makes a loaded copy observable over CDP, which registers a
|
||||
* `chrome-extension://<id>/background.js` target.
|
||||
*/
|
||||
export async function writeUnpackedExtension(
|
||||
directory,
|
||||
{ name = "Donut E2E Unpacked", version = "1.0.0" } = {},
|
||||
) {
|
||||
const absolute = path.resolve(directory);
|
||||
await mkdir(path.join(absolute, "icons"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(absolute, "manifest.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
manifest_version: 3,
|
||||
name,
|
||||
version,
|
||||
description: "Isolated unpacked test extension",
|
||||
icons: { 16: "icons/icon-16.png", 48: "icons/icon-48.png" },
|
||||
background: { service_worker: "background.js" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(absolute, "background.js"),
|
||||
[
|
||||
"globalThis.__donutE2eExtension = chrome.runtime.id;",
|
||||
"chrome.runtime.onInstalled.addListener(() => {",
|
||||
" console.log('donut e2e extension installed');",
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const icon = Buffer.from(EXTENSION_ICON_PNG_BASE64, "base64");
|
||||
for (const size of [16, 48]) {
|
||||
await writeFile(path.join(absolute, "icons", `icon-${size}.png`), icon);
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
export function currentHostOs() {
|
||||
return os.platform() === "darwin"
|
||||
? "macos"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
defaultWayfernPath,
|
||||
inspectWayfern,
|
||||
prepareWayfern,
|
||||
writeUnpackedExtension,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
|
||||
@@ -709,3 +710,182 @@ test("a proxy worker dies with its browser, with and without the app running", a
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Two things nothing else covers. First, that an assigned extension group
|
||||
// actually reaches Wayfern: a loaded MV3 extension registers a
|
||||
// `chrome-extension://<id>/background.js` service-worker target, so CDP can see
|
||||
// it from outside. Second, that staging is per profile. It used to be one
|
||||
// shared `extensions/unpacked` directory wiped on every launch, and because
|
||||
// Chromium records the absolute staging path and reads those files lazily for
|
||||
// the life of the process instead of copying them into the profile, launching a
|
||||
// second profile broke the extension in every browser already running.
|
||||
test("an assigned extension group reaches Wayfern and each profile stages its own copy", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const localWayfernPath = defaultWayfernPath(
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
const localWayfernVersion = existsSync(localWayfernPath)
|
||||
? inspectWayfern(localWayfernPath).version
|
||||
: null;
|
||||
const app = appFromEnvironment("browser-extensions", {
|
||||
seedVersionCache: localWayfernVersion ?? false,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
const launched = [];
|
||||
try {
|
||||
const prepared = await prepareWayfern(
|
||||
app,
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
if (!app.session) await app.start();
|
||||
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
|
||||
await app.invoke("accept_wayfern_terms");
|
||||
}
|
||||
|
||||
const extension = await app.invoke("add_unpacked_extension", {
|
||||
name: "Donut Launch Fixture",
|
||||
path: await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "loaded-extension"),
|
||||
{ name: "Donut Launch Fixture", version: "1.0.0" },
|
||||
),
|
||||
link: false,
|
||||
});
|
||||
const group = await app.invoke("create_extension_group", {
|
||||
name: "Launch Extensions",
|
||||
});
|
||||
await app.invoke("add_extension_to_group", {
|
||||
groupId: group.id,
|
||||
extensionId: extension.id,
|
||||
});
|
||||
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: true,
|
||||
api_port: 0,
|
||||
api_token: null,
|
||||
onboarding_completed: true,
|
||||
},
|
||||
});
|
||||
const base = `http://127.0.0.1:${await app.invoke("start_api_server", { port: 0 })}`;
|
||||
|
||||
const stagedManifest = (profileId) =>
|
||||
path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"extensions",
|
||||
"unpacked",
|
||||
profileId,
|
||||
extension.id,
|
||||
"manifest.json",
|
||||
);
|
||||
const extensionWorkers = async (debuggingPort) => {
|
||||
const targets = await fetch(
|
||||
`http://127.0.0.1:${debuggingPort}/json`,
|
||||
).then((response) => response.json());
|
||||
return targets.filter(
|
||||
(target) =>
|
||||
target.type === "service_worker" &&
|
||||
String(target.url).startsWith("chrome-extension://"),
|
||||
);
|
||||
};
|
||||
const launchWithExtension = async (name) => {
|
||||
const profile = await createRealProfile(app, prepared.version, name);
|
||||
assert.equal(
|
||||
(
|
||||
await app.invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: group.id,
|
||||
})
|
||||
).extension_group_id,
|
||||
group.id,
|
||||
);
|
||||
const run = await request(`${base}/v1/profiles/${profile.id}/run`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { url: `${fixtureUrl}/extension-launch`, headless: true },
|
||||
});
|
||||
assert.equal(run.response.status, 200, JSON.stringify(run.value));
|
||||
const record = {
|
||||
profile,
|
||||
debuggingPort: run.value.remote_debugging_port,
|
||||
};
|
||||
launched.push(record);
|
||||
const workers = await app.waitFor(
|
||||
async () => {
|
||||
const found = await extensionWorkers(record.debuggingPort);
|
||||
return found.length > 0 ? found : null;
|
||||
},
|
||||
{
|
||||
timeoutMs: 60_000,
|
||||
description: `the extension's service worker in ${name}`,
|
||||
},
|
||||
);
|
||||
assert.match(
|
||||
workers[0].url,
|
||||
/^chrome-extension:\/\/\w+\/background\.js$/,
|
||||
);
|
||||
return record;
|
||||
};
|
||||
|
||||
const first = await launchWithExtension("Extension Launch One");
|
||||
assert.ok(
|
||||
existsSync(stagedManifest(first.profile.id)),
|
||||
"the first profile must stage the extension under its own id",
|
||||
);
|
||||
if (process.platform !== "win32") {
|
||||
// The staged path is what Chromium was handed, and it is per profile.
|
||||
const running = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === first.profile.id,
|
||||
);
|
||||
const command = execFileSync(
|
||||
"ps",
|
||||
["-ww", "-o", "command=", "-p", String(running.process_id)],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.ok(
|
||||
command.includes(
|
||||
`--load-extension=${path.dirname(stagedManifest(first.profile.id))}`,
|
||||
),
|
||||
"Wayfern must be pointed at this profile's own staged copy",
|
||||
);
|
||||
}
|
||||
await launchWithExtension("Extension Launch Two");
|
||||
|
||||
// The regression itself: the second launch must not have taken the first
|
||||
// profile's files with it. The staged manifest is what its running browser
|
||||
// is still reading from.
|
||||
for (const { profile } of launched) {
|
||||
assert.ok(
|
||||
existsSync(stagedManifest(profile.id)),
|
||||
`${profile.name} lost its staged extension to another profile's launch`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const { profile } of launched) {
|
||||
const running = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
);
|
||||
await app.invoke("kill_browser_profile", { profile: running });
|
||||
await waitForProcessExit(app, running.process_id);
|
||||
}
|
||||
await app.invoke("stop_api_server");
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
if (app.session) {
|
||||
const running = await app.invoke("list_browser_profiles").catch(() => []);
|
||||
for (const { profile } of launched) {
|
||||
const record = running.find((item) => item.id === profile.id);
|
||||
if (record?.process_id && processExists(record.process_id)) {
|
||||
await app
|
||||
.invoke("kill_browser_profile", { profile: record })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
+107
-1
@@ -1,15 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import {
|
||||
extensionIconPngBase64,
|
||||
extensionZipBase64,
|
||||
wireGuardFixture,
|
||||
writeChromiumCookies,
|
||||
writeChromiumHistory,
|
||||
writeUnpackedExtension,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
async function createProfile(app, name = "Entity Profile") {
|
||||
@@ -555,6 +563,104 @@ test("extensions, extension groups, VPN storage, DNS rules, and event-backed ass
|
||||
await app.invoke("delete_extension_group", { groupId: extensionGroup.id });
|
||||
await app.invoke("delete_extension", { extensionId: extension.id });
|
||||
|
||||
// Folder imports, the "Load unpacked" flow. Copying packs the folder into
|
||||
// the store; linking loads it from where the user keeps it, which only
|
||||
// exists on this machine and therefore never syncs.
|
||||
const unpackedDir = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "unpacked-extension"),
|
||||
);
|
||||
const copied = await app.invoke("add_unpacked_extension", {
|
||||
name: "Overridden By The Manifest",
|
||||
path: unpackedDir,
|
||||
link: false,
|
||||
});
|
||||
assert.equal(copied.source_kind, "unpacked");
|
||||
assert.equal(copied.linked_path, null);
|
||||
assert.equal(copied.file_type, "zip");
|
||||
assert.equal(copied.file_name, "unpacked-extension.zip");
|
||||
assert.equal(copied.name, "Donut E2E Unpacked");
|
||||
assert.equal(copied.version, "1.0.0");
|
||||
// The folder declares icons, so packing it must carry one through into the
|
||||
// store rather than dropping it the way the icon-less ZIP fixture does.
|
||||
assert.equal(
|
||||
await app.invoke("get_extension_icon", { extensionId: copied.id }),
|
||||
`data:image/png;base64,${extensionIconPngBase64()}`,
|
||||
);
|
||||
|
||||
const linked = await app.invoke("add_unpacked_extension", {
|
||||
name: "Linked Fixture",
|
||||
path: unpackedDir,
|
||||
link: true,
|
||||
});
|
||||
assert.equal(linked.source_kind, "unpacked");
|
||||
assert.equal(linked.file_type, "unpacked");
|
||||
assert.equal(linked.linked_path, await realpath(unpackedDir));
|
||||
assert.equal(
|
||||
linked.sync_enabled,
|
||||
false,
|
||||
"a linked extension has no payload to upload, so it must never be synced",
|
||||
);
|
||||
|
||||
const repackedDir = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "unpacked-extension-v2"),
|
||||
{ name: "Donut E2E Unpacked v2", version: "2.0.0" },
|
||||
);
|
||||
const repacked = await app.invoke("update_extension_from_path", {
|
||||
extensionId: copied.id,
|
||||
name: "Repacked Fixture Extension",
|
||||
path: repackedDir,
|
||||
link: false,
|
||||
});
|
||||
assert.equal(repacked.name, "Repacked Fixture Extension");
|
||||
assert.equal(repacked.version, "2.0.0");
|
||||
assert.equal(repacked.file_name, "unpacked-extension-v2.zip");
|
||||
assert.deepEqual(
|
||||
await readdir(
|
||||
path.join(app.dataRoot, "data", "extensions", copied.id, "file"),
|
||||
),
|
||||
["unpacked-extension-v2.zip"],
|
||||
"re-importing replaces the stored payload instead of stacking a second one",
|
||||
);
|
||||
|
||||
// Re-importing a linked extension as a copy ends the link, which is what
|
||||
// makes it portable again. With no explicit name the manifest names it.
|
||||
const unlinked = await app.invoke("update_extension_from_path", {
|
||||
extensionId: linked.id,
|
||||
name: null,
|
||||
path: repackedDir,
|
||||
link: false,
|
||||
});
|
||||
assert.equal(unlinked.linked_path, null);
|
||||
assert.equal(unlinked.source_kind, "unpacked");
|
||||
assert.equal(unlinked.name, "Donut E2E Unpacked v2");
|
||||
|
||||
assert.match(
|
||||
await app.invokeError("add_unpacked_extension", {
|
||||
name: "Not An Extension",
|
||||
path: app.root,
|
||||
link: false,
|
||||
}),
|
||||
/EXTENSION_MANIFEST_MISSING/,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("update_extension_from_path", {
|
||||
extensionId: unlinked.id,
|
||||
name: null,
|
||||
path: path.join(app.root, "fixtures", "absent"),
|
||||
link: false,
|
||||
}),
|
||||
/EXTENSION_DIR_NOT_FOUND/,
|
||||
);
|
||||
|
||||
for (const id of [copied.id, unlinked.id]) {
|
||||
await app.invoke("delete_extension", { extensionId: id });
|
||||
}
|
||||
assert.deepEqual(await app.invoke("list_extensions"), []);
|
||||
assert.ok(
|
||||
existsSync(path.join(unpackedDir, "manifest.json")),
|
||||
"importing a folder must never move or consume the user's copy of it",
|
||||
);
|
||||
|
||||
const vpn = await app.invoke("create_vpn_config_manual", {
|
||||
name: "E2E WireGuard",
|
||||
vpnType: "WireGuard",
|
||||
|
||||
@@ -3,6 +3,14 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import {
|
||||
extensionZipBase64,
|
||||
LOCALIZED_EXTENSION_MESSAGES,
|
||||
localizedExtensionZipBase64,
|
||||
OVERSIZED_EXTENSION_NAME,
|
||||
oversizedExtensionZipBase64,
|
||||
writeUnpackedExtension,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
const VLESS_URI =
|
||||
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#MCP";
|
||||
@@ -93,6 +101,27 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
|
||||
]) {
|
||||
assert.ok(paths.includes(required), `OpenAPI is missing ${required}`);
|
||||
}
|
||||
// The served spec comes from the hand-maintained ApiDoc derive, not from
|
||||
// the router, so an extension route can answer requests while being absent
|
||||
// from the contract an agent generates its client from.
|
||||
for (const [route, methods] of [
|
||||
["/v1/extensions", ["get", "post"]],
|
||||
["/v1/extensions/{id}", ["get", "put", "delete"]],
|
||||
["/v1/extension-groups", ["get", "post"]],
|
||||
["/v1/extension-groups/{id}", ["get", "put", "delete"]],
|
||||
[
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
["post", "delete"],
|
||||
],
|
||||
]) {
|
||||
assert.ok(paths.includes(route), `OpenAPI is missing ${route}`);
|
||||
for (const method of methods) {
|
||||
assert.ok(
|
||||
openapi.value.paths[route][method],
|
||||
`OpenAPI is missing ${method.toUpperCase()} ${route}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const unauthorized = await jsonRequest(`${base}/v1/profiles`);
|
||||
assert.equal(unauthorized.response.status, 401);
|
||||
@@ -197,6 +226,428 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
|
||||
assert.equal(imported.response.status, 200);
|
||||
assert.equal(imported.value.imported_count, 1);
|
||||
|
||||
// Extensions arrive either as an inline payload or as a path the app can
|
||||
// read, and the folder form is the whole point: it is how an agent reaches
|
||||
// the "load unpacked" flow that the desktop offers through a file picker.
|
||||
const archiveExtension = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Archive Extension",
|
||||
file_name: "fixture.zip",
|
||||
file_data_base64: extensionZipBase64(),
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
archiveExtension.response.status,
|
||||
201,
|
||||
JSON.stringify(archiveExtension.value),
|
||||
);
|
||||
assert.equal(archiveExtension.value.name, "Donut E2E Fixture");
|
||||
assert.equal(archiveExtension.value.source_kind, "archive");
|
||||
assert.equal(archiveExtension.value.linked_path, null);
|
||||
|
||||
const unpackedDir = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "rest-unpacked-extension"),
|
||||
{ name: "Donut REST Unpacked", version: "1.2.0" },
|
||||
);
|
||||
const folderExtension = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Folder Extension", source_path: unpackedDir },
|
||||
});
|
||||
assert.equal(
|
||||
folderExtension.response.status,
|
||||
201,
|
||||
JSON.stringify(folderExtension.value),
|
||||
);
|
||||
assert.equal(folderExtension.value.name, "Donut REST Unpacked");
|
||||
assert.equal(folderExtension.value.version, "1.2.0");
|
||||
assert.equal(folderExtension.value.source_kind, "unpacked");
|
||||
assert.equal(folderExtension.value.linked_path, null);
|
||||
|
||||
// Two sources in one request have no defined winner, so the request is
|
||||
// refused rather than silently resolved.
|
||||
const ambiguousSource = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Ambiguous Extension",
|
||||
file_name: "fixture.zip",
|
||||
file_data_base64: extensionZipBase64(),
|
||||
source_path: unpackedDir,
|
||||
},
|
||||
});
|
||||
assert.equal(ambiguousSource.response.status, 400);
|
||||
assert.match(
|
||||
JSON.stringify(ambiguousSource.value),
|
||||
/EXTENSION_SOURCE_REQUIRED/,
|
||||
);
|
||||
const sourcelessExtension = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Sourceless Extension" },
|
||||
});
|
||||
assert.equal(sourcelessExtension.response.status, 400);
|
||||
assert.match(
|
||||
JSON.stringify(sourcelessExtension.value),
|
||||
/EXTENSION_SOURCE_REQUIRED/,
|
||||
);
|
||||
// An archive has no folder to keep loading from, so linking one is refused
|
||||
// rather than quietly stored as a copy.
|
||||
const linkedArchive = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Linked Archive",
|
||||
file_name: "fixture.zip",
|
||||
file_data_base64: extensionZipBase64(),
|
||||
link: true,
|
||||
},
|
||||
});
|
||||
assert.equal(linkedArchive.response.status, 400);
|
||||
assert.match(
|
||||
JSON.stringify(linkedArchive.value),
|
||||
/EXTENSION_LINK_REQUIRES_DIRECTORY/,
|
||||
);
|
||||
|
||||
const extensionId = folderExtension.value.id;
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extensions/${extensionId}`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value.id,
|
||||
extensionId,
|
||||
);
|
||||
const renamedExtension = await jsonRequest(
|
||||
`${base}/v1/extensions/${extensionId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Renamed Extension" },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
renamedExtension.response.status,
|
||||
200,
|
||||
JSON.stringify(renamedExtension.value),
|
||||
);
|
||||
assert.equal(renamedExtension.value.name, "REST Renamed Extension");
|
||||
assert.equal(
|
||||
(await jsonRequest(`${base}/v1/extensions`, { token: saved.api_token }))
|
||||
.value.length,
|
||||
2,
|
||||
);
|
||||
|
||||
// Axum's default body limit is 2 MiB, which plenty of real `.crx` files
|
||||
// exceed: every one of them was refused before the handler ran until the
|
||||
// extension payload routes got a limit of their own. The fixture below is
|
||||
// stored rather than deflated, so the body genuinely stays over the
|
||||
// default and a 201 can only come from the raised limit.
|
||||
const oversizedBody = {
|
||||
name: "REST Oversized Extension",
|
||||
file_name: "oversized.zip",
|
||||
file_data_base64: oversizedExtensionZipBase64(),
|
||||
};
|
||||
assert.ok(
|
||||
Buffer.byteLength(JSON.stringify(oversizedBody)) > 2 * 1024 * 1024,
|
||||
"the oversized fixture must exceed the default body limit it tests",
|
||||
);
|
||||
const oversized = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: oversizedBody,
|
||||
});
|
||||
assert.equal(
|
||||
oversized.response.status,
|
||||
201,
|
||||
JSON.stringify(oversized.value),
|
||||
);
|
||||
// Read out of the archive that arrived, so the payload landed whole rather
|
||||
// than merely being accepted.
|
||||
assert.equal(oversized.value.name, OVERSIZED_EXTENSION_NAME);
|
||||
assert.equal(oversized.value.version, "1.0.0");
|
||||
assert.equal(oversized.value.file_type, "zip");
|
||||
|
||||
// The raised limit is scoped to the two paths that carry a payload. A
|
||||
// group name is never megabytes long, so a route that accepted one would
|
||||
// mean the layer had been attached to the whole router.
|
||||
const oversizedGroupBody = { name: "G".repeat(3 * 1024 * 1024) };
|
||||
assert.ok(
|
||||
Buffer.byteLength(JSON.stringify(oversizedGroupBody)) > 2 * 1024 * 1024,
|
||||
);
|
||||
const oversizedGroup = await jsonRequest(`${base}/v1/extension-groups`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: oversizedGroupBody,
|
||||
});
|
||||
assert.equal(
|
||||
oversizedGroup.response.status,
|
||||
413,
|
||||
JSON.stringify(oversizedGroup.value),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value,
|
||||
[],
|
||||
"the refused group request must not have stored anything",
|
||||
);
|
||||
|
||||
// Chrome Web Store extensions overwhelmingly localize their manifest: the
|
||||
// name a user recognizes sits in `_locales/<default_locale>/messages.json`
|
||||
// and the manifest holds `__MSG_extName__`. Storing the manifest verbatim
|
||||
// is what puts a raw placeholder in the extension list.
|
||||
const localized = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Localized Extension",
|
||||
file_name: "localized.zip",
|
||||
file_data_base64: localizedExtensionZipBase64(),
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
localized.response.status,
|
||||
201,
|
||||
JSON.stringify(localized.value),
|
||||
);
|
||||
assert.equal(localized.value.name, LOCALIZED_EXTENSION_MESSAGES.extName);
|
||||
assert.equal(
|
||||
localized.value.description,
|
||||
LOCALIZED_EXTENSION_MESSAGES.extDescription,
|
||||
);
|
||||
assert.equal(
|
||||
localized.value.author,
|
||||
LOCALIZED_EXTENSION_MESSAGES.extAuthor,
|
||||
);
|
||||
assert.doesNotMatch(JSON.stringify(localized.value), /__MSG_/);
|
||||
// The resolved strings have to be what was persisted, not something the
|
||||
// create response computed on its way out.
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extensions/${localized.value.id}`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value.name,
|
||||
LOCALIZED_EXTENSION_MESSAGES.extName,
|
||||
);
|
||||
|
||||
// A placeholder the locale file cannot resolve falls back to the name the
|
||||
// caller sent. What it must never do is store `__MSG_extName__` itself.
|
||||
const unresolved = await jsonRequest(`${base}/v1/extensions`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Unresolved Placeholder",
|
||||
file_name: "unresolved.zip",
|
||||
file_data_base64: localizedExtensionZipBase64({ messages: {} }),
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
unresolved.response.status,
|
||||
201,
|
||||
JSON.stringify(unresolved.value),
|
||||
);
|
||||
assert.equal(unresolved.value.name, "REST Unresolved Placeholder");
|
||||
assert.equal(unresolved.value.description, null);
|
||||
assert.equal(unresolved.value.author, null);
|
||||
assert.doesNotMatch(JSON.stringify(unresolved.value), /__MSG_/);
|
||||
|
||||
const extensionGroup = await jsonRequest(`${base}/v1/extension-groups`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Extension Group" },
|
||||
});
|
||||
assert.equal(
|
||||
extensionGroup.response.status,
|
||||
201,
|
||||
JSON.stringify(extensionGroup.value),
|
||||
);
|
||||
assert.equal(extensionGroup.value.name, "REST Extension Group");
|
||||
assert.deepEqual(extensionGroup.value.extension_ids, []);
|
||||
const extensionGroupId = extensionGroup.value.id;
|
||||
const renamedExtensionGroup = await jsonRequest(
|
||||
`${base}/v1/extension-groups/${extensionGroupId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Extension Group Updated" },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
renamedExtensionGroup.response.status,
|
||||
200,
|
||||
JSON.stringify(renamedExtensionGroup.value),
|
||||
);
|
||||
assert.equal(
|
||||
renamedExtensionGroup.value.name,
|
||||
"REST Extension Group Updated",
|
||||
);
|
||||
|
||||
const membershipUrl = `${base}/v1/extension-groups/${extensionGroupId}/extensions/${extensionId}`;
|
||||
const joined = await jsonRequest(membershipUrl, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(joined.response.status, 200, JSON.stringify(joined.value));
|
||||
assert.deepEqual(joined.value.extension_ids, [extensionId]);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value.extension_ids,
|
||||
[extensionId],
|
||||
);
|
||||
const left = await jsonRequest(membershipUrl, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(left.response.status, 200, JSON.stringify(left.value));
|
||||
assert.deepEqual(left.value.extension_ids, []);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
|
||||
token: saved.api_token,
|
||||
})
|
||||
).value.extension_ids,
|
||||
[],
|
||||
);
|
||||
|
||||
// The whole path an automation client takes: an extension, a group holding
|
||||
// it, and a profile that will load that group the next time it launches.
|
||||
// Each piece already had coverage; the sequence did not, and it is the
|
||||
// sequence that has to work for extensions to be usable over REST at all.
|
||||
const launchProfile = await app.invoke("create_browser_profile_new", {
|
||||
name: "REST Extension Profile",
|
||||
browserStr: "wayfern",
|
||||
version: "150.0.7871.100",
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
// A stored fingerprint keeps this suite off the real browser; the
|
||||
// browser suite covers generation.
|
||||
wayfernConfig: { fingerprint: "{}" },
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
const launchGroup = await jsonRequest(`${base}/v1/extension-groups`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Launch Extension Group" },
|
||||
});
|
||||
assert.equal(
|
||||
launchGroup.response.status,
|
||||
201,
|
||||
JSON.stringify(launchGroup.value),
|
||||
);
|
||||
const launchGroupId = launchGroup.value.id;
|
||||
assert.deepEqual(
|
||||
(
|
||||
await jsonRequest(
|
||||
`${base}/v1/extension-groups/${launchGroupId}/extensions/${archiveExtension.value.id}`,
|
||||
{ method: "POST", token: saved.api_token },
|
||||
)
|
||||
).value.extension_ids,
|
||||
[archiveExtension.value.id],
|
||||
);
|
||||
const assigned = await jsonRequest(
|
||||
`${base}/v1/profiles/${launchProfile.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { extension_group_id: launchGroupId },
|
||||
},
|
||||
);
|
||||
assert.equal(assigned.response.status, 200, JSON.stringify(assigned.value));
|
||||
assert.equal(assigned.value.profile.id, launchProfile.id);
|
||||
// `ApiProfile` carries no `extension_group_id`, so the assignment can only
|
||||
// be read back through the surface the launcher itself resolves.
|
||||
const assignedGroup = () =>
|
||||
app.invoke("get_extension_group_for_profile", {
|
||||
profileId: launchProfile.id,
|
||||
});
|
||||
assert.equal((await assignedGroup()).id, launchGroupId);
|
||||
assert.deepEqual((await assignedGroup()).extension_ids, [
|
||||
archiveExtension.value.id,
|
||||
]);
|
||||
|
||||
// A group that does not exist used to be stored anyway and fail at launch,
|
||||
// far from the request that caused it.
|
||||
const missingExtensionGroup = await jsonRequest(
|
||||
`${base}/v1/profiles/${launchProfile.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { extension_group_id: "00000000-0000-0000-0000-0000000000ee" },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
missingExtensionGroup.response.status,
|
||||
404,
|
||||
JSON.stringify(missingExtensionGroup.value),
|
||||
);
|
||||
assert.equal(
|
||||
(await assignedGroup()).id,
|
||||
launchGroupId,
|
||||
"a refused assignment must leave the previous one in place",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, {
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { extension_group_id: "" },
|
||||
})
|
||||
).response.status,
|
||||
200,
|
||||
);
|
||||
assert.equal(await assignedGroup(), null);
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups/${launchGroupId}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
await app.invoke("delete_profile", { profileId: launchProfile.id });
|
||||
|
||||
for (const id of [
|
||||
extensionId,
|
||||
archiveExtension.value.id,
|
||||
oversized.value.id,
|
||||
localized.value.id,
|
||||
unresolved.value.id,
|
||||
]) {
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extensions/${id}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
|
||||
const missing = await jsonRequest(`${base}/v1/groups/missing`, {
|
||||
token: saved.api_token,
|
||||
});
|
||||
@@ -323,6 +774,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
|
||||
"run_profile_remote",
|
||||
"get_remote_session",
|
||||
"stop_remote_session",
|
||||
// Extension management is only usable from an agent if importing and
|
||||
// grouping are reachable, not just listing and deleting.
|
||||
"add_extension",
|
||||
"update_extension",
|
||||
"add_extension_to_group",
|
||||
"remove_extension_from_group",
|
||||
"update_extension_group",
|
||||
]) {
|
||||
assert.ok(names.includes(name), `MCP is missing ${name}`);
|
||||
}
|
||||
@@ -414,6 +872,101 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
|
||||
);
|
||||
await app.invoke("delete_stored_proxy", { proxyId: vlessProxy.id });
|
||||
|
||||
let toolCallId = 7;
|
||||
const callTool = (name, args) =>
|
||||
jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: toolCallId++,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
});
|
||||
|
||||
const unpackedDir = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "mcp-unpacked-extension"),
|
||||
{ name: "Donut MCP Unpacked", version: "1.0.0" },
|
||||
);
|
||||
const addedExtension = await callTool("add_extension", {
|
||||
path: unpackedDir,
|
||||
name: "MCP Folder Extension",
|
||||
});
|
||||
assert.equal(addedExtension.response.status, 200);
|
||||
const subscriptionGated = /subscription/i.test(
|
||||
addedExtension.value.error?.message ?? "",
|
||||
);
|
||||
// The e2e build overrides the paid-plan gate whenever a Wayfern test token
|
||||
// is present, so with one in the environment a gated answer means the
|
||||
// override stopped working and everything below it silently stopped
|
||||
// running.
|
||||
assert.ok(
|
||||
!subscriptionGated || !process.env.WAYFERN_TEST_TOKEN,
|
||||
`the e2e paid-plan override did not apply: ${addedExtension.value.error?.message}`,
|
||||
);
|
||||
if (subscriptionGated) {
|
||||
// Every extension tool is gated on an active paid plan and this session
|
||||
// is signed out, so the call path is unreachable here. The tool list
|
||||
// above still proves the tools are published.
|
||||
console.warn(
|
||||
"Skipping the MCP extension tool calls: this session has no paid entitlement",
|
||||
);
|
||||
} else {
|
||||
assert.equal(addedExtension.value.error, undefined);
|
||||
const stored = (await app.invoke("list_extensions")).find(
|
||||
(item) => item.name === "Donut MCP Unpacked",
|
||||
);
|
||||
assert.ok(stored, "the MCP import must produce a stored extension");
|
||||
assert.equal(stored.source_kind, "unpacked");
|
||||
assert.equal(stored.linked_path, null);
|
||||
|
||||
const renamedExtension = await callTool("update_extension", {
|
||||
extension_id: stored.id,
|
||||
name: "MCP Renamed Extension",
|
||||
});
|
||||
assert.equal(renamedExtension.value.error, undefined);
|
||||
assert.equal(
|
||||
(await app.invoke("list_extensions")).find(
|
||||
(item) => item.id === stored.id,
|
||||
).name,
|
||||
"MCP Renamed Extension",
|
||||
);
|
||||
|
||||
const extensionGroup = await app.invoke("create_extension_group", {
|
||||
name: "MCP Extension Group",
|
||||
});
|
||||
const joined = await callTool("add_extension_to_group", {
|
||||
group_id: extensionGroup.id,
|
||||
extension_id: stored.id,
|
||||
});
|
||||
assert.equal(joined.value.error, undefined);
|
||||
const readGroup = async () =>
|
||||
(await app.invoke("list_extension_groups")).find(
|
||||
(item) => item.id === extensionGroup.id,
|
||||
);
|
||||
assert.deepEqual((await readGroup()).extension_ids, [stored.id]);
|
||||
|
||||
const renamedGroup = await callTool("update_extension_group", {
|
||||
group_id: extensionGroup.id,
|
||||
name: "MCP Extension Group Updated",
|
||||
});
|
||||
assert.equal(renamedGroup.value.error, undefined);
|
||||
assert.equal((await readGroup()).name, "MCP Extension Group Updated");
|
||||
|
||||
const removed = await callTool("remove_extension_from_group", {
|
||||
group_id: extensionGroup.id,
|
||||
extension_id: stored.id,
|
||||
});
|
||||
assert.equal(removed.value.error, undefined);
|
||||
assert.deepEqual((await readGroup()).extension_ids, []);
|
||||
|
||||
await app.invoke("delete_extension", { extensionId: stored.id });
|
||||
await app.invoke("delete_extension_group", {
|
||||
groupId: extensionGroup.id,
|
||||
});
|
||||
}
|
||||
|
||||
const agents = await app.invoke("list_mcp_agents");
|
||||
assert.ok(agents.some((agent) => agent.id === "cursor"));
|
||||
await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", {
|
||||
|
||||
+461
-1
@@ -1,8 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, realpath, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import Color from "color";
|
||||
import en from "../../src/i18n/locales/en.json" with { type: "json" };
|
||||
import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import {
|
||||
extensionZipBase64,
|
||||
writeUnpackedExtension,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
const THEME_VARIABLES = [
|
||||
"--background",
|
||||
@@ -87,6 +94,21 @@ function themeVariablesEqual(actual, expected) {
|
||||
async function applyThemeForContrastAudit(app, theme) {
|
||||
await app.execute(
|
||||
`
|
||||
// This audit reads settled colour tokens, not the animation between
|
||||
// them. Tab triggers carry "transition-colors duration-150" and start
|
||||
// from --muted-foreground, so a computed style sampled mid-transition
|
||||
// returns an intermediate colour and the assertion fails on whichever
|
||||
// theme the machine happened to be slow on. Kill transitions for the
|
||||
// duration of the audit rather than racing them with a fixed sleep.
|
||||
let freeze = document.getElementById("donut-e2e-freeze-transitions");
|
||||
if (!freeze) {
|
||||
freeze = document.createElement("style");
|
||||
freeze.id = "donut-e2e-freeze-transitions";
|
||||
freeze.textContent =
|
||||
"*, *::before, *::after { transition: none !important; animation: none !important; }";
|
||||
document.head.appendChild(freeze);
|
||||
}
|
||||
|
||||
const [colors, derived, mode] = arguments;
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
@@ -97,7 +119,11 @@ async function applyThemeForContrastAudit(app, theme) {
|
||||
`,
|
||||
[theme.colors, getDerivedThemeColors(theme.colors), theme.mode],
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
// One frame is enough once transitions are off; the value cannot drift after
|
||||
// style recalculation.
|
||||
await app.execute(
|
||||
`return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))));`,
|
||||
);
|
||||
}
|
||||
|
||||
async function animatedTabContrastSnapshot(app) {
|
||||
@@ -1013,3 +1039,437 @@ test("a light custom preset keeps light component behavior after restart", async
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const EXTENSION_STRINGS = en.extensions;
|
||||
|
||||
/**
|
||||
* Answer the native directory picker from inside the webview.
|
||||
*
|
||||
* "Load unpacked" calls `open({ directory: true })` from
|
||||
* `@tauri-apps/plugin-dialog`, which puts an OS window on screen that no
|
||||
* WebDriver can reach. The call leaves the page as the `plugin:dialog|open` IPC
|
||||
* command, but Tauri locks its own entry points down: `invoke`, `ipc` and
|
||||
* `postMessage` are all installed with
|
||||
* `Object.defineProperty(window.__TAURI_INTERNALS__, name, { value })`, so they
|
||||
* are non-writable and cannot be wrapped. The seam underneath them is the
|
||||
* transport, which POSTs the command through `fetch` to
|
||||
* `ipc://localhost/<command>`. Answering that one request with the shape Tauri
|
||||
* expects (`Tauri-Response: ok` plus a JSON body) resolves the picker with a
|
||||
* folder and needs no test-only hook in the production component. Every other
|
||||
* command still reaches the real backend.
|
||||
*/
|
||||
async function stubFolderPicker(app, folder) {
|
||||
await app.execute(
|
||||
`const folder = arguments[0];
|
||||
if (!window.__donutOriginalFetch) {
|
||||
window.__donutOriginalFetch = window.fetch;
|
||||
}
|
||||
window.__donutFolderPickerCalls = [];
|
||||
window.fetch = function (input, init) {
|
||||
const url = String(
|
||||
typeof input === "string" ? input : (input && input.url) || "",
|
||||
);
|
||||
let command = "";
|
||||
try {
|
||||
command = decodeURIComponent(url.split("/").pop() || "");
|
||||
} catch (_error) {
|
||||
command = "";
|
||||
}
|
||||
if (command === "plugin:dialog|open") {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse((init && init.body) || "null");
|
||||
} catch (_error) {
|
||||
payload = null;
|
||||
}
|
||||
window.__donutFolderPickerCalls.push(payload);
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(folder), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"Tauri-Response": "ok",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return window.__donutOriginalFetch.apply(window, arguments);
|
||||
};
|
||||
return true;`,
|
||||
[folder],
|
||||
);
|
||||
}
|
||||
|
||||
/** Restores the real transport and returns what the picker was asked for. */
|
||||
async function restoreFolderPicker(app) {
|
||||
return app.execute(
|
||||
`const calls = window.__donutFolderPickerCalls ?? [];
|
||||
if (window.__donutOriginalFetch) {
|
||||
window.fetch = window.__donutOriginalFetch;
|
||||
delete window.__donutOriginalFetch;
|
||||
}
|
||||
delete window.__donutFolderPickerCalls;
|
||||
return calls;`,
|
||||
);
|
||||
}
|
||||
|
||||
async function openExtensionsPage(app) {
|
||||
await app.clickSelector('[aria-label="Extensions"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return Boolean(document.querySelector(arguments[0]));`, [
|
||||
`[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`,
|
||||
]),
|
||||
{ description: "extension management page" },
|
||||
);
|
||||
}
|
||||
|
||||
async function stageUnpackedFolder(app, folder) {
|
||||
await stubFolderPicker(app, folder);
|
||||
await app.clickSelector(`[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return Boolean(document.querySelector("#ext-link-folder"));`,
|
||||
),
|
||||
{
|
||||
description:
|
||||
"staged folder import form (the intercepted directory picker has to resolve)",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadArchiveThroughUi(app, archivePath, typedName) {
|
||||
// The real control is a hidden file input a button clicks for the user;
|
||||
// WebDriver can only type a path into an input it can see.
|
||||
await app.execute(`
|
||||
const input = document.querySelector("#ext-file-input");
|
||||
input.classList.remove("hidden");
|
||||
input.style.position = "fixed";
|
||||
input.style.left = "12px";
|
||||
input.style.bottom = "12px";
|
||||
`);
|
||||
const input = await app.session.findCss("#ext-file-input");
|
||||
await app.session.sendKeys(input, archivePath);
|
||||
await app.waitForText(path.basename(archivePath));
|
||||
await app.execute(`
|
||||
const input = document.querySelector("#ext-file-input");
|
||||
input.classList.add("hidden");
|
||||
input.removeAttribute("style");
|
||||
`);
|
||||
await app.fillSelector(
|
||||
`input[placeholder="${EXTENSION_STRINGS.namePlaceholder}"]`,
|
||||
typedName,
|
||||
);
|
||||
await app.clickText(en.common.buttons.add, { roles: ["button"] });
|
||||
}
|
||||
|
||||
/** The link checkbox plus the copy that is supposed to explain it. */
|
||||
async function linkCheckboxState(app, id) {
|
||||
return app.execute(
|
||||
`const checkbox = document.querySelector("#" + arguments[0]);
|
||||
const label = document.querySelector('label[for="' + arguments[0] + '"]');
|
||||
const help = label?.parentElement?.querySelector("p");
|
||||
return checkbox
|
||||
? {
|
||||
checked: checkbox.getAttribute("data-state") === "checked",
|
||||
label: (label?.innerText ?? "").trim(),
|
||||
help: (help?.innerText ?? "").trim(),
|
||||
}
|
||||
: null;`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
|
||||
function extensionRowScript(body) {
|
||||
return `const wanted = arguments[0];
|
||||
const row = [...document.querySelectorAll("tbody tr")].find((candidate) => {
|
||||
const cells = [...candidate.querySelectorAll("td")];
|
||||
return cells.length >= 7 && (cells[2].innerText || "").trim() === wanted;
|
||||
});
|
||||
${body}`;
|
||||
}
|
||||
|
||||
async function extensionRow(app, name) {
|
||||
return app.execute(
|
||||
extensionRowScript(`if (!row) return null;
|
||||
const cells = [...row.querySelectorAll("td")];
|
||||
const sync = row.querySelector('[data-slot="animated-switch"]');
|
||||
return {
|
||||
name: (cells[2].innerText || "").trim(),
|
||||
source: (cells[4].innerText || "").trim(),
|
||||
syncChecked: sync ? sync.getAttribute("data-state") === "checked" : null,
|
||||
syncDisabled: sync ? sync.disabled === true : null,
|
||||
};`),
|
||||
[name],
|
||||
);
|
||||
}
|
||||
|
||||
async function extensionEditButton(app, name) {
|
||||
return app.execute(
|
||||
extensionRowScript(
|
||||
`return row ? row.querySelector("td:last-child button") : null;`,
|
||||
),
|
||||
[name],
|
||||
);
|
||||
}
|
||||
|
||||
async function dialogText(app, title) {
|
||||
return app.execute(
|
||||
`const wanted = arguments[0];
|
||||
const dialog = [...document.querySelectorAll("[role='dialog']")]
|
||||
.reverse()
|
||||
.find((node) =>
|
||||
[...node.querySelectorAll("[data-slot='dialog-title']")].some(
|
||||
(heading) => (heading.textContent || "").trim() === wanted,
|
||||
),
|
||||
);
|
||||
return dialog ? (dialog.innerText || "").trim() : null;`,
|
||||
[title],
|
||||
);
|
||||
}
|
||||
|
||||
async function toastTexts(app) {
|
||||
return app.execute(
|
||||
`return [...document.querySelectorAll("[data-sonner-toast]")]
|
||||
.map((toast) => (toast.innerText || "").trim())
|
||||
.filter(Boolean);`,
|
||||
);
|
||||
}
|
||||
|
||||
test("an uploaded archive and a loaded folder both import, each under its own source", async () => {
|
||||
await withApp("ui-extension-import-sources", async (app) => {
|
||||
const archivePath = path.join(app.root, "ui-archive-extension.zip");
|
||||
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
|
||||
const folder = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "ui-copied-extension"),
|
||||
{ name: "Donut UI Copied Folder" },
|
||||
);
|
||||
|
||||
await openExtensionsPage(app);
|
||||
await uploadArchiveThroughUi(
|
||||
app,
|
||||
archivePath,
|
||||
"Overridden By The Manifest",
|
||||
);
|
||||
await app.waitForText("Donut E2E Fixture");
|
||||
|
||||
await stageUnpackedFolder(app, folder);
|
||||
assert.ok(await app.visibleTextIncludes(EXTENSION_STRINGS.selectedFolder));
|
||||
assert.ok(
|
||||
await app.visibleTextIncludes(folder),
|
||||
"the staged import has to name the folder it is about to read",
|
||||
);
|
||||
const staged = await linkCheckboxState(app, "ext-link-folder");
|
||||
assert.equal(staged?.checked, false, "linking a folder has to be opt-in");
|
||||
assert.equal(staged.help, EXTENSION_STRINGS.linkFolderOff);
|
||||
await app.clickText(en.common.buttons.add, { roles: ["button"] });
|
||||
await app.waitForText("Donut UI Copied Folder");
|
||||
|
||||
const pickerCalls = await restoreFolderPicker(app);
|
||||
assert.equal(pickerCalls.length, 1, "Load unpacked has to open the picker");
|
||||
assert.equal(pickerCalls[0].options.directory, true);
|
||||
assert.equal(pickerCalls[0].options.multiple, false);
|
||||
assert.equal(
|
||||
pickerCalls[0].options.title,
|
||||
EXTENSION_STRINGS.selectFolderTitle,
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
EXTENSION_STRINGS.source.archive,
|
||||
EXTENSION_STRINGS.source.unpacked,
|
||||
);
|
||||
assert.equal(
|
||||
(await extensionRow(app, "Donut E2E Fixture"))?.source,
|
||||
EXTENSION_STRINGS.source.archive,
|
||||
);
|
||||
assert.equal(
|
||||
(await extensionRow(app, "Donut UI Copied Folder"))?.source,
|
||||
EXTENSION_STRINGS.source.unpacked,
|
||||
);
|
||||
|
||||
const extensions = await app.invoke("list_extensions");
|
||||
assert.equal(extensions.length, 2);
|
||||
const copied = extensions.find(
|
||||
(extension) => extension.name === "Donut UI Copied Folder",
|
||||
);
|
||||
assert.equal(copied.source_kind, "unpacked");
|
||||
assert.equal(
|
||||
copied.linked_path,
|
||||
null,
|
||||
"an unlinked folder import is copied into the store, not pointed at",
|
||||
);
|
||||
assert.equal(copied.file_type, "zip");
|
||||
const archive = extensions.find(
|
||||
(extension) => extension.name === "Donut E2E Fixture",
|
||||
);
|
||||
assert.equal(archive.source_kind, "archive");
|
||||
assert.equal(archive.linked_path, null);
|
||||
});
|
||||
});
|
||||
|
||||
test("linking a folder says what it costs, records the path, and locks that row's sync off", async () => {
|
||||
await withApp("ui-extension-linked-folder", async (app) => {
|
||||
await app.invoke("add_extension", {
|
||||
name: "Copied Neighbour",
|
||||
fileName: "ui-neighbour-extension.zip",
|
||||
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||
});
|
||||
const folder = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "ui-linked-extension"),
|
||||
{ name: "Donut UI Linked Folder" },
|
||||
);
|
||||
|
||||
await openExtensionsPage(app);
|
||||
await app.waitForText("Donut E2E Fixture");
|
||||
await stageUnpackedFolder(app, folder);
|
||||
|
||||
const off = await linkCheckboxState(app, "ext-link-folder");
|
||||
assert.equal(off?.checked, false);
|
||||
assert.equal(off.label, EXTENSION_STRINGS.linkFolder);
|
||||
assert.equal(off.help, EXTENSION_STRINGS.linkFolderOff);
|
||||
|
||||
await app.clickSelector("#ext-link-folder");
|
||||
const on = await app.waitFor(
|
||||
async () => {
|
||||
const state = await linkCheckboxState(app, "ext-link-folder");
|
||||
return state?.checked ? state : false;
|
||||
},
|
||||
{ description: "link checkbox to turn on" },
|
||||
);
|
||||
assert.equal(on.help, EXTENSION_STRINGS.linkFolderOn);
|
||||
assert.notEqual(
|
||||
on.help,
|
||||
off.help,
|
||||
"the checkbox has to say what turning it on changes",
|
||||
);
|
||||
|
||||
await app.clickText(en.common.buttons.add, { roles: ["button"] });
|
||||
await app.waitForText("Donut UI Linked Folder");
|
||||
assert.equal((await restoreFolderPicker(app)).length, 1);
|
||||
|
||||
const linkedRow = await extensionRow(app, "Donut UI Linked Folder");
|
||||
assert.equal(linkedRow?.source, EXTENSION_STRINGS.source.linked);
|
||||
assert.equal(linkedRow.syncChecked, false);
|
||||
assert.equal(linkedRow.syncDisabled, true);
|
||||
assert.equal(
|
||||
(await extensionRow(app, "Donut E2E Fixture"))?.syncDisabled,
|
||||
false,
|
||||
"only the linked row loses its sync control",
|
||||
);
|
||||
|
||||
const linked = (await app.invoke("list_extensions")).find(
|
||||
(extension) => extension.name === "Donut UI Linked Folder",
|
||||
);
|
||||
assert.equal(linked.linked_path, await realpath(folder));
|
||||
assert.equal(linked.file_type, "unpacked");
|
||||
assert.equal(linked.sync_enabled, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("the edit dialog replaces an extension's payload from a folder", async () => {
|
||||
await withApp("ui-extension-replace-from-folder", async (app) => {
|
||||
const original = await app.invoke("add_extension", {
|
||||
name: "Replaced Later",
|
||||
fileName: "ui-original-extension.zip",
|
||||
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||
});
|
||||
assert.equal(original.version, "1.0.0");
|
||||
const folder = await writeUnpackedExtension(
|
||||
path.join(app.root, "fixtures", "ui-replacement-extension"),
|
||||
{ name: "Donut UI Replacement", version: "3.1.4" },
|
||||
);
|
||||
|
||||
await openExtensionsPage(app);
|
||||
await app.waitForText("Donut E2E Fixture");
|
||||
const editButton = await extensionEditButton(app, "Donut E2E Fixture");
|
||||
assert.ok(editButton, "the extension row's edit control was not visible");
|
||||
await app.clickElement(editButton, "extension edit button");
|
||||
const beforeReplace = await app.waitFor(
|
||||
() => dialogText(app, EXTENSION_STRINGS.editExtension),
|
||||
{ description: "extension edit dialog" },
|
||||
);
|
||||
assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.label));
|
||||
assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.archive));
|
||||
|
||||
await stubFolderPicker(app, folder);
|
||||
await app.clickTextIn('[role="dialog"]', EXTENSION_STRINGS.selectFolder, {
|
||||
roles: ["button"],
|
||||
});
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await dialogText(app, EXTENSION_STRINGS.editExtension))?.includes(
|
||||
folder,
|
||||
),
|
||||
{ description: "chosen replacement folder" },
|
||||
);
|
||||
const replaceLink = await linkCheckboxState(app, "ext-edit-link-folder");
|
||||
assert.equal(replaceLink?.checked, false);
|
||||
assert.equal(replaceLink.help, EXTENSION_STRINGS.linkFolderOff);
|
||||
|
||||
await app.clickTextIn('[role="dialog"]', en.common.buttons.save, {
|
||||
roles: ["button"],
|
||||
});
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await toastTexts(app)).some((text) =>
|
||||
text.includes(EXTENSION_STRINGS.updateSuccess),
|
||||
),
|
||||
{ description: "extension update confirmation" },
|
||||
);
|
||||
assert.equal((await restoreFolderPicker(app)).length, 1);
|
||||
|
||||
const extensions = await app.invoke("list_extensions");
|
||||
assert.equal(
|
||||
extensions.length,
|
||||
1,
|
||||
"replacing a payload must not add a second extension",
|
||||
);
|
||||
const [updated] = extensions;
|
||||
assert.equal(updated.id, original.id);
|
||||
assert.equal(updated.source_kind, "unpacked");
|
||||
assert.equal(updated.linked_path, null);
|
||||
assert.equal(updated.file_name, "ui-replacement-extension.zip");
|
||||
assert.equal(updated.version, "3.1.4");
|
||||
// The dialog's own name field stays authoritative, so the row keeps its
|
||||
// name while the payload underneath it is swapped.
|
||||
assert.equal(updated.name, "Donut E2E Fixture");
|
||||
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await extensionRow(app, "Donut E2E Fixture"))?.source ===
|
||||
EXTENSION_STRINGS.source.unpacked,
|
||||
{ description: "replaced row to report its new source" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a folder with no manifest fails with the translated reason, not a raw code", async () => {
|
||||
await withApp("ui-extension-manifest-missing", async (app) => {
|
||||
const folder = path.join(app.root, "fixtures", "ui-not-an-extension");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(path.join(folder, "readme.txt"), "no manifest here\n");
|
||||
|
||||
await openExtensionsPage(app);
|
||||
await stageUnpackedFolder(app, folder);
|
||||
await app.clickText(en.common.buttons.add, { roles: ["button"] });
|
||||
|
||||
const expected = en.backendErrors.extensionManifestMissing;
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await toastTexts(app)).some((text) => text.includes(expected)),
|
||||
{ description: "translated manifest-missing toast" },
|
||||
);
|
||||
const toasts = await toastTexts(app);
|
||||
assert.ok(
|
||||
toasts.every((text) => !text.includes("EXTENSION_MANIFEST_MISSING")),
|
||||
`a raw backend code reached the user: ${JSON.stringify(toasts)}`,
|
||||
);
|
||||
assert.ok(
|
||||
toasts.every((text) => !text.includes(EXTENSION_STRINGS.uploadFailed)),
|
||||
"the generic fallback would hide which folder problem this was",
|
||||
);
|
||||
assert.deepEqual(await app.invoke("list_extensions"), []);
|
||||
assert.equal((await restoreFolderPicker(app)).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user