- 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
927 lines
31 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|