Files
PaperClipAI/ui/src/components/IssueRow.test.tsx
T
cb0009b097 fix: preserve recovery retries across restarts (#11817)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The control plane must keep each active issue on a clear execution
or recovery path.
> - A missing issue disposition can require more than one bounded repair
attempt.
> - A server restart could lose that repair path or move source
ownership to the recovery owner.
> - A parked or expired retry could also make the user interface show a
false healthy state.
> - Concurrent recovery loops must not schedule the same repair attempt
twice.
> - This pull request keeps retry state durable, makes scheduling
atomic, and keeps source ownership stable.
> - The benefit is that recovery continues after a restart and operators
see the correct state.

## Linked Issues or Issue Description

**What happened?**

A run that ended without a valid issue disposition could lose its repair
path after a server restart. Manager recovery could also change the
source owner. In addition, a parked or expired retry could make the
issue look healthy when no active work existed. Concurrent
reconciliation could also schedule the same repair attempt twice.

**Expected behavior**

Paperclip must keep bounded source and manager repair attempts across
restarts. Recovery ownership must stay separate from source issue
ownership. The server and user interface must report only a live retry
as active work. Each repair attempt must be scheduled at most once per
company.

**Steps to reproduce**

1. Start an agent run on an issue.
2. End the run without a valid issue disposition.
3. Let the first repair attempt schedule a retry.
4. Restart the server, let the retry time pass without a live run, or
start two reconciliation loops together.
5. Observe that the repair path can stop, the issue can show a false
healthy state, or duplicate retries can be created.

**Paperclip version or commit**

The problem existed on `master` before candidate head
`d8e620fe86bade7df18decac332007f5821ae04f`.

**Deployment mode**

The problem affects self-hosted servers and local builds that use
automatic recovery.

## What Changed

- Persist bounded source-owner and manager repair lineages with stable
fingerprints and retry limits.
- Resume incomplete disposition repairs after a server restart.
- Keep recovery ownership separate from source issue ownership and
enforce source mutation authority.
- Project live retry evidence into issue and blocker summaries.
- Show recovery owner, return owner, attempt count, and retry state in
the board user interface.
- Treat expired or parked retries as attention states unless a queued or
running attempt exists.
- Atomically deduplicate disposition-repair wake requests with a
company-scoped partial unique index.
- Reuse the winning run when concurrent reconciliation loses the
uniqueness race, without duplicate scheduling activity.
- Honor disabled on-demand wake policy before recovery scheduling and
again before delayed retry promotion.
- Keep the new index migration safe for lagging seeded databases that
already contain the index.
- Add server and user interface tests for recovery, restart, ownership,
retry, concurrency, and blocker states.
- Update the implementation and execution semantics documents.

## Verification

- Focused server recovery and ownership suites: 282 tests passed on the
repaired base candidate.
- Focused user interface recovery suites: 128 tests passed on the
repaired base candidate.
- Atomic-deduplication schema and recovery suites: 111 tests passed on
the first Greptile repair.
- Recovery and scheduled-retry wake-policy suites: 126 tests passed at
`d8e620fe86bade7df18decac332007f5821ae04f`.
- The exact lagging-source migration-order test passed after the index
migration became idempotent: 1 test passed and 62 unrelated tests were
skipped.
- `@paperclipai/db` and `@paperclipai/server` typechecks passed at the
current head.
- Migration generation and migration safety checks passed for migration
`0226_tan_colossus.sql`.
- `pnpm check:token-gates` passed on the repaired base candidate.
- `pnpm -r typecheck` passed on the repaired base candidate.
- `pnpm build` passed on the repaired base candidate.
- `pnpm test:run` passed 4,540 tests on the repaired base candidate.
Four fixed-port cases met listeners that already existed on the host.
- The two unchanged fixed-port files passed in an isolated network
namespace: 129 tests passed and 27 tests were skipped.
- Independent Security and QA reviews approved
`63c0423aab54c66f2293a20b0fb3f3b013ee3ba8`; exact-head re-review is
required after automated checks settle on
`d8e620fe86bade7df18decac332007f5821ae04f`.

## Risks

- Recovery orchestration affects issue liveness and ownership. The new
paths use bounded attempts, stable fingerprints, row locks, authority
checks, and database uniqueness.
- A conservative attention state can show more warnings when a scheduled
retry has no queued or running attempt. It does not hide stopped work.
- Migration `0226_tan_colossus.sql` creates a partial unique index on a
known-large table. Migrations run transactionally, so `CONCURRENTLY` is
unavailable. The matching disposition-repair key namespace is introduced
by this release, so deployed databases have no matching rows before the
index is added.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex from the GPT-5 model family used agentic reasoning, tool
use, and code execution. The runtime did not expose the exact model ID
or context window.
- Anthropic Claude Opus 5 used a 1M context window, tool use, and code
execution for part of the user interface repair, as recorded in the
commit history.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:09:42 -05:00

641 lines
20 KiB
TypeScript

// @vitest-environment jsdom
import { act as reactAct } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueRow } from "./IssueRow";
import { StatusIcon } from "./StatusIcon";
vi.mock("@/lib/router", () => ({
Link: ({
children,
className,
disableIssueQuicklook: _disableIssueQuicklook,
issuePrefetch,
...props
}: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null }) => (
<a
className={className}
data-disable-issue-quicklook={_disableIssueQuicklook ? "true" : undefined}
data-issue-prefetch-id={issuePrefetch?.id}
{...props}
>
{children}
</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
if (typeof reactAct === "function") {
reactAct(callback);
return;
}
flushSync(callback);
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
identifier: "PAP-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Inbox item",
description: null,
status: "todo",
priority: "medium",
reviewPolicy: null,
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
isUnreadForMe: false,
...overrides,
workMode: overrides.workMode ?? "standard",
};
}
describe("IssueRow", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("renders the list status glyph at md (16px)", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue({ status: "in_progress" })} />);
});
// jsdom 30 does not value-match a CSS attribute selector against a
// mixed-case SVG attribute name, so `svg[viewBox="0 0 24 24"]` matches
// nothing. Select the glyph SVGs by attribute presence, then compare the
// viewBox value with getAttribute to keep the exact-value assertion.
const glyphs = Array.from(container.querySelectorAll("svg[viewBox]")).filter(
(svg) => svg.getAttribute("viewBox") === "0 0 24 24",
);
expect(glyphs.length).toBeGreaterThan(0);
glyphs.forEach((glyph) => {
expect(glyph.getAttribute("width")).toBe("16");
expect(glyph.getAttribute("height")).toBe("16");
});
act(() => {
root.unmount();
});
});
it("keeps editable row controls keyboard-accessible and outside the navigation link", () => {
const root = createRoot(container);
act(() => {
root.render(
<IssueRow
issue={createIssue()}
desktopMetaLeading={<StatusIcon status="todo" onChange={() => undefined} />}
/>,
);
});
const link = container.querySelector<HTMLAnchorElement>("[data-inbox-issue-link]");
const statusButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Change status (current: Todo)"]',
);
expect(link).not.toBeNull();
expect(statusButton).not.toBeNull();
expect(statusButton?.tabIndex).toBe(0);
expect(link?.contains(statusButton)).toBe(false);
act(() => {
root.unmount();
});
});
it("suppresses accent hover styling when the row is selected", () => {
const root = createRoot(container);
const issue = createIssue();
act(() => {
root.render(<IssueRow issue={issue} selected />);
});
// The hover wash lives on the ROOT row band (not the overlay link) so the
// tint paints behind the content. Selected rows suppress the accent hover.
const row = container.firstElementChild as HTMLElement | null;
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(row).not.toBeNull();
expect(row?.className).toContain("hover:bg-transparent");
expect(row?.className).not.toContain("hover:bg-accent/50");
// The overlay link no longer carries the hover wash.
expect(link?.className ?? "").not.toContain("hover:bg-transparent");
expect(link?.className ?? "").not.toContain("hover:bg-accent/50");
act(() => {
root.unmount();
});
});
it("neutralizes selected status and unread dot accents", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} selected unreadState="visible" />);
});
const markReadButton = container.querySelector('button[aria-label="Mark as read"]');
const unreadDot = markReadButton?.querySelector("span");
// Selected rows neutralize the status glyph to muted via `!`-important
// utilities, which override the glyph's inline colour var. The glyph is an
// <svg> (SVGAnimatedString className), so match on the class attribute.
const statusGlyph = container.querySelector('svg[class*="text-muted-foreground"]');
expect(markReadButton).not.toBeNull();
expect(markReadButton?.className).toContain("hover:bg-muted/80");
expect(markReadButton?.className).not.toContain("hover:bg-blue-500/20");
expect(unreadDot).not.toBeNull();
expect(unreadDot?.className).toContain("bg-muted-foreground/70");
expect(unreadDot?.className).not.toContain("bg-blue-600");
expect(statusGlyph).not.toBeNull();
expect(statusGlyph?.getAttribute("class")).toContain("!text-muted-foreground");
expect(statusGlyph?.getAttribute("class")).toContain("!border-muted-foreground");
act(() => {
root.unmount();
});
});
it("reserves the leading dot slot on read rows so unread rows never indent past them", () => {
const root = createRoot(container);
act(() => {
// A read inbox row still supplies `unreadState` (as "hidden").
root.render(<IssueRow issue={createIssue()} unreadState="hidden" />);
});
// The desktop dot slot is reserved even when read (empty), so unread rows
// add no column and line up with read rows.
const slot = container.querySelector('[data-testid="issue-row-unread-slot"]');
expect(slot).not.toBeNull();
expect(slot?.className).toContain("w-4");
expect(slot?.className).toContain("sm:inline-flex");
// In flow, not an absolute overlay.
expect(slot?.className).not.toContain("absolute");
// Read rows carry no dot button in the slot.
expect(slot?.querySelector('button[aria-label="Mark as read"]')).toBeNull();
act(() => {
root.unmount();
});
});
it("puts the unread dot in the reserved desktop slot and overlays it on mobile", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} unreadState="visible" />);
});
// Desktop: the dot lives in the reserved leading slot (far left, ahead of
// any leading control such as a parent's collapse caret).
const slot = container.querySelector('[data-testid="issue-row-unread-slot"]');
expect(slot).not.toBeNull();
expect(slot?.querySelector('button[aria-label="Mark as read"]')).not.toBeNull();
// Mobile: all inbox rows reserve a gutter and the dot stays fully inside
// it, so the control cannot be clipped or indent the status/title.
const mobileDot = container
.querySelector('button[aria-label="Mark as read"].sm\\:hidden, span.sm\\:hidden button[aria-label="Mark as read"]')
?.closest("span.sm\\:hidden");
expect(mobileDot).not.toBeNull();
expect(mobileDot?.className).toContain("absolute");
expect(mobileDot?.className).toContain("left-0");
expect(mobileDot?.className).not.toContain("order-first");
expect(container.firstElementChild?.className).toContain("pl-4");
act(() => {
root.unmount();
});
});
it("preserves the issue detail breadcrumb source and href in the link target", () => {
const root = createRoot(container);
const issue = createIssue();
const state = {
issueDetailBreadcrumb: { label: "Inbox", href: "/PAP/inbox/mine" },
issueDetailSource: "inbox",
};
act(() => {
root.render(<IssueRow issue={issue} issueLinkState={state} />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toBe("/issues/PAP-1");
act(() => {
root.unmount();
});
});
it("opts issue quicklook out for dense inbox rows", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.getAttribute("data-disable-issue-quicklook")).toBe("true");
act(() => {
root.unmount();
});
});
it("passes the visible row issue into the navigation prefetch path", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link?.getAttribute("data-issue-prefetch-id")).toBe("issue-1");
act(() => {
root.unmount();
});
});
it("renders titleSuffix inline after the issue title", () => {
const root = createRoot(container);
const issue = createIssue({ title: "Parent task" });
act(() => {
root.render(
<IssueRow
issue={issue}
titleSuffix={<span data-testid="suffix">(3 sub-tasks)</span>}
/>,
);
});
const titleEl = container.querySelector(".line-clamp-2, .truncate");
expect(titleEl?.textContent).toContain("Parent task");
expect(titleEl?.textContent).toContain("(3 sub-tasks)");
expect(container.querySelector('[data-testid="suffix"]')).not.toBeNull();
act(() => {
root.unmount();
});
});
it("renders checklist step numbers beside the issue identifier", () => {
const root = createRoot(container);
act(() => {
root.render(
<IssueRow
issue={createIssue({ identifier: "PAP-42" })}
checklistStepNumber="2.1"
mobileMeta="updated now"
/>,
);
});
const metaRow = Array.from(container.querySelectorAll("span.flex.items-center.gap-2"))
.find((element) => element.textContent?.includes("PAP-42"));
expect(metaRow).not.toBeUndefined();
expect(metaRow?.textContent?.replace(/\s+/g, "")).toContain("2.1.PAP-42");
act(() => {
root.unmount();
});
});
it("marks the current checklist step without adding a left border", () => {
const root = createRoot(container);
act(() => {
root.render(
<IssueRow
issue={createIssue({ identifier: "PAP-42" })}
checklistStepNumber="2.1"
checklistCurrentStep
/>,
);
});
// `aria-current="step"` stays on the overlay link (the focusable target),
// but the current-step wash moved to the ROOT row band alongside the hover
// wash so it paints behind the content.
const row = container.firstElementChild as HTMLElement | null;
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.getAttribute("aria-current")).toBe("step");
expect(row?.className).toContain("bg-primary/5");
expect(row?.className).not.toContain("border-l-");
expect(link?.className ?? "").not.toContain("bg-primary/5");
act(() => {
root.unmount();
});
});
it("does not render a planning mode marker for planning work mode issues", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue({ workMode: "planning" })} />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.textContent).not.toContain("Planning");
act(() => {
root.unmount();
});
});
it("renders without error when titleSuffix is omitted", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} />);
});
const titleEl = container.querySelector(".line-clamp-2, .truncate");
expect(titleEl?.textContent).toContain("Inbox item");
act(() => {
root.unmount();
});
});
it("flags rows blocked by an assigned-backlog leaf with a parked-work badge", () => {
const root = createRoot(container);
const issue = createIssue({
blockedBy: [
{
id: "blocker-1",
identifier: "PAP-2",
title: "Parked child",
status: "backlog",
priority: "high",
assigneeAgentId: "agent-99",
assigneeUserId: null,
},
],
});
act(() => {
root.render(<IssueRow issue={issue} />);
});
const badges = container.querySelectorAll('[data-testid="issue-row-parked-blocker"]');
expect(badges.length).toBeGreaterThan(0);
expect(badges[0]?.textContent).toContain("Blocked by parked work");
act(() => {
root.unmount();
});
});
it("does not show the parked-work badge when assigned blocker is not in backlog", () => {
const root = createRoot(container);
const issue = createIssue({
blockedBy: [
{
id: "blocker-1",
identifier: "PAP-2",
title: "Active child",
status: "in_progress",
priority: "high",
assigneeAgentId: "agent-99",
assigneeUserId: null,
},
],
});
act(() => {
root.render(<IssueRow issue={issue} />);
});
expect(container.querySelector('[data-testid="issue-row-parked-blocker"]')).toBeNull();
act(() => {
root.unmount();
});
});
it("renders no bottom divider by default", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} />);
});
// Dividers are opt-in: without `showDivider` no row-separating border
// renders on either the root band or the overlay link.
const row = container.firstElementChild as HTMLElement | null;
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(row).not.toBeNull();
expect(row?.className).not.toContain("border-b");
expect(link?.className ?? "").not.toContain("border-b");
act(() => {
root.unmount();
});
});
it("renders an opt-in bottom divider on the row root when showDivider is set", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} showDivider />);
});
// The divider lives on the ROOT row band with `last:border-b-0` so the real
// last row drops its border — it is not on the overlay link.
const row = container.firstElementChild as HTMLElement | null;
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(row?.className).toContain("border-b");
expect(row?.className).toContain("last:border-b-0");
expect(link?.className ?? "").not.toContain("border-b");
act(() => {
root.unmount();
});
});
it("keeps the hover wash on the row root while the overlay link stays a bare positioning layer", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} />);
});
const row = container.firstElementChild as HTMLElement | null;
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
// Hover wash paints behind the content on the root band...
expect(row?.className).toContain("hover:bg-accent/50");
expect(link?.className ?? "").not.toContain("hover:bg-accent/50");
// ...and the overlay link keeps only positioning + focus concerns.
expect(link?.className).toContain("absolute");
expect(link?.className).toContain("inset-0");
act(() => {
root.unmount();
});
});
describe("recovery chip liveness", () => {
const NOW = new Date("2026-08-18T12:00:00.000Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
function at(offsetMs: number) {
return new Date(NOW.getTime() + offsetMs).toISOString();
}
function recoveryIssue(retryAt: string, scheduledRetry: Issue["scheduledRetry"] = null): Issue {
return createIssue({
status: "in_progress",
scheduledRetry,
activeRecoveryAction: {
id: "action-1",
companyId: "company-1",
sourceIssueId: "issue-1",
recoveryIssueId: null,
kind: "deliberate_wait_without_target",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-owner",
ownerUserId: null,
previousOwnerAgentId: "agent-owner",
returnOwnerAgentId: "agent-owner",
cause: "deliberate_wait_without_target",
fingerprint: "fp",
evidence: {},
nextAction: "Record a real next step.",
wakePolicy: {
type: "bounded_owner_disposition_repair",
retryAgentId: "agent-owner",
attempt: 1,
maxAttempts: 5,
retryAt,
scheduledRunId: "run-2",
},
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 5,
timeoutAt: retryAt,
lastAttemptAt: retryAt,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: at(-10 * 60_000),
updatedAt: at(-10 * 60_000),
},
});
}
function renderChip(issue: Issue): HTMLElement | null {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={issue} />);
});
const chip = container.querySelector<HTMLElement>(
"[data-testid='issue-row-recovery-indicator']",
);
const snapshot = chip?.cloneNode(true) as HTMLElement | null;
act(() => {
root.unmount();
});
return snapshot;
}
it("stays calm while the next attempt is still ahead", () => {
const chip = renderChip(recoveryIssue(at(3 * 60_000)));
expect(chip?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(chip?.getAttribute("aria-label")).toContain("next try in 3m");
});
it("warns once the stored attempt came due and never ran", () => {
// The inbox chip must reach the same verdict as the source card, so a parent scanning
// the inbox is not told recovery is running when nothing is.
const chip = renderChip(recoveryIssue(at(-5 * 60_000)));
expect(chip?.getAttribute("data-recovery-state")).toBe("needed");
const label = chip?.getAttribute("aria-label") ?? "";
expect(label).toContain("Recovery needed");
expect(label).toContain("retry missed 5m ago");
expect(label).not.toContain("next try");
});
it("stays calm when the overdue attempt is a verified live run", () => {
const chip = renderChip(
recoveryIssue(at(-5 * 60_000), {
runId: "run-2",
status: "running",
agentId: "agent-owner",
agentName: "CodexCoder",
retryOfRunId: null,
scheduledRetryAt: at(-5 * 60_000),
scheduledRetryAttempt: 1,
scheduledRetryReason: null,
}),
);
expect(chip?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(chip?.getAttribute("aria-label")).toContain("attempt running now");
});
});
});