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>
8247 lines
295 KiB
TypeScript
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();
|
|
});
|
|
});
|