mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 22:17:28 +02:00
feat: profile templates
This commit is contained in:
1 parent
3f5b573ead
commit
2cc91ed250
29 files changed
+2430
-62
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
Reference in new issue
Block a user