Files
fusion/packages/dashboard/app/components/__tests__/TaskCard.test.tsx
gsxdsm 642a4fa264 consolidate/u12 — U12 consolidation: 4 live defects, the AST ratchet fail-closed, and the moves.ts flag scoped (#2647)
One branch, one PR, per the consolidation directive. Contents
file-by-file below.

**Supersedes #2625** (its overlapping conversions landed via U11's
#2624/#2626/#2636; only the parts nobody else did are folded here).
**#2630 and #2639 stay open** — both green with zero threads, per rule
3.

## Four live defects, each measured

**1. Every planning card renders an actions menu.**
`TaskContextMenu.tsx` still had `shouldShowActionsMenu: task.column !==
"triage"` on main *after* the rest of that file was converted. Since
#2515 removed the id, the condition is TRUE for every card, so the
suppression stopped applying anywhere — including on cards whose menu is
empty, the orphaned click target the Surface Enumeration rule exists to
catch.

Found **twice independently**: by reading the guard, and again by the
invariance test below, which failed on main with `shouldShowActionsMenu`
true on one lineage and false on another. That is the argument for an
invariance property over per-site conversion — the file had already been
converted "2 → 1" and the survivor was the live one.

**2. Worktree upcoming-work list empty on renamed boards.**
`groupByWorktree` filtered `t.column === "todo"`. On the default board
the id and the role coincide so every existing test passed; renamed, it
matched nothing and a whole panel read as idle.

**3. Hold-lane FIFO ordering lost on renamed boards.**
`sortTasksForDisplayColumn` gated priority-then-FIFO on `column ===
"todo"`, degrading to the generic id-ordered sort elsewhere. Cards
simply appear in the wrong order, silently.

**4. The AST ratchet still failed open** — fourth time in that file,
third found by review. `receiverName` understood only one-level property
access and bare identifiers, so `task["column"]`, `metadataColumn(entry,
"to")`, ternaries, `(task!.column)` and backtick literals were dropped.
**Measured on main: `in-progress` 196 → 197, `in-review` 211 → 213** —
three real guards nobody counted, including `metadataColumn(entry, "to")
=== "in-review"` in `reliability-metrics.ts`. Now walks wrappers,
resolves calls to the callee name, and emits a `<SyntaxKind>`
**sentinel** for anything unnameable: counted *and* trips the
classification guard, so a human judges it instead of it vanishing.

## Per-file guard counts

| file | before | after |
|---|---:|---:|
| `app/components/TaskContextMenu.tsx` | 1 | **0** |
| `app/utils/worktreeGrouping.ts` | 1 | **0** |
| `app/components/taskSorting.ts` | 1 | **0** |

The other dashboard files I had converted reached 0 via U11's PRs; where
our work overlapped I took theirs during the rebase, including two
places where theirs was **stronger** than mine — they deleted Column's
unreachable quick-create arm outright (with fixtures migrated) where I
had converted it, and they verified the same `isPreExecutionHoldColumn`
degraded-set asymmetry I did, independently.

## Flip precondition: the moves.ts flag is scoped, not flipped

`move-target-declared-census.test.ts` answers precondition 2 with
measurement. 41 engine `moveTask` calls have literal targets — `todo`
27, `in-progress` 7, `done` 6, `archived` 1 — and **all four are
declared by the default lineage**, so the default board is not the
exposure. `triage` appears only in a comment noting `replan-target.ts`
used to hardcode it. My own grep had said `todo=29`; the AST says 27,
because grep counts comments.

The exposure is **custom** lineages: 20 of the 41 carry no
`recoveryRehome` and would reject with unknown-column post-flip; 21 are
exempt via the #1411 carve-out, which makes that carve-out load-bearing.

I did not flip the flag. It is six seams, not the `789`/`837` pair every
summary including mine described, and seam 2 turns on *new refusals*
rather than swapping equivalent implementations — a green suite says
nothing about that. #2639 pins the blast radius.

## Tests

- `column-role-id-invariance.test.tsx` — hold traits fixed, vary only
the column id across MERGED / LEGACY / RENAMED; every decision must
agree. Drives the real consumers, so a component keeping an inline
comparison fails it. Includes a unanimous-and-**false** case so it can't
be satisfied by a predicate hardwired to true. **This is the test that
caught defect 1 on main.**
- `worktreeGrouping.test.ts` — includes two cards both in a column named
`staging`, one hold and one not, asserting opposite answers. That
assertion is impossible under a board-wide column-id set, which is why
hold resolution is keyed per task via `getEffectiveTaskWorkflowId`
(#2625 review).
- `taskSorting.test.ts` — discriminates on the **tiebreak**, not
priority: both branches sort by priority, so my first version passed for
the wrong reason. Equal-priority cards whose `createdAt` order disagrees
with their id order.
- `no-hardcoded-lifecycle-columns.test.ts` — 16 detector cases: 11
shapes counted, 4 legitimate ignored, one asserting the sentinel path.

Revert checks, all run: menu suppression → diff names the field;
worktree → `expected [] to include 'FN-50'`; sort → `FN-2, FN-9` instead
of `FN-9, FN-2`; ratchet → the 3 recovered guards disappear.

## One site that should never be converted

`MissionControlPanel.tsx:46` — `{ id: "triage", match: (c) => c ===
"triage" || c === "signal" || c === "backlog" }` is a deliberate
name-similarity heuristic for the SDLC funnel; it matches synonyms and
folds unknown columns into an "other" bucket so custom columns still
contribute. Converting it changes what the funnel displays. Like the
`live-agent-count` fallbacks, it belongs in a documented floor — **the
ratchet's target is that floor, not zero.**

`DocumentsView.tsx:73` is convertible but the file has no column flags
at all, so a real fix means plumbing board-workflow metadata into a view
that doesn't fetch it — its own unit of work.

## Verification

`pnpm lint` clean. `pnpm test:gate` green (10 / 482 / 71). `tsc -p
packages/dashboard/tsconfig.app.json` and `packages/core/tsconfig.json`
clean. Core ratchet + seam suites 24/24. Dashboard target suites 37/38 —
the one failure is the pre-existing `"Back to In Progress"` label
casing, confirmed identical on the base.

---

## Added after the initial push

**5. `TaskCard` lost inline editing on renamed boards; `TaskDetailModal`
kept it.** Still live on main: the modal resolved field editability from
traits in U10/R8, the card used a hardcoded `{triage, todo}` set with
**no trait path at all** — even though `taskColumnFlags` was already in
scope. On a renamed board the title was editable in the modal and the
pencil was missing from the card. Body moved unchanged into
`isFieldEditableColumnRole` so the two surfaces cannot drift again.

The veto traits are the substance: a column can legally carry `hold`
**and** a WIP or review trait, and a plain `intake || hold` check would
let an operator rewrite a description while a session executes against
it.

Coverage gap **measured, not assumed**: mutating `canEdit` back to the
hardcoded set left `TaskCard*` at the same failure count as the
unmutated run — nothing caught it. The four render cases assert the real
`aria-label`; that mutation now fails with `Unable to find an accessible
element ... name 'Edit task'`.

**6. The ratchet's target is a documented FLOOR, not zero** — and this
changes the completion bar.

Zero is not reachable, and chasing it means breaking working code. Two
categories are permanent, now protected as positive assertions so a
future sweep cannot "finish the job" by deleting them:

- `MissionControlPanel.tsx`'s `FUNNEL_STAGES` is a deliberate
**name-similarity** heuristic — it matches `signal`, `backlog`, `to-do`,
`ready`, `shipped` and folds unrecognised columns into an "other" bucket
so a custom board still contributes counts. It is not asking whether a
column has the intake trait; it buckets arbitrary column *names* for
display. Asserted on the **synonym list**, because the synonyms are what
prove it is name matching — if they disappear the site has changed
character and the exemption stops applying.
- `live-agent-count.ts`'s no-flags arm is reachable (a remote store is
deliberately given an empty flag map; a card in an undeclared column has
no flags at all) and deleting the literal makes such a card match **no**
arm, so the queued total silently under-reports a stranded card.

A count with an undocumented floor invites someone to drive it to zero.

**Not done, and why:** `DocumentsView.tsx:73` is convertible but that
file has no column flags anywhere, so a real fix means plumbing
board-workflow metadata into a view that does not fetch it — its own
unit of work, not something to smuggle into a conversion.

**Re-verified after these commits:** `pnpm lint` clean, `pnpm test:gate`
green (10 / 482 / 71), `tsc` clean on core and `tsconfig.app.json`, core
ratchet suite 26/26, `columnRoles` 10/10, `TaskCard.test.tsx` 384/386
(the 2 are pre-existing CSS assertions). `TaskDetail*` is 130 failed /
551 passed **both with and without** this change — verified by stashing,
so pre-existing and unrelated.

---

## Flag resolution: preconditions 1 and 2 are now DISCHARGED.
Precondition 3 is blocked, and by evidence.

**Precondition 1 — the side-effect equivalence proof — done.**
`moves-flag-equivalence.test.ts` runs the same journey under both flag
states against live PG and diffs the persisted row. **Result:
identical** — whole-row equality across 128 fields plus an equal timing
shape, over `todo → in-progress → in-review → todo → in-progress`.

That test was **wrong twice** before it meant anything, and both times
it was passing:

1. **It proved nothing.** `experimentalFeatures` is **global-only**, and
`moves.ts` reads `getSettingsFast()`, which filters global-only keys out
of the project layer. My `updateSettings` write was silently discarded,
`useWorkflow` was false in *both* runs, and the "proof" compared the
legacy path against itself. Found by stamping the flag-ON branch and
observing the test still passed. Now written via `updateGlobalSettings`,
and the helper **asserts the flag took effect** before the journey runs.
2. **The journey was forward-only**, so it never reached the reopen
hook's field resets (`status`, `error`, `blockedBy`, pause clearing) — a
mutation there passed. Extended with a backward move and a re-entry.

Mutation-verified after both fixes: stamping seam 3, and diverging the
reopen hook, each fail the comparison.

**Precondition 2 — done, and its answer is a blocker.** The census says
the default board is safe: all 41 literal engine move targets are
declared by the default lineage. But **20 of those 41 carry no
`recoveryRehome`**, so on a custom lineage that does not declare `todo`
/ `in-progress` / `done`, seam 2 would start rejecting them with
unknown-column. That is a user-facing break on custom boards, not a
theoretical one, and it is not fixed by the equivalence proof — seam 2
adds *new refusals* rather than swapping implementations.

**So the flip is one step away, and the step is not mine to take
alone:** those 20 call sites need to resolve their target from the
task's workflow (or justify `recoveryRehome`), and they live across
engine lanes in `moves.ts` caller territory — U2b/MAIN. Flipping before
that trades a dormant flag for broken custom boards.

What remains for precondition 3 once those land: flip both readers
**atomically** (`moves.ts` + `workflow-task-create-ops.ts`, since the
latter computes the preflight the former consumes), delete the flag-OFF
branch with its guards, and drop the settings key.

---

## CORRECTION: seam 2 is not a blocker. My earlier claim was wrong.

I stated in #2639 and above that "with the flag off there is **no**
target-column validation on the move path", so flipping would introduce
new refusals. **That is not what happens.** Reproduced against live PG:
the identical custom-lineage move rejects with the flag **OFF** as well
—

```
Error: Invalid transition: 'backlog' -> 'todo'. Valid targets: building
```

Transition validation is already in force on the flag-OFF path. So for
the shape in question — an engine move to a column the task's own
workflow does not declare — **the move already fails today**, and seam 2
introduces no new break for it. The 20 census sites lacking
`recoveryRehome` are broken on a custom lineage *now*, not broken by the
flip.

I found this because the discriminator I added to prove "the flag is the
cause" failed. Had I written the test to my assumption it would have
passed and the false claim would have shipped — the same way the
equivalence test passed while proving nothing until I tried to make it
fail.

**Revised precondition status:**

| precondition | status |
|---|---|
| 1 — side-effect equivalence | **discharged** — identical rows,
mutation-verified both directions |
| 2 — seam-2 exposure census | **discharged, and it is not a blocker** —
the rejection predates the flag |
| 3 — flip both readers atomically, delete the flag-OFF branch, drop the
settings key | **the remaining work** |

So the flip is no longer gated on fixing 20 engine call sites. What it
is still gated on is precondition 3 being done atomically across
`moves.ts` and `workflow-task-create-ops.ts` (the latter computes the
preflight the former consumes), which is `moves.ts` caller territory.

Three cases now cover seam 2: the flag-ON rejection, the flag-OFF
rejection (asserting the error *message*, so a change in which guard
rejects stays visible rather than reading as agreement), and the #1411
`recoveryRehome` carve-out succeeding — pinning why that carve-out is
load-bearing and must not be tidied away.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 00:13:58 -07:00

8247 lines
295 KiB
TypeScript

import React from "react";
import { afterEach, describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
import { TaskCard, formatElapsedDurationDone, __test_areTaskCardPropsEqual } from "../TaskCard";
import { CostBadgeProvider } from "../../context/CostBadgeContext";
// Pre-existing gap (unrelated to FN-7676): TaskCard unconditionally renders
// RuntimeFallbackBadge, which calls the shared useToast() hook directly (not
// via the addToast prop). This file renders <TaskCard> outside a
// ToastProvider, so mock the hook the same way sibling suites
// (PlanningModeModal.*.test.tsx) already do to avoid a widespread
// "useToast must be used within ToastProvider" failure across this file.
vi.mock("../../hooks/useToast", () => ({
useOptionalToast: () => null,
useToast: () => ({
addToast: vi.fn(),
removeToast: vi.fn(),
toasts: [],
}),
}));
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
import { useOverlayDismiss } from "../../hooks/useOverlayDismiss";
import type { ConfirmOptions } from "../../hooks/useConfirm";
import { TASK_PRIORITIES, type Task, type TaskPriority } from "@fusion/core";
import { getPriorityColorVar, getPriorityLabel } from "../../utils/priorityIndicator";
// Mock lucide-react to avoid SVG rendering issues in test env
vi.mock("lucide-react", () => ({
Link: () => null,
GitBranch: () => null,
Gitlab: () => null,
Clock: () => null,
Pencil: () => null,
Layers: () => null,
ChevronDown: () => null,
Folder: () => null,
GitPullRequest: () => null,
CircleDot: () => null,
Target: () => null,
Bot: () => null,
Trash2: () => null,
RotateCw: () => null,
Zap: () => <svg data-testid="icon-zap" />,
AlertTriangle: () => null,
ArrowDown: ({ style, ...props }: React.SVGProps<SVGSVGElement>) => <svg data-testid="priority-icon-low" className="lucide-arrow-down" style={style} {...props} />,
Flag: ({ style, ...props }: React.SVGProps<SVGSVGElement>) => <svg data-testid="priority-icon-normal" className="lucide-flag" style={style} {...props} />,
ArrowUp: ({ style, ...props }: React.SVGProps<SVGSVGElement>) => <svg data-testid="priority-icon-high" className="lucide-arrow-up" style={style} {...props} />,
TriangleAlert: ({ style, ...props }: React.SVGProps<SVGSVGElement>) => <svg data-testid="priority-icon-urgent" className="lucide-triangle-alert" style={style} {...props} />,
ArrowUpRight: () => null,
// FNXC:RefinementTitle 2026-07-26-20:10: icon on the "Refines <id>" provenance chip.
Sparkles: () => null,
// FN-7592: the overseer badge now renders an icon child instead of a text label,
// so tests must see a real SVG (like Zap) rather than a no-op render.
Eye: () => <svg data-testid="icon-eye" />,
// FNXC:TaskCardMenu 2026-07-10-12:00: visible ⋯ card-actions button icon.
MoreHorizontal: () => <svg data-testid="icon-more-horizontal" />,
}));
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
}));
vi.mock("../PrCreateModal", () => ({
PrCreateModal: ({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (prInfo: any) => void }) => (
<div data-testid="pr-create-modal" data-open={open ? "true" : "false"}>
<button type="button" onClick={() => onClose()}>close-pr-modal</button>
<button
type="button"
onClick={() => onCreated({
number: 42,
url: "https://github.com/owner/repo/pull/42",
status: "open",
title: "Created PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
})}
>
create-pr-modal
</button>
</div>
),
}));
const useTaskDiffStatsMock = vi.fn(() => ({ stats: null, loading: false }));
vi.mock("../../hooks/useTaskDiffStats", () => ({
useTaskDiffStats: (...args: any[]) => useTaskDiffStatsMock(...args),
}));
const badgeUpdatesMock = new Map<string, any>();
const subscribeToBadgeMock = vi.fn();
const unsubscribeFromBadgeMock = vi.fn();
vi.mock("../../hooks/useBadgeWebSocket", () => ({
useBadgeWebSocket: () => ({
badgeUpdates: badgeUpdatesMock,
isConnected: true,
subscribeToBadge: subscribeToBadgeMock,
unsubscribeFromBadge: unsubscribeFromBadgeMock,
}),
}));
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
getFreshBatchData: vi.fn(() => null),
}));
// Mock the api module
vi.mock("../../api", () => ({
addressPrFeedback: vi.fn(),
fetchTaskDetail: vi.fn(),
uploadAttachment: vi.fn(),
fetchMission: vi.fn(),
fetchAgent: vi.fn(),
fetchAgents: vi.fn(),
rebuildTaskSpec: vi.fn(),
refreshPrStatus: vi.fn(),
// FNXC:PlannerOversight 2026-07-04-13:00: tests that pass a `workflowBadge`
// prop trigger the FN-7516 workflow-effective-oversight fetch effect; mock
// it so those tests don't hit an unmocked API export. Resolves an empty
// effective map (no workflow-level override) by default.
fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }),
}));
const mockConfirm = vi.fn<(options: ConfirmOptions) => Promise<boolean>>();
const mockConfirmWithChoice = vi.fn<(options: ConfirmOptions) => Promise<"primary" | "tertiary" | "cancel">>();
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm, confirmWithChoice: mockConfirmWithChoice }),
}));
import { addressPrFeedback, uploadAttachment, fetchMission, fetchAgent, fetchAgents, refreshPrStatus } from "../../api";
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
import { writeCache, SWR_CACHE_KEYS } from "../../utils/swrCache";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-001",
title: "Test task",
column: "in-progress",
status: undefined as any,
steps: [],
dependencies: [],
description: "",
...overrides,
} as Task;
}
const noop = () => {};
function seedAgentsCache(projectId: string, agents: Array<{ id: string; name: string; role?: string; state?: string }>) {
writeCache(
`${SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX}${projectId}`,
agents.map((agent) => ({
role: "executor",
state: "active",
...agent,
})),
{ maxBytes: 500_000 },
);
}
function mountCssForBadgeTests() {
const style = document.createElement("style");
style.textContent = loadAllAppCss();
document.head.appendChild(style);
document.documentElement.style.setProperty("--status-error-bg", "rgb(255, 230, 230)");
document.documentElement.style.setProperty("--color-error-dark", "rgb(200, 0, 0)");
document.documentElement.style.setProperty("--status-in-review-bg", "rgb(230, 255, 230)");
document.documentElement.style.setProperty("--in-review", "rgb(0, 160, 0)");
return () => {
style.remove();
document.documentElement.style.removeProperty("--status-error-bg");
document.documentElement.style.removeProperty("--color-error-dark");
document.documentElement.style.removeProperty("--status-in-review-bg");
document.documentElement.style.removeProperty("--in-review");
};
}
function expectTimerInFooterRight(container: HTMLElement) {
const timer = container.querySelector(".card-time-indicator");
const footerRow = container.querySelector(".card-footer-row");
const rightCluster = container.querySelector(".card-footer-row-right");
expect(timer).not.toBeNull();
expect(footerRow?.contains(timer)).toBe(true);
expect(timer?.closest(".card-footer-row-right")).toBe(rightCluster);
expect(timer?.closest(".card-meta-badges")).toBeNull();
}
function mockBoardContextMenuGeometry() {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getMockRect(this: HTMLElement) {
if (this.classList.contains("card")) {
return { x: 520, y: 340, left: 520, top: 340, right: 760, bottom: 520, width: 240, height: 180, toJSON: () => ({}) } as DOMRect;
}
if (this.classList.contains("task-card-context-menu-popover")) {
const left = Number.parseFloat(this.style.left || "0");
const top = Number.parseFloat(this.style.top || "0");
return { x: left, y: top, left, top, right: left + 180, bottom: top + 220, width: 180, height: 220, toJSON: () => ({}) } as DOMRect;
}
return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect;
});
return () => {
rectSpy.mockRestore();
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
};
}
function expectBoardContextMenuPortaled() {
const menu = screen.getByRole("menu");
const popover = menu.closest(".task-card-context-menu-popover") as HTMLElement | null;
expect(popover).not.toBeNull();
expect(popover?.parentElement).toBe(document.body);
expect(popover?.closest(".card")).toBeNull();
expect(popover?.closest(".column")).toBeNull();
expect(popover?.closest(".column-body")).toBeNull();
expect(popover?.style.left).not.toBe("");
expect(popover?.style.top).not.toBe("");
return popover!;
}
const highFanout = {
totalCount: 7,
activeTodoCount: 3,
dependentIds: ["FN-002", "FN-003"],
dependencyDependentIds: [],
overlapBlockedDependentIds: ["FN-002", "FN-003"],
overlapBlockedActiveCount: 3,
overlapBlockedTodoCount: 3,
staleBlockedByDependentIds: [],
isHighFanout: true,
} as const;
afterEach(() => {
vi.useRealTimers();
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });
badgeUpdatesMock.clear();
subscribeToBadgeMock.mockReset();
unsubscribeFromBadgeMock.mockReset();
mockConfirm.mockReset();
mockConfirmWithChoice.mockReset();
vi.mocked(addressPrFeedback).mockReset();
vi.mocked(refreshPrStatus).mockReset();
});
describe("TaskCard", () => {
it("renders creation on all cards and terminal completion from canonical lifecycle timestamps", () => {
const createdAt = new Date().toISOString();
const archivedAt = "2026-07-20T09:00:00.000Z";
const { rerender } = render(
<TaskCard task={makeTask({ createdAt })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByTestId("card-lifecycle-dates")).toHaveTextContent("Created");
expect(screen.queryByText(/Completed/)).not.toBeInTheDocument();
rerender(
<TaskCard task={makeTask({ column: "archived", createdAt, archivedAt, columnMovedAt: "2026-07-19T09:00:00.000Z" })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByTestId("card-lifecycle-dates")).toHaveTextContent("Completed");
expect(screen.getByTestId("card-lifecycle-dates").querySelectorAll("time")).toHaveLength(2);
});
it("renders GitLab tracking badges for linked and stale items without dropping GitHub badges", () => {
const gitlabItem = {
kind: "merge_request" as const,
url: "https://gitlab.com/acme/app/-/merge_requests/5",
instanceUrl: "https://gitlab.com",
host: "gitlab.com",
iid: 5,
projectPath: "acme/app",
title: "MR title",
state: "opened",
createdAt: "2026-07-02T00:00:00.000Z",
};
render(
<TaskCard
task={makeTask({
gitlabTracking: { item: gitlabItem },
issueInfo: { url: "https://github.com/runfusion/fusion/issues/1", number: 1, state: "open", title: "GitHub issue" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByTestId("card-gitlab-badge")).toHaveAccessibleName("GitLab MR !5: MR title");
expect(screen.getByRole("link", { name: /GitLab MR !5/ })).toHaveAttribute("href", gitlabItem.url);
expect(screen.getByRole("link", { name: "#1" })).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/1");
});
it("updates memoized card equality when GitLab tracking changes", () => {
const base = { task: makeTask({ gitlabTracking: undefined }) };
const withGitLab = {
task: makeTask({
gitlabTracking: {
item: {
kind: "project_issue",
url: "https://gitlab.com/acme/app/-/issues/42",
instanceUrl: "https://gitlab.com",
host: "gitlab.com",
iid: 42,
createdAt: "2026-07-02T00:00:00.000Z",
},
},
}),
};
expect(__test_areTaskCardPropsEqual(base as any, withGitLab as any)).toBe(false);
});
it("repaints the memoized card when plannerOverseerState changes, and renders nothing when absent", () => {
const idleTask = makeTask({ plannerOversightLevel: "autonomous", plannerOverseerState: undefined });
const watchingTask = makeTask({
plannerOversightLevel: "autonomous",
plannerOverseerState: {
state: "watching",
oversightLevel: "autonomous",
watchedStage: "executor",
signal: "progressing",
attemptCount: 0,
attemptLimit: 3,
pendingConfirmation: false,
observedAt: 1700000000000,
},
});
expect(
__test_areTaskCardPropsEqual({ task: idleTask } as any, { task: watchingTask } as any),
).toBe(false);
expect(
__test_areTaskCardPropsEqual({ task: watchingTask } as any, { task: watchingTask } as any),
).toBe(true);
const { rerender } = render(<TaskCard task={idleTask} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByTestId("planner-overseer-state-badge")).not.toBeInTheDocument();
rerender(<TaskCard task={watchingTask} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByTestId("planner-overseer-state-badge")).toBeInTheDocument();
});
it.each(["in-progress", "in-review"] as const)("hides the stale non-off overseer snapshot and header wrapper when a task override resolves oversight off in %s", (column) => {
const staleSnapshotTask = makeTask({
column,
plannerOversightLevel: "off",
plannerOverseerState: {
state: "watching",
oversightLevel: "autonomous",
watchedStage: column === "in-review" ? "reviewer" : "executor",
signal: "progressing",
attemptCount: 0,
attemptLimit: 3,
pendingConfirmation: false,
observedAt: 1700000000000,
},
});
render(<TaskCard task={staleSnapshotTask} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByTestId("planner-overseer-state-badge")).not.toBeInTheDocument();
expect(screen.queryByTestId("card-header-badges")).not.toBeInTheDocument();
});
it("does not render an overseer badge for a stale non-idle oversight-off snapshot", () => {
const staleOffTask = makeTask({
column: "in-review",
plannerOverseerState: {
state: "watching",
oversightLevel: "off",
watchedStage: "reviewer",
signal: "progressing",
attemptCount: 0,
attemptLimit: 3,
pendingConfirmation: false,
observedAt: 1700000000000,
},
});
render(<TaskCard task={staleOffTask} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByTestId("planner-overseer-state-badge")).not.toBeInTheDocument();
expect(screen.queryByTestId("card-header-badges")).not.toBeInTheDocument();
});
// FN-7563: the badge used to print the raw kebab-case state (e.g.
// "awaiting-confirmation") with a bare "Planner overseer: awaiting-confirmation"
// tooltip. This reproduces the reported in-review symptom and asserts the badge
// is now human-readable and self-explanatory.
it("explains an in-review awaiting-confirmation badge with a readable label and a reason-bearing tooltip", () => {
const task = makeTask({
column: "in-review",
plannerOversightLevel: "autonomous",
plannerOverseerState: {
state: "awaiting-confirmation",
oversightLevel: "autonomous",
watchedStage: "executor",
signal: "stalled",
attemptCount: 2,
attemptLimit: 3,
pendingConfirmation: true,
observedAt: 1700000000000,
reason: "Retry limit reached; waiting for an operator decision",
},
});
render(<TaskCard task={task} onOpenDetail={noop} addToast={noop} />);
// FN-7592: the badge is now an icon-only glyph. The readable label moved from
// textContent to aria-label; the composed tooltip is unchanged on title.
const badge = screen.getByTestId("planner-overseer-state-badge");
expect(badge.querySelector("svg")).toBeInTheDocument();
expect(badge.getAttribute("aria-label")).not.toBe("awaiting-confirmation");
expect(badge.getAttribute("aria-label")).toBe("Awaiting confirmation");
expect(badge.getAttribute("data-planner-overseer-state")).toBe("awaiting-confirmation");
const title = badge.getAttribute("title") ?? "";
expect(title).not.toBe("Planner overseer: awaiting-confirmation");
expect(title).toContain("Retry limit reached; waiting for an operator decision");
expect(title).toMatch(/human decision/i);
expect(title).not.toMatch(/undefined/);
});
it("renders readable labels for in-progress watching and recovering overseer states", () => {
const watchingTask = makeTask({
column: "in-progress",
plannerOversightLevel: "autonomous",
plannerOverseerState: {
state: "watching",
oversightLevel: "autonomous",
watchedStage: "executor",
signal: "progressing",
attemptCount: 0,
attemptLimit: 3,
pendingConfirmation: false,
observedAt: 1700000000000,
},
});
const { unmount } = render(<TaskCard task={watchingTask} onOpenDetail={noop} addToast={noop} />);
// FN-7592: icon-only badge — assert the accessible name via aria-label and the
// per-state color hook via data-planner-overseer-state, not raw text content.
let badge = screen.getByTestId("planner-overseer-state-badge");
expect(badge.querySelector("svg")).toBeInTheDocument();
expect(badge.getAttribute("aria-label")).toBe("Overseer watching");
expect(badge.getAttribute("data-planner-overseer-state")).toBe("watching");
expect(badge.getAttribute("title")).not.toMatch(/undefined/);
unmount();
const recoveringTask = makeTask({
column: "in-progress",
plannerOversightLevel: "autonomous",
plannerOverseerState: {
state: "recovering",
oversightLevel: "autonomous",
attemptCount: 1,
attemptLimit: 3,
pendingConfirmation: false,
observedAt: 1700000000000,
},
});
render(<TaskCard task={recoveringTask} onOpenDetail={noop} addToast={noop} />);
badge = screen.getByTestId("planner-overseer-state-badge");
expect(badge.querySelector("svg")).toBeInTheDocument();
expect(badge.getAttribute("aria-label")).toBe("Overseer recovering");
// Distinct states expose distinct data-planner-overseer-state values, which is the
// hook TaskCard.css keys per-state color off of (jsdom cannot compute color-mix()).
expect(badge.getAttribute("data-planner-overseer-state")).toBe("recovering");
expect(badge.getAttribute("data-planner-overseer-state")).not.toBe("watching");
const title = badge.getAttribute("title") ?? "";
expect(title).not.toMatch(/undefined/);
expect(title.length).toBeGreaterThan(0);
});
it("shows an Answer-questions button when awaiting user input and opens the workflow tab", async () => {
const onOpenDetailWithTab = vi.fn();
render(
<TaskCard
task={makeTask({ status: "awaiting-user-input" as any })}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={onOpenDetailWithTab}
/>,
);
const btn = screen.getByLabelText("Answer questions");
fireEvent.click(btn);
expect(onOpenDetailWithTab).toHaveBeenCalledTimes(1);
expect(onOpenDetailWithTab.mock.calls[0][1]).toBe("workflow");
});
it("opens the board card context menu as a viewport portal on right-click without opening detail", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();
const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
try {
render(
<div className="column" style={{ overflow: "hidden" }}>
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
<TaskCard
task={makeTask({ column: "in-progress", status: "executing" as any })}
onOpenDetail={onOpenDetail}
addToast={noop}
onPauseTask={onPauseTask}
/>
</div>
</div>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 790, clientY: 590 });
const popover = await waitFor(() => expectBoardContextMenuPortaled());
expect(popover.style.left).toBe("612px");
expect(popover.style.top).toBe("372px");
expect(onOpenDetail).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("menuitem", { name: "Pause" }));
await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001"));
expect(onPauseTask).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(document.querySelector(".task-card-context-menu-popover")).toBeNull();
expect(onOpenDetail).not.toHaveBeenCalled();
} finally {
cleanupGeometry();
}
});
it("routes reset through the centralized confirm seam and proceeds in skip mode", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry();
const onResetTask = vi.fn(async () => makeTask());
mockConfirm.mockResolvedValueOnce(true);
try {
render(<TaskCard task={makeTask({ column: "in-progress" })} onOpenDetail={noop} onResetTask={onResetTask} addToast={noop} />);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
await waitFor(() => expectBoardContextMenuPortaled());
fireEvent.click(screen.getByRole("menuitem", { name: "Reset" }));
await waitFor(() => expect(onResetTask).toHaveBeenCalledWith("FN-001"));
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true, title: "Reset" }));
expect(document.querySelector(".confirm-dialog-overlay")).toBeNull();
} finally {
cleanupGeometry();
}
});
/*
FNXC:TaskCardMenu 2026-07-10-12:00:
The card actions menu must ALSO be reachable from the visible ⋯ button (first-run users never
discovered right-click). The button must open the exact same TaskContextMenu (same items — no
duplicated menu logic), anchored as a viewport portal, and toggle closed on a second press.
*/
it("opens the same card context menu from the visible ⋯ button and toggles it closed", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();
const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
try {
render(
<TaskCard
task={makeTask({ column: "in-progress", status: "executing" as any })}
onOpenDetail={onOpenDetail}
addToast={noop}
onPauseTask={onPauseTask}
/>,
);
// Capture the canonical right-click menu item set first.
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
await waitFor(() => expectBoardContextMenuPortaled());
const rightClickItems = screen.getAllByRole("menuitem").map((item) => item.textContent);
expect(rightClickItems.length).toBeGreaterThan(0);
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
// The ⋯ button opens the SAME menu (identical items) as right-click, portaled to the viewport.
const menuButton = screen.getByTestId("card-menu-btn-FN-001");
expect(menuButton).toHaveAttribute("aria-haspopup", "menu");
expect(menuButton).toHaveAttribute("aria-expanded", "false");
fireEvent.click(menuButton);
await waitFor(() => expectBoardContextMenuPortaled());
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual(rightClickItems);
expect(menuButton).toHaveAttribute("aria-expanded", "true");
expect(onOpenDetail).not.toHaveBeenCalled();
// A second press toggles the menu closed — the document pointerdown closer must not race it
// shut and immediately reopen it.
fireEvent.pointerDown(menuButton);
fireEvent.click(menuButton);
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
expect(document.querySelector(".task-card-context-menu-popover")).toBeNull();
expect(menuButton).toHaveAttribute("aria-expanded", "false");
// Selecting an action from the button-opened menu invokes the shared handler and closes.
fireEvent.click(menuButton);
await waitFor(() => expectBoardContextMenuPortaled());
fireEvent.click(screen.getByRole("menuitem", { name: "Pause" }));
await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001"));
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(onOpenDetail).not.toHaveBeenCalled();
} finally {
cleanupGeometry();
}
});
/*
FNXC:TaskContextMenu 2026-07-16-20:50 (FN-8178):
All card entry points converge on the same portaled auto-focus lifecycle. Simulate a browser that
scrolls a focused portal unless `preventScroll` is requested; that scroll previously reached the
capture-phase closer and made every card action unusable.
*/
it("keeps every card menu entry point open when autofocus would otherwise scroll", async () => {
vi.useFakeTimers();
const cleanupGeometry = mockBoardContextMenuGeometry();
const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
const nativeFocus = HTMLElement.prototype.focus;
const focusSpy = vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(function focusWithBrowserScroll(options?: FocusOptions) {
nativeFocus.call(this);
if (!options?.preventScroll) {
window.setTimeout(() => window.dispatchEvent(new Event("scroll")), 0);
}
});
const openMethods = [
() => fireEvent.click(screen.getByTestId("card-menu-btn-FN-001")),
() => fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }),
() => {
fireEvent.pointerDown(document.querySelector(".card")!, { pointerType: "touch", pointerId: 7, clientX: 24, clientY: 28 });
act(() => vi.advanceTimersByTime(550));
},
() => fireEvent.keyDown(document.querySelector(".card")!, { key: "ContextMenu" }),
];
try {
render(
<TaskCard
task={makeTask({ column: "in-progress", status: "executing" as any })}
onOpenDetail={noop}
addToast={noop}
onPauseTask={onPauseTask}
/>,
);
for (const openMenu of openMethods) {
openMenu();
act(() => vi.runOnlyPendingTimers());
expectBoardContextMenuPortaled();
expect(focusSpy).toHaveBeenLastCalledWith({ preventScroll: true });
const pauseItem = screen.getByRole("menuitem", { name: "Pause" });
fireEvent.pointerUp(pauseItem, { pointerType: "touch", pointerId: 8 });
await act(async () => {
await Promise.resolve();
});
expect(onPauseTask).toHaveBeenLastCalledWith("FN-001");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
}
// Explicit dismissals remain intentional after focus no longer creates a scroll dismissal.
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
expectBoardContextMenuPortaled();
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
act(() => window.dispatchEvent(new Event("scroll")));
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
} finally {
focusSpy.mockRestore();
cleanupGeometry();
}
});
it("opens Planning Mode from eligible pre-execution card menus only when wired", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry();
const onPlanningMode = vi.fn();
try {
const { rerender } = render(
<TaskCard
task={makeTask({ column: "triage", description: "Plan from description", title: "Fallback title" })}
onOpenDetail={noop}
onPlanningMode={onPlanningMode}
planningWorkflowId="WF-intake"
addToast={noop}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
await waitFor(() => expectBoardContextMenuPortaled());
fireEvent.click(screen.getByRole("menuitem", { name: "Plan" }));
expect(onPlanningMode).toHaveBeenCalledWith("Plan from description", "WF-intake");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
rerender(
<TaskCard
task={makeTask({ column: "ideas" as any, description: "", title: "Custom intake title" })}
taskColumnFlags={{ intake: true }}
onOpenDetail={noop}
onPlanningMode={onPlanningMode}
planningWorkflowId="WF-custom"
addToast={noop}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
await waitFor(() => expectBoardContextMenuPortaled());
fireEvent.click(screen.getByRole("menuitem", { name: "Plan" }));
expect(onPlanningMode).toHaveBeenLastCalledWith("Custom intake title", "WF-custom");
rerender(
<TaskCard
task={makeTask({ column: "todo" })}
onOpenDetail={noop}
onPlanningMode={onPlanningMode}
addToast={noop}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
await waitFor(() => expectBoardContextMenuPortaled());
expect(screen.queryByRole("menuitem", { name: "Plan" })).not.toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
rerender(
<TaskCard
task={makeTask({ column: "triage" })}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={vi.fn()}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
expect(screen.queryByRole("menuitem", { name: "Plan" })).not.toBeInTheDocument();
} finally {
cleanupGeometry();
}
});
it("opens Planning Mode from the mobile/touch long-press menu for custom hold cards", async () => {
vi.useFakeTimers();
const cleanupGeometry = mockBoardContextMenuGeometry();
const onPlanningMode = vi.fn();
try {
render(
<TaskCard
task={makeTask({ column: "waiting" as any, description: "Touch plan seed" })}
taskColumnFlags={{ hold: true }}
onOpenDetail={noop}
onPlanningMode={onPlanningMode}
planningWorkflowId="WF-hold"
addToast={noop}
/>,
);
const card = document.querySelector(".card") as HTMLElement;
fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 32, clientY: 36 });
act(() => vi.advanceTimersByTime(550));
expectBoardContextMenuPortaled();
fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Plan" }), { pointerType: "touch", pointerId: 2 });
await act(async () => {
await Promise.resolve();
});
expect(onPlanningMode).toHaveBeenCalledWith("Touch plan seed", "WF-hold");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
} finally {
cleanupGeometry();
}
});
it("enables GitHub tracking from the board card context menu and hides the action after refresh", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const onUpdateTask = vi.fn(async () => makeTask({ githubTracking: { enabled: true } as any }));
const { rerender } = render(
<TaskCard
task={makeTask({ githubTracking: undefined })}
projectId="project-1"
onOpenDetail={onOpenDetail}
addToast={addToast}
onUpdateTask={onUpdateTask}
/>,
);
try {
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
fireEvent.click(screen.getByRole("menuitem", { name: "Enable GitHub tracking" }));
await waitFor(() => expect(onUpdateTask).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } }));
expect(addToast).toHaveBeenCalledWith("Requested GitHub tracking issue creation", "info");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(onOpenDetail).not.toHaveBeenCalled();
rerender(
<TaskCard
task={makeTask({ githubTracking: { enabled: true } as any })}
projectId="project-1"
onOpenDetail={onOpenDetail}
addToast={addToast}
onUpdateTask={onUpdateTask}
/>,
);
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
expect(screen.queryByRole("menuitem", { name: "Enable GitHub tracking" })).not.toBeInTheDocument();
} finally {
cleanupGeometry();
}
});
it("opens the board card context menu from keyboard as a viewport portal, selects an action, and closes", async () => {
const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
try {
render(
<div className="column" style={{ overflow: "hidden" }}>
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
<TaskCard
task={makeTask({ column: "done", status: "done" as any })}
onOpenDetail={onOpenDetail}
addToast={noop}
onArchiveTask={onArchiveTask}
/>
</div>
</div>,
);
const card = document.querySelector(".card") as HTMLElement;
card.focus();
fireEvent.keyDown(card, { key: "F10", shiftKey: true });
expectBoardContextMenuPortaled();
fireEvent.click(screen.getByRole("menuitem", { name: "Archive" }));
await waitFor(() => expect(onArchiveTask).toHaveBeenCalledWith("FN-001"));
expect(onArchiveTask).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(onOpenDetail).not.toHaveBeenCalled();
} finally {
cleanupGeometry();
}
});
it("shows refine for a done card context menu and routes to the refinement opener", () => {
const onOpenDetail = vi.fn();
const onOpenRefine = vi.fn();
render(
<TaskCard
task={makeTask({ column: "done", status: "done" as any })}
onOpenDetail={onOpenDetail}
onOpenRefine={onOpenRefine}
addToast={noop}
onDeleteTask={vi.fn()}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
expect(onOpenRefine).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" }));
expect(onOpenDetail).not.toHaveBeenCalled();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("shows refine for custom complete cards on touch long-press", () => {
vi.useFakeTimers();
const onOpenRefine = vi.fn();
render(
<TaskCard
task={makeTask({ column: "complete" as any, status: "done" as any })}
taskColumnFlags={{ complete: true }}
onOpenDetail={noop}
onOpenRefine={onOpenRefine}
addToast={noop}
onDeleteTask={vi.fn()}
/>,
);
const card = document.querySelector(".card") as HTMLElement;
fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 });
act(() => vi.advanceTimersByTime(550));
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
expect(onOpenRefine).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" }));
});
it("confirms preserving progress before moving from the board context menu", async () => {
const onMoveTask = vi.fn(async () => makeTask({ column: "todo" }));
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "in-progress",
steps: [{ id: "s1", title: "done", status: "done" } as any],
})}
onOpenDetail={noop}
addToast={noop}
onMoveTask={onMoveTask}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
fireEvent.click(screen.getByRole("menuitem", { name: "Move to Todo" }));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true }));
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Preserve Progress?" }));
});
it("omits refine without a real modal callback and offers PR status actions from the board context menu", async () => {
const onOpenDetail = vi.fn();
vi.mocked(refreshPrStatus).mockResolvedValueOnce({} as any);
render(
<TaskCard
task={makeTask({
column: "in-review",
prInfo: { number: 12, url: "https://example.test/pr/12", status: "open" } as any,
})}
projectId="project-1"
onOpenDetail={onOpenDetail}
addToast={noop}
onMergeTask={vi.fn()}
mergeStrategy="pull-request"
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
expect(screen.queryByRole("menuitem", { name: "Refine" })).not.toBeInTheDocument();
expect(onOpenDetail).not.toHaveBeenCalled();
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
fireEvent.click(screen.getByRole("menuitem", { name: "Check PR Status" }));
await waitFor(() => expect(refreshPrStatus).toHaveBeenCalledWith("FN-001", "project-1"));
});
it("matches detail PR review labels before and during PR automation", () => {
const onMergeTask = vi.fn(async () => ({ merged: false }));
const { rerender } = render(
<TaskCard
task={makeTask({ column: "in-review" })}
onOpenDetail={noop}
addToast={noop}
onMergeTask={onMergeTask}
mergeStrategy="pull-request"
autoMergeEnabled={false}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
expect(screen.getByRole("menuitem", { name: "Start PR Review" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument();
rerender(
<TaskCard
task={makeTask({ column: "in-review", status: "creating-pr" as any })}
onOpenDetail={noop}
addToast={noop}
onMergeTask={onMergeTask}
mergeStrategy="pull-request"
autoMergeEnabled={false}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
expect(screen.getByRole("menuitem", { name: "Creating PR…" })).toBeDisabled();
expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument();
});
it("opens the board card context menu on touch long-press as a viewport portal, selects the tapped action, and suppresses detail click", async () => {
vi.useFakeTimers();
const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();
const onUnpauseTask = vi.fn(async () => makeTask());
try {
render(
<div className="column" style={{ overflow: "hidden" }}>
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
<TaskCard
task={makeTask({ paused: true, userPaused: true })}
onOpenDetail={onOpenDetail}
addToast={noop}
onUnpauseTask={onUnpauseTask}
/>
</div>
</div>,
);
const card = document.querySelector(".card") as HTMLElement;
fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 790, clientY: 590 });
act(() => vi.advanceTimersByTime(550));
expectBoardContextMenuPortaled();
fireEvent.pointerUp(card, { pointerType: "touch", pointerId: 1, clientX: 790, clientY: 590 });
fireEvent.click(card);
expect(onOpenDetail).not.toHaveBeenCalled();
fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Unpause" }), { pointerType: "touch", pointerId: 2 });
await act(async () => {
await Promise.resolve();
});
expect(onUnpauseTask).toHaveBeenCalledWith("FN-001");
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
} finally {
cleanupGeometry();
}
});
it("suppresses native text selection when touch long-press opens the board card context menu", async () => {
vi.useFakeTimers();
const cleanupGeometry = mockBoardContextMenuGeometry();
const onOpenDetail = vi.fn();
const onPauseTask = vi.fn(async () => makeTask({ paused: true }));
try {
render(
<div className="column" style={{ overflow: "hidden" }}>
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
<TaskCard
task={makeTask({ title: "Long selectable title", column: "in-progress", status: "executing" as any })}
onOpenDetail={onOpenDetail}
addToast={noop}
onPauseTask={onPauseTask}
/>
</div>
</div>,
);
const title = screen.getByText("Long selectable title");
const pointerDownWasNotCanceled = fireEvent.pointerDown(title, {
pointerType: "touch",
pointerId: 9,
clientX: 180,
clientY: 160,
cancelable: true,
});
expect(pointerDownWasNotCanceled).toBe(false);
act(() => vi.advanceTimersByTime(550));
expectBoardContextMenuPortaled();
expect(onOpenDetail).not.toHaveBeenCalled();
fireEvent.pointerUp(title, { pointerType: "touch", pointerId: 9, clientX: 180, clientY: 160 });
} finally {
cleanupGeometry();
}
});
it("cancels board card long-press when touch moves before the delay", () => {
vi.useFakeTimers();
render(
<TaskCard
task={makeTask()}
onOpenDetail={vi.fn()}
addToast={noop}
onPauseTask={vi.fn()}
/>,
);
const card = document.querySelector(".card") as HTMLElement;
fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 });
fireEvent.pointerMove(card, { pointerType: "touch", pointerId: 1, clientX: 40, clientY: 16 });
act(() => vi.advanceTimersByTime(550));
expect(screen.queryByRole("menu")).toBeNull();
});
it("dispatches portaled board menu actions for the interacted duplicate card", async () => {
mockConfirm.mockResolvedValueOnce(true);
const onDuplicateTask = vi.fn(async () => makeTask({ id: "FN-002-copy" }));
render(
<div className="column" style={{ overflow: "hidden" }}>
<div className="column-body" style={{ overflowX: "hidden", overflowY: "auto" }}>
<TaskCard
task={makeTask({ id: "FN-001", title: "Duplicate title", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
onDuplicateTask={onDuplicateTask}
/>
<TaskCard
task={makeTask({ id: "FN-002", title: "Duplicate title", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
onDuplicateTask={onDuplicateTask}
/>
</div>
</div>,
);
const secondCard = document.querySelectorAll(".card")[1] as HTMLElement;
fireEvent.contextMenu(secondCard, { clientX: 64, clientY: 72 });
expectBoardContextMenuPortaled();
fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" }));
await waitFor(() => expect(onDuplicateTask).toHaveBeenCalledWith("FN-002"));
expect(onDuplicateTask).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("cleans up the portaled board menu when the task column changes", () => {
const { rerender } = render(
<TaskCard
task={makeTask({ id: "FN-001", column: "in-progress", status: "executing" as any })}
onOpenDetail={noop}
addToast={noop}
onPauseTask={vi.fn()}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
expectBoardContextMenuPortaled();
rerender(
<TaskCard
task={makeTask({ id: "FN-001", column: "todo", status: "pending" as any })}
onOpenDetail={noop}
addToast={noop}
onPauseTask={vi.fn()}
/>,
);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
expect(document.querySelector(".task-card-context-menu-popover")).toBeNull();
});
it("does not show the Answer-questions button when not awaiting input", () => {
render(
<TaskCard
task={makeTask({ status: "executing" as any })}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
expect(screen.queryByLabelText("Answer questions")).toBeNull();
});
it("uses githubIssueAction for tracked task delete", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "triage",
githubTracking: {
enabled: true,
issue: { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-01-01T00:00:00Z" },
},
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "close" });
});
});
it("uses githubIssueAction=delete for tracked task delete", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "triage",
githubTracking: {
enabled: true,
issue: { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-01-01T00:00:00Z" },
},
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "delete" });
});
});
it("uses githubIssueAction=leave for tracked task delete", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
render(
<TaskCard
task={makeTask({
column: "triage",
githubTracking: {
enabled: true,
issue: { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-01-01T00:00:00Z" },
},
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "leave" });
});
});
it("uses githubIssueAction for source-imported task delete close", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "triage",
sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 42, externalIssueId: "42" },
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "close" });
});
});
it("uses githubIssueAction=delete for source-imported task delete", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "triage",
sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 42, externalIssueId: "42" },
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "delete" });
});
});
it("uses githubIssueAction=leave for source-imported task delete", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
render(
<TaskCard
task={makeTask({
column: "triage",
sourceIssue: { provider: "github", repository: "acme/widgets", issueNumber: 42, externalIssueId: "42" },
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "leave" });
});
});
it("deletes without githubIssueAction when no linked github issue exists", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({ column: "triage", githubTracking: { enabled: false }, sourceIssue: undefined } as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001");
});
});
it("runs the delete flow from the desktop task context menu", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({ column: "todo", githubTracking: { enabled: false }, sourceIssue: undefined } as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Delete Task" }));
expect(onDeleteTask).toHaveBeenCalledWith("FN-001");
});
});
it("runs the delete flow from the mobile pointer-up task context menu", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({ column: "todo", githubTracking: { enabled: false }, sourceIssue: undefined } as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Delete" }), { pointerType: "touch", pointerId: 7 });
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Delete Task" }));
expect(onDeleteTask).toHaveBeenCalledWith("FN-001");
});
});
it("preserves githubIssueAction on dependency-conflict retry", async () => {
const conflict = new Error("Cannot delete task FN-001: still referenced as a dependency by FN-002.") as Error & { status: number; details: { code: string; dependentIds: string[] } };
conflict.status = 409;
conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-002"] };
const onDeleteTask = vi.fn()
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce(makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "triage",
githubTracking: {
enabled: true,
issue: { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-01-01T00:00:00Z" },
},
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction: "delete",
});
});
});
it("retries delete after lineage-conflict confirmation", async () => {
const conflict = new Error("Cannot delete task FN-001: still referenced as a lineage parent by FN-010.") as Error & {
status: number;
details: { code: string; lineageChildIds: string[] };
};
conflict.status = 409;
conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-010", "FN-011"] };
const onDeleteTask = vi.fn()
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce(makeTask());
mockConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({
column: "triage",
githubTracking: {
enabled: true,
issue: { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-01-01T00:00:00Z" },
},
} as any)}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
/>,
);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction: "delete",
});
});
});
it("hides delete button for done tasks while keeping archive in the three-dot menu", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
render(
<TaskCard
task={makeTask({ column: "done" })}
onOpenDetail={noop}
addToast={noop}
onDeleteTask={onDeleteTask}
onArchiveTask={onArchiveTask}
/>,
);
expect(screen.queryByLabelText("Delete task")).toBeNull();
expect(screen.queryByRole("button", { name: "Actions" })).toBeNull();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
expect(await screen.findByRole("menuitem", { name: "Archive" })).toBeDefined();
});
it.each(["triage", "todo", "in-progress", "in-review"] as const)(
"hides archive action for %s tasks",
(column) => {
render(
<TaskCard
task={makeTask({ column })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
/>,
);
expect(screen.queryByLabelText("Archive task")).toBeNull();
expect(screen.queryByRole("button", { name: "Actions" })).toBeNull();
},
);
it("renders archive action for done tasks inside the three-dot menu", async () => {
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
render(
<TaskCard
task={makeTask({ column: "done" })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={onArchiveTask}
/>,
);
expect(screen.queryByLabelText("Archive task")).toBeNull();
expect(screen.queryByRole("button", { name: "Actions" })).toBeNull();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
const menu = await screen.findByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "Archive" })).toBeDefined();
fireEvent.click(within(menu).getByRole("menuitem", { name: "Archive" }));
await waitFor(() => expect(onArchiveTask).toHaveBeenCalledWith("FN-001"));
});
it("renders no action-menu shell when neither done action is available", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", { name: "Actions" })).toBeNull();
expect(container.querySelector(".card-done-actions")).toBeNull();
expect(screen.queryByTestId("card-menu-btn-FN-001")).toBeNull();
});
it("does not render archive action for archived tasks", () => {
render(
<TaskCard
task={makeTask({ column: "archived" })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
onUnarchiveTask={vi.fn(async () => makeTask({ column: "done" }))}
/>,
);
expect(screen.queryByLabelText("Archive task")).toBeNull();
expect(screen.queryByRole("button", { name: "Actions" })).toBeNull();
expect(screen.getByLabelText("Unarchive task")).toBeDefined();
});
/*
FNXC:TaskRevert 2026-07-15-00:00 (FN-8035):
Coverage for the Revert affordance across done + archived cards. Done-card
actions are available only through the three-dot context menu; archived cards
retain their inline row. The no-commit guard remains disabled in the menu.
*/
describe("Revert affordance", () => {
it("renders Archive and Revert in the done card three-dot menu when both are available", async () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(container.querySelector(".card-revert-btn")).toBeNull();
expect(container.querySelector(".card-done-actions")).toBeNull();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
const menu = await screen.findByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "Archive" })).toBeDefined();
expect(within(menu).getByRole("menuitem", { name: "Revert" })).toBeDefined();
});
it("renders the inline Revert button for an archived card with a landed commit", () => {
render(
<TaskCard
task={makeTask({ column: "archived", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(screen.getByLabelText("Revert this task's changes")).toBeDefined();
});
it("omits the Revert button when onRevertTask is not provided", () => {
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByLabelText("Revert this task's changes")).toBeNull();
});
it("renders Archive and disabled Revert in the done three-dot menu without a landed commit", async () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: undefined })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(container.querySelector(".card-revert-btn")).toBeNull();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
const menu = await screen.findByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "Archive" })).toBeDefined();
expect(within(menu).getByRole("menuitem", { name: "Revert" })).toBeDisabled();
});
it("renders only Revert in the done three-dot menu when archive is unavailable", async () => {
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
const menu = await screen.findByRole("menu");
expect(within(menu).queryByRole("menuitem", { name: "Archive" })).toBeNull();
expect(within(menu).getByRole("menuitem", { name: "Revert" })).toBeEnabled();
});
it("shows a disabled Revert context-menu entry when the task has no landed commit", () => {
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: undefined })}
onOpenDetail={noop}
addToast={noop}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
const menuItem = screen.getByRole("menuitem", { name: "Revert" });
expect(menuItem).toBeDisabled();
});
it("shows the Revert context-menu entry for done and archived cards", () => {
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 });
expect(screen.getByRole("menuitem", { name: "Revert" })).toBeDefined();
});
it("calls onRevertTask in auto mode and toasts the revert commit sha on a clean result", async () => {
const addToast = vi.fn();
const onRevertTask = vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef1234" }) as any);
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={addToast}
onRevertTask={onRevertTask}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Revert" }));
});
await waitFor(() => {
expect(onRevertTask).toHaveBeenCalledWith("FN-001", { mode: "auto" });
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("deadbeef1234"),
"success",
);
});
});
it("opens a confirm dialog on conflict and falls back to mode: ai, surfacing the created task id", async () => {
const addToast = vi.fn();
const onRevertTask = vi.fn()
.mockResolvedValueOnce({ mode: "git", clean: false, conflicts: [{}] } as any)
.mockResolvedValueOnce({ mode: "ai", createdTaskId: "FN-999" } as any);
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={addToast}
onRevertTask={onRevertTask}
/>,
);
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Revert" }));
});
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalled();
});
await waitFor(() => {
expect(onRevertTask).toHaveBeenNthCalledWith(2, "FN-001", { mode: "ai" });
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("FN-999"),
"success",
);
});
});
});
it("keeps two-button delete flow for non-done task", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm.mockResolvedValueOnce(false);
render(<TaskCard task={makeTask({ column: "triage" })} onOpenDetail={noop} addToast={noop} onDeleteTask={onDeleteTask} />);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
expect(mockConfirmWithChoice).not.toHaveBeenCalled();
});
it("keeps legacy delete options for untracked task", async () => {
const onDeleteTask = vi.fn(async () => makeTask());
mockConfirm.mockResolvedValueOnce(true);
render(<TaskCard task={makeTask({ column: "triage" })} onOpenDetail={noop} addToast={noop} onDeleteTask={onDeleteTask} />);
await act(async () => {
fireEvent.click(screen.getByLabelText("Delete task"));
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-001");
});
});
it("renders the card ID text", () => {
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByText("FN-001")).toBeDefined();
});
it("renders a per-branch progress badge when the task is in a parallel window (U9/U13)", () => {
const task = {
...makeTask(),
branchProgress: [
{ branchId: "b1", nodeId: "n1", status: "completed" },
{ branchId: "b2", nodeId: "n2", status: "running" },
],
} as Task;
render(<TaskCard task={task} onOpenDetail={noop} addToast={noop} />);
const badge = screen.getByTestId("branch-progress-badge");
expect(badge.textContent).toContain("1/2");
});
it("does not render a branch-progress badge when there is no parallel window", () => {
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByTestId("branch-progress-badge")).toBeNull();
});
it("keeps native card dragging enabled by default", () => {
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
const card = container.querySelector(".card") as HTMLElement;
expect(card.getAttribute("draggable")).toBe("true");
});
it("disables native card dragging when disableDrag is true", () => {
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} disableDrag={true} />);
const card = container.querySelector(".card") as HTMLElement;
expect(card.getAttribute("draggable")).toBe("false");
});
// FN-6389 follow-up: native HTML5 drag is desktop-mouse only and doesn't move
// cards via touch, but a `draggable` element still arms the browser's touch-drag
// heuristic, which intermittently hijacks horizontal swipes meant to scroll the
// mobile board. On touch-primary (coarse pointer) devices we drop `draggable`.
it("disables native card dragging on touch-primary (coarse pointer) devices", () => {
const original = window.matchMedia;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === "(hover: none) and (pointer: coarse)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia;
try {
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
const card = container.querySelector(".card") as HTMLElement;
expect(card.getAttribute("draggable")).toBe("false");
// No drag-start handler should be wired on touch (would arm the heuristic).
const dragStart = new Event("dragstart", { bubbles: true, cancelable: true });
const prevented = !card.dispatchEvent(dragStart);
expect(prevented).toBe(false);
} finally {
window.matchMedia = original;
}
});
it.each([
{ name: "merged", primaryPr: { status: "merged" as const }, expectedClass: "card-github-badge--merged" },
{ name: "conflicting", primaryPr: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" },
])("renders Nx PR badge label and resolver class for $name primary PR", ({ primaryPr, expectedClass }) => {
render(
<TaskCard
task={makeTask({
column: "in-review",
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
...primaryPr,
} as any,
prInfos: [
{
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
...primaryPr,
},
{
url: "https://github.com/owner/repo/pull/99",
number: 99,
status: "open",
title: "PR 2",
headBranch: "fusion/fn-001-2",
baseBranch: "main",
commentCount: 0,
},
] as any,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = screen.getByRole("link", { name: /2x #42/i });
expect(badge).toBeDefined();
expect(badge).toHaveClass(expectedClass);
});
it("clicking PR badge link does not open the task detail modal", () => {
const onOpenDetail = vi.fn();
render(
<TaskCard
task={makeTask({
column: "in-review",
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
} as any,
})}
onOpenDetail={onOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("link", { name: "#42" }));
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("renders Create PR quick action on eligible in-review cards", () => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
/>,
);
expect(screen.getByRole("button", { name: "Create pull request" })).toBeDefined();
});
it("renders Create PR quick action with chip class instead of btn classes", () => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
/>,
);
const createPrButton = screen.getByRole("button", { name: "Create pull request" });
expect(createPrButton).toHaveClass("card-create-pr-action");
expect(createPrButton).not.toHaveClass("btn");
expect(createPrButton).not.toHaveClass("btn-sm");
});
it.each(["in-progress", "todo", "done"] as const)("does not render Create PR quick action outside in-review (%s)", (column) => {
render(
<TaskCard
task={makeTask({ column, paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
/>,
);
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
});
it("does not render Create PR quick action when prAuthAvailable is false", () => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={false}
/>,
);
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
});
it.each([
{
name: "hides when task override turns auto-merge on while project default is off",
taskAutoMerge: true,
autoMergeEnabled: false,
shouldShow: false,
},
{
name: "hides when task follows an enabled project default",
taskAutoMerge: undefined,
autoMergeEnabled: true,
shouldShow: false,
},
{
name: "shows when task override turns auto-merge off while project default is on",
taskAutoMerge: false,
autoMergeEnabled: true,
shouldShow: true,
},
{
name: "shows when task follows a disabled project default",
taskAutoMerge: undefined,
autoMergeEnabled: false,
shouldShow: true,
},
])("Create PR quick action $name", ({ taskAutoMerge, autoMergeEnabled, shouldShow }) => {
render(
<TaskCard
task={makeTask({
column: "in-review",
paused: false,
userPaused: false,
prInfo: undefined as any,
autoMerge: taskAutoMerge,
})}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
autoMergeEnabled={autoMergeEnabled}
/>,
);
if (shouldShow) {
expect(screen.getByRole("button", { name: "Create pull request" })).toBeDefined();
return;
}
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
});
it("does not render Create PR quick action when task already has prInfo", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
prInfo: {
url: "https://github.com/owner/repo/pull/7",
number: 7,
status: "open",
title: "Existing PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
} as any,
})}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
/>,
);
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
});
it("hides Address PR feedback when the task has no actionable PR feedback", () => {
const noPrRender = render(
<TaskCard
task={makeTask({ id: "FN-NO-PR", prInfo: undefined as any, prInfos: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTestId("card-address-pr-feedback-FN-NO-PR")).toBeNull();
expect(noPrRender.container.querySelector(".card-action-row")).toBeNull();
noPrRender.unmount();
const noFeedbackRender = render(
<TaskCard
task={makeTask({
id: "FN-NO-FEEDBACK",
prInfo: {
url: "https://github.com/owner/repo/pull/8",
number: 8,
status: "open",
title: "No feedback PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
lastReviewDecision: "APPROVED",
} as any,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTestId("card-address-pr-feedback-FN-NO-FEEDBACK")).toBeNull();
expect(noFeedbackRender.container.querySelector(".card-action-row")).toBeNull();
noFeedbackRender.unmount();
const unsupportedColumnRender = render(
<TaskCard
task={makeTask({
id: "FN-DONE-FEEDBACK",
column: "done",
prInfo: {
url: "https://github.com/owner/repo/pull/13",
number: 13,
status: "open",
title: "Feedback on done task",
headBranch: "fusion/fn-done",
baseBranch: "main",
commentCount: 2,
lastReviewDecision: "CHANGES_REQUESTED",
} as any,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTestId("card-address-pr-feedback-FN-DONE-FEEDBACK")).toBeNull();
expect(unsupportedColumnRender.container.querySelector(".card-action-row")).toBeNull();
});
it("renders Address PR feedback once for actionable primary PR feedback", () => {
render(
<TaskCard
task={makeTask({
id: "FN-PR-FEEDBACK",
prInfos: [
{
url: "https://github.com/owner/repo/pull/9",
number: 9,
status: "open",
title: "Feedback PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 2,
} as any,
{
url: "https://github.com/owner/repo/pull/10",
number: 10,
status: "open",
title: "Secondary PR",
headBranch: "fusion/fn-001-alt",
baseBranch: "main",
commentCount: 4,
} as any,
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const buttons = screen.getAllByTestId("card-address-pr-feedback-FN-PR-FEEDBACK");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveClass("card-create-pr-action", "card-address-pr-feedback-action");
expect(buttons[0].closest(".card-action-row")).not.toBeNull();
});
it("starts Address PR feedback from the card without opening detail", async () => {
const addToast = vi.fn();
const onOpenDetail = vi.fn();
vi.mocked(addressPrFeedback).mockResolvedValue({ task: makeTask({ id: "FN-CLICK" }) });
render(
<TaskCard
task={makeTask({
id: "FN-CLICK",
prInfo: {
url: "https://github.com/owner/repo/pull/11",
number: 11,
status: "open",
title: "Changes requested PR",
headBranch: "fusion/fn-click",
baseBranch: "main",
commentCount: 0,
lastReviewDecision: "CHANGES_REQUESTED",
} as any,
})}
onOpenDetail={onOpenDetail}
addToast={addToast}
projectId="proj-1"
/>,
);
fireEvent.click(screen.getByTestId("card-address-pr-feedback-FN-CLICK"));
await waitFor(() => expect(addressPrFeedback).toHaveBeenCalledWith("FN-CLICK", "proj-1"));
expect(addToast).toHaveBeenCalledWith("Addressing PR feedback — AI session started", "success");
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("shows an error toast when Address PR feedback cannot start", async () => {
const addToast = vi.fn();
vi.mocked(addressPrFeedback).mockRejectedValue(new Error("wake failed"));
render(
<TaskCard
task={makeTask({
id: "FN-ERROR",
prInfo: {
url: "https://github.com/owner/repo/pull/12",
number: 12,
status: "open",
title: "Feedback PR",
headBranch: "fusion/fn-error",
baseBranch: "main",
commentCount: 1,
} as any,
})}
onOpenDetail={noop}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByTestId("card-address-pr-feedback-FN-ERROR"));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Failed to start PR feedback session: wake failed", "error"));
});
it.each([
{ paused: true, userPaused: false },
{ paused: false, userPaused: true },
])("does not render Create PR quick action when paused flags are set", ({ paused, userPaused }) => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused, userPaused, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
/>,
);
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
});
it("opens Create PR modal on click and does not open task detail", () => {
const onOpenDetail = vi.fn();
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={onOpenDetail}
addToast={noop}
prAuthAvailable={true}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Create pull request" }));
expect(screen.getByTestId("pr-create-modal").getAttribute("data-open")).toBe("true");
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("closes Create PR modal when onClose fires", () => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Create pull request" }));
fireEvent.click(screen.getByRole("button", { name: "close-pr-modal" }));
expect(screen.getByTestId("pr-create-modal").getAttribute("data-open")).toBe("false");
});
it("shows success toast and closes modal when PR is created", () => {
const addToast = vi.fn();
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={addToast}
prAuthAvailable={true}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Create pull request" }));
fireEvent.click(screen.getByRole("button", { name: "create-pr-modal" }));
expect(addToast).toHaveBeenCalledWith("Created PR #42", "success");
expect(screen.getByTestId("pr-create-modal").getAttribute("data-open")).toBe("false");
});
it("renders GitHub badge from live websocket data even when task payload has no badge fields", () => {
badgeUpdatesMock.set("default:FN-001", {
prInfo: {
url: "https://github.com/owner/repo/pull/77",
number: 77,
status: "open",
title: "Live PR",
headBranch: "feature/live",
baseBranch: "main",
commentCount: 0,
},
timestamp: "2026-05-13T12:00:00.000Z",
});
render(
<TaskCard
task={makeTask({ column: "in-review", prInfo: undefined, issueInfo: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(subscribeToBadgeMock).toHaveBeenCalledWith("FN-001");
expect(screen.getByRole("link", { name: "#77" })).toBeDefined();
});
it("clicking issue badge text does not open the task detail modal", () => {
const onOpenDetail = vi.fn();
render(
<TaskCard
task={makeTask({
column: "in-review",
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "open",
title: "Issue",
} as any,
})}
onOpenDetail={onOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("#123"));
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("renders the status badge when task.status is set", () => {
render(
<TaskCard
task={makeTask({ status: "executing" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("executing")).toBeDefined();
});
it("FN-8493 renders Revising, not Replan, for a bare needs-replan Board card", () => {
render(
<TaskCard
task={makeTask({ column: "triage", status: "needs-replan" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Revising")).toHaveClass("card-status-badge");
expect(screen.queryByText("Replan")).not.toBeInTheDocument();
});
it.each([
{ column: "todo" as const, status: "planning" },
{ column: "in-progress" as const, status: "planning" },
{ column: "triage" as const, status: "planning" },
])("FN-8475 renders real planning status and a non-empty header wrapper on $column cards", ({ column, status }) => {
const { container } = render(
<TaskCard
task={makeTask({ column, status })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = screen.getByText("Planning");
expect(badge).toHaveClass("card-status-badge");
expect(container.querySelector(".card-header-badges")).toContainElement(badge);
});
it("FN-8475 preserves non-planning status badges", () => {
const { rerender } = render(
<TaskCard
task={makeTask({ column: "triage", status: "planning" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Planning")).toBeDefined();
rerender(
<TaskCard
task={makeTask({
column: "in-progress",
status: "executing",
steps: [{ name: "Executing step", status: "in-progress" }],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("executing")).toBeDefined();
});
it("renders merge-remediation status as merge-active for in-review tasks", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review", status: "merging-fix" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = screen.getByText("Merging fixes");
expect(badge.classList.contains("card-status-badge")).toBe(true);
expect(badge.textContent).not.toContain("…");
expect(container.querySelector(".card-status-badge")).toBe(badge);
expect(badge.className).toContain("pulsing");
});
it("FN-4208 keeps failed in-review TaskCard badge on error colors", () => {
const cleanupCss = mountCssForBadgeTests();
try {
const { container } = render(
<TaskCard task={makeTask({ column: "in-review", status: "failed" as any, error: "boom" })} onOpenDetail={noop} addToast={noop} />,
);
const badge = container.querySelector(".card-status-badge") as HTMLElement;
expect(badge.className).toContain("card-status-badge--in-review");
expect(badge.className).toContain("failed");
expect(getComputedStyle(badge).color).toBe("var(--color-error-dark)");
expect(getComputedStyle(badge).color).not.toBe("var(--in-review)");
} finally {
cleanupCss();
}
});
it.each([
{ name: "undefined results", workflowStepResults: undefined, shouldRender: false },
{ name: "pending but not started", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending" }], shouldRender: false },
{ name: "running", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending", startedAt: "2026-07-11T12:00:00.000Z" }], shouldRender: true },
{ name: "passed", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "passed", startedAt: "2026-07-11T12:00:00.000Z", completedAt: "2026-07-11T12:01:00.000Z" }], shouldRender: false },
{ name: "failed", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "failed", startedAt: "2026-07-11T12:00:00.000Z", completedAt: "2026-07-11T12:01:00.000Z" }], shouldRender: false },
{ name: "skipped", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "skipped", startedAt: "2026-07-11T12:00:00.000Z", completedAt: "2026-07-11T12:01:00.000Z" }], shouldRender: false },
{ name: "advisory failure", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "advisory_failure", startedAt: "2026-07-11T12:00:00.000Z", completedAt: "2026-07-11T12:01:00.000Z" }], shouldRender: false },
])("renders the Reviewing badge only while Plan Review is actively running: $name", ({ workflowStepResults, shouldRender }) => {
const { container } = render(
<TaskCard
task={makeTask({
id: "FN-7831",
column: "triage",
status: "planning",
enabledWorkflowSteps: ["plan-review"],
workflowStepResults: workflowStepResults as Task["workflowStepResults"],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector('[data-testid="card-reviewing-FN-7831"]');
expect(Boolean(badge)).toBe(shouldRender);
if (shouldRender) {
expect(badge).toHaveTextContent("Plan Review");
/*
FNXC:StatusBadge 2026-07-26-14:05:
Exactly ONE badge names the gate. U12 let the status badge borrow the running step's IR name,
which now collides with the gate badge's own "Plan Review" copy, so the override yields and the
status badge states the card's status instead — as "Planning", not the raw engine token.
*/
expect(screen.getAllByText("Plan Review")).toHaveLength(1);
expect(screen.getByText("Planning")).toBeDefined();
}
});
it("keeps the card border and Reviewing badge in agreement for a status-null running Plan Review", () => {
const { container } = render(
<TaskCard
task={makeTask({
id: "FN-8055",
column: "triage",
status: null as any,
enabledWorkflowSteps: ["plan-review"],
workflowStepResults: [{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "pending",
startedAt: "2026-07-16T00:00:00.000Z",
}],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card")?.className).toContain("agent-active");
expect(container.querySelector('[data-testid="card-reviewing-FN-8055"]')?.className).toContain("pulsing");
});
it("turns off both border and Reviewing pulse when the render queue gate is active", () => {
const { container } = render(
<TaskCard
task={makeTask({
id: "FN-8055-queued",
column: "triage",
status: null as any,
enabledWorkflowSteps: ["plan-review"],
workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending", startedAt: "2026-07-16T00:00:00.000Z" }],
})}
queued
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card")?.className).not.toContain("agent-active");
expect(container.querySelector('[data-testid="card-reviewing-FN-8055-queued"]')).toBeNull();
});
/*
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
Code Review and Browser Verification surface as header badges on In-review cards while running — not as progress bullet rows.
*/
it.each([
{
id: "FN-CR",
workflowStepId: "code-review",
workflowStepName: "Code Review",
testId: "card-code-review-FN-CR",
label: "Code Review",
},
{
id: "FN-BV",
workflowStepId: "browser-verification",
workflowStepName: "Browser Verification",
testId: "card-browser-verification-FN-BV",
label: "Browser Verification",
},
])("renders a $label badge while that optional gate is running in In-review", ({ id, workflowStepId, workflowStepName, testId, label }) => {
const { container } = render(
<TaskCard
task={makeTask({
id,
column: "in-review",
status: null as any,
steps: [{ name: "Step 0", status: "done" }],
enabledWorkflowSteps: [workflowStepId],
workflowStepResults: [{
workflowStepId,
workflowStepName,
status: "pending",
startedAt: "2026-07-11T12:00:00.000Z",
}],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(`[data-testid="${testId}"]`);
expect(badge).toHaveTextContent(label);
expect(badge?.className).toContain("pulsing");
expect(container.querySelector(".card-progress")).toBeNull();
expect(container.querySelector(".card-steps-list")).toBeNull();
});
it("does not badge Code Review while the card is still in-progress", () => {
const { container } = render(
<TaskCard
task={makeTask({
id: "FN-CR-WIP",
column: "in-progress",
status: "executing" as any,
steps: [{ name: "Step 0", status: "done" }],
enabledWorkflowSteps: ["code-review"],
workflowStepResults: [{
workflowStepId: "code-review",
workflowStepName: "Code Review",
status: "pending",
startedAt: "2026-07-11T12:00:00.000Z",
}],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector('[data-testid="card-code-review-FN-CR-WIP"]')).toBeNull();
expect(screen.getByText("1/1")).toBeDefined();
});
/*
FNXC:CodingIdeasWorkflow 2026-07-21-22:18:
Coding (Ideas) Todo shows Ready for idle planned cards (steps present, status null). Plan Review also runs in Todo after finalize clears status, so Ready must not stack with the Reviewing badge while plan-review is running.
*/
it("renders Ready on an idle planned Todo card", () => {
const { container } = render(
<TaskCard
task={makeTask({
id: "FN-READY-IDLE",
column: "todo",
status: null as any,
steps: [{ id: "s1", title: "Step 1", status: "pending" }] as Task["steps"],
enabledWorkflowSteps: ["plan-review"],
workflowStepResults: [{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "passed",
startedAt: "2026-07-11T12:00:00.000Z",
completedAt: "2026-07-11T12:01:00.000Z",
}],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector('[data-testid="card-ready-FN-READY-IDLE"]')).toHaveTextContent("Ready");
expect(container.querySelector('[data-testid="card-reviewing-FN-READY-IDLE"]')).toBeNull();
});
it("does not render Ready while Plan Review is running on a status-null Todo card", () => {
const { container } = render(
<TaskCard
task={makeTask({
id: "FN-READY-REVIEW",
column: "todo",
status: null as any,
steps: [{ id: "s1", title: "Step 1", status: "pending" }] as Task["steps"],
enabledWorkflowSteps: ["plan-review"],
workflowStepResults: [{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "pending",
startedAt: "2026-07-11T12:00:00.000Z",
}],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector('[data-testid="card-ready-FN-READY-REVIEW"]')).toBeNull();
expect(container.querySelector('[data-testid="card-reviewing-FN-READY-REVIEW"]')).toHaveTextContent("Plan Review");
});
it("does not render Ready while Plan Review is running even when the queue gate hides Reviewing", () => {
const { container } = render(
<TaskCard
task={makeTask({
id: "FN-READY-QUEUED",
column: "todo",
status: null as any,
steps: [{ id: "s1", title: "Step 1", status: "pending" }] as Task["steps"],
enabledWorkflowSteps: ["plan-review"],
workflowStepResults: [{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "pending",
startedAt: "2026-07-11T12:00:00.000Z",
}],
})}
queued
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector('[data-testid="card-reviewing-FN-READY-QUEUED"]')).toBeNull();
expect(container.querySelector('[data-testid="card-ready-FN-READY-QUEUED"]')).toBeNull();
});
/*
FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
"Queued to plan" makes the planning-capacity wait visible. Symptom it fixes: a started card that
the concurrency pool had not admitted looked identical to a card nothing would happen to — the
throttle was only observable in the engine log.
Surface enumeration (invariant: exactly one of {planning status, Queued to plan, Ready} shows on
an idle Todo card, chosen by whether the card has steps and whether work is live):
- Unplanned idle Todo card -> Queued to plan, never Ready.
- Planned idle Todo card -> Ready, never Queued to plan (asserted in the Ready tests above).
- Planning in flight (status set) -> neither badge; the status badge owns the card.
- Plan Review running, agent-active, queued, and paused -> neither badge.
- Non-todo columns -> neither badge.
*/
describe("Queued to plan badge", () => {
const queuedToPlanTask = (overrides: Partial<Task> = {}) => makeTask({
id: "FN-QUEUED-PLAN",
column: "todo",
status: null as any,
steps: [] as Task["steps"],
...overrides,
});
const badge = (container: HTMLElement) =>
container.querySelector('[data-testid="card-queued-to-plan-FN-QUEUED-PLAN"]');
it("renders on an idle unplanned Todo card", () => {
const { container } = render(
<TaskCard task={queuedToPlanTask()} onOpenDetail={noop} addToast={noop} />,
);
expect(badge(container)).toHaveTextContent("Queued to plan");
// Mutually exclusive with Ready — a card is never both unplanned and planned.
expect(container.querySelector('[data-testid="card-ready-FN-QUEUED-PLAN"]')).toBeNull();
});
it("does not render once planning is in flight", () => {
const { container } = render(
<TaskCard task={queuedToPlanTask({ status: "planning" as any })} onOpenDetail={noop} addToast={noop} />,
);
expect(badge(container)).toBeNull();
});
it("does not render while Plan Review is running", () => {
const { container } = render(
<TaskCard
task={queuedToPlanTask({
enabledWorkflowSteps: ["plan-review"],
workflowStepResults: [{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "pending",
startedAt: "2026-07-11T12:00:00.000Z",
}],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(badge(container)).toBeNull();
});
/*
FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
Pause suppression matches the Ready badge exactly (`!isPaused`), for both pause flavors. The
board-level `queued` gate is deliberately NOT special-cased here, because Ready does not
special-case it either — a queued card is still genuinely waiting for a planning slot, and
forking a different suppression rule for the sibling badge in the same slot is the drift the
reuse rule exists to prevent.
*/
it("does not render on a paused card, matching Ready", () => {
for (const pauseFlag of ["paused", "userPaused"] as const) {
const { container, unmount } = render(
<TaskCard task={queuedToPlanTask({ [pauseFlag]: true })} onOpenDetail={noop} addToast={noop} />,
);
expect(badge(container), pauseFlag).toBeNull();
unmount();
}
});
it("does not render outside the todo column", () => {
for (const column of ["triage", "in-progress", "in-review", "done"] as const) {
const { container, unmount } = render(
<TaskCard task={queuedToPlanTask({ column })} onOpenDetail={noop} addToast={noop} />,
);
expect(badge(container), column).toBeNull();
unmount();
}
});
/*
FNXC:CodingIdeasWorkflow 2026-07-26-15:30:
Original symptom: this badge claimed a card was waiting for a PLANNING slot when the engine was
never going to plan it. The step count is TaskCard's own proxy for "unplanned", while every engine
lane decides from PROMPT.md seed-ness, so the two disagreed in both directions. The server now
ships that answer as `awaitingPlanning` (same `isTaskAwaitingPlanning` predicate as triage's
todo-discovery) and it OUTRANKS the step count.
Surface enumeration — every combination of (flag present/absent) x (steps present/absent):
- flag false + no steps -> Ready (the reported repro: real spec that parsed to zero steps).
- flag true + steps -> Queued to plan (the reverse mislabel: re-seeded card with stale steps).
- flag absent -> step-count fallback preserved in both directions (SSE payloads, older server).
- exactly one of the two badges renders in every case, since both derive from one value.
*/
describe("server awaitingPlanning outranks the step count", () => {
const readyBadge = (container: HTMLElement) =>
container.querySelector('[data-testid="card-ready-FN-QUEUED-PLAN"]');
it("renders Ready for a stepless card the server says is already planned", () => {
const { container } = render(
<TaskCard
task={queuedToPlanTask({ steps: [] as Task["steps"], awaitingPlanning: false })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(badge(container)).toBeNull();
expect(readyBadge(container)).toHaveTextContent("Ready");
});
it("renders Queued to plan for a card with stale steps the server says is unplanned", () => {
const { container } = render(
<TaskCard
task={queuedToPlanTask({
steps: [{ name: "stale step", status: "pending" }] as Task["steps"],
awaitingPlanning: true,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(badge(container)).toHaveTextContent("Queued to plan");
expect(readyBadge(container)).toBeNull();
});
it("falls back to the step count in both directions when the field is absent", () => {
const stepless = render(
<TaskCard task={queuedToPlanTask({ steps: [] as Task["steps"] })} onOpenDetail={noop} addToast={noop} />,
);
expect(badge(stepless.container)).toHaveTextContent("Queued to plan");
expect(readyBadge(stepless.container)).toBeNull();
stepless.unmount();
const withSteps = render(
<TaskCard
task={queuedToPlanTask({ steps: [{ name: "Step 1", status: "pending" }] as Task["steps"] })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(badge(withSteps.container)).toBeNull();
expect(readyBadge(withSteps.container)).toHaveTextContent("Ready");
});
});
});
it("renders the status badge after the card ID in DOM order", () => {
const { container } = render(
<TaskCard
task={makeTask({ status: "executing" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const cardId = container.querySelector(".card-id")!;
const badge = container.querySelector(".card-status-badge")!;
expect(cardId).toBeDefined();
expect(badge).toBeDefined();
const headerBadges = container.querySelector(".card-header-badges")!;
expect(headerBadges).toBeDefined();
expect(cardId.nextElementSibling).toBe(headerBadges);
expect(headerBadges.contains(badge)).toBe(true);
});
it("renders an active Planning badge when a status-null triage card has fresh planner activity", () => {
const recentAgentActivityAt = new Date().toISOString();
const { container } = render(
<TaskCard
task={makeTask({ column: "triage", status: null as any, recentAgentActivityAt })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card")).toHaveClass("agent-active");
expect(screen.getByLabelText("Planning")).toHaveClass("card-status-badge", "pulsing");
});
it("does not render a status badge when a status-null triage card has no fresh planner activity", () => {
const { container } = render(
<TaskCard task={makeTask({ column: "triage", status: undefined as any })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card")).not.toHaveClass("agent-active");
expect(container.querySelector(".card-status-badge")).toBeNull();
});
it("keeps board replan cards glowing and their status badge pulsing", () => {
const { container } = render(
<TaskCard
task={makeTask({ id: "FN-8494-board", column: "triage", status: "needs-replan" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card")).toHaveClass("agent-active");
expect(screen.getByText("Revising")).toHaveClass("card-status-badge", "pulsing");
});
it.each([
["global pause", { globalPaused: true }, {}],
["render queue", { queued: true }, {}],
["task pause", {}, { paused: true }],
])("suppresses board replan activity during $name", (_name, props, taskOverrides) => {
const { container } = render(
<TaskCard
task={makeTask({ id: `FN-8494-${_name}`, column: "triage", status: "needs-replan", ...taskOverrides })}
onOpenDetail={noop}
addToast={noop}
{...props}
/>,
);
expect(container.querySelector(".card")).not.toHaveClass("agent-active");
expect(container.querySelector(".card-status-badge")).not.toHaveClass("pulsing");
});
/*
* FNXC:ReleaseAuthorizationGate 2026-07-09-00:00: the triage release-authorization
* gate was removed. A legacy release-authorization hold now renders the generic
* "Awaiting Approval" badge like any manual plan-approval hold — no distinct
* release-authorization label or badge class.
*/
it("renders the generic Awaiting Approval badge for a legacy release-authorization hold", () => {
const { container: releaseContainer } = render(
<TaskCard
task={makeTask({ column: "triage", status: "awaiting-approval", awaitingApprovalReason: "release-authorization" } as any)}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(within(releaseContainer).getByText("Awaiting Approval")).toBeDefined();
expect(within(releaseContainer).queryByText("Awaiting Release Authorization")).toBeNull();
const releaseBadge = releaseContainer.querySelector(".card-status-badge") as HTMLElement;
expect(releaseBadge.className).not.toContain("awaiting-release-authorization");
});
/*
* FNXC:PlanReviewReplan 2026-07-15-11:09:
* When Plan Review exhausts automatic REVISE replans, the card must not look like a
* generic require-all hold — badge text + title explain the non-convergence reason.
*/
it("keeps the generic Awaiting Approval badge for an ordinary manual hold", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "triage", status: "awaiting-approval" } as any)}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(within(container).getByText("Awaiting Approval")).toBeDefined();
expect(container.querySelector(".awaiting-approval--plan-review-replan-cap")).toBeNull();
});
it("renders a distinct review-budget-exhausted badge when awaitingApprovalReason is plan-review-replan-cap", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "triage",
status: "awaiting-approval",
awaitingApprovalReason: "plan-review-replan-cap",
} as any)}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(within(container).getByText("Review budget exhausted")).toBeDefined();
expect(within(container).queryByText("Awaiting Approval")).toBeNull();
const badge = container.querySelector(".card-status-badge") as HTMLElement;
expect(badge.className).toContain("awaiting-approval--plan-review-replan-cap");
expect(badge.getAttribute("data-awaiting-approval-reason")).toBe("plan-review-replan-cap");
expect(badge.getAttribute("title")).toMatch(/Plan Review requested revisions/i);
});
it("renders stalled badge with visible reason when stalledReview is set", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
status: "merging",
stalledReview: {
reason: "Re-enqueued for merge 3 times in the last 60 minutes without leaving in-review",
heuristic: "reenqueue-churn",
matchCount: 3,
firstMatchAt: "2026-05-12T11:00:00.000Z",
lastMatchAt: "2026-05-12T11:50:00.000Z",
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const stalledBadge = screen.getByText("Stalled");
expect(stalledBadge.getAttribute("title")).toContain("Re-enqueued for merge 3 times");
expect(screen.getByText("Re-enqueued for merge 3 times in the last 60 minutes without leaving in-review")).toBeDefined();
});
it("does not render stalled badge when stalledReview is undefined", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
status: "merging",
stalledReview: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText("Stalled")).toBeNull();
expect(screen.queryByText(/Re-enqueued for merge/)).toBeNull();
});
it("renders retry-exhausted in-review stall badge with counter, code, and tooltip", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
status: "merging",
mergeRetries: 3,
inReviewStall: {
code: "merge-retries-exhausted",
reason: "Auto-merge retries exhausted",
observedAt: "2026-05-13T00:00:00.000Z",
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = screen.getByText("Retries exhausted 3/3");
expect(badge.getAttribute("data-stall-code")).toBe("merge-retries-exhausted");
expect(badge.getAttribute("title")).toContain("Auto-merge retries exhausted");
});
/*
FNXC:InReviewStallBadge 2026-07-26-18:12:
Inverted from "renders merge-blocker badge": the merge-blocker code is now badge-suppressed
(operator request — a pre-merge blocker is the ordinary in-review resting state, so badging it
marked routine cards abnormal). The card must show NO stall badge for this code, in any merge
status — the previous carve-out only suppressed it while isActiveMergeStatus(status) held, so
"failed" here is the case that used to badge and must now stay silent.
*/
it("suppresses the in-review stall badge for the merge-blocker code", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
status: "failed",
mergeRetries: 3,
inReviewStall: {
code: "merge-blocker",
reason: "Merge blocked by pre-merge check",
observedAt: "2026-05-13T00:00:00.000Z",
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText("Merge blocked")).toBeNull();
expect(document.querySelector('[data-stall-code="merge-blocker"]')).toBeNull();
// The suppression must not leave an empty badge shell behind.
expect(document.querySelector(".card-status-badge.in-review-stall")).toBeNull();
expect(screen.queryByText(/\/3/)).toBeNull();
});
it("FN-4570: hides merge-blocker stall badge while merge is active", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
status: "merging",
inReviewStall: {
code: "merge-blocker",
reason: "Merge blocked by pre-merge check",
observedAt: "2026-05-13T00:00:00.000Z",
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Merging")).toBeDefined();
expect(screen.queryByText("Merge blocked")).toBeNull();
});
it.each([
["merging", "Merging"],
["merging-pr", "Merging"],
["reviewing", "Merging"],
["landing", "Merging"],
["merging-fix", "Merging fixes"],
] as const)(
"FN-8482: shows compact %s badge without ellipsis while task.status is %s",
(status, expectedLabel) => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
status,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = screen.getByText(expectedLabel);
expect(badge.classList.contains("card-status-badge")).toBe(true);
expect(badge.textContent).not.toContain("…");
expect(container.querySelector(".card-status-badge")).toBe(badge);
},
);
it.each([
{
label: "paused in-review task",
task: makeTask({
column: "in-review",
paused: true,
status: "merging",
inReviewStall: {
code: "merge-retries-exhausted",
reason: "Auto-merge retries exhausted",
observedAt: "2026-05-13T00:00:00.000Z",
},
}),
},
{
label: "in-review task without inReviewStall",
task: makeTask({ column: "in-review", status: "merging", inReviewStall: undefined }),
},
{
label: "non in-review task with fabricated signal",
task: makeTask({
column: "in-progress",
status: "executing",
inReviewStall: {
code: "merge-retries-exhausted",
reason: "Auto-merge retries exhausted",
observedAt: "2026-05-13T00:00:00.000Z",
},
}),
},
])("hides in-review stall badge for $label", ({ task }) => {
render(<TaskCard task={task} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByText("Retries exhausted")).toBeNull();
});
it("renders stale paused review badge for paused in-review signal", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
paused: true,
stalePausedReview: {
code: "stale-paused-review",
reason: "Task has remained paused in review beyond threshold",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 86_400_000,
thresholdMs: 86_400_000,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Paused stall")).toBeDefined();
});
it("hides stale paused review badge when signal missing", () => {
render(
<TaskCard task={makeTask({ column: "in-review", paused: true, stalePausedReview: undefined })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.queryByText("Paused stall")).toBeNull();
});
it("hides stale paused review badge when task is not paused", () => {
render(
<TaskCard
task={makeTask({
column: "in-review",
paused: false,
stalePausedReview: {
code: "stale-paused-review",
reason: "Task has remained paused in review beyond threshold",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 86_400_000,
thresholdMs: 86_400_000,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText("Paused stall")).toBeNull();
});
it("renders warning task-age staleness badge", () => {
render(
<TaskCard
task={makeTask({
ageStaleness: {
level: "warning",
reason: "in-progress age exceeded warning threshold",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 5 * 60 * 60_000,
warningThresholdMs: 4 * 60 * 60_000,
criticalThresholdMs: 24 * 60 * 60_000,
column: "in-progress",
paused: false,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Stale")).toBeDefined();
});
it("renders critical task-age staleness badge", () => {
render(
<TaskCard
task={makeTask({
ageStaleness: {
level: "critical",
reason: "in-review age exceeded critical threshold",
observedAt: "2026-05-14T00:00:00.000Z",
ageMs: 80 * 60 * 60_000,
warningThresholdMs: 24 * 60 * 60_000,
criticalThresholdMs: 72 * 60 * 60_000,
column: "in-review",
paused: true,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Stale (critical)")).toBeDefined();
});
it("hides task-age staleness badge when signal is absent", () => {
render(<TaskCard task={makeTask({ ageStaleness: undefined })} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByText("Stale")).toBeNull();
expect(screen.queryByText("Stale (critical)")).toBeNull();
});
it("shows paused by agent label when pausedByAgentId is set", () => {
render(
<TaskCard task={makeTask({ paused: true, pausedByAgentId: "agent-1" })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByText("paused by agent")).toBeDefined();
});
it("shows plain paused label when pausedByAgentId is not set", () => {
render(
<TaskCard task={makeTask({ paused: true })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByText("paused")).toBeDefined();
expect(screen.queryByText("paused by agent")).toBeNull();
});
it("renders todo userPaused tasks as paused", () => {
const { container } = render(
<TaskCard task={makeTask({ column: "todo", paused: undefined, userPaused: true })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByText("paused")).toBeDefined();
expect(container.querySelector(".card")?.className).toContain("paused");
});
it("does not show paused by agent copy for userPaused-only tasks", () => {
render(
<TaskCard task={makeTask({ column: "todo", paused: undefined, userPaused: true, pausedByAgentId: undefined })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByText("paused")).toBeDefined();
expect(screen.queryByText("paused by agent")).toBeNull();
});
it("keeps done status badge when stale paused metadata exists", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", status: "paused", paused: true, userPaused: true, pausedByAgentId: "agent-1" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText("paused by agent")).toBeNull();
expect(screen.queryByText("paused")).toBeNull();
expect(screen.getByText("done")).toBeDefined();
expect(container.querySelector(".card")?.className).not.toContain("paused");
});
it("keeps done status badge when done task status is paused", () => {
render(
<TaskCard task={makeTask({ column: "done", status: "paused", paused: false, userPaused: false })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.queryByText("paused")).toBeNull();
expect(screen.getByText("done")).toBeDefined();
});
it("renders decision-only badge when noCommitsExpected is true", () => {
render(<TaskCard task={makeTask({ noCommitsExpected: true })} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByText("decision-only")).toBeTruthy();
});
it("hides decision-only badge when noCommitsExpected is false", () => {
render(<TaskCard task={makeTask({ noCommitsExpected: false })} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByText("decision-only")).toBeNull();
});
it("does not render fan-out badge when fanout is missing or zero", () => {
const { container, rerender } = render(
<TaskCard task={makeTask({ column: "todo" })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card-fanout-badge")).toBeNull();
rerender(
<TaskCard
task={makeTask({ column: "todo" })}
fanout={{ totalCount: 0, activeTodoCount: 0, dependentIds: [], dependencyDependentIds: [], overlapBlockedDependentIds: [], overlapBlockedActiveCount: 0, overlapBlockedTodoCount: 0, staleBlockedByDependentIds: [], isHighFanout: false }}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-fanout-badge")).toBeNull();
});
it("renders overlap scope badge when overlapBlockedBy is set without blockedBy", () => {
render(
<TaskCard
task={makeTask({ column: "todo", blockedBy: undefined, overlapBlockedBy: "FN-OVER" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("FN-OVER")).toBeInTheDocument();
});
it("does not render overlap scope badge when blockedBy and overlapBlockedBy are absent", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "todo", blockedBy: undefined, overlapBlockedBy: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-scope-badge")).toBeNull();
});
it("renders fan-out badge with downstream count and tooltip", () => {
render(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 7, activeTodoCount: 4, dependentIds: ["FN-002"], dependencyDependentIds: ["FN-002"], overlapBlockedDependentIds: [], overlapBlockedActiveCount: 0, overlapBlockedTodoCount: 0, staleBlockedByDependentIds: [], isHighFanout: false }}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toBeNull();
expect(badge.textContent).toContain("Blocks 7");
expect(badge.getAttribute("data-tooltip")).toContain("overlap blockedBy queue: 0 todo");
});
it("applies stale fan-out modifier when stale blockedBy dependents exist", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 3, activeTodoCount: 1, dependentIds: ["FN-003"], dependencyDependentIds: [], overlapBlockedDependentIds: ["FN-003"], overlapBlockedActiveCount: 1, overlapBlockedTodoCount: 1, staleBlockedByDependentIds: ["FN-003"], isHighFanout: false }}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-fanout-badge") as HTMLElement;
expect(badge.className).toContain("card-fanout-badge--stale");
expect(badge.textContent).toContain("(1 stale)");
});
it("renders overlap bottleneck badge without visible todo suffix while keeping tooltip context", () => {
render(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={highFanout}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = screen.getByText("Overlap bottleneck").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toBeNull();
expect(badge.textContent).toContain("Overlap bottleneck 7");
expect(badge.textContent).not.toContain("todo)");
expect(badge.getAttribute("data-tooltip")).toContain("overlap blockedBy queue: 3 todo");
});
it("escalates only threshold-crossing fan-out badges", () => {
const { rerender } = render(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={{
...highFanout,
totalCount: 8,
activeTodoCount: 5,
overlapBlockedTodoCount: 5,
overlapBlockedActiveCount: 8,
dependentIds: ["FN-003"],
escalation: { blockerId: "FN-001", activeTodoCount: 5, totalActiveCount: 8, blockingAgeMs: 3_600_000 },
}}
onOpenDetail={noop}
addToast={noop}
/>,
);
let badge = screen.getByText("Escalated overlap").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toBeNull();
expect(badge.textContent).toContain("Escalated");
expect(badge.textContent).toContain("8");
expect(badge.textContent).not.toContain("todo)");
rerender(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 8, activeTodoCount: 4, dependentIds: ["FN-003"], dependencyDependentIds: ["FN-003"], overlapBlockedDependentIds: [], overlapBlockedActiveCount: 0, overlapBlockedTodoCount: 0, staleBlockedByDependentIds: [], isHighFanout: false }}
onOpenDetail={noop}
addToast={noop}
/>,
);
badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toBeNull();
});
it("shows plain paused label when pausedByAgentId is not set", () => {
render(
<TaskCard task={makeTask({ paused: true })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByText("paused")).toBeDefined();
expect(screen.queryByText("paused by agent")).toBeNull();
});
it("hides default working branch and default base branch metadata", () => {
const { container } = render(
<TaskCard
task={makeTask({ branch: "fusion/fn-001", baseBranch: "main" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-branch-row")).toBeNull();
});
it("hides auto-generated suffixed default working branches", () => {
const { container } = render(
<TaskCard
task={makeTask({ branch: "fusion/fn-001-2", baseBranch: "main" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-branch-row")).toBeNull();
});
it("shows only custom working branch metadata when base branch is default", () => {
render(
<TaskCard
task={makeTask({ branch: "feature/working-only", baseBranch: "main" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Branch")).toBeDefined();
expect(screen.getByText("feature/working-only")).toBeDefined();
expect(screen.queryByText("Base")).toBeNull();
});
it("shows only non-default base branch metadata when working branch is default", () => {
render(
<TaskCard
task={makeTask({ branch: "fusion/fn-001", baseBranch: "release/2026-05" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Base")).toBeDefined();
expect(screen.getByText("release/2026-05")).toBeDefined();
expect(screen.queryByText("Branch")).toBeNull();
});
it("renders merge target from task.baseBranch, not prInfo.baseBranch metadata", () => {
render(
<TaskCard
task={makeTask({
branch: "fusion/fn-001",
baseBranch: "release/task-target",
prInfo: {
url: "https://github.com/runfusion/fusion/pull/10",
number: 10,
status: "open",
title: "PR title",
headBranch: "feature/pr-head",
baseBranch: "main",
commentCount: 0,
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Base")).toBeDefined();
expect(screen.getByText("release/task-target")).toBeDefined();
expect(screen.queryByText("main")).toBeNull();
});
it("shows both chips when branch and base branch are both non-default", () => {
const { container } = render(
<TaskCard
task={makeTask({ branch: "feature/fn-3423-card-branches", baseBranch: "develop" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const branchRow = container.querySelector(".card-branch-row");
expect(branchRow).not.toBeNull();
expect(screen.getByText("Branch")).toBeDefined();
expect(screen.getByText("feature/fn-3423-card-branches")).toBeDefined();
expect(screen.getByText("Base")).toBeDefined();
expect(screen.getByText("develop")).toBeDefined();
});
it("shows shared group chip with shared branch label for grouped tasks", () => {
render(
<TaskCard
task={makeTask({
branch: "feature/shared-branch",
branchContext: { groupId: "BG-22", source: "planning", assignmentMode: "shared" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Shared")).toBeDefined();
expect(screen.getAllByText("feature/shared-branch").length).toBeGreaterThan(0);
});
it("opens group modal from shared branch chip", () => {
const onOpenGroupModal = vi.fn();
render(
<TaskCard
task={makeTask({
branch: "feature/shared-branch",
branchContext: { groupId: "BG-22", source: "planning", assignmentMode: "shared" },
})}
onOpenDetail={noop}
onOpenGroupModal={onOpenGroupModal}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Shared"));
expect(onOpenGroupModal).toHaveBeenCalledWith("BG-22");
});
it("keeps long non-default branch names readable via text and title semantics", () => {
const longBranch = "feature/fn-3423-display-very-long-working-branch-name-for-card-metadata";
const { container } = render(
<TaskCard
task={makeTask({ branch: longBranch, baseBranch: "main" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const branchChip = container.querySelector(".card-branch-chip");
expect(branchChip?.getAttribute("title")).toBe(longBranch);
expect(screen.getByText(longBranch)).toBeDefined();
});
it("renders fast-mode indicator only when executionMode is fast", () => {
const { container, rerender } = render(
<TaskCard task={makeTask({ executionMode: "fast" })} onOpenDetail={noop} addToast={noop} />,
);
const fastBadge = container.querySelector(".card-execution-mode-badge");
expect(fastBadge).not.toBeNull();
expect(screen.getByTestId("icon-zap")).toBeDefined();
expect(fastBadge?.getAttribute("aria-label")).toBe("Fast mode");
rerender(
<TaskCard task={makeTask({ executionMode: "standard" })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card-execution-mode-badge")).toBeNull();
});
it("updates fast-mode indicator when executionMode changes", () => {
const { container, rerender } = render(
<TaskCard task={makeTask({ executionMode: "standard" })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card-execution-mode-badge")).toBeNull();
rerender(
<TaskCard task={makeTask({ executionMode: "fast" })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card-execution-mode-badge")).not.toBeNull();
expect(screen.getByTestId("icon-zap")).toBeDefined();
});
it("keeps priority and fast mode in meta while agent-created and time move to bottom rows", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
priority: "high",
executionMode: "fast",
sourceType: "automation",
sourceMetadata: { agentName: "Task Robot" },
executionStartedAt: "2026-04-25T13:00:00.000Z",
executionCompletedAt: "2026-04-25T15:00:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const group = container.querySelector(".card-meta-badges");
expect(group).not.toBeNull();
const expectedMetaSelectors = [
".card-priority-badge",
".card-execution-mode-badge",
];
expectedMetaSelectors.forEach((selector) => {
const badge = container.querySelector(selector);
expect(badge).not.toBeNull();
expect(badge?.closest(".card-meta-badges")).toBe(group);
});
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.closest(".card-meta-badges")).toBeNull();
expect(timer?.closest(".card-footer-row-right")).not.toBeNull();
// FNXC:PlannerOversight 2026-07-04-00:00: an unset per-task oversight
// override resolving to the inherited schema default ("autonomous") no
// longer renders a per-card badge (FN-7539) — an inherited default is not
// meaningfully-configured oversight, so it does not appear among the
// opt-in meta badges here.
const agentBadge = container.querySelector(".card-agent-created-badge");
expect(agentBadge).not.toBeNull();
expect(agentBadge?.closest(".card-agent-badge-row")).not.toBeNull();
expect(agentBadge?.closest(".card-meta-badges")).toBeNull();
expect(agentBadge?.closest(".card-header")).toBeNull();
expect(Array.from(group?.children ?? []).map((child) => child.className)).toEqual([
"card-priority-badge card-priority-badge--high",
"card-execution-mode-badge card-execution-mode-badge--fast",
]);
});
it("renders icon-only urgency-colored priority badges with accessible labels while normal stays hidden", () => {
for (const priority of TASK_PRIORITIES) {
const { container, unmount } = render(
<TaskCard
task={makeTask({ priority: priority as TaskPriority })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-priority-badge");
if (priority === "normal") {
expect(badge).toBeNull();
unmount();
continue;
}
const label = getPriorityLabel(priority);
expect(badge).not.toBeNull();
expect(badge).toHaveAttribute("aria-label", label);
expect(badge).toHaveAttribute("title", label);
expect(Array.from(badge?.childNodes ?? []).filter((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())).toEqual([]);
const visibleLabelSpans = Array.from(badge?.querySelectorAll("span") ?? []).filter((span) => !span.classList.contains("visually-hidden"));
expect(visibleLabelSpans).toEqual([]);
expect(badge?.querySelector(".visually-hidden")).toHaveTextContent(label);
const icon = badge?.querySelector("svg");
expect(icon).not.toBeNull();
expect(icon).toHaveAttribute("aria-hidden", "true");
expect(icon?.getAttribute("style")).toContain(`color: ${getPriorityColorVar(priority)}`);
unmount();
}
const { container } = render(
<TaskCard
task={makeTask({ priority: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-priority-badge")).toBeNull();
});
it("renders partial card meta groups without empty wrappers when time is absent", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "triage",
priority: "urgent",
executionMode: "fast",
sourceType: "automation",
sourceMetadata: { agentName: "Task Robot" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const group = container.querySelector(".card-meta-badges");
expect(group).not.toBeNull();
expect(group?.querySelector(".card-priority-badge")).not.toBeNull();
expect(group?.querySelector(".card-execution-mode-badge")).not.toBeNull();
expect(group?.querySelector(".card-agent-created-badge")).toBeNull();
expect(container.querySelector(".card-agent-created-badge")?.closest(".card-agent-badge-row")).not.toBeNull();
expect(group?.querySelector(".card-time-indicator")).toBeNull();
expect(container.querySelector(".card-footer-row")).toBeNull();
expect(container.querySelector(".card-footer-row-right")).toBeNull();
});
it("moves a lone time chip into the footer without rendering empty meta badges", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:05:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
columnMovedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:00:00.000Z",
// FNXC:PlannerOversight 2026-07-04-00:00: pin the oversight level "off" so
// the FN-7516 oversight/overseer-state badges don't populate
// .card-meta-badges here — this test is specifically about the
// lone-time-chip footer placement, not the oversight badges.
plannerOversightLevel: "off",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const group = container.querySelector(".card-meta-badges");
const timer = container.querySelector(".card-time-indicator");
const footerRow = container.querySelector(".card-footer-row");
const rightCluster = container.querySelector(".card-footer-row-right");
expect(group).toBeNull();
expect(timer).not.toBeNull();
expect(footerRow).not.toBeNull();
expect(rightCluster).not.toBeNull();
expect(timer?.closest(".card-footer-row-right")).toBe(rightCluster);
expect(Array.from(rightCluster?.children ?? [])).toEqual([timer]);
expect(container.querySelector(".card-priority-badge")).toBeNull();
expect(container.querySelector(".card-execution-mode-badge")).toBeNull();
expect(container.querySelector(".card-agent-created-badge")).toBeNull();
expect(container.querySelector(".card-agent-badge-row")).toBeNull();
});
it("does not render card meta badge shells when all grouped affordances are absent", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
priority: "normal",
executionMode: "standard",
sourceType: "dashboard_ui",
// FNXC:PlannerOversight 2026-07-04-00:00: an unset oversight override now
// resolves to the schema default ("autonomous") and renders a badge
// (FN-7516) — pin the level explicitly "off" here so this test keeps
// asserting the ORIGINAL affordance set (priority/fast-mode/agent-created)
// is what determines the wrapper's presence.
plannerOversightLevel: "off",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-meta-badges")).toBeNull();
expect(container.querySelector(".card-footer-row")).toBeNull();
expect(container.querySelector(".card-footer-row-right")).toBeNull();
});
it("defines responsive flex-wrap styling for grouped card meta badges", () => {
const fullCss = loadAllAppCss();
expect(fullCss).toMatch(/\.card-meta-badges\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*gap:\s*var\(--space-xs\);[^}]*\}/);
expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-meta-badges\s*\{[^}]*gap:\s*calc\(var\(--space-xs\) \/ 2\);[^}]*\}/);
});
describe("retry button on failed tasks", () => {
it("renders when task is failed and onRetryTask is provided", () => {
const onRetryTask = vi.fn(async () => ({}) as Task);
render(
<TaskCard
task={makeTask({ column: "todo", status: "failed", error: "Executor crashed" })}
onOpenDetail={noop}
addToast={noop}
onRetryTask={onRetryTask}
/>,
);
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
});
it("does not render for non-failed tasks", () => {
const onRetryTask = vi.fn(async () => ({}) as Task);
render(
<TaskCard task={makeTask({ column: "todo", status: "done", error: "Executor crashed" })} onOpenDetail={noop} addToast={noop} onRetryTask={onRetryTask} />,
);
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("does not render card-error banner for auto-recovered transient row", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
status: undefined,
error: undefined,
log: [{ timestamp: new Date().toISOString(), action: "Auto-recovered: retry/verification session targeted unusable worktree" }],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-error")).toBeNull();
});
it("suppresses failed chrome and Retry while a stale failed task has automatic recovery pending", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
status: "failed",
error: "Transient provider error",
recoveryRetryCount: 1,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
})}
onOpenDetail={noop}
addToast={noop}
onRetryTask={vi.fn(async () => ({}) as Task)}
/>,
);
expect(container.querySelector(".card-error")).toBeNull();
expect(container.querySelector(".card.failed")).toBeNull();
expect(container.querySelector(".card-status-badge.failed")).toBeNull();
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
it("calls onRetryTask with task id", async () => {
const onRetryTask = vi.fn(async () => ({}) as Task);
render(
<TaskCard task={makeTask({ column: "todo", status: "failed", error: "Executor crashed" })} onOpenDetail={noop} addToast={noop} onRetryTask={onRetryTask} />,
);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(onRetryTask).toHaveBeenCalledWith("FN-001"));
});
it("shows loading and disabled state while retry is in progress", async () => {
let resolveRetry: ((value: Task) => void) | null = null;
const onRetryTask = vi.fn(() => new Promise<Task>((resolve) => { resolveRetry = resolve; }));
render(
<TaskCard task={makeTask({ column: "todo", status: "failed", error: "Executor crashed" })} onOpenDetail={noop} addToast={noop} onRetryTask={onRetryTask} />,
);
const button = screen.getByRole("button", { name: "Retry" }) as HTMLButtonElement;
fireEvent.click(button);
expect(screen.getByRole("button", { name: "Retrying…" })).toBeDefined();
expect(button.disabled).toBe(true);
await act(async () => {
resolveRetry?.({} as Task);
});
await waitFor(() => expect(screen.getByRole("button", { name: "Retry" })).toBeDefined());
});
it("shows toast when retry fails", async () => {
const addToast = vi.fn();
const onRetryTask = vi.fn(async () => {
throw new Error("network down");
});
render(
<TaskCard task={makeTask({ column: "todo", status: "failed", error: "Executor crashed" })} onOpenDetail={noop} addToast={addToast} onRetryTask={onRetryTask} />,
);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to retry FN-001: network down", "error");
});
});
});
it("renders unified progress counts for task steps + non-lane workflow checks", () => {
render(
<TaskCard
task={makeTask({
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "pending" },
],
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003"],
workflowStepResults: [
{
workflowStepId: "WS-001",
workflowStepName: "Browser Verification",
status: "passed",
},
{
workflowStepId: "WS-002",
workflowStepName: "Frontend UX Design",
status: "failed",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("2/5")).toBeDefined();
expect(screen.getByText("5 steps")).toBeDefined();
});
/*
FNXC:TaskCardWorkflowProgress 2026-07-21-22:26:
In-progress progress is WIP-only. Plan Review (Todo) and Code Review (In-review) must not appear in the card checklist or completed/total counts.
*/
it("excludes Plan Review and Code Review from in-progress progress counts", () => {
render(
<TaskCard
task={makeTask({
column: "in-progress",
status: "executing" as any,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "pending" },
],
enabledWorkflowSteps: ["plan-review", "code-review"],
workflowStepResults: [
{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "passed",
startedAt: "2026-07-11T12:00:00.000Z",
completedAt: "2026-07-11T12:01:00.000Z",
},
{
workflowStepId: "code-review",
workflowStepName: "Code Review",
status: "pending",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("1/2")).toBeDefined();
expect(screen.getByText("2 steps")).toBeDefined();
expect(screen.queryByText("1/4")).toBeNull();
expect(screen.queryByText("Plan Review")).toBeNull();
expect(screen.queryByText("Code Review")).toBeNull();
});
it("surfaces in-progress implementation steps on the collapsed card", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
status: "executing" as any,
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("1/3")).toBeDefined();
expect(screen.getByText("1 active")).toBeDefined();
expect(screen.getByText("active")).toBeDefined();
expect(container.querySelector(".card-step-name.active")?.textContent).toBe("Step 1");
// FN-7676: the steps breakdown must still render once a task is out of Planning (`in-progress`/`executing`).
expect(container.querySelector(".card-steps-toggle")).not.toBeNull();
expect(container.querySelector(".card-progress")).not.toBeNull();
});
it("hides the steps breakdown while the task is still in triage, even with a running Plan Review", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "triage",
status: "planning" as any,
steps: [],
enabledWorkflowSteps: ["plan-review", "code-review"],
workflowStepResults: [
{
workflowStepId: "plan-review",
workflowStepName: "Plan Review",
status: "pending",
startedAt: "2026-07-04T00:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-progress")).toBeNull();
expect(container.querySelector(".card-steps-toggle")).toBeNull();
expect(container.querySelector(".card-steps-list")).toBeNull();
expect(screen.queryByText("0/2")).toBeNull();
expect(screen.queryByText("1 active")).toBeNull();
});
it("does not render the steps toggle for a triage card with populated steps", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "triage",
status: "planning" as any,
steps: Array.from({ length: 10 }, (_, i) => ({ name: `Step ${i}`, status: "pending" as const })),
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-progress")).toBeNull();
expect(container.querySelector(".card-steps-toggle")).toBeNull();
expect(container.querySelector(".card-steps-list")).toBeNull();
expect(screen.queryByText("0/10")).toBeNull();
expect(screen.queryByText("10 steps")).toBeNull();
});
it("does not show a false triage active indicator for enabled-but-not-started Plan Review", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "triage",
status: "planning" as any,
steps: [],
enabledWorkflowSteps: ["plan-review", "code-review"],
workflowStepResults: [],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText("1 active")).toBeNull();
expect(container.querySelector(".card-progress")).toBeNull();
expect(container.querySelector(".card-steps-toggle")).toBeNull();
});
it("does not render an empty triage progress shell without workflow progress", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "triage",
status: undefined as any,
steps: [],
enabledWorkflowSteps: undefined,
workflowStepResults: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-progress")).toBeNull();
expect(container.querySelector(".card-steps-toggle")).toBeNull();
});
it("uses singular step label when unified progress total is one", () => {
render(
<TaskCard
task={makeTask({
steps: [{ name: "Step 0", status: "done" }],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("1 step")).toBeDefined();
expect(screen.queryByText("1 steps")).toBeNull();
});
// FNXC:WorkflowSteps 2026-06-25-00:00 — graph-written results drive the card progress; names come from
// result.workflowStepName (with raw-id fallback), and advisory_failure (amber) is visually distinct
// from failed (red). No board-level name lookup is involved.
// FNXC:WorkflowSteps 2026-06-30-12:00 — expanded task-card rows deliberately omit the redundant workflow text badge; tests preserve mixed implementation/workflow visibility through names, status dots, and the active badge instead.
it("renders workflow checks after normal steps with graph-written statuses and no workflow text badges", () => {
const { container } = render(
<TaskCard
task={makeTask({
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "failed" as any },
],
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003", "WS-004", "WS-005"],
workflowStepResults: [
{
workflowStepId: "WS-001",
workflowStepName: "Browser Verification",
status: "passed",
},
{
workflowStepId: "WS-002",
workflowStepName: "Frontend UX Design",
status: "advisory_failure",
phase: "post-merge",
},
{
workflowStepId: "WS-004",
workflowStepName: "Code Review Gate",
status: "failed",
},
{
workflowStepId: "WS-005",
workflowStepName: "Merge Validation",
status: "pending",
startedAt: "2026-06-25T00:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
// WS-003 has no result → name falls back to the display-normalized id; all others resolve from result.workflowStepName.
expect(stepNames).toEqual([
"Step 0",
"Step 1",
"Browser Verification",
"Frontend UX Design",
"WS 003",
"Code Review Gate",
"Merge Validation",
]);
const dots = container.querySelectorAll(".card-step-dot");
// Impl step failure → blocking red.
expect(dots[1]?.className).toContain("card-step-dot--failed");
expect(dots[1]?.className).not.toContain("card-step-dot--advisory_failure");
// Passed workflow step → done.
expect(dots[2]?.className).toContain("card-step-dot--done");
// advisory_failure → amber, NOT the blocking red failed class.
expect(dots[3]?.className).toContain("card-step-dot--advisory_failure");
expect(dots[3]?.className).not.toContain("card-step-dot--failed");
// Enabled-but-not-run → pending.
expect(dots[4]?.className).toContain("card-step-dot--pending");
// Gate failure → blocking red, NOT amber advisory.
expect(dots[5]?.className).toContain("card-step-dot--failed");
expect(dots[5]?.className).not.toContain("card-step-dot--advisory_failure");
// Started-but-not-finished workflow step → running with the same active badge as implementation steps.
expect(dots[6]?.className).toContain("card-step-dot--running");
expect(dots[6]?.className).not.toContain("card-step-dot--pending");
expect(container.querySelector(".card-step-active-badge")?.textContent).toBe("active");
const workflowBadgeElements = container.querySelectorAll(".card-step-workflow-badge");
expect(workflowBadgeElements).toHaveLength(0);
expect(container.querySelector('[title="Workflow check"]')).toBeNull();
expect(Array.from(container.querySelectorAll(".card-step-item")).some((item) => item.textContent === "workflow")).toBe(false);
});
it("renders the running state for a started-but-not-completed workflow step", () => {
const { container } = render(
<TaskCard
task={makeTask({
enabledWorkflowSteps: ["WS-001"],
workflowStepResults: [
{
workflowStepId: "WS-001",
workflowStepName: "Browser Verification",
status: "pending",
startedAt: "2026-06-25T00:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const dots = container.querySelectorAll(".card-step-dot");
expect(dots[0]?.className).toContain("card-step-dot--running");
expect(dots[0]?.className).not.toContain("card-step-dot--pending");
});
it("falls back to the raw workflow step ID when the result name is blank", () => {
const { container } = render(
<TaskCard
task={makeTask({
enabledWorkflowSteps: ["WS-002", "WS-003"],
workflowStepResults: [
{
workflowStepId: "WS-002",
workflowStepName: " ",
status: "passed",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
// Blank result name → display-normalized id; WS-003 (no result) → display-normalized id.
expect(stepNames).toEqual(["WS 002", "WS 003"]);
});
it("shows drop indicator on file dragover and removes on dragleave", () => {
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
);
const card = container.querySelector(".card")!;
// Simulate file dragover
fireEvent.dragOver(card, {
dataTransfer: { types: ["Files"], dropEffect: "none" },
});
expect(card.classList.contains("file-drop-target")).toBe(true);
// Simulate dragleave
fireEvent.dragLeave(card, {
dataTransfer: { types: ["Files"] },
});
expect(card.classList.contains("file-drop-target")).toBe(false);
});
it("does not show drop indicator for non-file drag", () => {
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
);
const card = container.querySelector(".card")!;
// Simulate card dragover (not files)
fireEvent.dragOver(card, {
dataTransfer: { types: ["text/plain"], dropEffect: "none" },
});
expect(card.classList.contains("file-drop-target")).toBe(false);
});
it("calls uploadAttachment on file drop", async () => {
const mockUpload = vi.mocked(uploadAttachment);
mockUpload.mockResolvedValue({
filename: "abc-test.png",
originalName: "test.png",
mimeType: "image/png",
size: 1024,
createdAt: new Date().toISOString(),
});
const addToast = vi.fn();
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
);
const card = container.querySelector(".card")!;
const file = new File(["content"], "test.png", { type: "image/png" });
fireEvent.drop(card, {
dataTransfer: { types: ["Files"], files: [file] },
});
await waitFor(() => {
expect(mockUpload).toHaveBeenCalledWith("FN-001", file, undefined);
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Attached test.png"),
"success",
);
});
});
it("shows in-review files-changed chip from modifiedFiles fallback when no worktree diff is available", () => {
const onOpenDetailWithTab = vi.fn();
const task = makeTask({
column: "in-review",
worktree: undefined,
modifiedFiles: ["packages/dashboard/app/App.tsx", "packages/dashboard/app/styles.css"],
});
render(
<TaskCard
task={task}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={onOpenDetailWithTab}
/>,
);
const filesChangedButton = screen.getByRole("button", { name: "2 files changed" });
expect(filesChangedButton).toBeDefined();
expect((filesChangedButton as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(filesChangedButton);
expect(onOpenDetailWithTab).toHaveBeenCalledWith(task, "changes");
});
it("shows in-progress files-changed chip from modifiedFiles fallback when no live diff is available", () => {
const onOpenDetailWithTab = vi.fn();
const task = makeTask({
column: "in-progress",
worktree: undefined,
modifiedFiles: ["packages/core/src/store.ts", "packages/core/src/types.ts"],
});
render(
<TaskCard
task={task}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={onOpenDetailWithTab}
/>,
);
const filesChangedButton = screen.getByRole("button", { name: "2 files changed" });
expect(filesChangedButton).toBeDefined();
expect((filesChangedButton as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(filesChangedButton);
expect(onOpenDetailWithTab).toHaveBeenCalledWith(task, "changes");
});
it("shows error toast when upload fails", async () => {
const mockUpload = vi.mocked(uploadAttachment);
mockUpload.mockRejectedValue(new Error("Upload failed"));
const addToast = vi.fn();
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
);
const card = container.querySelector(".card")!;
const file = new File(["content"], "bad.png", { type: "image/png" });
fireEvent.drop(card, {
dataTransfer: { types: ["Files"], files: [file] },
});
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to attach bad.png"),
"error",
);
});
});
// Size badge positioning regression tests (KB-197)
it("renders size badge for sized tasks", () => {
const { container } = render(
<TaskCard task={makeTask({ size: "S" })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card-size-badge")).not.toBeNull();
expect(screen.getByText("S")).toBeDefined();
});
it("does not render size badge when task has no size", () => {
const { container } = render(
<TaskCard task={makeTask({ size: undefined })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card-size-badge")).toBeNull();
});
it("renders all three size values with correct CSS classes", () => {
const sizes: Array<"S" | "M" | "L"> = ["S", "M", "L"];
const expectedClasses = ["size-s", "size-m", "size-l"];
sizes.forEach((size, index) => {
const { container } = render(
<TaskCard task={makeTask({ size })} onOpenDetail={noop} addToast={noop} />,
);
const header = container.querySelector(".card-header");
const cardId = container.querySelector(".card-id");
const badge = container.querySelector(".card-size-badge");
expect(badge).not.toBeNull();
expect(badge?.classList.contains(expectedClasses[index])).toBe(true);
expect(container.querySelectorAll(".card-size-badge")).toHaveLength(1);
expect(badge?.parentElement).toBe(header);
expect(cardId?.nextElementSibling).toBe(badge);
// Clean up for next iteration
container.remove();
});
});
it("places size badge directly after the task id outside header badge and action groups", () => {
const { container } = render(
<TaskCard task={makeTask({ size: "M" })} onOpenDetail={noop} addToast={noop} />,
);
const header = container.querySelector(".card-header");
const cardId = container.querySelector(".card-id");
const headerBadges = container.querySelector(".card-header-badges");
const actionsContainer = container.querySelector(".card-header-actions");
const sizeBadge = container.querySelector(".card-size-badge");
expect(header).not.toBeNull();
expect(sizeBadge).not.toBeNull();
expect(sizeBadge?.parentElement).toBe(header);
expect(cardId?.nextElementSibling).toBe(sizeBadge);
expect(actionsContainer?.contains(sizeBadge)).toBe(false);
expect(headerBadges?.contains(sizeBadge) ?? false).toBe(false);
expect(sizeBadge?.closest(".card-header-badges")).toBeNull();
});
it("keeps trailing controls in header actions after the id-adjacent size badge", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-progress", status: "executing" as any, size: "M" })}
onOpenDetail={noop}
addToast={noop}
onPauseTask={async () => makeTask({ paused: true })}
/>,
);
const header = container.querySelector(".card-header");
const cardId = container.querySelector(".card-id");
const headerBadges = container.querySelector(".card-header-badges");
const actionsContainer = container.querySelector(".card-header-actions") as HTMLElement | null;
const menuButton = container.querySelector(".card-menu-btn");
const sizeBadge = container.querySelector(".card-size-badge");
expect(actionsContainer).not.toBeNull();
expect(menuButton).not.toBeNull();
expect(sizeBadge).not.toBeNull();
expect(sizeBadge?.parentElement).toBe(header);
expect(cardId?.nextElementSibling).toBe(sizeBadge);
expect(sizeBadge?.compareDocumentPosition(headerBadges!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(actionsContainer?.contains(menuButton)).toBe(true);
expect(actionsContainer?.contains(sizeBadge)).toBe(false);
});
it("places card-header-actions as a direct header child after the wrapped badge group", () => {
const { container } = render(
<TaskCard
task={makeTask({ size: "S", priority: "urgent" as Task["priority"], executionMode: "fast" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const header = container.querySelector(".card-header")!;
const cardId = container.querySelector(".card-id")!;
const headerBadges = container.querySelector(".card-header-badges")!;
const actionsContainer = container.querySelector(".card-header-actions")!;
expect(cardId).not.toBeNull();
expect(headerBadges).not.toBeNull();
expect(actionsContainer).not.toBeNull();
expect(actionsContainer.parentElement).toBe(header);
expect(headerBadges.parentElement).toBe(header);
expect(
cardId.compareDocumentPosition(headerBadges) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(
headerBadges.compareDocumentPosition(actionsContainer) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});
it("renders edit button inside card-header-actions for editable columns", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "todo", size: "S" })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={async () => makeTask()}
/>,
);
const actionsContainer = container.querySelector(".card-header-actions");
const editBtn = container.querySelector(".card-edit-btn");
expect(actionsContainer).not.toBeNull();
expect(editBtn).not.toBeNull();
expect(actionsContainer?.contains(editBtn)).toBe(true);
});
it("renders the done-card three-dot menu inside card-header-actions", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", size: "L" })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={async () => makeTask()}
/>,
);
const actionsContainer = container.querySelector(".card-header-actions");
const menuButton = screen.getByTestId("card-menu-btn-FN-001");
expect(actionsContainer).not.toBeNull();
expect(container.querySelector(".card-archive-btn")).toBeNull();
expect(container.querySelector(".card-done-actions")).toBeNull();
expect(screen.queryByRole("button", { name: "Actions" })).toBeNull();
expect(actionsContainer?.contains(menuButton)).toBe(true);
});
it.each([
{ name: "meta-row overlap badge", task: makeTask({ column: "in-review", overlapBlockedBy: "FN-OVER", blockedBy: undefined }), queued: false },
{ name: "meta-row queued badge", task: makeTask({ column: "in-review", status: "queued" as any, dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined }), queued: true },
{ name: "no-meta action-row placement", task: makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any }), queued: false },
])("uses the three-dot menu as the only in-review move entry point for $name", ({ task, queued }) => {
const onMoveTask = vi.fn();
const { container } = render(
<TaskCard
task={task}
queued={queued}
onOpenDetail={noop}
addToast={noop}
onMoveTask={onMoveTask}
/>,
);
expect(container.querySelector(".card-send-back")).toBeNull();
expect(screen.queryByRole("button", { name: "Move task" })).toBeNull();
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
expect(screen.getByRole("menuitem", { name: "Done (no merge)" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
fireEvent.click(screen.getByRole("menuitem", { name: "Done (no merge)" }));
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "done", undefined);
});
it("uses the three-dot menu for every in-progress move target without a Send back shell", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-progress" })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
expect(container.querySelector(".card-send-back")).toBeNull();
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
fireEvent.click(screen.getByTestId("card-menu-btn-FN-001"));
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
});
it("does not render a move shell when onMoveTask is absent", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-send-back")).toBeNull();
expect(screen.queryByRole("button", { name: "Move task" })).toBeNull();
expect(screen.queryByRole("button", { name: "Send back" })).toBeNull();
});
it("shows timer chip for in-progress cards summing workflow runtime + timed events", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T12:00:00.000Z",
completedAt: "2026-04-25T12:08:00.000Z",
},
],
log: [
{
timestamp: "2026-04-25T12:09:00.000Z",
action: "[timing] llm_call in 240000ms",
outcome: "",
} as unknown as Task["log"][number],
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expectTimerInFooterRight(container);
// 8m workflow + 4m timed = 12m
expect(timer?.textContent).toContain("12m");
expect(timer?.getAttribute("title")).toContain("In progress 12m");
});
it("updates the in-progress timer when timedExecutionMs changes", () => {
const { container, rerender } = render(
<TaskCard
task={makeTask({
column: "in-progress",
timedExecutionMs: 60_000,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1m");
expectTimerInFooterRight(container);
rerender(
<TaskCard
task={makeTask({
column: "in-progress",
timedExecutionMs: 120_000,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("2m");
expectTimerInFooterRight(container);
});
it("shows timer chip for done cards summing workflow runtime + timed events", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T15:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T13:00:00.000Z",
completedAt: "2026-04-25T14:00:00.000Z",
},
],
log: [
{
timestamp: "2026-04-25T14:30:00.000Z",
action: "[timing] llm_call in 3600000ms",
outcome: "",
} as unknown as Task["log"][number],
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expectTimerInFooterRight(container);
// 1h workflow + 1h timed = 2h
expect(timer?.textContent).toContain("2h");
expect(timer?.getAttribute("title")).toContain("Execution time 2h");
expect(timer?.getAttribute("title")).toContain("Completed");
});
it("renders GitHub provenance marker for github_import tasks", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const footerRow = container.querySelector(".card-footer-row");
const provenance = container.querySelector(".card-source-provenance");
expect(footerRow).not.toBeNull();
expect(provenance).not.toBeNull();
expect(provenance?.getAttribute("title")).toContain("https://github.com/owner/repo/issues/42");
expect(screen.getByTestId("provider-icon-github")).toBeDefined();
});
it("does not render GitHub provenance marker for non-imported tasks", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-source-provenance")).toBeNull();
expect(screen.queryByTestId("provider-icon-github")).toBeNull();
});
it("renders a GitHub tracking link for tracked issues on non-imported tasks", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const link = screen.getByRole("link", { name: "Linked GitHub issue #42" });
const bottomRightRow = container.querySelector(".card-bottom-right-row");
const footerRow = container.querySelector(".card-footer-row");
expect(link.getAttribute("href")).toBe("https://github.com/owner/repo/issues/42");
expect(link.getAttribute("title")).toBe("Linked GitHub issue: owner/repo#42");
expect(link).toHaveClass("card-github-tracking-chip", "card-github-tracking-link");
expect(link).toHaveTextContent("#42");
expect(footerRow).not.toBeNull();
expect(footerRow?.contains(link)).toBe(true);
expect(bottomRightRow).toBeNull();
expect(screen.getByTestId("provider-icon-github")).toBeDefined();
});
it("renders the GitHub tracking link inline with queued metadata when the footer has no leading content", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
status: "queued",
sourceType: "dashboard_ui",
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const link = screen.getByRole("link", { name: "Linked GitHub issue #42" });
const metaRow = container.querySelector(".card-meta");
const queuedBadge = container.querySelector(".queued-badge");
expect(container.querySelector(".card-footer-row")).toBeNull();
expect(link.closest(".card-meta")).toBe(metaRow);
expect(link.closest(".card-footer-row-right")?.closest(".card-meta")).toBe(metaRow);
expect(container.querySelector(".card-bottom-right-row")).toBeNull();
expect(queuedBadge).not.toBeNull();
expect(queuedBadge?.compareDocumentPosition(link) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("renders tracking, retry, and timer in the footer right cluster", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
sourceType: "dashboard_ui",
retrySummary: { total: 3 } as any,
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
executionStartedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:12:00.000Z",
// FNXC:PlannerOversight 2026-07-04-00:00: pin the oversight level "off" so
// the FN-7516 oversight/overseer-state badges don't populate
// .card-meta-badges here — this test is specifically about the footer
// right-cluster grouping, not the oversight badges.
plannerOversightLevel: "off",
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row");
const metaBadges = container.querySelector(".card-meta-badges");
const rightCluster = container.querySelector(".card-footer-row-right");
const trackingLink = container.querySelector(".card-github-tracking-chip");
const retryChip = container.querySelector(".card-retry-badge");
const timerChip = container.querySelector(".card-time-indicator");
expect(footerRow).not.toBeNull();
expect(metaBadges).toBeNull();
expect(rightCluster).not.toBeNull();
expect(footerRow?.contains(trackingLink)).toBe(true);
expect(footerRow?.contains(retryChip)).toBe(true);
expect(footerRow?.contains(timerChip)).toBe(true);
expect(rightCluster?.contains(timerChip)).toBe(true);
expect(Array.from(rightCluster?.children ?? [])).toContain(timerChip);
expect(container.querySelector(".card-bottom-right-row")).toBeNull();
});
it("keeps the GitHub tracking link keyboard focusable", () => {
render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const link = screen.getByRole("link", { name: "Linked GitHub issue #42" });
expect(link.tabIndex).not.toBe(-1);
link.focus();
expect(document.activeElement).toBe(link);
});
it("renders safe external-link attributes for the GitHub tracking link", () => {
render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const link = screen.getByRole("link", { name: "Linked GitHub issue #42" });
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("rel")).toContain("noopener");
expect(link.getAttribute("rel")).toContain("noreferrer");
});
it("keeps GitHub tracking chip interaction-affordance CSS contract", () => {
const css = loadAllAppCssBaseOnly();
expect(css).toMatch(/\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*display:\s*inline-flex;[^}]*font-family:\s*var\(--font-mono\);[^}]*\}/);
expect(css).toContain(".card-github-tracking-chip:hover");
expect(css).toMatch(/\.card-github-tracking-chip:focus-visible\s*\{[^}]*--focus-ring-strong/);
expect(css).toMatch(/\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*padding:\s*var\(--space-xs\)\s+var\(--space-sm\);[^}]*height:\s*var\(--card-chip-height\);[^}]*border-radius:\s*var\(--radius-pill\);[^}]*font-size:\s*0\.6875rem;[^}]*line-height:\s*1;[^}]*\}/);
expect(css).toMatch(/\.card-github-tracking-chip\s+\.provider-icon\s+svg\s*\{[^}]*width:\s*12px;[^}]*height:\s*12px;[^}]*\}/);
render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const link = screen.getByRole("link", { name: "Linked GitHub issue #42" });
const chipStyle = getComputedStyle(link);
expect(chipStyle.display).toBe("inline-flex");
expect(chipStyle.whiteSpace).toBe("nowrap");
});
it("FN-4287: keeps GitHub provenance indicators grouped on the right edge", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
githubTracking: {
issue: {
owner: "other",
repo: "tracking",
number: 99,
url: "https://github.com/other/tracking/issues/99",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const footerRow = container.querySelector(".card-footer-row");
const footerProvenance = container.querySelectorAll(".card-footer-row .card-source-provenance");
const trackingLink = container.querySelector(".card-github-tracking-link");
expect(footerRow).not.toBeNull();
expect(footerProvenance).toHaveLength(1);
expect(trackingLink).not.toBeNull();
const css = loadAllAppCssBaseOnly();
expect(css).toMatch(/\.card-footer-row-right\s*\{[^}]*margin-left:\s*auto;[^}]*\}/);
const provenanceRule = css.match(/\.card-source-provenance\s*\{[^}]*\}/)?.[0] ?? "";
expect(provenanceRule).not.toMatch(/margin-left\s*:\s*auto/);
});
describe("FN-4607 TaskCard GitHub badge right-alignment", () => {
it("keeps source provenance and tracking chip grouped in footer order", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
githubTracking: {
issue: {
owner: "other",
repo: "tracking",
number: 99,
url: "https://github.com/other/tracking/issues/99",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const footerRow = container.querySelector(".card-footer-row");
const sourceBadge = container.querySelector(".card-footer-row > .card-source-provenance");
const trackingChip = container.querySelector(".card-footer-row-right > .card-github-tracking-chip");
expect(footerRow).not.toBeNull();
expect(sourceBadge).not.toBeNull();
expect(trackingChip).not.toBeNull();
const rightCluster = container.querySelector(".card-footer-row > .card-footer-row-right");
expect(rightCluster).not.toBeNull();
expect((sourceBadge as Element).nextElementSibling).toBe(rightCluster);
const css = loadAllAppCssBaseOnly();
expect(css).toMatch(/\.card-footer-row-right\s*\{[^}]*margin-left:\s*auto;[^}]*\}/);
});
it("applies right-alignment rule when only tracking chip is rendered", () => {
const css = loadAllAppCssBaseOnly();
expect(css).toMatch(/\.card-footer-row-right\s*\{[^}]*margin-left:\s*auto;[^}]*\}/);
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const footerRow = container.querySelector(".card-footer-row");
const trackingChip = container.querySelector(".card-footer-row-right > .card-github-tracking-chip");
expect(footerRow).not.toBeNull();
expect(trackingChip).not.toBeNull();
expect(container.querySelector(".card-footer-row > .card-source-provenance")).toBeNull();
});
it("keeps github badges before retry while time chip ends the footer cluster", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
retrySummary: { total: 3 } as any,
githubTracking: {
issue: {
owner: "other",
repo: "tracking",
number: 99,
url: "https://github.com/other/tracking/issues/99",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
executionStartedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:12:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row");
expect(footerRow).not.toBeNull();
const sourceNode = footerRow?.querySelector(".card-source-provenance");
const rightCluster = footerRow?.querySelector(".card-footer-row-right");
const timerChip = container.querySelector(".card-time-indicator");
expect(sourceNode).not.toBeNull();
expect(rightCluster).not.toBeNull();
expect(timerChip?.closest(".card-footer-row-right")).toBe(rightCluster);
const orderedNodes = [
rightCluster?.querySelector(".card-github-tracking-chip"),
rightCluster?.querySelector(".card-retry-badge"),
timerChip,
];
orderedNodes.forEach((node) => expect(node).not.toBeNull());
expect(Array.from((rightCluster as Element).children)).toEqual(orderedNodes);
});
});
describe("FN-4634 in-progress GitHub linked badge far-right placement", () => {
const trackedIssue = {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
};
it.each([
{
name: "retry summary present",
taskPatch: { retrySummary: { total: 2 } as any },
expectedLeftChipSelector: ".card-retry-badge",
},
{
name: "time indicator present",
taskPatch: {
executionStartedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:12:00.000Z",
},
expectedLeftChipSelector: ".card-time-indicator",
},
])("keeps right-side chips after tracking on in-progress when files changed is absent: $name", ({ taskPatch, expectedLeftChipSelector }) => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
sourceType: "dashboard_ui",
modifiedFiles: [],
githubTracking: { issue: trackedIssue },
...taskPatch,
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null;
const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null;
const rightSideChip = container.querySelector(expectedLeftChipSelector) as HTMLElement | null;
expect(footerRow).not.toBeNull();
expect(footerRow).toHaveClass("card-footer-row--chip-far-right");
expect(trackingChip).not.toBeNull();
expect(rightSideChip).not.toBeNull();
const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null;
expect(rightCluster).not.toBeNull();
const children = Array.from((rightCluster as HTMLElement).children);
expect(children).toContain(trackingChip as HTMLElement);
expect(children).toContain(rightSideChip as HTMLElement);
expect(children.indexOf(rightSideChip as HTMLElement)).toBeGreaterThan(children.indexOf(trackingChip as HTMLElement));
expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto");
});
it.each(["in-progress", "in-review"] as const)("renders time indicator in footer right cluster beside tracking chip for %s", (column) => {
const { container } = render(
<TaskCard
task={makeTask({
column,
sourceType: "dashboard_ui",
modifiedFiles: [],
githubTracking: { issue: trackedIssue },
executionStartedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:12:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null;
const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null;
const timeChip = container.querySelector(".card-time-indicator") as HTMLElement | null;
expect(footerRow).not.toBeNull();
expect(trackingChip).not.toBeNull();
expect(timeChip).not.toBeNull();
const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null;
expect(rightCluster).not.toBeNull();
const children = Array.from((rightCluster as HTMLElement).children);
expect(children).toContain(trackingChip);
expect(children).toContain(timeChip);
expect(timeChip?.closest(".card-footer-row-right")).toBe(rightCluster);
});
it("does not force far-right modifier when in-progress card has files changed", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
sourceType: "dashboard_ui",
modifiedFiles: ["src/file.ts"],
githubTracking: { issue: trackedIssue },
retrySummary: { total: 2 } as any,
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row");
expect(footerRow).not.toBeNull();
expect(footerRow).not.toHaveClass("card-footer-row--chip-far-right");
});
it.each([
{
name: "retry summary present",
taskPatch: { retrySummary: { total: 2 } as any },
expectedLeftChipSelector: ".card-retry-badge",
},
{
name: "time indicator present",
taskPatch: {
executionStartedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:12:00.000Z",
},
expectedLeftChipSelector: ".card-time-indicator",
},
])("keeps right-side chips after tracking on in-review when files changed is absent: $name", ({ taskPatch, expectedLeftChipSelector }) => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
sourceType: "dashboard_ui",
modifiedFiles: [],
githubTracking: { issue: trackedIssue },
...taskPatch,
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null;
const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null;
const rightSideChip = container.querySelector(expectedLeftChipSelector) as HTMLElement | null;
expect(footerRow).not.toBeNull();
expect(footerRow).toHaveClass("card-footer-row--chip-far-right");
expect(trackingChip).not.toBeNull();
expect(rightSideChip).not.toBeNull();
const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null;
expect(rightCluster).not.toBeNull();
const children = Array.from((rightCluster as HTMLElement).children);
expect(children).toContain(trackingChip as HTMLElement);
expect(children).toContain(rightSideChip as HTMLElement);
expect(children.indexOf(rightSideChip as HTMLElement)).toBeGreaterThan(children.indexOf(trackingChip as HTMLElement));
expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto");
});
it("does not force far-right modifier when in-review card has files changed", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
sourceType: "dashboard_ui",
modifiedFiles: ["src/file.ts"],
githubTracking: { issue: trackedIssue },
retrySummary: { total: 2 } as any,
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row");
expect(footerRow).not.toBeNull();
expect(footerRow).not.toHaveClass("card-footer-row--chip-far-right");
});
});
describe("FN-4923 tracking chip alignment", () => {
it("FN-4923 right-aligns tracking chip next to timer in done when files-changed is absent", () => {
const cleanupCss = mountCssForBadgeTests();
try {
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
sourceType: "dashboard_ui",
modifiedFiles: [],
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
executionStartedAt: "2026-04-25T12:00:00.000Z",
executionCompletedAt: "2026-04-25T12:12:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null;
const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null;
const timerChip = container.querySelector(".card-time-indicator") as HTMLElement | null;
expect(footerRow).not.toBeNull();
expect(footerRow).toHaveClass("card-footer-row--chip-far-right");
expect(trackingChip).not.toBeNull();
expect(timerChip).not.toBeNull();
const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null;
expect(rightCluster).not.toBeNull();
expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto");
expect((trackingChip as HTMLElement).nextElementSibling).toBe(timerChip);
expect(timerChip?.closest(".card-footer-row-right")).toBe(rightCluster);
} finally {
cleanupCss();
}
});
it("FN-4923 preserves in-progress tracking-chip right alignment without files-changed", () => {
const cleanupCss = mountCssForBadgeTests();
try {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
sourceType: "dashboard_ui",
modifiedFiles: [],
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
executionStartedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:12:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null;
const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null;
expect(trackingChip).not.toBeNull();
expect(rightCluster).not.toBeNull();
expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto");
} finally {
cleanupCss();
}
});
it("FN-4923 keeps tracking chip, retry/timer chips grouped as one right-aligned cluster with files-changed", () => {
const cleanupCss = mountCssForBadgeTests();
try {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
sourceType: "dashboard_ui",
modifiedFiles: ["src/file.ts"],
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
executionStartedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:12:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null;
const filesChangedButton = screen.getByRole("button", { name: "1 file changed" });
const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null;
const timerChip = container.querySelector(".card-time-indicator") as HTMLElement | null;
expect(footerRow).not.toBeNull();
expect(footerRow).not.toHaveClass("card-footer-row--chip-far-right");
expect(trackingChip).not.toBeNull();
expect(timerChip).not.toBeNull();
const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null;
expect(filesChangedButton.compareDocumentPosition(trackingChip as HTMLElement)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(rightCluster).not.toBeNull();
expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto");
expect(getComputedStyle(timerChip as HTMLElement).marginLeft).not.toBe("auto");
} finally {
cleanupCss();
}
});
});
it("does not render a GitHub tracking link when githubTracking is absent", () => {
render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByRole("link", { name: /Linked GitHub issue/i })).toBeNull();
});
it("keeps the plain provenance badge when source issue differs from tracked issue", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
githubTracking: {
issue: {
owner: "other",
repo: "tracking",
number: 99,
url: "https://github.com/other/tracking/issues/99",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-source-provenance")).not.toBeNull();
expect(screen.getByRole("link", { name: "Linked GitHub issue #99" })).toBeDefined();
});
it("replaces the GitHub import provenance badge with the linked-issue chip when source and tracked issue match", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const chips = container.querySelectorAll(".card-github-tracking-chip");
const link = screen.getByRole("link", { name: "Linked GitHub issue #42" });
expect(chips).toHaveLength(1);
expect(link.getAttribute("href")).toBe("https://github.com/owner/repo/issues/42");
expect(link).toHaveTextContent("#42");
expect(container.querySelector(".card-source-provenance")).toBeNull();
});
it("keeps the plain provenance badge alone when there is no linked issue", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
githubTracking: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-source-provenance")).not.toBeNull();
expect(container.querySelector(".card-github-tracking-chip")).toBeNull();
});
it("uses the same matching logic for task.issueInfo", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "github_import",
issueInfo: {
url: "https://github.com/owner/repo/issues/42",
number: 42,
state: "open",
title: "Issue",
},
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByRole("link", { name: "Linked GitHub issue #42" })).toBeDefined();
expect(container.querySelector(".card-source-provenance")).toBeNull();
});
it("does not render a GitHub tracking link when a matching issue badge is already shown", () => {
render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
issueInfo: {
url: "https://github.com/owner/repo/issues/42",
number: 42,
state: "open",
title: "Issue",
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByRole("link", { name: /Linked GitHub issue/i })).toBeNull();
});
it("clicking the GitHub tracking link does not open the task detail modal", () => {
const onOpenDetail = vi.fn();
render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
githubTracking: {
issue: {
owner: "owner",
repo: "repo",
number: 42,
url: "https://github.com/owner/repo/issues/42",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={onOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("link", { name: "Linked GitHub issue #42" }));
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("renders agent-created provenance badge for automation tasks and prefers sourceMetadata.agentName", () => {
seedAgentsCache("p1", [{ id: "agent-123", name: "Cache Robot" }]);
const { container } = render(
<TaskCard
projectId="p1"
task={makeTask({
column: "todo",
sourceType: "automation",
sourceAgentId: "agent-123",
sourceMetadata: { agentName: "Task Robot" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-agent-created-badge");
expect(badge).not.toBeNull();
expect(badge?.closest(".card-agent-badge-row")).not.toBeNull();
expect(badge?.closest(".card-header")).toBeNull();
expect(badge?.getAttribute("title")).toBe("Created by agent: Task Robot");
expect(badge?.getAttribute("aria-label")).toBe("Created by agent: Task Robot");
expect(badge?.querySelector("span[aria-hidden='true']")?.textContent).toBe("Task Robot");
expect(badge?.querySelector(".visually-hidden")?.textContent).toBe("Created by agent: Task Robot");
});
it("renders agent-created provenance badge for agent_heartbeat tasks", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "agent_heartbeat",
sourceAgentId: "heartbeat-agent-1",
sourceMetadata: { agentName: "Scheduler Bot" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-agent-created-badge");
expect(badge).not.toBeNull();
expect(badge?.closest(".card-agent-badge-row")).not.toBeNull();
expect(badge?.closest(".card-meta-badges")).toBeNull();
expect(badge?.getAttribute("title")).toBe("Created by agent: Scheduler Bot");
expect(badge?.getAttribute("aria-label")).toBe("Created by agent: Scheduler Bot");
});
it("renders agent-created provenance badge with cached source agent names before falling back to ids", () => {
seedAgentsCache("p1", [{ id: "legacy-agent-1", name: "Legacy Robot" }]);
const { container } = render(
<TaskCard
projectId="p1"
task={makeTask({
column: "todo",
sourceAgentId: "legacy-agent-1",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-agent-created-badge");
expect(badge).not.toBeNull();
expect(badge?.closest(".card-agent-badge-row")).not.toBeNull();
expect(badge?.getAttribute("title")).toBe("Created by agent: Legacy Robot");
expect(badge?.querySelector("span[aria-hidden='true']")?.textContent).toBe("Legacy Robot");
});
it("falls back to the generic Agent label when source type is agent-created without a resolvable name", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "automation",
sourceAgentId: undefined,
sourceMetadata: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-agent-created-badge");
expect(badge).not.toBeNull();
expect(badge?.closest(".card-agent-badge-row")).not.toBeNull();
expect(badge?.getAttribute("title")).toBe("Created by agent");
expect(badge?.getAttribute("aria-label")).toBe("Created by agent");
expect(badge?.querySelector("span[aria-hidden='true']")?.textContent).toBe("Agent");
expect(badge?.querySelector(".visually-hidden")?.textContent).toBe("Created by agent");
});
it("does not render agent-created provenance badge for non-agent task sources", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "dashboard_ui",
sourceAgentId: undefined,
sourceMetadata: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-agent-created-badge")).toBeNull();
expect(container.querySelector(".card-agent-badge-row")).toBeNull();
});
it("FN-8423 keeps only the assigned badge for the same agent ID", () => {
seedAgentsCache("fn-8423-same-id", [{ id: "agent-qa", name: "QA Engineer" }]);
const { container } = render(
<TaskCard
projectId="fn-8423-same-id"
task={makeTask({
column: "todo",
assignedAgentId: "agent-qa",
sourceType: "agent_heartbeat",
sourceAgentId: "agent-qa",
sourceMetadata: { agentName: "QA Engineer" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const assignedBadge = container.querySelector(".card-agent-badge");
expect(assignedBadge).not.toBeNull();
expect(assignedBadge).toHaveAttribute("title", "Assigned to QA Engineer");
expect(container.querySelector(".card-agent-created-badge")).toBeNull();
expect(container.querySelector(".card-agent-badge-row")).toBeNull();
});
it("FN-8423 retains both badges for different IDs with the same display name", () => {
seedAgentsCache("fn-8423-distinct-ids", [
{ id: "agent-a", name: "QA Engineer" },
{ id: "agent-b", name: "QA Engineer" },
]);
const { container } = render(
<TaskCard
projectId="fn-8423-distinct-ids"
task={makeTask({
column: "todo",
assignedAgentId: "agent-a",
sourceType: "automation",
sourceAgentId: "agent-b",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-agent-badge")).toHaveAttribute("title", "Assigned to QA Engineer");
expect(container.querySelector(".card-agent-created-badge")).toHaveAttribute("title", "Created by agent: QA Engineer");
expect(container.querySelector(".card-agent-badge-row")).not.toBeNull();
});
it("FN-8423 falls back to matching names only when source agent ID is unavailable", () => {
seedAgentsCache("fn-8423-name-fallback", [{ id: "agent-qa", name: "QA Engineer" }]);
const { container } = render(
<TaskCard
projectId="fn-8423-name-fallback"
task={makeTask({
column: "todo",
assignedAgentId: "agent-qa",
sourceType: "automation",
sourceMetadata: { agentName: " qa engineer " },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-agent-badge")).toHaveAttribute("title", "Assigned to QA Engineer");
expect(container.querySelector(".card-agent-created-badge")).toBeNull();
expect(container.querySelector(".card-agent-badge-row")).toBeNull();
});
it("coexists with GitHub badge and timer metadata", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
sourceType: "github_import",
sourceAgentId: "agent-42",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/7" },
issueInfo: {
owner: "owner",
repo: "repo",
issueNumber: 7,
state: "open",
title: "Fix bug",
} as any,
executionStartedAt: "2026-04-25T13:00:00.000Z",
executionCompletedAt: "2026-04-25T15:00:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-github-badge")).not.toBeNull();
expect(container.querySelector(".card-source-provenance")).not.toBeNull();
expect(container.querySelector(".card-agent-created-badge")?.closest(".card-agent-badge-row")).not.toBeNull();
expect(container.querySelector(".card-time-indicator")).not.toBeNull();
});
it("FN-4511 keeps GitHub badge sizing tokens aligned with card-time-indicator", () => {
const baseCss = loadAllAppCssBaseOnly();
expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*padding:\s*var\(--space-xs\)\s+var\(--space-sm\);[^}]*\}/);
expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*font-size:\s*0\.6875rem;[^}]*\}/);
expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*gap:\s*var\(--space-xs\);[^}]*\}/);
expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*font-family:\s*var\(--font-mono\);[^}]*\}/);
const fullCss = loadAllAppCss();
expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-github-badge\s*\{[^}]*font-size:\s*0\.625rem;[^}]*\}/);
});
it("FN-4525 defines shared card-chip height tokens and applies them to badges and chips", () => {
const baseCss = loadAllAppCssBaseOnly();
expect(baseCss).toMatch(/:root\s*\{[^}]*--card-chip-height:\s*22px;[^}]*--card-chip-height-mobile:\s*20px;[^}]*\}/);
expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*height:\s*var\(--card-chip-height\);[^}]*\}/);
expect(baseCss).toMatch(/\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height\);[^}]*\}/);
});
it("FN-4525 applies shared mobile card-chip height token to badges and chips", () => {
const fullCss = loadAllAppCss();
expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-github-badge\s*\{[^}]*height:\s*var\(--card-chip-height-mobile\);[^}]*\}/);
expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height-mobile\);[^}]*\}/);
});
it("keeps Create PR action on shared chip height tokens", () => {
const baseCss = loadAllAppCssBaseOnly();
const fullCss = loadAllAppCss();
expect(baseCss).toMatch(/\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height\);[^}]*\}/);
expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height-mobile\);[^}]*\}/);
});
it("FN-4511 keeps GitHub badge and timer chip geometry in parity", () => {
const cleanupCss = mountCssForBadgeTests();
try {
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
issueInfo: {
owner: "owner",
repo: "repo",
issueNumber: 7,
state: "open",
title: "Fix bug",
} as any,
executionStartedAt: "2026-04-25T13:00:00.000Z",
executionCompletedAt: "2026-04-25T15:00:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const githubBadge = container.querySelector(".card-github-badge") as HTMLElement;
const timeIndicator = container.querySelector(".card-time-indicator") as HTMLElement;
expect(githubBadge).toBeDefined();
expect(timeIndicator).toBeDefined();
const githubStyles = getComputedStyle(githubBadge);
const timeStyles = getComputedStyle(timeIndicator);
expect(githubStyles.padding).toBe(timeStyles.padding);
expect(githubStyles.fontSize).toBe(timeStyles.fontSize);
expect(githubStyles.lineHeight).toBe(timeStyles.lineHeight);
const githubBorderTopWidth = githubStyles.borderTopWidth || "1px";
const timeBorderTopWidth = timeStyles.borderTopWidth || "1px";
const githubBorderBottomWidth = githubStyles.borderBottomWidth || "1px";
const timeBorderBottomWidth = timeStyles.borderBottomWidth || "1px";
expect(githubBorderTopWidth).toBe(timeBorderTopWidth);
expect(githubBorderBottomWidth).toBe(timeBorderBottomWidth);
expect(githubStyles.gap).toBe(timeStyles.gap);
if (githubBadge.offsetHeight > 0 || timeIndicator.offsetHeight > 0) {
expect(githubBadge.offsetHeight).toBe(timeIndicator.offsetHeight);
} else {
expect(githubStyles.height).not.toBe("");
expect(githubStyles.height).toBe(timeStyles.height);
}
} finally {
cleanupCss();
}
});
it("FN-4511 preserves transparent border slot on .card-github-badge", () => {
const css = loadAllAppCssBaseOnly();
expect(css).toMatch(/\.card-github-badge\s*\{[^}]*border:\s*1px\s+solid\s+transparent;[^}]*\}/);
});
it.each([
{
name: "uses live diff stats over stale mergeDetails",
diff: { stats: { filesChanged: 2, additions: 4, deletions: 1 }, loading: false },
mergeDetails: { filesChanged: 108 },
expectedLabel: "2 files changed",
},
{
name: "uses mergeDetails as transient placeholder while loading",
diff: { stats: null, loading: true },
mergeDetails: { filesChanged: 108 },
expectedLabel: "108 files changed",
},
{
name: "hides badge when fetch resolved null and no execution fallback exists",
diff: { stats: null, loading: false },
mergeDetails: { filesChanged: 108 },
expectedLabel: null,
},
{
name: "hides badge when live diff resolves zero",
diff: { stats: { filesChanged: 0, additions: 0, deletions: 0 }, loading: false },
mergeDetails: { filesChanged: 108 },
expectedLabel: null,
},
{
name: "clamps live diff count when landed files are attribution-restricted",
diff: { stats: { filesChanged: 5, additions: 10, deletions: 2 }, loading: false },
mergeDetails: { landedFilesAttributionRestricted: true, landedFiles: ["a.ts"] },
expectedLabel: "1 file changed",
},
{
name: "does not clamp when attribution restriction is absent",
diff: { stats: { filesChanged: 5, additions: 10, deletions: 2 }, loading: false },
mergeDetails: { landedFiles: ["a.ts"] },
expectedLabel: "5 files changed",
},
{
name: "uses singular grammar for one live file",
diff: { stats: { filesChanged: 1, additions: 1, deletions: 0 }, loading: false },
mergeDetails: undefined,
expectedLabel: "1 file changed",
},
])("FN-4527 done-task files changed contract: $name", ({ diff, mergeDetails, expectedLabel }) => {
useTaskDiffStatsMock.mockReturnValue(diff);
render(
<TaskCard
task={makeTask({
column: "done",
mergeDetails: mergeDetails
? {
commitSha: "abc123",
insertions: 10,
deletions: 2,
mergedAt: "2026-04-25T15:00:00.000Z",
mergeConfirmed: true,
...mergeDetails,
}
: undefined,
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
if (expectedLabel) {
expect(screen.getByRole("button", { name: expectedLabel })).toBeDefined();
return;
}
const filesChangedButton = document.querySelector(".card-session-files");
expect(filesChangedButton).toBeNull();
});
it("backfills done-card files-changed chip when mergeDetails enrichment arrives without remount", () => {
useTaskDiffStatsMock.mockImplementation((...args: any[]) => {
const options = args[4] as { mergeSignature?: string } | undefined;
if (options?.mergeSignature === "3:3") {
return { stats: { filesChanged: 3, additions: 9, deletions: 2 }, loading: false };
}
return { stats: null, loading: false };
});
const baseTask = makeTask({
column: "done",
mergeDetails: {
commitSha: "abc123",
insertions: 10,
deletions: 2,
mergedAt: "2026-04-25T15:00:00.000Z",
mergeConfirmed: true,
},
});
const { rerender } = render(
<TaskCard
task={baseTask}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: /files changed/i })).toBeNull();
expect(useTaskDiffStatsMock).toHaveBeenLastCalledWith(
"FN-001",
"done",
"abc123",
undefined,
expect.objectContaining({ mergeSignature: ":" }),
);
rerender(
<TaskCard
task={{
...baseTask,
mergeDetails: {
...baseTask.mergeDetails,
filesChanged: 3,
landedFiles: ["a.ts", "b.ts", "c.ts"],
},
}}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "3 files changed" })).toBeDefined();
expect(useTaskDiffStatsMock).toHaveBeenLastCalledWith(
"FN-001",
"done",
"abc123",
undefined,
expect.objectContaining({ mergeSignature: "3:3" }),
);
});
it("prefers landedFiles fallback files-changed label for done tasks when lineage stats are unavailable", () => {
const onOpenDetailWithTab = vi.fn();
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });
render(
<TaskCard
task={makeTask({
column: "done",
mergeDetails: { landedFiles: ["a.ts", "b.ts"] },
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={onOpenDetailWithTab}
/>,
);
const landedButton = screen.getByRole("button", { name: "2 files changed" });
expect(landedButton).toBeDefined();
fireEvent.click(landedButton);
expect(onOpenDetailWithTab).toHaveBeenCalledTimes(1);
expect(onOpenDetailWithTab.mock.calls[0]?.[1]).toBe("changes");
});
it("hides the done-task file chip when only execution-touched modifiedFiles exist", () => {
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
modifiedFiles: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"],
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
expect(screen.queryByText(/touched during execution/i)).toBeNull();
expect(screen.queryByRole("button", { name: /files changed/i })).toBeNull();
expect(container.querySelector(".card-session-files")).toBeNull();
});
it("prefers lineage files-changed stats over stale execution-touched modifiedFiles for done tasks", () => {
useTaskDiffStatsMock.mockReturnValue({
stats: { filesChanged: 4, additions: 12, deletions: 3 },
loading: false,
});
render(
<TaskCard
task={makeTask({
column: "done",
modifiedFiles: [
"one.ts",
"two.ts",
"three.ts",
"four.ts",
"five.ts",
"six.ts",
"seven.ts",
"eight.ts",
"nine.ts",
"ten.ts",
],
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "4 files changed" })).toBeDefined();
expect(screen.queryByText(/touched during execution/i)).toBeNull();
expect(screen.queryByText(/in merged commit/i)).toBeNull();
});
it("uses singular 'file changed' grammar for landedFiles-only done tasks", () => {
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });
render(
<TaskCard
task={makeTask({
column: "done",
mergeDetails: { landedFiles: ["a.ts"] },
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "1 file changed" })).toBeDefined();
});
it("hides done-task file chip when lineage stats are unavailable and no execution fallback exists", () => {
useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false });
const { container } = render(
<TaskCard
task={makeTask({ column: "done", modifiedFiles: [] })}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
expect(container.querySelector(".card-session-files")).toBeNull();
});
it("renders files-changed metadata and timer chip in footer row", () => {
useTaskDiffStatsMock.mockReturnValue({
stats: { filesChanged: 4, additions: 10, deletions: 2 },
loading: false,
});
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
columnMovedAt: "2026-04-25T15:00:00.000Z",
updatedAt: "2026-04-25T15:00:00.000Z",
createdAt: "2026-04-25T13:00:00.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T13:00:00.000Z",
completedAt: "2026-04-25T15:00:00.000Z",
},
],
mergeDetails: {
commitSha: "abc123",
filesChanged: 4,
insertions: 10,
deletions: 2,
mergedAt: "2026-04-25T15:00:00.000Z",
mergeConfirmed: true,
},
})}
onOpenDetail={noop}
addToast={noop}
onOpenDetailWithTab={vi.fn()}
/>,
);
const header = container.querySelector(".card-header");
const footerRow = container.querySelector(".card-footer-row");
const filesChanged = container.querySelector(".card-session-files");
const timer = container.querySelector(".card-time-indicator");
expect(header).not.toBeNull();
expect(footerRow).not.toBeNull();
expect(filesChanged).not.toBeNull();
expect(timer).not.toBeNull();
const rightCluster = container.querySelector(".card-footer-row-right");
expect(footerRow?.contains(filesChanged)).toBe(true);
expect(footerRow?.contains(timer)).toBe(true);
expect(header?.contains(timer)).toBe(false);
expect(timer?.closest(".card-footer-row-right")).toBe(rightCluster);
expect(rightCluster).not.toBeNull();
expect(Array.from(footerRow?.children ?? [])).toEqual([filesChanged, rightCluster]);
});
it("shows timer chip for in-review cards", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T12:00:00.000Z",
completedAt: "2026-04-25T12:08:00.000Z",
},
],
log: [
{
timestamp: "2026-04-25T12:09:00.000Z",
action: "[timing] llm_call in 240000ms",
outcome: "",
} as unknown as Task["log"][number],
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expectTimerInFooterRight(container);
expect(timer?.textContent).toContain("12m");
expect(timer?.getAttribute("title")).toContain("Execution time 12m");
expect(timer?.getAttribute("title")).not.toContain("Completed");
});
it("keeps the in-review timer live from executionStartedAt when present", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
executionStartedAt: "2026-04-25T12:00:00.000Z",
columnMovedAt: "2026-04-25T12:12:00.000Z",
updatedAt: "2026-04-25T12:30:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expectTimerInFooterRight(container);
expect(timer?.textContent).toContain("30m");
expect(timer?.getAttribute("title")).toBe("Execution time 30m");
act(() => {
vi.advanceTimersByTime(5 * 60_000);
});
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("35m");
expect(container.querySelector(".card-time-indicator")?.getAttribute("title")).toBe("Execution time 35m");
});
it("shows cumulative runtime across a user reopen", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-15T13:17:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
firstExecutionAt: "2026-05-15T08:42:00.000Z",
cumulativeActiveMs: 6 * 60_000,
executionStartedAt: "2026-05-15T13:15:00.000Z",
columnMovedAt: "2026-05-15T13:17:00.000Z",
updatedAt: "2026-05-15T13:17:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expectTimerInFooterRight(container);
expect(timer?.textContent).toContain("6m");
expect(timer?.getAttribute("title")).toBe("Execution time 6m");
});
it("renders planning-only active duration when execution timing is absent", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
cumulativeActiveMs: undefined,
cumulativePlanningMs: 6 * 60_000,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer?.textContent).toContain("6m");
});
it("keeps legacy wall-clock timers after firstExecutionAt migration backfill", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
executionStartedAt: "2026-04-25T12:00:00.000Z",
executionCompletedAt: "2026-04-25T12:30:00.000Z",
firstExecutionAt: "2026-04-25T12:00:00.000Z",
cumulativeActiveMs: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("30m");
});
it("renders the optional cost badge only when enabled and token usage exists", () => {
const pricedTask = makeTask({
column: "done",
tokenUsage: {
inputTokens: 1_000_000,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 1_000_000,
firstUsedAt: "2026-01-01T00:00:00Z",
lastUsedAt: "2026-01-01T00:00:00Z",
modelProvider: "openai",
modelId: "gpt-5-mini",
},
} as Partial<Task>);
const disabled = render(<TaskCard task={pricedTask} onOpenDetail={noop} addToast={noop} />);
expect(disabled.container.querySelector(".card-cost-indicator")).toBeNull();
disabled.unmount();
const enabled = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard task={pricedTask} onOpenDetail={noop} addToast={noop} />
</CostBadgeProvider>,
);
const costBadge = enabled.container.querySelector(".card-cost-indicator") as HTMLElement | null;
expect(costBadge).not.toBeNull();
expect(costBadge?.textContent).toContain("$0.25");
expect(costBadge?.querySelector("svg")).toBeNull();
expect(costBadge?.getAttribute("aria-label")).toBe("Estimated cost $0.25");
expect(costBadge?.getAttribute("title")).toBe("Estimated cost $0.25");
expect(costBadge?.closest(".card-footer-row-right")).toBe(enabled.container.querySelector(".card-footer-row-right"));
expect(costBadge?.closest(".card-footer-row")).toBe(enabled.container.querySelector(".card-footer-row"));
expect(costBadge?.closest(".card-meta")).toBeNull();
enabled.unmount();
const noUsage = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard task={makeTask({ id: "FN-002", column: "done" })} onOpenDetail={noop} addToast={noop} />
</CostBadgeProvider>,
);
expect(noUsage.container.querySelector(".card-cost-indicator")).toBeNull();
});
it("places an enabled cost badge below Promote without leaving it in the footer cluster", () => {
const pricedTask = makeTask({
id: "FN-8324",
column: "todo",
tokenUsage: {
inputTokens: 1_000_000,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 1_000_000,
firstUsedAt: "2026-01-01T00:00:00Z",
lastUsedAt: "2026-01-01T00:00:00Z",
modelProvider: "openai",
modelId: "gpt-5-mini",
},
} as Partial<Task>);
const { container } = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard
task={pricedTask}
onOpenDetail={noop}
addToast={noop}
onPromote={vi.fn().mockResolvedValue(undefined)}
/>
</CostBadgeProvider>,
);
const promoteButton = screen.getByTestId("card-promote-FN-8324");
const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null;
const costRow = container.querySelector(".card-promote-cost-row");
expect(costBadge).not.toBeNull();
expect(costBadge?.closest(".card-promote-cost-row")).toBe(costRow);
expect(costBadge?.closest(".card-footer-row-right")).toBeNull();
expect(promoteButton.compareDocumentPosition(costBadge!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const css = loadAllAppCss();
expect(css).toMatch(/\.card-promote-cost-row\s*\{[^}]*justify-content:\s*flex-end;[^}]*margin-top:\s*var\(--space-xs\);[^}]*\}/);
expect(css).toMatch(/@media[^{}]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-promote-cost-row\s*\{[^}]*justify-content:\s*flex-end;[^}]*\}/);
});
it("keeps the cost badge absent below Promote when disabled or without token usage", () => {
const onPromote = vi.fn().mockResolvedValue(undefined);
const pricedTask = makeTask({
id: "FN-8324-disabled",
column: "todo",
tokenUsage: {
inputTokens: 1_000_000,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 1_000_000,
firstUsedAt: "2026-01-01T00:00:00Z",
lastUsedAt: "2026-01-01T00:00:00Z",
modelProvider: "openai",
modelId: "gpt-5-mini",
},
} as Partial<Task>);
const disabled = render(<TaskCard task={pricedTask} onOpenDetail={noop} addToast={noop} onPromote={onPromote} />);
expect(disabled.container.querySelector(".card-cost-indicator")).toBeNull();
expect(disabled.container.querySelector(".card-promote-cost-row")).toBeNull();
disabled.unmount();
const noUsage = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard task={makeTask({ id: "FN-8324-no-usage", column: "todo" })} onOpenDetail={noop} addToast={noop} onPromote={onPromote} />
</CostBadgeProvider>,
);
expect(noUsage.container.querySelector(".card-cost-indicator")).toBeNull();
expect(noUsage.container.querySelector(".card-promote-cost-row")).toBeNull();
});
it("places a todo cost badge inside the meta row when the footer has no leading content", () => {
const { container } = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard
task={makeTask({
column: "todo",
dependencies: ["FN-000"],
tokenUsage: {
inputTokens: 1_000_000,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 1_000_000,
firstUsedAt: "2026-01-01T00:00:00Z",
lastUsedAt: "2026-01-01T00:00:00Z",
modelProvider: "openai",
modelId: "gpt-5-mini",
},
} as Partial<Task>)}
onOpenDetail={noop}
addToast={noop}
/>
</CostBadgeProvider>,
);
const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null;
const metaRow = container.querySelector(".card-meta");
const rightCluster = container.querySelector(".card-footer-row-right");
expect(costBadge).not.toBeNull();
expect(costBadge?.textContent).toContain("$0.25");
expect(costBadge?.closest(".card-meta")).toBe(metaRow);
expect(costBadge?.closest(".card-footer-row")).toBeNull();
expect(rightCluster?.closest(".card-meta")).toBe(metaRow);
expect(rightCluster?.contains(costBadge)).toBe(true);
expect(container.querySelector(".card-footer-row")).toBeNull();
});
it("places the unavailable cost sentinel inside todo meta without adding an icon", () => {
const { container } = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard
task={makeTask({
column: "todo",
dependencies: ["FN-000"],
tokenUsage: {
inputTokens: 1,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 1,
firstUsedAt: "2026-01-01T00:00:00Z",
lastUsedAt: "2026-01-01T00:00:00Z",
modelProvider: "unknown",
modelId: "no-price",
},
} as Partial<Task>)}
onOpenDetail={noop}
addToast={noop}
/>
</CostBadgeProvider>,
);
const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null;
expect(costBadge).not.toBeNull();
expect(costBadge?.textContent).toContain("—");
expect(costBadge?.querySelector("svg")).toBeNull();
expect(costBadge?.getAttribute("aria-label")).toBe("Estimated cost —");
expect(costBadge?.getAttribute("title")).toBe("Estimated cost —");
expect(costBadge?.closest(".card-meta")).toBe(container.querySelector(".card-meta"));
expect(costBadge?.closest(".card-footer-row")).toBeNull();
expect(container.querySelector(".card-footer-row")).toBeNull();
});
it("keeps in-progress cost badges in the footer row with files changed", () => {
const { container } = render(
<CostBadgeProvider value={{ enabled: true }}>
<TaskCard
task={makeTask({
column: "in-progress",
modifiedFiles: ["packages/dashboard/app/components/TaskCard.tsx"],
tokenUsage: {
inputTokens: 1_000_000,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 1_000_000,
firstUsedAt: "2026-01-01T00:00:00Z",
lastUsedAt: "2026-01-01T00:00:00Z",
modelProvider: "openai",
modelId: "gpt-5-mini",
},
} as Partial<Task>)}
onOpenDetail={noop}
addToast={noop}
/>
</CostBadgeProvider>,
);
const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null;
const footerRow = container.querySelector(".card-footer-row");
const rightCluster = container.querySelector(".card-footer-row-right");
expect(container.querySelector(".card-session-files")).not.toBeNull();
expect(costBadge).not.toBeNull();
expect(costBadge?.closest(".card-footer-row")).toBe(footerRow);
expect(costBadge?.closest(".card-footer-row-right")).toBe(rightCluster);
expect(costBadge?.closest(".card-meta")).toBeNull();
});
it.each(["merging", "merging-fix"] as const)("shows live merge elapsed in timer chip while task.status is %s", (status) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T13:45:00.000Z"));
try {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
status,
executionStartedAt: "2026-04-25T13:00:00.000Z",
updatedAt: "2026-04-25T13:44:30.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T12:00:00.000Z",
completedAt: "2026-04-25T12:03:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expectTimerInFooterRight(container);
expect(timer?.textContent).toContain("45m");
expect(timer?.getAttribute("title")).toBe("Execution time 45m. Merge phase <1m");
} finally {
vi.useRealTimers();
}
});
it("does not render timer chip for in-review cards without instrumentation data", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
workflowStepResults: undefined,
log: [],
timedExecutionMs: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")).toBeNull();
});
it.each(["triage", "todo", "archived"] as const)(
"does not render timer chip for %s cards",
(column) => {
const { container } = render(
<TaskCard
task={makeTask({
column,
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T13:00:00.000Z",
completedAt: "2026-04-25T15:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")).toBeNull();
},
);
it("shows wall-clock timer for in-progress cards when columnMovedAt is available", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:05:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
columnMovedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:00:00.000Z",
createdAt: "2026-04-25T11:58:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer?.textContent).toContain("5m");
expect(timer?.getAttribute("title")).toContain("In progress 5m");
});
it("prefers executionStartedAt over a newer columnMovedAt for in-progress timers", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:10:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
columnMovedAt: "2026-04-25T12:08:00.000Z",
executionStartedAt: "2026-04-25T12:00:00.000Z",
updatedAt: "2026-04-25T12:08:00.000Z",
createdAt: "2026-04-25T11:58:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer?.textContent).toContain("10m");
expect(timer?.getAttribute("title")).toContain("In progress 10m");
});
it("does not render timer chip on done card without instrumentation, even with old timestamps", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
createdAt: "2026-04-25T10:00:00.000Z",
columnMovedAt: "2026-04-25T12:30:00.000Z",
updatedAt: "2026-04-25T12:30:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")).toBeNull();
});
describe("formatElapsedDuration rounding for done tasks", () => {
it.each([
[59_999, "1m"],
[60_000, "1m"],
[90_000, "2m"],
[3_540_000, "1h"],
[3_600_000, "1h"],
[86_400_000, "1d"],
])("formats %dms as %s for done tasks", (elapsedMs, expected) => {
expect(formatElapsedDurationDone(elapsedMs)).toBe(expected);
});
it("keeps in-progress rounding with floor semantics", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:01:30.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "pending" as const,
startedAt: "2026-04-25T12:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1m");
});
it("renders done-card timer with ceiling rounding for fractional minutes", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "done",
createdAt: "2026-04-25T12:00:00.000Z",
columnMovedAt: "2026-04-25T12:04:30.000Z",
updatedAt: "2026-04-25T12:04:30.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T12:00:00.000Z",
completedAt: "2026-04-25T12:04:30.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("5m");
});
});
it("live-ticks workflow runtime for in-progress steps", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:00:30.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "pending" as const,
startedAt: "2026-04-25T12:00:00.000Z",
},
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer?.textContent).toContain("<1m");
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1m");
});
});
describe("TaskCard provider icons on agent row", () => {
it("renders provider icons when task has model overrides", () => {
render(
<TaskCard
task={makeTask({ modelProvider: "anthropic", assignedAgentId: "agent-1" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByTestId("card-provider-icons")).toBeDefined();
expect(screen.getByTestId("provider-icon-anthropic")).toBeDefined();
});
it("keeps assigned agent badge accessible when label is visually collapsible", async () => {
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-robot",
name: "Task Robot",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
const { container } = render(
<TaskCard
task={makeTask({ modelProvider: "anthropic", assignedAgentId: "agent-robot" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
await waitFor(() => {
const badge = container.querySelector(".card-agent-badge");
expect(badge).not.toBeNull();
expect(badge?.getAttribute("title")).toBe("Assigned to Task Robot");
expect(badge?.querySelector(".visually-hidden")?.textContent).toContain("Assigned to Task Robot");
});
});
it("deduplicates when executor and validator use same provider", () => {
render(
<TaskCard
task={makeTask({
modelProvider: "openai",
validatorModelProvider: "openai",
planningModelProvider: "anthropic",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const icons = screen.getByTestId("card-provider-icons");
expect(icons.querySelectorAll("[data-testid^='provider-icon-']").length).toBe(2);
expect(screen.getByTestId("provider-icon-openai")).toBeDefined();
expect(screen.getByTestId("provider-icon-anthropic")).toBeDefined();
});
it("renders agent row with provider icons even without assignedAgentId", () => {
render(
<TaskCard
task={makeTask({ modelProvider: "anthropic", assignedAgentId: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByTestId("card-provider-icons")).toBeDefined();
expect(screen.getByTestId("provider-icon-anthropic")).toBeDefined();
});
it("does not render provider icons when no model overrides set", () => {
render(
<TaskCard
task={makeTask({ assignedAgentId: "agent-1" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTestId("card-provider-icons")).toBeNull();
});
});
describe("TaskCard near-duplicate chip", () => {
it("shows a needs-user-feedback status when a triage duplicate is paused for a decision", () => {
render(
<TaskCard
task={makeTask({
paused: true,
pausedReason: "duplicate-decision-required",
sourceMetadata: { nearDuplicateOf: "FN-1234", duplicateSource: "triage-marker" },
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByTestId("card-needs-user-feedback-FN-001")).toHaveTextContent("Needs your decision");
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
});
it("renders duplicate chip when nearDuplicateOf is present", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" })).toBeInTheDocument();
});
it("hides duplicate chip when nearDuplicateDismissed is true", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true } })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
});
it("hides duplicate chip when parent resolves the canonical as inactive or missing", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
nearDuplicateCanonicalInactive={true}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
});
it("renders duplicate chip when canonical activity is unknown", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
nearDuplicateCanonicalInactive={undefined}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
});
it("hides duplicate chip in archived and done columns", () => {
const { rerender } = render(
<TaskCard
task={makeTask({ column: "archived", sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
rerender(
<TaskCard
task={makeTask({ column: "done", sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={vi.fn()}
/>,
);
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
});
it("clicking Keep calls updateTask dismissNearDuplicate", async () => {
const onUpdateTask = vi.fn().mockResolvedValue(makeTask());
render(
<TaskCard
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={onUpdateTask}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" }));
await waitFor(() => {
expect(onUpdateTask).toHaveBeenCalledWith("FN-001", { dismissNearDuplicate: true });
});
});
});
/*
FNXC:RefinementTitle 2026-07-26-20:10:
A refinement card is titled by the operator's feedback now, so the title no longer says the card
is a refinement. The "Refines <id>" chip is what carries that, and this covers the affordance's
surfaces: present for a `task_refine` task with a parent, absent for an ordinary task (with no
empty chip shell left behind), and absent when the parent is unresolvable — a chip whose only
content is the parent id must not render without one.
*/
describe("TaskCard refines chip", () => {
it("renders the refines chip for a refinement task", () => {
render(
<TaskCard
task={makeTask({ sourceType: "task_refine", sourceParentTaskId: "FN-1234" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Refines FN-1234")).toBeInTheDocument();
});
it("renders no refines chip and no empty shell for an ordinary task", () => {
render(
<TaskCard
task={makeTask({ sourceType: "cli", sourceParentTaskId: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText(/Refines /)).toBeNull();
expect(document.querySelector(".card-refine-chip")).toBeNull();
});
it("renders no refines chip when the refinement has no resolvable parent", () => {
render(
<TaskCard
task={makeTask({ sourceType: "task_refine", sourceParentTaskId: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(document.querySelector(".card-refine-chip")).toBeNull();
});
// A non-refinement that merely carries a parent id (duplicates, agent-created follow-ups)
// must not be mislabeled as a refinement.
it("does not render the refines chip for a non-refinement task that has a parent", () => {
render(
<TaskCard
task={makeTask({ sourceType: "task_duplicate", sourceParentTaskId: "FN-1234" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(document.querySelector(".card-refine-chip")).toBeNull();
});
});
/**
* FNXC:TaskRevert 2026-07-04-00:00:
* FN-7555 forward affordance coverage. Mirrors the near-duplicate chip test shape
* above: an AI-undo task (`sourceMetadata.revertOf` set by `createAiUndoTask`) shows
* an "Undo of <id>" footer chip; an ordinary task without that marker renders no chip
* and no empty footer shell.
*/
describe("TaskCard undo-of chip", () => {
it("renders undo-of chip when sourceMetadata.revertOf is present", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { revertOf: "FN-1234" } })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Undo of FN-1234")).toBeInTheDocument();
});
it("renders no undo-of chip and no empty footer shell for an ordinary task", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText(/Undo of/)).toBeNull();
expect(document.querySelector(".card-undo-chip")).toBeNull();
});
it("does not throw and renders nothing for malformed sourceMetadata.revertOf", () => {
render(
<TaskCard
task={makeTask({ sourceMetadata: { revertOf: 12345 as any } })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByText(/Undo of/)).toBeNull();
});
});
/*
* FNXC:TaskRevert 2026-07-16-00:00:
* FN-8066 regression coverage locks the completed-source invariant at TaskCard,
* the shared board/list card component: only persisted, non-blank revert markers
* render the compact chip in done or archived columns, while other provenance
* chips can coexist in the same footer cluster.
*/
describe("TaskCard reverted chip", () => {
it.each(["done", "archived"] as const)("renders for reverted %s cards", (column) => {
render(
<TaskCard
task={makeTask({ column, sourceMetadata: { revertedAt: "2026-07-16T00:00:00.000Z" } })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByLabelText("This task's changes were reverted")).toBeInTheDocument();
});
it("does not render for missing, blank, or non-completed revert markers", () => {
const { rerender } = render(
<TaskCard task={makeTask({ column: "done" })} onOpenDetail={noop} addToast={noop} />,
);
expect(document.querySelector(".card-reverted-chip")).toBeNull();
rerender(
<TaskCard
task={makeTask({ column: "archived", sourceMetadata: { revertedAt: " " } })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(document.querySelector(".card-reverted-chip")).toBeNull();
rerender(
<TaskCard
task={makeTask({ column: "todo", sourceMetadata: { revertedAt: "2026-07-16T00:00:00.000Z" } })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(document.querySelector(".card-reverted-chip")).toBeNull();
});
it("coexists with undo and duplicate provenance chips", () => {
render(
<TaskCard
task={makeTask({
column: "done",
sourceMetadata: {
revertedAt: "2026-07-16T00:00:00.000Z",
revertOf: "FN-1234",
nearDuplicateOf: "FN-5678",
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Reverted")).toBeInTheDocument();
expect(screen.getByText("Undo of FN-1234")).toBeInTheDocument();
expect(screen.queryByText("Duplicate of FN-5678")).toBeNull();
});
});
describe("TaskCard memo comparator provenance behavior", () => {
it("returns false when prAuthAvailable changes", () => {
const task = makeTask({ column: "in-review" });
expect(
__test_areTaskCardPropsEqual(
{ task, onOpenDetail: noop, addToast: noop, prAuthAvailable: false } as any,
{ task, onOpenDetail: noop, addToast: noop, prAuthAvailable: true } as any,
),
).toBe(false);
});
it("returns false when disableDrag changes", () => {
const task = makeTask();
expect(
__test_areTaskCardPropsEqual(
{ task, onOpenDetail: noop, addToast: noop, disableDrag: false } as any,
{ task, onOpenDetail: noop, addToast: noop, disableDrag: true } as any,
),
).toBe(false);
});
it("returns false when board context-menu action handlers change", () => {
const task = makeTask();
const actionHandler = vi.fn();
expect(
__test_areTaskCardPropsEqual(
{ task, onOpenDetail: noop, addToast: noop, onPauseTask: actionHandler } as any,
{ task, onOpenDetail: noop, addToast: noop, onUnpauseTask: actionHandler } as any,
),
).toBe(false);
});
it("returns false when sourceMetadata.agentName changes", () => {
const previousTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent One" } });
const nextTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent Two" } });
const previousProps = {
task: previousTask,
onOpenDetail: noop,
addToast: noop,
};
const nextProps = {
task: nextTask,
onOpenDetail: noop,
addToast: noop,
};
expect(__test_areTaskCardPropsEqual(previousProps as any, nextProps as any)).toBe(false);
});
it("returns false when sourceType changes", () => {
const previousTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent" } });
const nextTask = makeTask({ sourceType: "dashboard_ui", sourceMetadata: { agentName: "Agent" } });
expect(
__test_areTaskCardPropsEqual(
{ task: previousTask, onOpenDetail: noop, addToast: noop } as any,
{ task: nextTask, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
it("returns false when sourceAgentId changes", () => {
const previousTask = makeTask({ sourceType: "automation", sourceAgentId: "agent-a" });
const nextTask = makeTask({ sourceType: "automation", sourceAgentId: "agent-b" });
expect(
__test_areTaskCardPropsEqual(
{ task: previousTask, onOpenDetail: noop, addToast: noop } as any,
{ task: nextTask, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
it("returns false when branch changes", () => {
const previousTask = makeTask({ branch: "feature/old", baseBranch: "main" });
const nextTask = makeTask({ branch: "feature/new", baseBranch: "main" });
expect(
__test_areTaskCardPropsEqual(
{ task: previousTask, onOpenDetail: noop, addToast: noop } as any,
{ task: nextTask, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
it("returns false when baseBranch changes", () => {
const previousTask = makeTask({ branch: "fusion/fn-001", baseBranch: "main" });
const nextTask = makeTask({ branch: "fusion/fn-001", baseBranch: "release/2026-05" });
expect(
__test_areTaskCardPropsEqual(
{ task: previousTask, onOpenDetail: noop, addToast: noop } as any,
{ task: nextTask, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
it.each([
{ name: "mergeable", patch: { mergeable: "conflicting" } },
{ name: "draft", patch: { draft: true } },
{ name: "isDraft", patch: { isDraft: true } },
])("returns false when multi-PR badge $name color input changes", ({ patch }) => {
const basePr = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "PR",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 0,
};
const secondPr = { ...basePr, url: "https://github.com/owner/repo/pull/99", number: 99, title: "PR 2" };
const previousTask = makeTask({ prInfos: [basePr, secondPr] as any });
const nextTask = makeTask({ prInfos: [{ ...basePr, ...patch }, secondPr] as any });
expect(
__test_areTaskCardPropsEqual(
{ task: previousTask, onOpenDetail: noop, addToast: noop } as any,
{ task: nextTask, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
it("skips customFields JSON.stringify when both cardFieldDefs are absent", () => {
// Without cardFieldDefs present, two tasks with different customFields should
// compare equal (JSON.stringify is skipped — guard path).
const taskA = makeTask({ customFields: { x: "a" } });
const taskB = makeTask({ customFields: { x: "b" } });
expect(
__test_areTaskCardPropsEqual(
{ task: taskA, onOpenDetail: noop, addToast: noop } as any,
{ task: taskB, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(true);
});
it("detects customFields change when cardFieldDefs are present on both sides", () => {
const defs = [{ id: "sev", name: "Severity", type: "enum" as const, render: { placement: "card" as const } }];
const taskA = makeTask({ customFields: { sev: "low" } });
const taskB = makeTask({ customFields: { sev: "high" } });
expect(
__test_areTaskCardPropsEqual(
{ task: taskA, cardFieldDefs: defs, onOpenDetail: noop, addToast: noop } as any,
{ task: taskB, cardFieldDefs: defs, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
it("detects customFields change when only one side has cardFieldDefs", () => {
const defs = [{ id: "sev", name: "Severity", type: "enum" as const, render: { placement: "card" as const } }];
const task = makeTask({ customFields: { sev: "low" } });
expect(
__test_areTaskCardPropsEqual(
{ task, cardFieldDefs: undefined, onOpenDetail: noop, addToast: noop } as any,
{ task, cardFieldDefs: defs, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
it("detects workflow badge metadata changes", () => {
const task = makeTask();
expect(
__test_areTaskCardPropsEqual(
{ task, workflowBadge: { workflowId: "builtin:coding", workflowName: "Coding" }, onOpenDetail: noop, addToast: noop } as any,
{ task, workflowBadge: { workflowId: "wf-custom", workflowName: "Custom Flow" }, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
});
describe("TaskCard workflow badges", () => {
it("renders a compact accessible workflow badge only when metadata is present", () => {
const { container, rerender } = render(
<TaskCard
// FNXC:PlannerOversight 2026-07-04-00:00: pin the oversight level "off" so
// the FN-7516 oversight/overseer-state badges don't populate
// .card-meta-badges here — this test is specifically about workflow-badge
// placement, not the oversight badges.
task={makeTask({ plannerOversightLevel: "off" })}
onOpenDetail={noop}
addToast={noop}
workflowBadge={{ workflowId: "wf-custom", workflowName: "Custom Flow", workflowIcon: "⚙️" }}
/>,
);
const badge = screen.getByTestId("card-workflow-badge");
const row = screen.getByTestId("card-workflow-badge-row");
const workflowBadgeBlock = [...loadAllAppCssBaseOnly().matchAll(/^\.card-workflow-badge\s*\{([^}]*)\}/gm)]
.map((match) => match[1])
.join("\n");
expect(badge).toHaveTextContent("Custom Flow");
expect(badge.querySelector(".workflow-icon")).not.toBeNull();
expect(badge.querySelector(".workflow-icon")?.nextElementSibling).toHaveTextContent("Custom Flow");
expect(workflowBadgeBlock).toContain("column-gap: calc(var(--space-xs) / 2);");
expect(badge).toHaveAttribute("data-workflow-id", "wf-custom");
expect(badge).toHaveAccessibleName("Workflow Custom Flow");
expect(row).toContainElement(badge);
expect(badge.closest(".card-meta-badges")).toBeNull();
expect(container.querySelector(".card-meta-badges")).toBeNull();
rerender(
<TaskCard
task={makeTask()}
onOpenDetail={noop}
addToast={noop}
workflowBadge={{ workflowId: "", workflowName: "" }}
/>,
);
expect(screen.queryByTestId("card-workflow-badge")).toBeNull();
expect(screen.queryByTestId("card-workflow-badge-row")).toBeNull();
});
it("keeps top badges in the meta cluster while rendering agent and workflow identity below card rows", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-30T12:10:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "in-progress",
priority: "high",
executionMode: "fast",
sourceType: "automation",
sourceMetadata: { agentName: "Created Robot" },
assignedAgentId: "agent-1",
columnMovedAt: "2026-06-30T12:00:00.000Z",
updatedAt: "2026-06-30T12:00:00.000Z",
dependencies: ["FN-1"],
})}
onOpenDetail={noop}
addToast={noop}
workflowBadge={{ workflowId: "wf-long", workflowName: "Very long custom workflow name for aggregate cards", workflowIcon: "🧭" }}
/>,
);
const metaBadges = screen.getByTestId("card-meta-badges");
const badge = screen.getByTestId("card-workflow-badge");
const workflowRow = screen.getByTestId("card-workflow-badge-row");
const agentRow = screen.getByTestId("card-agent-badge-row");
const agentBadge = container.querySelector(".card-agent-created-badge");
expect(metaBadges.querySelector(".card-priority-badge")).not.toBeNull();
expect(metaBadges.querySelector(".card-execution-mode-badge")).not.toBeNull();
expect(metaBadges.querySelector(".card-agent-created-badge")).toBeNull();
expect(metaBadges.querySelector(".card-workflow-badge")).toBeNull();
expect(agentRow).toContainElement(agentBadge as HTMLElement);
expect(agentBadge?.closest(".card-header")).toBeNull();
expect(workflowRow).toContainElement(badge);
expect(agentRow.compareDocumentPosition(workflowRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(container.querySelector(".card-footer-row")).toBeNull();
[".card-meta", ".card-agent-row"].forEach((selector) => {
const row = container.querySelector(selector);
expect(row, `${selector} should render for the placement fixture`).not.toBeNull();
expect(row!.compareDocumentPosition(workflowRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
expect(badge.closest(".card-action-row")).toBeNull();
vi.useRealTimers();
});
});
describe("TaskCard mission badge", () => {
// Access the internal cache reset helper
let clearCache: () => void;
beforeAll(async () => {
const mod = await import("../TaskCard");
clearCache = (mod as any).__test_clearMissionTitleCache;
});
beforeEach(() => {
clearCache?.();
vi.mocked(fetchMission).mockReset();
});
it("displays mission title instead of missionId", async () => {
vi.mocked(fetchMission).mockResolvedValue({
id: "M-ABC123",
title: "Database Optimization",
status: "active",
interviewState: "completed",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-ABC123" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
// MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..."
expect(badge?.textContent).toContain("Database ...");
});
});
it("abbreviates long mission titles with ellipsis", async () => {
vi.mocked(fetchMission).mockResolvedValue({
id: "M-LONG1",
title: "This Is A Very Long Mission Title That Exceeds Twenty Characters",
status: "active",
interviewState: "completed",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-LONG1" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
// MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..."
expect(badge?.textContent).toContain("This Is A...");
});
});
it("falls back to missionId on fetch error", async () => {
vi.mocked(fetchMission).mockRejectedValue(new Error("Network error"));
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-ERR99" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
expect(badge?.textContent).toContain("M-ERR99");
});
});
it("renders a promote action when onPromote is provided", () => {
const onPromote = vi.fn().mockResolvedValue(undefined);
const style = document.createElement("style");
style.textContent = loadAllAppCss();
document.head.appendChild(style);
try {
render(
<TaskCard
task={makeTask({ id: "FN-777", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
/>,
);
const promoteButton = screen.getByTestId("card-promote-FN-777");
expect(promoteButton).toBeDefined();
expect(promoteButton).toHaveClass("card-promote-action");
expect(promoteButton.textContent).toContain("Promote");
const styles = getComputedStyle(promoteButton);
expect(styles.gap).toBe("var(--space-xs)");
expect(styles.padding).toBe("var(--space-xs) var(--space-sm)");
} finally {
style.remove();
}
});
it("right-aligns the promote action inside the card action row", () => {
const onPromote = vi.fn().mockResolvedValue(undefined);
const css = loadAllAppCssBaseOnly();
render(
<TaskCard
task={makeTask({ id: "FN-781", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
/>,
);
const promoteButton = screen.getByTestId("card-promote-FN-781");
const actionRow = promoteButton.closest(".card-action-row");
expect(actionRow).not.toBeNull();
expect(actionRow?.contains(promoteButton)).toBe(true);
expect(css).toMatch(/\.card-promote-action\s*\{[^}]*margin-left:\s*auto;[^}]*\}/);
expect(css).toMatch(/\.card-promote-action\.card-send-back-btn\s*\{[^}]*margin-left:\s*auto;[^}]*\}/);
});
it("keeps the promote action right-aligned in the mobile card action row", () => {
const css = loadAllAppCss();
const onPromote = vi.fn().mockResolvedValue(undefined);
const soloRender = render(
<TaskCard
task={makeTask({ id: "FN-782", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
/>,
);
const soloPromoteButton = screen.getByTestId("card-promote-FN-782");
expect(soloPromoteButton.closest(".card-action-row")?.children).toHaveLength(1);
expect(soloPromoteButton).toHaveClass("card-promote-action", "card-send-back-btn");
soloRender.unmount();
render(
<TaskCard
task={makeTask({ id: "FN-783", column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
prAuthAvailable={true}
autoMergeEnabled={false}
/>,
);
const createPrButton = screen.getByRole("button", { name: "Create pull request" });
const promoteButton = screen.getByTestId("card-promote-FN-783");
const actionRow = promoteButton.closest(".card-action-row");
expect(actionRow).not.toBeNull();
expect(actionRow?.contains(createPrButton)).toBe(true);
expect(createPrButton.compareDocumentPosition(promoteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(css).toMatch(/@media[^{}]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-promote-action\.card-send-back-btn\s*\{[^}]*margin-left:\s*auto;[^}]*\}/);
});
it("calls onPromote without opening the card when promote is clicked", () => {
const onPromote = vi.fn().mockResolvedValue(undefined);
const onOpenDetail = vi.fn();
render(
<TaskCard
task={makeTask({ id: "FN-778", column: "todo" })}
onOpenDetail={onOpenDetail}
addToast={noop}
onPromote={onPromote}
/>,
);
fireEvent.click(screen.getByTestId("card-promote-FN-778"));
expect(onPromote).toHaveBeenCalledWith("FN-778");
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("disables the promote action and shows loading copy while promoting", () => {
const onPromote = vi.fn().mockResolvedValue(undefined);
render(
<TaskCard
task={makeTask({ id: "FN-779", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
onPromote={onPromote}
isPromoting
/>,
);
const promoteButton = screen.getByTestId("card-promote-FN-779") as HTMLButtonElement;
expect(promoteButton.disabled).toBe(true);
expect(promoteButton.textContent).toContain("Promoting…");
fireEvent.click(promoteButton);
expect(onPromote).not.toHaveBeenCalled();
});
it("does not render a promote action when onPromote is omitted", () => {
render(
<TaskCard
task={makeTask({ id: "FN-780", column: "todo" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTestId("card-promote-FN-780")).toBeNull();
});
it("shows mission title in title attribute", async () => {
vi.mocked(fetchMission).mockResolvedValue({
id: "M-TITLE",
title: "Refactor Auth",
status: "active",
interviewState: "completed",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-TITLE" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
expect(badge?.getAttribute("title")).toBe("Mission: Refactor Auth");
});
});
it("shows short mission title without abbreviation", async () => {
vi.mocked(fetchMission).mockResolvedValue({
id: "M-SHORT",
title: "Auth Fix",
status: "active",
interviewState: "completed",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const { container } = render(
<TaskCard
task={makeTask({ missionId: "M-SHORT" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-mission-badge");
expect(badge).not.toBeNull();
await waitFor(() => {
// "Auth Fix" is 8 chars, well under 20 — no abbreviation needed
expect(badge?.textContent).toContain("Auth Fix");
expect(badge?.textContent).not.toContain("...");
});
});
});
describe("TaskCard Android tap regression", () => {
function AndroidTapHarness({
task,
onOpenDetail,
onOpenDetailWithTab,
onClose,
}: {
task: Task;
onOpenDetail: (task: Task) => void;
onOpenDetailWithTab: (task: Task, tab: "changes") => void;
onClose: () => void;
}) {
const [isOpen, setIsOpen] = React.useState(false);
const nav = useNavigationHistory({ enabled: true });
const overlayDismiss = useOverlayDismiss(() => {
onClose();
setIsOpen(false);
});
return (
<NavigationHistoryProvider value={nav}>
<TaskCard
task={task}
onOpenDetail={(nextTask) => {
onOpenDetail(nextTask as Task);
setIsOpen(true);
nav.pushNav({
type: "modal",
close: () => {
onClose();
setIsOpen(false);
},
});
}}
onOpenDetailWithTab={onOpenDetailWithTab}
addToast={noop}
/>
{isOpen && (
<div className="modal-overlay" data-testid="android-modal-overlay" {...overlayDismiss}>
<div className="modal-content">detail</div>
</div>
)}
</NavigationHistoryProvider>
);
}
it("keeps modal open after Android compatibility mouse sequence and supports popstate close", () => {
const onOpenDetail = vi.fn();
const onOpenDetailWithTab = vi.fn();
const onClose = vi.fn();
const pushStateSpy = vi.spyOn(window.history, "pushState");
render(
<AndroidTapHarness
task={makeTask({ column: "todo", status: undefined, mergeDetails: { landedFiles: ["a.ts"] } } as any)}
onOpenDetail={onOpenDetail}
onOpenDetailWithTab={onOpenDetailWithTab}
onClose={onClose}
/>,
);
const card = document.querySelector(".card") as HTMLElement;
fireEvent.touchStart(card, {
touches: [{ clientX: 20, clientY: 20 }],
changedTouches: [{ clientX: 20, clientY: 20 }],
});
fireEvent.touchEnd(card, {
touches: [],
changedTouches: [{ clientX: 20, clientY: 20 }],
});
expect(onOpenDetail).toHaveBeenCalledTimes(1);
expect(pushStateSpy).toHaveBeenCalledTimes(1);
const overlay = screen.getByTestId("android-modal-overlay");
fireEvent.mouseDown(overlay);
fireEvent.mouseUp(overlay);
expect(onClose).toHaveBeenCalledTimes(0);
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("keeps files-changed chip touch path opening changes tab once", () => {
const onOpenDetail = vi.fn();
const onOpenDetailWithTab = vi.fn();
const onClose = vi.fn();
const task = makeTask({
column: "done",
status: undefined,
mergeDetails: { landedFiles: ["a.ts", "b.ts"] },
} as any);
render(
<AndroidTapHarness
task={task}
onOpenDetail={onOpenDetail}
onOpenDetailWithTab={onOpenDetailWithTab as any}
onClose={onClose}
/>,
);
const filesChip = screen.getByRole("button", { name: "2 files changed" });
fireEvent.touchStart(filesChip, {
touches: [{ clientX: 12, clientY: 12 }],
changedTouches: [{ clientX: 12, clientY: 12 }],
});
fireEvent.touchEnd(filesChip, {
touches: [],
changedTouches: [{ clientX: 12, clientY: 12 }],
});
fireEvent.click(filesChip);
expect(onOpenDetailWithTab).toHaveBeenCalledTimes(1);
expect(onOpenDetailWithTab).toHaveBeenCalledWith(task, "changes");
expect(onOpenDetail).toHaveBeenCalledTimes(0);
expect(onClose).toHaveBeenCalledTimes(0);
});
it("allows horizontal board pan from every non-editing card descendant on mobile", () => {
const css = loadAllAppCssBaseOnly();
expect(css).toMatch(
/\.card:not\(\.card-editing\)\s*,\s*\.card:not\(\.card-editing\)\s+\*\s*\{[^}]*touch-action:\s*pan-x\s+pan-y;[^}]*\}/,
);
});
});
describe("TaskCard agent badge", () => {
let clearAgentCache: () => void;
beforeAll(async () => {
const mod = await import("../TaskCard");
clearAgentCache = (mod as { __test_clearAgentNameCache?: () => void }).__test_clearAgentNameCache ?? (() => undefined);
});
beforeEach(() => {
clearAgentCache?.();
localStorage.clear();
vi.mocked(fetchAgent).mockReset();
vi.mocked(fetchAgents).mockReset();
vi.mocked(fetchAgents).mockResolvedValue([] as any);
});
it("renders agent badge synchronously from the seeded agents cache", () => {
seedAgentsCache("p1", [{ id: "agent-001", name: "Task Robot" }]);
const { container } = render(
<TaskCard
projectId="p1"
task={makeTask({ assignedAgentId: "agent-001" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-agent-badge");
expect(badge).not.toBeNull();
expect(badge?.getAttribute("title")).toBe("Assigned to Task Robot");
expect(badge?.className).not.toContain("card-agent-badge--loading");
expect(badge?.querySelector(".card-agent-badge-text")?.textContent).toBe("Task Robot");
expect(badge?.querySelector(".visually-hidden")?.textContent).toContain("Assigned to Task Robot");
expect(fetchAgent).not.toHaveBeenCalled();
});
it("renders agent badge when task has assignedAgentId and falls back to fetchAgent on cache miss", async () => {
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-001",
name: "Task Robot",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
render(
<TaskCard
task={makeTask({ assignedAgentId: "agent-001" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
await waitFor(() => {
const badge = screen.getByTitle("Assigned to Task Robot");
expect(badge).toBeDefined();
expect(badge.querySelector(".visually-hidden")?.textContent).toContain("Assigned to Task Robot");
});
});
it("does not render agent badge when assignedAgentId is undefined", () => {
render(
<TaskCard
task={makeTask()}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTitle(/Assigned to/)).toBeNull();
});
});
describe("TaskCard custom field badges (U13/KTD-14)", () => {
type FieldDef = import("../../api").WorkflowFieldDefinition;
const cardDef = (over: Partial<FieldDef> & Pick<FieldDef, "id" | "name" | "type">): FieldDef => ({
render: { placement: "card" },
...over,
});
it("renders no badges and stays byte-identical when no field defs are passed", () => {
const { container: withTask } = render(
<TaskCard task={makeTask({ customFields: { x: "y" } })} onOpenDetail={noop} addToast={noop} />,
);
expect(withTask.querySelector('[data-testid="card-field-badges"]')).toBeNull();
});
it("renders an enum badge with the option color and label", () => {
const defs: FieldDef[] = [
cardDef({ id: "sev", name: "Severity", type: "enum", options: [{ value: "high", label: "High", color: "#ef4444" }] }),
];
render(
<TaskCard
task={makeTask({ customFields: { sev: "high" } })}
onOpenDetail={noop}
addToast={noop}
cardFieldDefs={defs}
/>,
);
const badge = screen.getByText("High");
expect(badge.getAttribute("style")).toContain("rgb(239, 68, 68)");
});
it("renders a labeled chip for boolean true and nothing for false", () => {
const defs: FieldDef[] = [cardDef({ id: "blk", name: "Blocked", type: "boolean" })];
const { rerender } = render(
<TaskCard task={makeTask({ customFields: { blk: true } })} onOpenDetail={noop} addToast={noop} cardFieldDefs={defs} />,
);
expect(screen.getByText("Blocked")).toBeTruthy();
rerender(
<TaskCard task={makeTask({ customFields: { blk: false } })} onOpenDetail={noop} addToast={noop} cardFieldDefs={defs} />,
);
expect(screen.queryByTestId("card-field-badges")).toBeNull();
});
it("caps at 3 badges and shows a +N overflow indicator", () => {
const defs: FieldDef[] = [
cardDef({ id: "a", name: "A", type: "string" }),
cardDef({ id: "b", name: "B", type: "string" }),
cardDef({ id: "c", name: "C", type: "string" }),
cardDef({ id: "d", name: "D", type: "string" }),
cardDef({ id: "e", name: "E", type: "string" }),
];
render(
<TaskCard
task={makeTask({ customFields: { a: "1", b: "2", c: "3", d: "4", e: "5" } })}
onOpenDetail={noop}
addToast={noop}
cardFieldDefs={defs}
/>,
);
const overflow = screen.getByTestId("card-field-overflow");
expect(overflow.textContent).toBe("+2");
// Exactly 3 value badges + 1 overflow chip.
const container = screen.getByTestId("card-field-badges");
expect(container.querySelectorAll(".card-field-badge").length).toBe(4);
});
it("ignores non-card-placed defs", () => {
const defs: FieldDef[] = [
{ id: "detailOnly", name: "Detail", type: "string", render: { placement: "detail" } },
];
render(
<TaskCard
task={makeTask({ customFields: { detailOnly: "x" } })}
onOpenDetail={noop}
addToast={noop}
cardFieldDefs={defs}
/>,
);
expect(screen.queryByTestId("card-field-badges")).toBeNull();
});
});
/*
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
FN-7596 regression-tests the TaskCard "Start" affordance that promotes a Coding (Ideas) manual-intake card. `showStartAction` requires taskColumnFlags.intake and a non-"triage" column; `startTargetColumn` derives the destination from `taskMoveColumns` (first non-intake/non-archived/non-hiddenFromBoard column) rather than a hard-coded "todo" string, per the FNXC comment at its call site.
*/
describe("TaskCard Start affordance (FN-7596)", () => {
it("renders the Start button for a manual-intake column with onMoveTask provided", () => {
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true, manualIntake: true }}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
expect(screen.getByTestId("card-start-FN-001")).toBeInTheDocument();
});
it("omits the Start button when the column is not flagged as an intake", () => {
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: false }}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
Retitled and re-fixtured. The rule was never about the id `triage` — it was "an intake
lane that AUTO-triages needs no Start button, because the engine picks the card up on
its own". That is now expressed by the absence of `manualIntake` rather than by naming
a column, which is what makes it survive U11 deleting `triage`.
*/
it("omits the Start button for an AUTO-triaging intake column", () => {
render(
<TaskCard
task={makeTask({ column: "triage" })}
taskColumnFlags={{ intake: true }}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
});
it("omits the Start button when no onMoveTask handler is provided", () => {
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true, manualIntake: true }}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
});
it("derives the Start target from taskMoveColumns instead of a hard-coded 'todo' string", async () => {
const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "custom-working-stage" as any }));
const addToast = vi.fn();
// The intake column itself, plus a non-intake working column that is NOT literally
// named "todo", must win over any coincidental fallback — proving derivation, not a hard-coded string.
const taskMoveColumns = [
{ id: "ideas" as any, label: "Ideas", flags: { intake: true } },
{ id: "custom-working-stage" as any, label: "Custom Working Stage", flags: {} },
{ id: "todo" as any, label: "Todo", flags: {} },
];
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true, manualIntake: true }}
taskMoveColumns={taskMoveColumns}
onOpenDetail={noop}
addToast={addToast}
onMoveTask={onMoveTask}
/>,
);
fireEvent.click(screen.getByTestId("card-start-FN-001"));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "custom-working-stage"));
});
it("falls back to 'todo' when taskMoveColumns metadata is unavailable", async () => {
const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "todo" }));
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true, manualIntake: true }}
onOpenDetail={noop}
addToast={noop}
onMoveTask={onMoveTask}
/>,
);
fireEvent.click(screen.getByTestId("card-start-FN-001"));
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo"));
});
it("disables the button and shows the Starting label while the move is in flight, then shows a success toast", async () => {
let resolveMove: (task: ReturnType<typeof makeTask>) => void = () => {};
const onMoveTask = vi.fn().mockImplementation(
() => new Promise((resolve) => { resolveMove = resolve; }),
);
const addToast = vi.fn();
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true, manualIntake: true }}
onOpenDetail={noop}
addToast={addToast}
onMoveTask={onMoveTask}
/>,
);
const startButton = screen.getByTestId("card-start-FN-001");
fireEvent.click(startButton);
await waitFor(() => expect(startButton).toBeDisabled());
expect(startButton.textContent).toContain("Starting");
resolveMove(makeTask({ column: "todo" }));
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringContaining("FN-001"), "success"));
await waitFor(() => expect(startButton).not.toBeDisabled());
});
/*
FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
The Start toast must not claim planning has begun. Start performs a bare column move — it cannot
observe admission — so "Started planning {id}" reported an outcome that a busy concurrency pool
could defer indefinitely, which is what made a throttled card look broken.
*/
it("reports the Start move as queued, not as planning already started", async () => {
const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "todo" }));
const addToast = vi.fn();
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true, manualIntake: true }}
onOpenDetail={noop}
addToast={addToast}
onMoveTask={onMoveTask}
/>,
);
fireEvent.click(screen.getByTestId("card-start-FN-001"));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Queued FN-001 for planning", "success"));
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("Started planning"), expect.anything());
});
it("shows an error toast when the Start move fails", async () => {
const onMoveTask = vi.fn().mockRejectedValue(new Error("move blocked"));
const addToast = vi.fn();
render(
<TaskCard
task={makeTask({ column: "ideas" as any })}
taskColumnFlags={{ intake: true, manualIntake: true }}
onOpenDetail={noop}
addToast={addToast}
onMoveTask={onMoveTask}
/>,
);
fireEvent.click(screen.getByTestId("card-start-FN-001"));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("move blocked", "error"));
});
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-00:15 (U12 — the affordance this file never covered):
THE EDIT BUTTON ON A RENAMED BOARD.
TaskDetailModal resolved field editability from column traits in U10/R8. TaskCard implemented the
same affordance with a hardcoded `{triage, todo}` id set and NO trait path, even though
`taskColumnFlags` was already in scope — so on a board whose pre-implementation column is renamed,
the title was editable in the detail modal and the pencil was absent from the card.
VERIFIED UNCOVERED rather than assumed: mutating `canEdit` back to the hardcoded set left
`app/components/__tests__/TaskCard*` at exactly the same failure count as the unmutated run, so
nothing caught it. These four assert the real `aria-label`, and that mutation now fails with
"Unable to find an accessible element ... name 'Edit task'".
*/
describe("TaskCard field editability resolves column traits (U12 — R8)", () => {
const EDIT_LABEL = { name: "Edit task" };
it("renders the edit button for a RENAMED pre-implementation column", () => {
render(
<TaskCard
task={makeTask({ column: "backlog" as any })}
taskColumnFlags={{ intake: true, hold: true }}
onUpdateTask={noop}
onOpenDetail={noop}
addToast={noop}
/>,
);
// Fails with the hardcoded id set: `backlog` is not in it.
expect(screen.getByRole("button", EDIT_LABEL)).toBeInTheDocument();
});
it("does NOT render it for a resolved mid-flight column", () => {
// The narrowing guard: without it the case above passes for a card that always shows the pencil,
// letting an operator rewrite a description while a session executes against it.
render(
<TaskCard
task={makeTask({ column: "building" as any })}
taskColumnFlags={{ countsTowardWip: true }}
onUpdateTask={noop}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", EDIT_LABEL)).not.toBeInTheDocument();
});
it("vetoes editing when a hold column ALSO carries a review trait", () => {
// A legal shape a plain `intake || hold` check gets wrong.
render(
<TaskCard
task={makeTask({ column: "backlog" as any })}
taskColumnFlags={{ hold: true, mergeBlocker: true }}
onUpdateTask={noop}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", EDIT_LABEL)).not.toBeInTheDocument();
});
it("still renders it for a legacy `todo` card with no flags resolved", () => {
// The pre-load window, and what every board did before the conversion.
render(<TaskCard task={makeTask({ column: "todo" as any })} onUpdateTask={noop} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByRole("button", EDIT_LABEL)).toBeInTheDocument();
});
});