Files
multica/packages/views/onboarding/source-backfill-modal.test.tsx
Jiayuan Zhang a61a8ecfed feat(onboarding): merge About-you step, collect source after agents deliver value (#5786)
Flow drops from five steps to three: role + use_case merge into a
single About-you screen (one Skip covers both; Continue stamps skip
markers on whichever group was left unanswered), and the source
question leaves onboarding entirely.

Source is now collected only by the workspace source-backfill prompt,
which additionally waits until agents/squads have completed at least
SOURCE_BACKFILL_MIN_AGENT_DONE_ISSUES (3) issues in the workspace —
attribution is asked after Multica has visibly delivered value, not
before. The count rides a limit:1 issues query keyed under
issueKeys.all so realtime invalidations keep it fresh, enabled only
for users who still owe an answer.

Server: questionnaire complete() narrows to role + use_case so the
funnel step doesn't stall on the now-deferred source; a new
metrics-only onboarding_source_submitted event (+ Prometheus counter)
tracks the backfill prompt's answer/decline transition once per user.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 01:57:44 +08:00

379 lines
12 KiB
TypeScript

import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../locales/en/common.json";
import enOnboarding from "../locales/en/onboarding.json";
const TEST_RESOURCES = { en: { common: enCommon, onboarding: enOnboarding } };
const { mockUser, mockSaveQuestionnaire, mockWorkspace, mockAgentDoneTotal, mockListIssues } =
vi.hoisted(() => ({
mockUser: { value: null as null | Record<string, unknown> },
mockSaveQuestionnaire: vi.fn(),
mockWorkspace: {
value: { id: "ws-1", slug: "ws-1" } as null | { id: string; slug: string },
},
mockAgentDoneTotal: { value: 3 },
mockListIssues: vi.fn(),
}));
vi.mock("@multica/core/auth", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/auth")>(
"@multica/core/auth",
);
const useAuthStore = Object.assign(
(selector: (s: { user: unknown }) => unknown) =>
selector({ user: mockUser.value }),
{ getState: () => ({ user: mockUser.value }) },
);
return { ...actual, useAuthStore };
});
vi.mock("@multica/core/onboarding", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/onboarding")>(
"@multica/core/onboarding",
);
return { ...actual, saveQuestionnaire: mockSaveQuestionnaire };
});
vi.mock("@multica/core/paths", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return { ...actual, useCurrentWorkspace: () => mockWorkspace.value };
});
vi.mock("@multica/core/api", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/api")>(
"@multica/core/api",
);
return {
...actual,
api: {
...actual.api,
listIssues: mockListIssues,
},
};
});
import { SourceBackfillModal } from "./source-backfill-modal";
function setUser(partial: Record<string, unknown> | null) {
mockUser.value = partial;
}
function wipeDismissCounters() {
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const k = window.localStorage.key(i);
if (k && k.startsWith("multica.source_backfill.dismiss.")) {
window.localStorage.removeItem(k);
}
}
}
/**
* Default tests run with reduced-motion *on* so the modal's entrance
* delay short-circuits and the dialog opens synchronously — keeps the
* behavioural tests focused on selection / submit / skip semantics
* rather than fighting timers. The dedicated entrance-delay test below
* overrides this to assert the deferred-open path.
*/
function mockPrefersReducedMotion(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: (q: string) => ({
matches: q.includes("reduce") ? matches : false,
media: q,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
beforeEach(() => {
mockSaveQuestionnaire.mockReset();
mockSaveQuestionnaire.mockResolvedValue(undefined);
mockListIssues.mockReset();
// Default: agents have already completed enough issues that the
// workspace-level gate passes — tests about the user-level gate and
// submit/skip semantics shouldn't have to care about it.
mockAgentDoneTotal.value = 3;
mockListIssues.mockImplementation(async () => ({
issues: [],
total: mockAgentDoneTotal.value,
}));
mockWorkspace.value = { id: "ws-1", slug: "ws-1" };
setUser(null);
wipeDismissCounters();
mockPrefersReducedMotion(true);
});
afterEach(() => {
wipeDismissCounters();
});
function renderModal() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={qc}>
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<SourceBackfillModal />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("SourceBackfillModal", () => {
it("does not render when there is no user", () => {
renderModal();
expect(
screen.queryByText(/How did you hear about Multica/i),
).not.toBeInTheDocument();
});
it("does not render when the user already recorded a source", () => {
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: { source: ["search"] },
});
renderModal();
expect(
screen.queryByText(/How did you hear about Multica/i),
).not.toBeInTheDocument();
// A settled user must not even pay for the count query.
expect(mockListIssues).not.toHaveBeenCalled();
});
it("opens for an onboarded user with empty source once agents completed enough issues", async () => {
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: { source: [] },
});
renderModal();
await waitFor(() => {
expect(
screen.getByText(/How did you hear about Multica/i),
).toBeInTheDocument();
});
});
it("stays closed while agents have completed fewer issues than the threshold", async () => {
mockAgentDoneTotal.value = 2;
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: { source: [] },
});
renderModal();
// Let the count query settle before asserting the negative.
await waitFor(() => {
expect(mockListIssues).toHaveBeenCalled();
});
expect(
screen.queryByText(/How did you hear about Multica/i),
).not.toBeInTheDocument();
});
it("stays closed outside a workspace context", async () => {
mockWorkspace.value = null;
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: { source: [] },
});
renderModal();
expect(
screen.queryByText(/How did you hear about Multica/i),
).not.toBeInTheDocument();
expect(mockListIssues).not.toHaveBeenCalled();
});
it("counts done issues assigned to agents or squads in the current workspace", async () => {
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: { source: [] },
});
renderModal();
await waitFor(() => {
expect(mockListIssues).toHaveBeenCalledWith({
workspace_id: "ws-1",
statuses: ["done"],
assignee_types: ["agent", "squad"],
limit: 1,
});
});
});
it("Submit PATCHes the merged questionnaire preserving role / use_case", async () => {
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: {
source: [],
role: "engineer",
role_skipped: false,
use_case: ["ship_code", "plan_research"],
use_case_skipped: false,
version: 2,
},
});
const user = userEvent.setup();
renderModal();
await user.click(await screen.findByText("Friends or colleagues"));
await user.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => {
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
});
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
expect(sent.source).toEqual(["friends_colleagues"]);
expect(sent.source_skipped).toBe(false);
expect(sent.role).toBe("engineer");
expect(sent.use_case).toEqual(["ship_code", "plan_research"]);
expect(sent.version).toBe(2);
});
it("Skip PATCHes source_skipped=true preserving role / use_case", async () => {
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: {
source: [],
role: "founder",
use_case: ["manage_team"],
version: 2,
},
});
const user = userEvent.setup();
renderModal();
await user.click(
await screen.findByRole("button", { name: "Skip" }),
);
await waitFor(() => {
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
});
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
expect(sent.source).toEqual([]);
expect(sent.source_skipped).toBe(true);
expect(sent.role).toBe("founder");
expect(sent.use_case).toEqual(["manage_team"]);
});
it("treats a legacy single-string source as already answered", () => {
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: { source: "search" },
});
renderModal();
expect(
screen.queryByText(/How did you hear about Multica/i),
).not.toBeInTheDocument();
});
it("picking a second option replaces the first (single-select primary source)", async () => {
// The modal is a single-select radio. Industry default for HDYHAU
// is to capture the primary acquisition source, so picking a
// second option must replace the first — never accumulate.
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: { source: [] },
});
const user = userEvent.setup();
renderModal();
await screen.findByText("Friends or colleagues");
const radios = screen.getAllByRole("radio");
const friends = radios[0]!;
const search = radios[1]!;
await user.click(friends);
expect(friends).toHaveAttribute("aria-checked", "true");
expect(search).toHaveAttribute("aria-checked", "false");
// Pick a second option — the first must clear and Submit stays
// enabled with exactly one pick in the payload.
await user.click(search);
expect(friends).toHaveAttribute("aria-checked", "false");
expect(search).toHaveAttribute("aria-checked", "true");
await user.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => {
expect(mockSaveQuestionnaire).toHaveBeenCalledTimes(1);
});
const sent = mockSaveQuestionnaire.mock.calls[0]![0];
// Server schema is still `source: string[]` for back-compat with
// v2 rows; the client always sends a single-element array.
expect(sent.source).toEqual(["search"]);
expect(sent.source).not.toContain("friends_colleagues");
});
it("defers the entrance by ~700ms when the user has not opted into reduced motion", async () => {
mockPrefersReducedMotion(false);
// Fake timers from the very start so the 700ms entrance timer is
// scheduled on the fake clock. The count query itself is
// promise-based; advanceTimersByTimeAsync flushes the microtasks
// that deliver its data and let the gate open.
vi.useFakeTimers();
try {
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: { source: [] },
});
renderModal();
// Flush the count query → gate passes → timer scheduled (t=0).
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(
screen.queryByText(/How did you hear about Multica/i),
).not.toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(699);
});
expect(
screen.queryByText(/How did you hear about Multica/i),
).not.toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
expect(
screen.getByText(/How did you hear about Multica/i),
).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
it("does not open once the per-user dismiss cap is reached on this browser", () => {
window.localStorage.setItem("multica.source_backfill.dismiss.u1", "3");
setUser({
id: "u1",
onboarded_at: "2026-01-01T00:00:00Z",
onboarding_questionnaire: { source: [] },
});
renderModal();
expect(
screen.queryByText(/How did you hear about Multica/i),
).not.toBeInTheDocument();
expect(mockListIssues).not.toHaveBeenCalled();
});
});