Files
fusion/packages/dashboard/app/hooks/__tests__/useDeepLink.test.ts
gsxdsm 1c38c6e93b fix(dashboard): make duplicate-warning "Open" actually open the task
The "Open" button on a possible-duplicate warning did nothing — the modal
closed and no task appeared. The app has two task deep-link shapes and only
one had a consumer: `?task=<id>` was handled by useDeepLink, while
`#/tasks/<id>` had no `hashchange` listener anywhere in the dashboard.

Five surfaces write the hash form. InlineCreateCard and NewTaskModal write it
unconditionally, so their Open was always dead. QuickEntryBox, Column, and
ListView try an in-memory board lookup first and fall through to the dead hash,
which is why it looked intermittent: duplicate matches come from a
project-wide searchTasks, so a match that is `done` or outside the loaded
board slice misses the lookup and lands on the no-op.

Handle the hash form inside useDeepLink so the app keeps one deep-link
authority owning both shapes, rather than forking a parallel hook. The id is
resolved by fetch instead of an in-memory lookup (that lookup is the dead end
being removed), the hash is cleared via replaceState so re-Opening the same
task fires again, and an unresolvable id toasts instead of failing silently.

Regression tests assert the invariant shared by all five surfaces rather than
the single reported repro: a written hash always resolves to an open or a
toast, never a no-op. Verified against the pre-fix code — 5 of the 6 new tests
fail without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:07:09 -07:00

521 lines
17 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);
});
});
/*
FNXC:DeepLink 2026-07-25-11:20:
Regression coverage for the `#/tasks/<id>` hash deep link, which had NO consumer: every
surface writing it (duplicate-warning "Open" in InlineCreateCard / NewTaskModal /
QuickEntryBox, and the Column / ListView quick-add fallbacks) silently did nothing.
These assert the shared invariant those five surfaces depend on rather than the single
reported repro: a written hash always resolves to an open or a toast, never a no-op.
*/
describe("#/tasks/:id hash deep link (shared by all duplicate-warning Open surfaces)", () => {
function setHash(hash: string) {
Object.defineProperty(window, "location", {
configurable: true,
value: new URL(`http://localhost:3000/${hash}`),
});
}
it("opens the task when the hash is present on mount", async () => {
setHash("#/tasks/FN-4242");
const { openTaskDetail } = renderUseDeepLink();
await waitFor(() => {
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-4242", "proj_123");
expect(openTaskDetail).toHaveBeenCalledTimes(1);
});
});
it("opens the task when the hash is written after mount (the Open-button path)", async () => {
const { openTaskDetail } = renderUseDeepLink();
setHash("#/tasks/FN-4242");
window.dispatchEvent(new HashChangeEvent("hashchange"));
await waitFor(() => {
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-4242", "proj_123");
expect(openTaskDetail).toHaveBeenCalledTimes(1);
});
});
it("clears the hash so re-Opening the same task fires again", async () => {
const { openTaskDetail } = renderUseDeepLink();
setHash("#/tasks/FN-4242");
window.dispatchEvent(new HashChangeEvent("hashchange"));
await waitFor(() => expect(openTaskDetail).toHaveBeenCalledTimes(1));
// The consumer must have stripped the hash; otherwise the second Open is a no-op.
expect(window.location.hash).toBe("");
setHash("#/tasks/FN-4242");
window.dispatchEvent(new HashChangeEvent("hashchange"));
await waitFor(() => expect(openTaskDetail).toHaveBeenCalledTimes(2));
});
it("toasts instead of failing silently when the task cannot be resolved", async () => {
mockFetchTaskDetail.mockRejectedValueOnce(new Error("not found"));
setHash("#/tasks/FN-9001");
const { addToast, openTaskDetail } = renderUseDeepLink();
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("FN-9001"), "error");
});
expect(openTaskDetail).not.toHaveBeenCalled();
});
it("scopes the fetch to the active project", async () => {
setHash("#/tasks/FN-4242");
renderUseDeepLink({ projectId: otherProject.id, currentProject: otherProject });
await waitFor(() => {
expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-4242", "proj_456");
});
});
it("ignores hashes that are not task deep links", async () => {
setHash("#message-abc");
renderUseDeepLink();
await waitFor(() => {
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
});
});
});
});