feat: full screen onboarding with a plugin picker and per step versions

This commit is contained in:
LukeGus committed 2026-10-06 17:44:25 -05:00
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");
});
});