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

@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
const api = vi.hoisted(() => ({
getUiPreferences: vi.fn(),
saveUiPreferences: vi.fn(async () => {}),
saveUiPreferences: vi.fn(async (_body: unknown) => {}),
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
}));
@@ -199,3 +199,64 @@ describe("UiPreferencesProvider", () => {
expect(result.current?.preferences.overrides).toEqual({});
});
});
describe("onboarding state", () => {
it("uses preferences handed in by the boot gate without fetching", () => {
const initial = { ...defaultUiPreferences(), preset: "advanced" as const };
const { result } = renderHook(() => useUiPreferencesContext(), {
wrapper: ({ children }: { children: ReactNode }) => (
<UiPreferencesProvider initial={initial}>
{children}
</UiPreferencesProvider>
),
});
expect(result.current?.loaded).toBe(true);
expect(result.current?.preferences.preset).toBe("advanced");
expect(api.getUiPreferences).not.toHaveBeenCalled();
});
it("merges seen steps and sends them right away on flushNow, even in local mode", async () => {
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
await waitFor(() => expect(result.current?.loaded).toBe(true));
act(() => {
result.current?.markOnboardingSeen({ welcome: 1, "rd:guacd": 2 });
result.current?.markOnboardingSeen(
{ "rd:guacd": 1, done: 1 },
{ completed: true, skipped: false },
);
});
expect(result.current?.preferences.onboarding.seen).toEqual({
welcome: 1,
"rd:guacd": 2,
done: 1,
});
expect(result.current?.preferences.onboarding.completedAt).toBeTruthy();
await act(async () => {
await result.current?.flushNow();
});
expect(api.saveUiPreferences).toHaveBeenCalledTimes(1);
const body = api.saveUiPreferences.mock.calls[0][0] as {
onboarding: { seen: Record<string, number>; skipped: boolean };
};
expect(body.onboarding.seen).toEqual({
welcome: 1,
"rd:guacd": 2,
done: 1,
});
expect(body.onboarding.skipped).toBe(false);
});
it("sends a queued change when the provider unmounts", async () => {
const { result, unmount } = renderHook(() => useUiPreferencesContext(), {
wrapper,
});
await waitFor(() => expect(result.current?.loaded).toBe(true));
act(() => {
result.current?.markOnboardingSeen({ appearance: 1 });
});
unmount();
await waitFor(() => expect(api.saveUiPreferences).toHaveBeenCalled());
});
});
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isEmbeddedAuthFrame,
isInElectronWebView,
isInMobileWebView,
} from "@/lib/embedded-frame";
type TestWindow = Window & {
ReactNativeWebView?: unknown;
IS_ELECTRON_WEBVIEW?: boolean;
};
afterEach(() => {
vi.restoreAllMocks();
delete (window as TestWindow).ReactNativeWebView;
delete (window as TestWindow).IS_ELECTRON_WEBVIEW;
});
describe("embedded frame detection", () => {
it("is false for a normal browser tab", () => {
expect(isInMobileWebView()).toBe(false);
expect(isInElectronWebView()).toBe(false);
expect(isEmbeddedAuthFrame()).toBe(false);
});
it("spots the mobile app by user agent or bridge", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 Termix-Mobile/Android",
);
expect(isInMobileWebView()).toBe(true);
vi.restoreAllMocks();
(window as TestWindow).ReactNativeWebView = { postMessage() {} };
expect(isInMobileWebView()).toBe(true);
expect(isInElectronWebView()).toBe(false);
expect(isEmbeddedAuthFrame()).toBe(true);
});
it("spots the desktop link window by flag", () => {
(window as TestWindow).IS_ELECTRON_WEBVIEW = true;
expect(isInElectronWebView()).toBe(true);
expect(isEmbeddedAuthFrame()).toBe(true);
});
});
@@ -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);
});
});
@@ -0,0 +1,164 @@
import { afterEach, describe, expect, it } from "vitest";
import type { TermixApp } from "@termix-ssh/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix-ssh/plugin-sdk/testing";
import { resetActionRegistry } from "@/shell/action-registry";
import { resetPluginStore } from "@/plugin-host/plugin-store";
import {
onboardingSteps,
SECTION_POSITION,
} from "@/onboarding/onboarding-registry";
import { pluginKey } from "@/lib/plugin-i18n";
const mounted: RenderedPluginApp[] = [];
afterEach(async () => {
while (mounted.length > 0) await mounted.pop()!.deactivate();
resetActionRegistry();
resetPluginStore();
onboardingSteps.reset();
});
const Step = () => <span>step</span>;
async function mount(activate: (app: TermixApp) => void) {
const rendered = await renderWithApp(
{ activate: async (app: TermixApp) => activate(app) },
{ manifest: { id: "guac" } },
);
mounted.push(rendered);
return rendered;
}
describe("app.registerOnboardingStep", () => {
it("keys the step by plugin, namespaces its text and places it by section", async () => {
await mount((app) => {
app.registerOnboardingStep({
id: "setup",
version: 2,
titleKey: "onboarding.title",
descriptionKey: "onboarding.desc",
component: Step,
audience: "admin",
section: "setup",
permission: "manage",
});
});
const [step] = onboardingSteps.list();
expect(step).toMatchObject({
id: "guac:setup",
pluginId: "guac",
version: 2,
audience: "admin",
titleKey: pluginKey("guac", "onboarding.title"),
descriptionKey: pluginKey("guac", "onboarding.desc"),
permission: "guac.manage",
});
expect(Math.floor(step.position)).toBe(SECTION_POSITION.setup);
});
it("defaults to version 1, everyone, and the explore section", async () => {
await mount((app) => {
app.registerOnboardingStep({
id: "tour",
titleKey: "t",
component: Step,
});
});
const [step] = onboardingSteps.list();
expect(step.version).toBe(1);
expect(step.audience).toBe("all");
expect(Math.round(step.position)).toBe(SECTION_POSITION.explore);
});
it("passes only the documented context to when, and survives it throwing", async () => {
let seen: unknown;
await mount((app) => {
app.registerOnboardingStep({
id: "a",
titleKey: "t",
component: Step,
when: (context) => {
seen = context;
return context.isDesktop;
},
});
app.registerOnboardingStep({
id: "b",
titleKey: "t",
component: Step,
when: () => {
throw new Error("boom");
},
});
});
const ctx = {
mode: "full" as const,
isAdmin: true,
isDesktop: true,
has: () => true,
pluginSetupPending: true,
pluginsManagedElsewhere: false,
};
const [a, b] = onboardingSteps.list();
expect(a.isRelevant?.(ctx)).toBe(true);
expect(seen).toEqual({ isAdmin: true, isDesktop: true, mode: "full" });
expect(b.isRelevant?.(ctx)).toBe(false);
});
it("refuses a bad id or version", async () => {
await expect(
mount((app) => {
app.registerOnboardingStep({
id: "Bad Id",
titleKey: "t",
component: Step,
});
}),
).rejects.toThrow(/not valid/);
await expect(
mount((app) => {
app.registerOnboardingStep({
id: "ok",
version: 0,
titleKey: "t",
component: Step,
});
}),
).rejects.toThrow(/version/);
});
it("removes everything when the plugin is turned off", async () => {
const rendered = await mount((app) => {
app.registerOnboardingStep({ id: "s", titleKey: "t", component: Step });
});
expect(onboardingSteps.list()).toHaveLength(1);
mounted.splice(mounted.indexOf(rendered), 1);
await rendered.deactivate();
expect(onboardingSteps.list()).toEqual([]);
});
});
describe("renderWithApp onboarding helpers", () => {
it("lists and renders a plugin's onboarding step", async () => {
const rendered = await mount((app) => {
app.registerOnboardingStep({
id: "check",
titleKey: "t",
component: ({ setCanContinue, setBeforeNext }) => {
setCanContinue(false);
setBeforeNext(() => false);
return <span>checking</span>;
},
});
});
expect(rendered.registered.onboardingSteps()).toEqual(["check"]);
const step = rendered.renderOnboardingStep("check");
expect(step.element.textContent).toContain("checking");
expect(step.canContinue()).toBe(false);
expect(await step.next()).toBe(false);
});
});
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
act,
fireEvent,
render,
renderHook,
screen,
} from "@testing-library/react";
const prefsApi = vi.hoisted(() => ({
getUserPreferences: vi.fn(),
saveUserPreferences: vi.fn(async () => {}),
}));
vi.mock("@/api/open-tabs-api", () => prefsApi);
const theme = vi.hoisted(() => ({ value: "dark", setTheme: vi.fn() }));
vi.mock("@/components/theme-provider", () => ({
useTheme: () => ({ theme: theme.value, setTheme: theme.setTheme }),
}));
import {
AccentPicker,
isHexColor,
ThemeGrid,
THEMES,
useAppearanceSettings,
} from "@/settings/appearance/appearance-controls";
beforeEach(() => {
localStorage.clear();
prefsApi.getUserPreferences.mockReset();
prefsApi.saveUserPreferences.mockClear();
theme.setTheme.mockClear();
});
describe("isHexColor", () => {
it("accepts 3 and 6 digit hex only", () => {
expect(isHexColor("#fff")).toBe(true);
expect(isHexColor("#f59145")).toBe(true);
expect(isHexColor("f59145")).toBe(false);
expect(isHexColor("#f5914")).toBe(false);
expect(isHexColor("red")).toBe(false);
});
});
describe("useAppearanceSettings", () => {
it("saves to the account only in cloud mode", async () => {
prefsApi.getUserPreferences.mockResolvedValue({ storageMode: "local" });
const { result } = renderHook(() => useAppearanceSettings());
await act(async () => {
result.current.setAccent("#123456");
});
expect(localStorage.getItem("termix-accent")).toBe("#123456");
expect(prefsApi.saveUserPreferences).not.toHaveBeenCalled();
prefsApi.getUserPreferences.mockResolvedValue({ storageMode: "cloud" });
await act(async () => {
result.current.setTheme("nord");
result.current.setFontSize("lg");
});
expect(theme.setTheme).toHaveBeenCalledWith("nord");
expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({
theme: "nord",
});
expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({
fontSize: "lg",
});
});
});
describe("ThemeGrid", () => {
it("offers every theme and reports the pick", () => {
const onChange = vi.fn();
render(<ThemeGrid value="dark" onChange={onChange} />);
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(THEMES.length);
fireEvent.click(buttons[THEMES.findIndex((t) => t.id === "gruvbox")]);
expect(onChange).toHaveBeenCalledWith("gruvbox");
});
});
describe("AccentPicker", () => {
it("takes a typed hex on Enter and ignores anything else", () => {
const onChange = vi.fn();
render(<AccentPicker value="#f59145" onChange={onChange} />);
const input = screen.getByPlaceholderText("#f97316");
fireEvent.change(input, { target: { value: "nope" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "#abcdef" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("#abcdef");
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
CORE_ONBOARDING_STEPS,
LEGACY_SEEN,
MAX_SEEN_ENTRIES,
mergeSeen,
pluginStepKey,
sanitizeSeen,
} from "@/types/onboarding";
describe("onboarding seen map", () => {
it("keys plugin steps by plugin id", () => {
expect(pluginStepKey("remote-desktop", "guacd")).toBe(
"remote-desktop:guacd",
);
});
it("merges per key keeping the higher version", () => {
expect(mergeSeen({ a: 2, b: 1 }, { a: 1, b: 3, c: 1 })).toEqual({
a: 2,
b: 3,
c: 1,
});
});
it("drops invalid entries", () => {
expect(
sanitizeSeen({ ok: 1, zero: 0, neg: -1, frac: 1.2, str: "1", "": 1 }),
).toEqual({ ok: 1 });
expect(sanitizeSeen(["a"])).toEqual({});
expect(sanitizeSeen(null)).toEqual({});
});
it("caps the number of entries", () => {
const big: Record<string, number> = {};
for (let i = 0; i < MAX_SEEN_ENTRIES + 20; i++) big[`k${i}`] = 1;
expect(Object.keys(sanitizeSeen(big))).toHaveLength(MAX_SEEN_ENTRIES);
});
it("legacy seen covers every core step at launch", () => {
expect(Object.keys(LEGACY_SEEN).sort()).toEqual(
Object.keys(CORE_ONBOARDING_STEPS).sort(),
);
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
applyOrder,
isPluginChoice,
resolvePluginChoices,
type ChoicePlugin,
} from "@/types/plugin-onboarding";
const plugins: ChoicePlugin[] = [
{ id: "base", dependencies: [] },
{ id: "mid", dependencies: ["base"] },
{ id: "top", dependencies: ["mid"] },
{ id: "solo", dependencies: [] },
];
describe("resolvePluginChoices", () => {
it("leaves consistent choices alone", () => {
const choices = {
base: "enabled",
mid: "enabled",
top: "disabled",
solo: "remove",
} as const;
const result = resolvePluginChoices(plugins, choices);
expect(result.choices).toEqual(choices);
expect(result.adjustments).toEqual([]);
});
it("enables the whole chain under an enabled plugin", () => {
const result = resolvePluginChoices(plugins, {
base: "remove",
mid: "disabled",
top: "enabled",
});
expect(result.choices).toEqual({
base: "enabled",
mid: "enabled",
top: "enabled",
});
expect(result.adjustments).toEqual([
{ id: "base", from: "remove", to: "enabled", requiredBy: ["mid"] },
{ id: "mid", from: "disabled", to: "enabled", requiredBy: ["top"] },
]);
});
it("keeps a dependency of a disabled plugin instead of removing it", () => {
const result = resolvePluginChoices(plugins, {
base: "remove",
mid: "disabled",
});
expect(result.choices.base).toBe("disabled");
});
it("lets a removed plugin take nothing with it", () => {
const result = resolvePluginChoices(plugins, {
base: "remove",
mid: "remove",
top: "remove",
});
expect(result.adjustments).toEqual([]);
});
it("ignores unknown dependencies and survives cycles", () => {
const cyclic: ChoicePlugin[] = [
{ id: "a", dependencies: ["b", "ghost"] },
{ id: "b", dependencies: ["a"] },
];
const result = resolvePluginChoices(cyclic, { a: "enabled", b: "remove" });
expect(result.choices).toEqual({ a: "enabled", b: "enabled" });
});
});
describe("applyOrder", () => {
it("enables dependencies first and removes dependents first", () => {
const order = applyOrder(plugins, {
base: "enabled",
mid: "enabled",
top: "enabled",
});
expect(order.enable).toEqual(["base", "mid", "top"]);
const removal = applyOrder(plugins, {
base: "remove",
mid: "remove",
top: "remove",
solo: "disabled",
});
expect(removal.remove).toEqual(["top", "mid", "base"]);
expect(removal.disable).toEqual(["solo"]);
});
});
describe("isPluginChoice", () => {
it("accepts only the three choices", () => {
expect(isPluginChoice("enabled")).toBe(true);
expect(isPluginChoice("remove")).toBe(true);
expect(isPluginChoice("delete")).toBe(false);
});
});
+23 -3
View File
@@ -9,6 +9,7 @@ import {
sanitizeUiPreferences,
UI_PREFERENCES_VERSION,
} from "@/types/ui-preferences";
import { LEGACY_SEEN } from "@/types/onboarding";
describe("defaultUiPreferences", () => {
it("starts every user on balanced with no overrides", () => {
@@ -16,9 +17,10 @@ describe("defaultUiPreferences", () => {
expect(defaults.preset).toBe("balanced");
expect(defaults.overrides).toEqual({});
expect(defaults.onboarding).toEqual({
completedVersion: 0,
seen: {},
completedAt: null,
skipped: false,
baselinePending: false,
});
});
});
@@ -36,9 +38,10 @@ describe("sanitizeUiPreferences", () => {
preset: "simple" as const,
overrides: { hostList: { density: "compact" as const, showTags: true } },
onboarding: {
completedVersion: 1,
seen: { welcome: 1, "remote-desktop:guacd": 2 },
completedAt: "2026-01-01T00:00:00.000Z",
skipped: false,
baselinePending: false,
},
};
expect(sanitizeUiPreferences(valid)).toEqual(valid);
@@ -56,11 +59,28 @@ describe("sanitizeUiPreferences", () => {
it("ignores a malformed onboarding block", () => {
expect(sanitizeUiPreferences({ onboarding: "done" }).onboarding).toEqual({
completedVersion: 0,
seen: {},
completedAt: null,
skipped: false,
baselinePending: false,
});
});
it("migrates the old completedVersion shape to the legacy seen map", () => {
const onboarding = sanitizeUiPreferences({
onboarding: { completedVersion: 2, completedAt: null, skipped: true },
}).onboarding;
expect(onboarding.seen).toEqual(LEGACY_SEEN);
expect(onboarding.baselinePending).toBe(true);
expect(onboarding.skipped).toBe(true);
});
it("treats an old never-completed shape as new", () => {
expect(
sanitizeUiPreferences({ onboarding: { completedVersion: 0 } }).onboarding
.seen,
).toEqual({});
});
});
describe("sanitizeUiOverrides", () => {