Merge branch 'main' into feat/editable-agent-name
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -150,17 +151,65 @@ describe("useDeepLink", () => {
|
||||
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", async () => {
|
||||
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 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
expect(addToast).toHaveBeenCalledWith("Project 'missing' not found", "error");
|
||||
});
|
||||
|
||||
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 } = renderUseDeepLink();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,15 +219,74 @@ describe("useDeepLink", () => {
|
||||
value: new URL("http://localhost:3000/?project=missing&task=FN-123"),
|
||||
});
|
||||
|
||||
const { addToast } = renderUseDeepLink();
|
||||
const { addToast, setCurrentProject } = renderUseDeepLink();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Project 'missing' not found", "error");
|
||||
});
|
||||
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
expect(setCurrentProject).not.toHaveBeenCalled();
|
||||
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -46,6 +46,12 @@ export function useDeepLink(options: UseDeepLinkOptions): UseDeepLinkResult {
|
||||
// Track whether the currently open detail modal came from a deep-link.
|
||||
const deepLinkTaskIdRef = useRef<string | null>(null);
|
||||
|
||||
// Avoid duplicate not-found toasts in StrictMode double-effect runs.
|
||||
const projectNotFoundToastRef = useRef<string | null>(null);
|
||||
|
||||
// Ensure project switching from ?project= only happens once per project value.
|
||||
const projectSwitchAppliedRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathRewroteRef.current) {
|
||||
const pathMatch = window.location.pathname.match(/^\/tasks\/([A-Z]+-\d+)\/?$/);
|
||||
@@ -66,25 +72,40 @@ export function useDeepLink(options: UseDeepLinkOptions): UseDeepLinkResult {
|
||||
const projectParam = params.get("project");
|
||||
const taskId = params.get("task");
|
||||
|
||||
if (!taskId) return;
|
||||
if (projectsLoading) return;
|
||||
|
||||
let taskProjectId = projectId;
|
||||
|
||||
if (projectParam) {
|
||||
const matchingProject = projects.find((project) => project.id === projectParam);
|
||||
if (!matchingProject) {
|
||||
addToast(`Project '${projectParam}' not found`, "error");
|
||||
if (projectNotFoundToastRef.current !== projectParam) {
|
||||
addToast(`Project '${projectParam}' not found`, "error");
|
||||
projectNotFoundToastRef.current = projectParam;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentProject?.id !== matchingProject.id) {
|
||||
projectNotFoundToastRef.current = null;
|
||||
taskProjectId = matchingProject.id;
|
||||
|
||||
if (
|
||||
currentProject?.id !== matchingProject.id
|
||||
&& projectSwitchAppliedRef.current !== matchingProject.id
|
||||
) {
|
||||
setCurrentProject(matchingProject);
|
||||
projectSwitchAppliedRef.current = matchingProject.id;
|
||||
}
|
||||
} else {
|
||||
projectNotFoundToastRef.current = null;
|
||||
projectSwitchAppliedRef.current = null;
|
||||
}
|
||||
|
||||
if (!taskId) return;
|
||||
|
||||
if (deepLinkFetchedRef.current) return;
|
||||
deepLinkFetchedRef.current = true;
|
||||
|
||||
const taskProjectId = projectParam ?? projectId;
|
||||
fetchTaskDetail(taskId, taskProjectId)
|
||||
.then((detail) => {
|
||||
openTaskDetail(detail);
|
||||
|
||||
@@ -3,8 +3,11 @@ import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import express from "express";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { GitHubTrackingStateService } from "../github-tracking-state.js";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
|
||||
type GitHubIssueActionPayload = Record<string, unknown>;
|
||||
type StoreEventApi = {
|
||||
@@ -73,6 +76,11 @@ async function expectNoGithubIssueAction(
|
||||
).rejects.toThrow(timeoutMessage);
|
||||
}
|
||||
|
||||
async function requestDelete(app: express.Express, path: string): Promise<{ status: number; body: any }> {
|
||||
const res = await performRequest(app, "DELETE", path);
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
describe("github tracking delete flow", () => {
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
@@ -157,6 +165,98 @@ describe("github tracking delete flow", () => {
|
||||
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes linked issue when delete receives explicit githubIssueAction=close", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "delete tracked task with explicit close",
|
||||
githubTracking: { enabled: true },
|
||||
});
|
||||
|
||||
await store.linkGithubIssue(task.id, {
|
||||
owner: "octocat",
|
||||
repo: "hello-world",
|
||||
number: 10,
|
||||
url: "https://github.com/octocat/hello-world/issues/10",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const closeAction = waitForGithubIssueAction(
|
||||
store,
|
||||
(payload) => payload.taskId === task.id && payload.action === "close" && payload.outcome === "success",
|
||||
{ timeoutMessage: `Timed out waiting for explicit close action for deleted task ${task.id}` },
|
||||
);
|
||||
|
||||
await store.deleteTask(task.id, { githubIssueAction: "close" });
|
||||
await closeAction;
|
||||
|
||||
expect(mockSetIssueState).toHaveBeenCalledWith("octocat", "hello-world", 10, "closed", "not_planned");
|
||||
});
|
||||
|
||||
it("does not report failed close when task is done then deleted", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "done then deleted task",
|
||||
githubTracking: { enabled: true },
|
||||
});
|
||||
|
||||
await store.linkGithubIssue(task.id, {
|
||||
owner: "octocat",
|
||||
repo: "hello-world",
|
||||
number: 11,
|
||||
url: "https://github.com/octocat/hello-world/issues/11",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await store.moveTask(task.id, "done");
|
||||
mockSetIssueState.mockClear();
|
||||
mockGetIssue.mockResolvedValue({ state: "closed" });
|
||||
|
||||
const skippedCloseAction = waitForGithubIssueAction(
|
||||
store,
|
||||
(payload) => payload.taskId === task.id && payload.action === "close" && payload.outcome === "skipped",
|
||||
{ timeoutMessage: `Timed out waiting for skipped close action for deleted task ${task.id}` },
|
||||
);
|
||||
|
||||
await store.deleteTask(task.id);
|
||||
await skippedCloseAction;
|
||||
|
||||
expect(mockSetIssueState).not.toHaveBeenCalled();
|
||||
await expectNoGithubIssueAction(
|
||||
store,
|
||||
(payload) => payload.taskId === task.id && payload.action === "close" && payload.outcome === "failed",
|
||||
`Unexpected failed close action for done-then-deleted task ${task.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("route delete uses same store instance observed by tracking state service", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "route delete tracked task",
|
||||
githubTracking: { enabled: true },
|
||||
});
|
||||
|
||||
await store.linkGithubIssue(task.id, {
|
||||
owner: "octocat",
|
||||
repo: "hello-world",
|
||||
number: 12,
|
||||
url: "https://github.com/octocat/hello-world/issues/12",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const closeAction = waitForGithubIssueAction(
|
||||
store,
|
||||
(payload) => payload.taskId === task.id && payload.action === "close" && payload.outcome === "success",
|
||||
{ timeoutMessage: `Timed out waiting for route close action for deleted task ${task.id}` },
|
||||
);
|
||||
|
||||
const response = await requestDelete(app, `/api/tasks/${task.id}?githubIssueAction=close`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
await closeAction;
|
||||
expect(mockSetIssueState).toHaveBeenCalledWith("octocat", "hello-world", 12, "closed", "not_planned");
|
||||
});
|
||||
|
||||
it("does not trigger an unhandled rejection when closing linked issue fails on delete", async () => {
|
||||
const task = await store.createTask({
|
||||
description: "delete tracked task with close failure",
|
||||
|
||||
Reference in New Issue
Block a user