mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: full screen onboarding with a plugin picker and per step versions
This commit is contained in:
1 parent
1e74ce4827
commit
b9dd3488a5
59 files changed
+5413
-643
No files matched your search
@@ -192,6 +192,13 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const onboardingMock = vi.hoisted(() => ({
|
||||
describeOnboardingPlugins: vi.fn(),
|
||||
applyOnboardingChoices: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
|
||||
|
||||
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
|
||||
.default;
|
||||
|
||||
@@ -614,6 +621,87 @@ describe("plugins route", () => {
|
||||
expect(state.grants).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe("onboarding plugin picker", () => {
|
||||
const post = (body: unknown, user = "admin-1") =>
|
||||
fetch(`${baseUrl}/plugins/onboarding/apply`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-test-user-id": user },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
onboardingMock.describeOnboardingPlugins.mockReset();
|
||||
onboardingMock.applyOnboardingChoices.mockReset();
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
auditMock.logAudit.mockClear();
|
||||
});
|
||||
|
||||
it("lists plugins for a manager only", async () => {
|
||||
onboardingMock.describeOnboardingPlugins.mockResolvedValue({
|
||||
pending: true,
|
||||
managedByLinkedServer: false,
|
||||
plugins: [],
|
||||
});
|
||||
const ok = await fetch(`${baseUrl}/plugins/onboarding`, {
|
||||
headers: { "x-test-user-id": "admin-1" },
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.json()).toMatchObject({ pending: true });
|
||||
|
||||
const denied = await fetch(`${baseUrl}/plugins/onboarding`, {
|
||||
headers: { "x-test-user-id": "user-2" },
|
||||
});
|
||||
expect(denied.status).toBe(403);
|
||||
});
|
||||
|
||||
it("applies choices and audits each change", async () => {
|
||||
onboardingMock.applyOnboardingChoices.mockResolvedValue({
|
||||
resolved: {},
|
||||
adjustments: [],
|
||||
enabled: ["a"],
|
||||
disabled: ["b"],
|
||||
removed: ["c"],
|
||||
failed: [],
|
||||
});
|
||||
const res = await post({
|
||||
choices: { a: "enabled", b: "disabled", c: "remove" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(onboardingMock.applyOnboardingChoices).toHaveBeenCalledWith(
|
||||
{ a: "enabled", b: "disabled", c: "remove" },
|
||||
{ dryRun: false },
|
||||
);
|
||||
const actions = auditMock.logAudit.mock.calls.map(
|
||||
(call) => (call as unknown as [{ action: string }])[0].action,
|
||||
);
|
||||
expect(actions).toEqual([
|
||||
"enable_plugin",
|
||||
"disable_plugin",
|
||||
"uninstall_plugin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects bad choices", async () => {
|
||||
expect((await post({})).status).toBe(400);
|
||||
expect((await post({ choices: ["a"] })).status).toBe(400);
|
||||
expect((await post({ choices: { a: "delete" } })).status).toBe(400);
|
||||
expect(
|
||||
(await post({ choices: { a: "enabled" }, dryRun: "yes" })).status,
|
||||
).toBe(400);
|
||||
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses on a linked desktop and for non-managers", async () => {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
expect((await post({ choices: { a: "enabled" } })).status).toBe(409);
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(false);
|
||||
expect((await post({ choices: { a: "enabled" } }, "user-2")).status).toBe(
|
||||
403,
|
||||
);
|
||||
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("install, update, uninstall", () => {
|
||||
const post = (path: string, body: unknown = {}, user = "admin-1") =>
|
||||
fetch(`${baseUrl}${path}`, {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { UI_ONBOARDING_VERSION } from "../../../../types/ui-preferences.js";
|
||||
import { LEGACY_SEEN } from "../../../../types/onboarding.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
row: null as { data: string } | null,
|
||||
@@ -80,18 +80,16 @@ afterEach(async () => {
|
||||
|
||||
describe("ui-preferences onboarding backfill", () => {
|
||||
it("treats an old account with no row as onboarded", async () => {
|
||||
expect((await get()).onboarding.completedVersion).toBe(
|
||||
UI_ONBOARDING_VERSION,
|
||||
);
|
||||
const onboarding = (await get()).onboarding;
|
||||
expect(onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
expect(onboarding.baselinePending).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an old account onboarded after its first write", async () => {
|
||||
await put({ overrides: { "plugin:x": { a: 1 } } });
|
||||
const stored = JSON.parse(state.row!.data);
|
||||
expect(stored.onboarding.completedVersion).toBe(UI_ONBOARDING_VERSION);
|
||||
expect((await get()).onboarding.completedVersion).toBe(
|
||||
UI_ONBOARDING_VERSION,
|
||||
);
|
||||
expect(stored.onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
expect((await get()).onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
});
|
||||
|
||||
it("repairs a row an earlier write stored as not completed", async () => {
|
||||
@@ -100,29 +98,61 @@ describe("ui-preferences onboarding backfill", () => {
|
||||
onboarding: { completedVersion: 0, completedAt: null, skipped: false },
|
||||
}),
|
||||
};
|
||||
expect((await get()).onboarding.completedVersion).toBe(
|
||||
UI_ONBOARDING_VERSION,
|
||||
);
|
||||
expect((await get()).onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
});
|
||||
|
||||
it("migrates the old completedVersion shape", async () => {
|
||||
state.registeredAt = NEW;
|
||||
state.row = {
|
||||
data: JSON.stringify({
|
||||
onboarding: { completedVersion: 2, completedAt: NEW, skipped: false },
|
||||
}),
|
||||
};
|
||||
const onboarding = (await get()).onboarding;
|
||||
expect(onboarding.seen).toEqual(LEGACY_SEEN);
|
||||
expect(onboarding.baselinePending).toBe(true);
|
||||
});
|
||||
|
||||
it("still shows onboarding to a new account", async () => {
|
||||
state.registeredAt = NEW;
|
||||
await put({ preset: "simple" });
|
||||
expect((await get()).onboarding.completedVersion).toBe(0);
|
||||
expect((await get()).onboarding.seen).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not hide a newer onboarding version from someone who finished an older one", async () => {
|
||||
state.row = {
|
||||
data: JSON.stringify({
|
||||
onboarding: {
|
||||
completedVersion: UI_ONBOARDING_VERSION - 1,
|
||||
completedAt: OLD,
|
||||
skipped: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
expect((await get()).onboarding.completedVersion).toBe(
|
||||
UI_ONBOARDING_VERSION - 1,
|
||||
);
|
||||
describe("ui-preferences onboarding seen merge", () => {
|
||||
beforeEach(() => {
|
||||
state.registeredAt = NEW;
|
||||
});
|
||||
|
||||
it("keeps the higher version per step", async () => {
|
||||
await put({ onboarding: { seen: { welcome: 2, "rd:guacd": 1 } } });
|
||||
await put({ onboarding: { seen: { welcome: 1, appearance: 1 } } });
|
||||
expect((await get()).onboarding.seen).toEqual({
|
||||
welcome: 2,
|
||||
"rd:guacd": 1,
|
||||
appearance: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops invalid seen entries", async () => {
|
||||
await put({
|
||||
onboarding: { seen: { ok: 1, zero: 0, text: "1", frac: 1.5 } },
|
||||
});
|
||||
expect((await get()).onboarding.seen).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it("does not clear completedAt when a later write omits it", async () => {
|
||||
await put({ onboarding: { completedAt: NEW, seen: { done: 1 } } });
|
||||
await put({ onboarding: { seen: { "x:y": 1 } } });
|
||||
expect((await get()).onboarding.completedAt).toBe(NEW);
|
||||
});
|
||||
|
||||
it("clears baselinePending when told to", async () => {
|
||||
state.registeredAt = OLD;
|
||||
await put({ onboarding: { baselinePending: false, seen: { "x:y": 1 } } });
|
||||
const onboarding = (await get()).onboarding;
|
||||
expect(onboarding.baselinePending).toBe(false);
|
||||
expect(onboarding.seen["x:y"]).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
initialPluginState,
|
||||
onboardingDefaults,
|
||||
resetBundledIndexCache,
|
||||
} from "../../plugins/bundled-index.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
function writeIndex(plugins: Record<string, unknown>) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "bundled-index.json"),
|
||||
JSON.stringify({ version: 1, plugins }),
|
||||
);
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bundled-index-"));
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = dir;
|
||||
resetBundledIndexCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.ELECTRON_EMBEDDED;
|
||||
delete process.env.TEST_SSO_CLIENT;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
resetBundledIndexCache();
|
||||
});
|
||||
|
||||
describe("onboardingDefaults", () => {
|
||||
it("treats everything as recommended without an index", () => {
|
||||
expect(onboardingDefaults("anything")).toEqual({
|
||||
recommended: true,
|
||||
consent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads recommended and consent from the index", () => {
|
||||
writeIndex({
|
||||
kept: { onboarding: { recommended: true, consent: false } },
|
||||
ask: { onboarding: { recommended: true, consent: true } },
|
||||
});
|
||||
expect(onboardingDefaults("kept")).toEqual({
|
||||
recommended: true,
|
||||
consent: false,
|
||||
});
|
||||
expect(onboardingDefaults("ask").consent).toBe(true);
|
||||
expect(onboardingDefaults("other")).toEqual({
|
||||
recommended: false,
|
||||
consent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("recommends desktop-only plugins in the desktop app only", () => {
|
||||
writeIndex({ desk: { onboarding: { recommended: "desktop" } } });
|
||||
expect(onboardingDefaults("desk").recommended).toBe(false);
|
||||
process.env.ELECTRON_EMBEDDED = "true";
|
||||
expect(onboardingDefaults("desk").recommended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enabledByEnv", () => {
|
||||
it("recommends a plugin once its environment config is set", () => {
|
||||
writeIndex({
|
||||
sso: { onboarding: { enabledByEnv: ["TEST_SSO_CLIENT"] } },
|
||||
});
|
||||
expect(onboardingDefaults("sso").recommended).toBe(false);
|
||||
expect(initialPluginState("sso", "bundled", true)).toBe("disabled");
|
||||
process.env.TEST_SSO_CLIENT = " ";
|
||||
expect(onboardingDefaults("sso").recommended).toBe(false);
|
||||
process.env.TEST_SSO_CLIENT = "termix";
|
||||
expect(onboardingDefaults("sso").recommended).toBe(true);
|
||||
expect(initialPluginState("sso", "bundled", true)).toBe("enabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialPluginState", () => {
|
||||
beforeEach(() => {
|
||||
writeIndex({
|
||||
kept: { onboarding: { recommended: true } },
|
||||
ask: { onboarding: { recommended: true, consent: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("enables every bundled plugin on an existing install", () => {
|
||||
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
|
||||
expect(initialPluginState("ask", "bundled", false)).toBe("enabled");
|
||||
});
|
||||
|
||||
it("enables only recommended, non-consent plugins on a fresh install", () => {
|
||||
expect(initialPluginState("kept", "bundled", true)).toBe("enabled");
|
||||
expect(initialPluginState("ask", "bundled", true)).toBe("disabled");
|
||||
expect(initialPluginState("other", "bundled", true)).toBe("disabled");
|
||||
});
|
||||
|
||||
it("never enables a plugin dropped into the data directory", () => {
|
||||
expect(initialPluginState("kept", "user", true)).toBe("disabled");
|
||||
expect(initialPluginState("kept", "user", false)).toBe("disabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* The onboarding plugin picker, against a real PluginLoader and fixture
|
||||
* plugins on disk. The database is faked.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
rows: new Map<string, Record<string, unknown>>(),
|
||||
settings: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
listAll: async () => [...db.rows.values()],
|
||||
findById: async (id: string) => db.rows.get(id) ?? null,
|
||||
update: async (id: string, input: Record<string, unknown>) => {
|
||||
const row = db.rows.get(id);
|
||||
if (!row) return null;
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) row[key] = value;
|
||||
}
|
||||
return row;
|
||||
},
|
||||
delete: async (id: string) => db.rows.delete(id),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => db.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => void db.settings.set(key, value),
|
||||
delete: async (key: string) => void db.settings.delete(key),
|
||||
}),
|
||||
createCurrentPluginStorageRepository: () => ({
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => false,
|
||||
listKeys: async () => [],
|
||||
countKeys: async () => 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/data.js", () => ({
|
||||
migratePlugin: async () => [],
|
||||
removePluginData: async () => ({ tables: [], kvKeys: 0, migrations: 0 }),
|
||||
describePluginTables: async () => [],
|
||||
registerTable: () => ({}),
|
||||
forgetTables: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
invalidatePluginPermissionCache: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/index.js", async () => {
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const loader = new PluginLoader();
|
||||
return {
|
||||
getPluginRuntime: () => ({ loader }),
|
||||
activatePlugin: (id: string) => loader.activate(id),
|
||||
deactivatePlugin: (id: string) => loader.deactivate(id),
|
||||
registerLoadedPlugins: async (plugins: LoadedPlugin[]) => {
|
||||
for (const plugin of plugins) {
|
||||
db.rows.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
state: "enabled",
|
||||
lastError: null,
|
||||
pinnedVersion: null,
|
||||
manifestJson: JSON.stringify(plugin.manifest),
|
||||
});
|
||||
}
|
||||
},
|
||||
unloadPlugin: async () => {},
|
||||
};
|
||||
});
|
||||
|
||||
const { getPluginRuntime, registerLoadedPlugins } =
|
||||
await import("../../plugins/index.js");
|
||||
const manage = await import("../../plugins/manage.js");
|
||||
const onboarding = await import("../../plugins/onboarding.js");
|
||||
const { resetBundledIndexCache } =
|
||||
await import("../../plugins/bundled-index.js");
|
||||
|
||||
const roots: string[] = [];
|
||||
let bundled: string;
|
||||
let data: string;
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeIndex(plugins: Record<string, unknown>) {
|
||||
fs.writeFileSync(
|
||||
path.join(bundled, "bundled-index.json"),
|
||||
JSON.stringify({ version: 1, plugins }),
|
||||
);
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
|
||||
async function chain() {
|
||||
createFixturePlugin({ id: "base", root: bundled });
|
||||
createFixturePlugin({
|
||||
id: "addon",
|
||||
root: bundled,
|
||||
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
||||
});
|
||||
createFixturePlugin({ id: "extra", root: bundled });
|
||||
const { loader } = getPluginRuntime();
|
||||
await manage.applyStoredPluginChoices(loader);
|
||||
const loaded = await loader.loadAll();
|
||||
await registerLoadedPlugins(loaded);
|
||||
for (const plugin of loaded) await loader.activate(plugin.id);
|
||||
return loader;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
bundled = tempRoot("termix-bundled-");
|
||||
data = tempRoot("termix-data-");
|
||||
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
||||
process.env.DATA_DIR = data;
|
||||
db.rows.clear();
|
||||
db.settings.clear();
|
||||
resetBundledIndexCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { loader } = getPluginRuntime();
|
||||
await loader.shutdown();
|
||||
for (const plugin of loader.list()) loader.forget(plugin.id);
|
||||
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
||||
delete process.env.DATA_DIR;
|
||||
while (roots.length) {
|
||||
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("offerPluginSetup", () => {
|
||||
it("offers the picker once, as fresh or as an upgrade", async () => {
|
||||
await onboarding.offerPluginSetup(false);
|
||||
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe(
|
||||
"upgrade",
|
||||
);
|
||||
expect(await onboarding.pluginSetupReason()).toBe("upgrade");
|
||||
|
||||
db.settings.delete(onboarding.PLUGIN_SETUP_PENDING_KEY);
|
||||
await onboarding.offerPluginSetup(true);
|
||||
expect(await onboarding.isPluginSetupPending()).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a fresh install", async () => {
|
||||
await onboarding.offerPluginSetup(true);
|
||||
expect(await onboarding.pluginSetupReason()).toBe("fresh");
|
||||
});
|
||||
|
||||
it("is not offered again after the picker was applied", async () => {
|
||||
await chain();
|
||||
await onboarding.offerPluginSetup(false);
|
||||
await onboarding.applyOnboardingChoices({});
|
||||
db.settings.delete(onboarding.PLUGIN_SETUP_OFFERED_KEY);
|
||||
await onboarding.applyOnboardingChoices({});
|
||||
await onboarding.offerPluginSetup(false);
|
||||
expect(await onboarding.isPluginSetupPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeOnboardingPlugins", () => {
|
||||
it("lists installed plugins with their defaults and the pending flag", async () => {
|
||||
writeIndex({
|
||||
base: { onboarding: { recommended: true } },
|
||||
extra: { onboarding: { recommended: true, consent: true } },
|
||||
});
|
||||
await chain();
|
||||
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
|
||||
|
||||
const list = await onboarding.describeOnboardingPlugins();
|
||||
expect(list.pending).toBe(true);
|
||||
expect(list.reason).toBe("fresh");
|
||||
expect(list.managedByLinkedServer).toBe(false);
|
||||
const byId = Object.fromEntries(list.plugins.map((p) => [p.id, p]));
|
||||
expect(byId.base).toMatchObject({
|
||||
recommended: true,
|
||||
consent: false,
|
||||
source: "bundled",
|
||||
state: "enabled",
|
||||
});
|
||||
expect(byId.addon).toMatchObject({
|
||||
recommended: false,
|
||||
dependencies: ["base"],
|
||||
});
|
||||
expect(byId.extra).toMatchObject({ recommended: true, consent: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOnboardingChoices", () => {
|
||||
it("enables, disables and removes, then clears the pending flag", async () => {
|
||||
const loader = await chain();
|
||||
db.rows.get("extra")!.state = "disabled";
|
||||
await loader.deactivate("extra");
|
||||
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
|
||||
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
base: "disabled",
|
||||
addon: "remove",
|
||||
extra: "enabled",
|
||||
});
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.enabled).toEqual(["extra"]);
|
||||
expect(result.disabled).toEqual(["base"]);
|
||||
expect(result.removed).toEqual(["addon"]);
|
||||
expect(loader.get("addon")).toBeUndefined();
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
expect(loader.get("extra")?.state).toBe("active");
|
||||
expect(JSON.parse(db.settings.get("plugins_uninstalled")!)).toEqual([
|
||||
"addon",
|
||||
]);
|
||||
expect(db.settings.has(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a dependency that a kept plugin still needs", async () => {
|
||||
const loader = await chain();
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
base: "remove",
|
||||
addon: "disabled",
|
||||
});
|
||||
expect(result.adjustments).toEqual([
|
||||
{ id: "base", from: "remove", to: "disabled", requiredBy: ["addon"] },
|
||||
]);
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(loader.get("base")?.state).toBe("stopped");
|
||||
expect(loader.get("addon")?.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("removes dependents before what they depend on", async () => {
|
||||
const loader = await chain();
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
base: "remove",
|
||||
addon: "remove",
|
||||
});
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.removed).toEqual(["addon", "base"]);
|
||||
expect(loader.get("base")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes nothing on a dry run", async () => {
|
||||
const loader = await chain();
|
||||
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
|
||||
const result = await onboarding.applyOnboardingChoices(
|
||||
{ extra: "remove", base: "disabled", addon: "disabled" },
|
||||
{ dryRun: true },
|
||||
);
|
||||
expect(result.removed).toEqual(["extra"]);
|
||||
expect(result.disabled).toEqual(["addon", "base"]);
|
||||
expect(loader.get("extra")?.state).toBe("active");
|
||||
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe("1");
|
||||
});
|
||||
|
||||
it("ignores ids that are not installed", async () => {
|
||||
await chain();
|
||||
const result = await onboarding.applyOnboardingChoices({
|
||||
ghost: "remove",
|
||||
});
|
||||
expect(result.resolved).toEqual({});
|
||||
expect(result.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for a plugin change already in progress", async () => {
|
||||
const loader = await chain();
|
||||
const first = manage.setPluginState("extra", false);
|
||||
const second = onboarding.applyOnboardingChoices({ extra: "enabled" });
|
||||
await first;
|
||||
const result = await second;
|
||||
expect(result.enabled).toEqual(["extra"]);
|
||||
expect(loader.get("extra")?.state).toBe("active");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user