feat: extension export via api

This commit is contained in:
zhom
2026-08-16 19:50:12 +04:00
parent 2be0d4df0b
commit d7f002d8ac
26 changed files with 4489 additions and 443 deletions
+180
View File
@@ -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
View File
@@ -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",
+553
View File
@@ -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
View File
@@ -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);
});
});