mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user