Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion __tests__/components/AccountDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe("AccountDetail", () => {
render(<AccountDetail accountId="0xabc123" />);

expect(screen.getByText("Authorized signers")).toBeInTheDocument();
expect(screen.getByText(/state update is in progress/i)).toBeInTheDocument();
expect(screen.getByText(/submitted transaction is awaiting confirmation/i)).toBeInTheDocument();
expect(screen.getByText("1,500 units")).toBeInTheDocument();
expect(screen.getByText("Non-fungible assets")).toBeInTheDocument();

Expand Down
18 changes: 16 additions & 2 deletions __tests__/components/AccountsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ beforeEach(() => vi.clearAllMocks());
// than filter what it already holds.
describe("AccountsPanel frozen-only", () => {
it("asks the Guardian for paused accounts and says the table is filtered", () => {
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("paused=true") as never);
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("state=frozen") as never);
// Keyed by URL: a blanket mockReturnValue hands the accounts payload to
// StatStrip as well, which then reads stats fields off it.
useSWR.mockImplementation((key: string) =>
Expand All @@ -47,7 +47,7 @@ describe("AccountsPanel frozen-only", () => {
// "No accounts registered" would be a lie to someone who arrived from the
// frozen count, and would leave them with no way back.
it("says nothing is frozen rather than that the server is empty", () => {
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("paused=true") as never);
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("state=frozen") as never);
useSWR.mockImplementation((key: string) =>
key === "/api/accounts?limit=500&paused=true"
? { data: { items: [], nextCursor: null }, error: undefined }
Expand Down Expand Up @@ -238,6 +238,20 @@ describe("AccountsPanel", () => {
expect(screen.getByText("0xfrozen")).toBeInTheDocument();
});

// Frozen is also the Guardian's own paused filter. Picking another chip has
// to drop it, or "Any status" would still show the frozen set.
it("leaves the server-side frozen set when another status chip is picked", () => {
const replace = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: vi.fn(), replace } as never);
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("state=frozen") as never);
useSWR.mockImplementation(() => ({ data: { items: [], nextCursor: null }, error: undefined }));
render(<AccountsPanel />);
expect(screen.getByRole("button", { name: "Frozen" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByText("Any status"));
expect(replace).toHaveBeenCalledWith("/accounts");
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams() as never);
});

// The Overview lifecycle counts link here; the chip they name is already selected.
it("preselects the status chip named in the URL", () => {
vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams("state=released") as never);
Expand Down
13 changes: 11 additions & 2 deletions __tests__/components/AttentionCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ describe("AttentionCards", () => {
expect(screen.queryByText("0")).not.toBeInTheDocument();
});

it("frozen leads to the Guardian's paused filter", () => {
it("frozen leads to the Guardian's frozen set", () => {
useSWR.mockImplementation((key: string) => ({
data: key === "/api/accounts/stats" ? { active: 1, frozen: 2, released: 0 } : { latestActivity: null },
}));
render(<AttentionCards />);
expect(screen.getByRole("link", { name: "2" })).toHaveAttribute("href", "/accounts?paused=true");
expect(screen.getByRole("link", { name: "2" })).toHaveAttribute("href", "/accounts?state=frozen");
expect(screen.getByText("Nothing recorded")).toBeInTheDocument();
});

Expand All @@ -49,4 +49,13 @@ describe("AttentionCards", () => {
render(<AttentionCards />);
expect(screen.getByText(/Statistics as of/)).toBeInTheDocument();
});

// Two skeletons that never ended, on a Guardian that refused both calls.
it("shows a dash with the reason when the Guardian did not answer", () => {
const error = Object.assign(new Error("Request failed (503)"), { status: 503 });
useSWR.mockImplementation(() => ({ data: undefined, error }));
render(<AttentionCards />);
expect(screen.getAllByText("—")).toHaveLength(4);
expect(screen.queryByTestId("skeleton")).not.toBeInTheDocument();
});
});
7 changes: 7 additions & 0 deletions __tests__/components/ErrorPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ describe("ErrorPanel", () => {

// Retrying a permission denial or a missing account cannot succeed, so the
// button would be an invitation to a second identical failure.
// The fetcher's fallback message is a status code in words. The house
// sentence never showed because that fallback always counted as a message.
it("says the Guardian did not answer rather than repeating a status code", () => {
render(<ErrorPanel error={new FetchError("Request failed (503)", 503)} />);
expect(screen.getByText("The Guardian did not answer.")).toBeInTheDocument();
});

it("offers a retry only where retrying could work", () => {
const onRetry = vi.fn();
const { rerender } = render(<ErrorPanel error={err(503)} onRetry={onRetry} />);
Expand Down
20 changes: 17 additions & 3 deletions __tests__/components/GuardianStatusCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { FetchError } from "@/lib/utils";
import { GuardianStatusCard } from "@/components/overview/GuardianStatusCard";

vi.mock("swr", () => ({ default: vi.fn() }));
Expand Down Expand Up @@ -53,6 +54,19 @@ describe("GuardianStatusCard", () => {
expect(container.querySelector("[data-slot='skeleton'], .animate-pulse")).toBeTruthy();
});

// The skeleton used to stay up for good when the health call itself failed.
it("says Offline with the reason when the health call fails", () => {
const error = new FetchError("Request failed (503)", 503);
useSWR.mockImplementation((key: string) => {
if (key === "/api/health") return { data: undefined, error };
if (key === "/api/operator-info") return { data: undefined, error };
return { data: overview };
});
render(<GuardianStatusCard />);
expect(screen.getByText("Offline")).toBeInTheDocument();
expect(screen.getAllByText("The Guardian did not answer.")).toHaveLength(2);
});

it("shows Online badge when Guardian is up", () => {
mockSWR();
render(<GuardianStatusCard />);
Expand Down Expand Up @@ -203,10 +217,10 @@ describe("GuardianStatusCard service status", () => {
it("explains a capped aggregate rather than warning about it", () => {
withStatus("degraded", ["accounts_by_auth_method"]);
render(<GuardianStatusCard />);
const line = screen.getByText(/stops computing above 1000 accounts/);
const line = screen.getByText(/stops computing above 1,000 accounts/);
expect(line).toHaveTextContent(
"The Guardian reports itself degraded: the account breakdown by auth method stops " +
"computing above 1000 accounts (aggregation service is WIP).",
"The Guardian reports itself degraded: the account breakdown by signature scheme stops " +
"computing above 1,000 accounts.",
);
// The amber treatment is what said "something is wrong here".
expect(line.className).not.toContain("text-state-frozen");
Expand Down
4 changes: 2 additions & 2 deletions __tests__/components/TableControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ describe("accounts table controls", () => {
it("remembers the choice for the next visit", () => {
const first = render(<AccountsPanel />);
openMenu();
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Pending" }));
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "Submitted" }));
first.unmount();

const { container } = render(<AccountsPanel />);
expect(container.querySelectorAll("thead th")).toHaveLength(8);
expect(screen.queryByText("Pending")).not.toBeInTheDocument();
expect(screen.queryByText("Submitted")).not.toBeInTheDocument();
});

// A stored key for a column that no longer exists must not count against the
Expand Down
2 changes: 1 addition & 1 deletion __tests__/components/TransactionsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe("TransactionsPanel", () => {
// next page arrives by scrolling. Sorted on Activity rather than Date, since
// the feed already arrives newest first and a date sort would look the same.
it("cycles a column through descending, ascending, then back to feed order", () => {
// Three rows whose labels (Note Created, Asset Transfer, Custom) are in
// Three rows whose labels (Note created, Asset transfer, Custom) are in
// neither alphabetical order, so every step of the cycle differs.
const a = { ...delta("0xaaa", 3), category: "note_creation", statusTimestamp: "2026-03-01T00:00:00Z" };
const b = { ...delta("0xbbb", 2), category: "asset_transfer", statusTimestamp: "2026-02-01T00:00:00Z" };
Expand Down
30 changes: 15 additions & 15 deletions __tests__/components/activity-cells.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,21 @@ describe("statusReasonText", () => {
*/
describe("activityLabel", () => {
it("names the types the new testnet brought", () => {
expect(activityLabel("custom", "recallable_send")).toBe("Recallable Send");
expect(activityLabel("custom", "bridged_send")).toBe("Bridged Send");
expect(activityLabel("custom", "recallable_send")).toBe("Recallable send");
expect(activityLabel("custom", "bridged_send")).toBe("Bridged send");
expect(activityLabel("custom", "swap")).toBe("Swap");
});

// The proposal type is the more specific view of the same event, so it wins.
// The same inversion used to call an add_signer delta "Account Changed".
// The same inversion used to call an add_signer delta "Account changed".
it("prefers the proposal type over the category it arrives with", () => {
expect(activityLabel("account_storage_change", "add_signer")).toBe("Signer Added");
expect(activityLabel("account_storage_change", "remove_signer")).toBe("Signer Removed");
expect(activityLabel("note_consumption", "consume_notes")).toBe("Note Consumed");
expect(activityLabel("account_storage_change", "add_signer")).toBe("Signer added");
expect(activityLabel("account_storage_change", "remove_signer")).toBe("Signer removed");
expect(activityLabel("note_consumption", "consume_notes")).toBe("Note consumed");
});

it("still labels a delta that carries no proposal type", () => {
expect(activityLabel("note_creation")).toBe("Note Created");
expect(activityLabel("note_creation")).toBe("Note created");
expect(activityLabel("guardian_switch")).toBe("Switch Guardian");
});

Expand All @@ -86,7 +86,7 @@ describe("activityLabel", () => {
});

// A category this build has never heard of is still passed through rather
// than replaced by "State Change", which would throw away what the Guardian
// than replaced by "State change", which would throw away what the Guardian
// did say. Reached only when the proposal type cannot name itself; a type
// that can now wins, which is what "title-cases a type nobody has curated
// yet" below asserts.
Expand All @@ -96,23 +96,23 @@ describe("activityLabel", () => {
});

it("has a last resort when the Guardian gives neither", () => {
expect(activityLabel()).toBe("State Change");
expect(activityLabel()).toBe("State change");
});

// Proposal types are defined by the applications on Miden, not by the
// Guardian, and every one of them arrives under the `custom` category. Four
// new ones appeared between 2026-09-10 and 2026-10-06, so an unlisted type
// has to read as itself rather than as "Custom".
it("title-cases a type nobody has curated yet", () => {
expect(activityLabel("custom", "earn_deposit")).toBe("Earn Deposit");
expect(activityLabel("custom", "live_send")).toBe("Live Send");
it("sentence-cases a type nobody has curated yet", () => {
expect(activityLabel("custom", "earn_deposit")).toBe("Earn deposit");
expect(activityLabel("custom", "live_send")).toBe("Live send");
expect(activityLabel("custom", "b2agg")).toBe("B2agg");
});

it("prefers a curated label over the derived one", () => {
// Title-casing these would give "P2id" and "Midenid Register".
expect(activityLabel("asset_transfer", "p2id")).toBe("Asset Transfer");
expect(activityLabel("custom", "midenid_register")).toBe("Miden ID Registered");
expect(activityLabel("asset_transfer", "p2id")).toBe("Asset transfer");
expect(activityLabel("custom", "midenid_register")).toBe("Miden ID registered");
});

// `custom_transaction` really is an arbitrary script, so the generic word is
Expand All @@ -133,6 +133,6 @@ describe("activityLabel", () => {
it("declines to derive a label from a payload-shaped type", () => {
expect(activityLabel("custom", "x".repeat(400))).toBe("Custom");
expect(activityLabel("custom", "Not_A_Wire_Token")).toBe("Custom");
expect(activityLabel(undefined, "y".repeat(400))).toBe("State Change");
expect(activityLabel(undefined, "y".repeat(400))).toBe("State change");
});
});
6 changes: 3 additions & 3 deletions __tests__/lib/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { truncateId, formatAmount, storageSlotLabel, accountState, toCsv, accoun
describe("activityToCsv", () => {
it("writes the words the table shows, with every asset the row moved", () => {
const csv = activityToCsv([{
accountId: "0xabc", counterparty: { accountId: "0xdef", direction: "in" }, label: "Asset Transfer",
accountId: "0xabc", counterparty: { accountId: "0xdef", direction: "in" }, label: "Asset transfer",
assets: [{ amount: "1000" }, { amount: "-5" }, {}], status: "confirmed", timestamp: "2026-01-01T00:00:00.000Z",
}]);
expect(csv.split("\r\n")).toEqual([
"Account ID,To / From,Direction,Activity,Amount,Status,Date",
'0xabc,0xdef,in,Asset Transfer,"1,000; -5",confirmed,2026-01-01T00:00:00.000Z',
'0xabc,0xdef,in,Asset transfer,"1,000; -5",confirmed,2026-01-01T00:00:00.000Z',
]);
});

Expand Down Expand Up @@ -118,7 +118,7 @@ describe("accountsToCsv", () => {

it("exports both id forms, the state word and an ISO timestamp", () => {
const [header, row] = accountsToCsv([account], { "0xabc": 1234.5 }).split("\r\n");
expect(header).toBe("Account ID,Account ID (hex),Status,Type,Signers,Pending,Total assets (USD),Created,Updated");
expect(header).toBe("Account ID,Account ID (hex),Status,Type,Signers,Submitted,Total assets (USD),Created,Updated");
expect(row).toBe("mtst1abc,0xabc,active,wallet,2,,1234.5,2026-01-01T00:00:00.000Z,2026-01-02T00:00:00.000Z");
});

Expand Down
2 changes: 1 addition & 1 deletion app/accounts/[accountId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default async function AccountDetailPage({
const { accountId } = await params;
return (
<div className="flex flex-col gap-6">
<h1 className="text-title">Account Detail</h1>
<h1 className="text-title">Account details</h1>
<AccountDetail accountId={decodeURIComponent(accountId)} />
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion app/accounts/[accountId]/transactions/[nonce]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default async function DeltaDetailPage({
const { accountId, nonce } = await params;
return (
<div className="flex flex-col gap-6">
<h1 className="text-title">Transaction Details</h1>
<h1 className="text-title">Transaction details</h1>
<AccountDeltaDetail
accountId={decodeURIComponent(accountId)}
nonce={parseInt(nonce, 10)}
Expand Down
7 changes: 5 additions & 2 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { fetcher } from "@/lib/utils";
import { ErrorPanel } from "@/components/ui/ErrorPanel";

interface ClerkUser {
id: string;
Expand Down Expand Up @@ -93,7 +94,7 @@ function EditModal({ user, onClose }: { user: ClerkUser; onClose: () => void })
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="rounded-xl bg-popover border p-6 max-w-sm w-full mx-4 shadow-xl space-y-4">
<h2 className="text-sm font-semibold">Edit access — {name}</h2>
<h2 className="text-sm font-semibold">Edit access for {name}</h2>

<div>
<p className="text-xs text-muted-foreground mb-2">Role</p>
Expand Down Expand Up @@ -167,7 +168,9 @@ export default function AdminPage() {
</CardHeader>
<CardContent className="p-0 overflow-x-auto">
{!data ? (
<p className="px-4 py-6 text-sm text-muted-foreground">Loading…</p>
error
? <ErrorPanel error={error} />
: <p className="px-4 py-6 text-sm text-muted-foreground">Loading…</p>
) : (
<table className="w-full text-sm">
<thead>
Expand Down
10 changes: 6 additions & 4 deletions components/accounts/AccountDeltaDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ const NOTE_TAG_LABELS: Record<string, string> = {
custom: "Custom script",
};

function Row({ label, value }: { label: React.ReactNode; value: React.ReactNode }) {
function Row({ label, value, title }: { label: React.ReactNode; value: React.ReactNode; title?: string }) {
return (
<div className="flex items-start justify-between gap-4 py-2 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-muted-foreground shrink-0" title={title}>{label}</span>
<span className="font-medium text-right break-all">{value}</span>
</div>
);
Expand Down Expand Up @@ -155,7 +155,7 @@ export function AccountDeltaDetail({ accountId, nonce }: Props) {
{/* The same label the tables show for this row. The raw proposal
type is not for a cell: the live USDCx one is a ~1,500
character payload. */}
<Row label="Type" value={activityLabel(data!.category, data!.proposal?.proposalType)} />
<Row label="Activity" value={activityLabel(data!.category, data!.proposal?.proposalType)} />
<Row label="Date" value={<Timestamp iso={data!.statusTimestamp} />} />
{data!.retryCount !== undefined && data!.retryCount > 0 && (
<Row label="Retries" value={data!.retryCount} />
Expand Down Expand Up @@ -286,13 +286,15 @@ export function AccountDeltaDetail({ accountId, nonce }: Props) {
{showTechnical && (
<Card>
<CardContent className="pt-4 divide-y">
<Row label="Nonce" value={<span className="font-mono">{data!.nonce}</span>} />
<Row label="Nonce" title="The account's transaction counter. Each transaction takes the next number." value={<span className="font-mono">{data!.nonce}</span>} />
<Row
label="Previous state"
title="Hash of the account's state before this transaction."
value={<CopyableId id={data!.prevCommitment} prefixLen={12} suffixLen={8} />}
/>
<Row
label="New state"
title="Hash of the account's state after this transaction."
value={data!.newCommitment
? <CopyableId id={data!.newCommitment} prefixLen={12} suffixLen={8} />
: "—"}
Expand Down
Loading
Loading