- Right dock now defaults to HIDDEN when the operator has no stored preference (readStoredRightDockOpen), so first-run/onboarding lands on an uncluttered board; users opt in via the Header toggle (persists "true"). Combined with the existing taskView="board" default and viewMode->"project" transition, first run lands on the board. Updated the design comments and controller Harness tests (seed the open flag) and added a focused default-closed assertion. - useDeepLink deferred the "Project 'X' not found" toast behind a 3s grace window. A project selected during onboarding is deep-linked via ?project=<id> before the projects list revalidates to include it, so the eager toast fired even though the project loads a beat later. The deferred toast is cancelled as soon as the project appears in the list; only a genuinely-absent project past the window still errors. Added symptom-verification coverage (project appears within window -> no toast) and updated the StrictMode/unknown-project/App-level tests to advance fake timers. - Removed the stale ModelOnboardingModal test asserting the research-runs note that was intentionally removed from onboarding earlier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { StrictMode, createElement, type PropsWithChildren } from "react";
|
|
import { renderHook, waitFor } from "@testing-library/react";
|
|
import { useDeepLink } from "../useDeepLink";
|
|
import * as api from "../../api";
|
|
import type { ProjectInfo } from "../../api";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchTaskDetail: vi.fn(),
|
|
}));
|
|
|
|
const mockFetchTaskDetail = vi.mocked(api.fetchTaskDetail);
|
|
|
|
describe("useDeepLink", () => {
|
|
const originalLocation = window.location;
|
|
const originalReplaceState = window.history.replaceState;
|
|
|
|
const defaultProject: ProjectInfo = {
|
|
id: "proj_123",
|
|
name: "Project 123",
|
|
path: "/repo-123",
|
|
status: "active",
|
|
isolationMode: "in-process",
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
};
|
|
|
|
const otherProject: ProjectInfo = {
|
|
id: "proj_456",
|
|
name: "Project 456",
|
|
path: "/repo-456",
|
|
status: "active",
|
|
isolationMode: "in-process",
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
window.history.replaceState = vi.fn((_state, _unused, url) => {
|
|
if (typeof url === "string" && url.length > 0) {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL(url, "http://localhost:3000"),
|
|
});
|
|
}
|
|
}) as typeof window.history.replaceState;
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/"),
|
|
});
|
|
|
|
mockFetchTaskDetail.mockResolvedValue({ id: "FN-123" } as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: originalLocation,
|
|
});
|
|
window.history.replaceState = originalReplaceState;
|
|
});
|
|
|
|
function renderUseDeepLink(overrides: Partial<Parameters<typeof useDeepLink>[0]> = {}) {
|
|
const openTaskDetail = vi.fn();
|
|
const closeTaskDetail = vi.fn();
|
|
const setCurrentProject = vi.fn();
|
|
const addToast = vi.fn();
|
|
|
|
const options: Parameters<typeof useDeepLink>[0] = {
|
|
projectId: defaultProject.id,
|
|
projects: [defaultProject, otherProject],
|
|
projectsLoading: false,
|
|
currentProject: defaultProject,
|
|
setCurrentProject,
|
|
addToast,
|
|
openTaskDetail,
|
|
closeTaskDetail,
|
|
...overrides,
|
|
};
|
|
|
|
const hook = renderHook(() => useDeepLink(options));
|
|
return { ...hook, options, openTaskDetail, closeTaskDetail, setCurrentProject, addToast };
|
|
}
|
|
|
|
it("does nothing when no task param is present", async () => {
|
|
renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("rewrites /tasks/:id path and opens detail", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/tasks/FN-9999"),
|
|
});
|
|
|
|
const { openTaskDetail } = renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-9999");
|
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_123");
|
|
expect(openTaskDetail).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("preserves project query when rewriting /tasks/:id path", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/tasks/FN-9999?project=proj_456"),
|
|
});
|
|
|
|
const { setCurrentProject } = renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/?project=proj_456&task=FN-9999");
|
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_456");
|
|
});
|
|
});
|
|
|
|
it("ignores invalid /tasks/:id path", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/tasks/not-a-task-id"),
|
|
});
|
|
|
|
renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(window.history.replaceState).not.toHaveBeenCalled();
|
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("fetches and opens task detail for existing ?task deep-link without path rewrite", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?task=FN-123"),
|
|
});
|
|
|
|
const { openTaskDetail } = renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
|
|
expect(openTaskDetail).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
expect(window.history.replaceState).not.toHaveBeenCalledWith(expect.anything(), "", "/?task=FN-123");
|
|
});
|
|
|
|
it("switches project for project-only deep links without opening task detail", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=proj_456"),
|
|
});
|
|
|
|
const { setCurrentProject, openTaskDetail, addToast } = renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(setCurrentProject).toHaveBeenCalledTimes(1);
|
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
|
});
|
|
|
|
expect(openTaskDetail).not.toHaveBeenCalled();
|
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
|
expect(addToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows unknown project toast only once under StrictMode (after the grace window)", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=missing"),
|
|
});
|
|
|
|
const addToast = vi.fn();
|
|
const strictWrapper = ({ children }: PropsWithChildren) => createElement(StrictMode, null, children);
|
|
|
|
renderHook(() => useDeepLink({
|
|
projectId: defaultProject.id,
|
|
projects: [defaultProject, otherProject],
|
|
projectsLoading: false,
|
|
currentProject: defaultProject,
|
|
setCurrentProject: vi.fn(),
|
|
addToast,
|
|
openTaskDetail: vi.fn(),
|
|
closeTaskDetail: vi.fn(),
|
|
}), { wrapper: strictWrapper });
|
|
|
|
// The not-found toast is deferred behind the grace window — nothing fires immediately.
|
|
expect(addToast).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(3000);
|
|
|
|
expect(addToast).toHaveBeenCalledTimes(1);
|
|
expect(addToast).toHaveBeenCalledWith("Project 'missing' not found", "error");
|
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("does not toast when a freshly-created project appears within the grace window", async () => {
|
|
// FNXC:DeepLink 2026-07-03-09:50: symptom verification for the onboarding spurious
|
|
// "Project not found" toast — a project deep-linked before the list revalidates must NOT error.
|
|
vi.useFakeTimers();
|
|
try {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=proj_new"),
|
|
});
|
|
|
|
const addToast = vi.fn();
|
|
const setCurrentProject = vi.fn();
|
|
const newProject: ProjectInfo = { ...otherProject, id: "proj_new", name: "Freshly Created" };
|
|
|
|
const { rerender } = renderHook(
|
|
({ projects }: { projects: ProjectInfo[] }) =>
|
|
useDeepLink({
|
|
projectId: defaultProject.id,
|
|
projects,
|
|
projectsLoading: false,
|
|
currentProject: defaultProject,
|
|
setCurrentProject,
|
|
addToast,
|
|
openTaskDetail: vi.fn(),
|
|
closeTaskDetail: vi.fn(),
|
|
}),
|
|
{ initialProps: { projects: [defaultProject] } },
|
|
);
|
|
|
|
// Missing from the (stale) list, but still inside the grace window: no toast yet.
|
|
expect(addToast).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
// Projects list revalidates and now includes the just-created project.
|
|
rerender({ projects: [defaultProject, newProject] });
|
|
|
|
// Advance well past the original grace deadline; the pending toast must have been cancelled.
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
|
|
expect(addToast).not.toHaveBeenCalled();
|
|
expect(setCurrentProject).toHaveBeenCalledWith(newProject);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("switches project and uses project param for task fetch", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=proj_456&task=FN-999"),
|
|
});
|
|
|
|
const { setCurrentProject, openTaskDetail } = renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(setCurrentProject).toHaveBeenCalledTimes(1);
|
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-999", "proj_456");
|
|
expect(openTaskDetail).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("shows toast and skips fetch for unknown project param (after the grace window)", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=missing&task=FN-123"),
|
|
});
|
|
|
|
const { addToast, setCurrentProject } = renderUseDeepLink();
|
|
|
|
await vi.advanceTimersByTimeAsync(3000);
|
|
|
|
expect(addToast).toHaveBeenCalledWith("Project 'missing' not found", "error");
|
|
expect(addToast).toHaveBeenCalledTimes(1);
|
|
expect(setCurrentProject).not.toHaveBeenCalled();
|
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("keeps task-only deep-link behavior and strips task on detail close", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?task=FN-9999"),
|
|
});
|
|
|
|
const { result, setCurrentProject, closeTaskDetail } = renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-9999", "proj_123");
|
|
});
|
|
|
|
expect(setCurrentProject).not.toHaveBeenCalled();
|
|
|
|
result.current.handleDetailClose();
|
|
expect(window.history.replaceState).toHaveBeenCalledWith(expect.anything(), "", "/");
|
|
expect(closeTaskDetail).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("leaves mailbox view deep-link params intact after project switch", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=proj_456&view=mailbox&mailbox-message=msg-1#message-msg-1"),
|
|
});
|
|
|
|
const { setCurrentProject, openTaskDetail } = renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(setCurrentProject).toHaveBeenCalledTimes(1);
|
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
|
});
|
|
|
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
|
expect(openTaskDetail).not.toHaveBeenCalled();
|
|
expect(window.location.search).toContain("view=mailbox");
|
|
expect(window.location.search).toContain("mailbox-message=msg-1");
|
|
});
|
|
|
|
it("switches project for rooms view deep links without consuming room params", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=proj_456&view=rooms&room=room-1"),
|
|
});
|
|
|
|
const { setCurrentProject, openTaskDetail } = renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(setCurrentProject).toHaveBeenCalledTimes(1);
|
|
expect(setCurrentProject).toHaveBeenCalledWith(otherProject);
|
|
});
|
|
|
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
|
expect(openTaskDetail).not.toHaveBeenCalled();
|
|
expect(window.location.search).toContain("view=rooms");
|
|
expect(window.location.search).toContain("room=room-1");
|
|
});
|
|
|
|
it("waits for projects to load before resolving deep links", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=proj_456&task=FN-123"),
|
|
});
|
|
|
|
renderUseDeepLink({
|
|
projectsLoading: true,
|
|
currentProject: null,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("cleans task query param when deep-linked modal closes and preserves history state", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=proj_123&task=FN-123"),
|
|
});
|
|
const replaceStateMock = window.history.replaceState as ReturnType<typeof vi.fn>;
|
|
window.history.replaceState = originalReplaceState;
|
|
window.history.replaceState({ navIndex: 2, existing: "value" }, "");
|
|
window.history.replaceState = replaceStateMock;
|
|
|
|
const { result, closeTaskDetail } = renderUseDeepLink();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-123", "proj_123");
|
|
});
|
|
|
|
result.current.handleDetailClose();
|
|
|
|
expect(window.history.replaceState).toHaveBeenCalledWith(
|
|
{ navIndex: 2, existing: "value" },
|
|
"",
|
|
"/?project=proj_123",
|
|
);
|
|
expect(closeTaskDetail).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("prevents duplicate fetches when rerendering after project switch", async () => {
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: new URL("http://localhost:3000/?project=proj_456&task=FN-777"),
|
|
});
|
|
|
|
const addToast = vi.fn();
|
|
const setCurrentProject = vi.fn();
|
|
const openTaskDetail = vi.fn();
|
|
const closeTaskDetail = vi.fn();
|
|
|
|
const { rerender } = renderHook(
|
|
(props: Parameters<typeof useDeepLink>[0]) => useDeepLink(props),
|
|
{
|
|
initialProps: {
|
|
projectId: defaultProject.id,
|
|
projects: [defaultProject, otherProject],
|
|
projectsLoading: false,
|
|
currentProject: defaultProject,
|
|
setCurrentProject,
|
|
addToast,
|
|
openTaskDetail,
|
|
closeTaskDetail,
|
|
},
|
|
},
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchTaskDetail).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
rerender({
|
|
projectId: otherProject.id,
|
|
projects: [defaultProject, otherProject],
|
|
projectsLoading: false,
|
|
currentProject: otherProject,
|
|
setCurrentProject,
|
|
addToast,
|
|
openTaskDetail,
|
|
closeTaskDetail,
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchTaskDetail).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|