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