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

@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
const api = vi.hoisted(() => ({
getUiPreferences: vi.fn(),
saveUiPreferences: vi.fn(async () => {}),
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
}));
vi.mock("@/main-axios", () => api);
const frame = vi.hoisted(() => ({ embedded: false }));
vi.mock("@/lib/embedded-frame", () => ({
isEmbeddedAuthFrame: () => frame.embedded,
}));
vi.mock("@/plugin-host/plugin-store", () => ({
settledPromise: () => Promise.resolve(),
}));
vi.mock("@/onboarding/OnboardingStage", () => ({
OnboardingStage: ({ mode }: { mode: string }) => <p>stage {mode}</p>,
}));
vi.mock("@/onboarding/onboarding-steps", () => ({
CORE_STEPS: [
{
id: "appearance",
version: 1,
titleKey: "a",
Component: () => null,
audience: "all",
position: 40,
},
],
}));
import {
OnboardingHost,
OPEN_ONBOARDING_EVENT,
} from "@/onboarding/OnboardingHost";
import { onboardingSteps } from "@/onboarding/onboarding-registry";
import {
UiPreferencesProvider,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
import { setPermissionsForTesting } from "@/hooks/use-permissions";
import { defaultUiPreferences } from "@/types/ui-preferences";
function prefs(extra = {}) {
return {
...defaultUiPreferences(),
onboarding: {
seen: { appearance: 1 },
completedAt: "2026-01-01T00:00:00.000Z",
skipped: false,
baselinePending: false,
...extra,
},
};
}
let seen: Record<string, number> = {};
function SeenProbe() {
seen = useUiPreferencesContext()?.preferences.onboarding.seen ?? {};
return null;
}
function renderHost(preferences = prefs()) {
render(
<UiPreferencesProvider initial={preferences}>
<OnboardingHost />
<SeenProbe />
</UiPreferencesProvider>,
);
}
const guacd = {
id: "rd:guacd",
pluginId: "rd",
version: 1,
titleKey: "g",
Component: () => null,
audience: "all" as const,
position: 50,
};
beforeEach(() => {
frame.embedded = false;
setPermissionsForTesting([]);
onboardingSteps.reset();
});
afterEach(() => {
localStorage.clear();
});
describe("OnboardingHost", () => {
it("opens a rerun when settings asks for one", async () => {
renderHost();
await act(async () => {
window.dispatchEvent(new Event(OPEN_ONBOARDING_EVENT));
});
expect(await screen.findByText("stage rerun")).toBeTruthy();
});
it("opens a partial run when a plugin adds a step mid-session", async () => {
renderHost();
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByText(/stage/)).toBeNull();
act(() => {
onboardingSteps.register(guacd);
});
expect(
await screen.findByText("stage partial", {}, { timeout: 2000 }),
).toBeTruthy();
});
it("marks existing plugin steps seen for a carried over user without showing them", async () => {
onboardingSteps.register(guacd);
renderHost(prefs({ baselinePending: true }));
await waitFor(() => expect(seen["rd:guacd"]).toBe(1));
await new Promise((r) => setTimeout(r, 900));
expect(screen.queryByText(/stage/)).toBeNull();
});
it("does nothing inside an embedded sign in frame", async () => {
frame.embedded = true;
onboardingSteps.register(guacd);
renderHost();
await act(async () => {
window.dispatchEvent(new Event(OPEN_ONBOARDING_EVENT));
});
await new Promise((r) => setTimeout(r, 900));
expect(screen.queryByText(/stage/)).toBeNull();
});
});
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { useEffect } from "react";
import type { OnboardingStepProps } from "@termix-ssh/plugin-sdk/frontend";
const api = vi.hoisted(() => ({
getUiPreferences: vi.fn(),
saveUiPreferences: vi.fn(async (_body: unknown) => {}),
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
}));
vi.mock("@/main-axios", () => api);
vi.mock("@/api/plugins-api", () => ({
getOnboardingPlugins: vi.fn(async () => ({
pending: false,
managedByLinkedServer: false,
plugins: [],
})),
}));
const gate = vi.hoisted(() => ({ allow: true }));
function Plain({ label }: { label: string }) {
return <p>{label} body</p>;
}
function Gated({ setBeforeNext }: OnboardingStepProps) {
useEffect(() => {
setBeforeNext(() => gate.allow);
return () => setBeforeNext(null);
}, [setBeforeNext]);
return <p>picker body</p>;
}
vi.mock("@/onboarding/onboarding-steps", () => ({
CORE_STEPS: [
{
id: "preset",
version: 1,
titleKey: "preset",
Component: () => <Plain label="preset" />,
audience: "all",
position: 0,
},
{
id: "plugins",
version: 1,
titleKey: "picker",
Component: Gated,
audience: "all",
position: 10,
required: true,
},
{
id: "appearance",
version: 2,
titleKey: "appearance",
Component: () => <Plain label="appearance" />,
audience: "all",
position: 40,
},
{
id: "security",
version: 1,
titleKey: "security",
Component: () => <Plain label="security" />,
audience: "all",
position: 100,
},
],
}));
import { OnboardingStage } from "@/onboarding/OnboardingStage";
import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext";
import { setPermissionsForTesting } from "@/hooks/use-permissions";
import { onboardingSteps } from "@/onboarding/onboarding-registry";
import {
defaultUiPreferences,
type UiPreferences,
} from "@/types/ui-preferences";
function renderStage(
mode: "full" | "partial" | "rerun",
preferences: UiPreferences = defaultUiPreferences(),
) {
const onDone = vi.fn();
render(
<UiPreferencesProvider initial={preferences}>
<OnboardingStage mode={mode} shellReady={false} onDone={onDone} />
</UiPreferencesProvider>,
);
return onDone;
}
const next = () =>
fireEvent.click(screen.getByRole("button", { name: /onboarding.next|Next/ }));
beforeEach(() => {
gate.allow = true;
api.saveUiPreferences.mockClear();
setPermissionsForTesting([]);
});
afterEach(() => {
localStorage.clear();
});
describe("OnboardingStage", () => {
it("hides Skip until the required step is confirmed, then Skip finishes", async () => {
const onDone = renderStage("full");
await screen.findByText("preset body");
expect(screen.queryByRole("button", { name: /skip/i })).toBeNull();
next();
await screen.findByText("picker body");
expect(screen.queryByRole("button", { name: /skip/i })).toBeNull();
await act(async () => next());
await screen.findByText("appearance body");
await act(async () =>
fireEvent.click(screen.getByRole("button", { name: /skip/i })),
);
await waitFor(() => expect(onDone).toHaveBeenCalled());
const body = api.saveUiPreferences.mock.calls.at(-1)?.[0] as {
onboarding: { skipped: boolean };
};
expect(body.onboarding.skipped).toBe(true);
});
it("stays on a step whose beforeNext says no", async () => {
gate.allow = false;
renderStage("full");
await screen.findByText("preset body");
next();
await screen.findByText("picker body");
await act(async () => next());
expect(screen.getByText("picker body")).toBeTruthy();
});
it("marks every step in the run as seen when it finishes", async () => {
const onDone = renderStage("full");
await screen.findByText("preset body");
next();
await screen.findByText("picker body");
await act(async () => next());
await screen.findByText("appearance body");
next();
await screen.findByText("security body");
await act(async () =>
fireEvent.click(screen.getByRole("button", { name: /finish|Done/ })),
);
await waitFor(() => expect(onDone).toHaveBeenCalled());
const body = api.saveUiPreferences.mock.calls.at(-1)?.[0] as {
onboarding: { seen: Record<string, number> };
};
expect(body.onboarding.seen).toEqual({
preset: 1,
plugins: 1,
appearance: 2,
security: 1,
});
});
it("has no sidebar, just the step and the buttons", async () => {
renderStage("full");
await screen.findByText("preset body");
expect(screen.queryByRole("navigation")).toBeNull();
});
it("leaves plugin steps out for someone carried over from an older version", async () => {
const off = onboardingSteps.register({
id: "rd:guacd",
pluginId: "rd",
version: 1,
titleKey: "guacd",
Component: () => <Plain label="guacd" />,
audience: "all",
position: 50,
});
const preferences = {
...defaultUiPreferences(),
onboarding: {
seen: { preset: 1, plugins: 1, appearance: 1, security: 1 },
completedAt: null,
skipped: false,
baselinePending: true,
},
};
renderStage("partial", preferences);
await screen.findByText("appearance body");
expect(screen.queryByText("guacd body")).toBeNull();
off();
});
it("shows only what someone has not seen on a partial run", async () => {
const preferences = {
...defaultUiPreferences(),
onboarding: {
seen: { preset: 1, plugins: 1, appearance: 1, security: 1 },
completedAt: "2026-01-01T00:00:00.000Z",
skipped: false,
baselinePending: false,
},
};
const onDone = renderStage("partial", preferences);
await screen.findByText("appearance body");
expect(screen.queryByText("preset body")).toBeNull();
await act(async () =>
fireEvent.click(screen.getByRole("button", { name: /finish|Done/ })),
);
await waitFor(() => expect(onDone).toHaveBeenCalled());
});
});
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import {
baselineSeen,
computeFlow,
needsFullFlow,
seenFor,
} from "@/onboarding/onboarding-flow";
import {
mergeStepDefs,
sectionPosition,
type OnboardingCtx,
type OnboardingStepDef,
} from "@/onboarding/onboarding-registry";
import { defaultOnboardingState } from "@/types/ui-preferences";
const Noop = () => null;
function step(
id: string,
position: number,
extra: Partial<OnboardingStepDef> = {},
): OnboardingStepDef {
return {
id,
version: 1,
titleKey: id,
Component: Noop,
audience: "all",
position,
...extra,
};
}
function ctx(overrides: Partial<OnboardingCtx> = {}): OnboardingCtx {
return {
mode: "partial",
isAdmin: false,
isDesktop: false,
has: () => false,
pluginSetupPending: false,
pluginsManagedElsewhere: false,
...overrides,
};
}
const steps = [
step("welcome", 0),
step("appearance", 40, { version: 2 }),
step("rd:guacd", sectionPosition("setup"), {
pluginId: "rd",
audience: "admin",
}),
step("ai:assistant", sectionPosition("explore"), {
pluginId: "ai",
permission: "ai.use",
}),
step("done", 100),
];
const ids = (list: OnboardingStepDef[]) => list.map((s) => s.id);
describe("computeFlow", () => {
it("shows every relevant step on a full run", () => {
expect(ids(computeFlow(steps, {}, ctx({ mode: "full" })))).toEqual([
"welcome",
"appearance",
"done",
]);
});
it("filters admin and permission steps", () => {
const flow = computeFlow(
steps,
{},
ctx({ mode: "rerun", isAdmin: true, has: (p) => p === "ai.use" }),
);
expect(ids(flow)).toEqual([
"welcome",
"appearance",
"rd:guacd",
"ai:assistant",
"done",
]);
});
it("shows only unseen or bumped steps on a partial run", () => {
const flow = computeFlow(
steps,
{ welcome: 1, appearance: 1, "rd:guacd": 1, done: 1 },
ctx({ isAdmin: true, has: () => true }),
);
expect(ids(flow)).toEqual(["appearance", "ai:assistant"]);
});
it("is empty when everything is seen", () => {
expect(
computeFlow(
steps,
{ welcome: 1, appearance: 2, "rd:guacd": 1, done: 1 },
ctx({ isAdmin: true }),
),
).toEqual([]);
});
it("includes a seen step that forces itself pending", () => {
const picker = step("plugins", 10, {
forcePending: (c) => c.pluginSetupPending,
});
expect(
ids(
computeFlow(
[picker],
{ plugins: 1 },
ctx({ pluginSetupPending: true }),
),
),
).toEqual(["plugins"]);
expect(computeFlow([picker], { plugins: 1 }, ctx())).toEqual([]);
});
it("drops a step whose relevance check throws", () => {
const broken = step("x:y", 50, {
isRelevant: () => {
throw new Error("boom");
},
});
expect(computeFlow([broken], {}, ctx())).toEqual([]);
});
});
describe("seen bookkeeping", () => {
it("records every step in a run at its version", () => {
expect(seenFor(steps.slice(0, 2))).toEqual({ welcome: 1, appearance: 2 });
});
it("baselines only plugin steps", () => {
expect(baselineSeen(steps)).toEqual({ "rd:guacd": 1, "ai:assistant": 1 });
});
it("needs the full run only for someone who never finished one", () => {
expect(needsFullFlow(defaultOnboardingState())).toBe(true);
expect(
needsFullFlow({ ...defaultOnboardingState(), seen: { welcome: 1 } }),
).toBe(false);
expect(
needsFullFlow({ ...defaultOnboardingState(), completedAt: "x" }),
).toBe(false);
});
});
describe("mergeStepDefs", () => {
it("orders by position and lets a registered step win over a legacy one", () => {
const legacy = step("ai:assistant", 80, { titleKey: "old" });
const registered = step("ai:assistant", 50, { titleKey: "new" });
const merged = mergeStepDefs(
[step("welcome", 0), step("done", 100)],
[registered],
[legacy, step("x:legacy", 80)],
);
expect(merged.map((s) => [s.id, s.titleKey])).toEqual([
["welcome", "welcome"],
["ai:assistant", "new"],
["x:legacy", "x:legacy"],
["done", "done"],
]);
});
it("places plugin sections between the core steps", () => {
expect(sectionPosition("setup")).toBeGreaterThan(40);
expect(sectionPosition("setup")).toBeLessThan(60);
expect(sectionPosition("explore", 1000)).toBeLessThan(90);
expect(sectionPosition("security")).toBeLessThan(100);
});
});
+102 -60
View File
@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { ONBOARDING_STEPS, relevantSteps } from "@/onboarding/onboarding-steps";
import { describe, expect, it } from "vitest";
import { CORE_STEPS } from "@/onboarding/onboarding-steps";
import { computeFlow } from "@/onboarding/onboarding-flow";
import type { OnboardingCtx } from "@/onboarding/onboarding-registry";
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
import { UI_AREA_KEYS, PRESETS } from "@/types/ui-preferences";
import en from "@/locales/en.json";
const isElectron = vi.hoisted(() => vi.fn(() => false));
vi.mock("@/lib/electron", () => ({ isElectron }));
function lookup(key: string): unknown {
return key
.split(".")
@@ -18,83 +18,125 @@ function lookup(key: string): unknown {
);
}
describe("ONBOARDING_STEPS", () => {
it("has unique ids and real title translations", () => {
const ids = ONBOARDING_STEPS.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
for (const step of ONBOARDING_STEPS) {
function ctx(overrides: Partial<OnboardingCtx> = {}): OnboardingCtx {
return {
mode: "full",
isAdmin: false,
isDesktop: false,
has: () => false,
pluginSetupPending: false,
pluginsManagedElsewhere: false,
canEnrollSecondFactor: false,
...overrides,
};
}
const ids = (c: OnboardingCtx) =>
computeFlow(CORE_STEPS, {}, c).map((s) => s.id);
describe("CORE_STEPS", () => {
it("has unique ids, real titles and the versions in CORE_ONBOARDING_STEPS", () => {
const stepIds = CORE_STEPS.map((s) => s.id);
expect(new Set(stepIds).size).toBe(stepIds.length);
expect(stepIds.sort()).toEqual(Object.keys(CORE_ONBOARDING_STEPS).sort());
for (const step of CORE_STEPS) {
expect(typeof lookup(step.titleKey), step.titleKey).toBe("string");
expect(step.version).toBe(
CORE_ONBOARDING_STEPS[step.id as keyof typeof CORE_ONBOARDING_STEPS],
);
}
});
it("opens with the preset picker right after the welcome", () => {
expect(ONBOARDING_STEPS[0].id).toBe("welcome");
expect(ONBOARDING_STEPS[1].id).toBe("preset");
it("is only choices: preset and appearance for everyone", () => {
expect(ids(ctx())).toEqual(["preset", "appearance"]);
});
it("has no add-a-host step, which used to close onboarding mid-flow", () => {
const ids = relevantSteps({}).map((s) => s.id);
expect(ids).not.toContain("first-host");
it("opens on the plugin picker for an admin on a fresh install", () => {
expect(ids(ctx({ isAdmin: true, pluginSetupPending: true }))).toEqual([
"plugins",
"preset",
"appearance",
]);
});
it("never shows the plugin picker to someone who is not an admin", () => {
expect(ids(ctx({ pluginSetupPending: true }))).not.toContain("plugins");
expect(ids(ctx({ mode: "rerun" }))).not.toContain("plugins");
});
it("leaves the picker out once it was applied, even on a rerun", () => {
expect(ids(ctx({ isAdmin: true, mode: "rerun" }))).not.toContain("plugins");
expect(ids(ctx({ isAdmin: true }))).not.toContain("plugins");
});
it("never shows the picker on a linked desktop", () => {
expect(
ids(
ctx({
isAdmin: true,
pluginSetupPending: true,
pluginsManagedElsewhere: true,
}),
),
).not.toContain("plugins");
});
it("makes the plugin picker required", () => {
expect(CORE_STEPS.find((s) => s.id === "plugins")?.required).toBe(true);
});
it("asks how the desktop app is used only on the desktop", () => {
isElectron.mockReturnValue(false);
expect(relevantSteps({}).map((s) => s.id)).not.toContain("desktop-sync");
isElectron.mockReturnValue(true);
expect(relevantSteps({}).map((s) => s.id)).toContain("desktop-sync");
isElectron.mockReturnValue(false);
expect(ids(ctx({ isDesktop: true }))).toEqual([
"desktop-sync",
"preset",
"appearance",
]);
});
it("ends on the done step", () => {
const ids = relevantSteps({}).map((s) => s.id);
expect(ids[ids.length - 1]).toBe("done");
});
it("tours the feature set after the setup choices", () => {
const ids = ONBOARDING_STEPS.map((s) => s.id);
for (const id of ["features", "workflow", "security"]) {
expect(ids).toContain(id);
expect(ids.indexOf(id)).toBeGreaterThan(ids.indexOf("appearance"));
}
it("offers account security only when there is something to set up", () => {
expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual([
"preset",
"appearance",
"security",
]);
});
});
describe("onboarding step body translations", () => {
const KEYS: Record<string, string[]> = {
welcome: ["hosts", "terminal", "files"],
workflow: ["palette", "split", "dock"],
security: ["credentials", "twofa", "identity", "sharing"],
done: ["settings", "rerun", "docs"],
};
it("has a title and description for every card the steps render", () => {
for (const [prefix, keys] of Object.entries(KEYS)) {
for (const key of keys) {
expect(
lookup(`onboarding.${prefix}_${key}`),
`${prefix}_${key}`,
).toBeTypeOf("string");
expect(
lookup(`onboarding.${prefix}_${key}_desc`),
`${prefix}_${key}_desc`,
).toBeTypeOf("string");
}
}
});
it("has the intro copy each step shows above its cards", () => {
describe("onboarding translations", () => {
it("has the copy the stage and the steps show", () => {
for (const key of [
"welcomeIntro",
"presetIntro",
"appearanceIntro",
"featuresIntro",
"workflowIntro",
"securityIntro",
"doneDesc",
"pluginsIntro",
"pluginsRemoveNote",
"pluginsChoice_enabled",
"pluginsChoice_disabled",
"pluginsChoice_remove",
"pluginsConsentTitle",
"partialHeading",
"setupHeading",
"skipPartial",
"next",
"finish",
]) {
expect(lookup(`onboarding.${key}`), key).toBeTypeOf("string");
}
});
it("no longer carries the feature tour", () => {
const keys = Object.keys(
(en as Record<string, Record<string, unknown>>).onboarding,
);
expect(
keys.filter((k) => /^(welcome|features|workflow|done)/.test(k)),
).toEqual([]);
});
it("uses no em dashes", () => {
const text = JSON.stringify((en as Record<string, unknown>).onboarding);
expect(text).not.toContain("—");
});
});
describe("interface settings translations", () => {
+184
View File
@@ -0,0 +1,184 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defaultUiPreferences } from "@/types/ui-preferences";
const mocks = vi.hoisted(() => ({
getUiPreferences: vi.fn(),
getOnboardingPlugins: vi.fn(),
has: vi.fn((_p: string) => false),
embedded: false,
}));
vi.mock("@/api/ui-preferences-api", () => ({
getUiPreferences: mocks.getUiPreferences,
}));
vi.mock("@/api/plugins-api", () => ({
getOnboardingPlugins: mocks.getOnboardingPlugins,
}));
vi.mock("@/hooks/use-permissions", () => ({
loadPermissionChecker: async () => mocks.has,
}));
vi.mock("@/lib/embedded-frame", () => ({
isEmbeddedAuthFrame: () => mocks.embedded,
}));
vi.mock("@/onboarding/onboarding-steps", () => ({
CORE_STEPS: [
{
id: "welcome",
version: 1,
titleKey: "w",
Component: () => null,
audience: "all",
position: 0,
},
{
id: "plugins",
version: 1,
titleKey: "p",
Component: () => null,
audience: "admin",
position: 10,
isRelevant: (ctx: { pluginSetupPending: boolean }) =>
ctx.pluginSetupPending,
forcePending: (ctx: { pluginSetupPending: boolean }) =>
ctx.pluginSetupPending,
},
{
id: "appearance",
version: 2,
titleKey: "a",
Component: () => null,
audience: "all",
position: 40,
},
],
}));
import { resolveStartupOnboarding } from "@/onboarding/startup";
import { onboardingSteps } from "@/onboarding/onboarding-registry";
function prefs(seen: Record<string, number>, extra = {}) {
return {
...defaultUiPreferences(),
onboarding: {
seen,
completedAt: "2026-01-01T00:00:00.000Z",
skipped: false,
baselinePending: false,
...extra,
},
};
}
beforeEach(() => {
mocks.embedded = false;
mocks.has.mockReset();
mocks.has.mockReturnValue(false);
mocks.getUiPreferences.mockReset();
mocks.getOnboardingPlugins.mockReset();
mocks.getOnboardingPlugins.mockResolvedValue({
pending: false,
managedByLinkedServer: false,
plugins: [],
});
onboardingSteps.reset();
});
describe("resolveStartupOnboarding", () => {
it("never runs inside the mobile app or a desktop link window", async () => {
mocks.embedded = true;
expect((await resolveStartupOnboarding()).mode).toBeNull();
expect(mocks.getUiPreferences).not.toHaveBeenCalled();
});
it("runs the full flow for a new user", async () => {
mocks.getUiPreferences.mockResolvedValue(defaultUiPreferences());
const plan = await resolveStartupOnboarding();
expect(plan.mode).toBe("full");
expect(plan.preferences).toBeTruthy();
});
it("does not ask for the plugin list as a non-admin", async () => {
mocks.getUiPreferences.mockResolvedValue(defaultUiPreferences());
await resolveStartupOnboarding();
expect(mocks.getOnboardingPlugins).not.toHaveBeenCalled();
});
it("shows nothing to someone who has seen everything", async () => {
mocks.getUiPreferences.mockResolvedValue(
prefs({ welcome: 1, plugins: 1, appearance: 2 }),
);
expect((await resolveStartupOnboarding()).mode).toBeNull();
});
it("runs a partial flow for a bumped core step", async () => {
mocks.getUiPreferences.mockResolvedValue(
prefs({ welcome: 1, appearance: 1 }),
);
expect((await resolveStartupOnboarding()).mode).toBe("partial");
});
it("brings an admin back to the picker while it is still pending", async () => {
mocks.has.mockReturnValue(true);
mocks.getOnboardingPlugins.mockResolvedValue({
pending: true,
managedByLinkedServer: false,
plugins: [],
});
mocks.getUiPreferences.mockResolvedValue(
prefs({ welcome: 1, plugins: 1, appearance: 2 }),
);
const plan = await resolveStartupOnboarding();
expect(plan.mode).toBe("partial");
expect(plan.plugins?.pending).toBe(true);
});
it("treats a failed plugin list as not pending", async () => {
mocks.has.mockReturnValue(true);
mocks.getOnboardingPlugins.mockRejectedValue(new Error("403"));
mocks.getUiPreferences.mockResolvedValue(
prefs({ welcome: 1, plugins: 1, appearance: 2 }),
);
expect((await resolveStartupOnboarding()).mode).toBeNull();
});
it("runs a partial flow for a new plugin step", async () => {
onboardingSteps.register({
id: "rd:guacd",
pluginId: "rd",
version: 1,
titleKey: "g",
Component: () => null,
audience: "all",
position: 50,
});
mocks.getUiPreferences.mockResolvedValue(
prefs({ welcome: 1, appearance: 2 }),
);
expect((await resolveStartupOnboarding()).mode).toBe("partial");
});
it("leaves plugin steps to the baseline pass for carried over users", async () => {
onboardingSteps.register({
id: "rd:guacd",
pluginId: "rd",
version: 1,
titleKey: "g",
Component: () => null,
audience: "all",
position: 50,
});
mocks.getUiPreferences.mockResolvedValue(
prefs({ welcome: 1, appearance: 2 }, { baselinePending: true }),
);
expect((await resolveStartupOnboarding()).mode).toBeNull();
});
it("gives up quietly when preferences cannot load", async () => {
mocks.getUiPreferences.mockRejectedValue(new Error("offline"));
expect(await resolveStartupOnboarding()).toEqual({
preferences: null,
mode: null,
plugins: null,
});
});
});
@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import type { OnboardingPluginList } from "@/api/plugins-api";
const api = vi.hoisted(() => ({
getOnboardingPlugins: vi.fn(),
applyOnboardingPlugins: vi.fn(),
}));
vi.mock("@/api/plugins-api", () => api);
const loader = vi.hoisted(() => ({ syncPlugins: vi.fn(async () => {}) }));
vi.mock("@/plugin-host/loader", () => loader);
import { PluginsStep } from "@/onboarding/steps/PluginsStep";
import { defaultChoices } from "@/onboarding/plugin-choices";
function plugin(
id: string,
extra: Partial<OnboardingPluginList["plugins"][number]> = {},
) {
return {
id,
name: id.toUpperCase(),
description: `${id} plugin`,
icon: null,
category: "Terminal",
version: "1.0.0",
source: "bundled" as const,
state: "enabled",
dependencies: [],
recommended: false,
consent: false,
...extra,
};
}
const LIST: OnboardingPluginList = {
pending: true,
reason: "fresh",
managedByLinkedServer: false,
plugins: [
plugin("term", { recommended: true }),
plugin("base", { category: "Infrastructure" }),
plugin("addon", { category: "Infrastructure", dependencies: ["base"] }),
plugin("stats", { recommended: true, consent: true, state: "disabled" }),
],
};
describe("defaultChoices", () => {
it("keeps recommended plugins and removes the rest on a fresh install", () => {
expect(defaultChoices(LIST)).toEqual({
term: "enabled",
base: "remove",
addon: "remove",
stats: "enabled",
});
});
it("starts from how things are now on an upgraded install", () => {
expect(defaultChoices({ ...LIST, reason: "upgrade" })).toEqual({
term: "enabled",
base: "enabled",
addon: "enabled",
stats: "disabled",
});
});
it("starts from how things are now on a later run", () => {
expect(defaultChoices({ ...LIST, pending: false })).toEqual({
term: "enabled",
base: "enabled",
addon: "enabled",
stats: "disabled",
});
});
});
describe("PluginsStep", () => {
let beforeNext: (() => boolean | Promise<boolean>) | null = null;
beforeEach(() => {
beforeNext = null;
api.getOnboardingPlugins.mockResolvedValue(LIST);
api.applyOnboardingPlugins.mockReset();
loader.syncPlugins.mockClear();
});
async function renderStep() {
render(
<PluginsStep
mode="full"
isAdmin
isDesktop={false}
shellReady={false}
setCanContinue={() => {}}
setBeforeNext={(fn) => {
beforeNext = fn;
}}
/>,
);
await screen.findByText("TERM");
}
it("groups plugins by category and puts consent plugins in their own section", async () => {
await renderStep();
expect(screen.getByText("Terminal")).toBeTruthy();
expect(screen.getByText("Infrastructure")).toBeTruthy();
expect(screen.getByRole("checkbox")).toHaveProperty("checked", true);
});
it("tells an upgrading admin that nothing changes unless they say so", async () => {
api.getOnboardingPlugins.mockResolvedValue({ ...LIST, reason: "upgrade" });
await renderStep();
expect(screen.getByText(/onboarding.pluginsUpgradeNote/)).toBeTruthy();
});
it("keeps a dependency the admin kept something for, and says why", async () => {
await renderStep();
const addon = screen.getByRole("radiogroup", { name: "ADDON" });
fireEvent.click(within(addon).getByRole("radio", { name: /enabled|On/ }));
expect(
await screen.findByText("onboarding.pluginsKeptBecause"),
).toBeTruthy();
const base = screen.getByRole("radiogroup", { name: "BASE" });
expect(
within(base)
.getByRole("radio", { name: /enabled|On/ })
.getAttribute("aria-checked"),
).toBe("true");
});
it("applies the choices on Next and resyncs the plugin runtime", async () => {
api.applyOnboardingPlugins.mockResolvedValue({
resolved: {},
adjustments: [],
enabled: [],
disabled: [],
removed: ["base", "addon"],
failed: [],
});
api.getOnboardingPlugins.mockResolvedValueOnce(LIST).mockResolvedValue({
...LIST,
pending: false,
});
await renderStep();
let ok: boolean | undefined;
await act(async () => {
ok = await beforeNext!();
});
expect(ok).toBe(true);
expect(api.applyOnboardingPlugins).toHaveBeenCalledWith({
term: "enabled",
base: "remove",
addon: "remove",
stats: "enabled",
});
expect(loader.syncPlugins).toHaveBeenCalled();
});
it("stops once to show failures, then lets the admin move on", async () => {
api.applyOnboardingPlugins.mockResolvedValue({
resolved: {},
adjustments: [],
enabled: [],
disabled: [],
removed: [],
failed: [{ id: "base", error: "busy" }],
});
api.getOnboardingPlugins.mockResolvedValueOnce(LIST).mockResolvedValue({
...LIST,
pending: false,
});
await renderStep();
let ok: boolean | undefined;
await act(async () => {
ok = await beforeNext!();
});
expect(ok).toBe(false);
expect(screen.getByText("BASE: busy")).toBeTruthy();
await act(async () => {
ok = await beforeNext!();
});
expect(ok).toBe(true);
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1);
});
});