Files
fusion/packages/dashboard/app/components/__tests__/model-onboarding-state.test.ts
Fusion 24f0bf8d36 feat(FN-1943): add post-onboarding setup recommendations panel
- Add a PostOnboardingRecommendations component that detects incomplete AI provider, default model, and GitHub setup and offers direct actions
- Persist post-onboarding dismissal state in model-onboarding-state with new helpers for dismiss, check, and clear flows
- Integrate recommendations into App rendering so they appear after completed onboarding when resume onboarding is not active
- Add dashboard styles and focused tests for recommendation rendering/actions plus onboarding dismissal state coverage
- Add a changeset for @gsxdsm/fusion patch release describing the post-onboarding recommendations
2026-04-17 05:36:02 -07:00

927 lines
31 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
getOnboardingState,
saveOnboardingState,
clearOnboardingState,
dismissPostOnboardingRecommendations,
isPostOnboardingDismissed,
clearPostOnboardingDismissal,
isOnboardingResumable,
getOnboardingResumeStep,
markOnboardingCompleted,
isOnboardingCompleted,
getOnboardingCompletedAt,
markStepCompleted,
markStepSkipped,
getCompletedSteps,
getSkippedSteps,
getStepData,
ONBOARDING_STEP_LABELS,
} from "../model-onboarding-state";
describe("model-onboarding-state", () => {
const STORAGE_KEY = "fusion_model_onboarding_state";
// Mutable store shared across tests
let mockStore: Record<string, string> = {};
// Mock localStorage implementation
const mockLocalStorage = {
getItem: (key: string) => mockStore[key] ?? null,
setItem: (key: string, value: string) => {
mockStore[key] = value;
},
removeItem: (key: string) => {
delete mockStore[key];
},
clear: () => {
mockStore = {};
},
};
beforeEach(() => {
// Reset store for each test
mockStore = {};
// Reset modules to avoid caching issues
vi.resetModules();
// Stub the global localStorage
vi.stubGlobal("localStorage", mockLocalStorage);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("getOnboardingState", () => {
it("returns null when no state exists", () => {
expect(getOnboardingState()).toBeNull();
});
it("returns null for malformed JSON", () => {
mockStore[STORAGE_KEY] = "not valid json";
const result = getOnboardingState();
expect(result).toBeNull();
});
it("returns null for non-object JSON", () => {
mockStore[STORAGE_KEY] = '"just a string"';
const result = getOnboardingState();
expect(result).toBeNull();
});
it("returns parsed state for valid data with defaults applied", () => {
const state = { currentStep: "ai-setup" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingState();
expect(result).toEqual({
currentStep: "ai-setup",
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
});
it("returns parsed state for unknown step IDs", () => {
const state = { currentStep: "unknown-step", updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
// Unknown steps are now accepted (fallback label logic handles them)
expect(getOnboardingState()).toEqual({
currentStep: "unknown-step",
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
});
it("returns null when currentStep is missing", () => {
const state = { updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingState();
expect(result).toBeNull();
});
it("returns null when currentStep is not a string", () => {
const state = { currentStep: 123, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingState();
expect(result).toBeNull();
});
it("returns state with defaults when stored data has only currentStep and updatedAt (pre-FN-1860 format)", () => {
const state = { currentStep: "github" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingState();
expect(result).toEqual({
currentStep: "github",
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
});
it("preserves new fields when present", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"] as const,
skippedSteps: [],
dismissed: true,
completed: false,
stepData: { "ai-setup": { someData: "value" } },
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingState();
expect(result).toEqual(state);
});
});
describe("saveOnboardingState", () => {
it("persists state to localStorage without options (backward compatible)", () => {
saveOnboardingState("ai-setup");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("ai-setup");
expect(parsed.updatedAt).toBeDefined();
expect(parsed.completedSteps).toEqual([]);
expect(parsed.dismissed).toBe(false);
expect(parsed.completed).toBe(false);
expect(parsed.stepData).toEqual({});
});
it("overwrites existing state when called without options", () => {
saveOnboardingState("ai-setup");
saveOnboardingState("first-task");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("first-task");
expect(parsed.completedSteps).toEqual([]);
});
it("saves completedSteps when provided", () => {
saveOnboardingState("github", { completedSteps: ["ai-setup"] });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completedSteps).toEqual(["ai-setup"]);
expect(parsed.currentStep).toBe("github");
});
it("sets dismissed: true when options.dismissed is true", () => {
saveOnboardingState("github", { dismissed: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.dismissed).toBe(true);
expect(parsed.completed).toBe(false);
});
it("sets completed: true when options.completed is true", () => {
saveOnboardingState("complete", { completed: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completed).toBe(true);
expect(parsed.dismissed).toBe(false);
});
it("completed takes precedence over dismissed when both are true", () => {
saveOnboardingState("complete", { dismissed: true, completed: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completed).toBe(true);
expect(parsed.dismissed).toBe(false);
});
it("merges stepData per-step without overwriting other steps", () => {
// Set initial state with step data
saveOnboardingState("ai-setup", { stepData: { "ai-setup": { key1: "value1" } } });
// Update with new step data
saveOnboardingState("github", { stepData: { github: { key2: "value2" } } });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.stepData).toEqual({
"ai-setup": { key1: "value1" },
github: { key2: "value2" },
});
});
it("merges with existing state when options provided", () => {
// Set initial state
saveOnboardingState("ai-setup", { completedSteps: ["ai-setup"] });
// Update current step while preserving completedSteps
saveOnboardingState("github", { completedSteps: ["ai-setup"] });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("github");
expect(parsed.completedSteps).toEqual(["ai-setup"]);
});
it("works with empty options object (backward compatible)", () => {
saveOnboardingState("ai-setup", {});
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("ai-setup");
expect(parsed.completedSteps).toEqual([]);
});
});
describe("markStepCompleted", () => {
it("creates fresh state with step when no state exists", () => {
markStepCompleted("ai-setup");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("ai-setup");
expect(parsed.completedSteps).toEqual(["ai-setup"]);
expect(parsed.skippedSteps).toEqual([]);
expect(parsed.dismissed).toBe(false);
expect(parsed.completed).toBe(false);
});
it("appends step to existing completedSteps without duplicates", () => {
saveOnboardingState("github", { completedSteps: ["ai-setup"] });
markStepCompleted("github");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completedSteps).toEqual(["ai-setup", "github"]);
});
it("does not duplicate step if already in completedSteps", () => {
saveOnboardingState("github", { completedSteps: ["ai-setup", "github"] });
markStepCompleted("github");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completedSteps).toEqual(["ai-setup", "github"]);
});
it("works when state already has other completed steps", () => {
saveOnboardingState("first-task", { completedSteps: ["ai-setup", "github"] });
markStepCompleted("first-task");
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.completedSteps).toEqual(["ai-setup", "github", "first-task"]);
});
it("markStepCompleted removes step from skippedSteps", () => {
markStepSkipped("ai-setup");
markStepCompleted("ai-setup");
const state = getOnboardingState();
expect(state?.completedSteps).toContain("ai-setup");
expect(state?.skippedSteps).not.toContain("ai-setup");
});
});
describe("markStepSkipped", () => {
it("markStepSkipped adds step to skippedSteps", () => {
markStepSkipped("ai-setup");
expect(getOnboardingState()?.skippedSteps).toContain("ai-setup");
});
it("markStepSkipped does not add to completedSteps", () => {
markStepSkipped("ai-setup");
expect(getOnboardingState()?.completedSteps).not.toContain("ai-setup");
});
});
describe("clearOnboardingState", () => {
it("removes state from localStorage when called without options (backward compatible)", () => {
const state = {
currentStep: "ai-setup" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
clearOnboardingState();
expect(mockStore[STORAGE_KEY]).toBeUndefined();
});
it("sets completed: true with preserveProgress while preserving completedSteps and stepData", () => {
const state = {
currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"] as const,
skippedSteps: [],
dismissed: true,
completed: false,
stepData: { "ai-setup": { someData: "value" } },
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
clearOnboardingState({ preserveProgress: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("complete");
expect(parsed.completed).toBe(true);
expect(parsed.dismissed).toBe(false);
expect(parsed.completedSteps).toEqual(["ai-setup", "github"]);
expect(parsed.stepData).toEqual({ "ai-setup": { someData: "value" } });
});
it("creates minimal completed state when no existing state with preserveProgress", () => {
clearOnboardingState({ preserveProgress: true });
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("complete");
expect(parsed.completed).toBe(true);
expect(parsed.dismissed).toBe(false);
expect(parsed.completedSteps).toEqual([]);
expect(parsed.stepData).toEqual({});
});
it("preserveProgress: false removes key (explicit option)", () => {
const state = {
currentStep: "ai-setup" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
clearOnboardingState({ preserveProgress: false });
expect(mockStore[STORAGE_KEY]).toBeUndefined();
});
});
describe("post-onboarding dismissal", () => {
it("dismissPostOnboardingRecommendations sets postOnboardingDismissedAt", () => {
saveOnboardingState("complete", { completed: true });
dismissPostOnboardingRecommendations();
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.postOnboardingDismissedAt).toBeDefined();
});
it("isPostOnboardingDismissed returns true after dismissal", () => {
dismissPostOnboardingRecommendations();
expect(isPostOnboardingDismissed()).toBe(true);
});
it("isPostOnboardingDismissed returns false when not dismissed", () => {
saveOnboardingState("complete", { completed: true });
expect(isPostOnboardingDismissed()).toBe(false);
});
it("clearPostOnboardingDismissal removes the dismissal field", () => {
dismissPostOnboardingRecommendations();
clearPostOnboardingDismissal();
const state = getOnboardingState();
expect(state?.postOnboardingDismissedAt).toBeUndefined();
expect(isPostOnboardingDismissed()).toBe(false);
});
});
describe("isOnboardingCompleted", () => {
it("returns false when no state exists", () => {
expect(isOnboardingCompleted()).toBe(false);
});
it("returns false when completed field is false", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(false);
});
it("returns true when completed field is true", () => {
const state = {
currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github", "first-task"],
skippedSteps: [],
dismissed: false,
completed: true,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(true);
});
it("returns true when completedAt is set (legacy format)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(true);
});
it("returns true when completedAt is set even if completed is false", () => {
const state = {
currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(true);
});
it("returns false when state exists but completed field is missing (legacy state)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
stepData: {},
// Note: no completed field
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(false);
});
it("returns false when completedAt is empty string", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(false);
});
});
describe("getCompletedSteps", () => {
it("returns empty array when no state exists", () => {
expect(getCompletedSteps()).toEqual([]);
});
it("returns stored completedSteps array", () => {
saveOnboardingState("first-task", { completedSteps: ["ai-setup", "github"] });
expect(getCompletedSteps()).toEqual(["ai-setup", "github"]);
});
it("returns empty array when state exists but completedSteps is missing", () => {
// Legacy state without completedSteps
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getCompletedSteps()).toEqual([]);
});
});
describe("getSkippedSteps", () => {
it("getSkippedSteps returns persisted skipped steps", () => {
saveOnboardingState("github", { skippedSteps: ["ai-setup"] });
expect(getSkippedSteps()).toEqual(["ai-setup"]);
});
it("skippedSteps defaults to empty array for legacy state", () => {
const legacyState = {
currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(legacyState);
expect(getSkippedSteps()).toEqual([]);
});
});
describe("getStepData", () => {
it("returns null when no state exists", () => {
expect(getStepData("ai-setup")).toBeNull();
});
it("returns null when step has no data", () => {
saveOnboardingState("github", { completedSteps: ["ai-setup"] });
expect(getStepData("github")).toBeNull();
});
it("returns stored data for the step", () => {
saveOnboardingState("github", {
stepData: { "ai-setup": { selectedProvider: "anthropic", selectedModel: "claude-3" } },
});
expect(getStepData("ai-setup")).toEqual({ selectedProvider: "anthropic", selectedModel: "claude-3" });
});
it("returns null when state exists but stepData is missing (legacy state)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
// Note: no stepData field
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getStepData("ai-setup")).toBeNull();
});
});
describe("isOnboardingResumable", () => {
it("returns false when no state exists", () => {
expect(isOnboardingResumable()).toBe(false);
});
it("returns false when step is 'complete'", () => {
const state = {
currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(false);
});
it("returns true for non-terminal steps", () => {
const steps: Array<"ai-setup" | "github" | "first-task"> = ["ai-setup", "github", "first-task"];
for (const step of steps) {
const state = {
currentStep: step,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(true);
}
});
it("returns false when completed: true (new format)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"],
skippedSteps: [],
dismissed: false,
completed: true,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(false);
});
it("returns false when completedAt is set (legacy format)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(false);
});
it("returns false when dismissed: true", () => {
const state = {
currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"],
skippedSteps: [],
dismissed: true,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
// Dismissed onboarding should still show resume card (user can restart)
expect(isOnboardingResumable()).toBe(true);
});
});
describe("getOnboardingResumeStep", () => {
it("returns null when no state exists", () => {
expect(getOnboardingResumeStep()).toBeNull();
});
it("returns null when step is 'complete'", () => {
const state = {
currentStep: "complete" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingResumeStep()).toBeNull();
});
it("returns step info for known steps", () => {
const steps: Array<"ai-setup" | "github" | "first-task"> = ["ai-setup", "github", "first-task"];
for (const step of steps) {
const state = {
currentStep: step,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result).toEqual({
currentStep: step,
label: ONBOARDING_STEP_LABELS[step],
completedSteps: [],
});
}
});
it("returns completedSteps in the return value", () => {
const state = {
currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup"] as const,
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result?.completedSteps).toEqual(["ai-setup"]);
});
it("returns empty completedSteps when field is missing (legacy state)", () => {
const state = {
currentStep: "github" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
dismissed: false,
completed: false,
stepData: {},
// Note: no completedSteps field
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result?.completedSteps).toEqual([]);
});
it("returns null when completed: true", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: ["ai-setup", "github"],
skippedSteps: [],
dismissed: false,
completed: true,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingResumeStep()).toBeNull();
});
it("returns null when completedAt is set (legacy format)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: "2024-01-02T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingResumeStep()).toBeNull();
});
it("returns fallback label for unknown future step IDs", () => {
const state = {
currentStep: "custom-step" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result).toEqual({
currentStep: "custom-step",
label: "Custom Step", // Falls back to title-case formatting
completedSteps: [],
});
});
it("handles kebab-case unknown steps", () => {
const state = {
currentStep: "my-custom-step" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result?.label).toBe("My Custom Step");
});
it("handles snake_case unknown steps", () => {
const state = {
currentStep: "my_custom_step" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingResumeStep();
expect(result?.label).toBe("My Custom Step");
});
});
describe("ONBOARDING_STEP_LABELS", () => {
it("has labels for all known steps", () => {
expect(ONBOARDING_STEP_LABELS["ai-setup"]).toBe("AI Setup");
expect(ONBOARDING_STEP_LABELS["github"]).toBe("GitHub");
expect(ONBOARDING_STEP_LABELS["first-task"]).toBe("First Task");
expect(ONBOARDING_STEP_LABELS["complete"]).toBe("Complete");
});
});
describe("markOnboardingCompleted", () => {
it("sets completed: true and completedAt on existing state", () => {
saveOnboardingState("first-task", { completedSteps: ["ai-setup"] });
markOnboardingCompleted();
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("first-task");
expect(parsed.completed).toBe(true);
expect(parsed.completedAt).toBeDefined();
expect(parsed.dismissed).toBe(false);
});
it("creates minimal state when no state exists", () => {
markOnboardingCompleted();
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("complete");
expect(parsed.completed).toBe(true);
expect(parsed.completedAt).toBeDefined();
expect(parsed.updatedAt).toBeDefined();
expect(parsed.completedSteps).toEqual([]);
expect(parsed.dismissed).toBe(false);
});
it("does not change currentStep if one is already set", () => {
saveOnboardingState("github");
markOnboardingCompleted();
const stored = mockStore[STORAGE_KEY];
const parsed = JSON.parse(stored);
expect(parsed.currentStep).toBe("github");
expect(parsed.completed).toBe(true);
expect(parsed.completedAt).toBeDefined();
});
it("updates completedAt timestamp on subsequent calls", async () => {
saveOnboardingState("ai-setup", { completedSteps: ["ai-setup"] });
markOnboardingCompleted();
const firstStored = mockStore[STORAGE_KEY];
const firstParsed = JSON.parse(firstStored);
// Wait a tiny bit to ensure timestamp difference
await new Promise((resolve) => setTimeout(resolve, 10));
markOnboardingCompleted();
const secondStored = mockStore[STORAGE_KEY];
const secondParsed = JSON.parse(secondStored);
expect(secondParsed.completedAt).not.toBe(firstParsed.completedAt);
});
});
describe("getOnboardingCompletedAt", () => {
it("returns timestamp when completedAt is set", () => {
const timestamp = "2024-01-02T00:00:00.000Z";
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedAt: timestamp,
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: true,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingCompletedAt()).toBe(timestamp);
});
it("returns null when not completed (no completedAt)", () => {
const state = {
currentStep: "first-task" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
};
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getOnboardingCompletedAt()).toBeNull();
});
it("returns null when no state exists", () => {
expect(getOnboardingCompletedAt()).toBeNull();
});
});
describe("backward compatibility with legacy state", () => {
it("getOnboardingState returns defaults for legacy state", () => {
// Pre-FN-1860 format: only currentStep and updatedAt
const state = { currentStep: "github" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
const result = getOnboardingState();
expect(result).toEqual({
currentStep: "github",
updatedAt: "2024-01-01T00:00:00.000Z",
completedSteps: [],
skippedSteps: [],
dismissed: false,
completed: false,
stepData: {},
});
});
it("isOnboardingCompleted returns false for legacy state without completedAt", () => {
const state = { currentStep: "first-task" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingCompleted()).toBe(false);
});
it("isOnboardingResumable works with legacy state", () => {
const state = { currentStep: "github" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(isOnboardingResumable()).toBe(true);
});
it("getCompletedSteps returns empty array for legacy state", () => {
const state = { currentStep: "first-task" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getCompletedSteps()).toEqual([]);
});
it("getStepData returns null for legacy state", () => {
const state = { currentStep: "first-task" as const, updatedAt: "2024-01-01T00:00:00.000Z" };
mockStore[STORAGE_KEY] = JSON.stringify(state);
expect(getStepData("ai-setup")).toBeNull();
});
});
});