* fix(frontend): populate session.pullRequest from /pr endpoint (#251) WorkspaceSession.pullRequest was declared but never populated, so every Boolean(session.pullRequest) check was dead: the Summary tab gated its PR fetch on it (never ran), and the Board card and /prs page read it directly (always empty). Hydrate the field centrally in fetchWorkspaces — the single place that builds session objects for the workspace, board, PR page, and sidebar — by fetching GET /sessions/{sessionId}/pr per non-terminated session in parallel and attaching {number, state}. A per-session fetch error degrades to "no PR" rather than failing the whole workspace query; terminated sessions are skipped. GET /sessions/{sessionId}/pr stays the single source of truth, so no new backend endpoint and no changes to the consuming components. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: format with prettier [skip ci] * test(frontend): verify PR hydration end-to-end for a normal project (#251) Drives the real useWorkspaceQuery + real SessionsBoard / PullRequestsPage for an ordinary project (from /api/v1/projects) whose session has an open PR, mocking only the HTTP client and router. Confirms PR facts fetched from /sessions/{id}/pr flow through the shared workspace cache into both the Board card ("PR #278 · open") and the PR page row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(frontend): move PR hydration integration test into __tests__/integration Cross-component integration test belongs in a dedicated folder, separate from the co-located unit tests. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
3986d488c8
commit
6ac65d2cd3
|
|
@ -0,0 +1,100 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Drives the real useWorkspaceQuery + real Board / PR-page consumers end to end
|
||||
// for a normal project, mocking only the HTTP client and the router. Proves the
|
||||
// #251 fix: PR facts fetched from /pr flow through the shared workspace cache
|
||||
// into every consumer.
|
||||
const { getMock, navigateMock } = vi.hoisted(() => ({ getMock: vi.fn(), navigateMock: vi.fn() }));
|
||||
|
||||
vi.mock("../../lib/api-client", () => ({
|
||||
apiClient: { GET: getMock, POST: vi.fn() },
|
||||
apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||
return { ...actual, useNavigate: () => navigateMock };
|
||||
});
|
||||
|
||||
import { SessionsBoard } from "../../components/SessionsBoard";
|
||||
import { PullRequestsPage } from "../../components/PullRequestsPage";
|
||||
|
||||
// One ordinary project with one worker session that has an open PR (#278).
|
||||
function respondWithProjectAndPR() {
|
||||
getMock.mockImplementation(async (url: string, options?: { params?: { path?: { sessionId?: string } } }) => {
|
||||
if (url === "/api/v1/projects") {
|
||||
return { data: { projects: [{ id: "proj-1", name: "my-app", path: "/repo/my-app" }] }, error: undefined };
|
||||
}
|
||||
if (url === "/api/v1/sessions") {
|
||||
return {
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
id: "sess-1",
|
||||
projectId: "proj-1",
|
||||
displayName: "fix the bug",
|
||||
harness: "claude-code",
|
||||
status: "pr_open",
|
||||
isTerminated: false,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
if (url === "/api/v1/sessions/{sessionId}/pr") {
|
||||
expect(options?.params?.path?.sessionId).toBe("sess-1");
|
||||
return {
|
||||
data: {
|
||||
sessionId: "sess-1",
|
||||
prs: [
|
||||
{
|
||||
number: 278,
|
||||
state: "open",
|
||||
url: "https://github.com/aoagents/ReverbCode/pull/278",
|
||||
ci: "passing",
|
||||
review: "approved",
|
||||
mergeability: "clean",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected GET ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
function renderWithProviders(node: ReactNode) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getMock.mockReset();
|
||||
navigateMock.mockReset();
|
||||
respondWithProjectAndPR();
|
||||
});
|
||||
|
||||
describe("PR hydration for a normal project (#251)", () => {
|
||||
it("renders the PR on the Board card instead of 'no PR yet'", async () => {
|
||||
renderWithProviders(<SessionsBoard />);
|
||||
|
||||
expect(await screen.findByText("PR #278 · open")).toBeInTheDocument();
|
||||
expect(screen.queryByText("no PR yet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the session on the PR page instead of being empty", async () => {
|
||||
renderWithProviders(<PullRequestsPage />);
|
||||
|
||||
expect(await screen.findByText("#278")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No open pull requests.")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("fix the bug")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -20,10 +20,15 @@ function wrapper({ children }: { children: ReactNode }) {
|
|||
function respondWith(payload: {
|
||||
projects?: { data?: unknown; error?: unknown };
|
||||
sessions?: { data?: unknown; error?: unknown };
|
||||
prsBySession?: Record<string, { data?: unknown; error?: unknown }>;
|
||||
}) {
|
||||
getMock.mockImplementation(async (url: string) => {
|
||||
getMock.mockImplementation(async (url: string, options?: { params?: { path?: { sessionId?: string } } }) => {
|
||||
if (url === "/api/v1/projects") return payload.projects ?? { data: { projects: [] }, error: undefined };
|
||||
if (url === "/api/v1/sessions") return payload.sessions ?? { data: { sessions: [] }, error: undefined };
|
||||
if (url === "/api/v1/sessions/{sessionId}/pr") {
|
||||
const sessionId = options?.params?.path?.sessionId ?? "";
|
||||
return payload.prsBySession?.[sessionId] ?? { data: { sessionId, prs: [] }, error: undefined };
|
||||
}
|
||||
throw new Error(`unexpected GET ${url}`);
|
||||
});
|
||||
}
|
||||
|
|
@ -91,6 +96,113 @@ describe("useWorkspaceQuery", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("hydrates each session's pullRequest from the /pr endpoint (issue #251)", async () => {
|
||||
respondWith({
|
||||
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
|
||||
sessions: {
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
id: "sess-1",
|
||||
projectId: "proj-1",
|
||||
status: "pr_open",
|
||||
isTerminated: false,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
{
|
||||
id: "sess-2",
|
||||
projectId: "proj-1",
|
||||
status: "working",
|
||||
isTerminated: false,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
},
|
||||
prsBySession: {
|
||||
"sess-1": {
|
||||
data: {
|
||||
sessionId: "sess-1",
|
||||
prs: [
|
||||
{
|
||||
number: 278,
|
||||
state: "open",
|
||||
url: "u",
|
||||
ci: "passing",
|
||||
review: "approved",
|
||||
mergeability: "clean",
|
||||
reviewComments: false,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const sessions = result.current.data?.[0].sessions ?? [];
|
||||
expect(sessions[0].pullRequest).toEqual({ number: 278, state: "open" });
|
||||
// No PR for the endpoint's empty response → undefined, so the empty states render.
|
||||
expect(sessions[1].pullRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats a per-session PR fetch error as no PR without failing the query", async () => {
|
||||
respondWith({
|
||||
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
|
||||
sessions: {
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
id: "sess-1",
|
||||
projectId: "proj-1",
|
||||
status: "pr_open",
|
||||
isTerminated: false,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
},
|
||||
prsBySession: { "sess-1": { data: undefined, error: new Error("pr backend down") } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips the PR fetch for terminated sessions", async () => {
|
||||
respondWith({
|
||||
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
|
||||
sessions: {
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
id: "sess-1",
|
||||
projectId: "proj-1",
|
||||
status: "merged",
|
||||
isTerminated: true,
|
||||
updatedAt: "2026-06-10T16:15:04Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(getMock).not.toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/pr", expect.anything());
|
||||
expect(result.current.data?.[0].sessions[0].pullRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks terminated sessions regardless of their reported status", async () => {
|
||||
respondWith({
|
||||
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "../lib/api-client";
|
||||
import { mockWorkspaces } from "../lib/mock-data";
|
||||
import { toAgentProvider, toSessionStatus, type WorkspaceSummary } from "../types/workspace";
|
||||
import { toAgentProvider, toSessionStatus, type WorkspaceSession, type WorkspaceSummary } from "../types/workspace";
|
||||
|
||||
export const workspaceQueryKey = ["workspaces"] as const;
|
||||
const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1";
|
||||
|
||||
// GET /sessions/{sessionId}/pr is the single source of truth for PR facts — no
|
||||
// PR data rides on the session list — so we hydrate each session's lightweight
|
||||
// {number, state} here, centrally, for every consumer (Summary, Board, PR page,
|
||||
// Sidebar) that reads this query's cache. A per-session failure is treated as
|
||||
// "no PR" rather than failing the whole workspace query.
|
||||
async function fetchSessionPR(sessionId: string): Promise<WorkspaceSession["pullRequest"]> {
|
||||
const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/pr", {
|
||||
params: { path: { sessionId } },
|
||||
});
|
||||
if (error) return undefined;
|
||||
const pr = data?.prs?.[0];
|
||||
return pr ? { number: pr.number, state: pr.state } : undefined;
|
||||
}
|
||||
|
||||
async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
|
||||
if (usePreviewData) {
|
||||
return mockWorkspaces;
|
||||
|
|
@ -16,11 +30,21 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
|
|||
|
||||
if (projectsError || sessionsError) throw projectsError ?? sessionsError;
|
||||
|
||||
const sessions = sessionsData?.sessions ?? [];
|
||||
// Skip terminated sessions — their PRs are archived and the call is wasted.
|
||||
const prBySession = new Map(
|
||||
await Promise.all(
|
||||
sessions
|
||||
.filter((session) => !session.isTerminated)
|
||||
.map(async (session) => [session.id, await fetchSessionPR(session.id)] as const),
|
||||
),
|
||||
);
|
||||
|
||||
return (projectsData?.projects ?? []).map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
sessions: (sessionsData?.sessions ?? [])
|
||||
sessions: sessions
|
||||
.filter((session) => session.projectId === project.id)
|
||||
.map((session) => ({
|
||||
id: session.id,
|
||||
|
|
@ -34,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
|
|||
status: toSessionStatus(session.status, session.isTerminated),
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
pullRequest: prBySession.get(session.id),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue