feat: profile templates

This commit is contained in:
zhom committed 2026-10-12 02:58:07 +04:00
1 parent 3f5b573ead
commit 2cc91ed250
29 files changed
+2430 -62

No files matched your search

+7
View File
@@ -63,6 +63,13 @@ export const commandCoverage = {
"profile_statuses::update_profile_status",
"profile_statuses::delete_profile_status",
"profile_statuses::set_profiles_status",
"profile_templates::list_profile_templates",
"profile_templates::create_profile_template",
"profile_templates::update_profile_template",
"profile_templates::delete_profile_template",
"profile_templates::save_profile_template_from_profile",
"profile_templates::bulk_create_profiles",
"profile_templates::cancel_bulk_create_profiles",
"totp::generate_totp_code",
"profile_totp::list_profile_totp",
"profile_totp::add_profile_totp",
+68
View File
@@ -1854,6 +1854,74 @@ test("start pages open on a launch from the app, and a given URL replaces them",
}
});
test("a batch from a template makes numbered profiles, each with its own device", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const app = appFromEnvironment("browser-template-batch", {
seedVersionCache:
cachedFixtureVersion(process.env.DONUT_E2E_PROJECT_ROOT) ?? false,
wayfernTermsAccepted: false,
});
try {
const prepared = await prepareWayfern(
app,
process.env.DONUT_E2E_PROJECT_ROOT,
);
if (!app.session) await app.start();
await app.invoke("accept_wayfern_terms");
// A profile that already holds the name, so numbering continues after it.
await createRealProfile(app, prepared.version, "Real Batch 1");
const template = await app.invoke("create_profile_template", {
name: "Real",
settings: {
tags: ["batch"],
start_urls: ["shop.example"],
wayfern_config: {
geoip: false,
randomize_fingerprint_on_launch: false,
},
},
});
const result = await app.invoke("bulk_create_profiles", {
settings: template.settings,
name: "Real Batch",
count: 2,
});
assert.equal(result.error, null);
assert.equal(result.cancelled, false);
assert.deepEqual(
result.created.map((p) => p.name),
["Real Batch 2", "Real Batch 3"],
);
const stored = (await app.invoke("list_browser_profiles")).filter((p) =>
result.created.some((made) => made.id === p.id),
);
assert.equal(stored.length, 2);
for (const profile of stored) {
assert.deepEqual(profile.tags, ["batch"]);
assert.deepEqual(profile.start_urls, ["https://shop.example/"]);
assert.equal(profile.version, prepared.version);
}
const devices = stored.map(
(p) => p.wayfern_config?.identity_id ?? p.wayfern_config?.fingerprint,
);
assert.ok(devices.every(Boolean), "every profile got a device");
assert.notEqual(devices[0], devices[1], "no two profiles share a device");
for (const profile of await app.invoke("list_browser_profiles")) {
await app.invoke("delete_profile", {
profileId: profile.id,
permanent: true,
});
}
} catch (error) {
await app.capture("failure");
throw error;
} finally {
await app.close();
}
});
test("custom launch arguments reach only their Wayfern profile", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const app = appFromEnvironment("browser-launch-args", {
+162
View File
@@ -1569,6 +1569,168 @@ test("bulk edit changes only the ticked settings, checks every value first, and
});
});
test("templates keep settings but never a device, and a batch stops at the hourly allowance", async () => {
await withApp(
"entities-templates",
async (app) => {
const status = await app.invoke("create_profile_status", {
name: "Fresh",
color: "#22c55e",
});
const profile = await createProfile(app, "Template Source");
await app.invoke("update_profile_note", {
profileId: profile.id,
note: "login for one shop",
});
await app.invoke("bulk_update_profiles", {
profileIds: [profile.id],
patch: {
status_id: status.id,
tags_add: ["shop"],
start_urls: ["shop.example"],
block_images: true,
},
});
const saved = await app.invoke("save_profile_template_from_profile", {
profileId: profile.id,
name: " Shop ",
});
assert.equal(saved.name, "Shop");
assert.equal(saved.settings.status_id, status.id);
assert.deepEqual(saved.settings.tags, ["shop"]);
assert.deepEqual(saved.settings.start_urls, ["https://shop.example/"]);
assert.equal(saved.settings.note, null, "a note belongs to its profile");
assert.equal(saved.settings.wayfern_config.block_images, true);
assert.equal(
saved.settings.wayfern_config.fingerprint ?? null,
null,
"a template never carries the source profile's device",
);
assert.equal(saved.settings.wayfern_config.identity_id ?? null, null);
assert.match(
await app.invokeError("save_profile_template_from_profile", {
profileId: profile.id,
name: "shop",
}),
/TEMPLATE_EXISTS/,
);
for (const [settings, code] of [
[{ proxy_id: "p", vpn_id: "v" }, /PROXY_AND_VPN_MUTUALLY_EXCLUSIVE/],
[{ status_id: "missing" }, /PROFILE_STATUS_NOT_FOUND/],
[{ start_urls: ["file:///etc/passwd"] }, /START_URL_INVALID/],
]) {
assert.match(
await app.invokeError("create_profile_template", {
name: "Broken",
settings,
}),
code,
);
}
assert.match(
await app.invokeError("create_profile_template", {
name: "x".repeat(61),
settings: {},
}),
/TEMPLATE_NAME_TOO_LONG/,
);
const mail = await app.invoke("create_profile_template", {
name: "Mail",
settings: {
name_prefix: " Mail ",
tags: ["mail", " mail "],
ephemeral: true,
clear_on_close: true,
wayfern_config: { fingerprint: "{}", os: "linux" },
},
});
assert.equal(mail.settings.name_prefix, "Mail");
assert.deepEqual(mail.settings.tags, ["mail"]);
assert.equal(mail.settings.clear_on_close, false);
assert.equal(mail.settings.wayfern_config.fingerprint ?? null, null);
assert.deepEqual(
(await app.invoke("list_profile_templates")).map((t) => t.name),
["Shop", "Mail"],
);
const renamed = await app.invoke("update_profile_template", {
templateId: mail.id,
name: "Webmail",
settings: { ...mail.settings, tags: ["webmail"] },
});
assert.equal(renamed.name, "Webmail");
assert.deepEqual(renamed.settings.tags, ["webmail"]);
assert.ok(renamed.updated_at >= mail.updated_at);
assert.match(
await app.invokeError("update_profile_template", {
templateId: mail.id,
name: "SHOP",
settings: {},
}),
/TEMPLATE_EXISTS/,
);
for (const count of [0, 101]) {
assert.match(
await app.invokeError("bulk_create_profiles", {
settings: saved.settings,
name: "Batch",
count,
}),
/BULK_CREATE_COUNT_INVALID/,
);
}
// Nothing to stop: cancelling with no batch running is harmless.
await app.invoke("cancel_bulk_create_profiles");
// The signed-out allowance is eight an hour. With eight used, one more
// manual profile would only warn, but a batch never goes past it. The
// window is read on its first use, and nothing above used it, so the
// file is read without a restart (a restart would also drop the seeded
// browser, which has no binary).
const now = Math.floor(Date.now() / 1000);
await writeFile(
path.join(app.dataRoot, "data", "settings", "profile_generations.json"),
JSON.stringify({
generations: {
local: Array.from({ length: 8 }, (_, i) => now - 600 + i),
},
}),
);
const before = (await app.invoke("list_browser_profiles")).length;
assert.match(
await app.invokeError("bulk_create_profiles", {
settings: saved.settings,
name: "Batch",
count: 3,
}),
/PROFILE_GENERATION_LIMIT_REACHED/,
);
assert.equal(
(await app.invoke("list_browser_profiles")).length,
before,
"a refused batch makes nothing",
);
await app.invoke("delete_profile_template", { templateId: mail.id });
assert.match(
await app.invokeError("delete_profile_template", {
templateId: mail.id,
}),
/TEMPLATE_NOT_FOUND/,
);
assert.deepEqual(
(await app.invoke("list_profile_templates")).map((t) => t.id),
[saved.id],
);
},
{ seedDownloadedBrowser: true },
);
});
test("deleted profiles land in the trash and come back intact on restore", async () => {
await withApp("entities-trash", async (app) => {
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
+104 -1
View File
@@ -170,7 +170,7 @@ test("profile creation stays compact and saves basic, protected, and ephemeral p
JSON.stringify(basic),
);
assert.equal(basic.tabs, 0);
assert.deepEqual(basic.fields, ["profile-name"]);
assert.deepEqual(basic.fields, ["profile-name", "profile-count"]);
assert.deepEqual(basic.checkboxes, ["enable-password", "ephemeral"]);
assert.equal(basic.advancedVisible, false);
assert.ok(!basic.text.includes(en.profiles.ephemeralDescription));
@@ -3349,6 +3349,109 @@ test("bulk edit opens from the action bar and changes only the ticked rows", asy
);
});
test("templates save from the create dialog and the inspector, and several profiles go to one batch", async () => {
await withApp(
"ui-templates",
async (app) => {
await stubCommand(app, "is_geoip_database_available", true);
const base = await createUiProfile(app, "Template Base");
await app.waitForText(base.name);
const templates = () => app.invoke("list_profile_templates");
await openProfileCreation(app);
await app.fillSelector("#profile-name", "Batch");
await app.clickSelector('[data-slot="template-picker"]');
await app.waitForText(en.templates.empty);
await app.fillSelector('[data-slot="template-name"]', "Quick");
await app.clickSelector('[data-slot="template-save"]');
await app.waitFor(
async () => (await templates()).some((t) => t.name === "Quick"),
{ description: "the template saved from the form" },
);
const [quick] = await templates();
assert.equal(quick.settings.name_prefix, "Batch");
await app.waitFor(
() =>
app.execute(
`return document.querySelector('[data-slot="template-picker"]')?.innerText.includes("Quick") ?? false;`,
),
{ description: "the saved template shown as applied" },
);
await app.clickSelector('[data-slot="profile-count-more"]');
await app.clickSelector('[data-slot="profile-count-more"]');
await app.waitForText(
en.templates.createMany_other.replace("{{count}}", "3"),
);
assert.equal(
await app.execute(
`return document.querySelector('[data-slot="profile-count-hint"]') !== null;`,
),
true,
);
assert.equal(
await app.execute(
`return document.querySelector('#enable-password')?.disabled ?? null;`,
),
true,
"a password is for one profile only",
);
await app.capture("templates-create-many");
await stubCommand(app, "bulk_create_profiles", {
created: [{ id: "a" }, { id: "b" }, { id: "c" }],
error: null,
cancelled: false,
});
await app.clickSelector('[data-slot="profile-create-submit"]');
await app.waitFor(
() => app.execute(`return !document.querySelector('#profile-name');`),
{ description: "the dialog closing after the batch" },
);
const [call] = await app.execute(
`return (window.__donutStubbedCalls ?? []).filter((c) => c.command === "bulk_create_profiles");`,
);
assert.equal(call.payload.count, 3);
assert.equal(call.payload.name, "Batch");
assert.equal(call.payload.settings.name_prefix, "Batch");
await app.waitForText(
en.templates.created_other.replace("{{count}}", "3"),
);
await openProfileCreation(app);
await app.clickSelector('[data-slot="template-picker"]');
await app.clickSelector('[data-slot="template-option"]');
await app.waitFor(
() =>
app.execute(
`return document.querySelector('#profile-name')?.value === "Batch";`,
),
{ description: "the template's name prefix filling an empty name" },
);
await app.clickSelector('[data-slot="template-picker"]');
await app.clickSelector('[data-slot="template-delete"]');
await app.clickSelector('[data-slot="template-delete-confirm"]');
await app.waitFor(async () => (await templates()).length === 0, {
description: "the template deleted from the picker",
});
await dismissSurface(app);
await dismissSurface(app);
await app.clickSelector(
`[data-slot="profile-inspect-trigger"][data-profile-id="${base.id}"]`,
);
await app.clickSelector('[data-slot="profile-info-save-template"]');
await app.waitForText(en.templates.saveFromProfileDescription);
await app.clickSelector('[data-slot="save-template-confirm"]');
await app.waitFor(
async () => (await templates()).some((t) => t.name === "Template Base"),
{ description: "the template saved from the inspector" },
);
},
{ seedDownloadedBrowser: true },
);
});
test("the group bookmark editor adds, reorders and removes a row", async () => {
await withApp("ui-group-bookmarks", async (app) => {
const group = await app.invoke("create_profile_group", { name: "Client" });