From 1b431d7b6211dfe78ef29db3576f175c12e7cc90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 21:57:01 +0900 Subject: [PATCH 1/9] test(frontend): cover LineageDag with tests and stories LineageDag renders the git-branch-style multi-thread lineage graph used by both the post-detail popup and the Ask Agent's multi-lineage answer view (ADR 0120), and had zero test or story coverage despite being a core, non-trivial component. Adds tests for the empty state, multi-group branch rendering, group-heading fallback for missing/UUID groups, click and keyboard node selection, the current-post marker, and label truncation with an accessible full-label fallback. Adds stories for empty, single-branch, multi-branch (with an actual fork), ungrouped, and long-label scenarios. --- frontend/src/LineageDag.stories.tsx | 92 ++++++++++++++++++++++++ frontend/src/LineageDag.test.tsx | 108 ++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 frontend/src/LineageDag.stories.tsx create mode 100644 frontend/src/LineageDag.test.tsx diff --git a/frontend/src/LineageDag.stories.tsx b/frontend/src/LineageDag.stories.tsx new file mode 100644 index 000000000..a0f5101ac --- /dev/null +++ b/frontend/src/LineageDag.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LineageDag } from "./LineageDag"; +import type { LineageGraph } from "./api"; + +const meta = { + title: "Lineage/LineageDag", + component: LineageDag, + args: { + onSelectPost: () => undefined, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// Edge case: no reconstructed lineage yet -- must not render an empty SVG. +export const Empty: Story = { + args: { + graph: { nodes: [], edges: [] }, + }, +}; + +export const SingleBranch: Story = { + args: { + graph: { + nodes: [ + { id: "a1", group: "Northwind Renewal", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }, + { id: "a2", group: "Northwind Renewal", label: "Follow-up call", occurred_at: "2026-01-04T00:00:00Z", is_root: false, is_branch_point: false }, + { id: "a3", group: "Northwind Renewal", label: "Contract signed", occurred_at: "2026-01-10T00:00:00Z", is_root: false, is_branch_point: false }, + ], + edges: [ + { source: "a1", target: "a2", fused_score: 0.86 }, + { source: "a2", target: "a3", fused_score: 0.91 }, + ], + } satisfies LineageGraph, + }, +}; + +// The multi-branch, git-branch-style case the Ask Agent answer view relies on: +// several independent lineage threads rendered as separate figures, plus one +// thread with an actual fork (a branch point with two children). +export const MultipleGitStyleBranches: Story = { + args: { + graph: { + nodes: [ + { id: "a1", group: "Northwind Renewal", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: true }, + { id: "a2", group: "Northwind Renewal", label: "Legal review", occurred_at: "2026-01-04T00:00:00Z", is_root: false, is_branch_point: false }, + { id: "a3", group: "Northwind Renewal", label: "Pricing review", occurred_at: "2026-01-04T00:00:00Z", is_root: false, is_branch_point: false }, + { id: "b1", group: "Acme Onboarding", label: "Welcome call", occurred_at: "2026-02-01T00:00:00Z", is_root: true, is_branch_point: false }, + { id: "b2", group: "Acme Onboarding", label: "Access provisioned", occurred_at: "2026-02-03T00:00:00Z", is_root: false, is_branch_point: false }, + ], + edges: [ + { source: "a1", target: "a2", fused_score: 0.78 }, + { source: "a1", target: "a3", fused_score: 0.64 }, + { source: "b1", target: "b2", fused_score: 0.9 }, + ], + } satisfies LineageGraph, + currentPostId: "a1", + }, +}; + +// Edge case: a node with no explicit group (or a raw UUID group id from a +// partially-processed corpus) must still render, bucketed under "Ungrouped". +export const UngroupedNode: Story = { + args: { + graph: { + nodes: [{ id: "u1", group: "", label: "Standalone note with no thread yet", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + } satisfies LineageGraph, + }, +}; + +// Edge case: a very long post title must truncate in the graph without +// breaking layout, while the full title stays available to screen readers. +export const LongNodeLabel: Story = { + args: { + graph: { + nodes: [ + { + id: "a1", + group: "Northwind Renewal", + label: "Quarterly vendor renewal kickoff call with legal, procurement, and finance stakeholders", + occurred_at: "2026-01-01T00:00:00Z", + is_root: true, + is_branch_point: false, + }, + ], + edges: [], + } satisfies LineageGraph, + }, +}; diff --git a/frontend/src/LineageDag.test.tsx b/frontend/src/LineageDag.test.tsx new file mode 100644 index 000000000..1936b825a --- /dev/null +++ b/frontend/src/LineageDag.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { LineageDag } from "./LineageDag"; +import type { LineageGraph } from "./api"; + +describe("LineageDag", () => { + it("shows an empty-state message instead of an empty graph", () => { + render(); + expect(screen.getByText("No reconstructed lineage yet. Rebuild after seeding posts.")).toBeInTheDocument(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("renders one branch figure per lineage group, git-branch style", () => { + const graph: LineageGraph = { + nodes: [ + { id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }, + { id: "a2", group: "Project Alpha", label: "Follow-up note", occurred_at: "2026-01-02T00:00:00Z", is_root: false, is_branch_point: false }, + { id: "b1", group: "Project Beta", label: "Beta kickoff", occurred_at: "2026-01-03T00:00:00Z", is_root: true, is_branch_point: false }, + ], + edges: [{ source: "a1", target: "a2", fused_score: 0.82 }], + }; + render(); + + expect(screen.getByText("Project Alpha (2 records, 1 lineage edges)")).toBeInTheDocument(); + expect(screen.getByText("Project Beta (1 records, 0 lineage edges)")).toBeInTheDocument(); + expect(screen.getAllByRole("img")).toHaveLength(2); + }); + + it("groups a missing/UUID group id under an Ungrouped heading, sorted last", () => { + const graph: LineageGraph = { + nodes: [ + { id: "u1", group: "3f9c8b1a-1111-2222-3333-444455556666", label: "Loose note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }, + { id: "z1", group: "Zeta Corp", label: "Zeta note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }, + ], + edges: [], + }; + render(); + + const headings = screen.getAllByRole("img").map((img) => img.getAttribute("aria-label")); + expect(headings).toEqual(["Zeta Corp lineage", "Ungrouped lineage"]); + }); + + it("reports the clicked post id", () => { + const onSelectPost = vi.fn(); + const graph: LineageGraph = { + nodes: [{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + }; + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open post: Kickoff note" })); + expect(onSelectPost).toHaveBeenCalledWith("a1"); + }); + + it("also selects a node via Enter and Space for keyboard users", () => { + const onSelectPost = vi.fn(); + const graph: LineageGraph = { + nodes: [{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + }; + render(); + + const node = screen.getByRole("button", { name: "Open post: Kickoff note" }); + fireEvent.keyDown(node, { key: "Enter" }); + fireEvent.keyDown(node, { key: " " }); + expect(onSelectPost).toHaveBeenCalledTimes(2); + }); + + it("does not select on an unrelated key press", () => { + const onSelectPost = vi.fn(); + const graph: LineageGraph = { + nodes: [{ id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + }; + render(); + + fireEvent.keyDown(screen.getByRole("button", { name: "Open post: Kickoff note" }), { key: "Tab" }); + expect(onSelectPost).not.toHaveBeenCalled(); + }); + + it("marks the current post distinctly from the rest", () => { + const graph: LineageGraph = { + nodes: [ + { id: "a1", group: "Project Alpha", label: "Kickoff note", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }, + { id: "a2", group: "Project Alpha", label: "Follow-up note", occurred_at: "2026-01-02T00:00:00Z", is_root: false, is_branch_point: false }, + ], + edges: [{ source: "a1", target: "a2", fused_score: 0.5 }], + }; + render(); + + expect(screen.getByRole("button", { name: "Open post: Follow-up note" })).toHaveAttribute("aria-current", "true"); + expect(screen.getByRole("button", { name: "Open post: Kickoff note" })).not.toHaveAttribute("aria-current"); + }); + + it("truncates a very long node label instead of overflowing the graph", () => { + const longLabel = "A".repeat(60); + const graph: LineageGraph = { + nodes: [{ id: "a1", group: "Project Alpha", label: longLabel, occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], + edges: [], + }; + render(); + + expect(screen.getByText(`${"A".repeat(33)}…`)).toBeInTheDocument(); + expect(screen.queryByText(longLabel)).not.toBeInTheDocument(); + // The full label is still reachable via the accessible name for screen readers. + expect(screen.getByRole("button", { name: `Open post: ${longLabel}` })).toBeInTheDocument(); + }); +}); From 590c6c3185ba1483f0e610c982308f3301965c7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 22:24:36 +0900 Subject: [PATCH 2/9] fix(frontend): use OIDC return-url helpers on the login button Same shared-ancestor bug as #418/#415/#426/#427/#429/#431/#434/#435/#436/#437/#438: the login button built an unsanitized returnUrl inline instead of returnUrlFromLocation()/rememberOidcReturnUrl(), and removed the unreachable login-screen AdminPanel render. --- frontend/src/App.test.tsx | 3 +++ frontend/src/App.tsx | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..70eb27590 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -41,6 +41,9 @@ describe("App, unauthenticated", () => { state: expect.objectContaining({ returnUrl: expect.stringMatching(/^\//) }), }), ); + // Persisted as a fallback in case the OIDC state round-trip is dropped + // (see oidcReturnUrl.ts's restoreOidcReturnUrl, consumed in main.tsx). + expect(window.sessionStorage.getItem("lineageweave.oidc.returnUrl")).toMatch(/^\//); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..1b5b351ab 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4610,7 +4610,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
- {destination === "admin" ? : null}