From ded9e6cc0d7955e47298394ee3b5be4a2aa83107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Max-Ow=C3=B3lab=C3=AD?= Date: Sat, 29 Aug 2026 00:36:38 +0100 Subject: [PATCH 1/4] feat: add notification_bell with a11y compliance and validation alerts Implements the navbar alert bell badge as a keyboard-operable (role=button) disclosure with ARIA compliance (#320): accessible name, aria-haspopup/aria-expanded/aria-controls, aria-live announcement regions, aria-hidden on decorative glyphs, focus-visible rings and design-token contrast. Adds field error indicators and alerts (#324): validation field configs render role=alert error text that toggles as validation triggers, wired via aria-describedby and counted toward the unread badge. Adds React Testing Library tests covering both requirements. Closes #320 Closes #324 --- __tests__/notification_bell.test.tsx | 257 +++++++++++++++++++++++++++ app/components/notification_bell.tsx | 184 +++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 __tests__/notification_bell.test.tsx create mode 100644 app/components/notification_bell.tsx diff --git a/__tests__/notification_bell.test.tsx b/__tests__/notification_bell.test.tsx new file mode 100644 index 0000000..9b3ec60 --- /dev/null +++ b/__tests__/notification_bell.test.tsx @@ -0,0 +1,257 @@ +/** + * Test suite for `notification_bell` (Navbar alert bell badge). + * + * Covers: + * - #320 a11y compliance: keyboard operability, ARIA roles/attributes, + * aria-live regions, aria-hidden on decorative glyphs, focus-visible + * styling, and accessible labels / badge counts. + * - #324 validation alerts: error text elements that toggle when + * validation triggers, role="alert" announcement, aria-invalid + + * aria-describedby wiring, and badge counts driven by errors. + */ + +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import NotificationBell from "@/app/components/notification_bell"; + +const renderBell = (props = {}) => render(); + +// =========================================================================== +// #320 — a11y: keyboard operability & ARIA roles +// =========================================================================== + +describe("notification_bell — a11y (keyboard & ARIA)", () => { + it("renders a native button trigger with an accessible name", () => { + renderBell(); + expect(screen.getByRole("button", { name: /Notifications/ })).toBeInTheDocument(); + expect(screen.getByRole("button")).toBeInstanceOf(HTMLButtonElement); + }); + + it("exposes the disclosure state via aria-expanded", () => { + renderBell(); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); + + it("declares the panel with aria-haspopup and links it via aria-controls", () => { + renderBell(); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + const panelId = trigger.getAttribute("aria-controls"); + expect(panelId).toBeTruthy(); + expect(document.getElementById(panelId as string)?.id).toBe(panelId); + }); + + it("marks decorative bell glyph as aria-hidden", () => { + renderBell(); + const glyph = screen.getByText("🔔"); + expect(glyph).toHaveAttribute("aria-hidden", "true"); + }); + + it("marks the visible badge count as aria-hidden and duplicates it in sr-only text", () => { + renderBell({ + notifications: [{ id: "n1", type: "info", title: "Hi" }], + }); + const hiddenCount = screen.getAllByText("1").find((el) => + el.hasAttribute("aria-hidden") + ); + expect(hiddenCount).toBeTruthy(); + expect( + screen.getByText("1 unread notification") + ).toBeInTheDocument(); + }); + + it("announces the panel via an aria-live region once opened", () => { + renderBell(); + fireEvent.click(screen.getByRole("button")); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-live", "polite"); + }); + + it("provides a focus-visible ring class on the trigger", () => { + renderBell(); + expect(screen.getByRole("button").className).toMatch(/focus-visible:ring/); + }); + + it("operates from the keyboard (Enter/Space activate the native button)", () => { + renderBell(); + const trigger = screen.getByRole("button"); + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + fireEvent.keyDown(trigger, { key: " " }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); +}); + +// =========================================================================== +// #320 — a11y: accessible names & landmark context +// =========================================================================== + +describe("notification_bell — a11y (labels & landmarks)", () => { + it("supports a custom accessible-name label on the trigger", () => { + renderBell({ label: "Alerts" }); + expect(screen.getByRole("button", { name: /Alerts/ })).toBeInTheDocument(); + }); + + it("names the dialog panel after the label", () => { + renderBell({ label: "Alerts" }); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("dialog", { name: "Alerts panel" })).toBeInTheDocument(); + }); + + it("groups validation fields inside a labelled region", () => { + renderBell({ fields: [{ name: "amount", label: "Amount" }] }); + fireEvent.click(screen.getByRole("button")); + expect( + screen.getByRole("group", { name: "Validation errors" }) + ).toBeInTheDocument(); + }); + + it("shows a 'caught up' message when there is nothing to show", () => { + renderBell(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("You're all caught up.")).toBeInTheDocument(); + }); +}); + +// =========================================================================== +// #324 — validation alerts toggle with validation triggers +// =========================================================================== + +describe("notification_bell — validation alerts (#324)", () => { + it("renders an error message when a field is invalid", () => { + renderBell({ + fields: [ + { name: "amount", label: "Milestone amount", error: "Amount is required." }, + ], + }); + fireEvent.click(screen.getByRole("button")); + expect( + screen.getByRole("alert", { name: "" }) + ).toBeInTheDocument(); + expect(screen.getByText("Amount is required.")).toBeInTheDocument(); + expect(screen.getByText("Invalid")).toBeInTheDocument(); + }); + + it("hides the error text when the field becomes valid", () => { + const { rerender } = render( + + ); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("Amount is required.")).toBeInTheDocument(); + + rerender( + + ); + expect(screen.queryByText("Amount is required.")).not.toBeInTheDocument(); + expect(screen.getByText("Valid")).toBeInTheDocument(); + }); + + it("marks valid fields as clean with no alert role", () => { + renderBell({ fields: [{ name: "amount", label: "Milestone amount" }] }); + fireEvent.click(screen.getByRole("button")); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByText("Valid")).toBeInTheDocument(); + }); + + it("announces field errors with role=alert and assertive aria-live", () => { + renderBell({ + fields: [ + { name: "deadline", label: "Deadline", error: "Deadline is in the past." }, + ], + }); + fireEvent.click(screen.getByRole("button")); + const alertEl = screen.getByRole("alert"); + expect(alertEl).toHaveAttribute("aria-live", "assertive"); + expect(alertEl).toHaveTextContent("Deadline is in the past."); + }); + + it("counts invalid fields toward the badge", () => { + renderBell({ + fields: [ + { name: "a", label: "A", error: "bad" }, + { name: "b", label: "B", error: "bad" }, + ], + }); + expect(screen.getAllByText("2")).toHaveLength(1); + expect(screen.getByText("2 unread notifications")).toBeInTheDocument(); + }); + + it("clears the badge when all fields validate", () => { + const { rerender } = render( + + ); + expect(screen.getByText("1 unread notification")).toBeInTheDocument(); + rerender(); + expect(screen.queryByText(/unread notification/)).not.toBeInTheDocument(); + }); + + it("renders a per-field indicator inside the validation group", () => { + renderBell({ + fields: [ + { name: "amount", label: "Milestone amount", error: "Amount is required." }, + { name: "token", label: "Token" }, + ], + }); + fireEvent.click(screen.getByRole("button")); + const group = screen.getByRole("group", { name: "Validation errors" }); + expect(within(group).getByText("Amount is required.")).toBeInTheDocument(); + expect(within(group).getByText("Milestone amount")).toBeInTheDocument(); + expect(within(group).getByText("Token")).toBeInTheDocument(); + }); +}); + +// =========================================================================== +// #324 — notification panels & alert roles +// =========================================================================== + +describe("notification_bell — notifications & alert roles", () => { + it("renders each notification in the panel", () => { + renderBell({ + notifications: [ + { id: "n1", type: "info", title: "New milestone" }, + { id: "n2", type: "warning", title: "Low balance" }, + ], + }); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByText("New milestone")).toBeInTheDocument(); + expect(screen.getByText("Low balance")).toBeInTheDocument(); + }); + + it("uses role=alert with assertive live for error notifications", () => { + renderBell({ + notifications: [{ id: "err", type: "error", title: "Signature failed" }], + }); + fireEvent.click(screen.getByRole("button")); + const alertEl = screen.getByRole("alert"); + expect(alertEl).toHaveAttribute("aria-live", "assertive"); + expect(alertEl).toHaveTextContent("Signature failed"); + }); + + it("uses role=status for non-error notifications", () => { + renderBell({ + notifications: [{ id: "ok", type: "success", title: "Released" }], + }); + fireEvent.click(screen.getByRole("button")); + const statusEl = screen.getAllByRole("status").find((el) => + el.textContent?.includes("Released") + ); + expect(statusEl).toBeTruthy(); + }); + + it("marks the panel hidden until opened", () => { + renderBell({ notifications: [{ id: "n1", type: "info", title: "Hi" }] }); + const dialog = screen.getByRole("dialog", { hidden: true }); + expect(dialog).toHaveProperty("hidden", true); + fireEvent.click(screen.getByRole("button")); + expect(dialog).toHaveProperty("hidden", false); + }); +}); diff --git a/app/components/notification_bell.tsx b/app/components/notification_bell.tsx new file mode 100644 index 0000000..4b6dc17 --- /dev/null +++ b/app/components/notification_bell.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useId, useState } from "react"; + +export type NotificationType = "error" | "warning" | "success" | "info"; + +export interface NotificationItem { + id: string; + type: NotificationType; + title: string; + message?: string; +} + +export interface NotificationField { + name: string; + label: string; + error?: string | null; +} + +export interface NotificationBellProps { + /** Notifications to surface in the panel. */ + notifications?: NotificationItem[]; + /** Validation field configurations; entries with an `error` render an alert. */ + fields?: NotificationField[]; + /** Label used for the trigger button (defaults to "Notifications"). */ + label?: string; +} + +const TYPE_STYLES: Record = { + error: "border-danger bg-danger/40 text-danger-soft", + warning: "border-warning bg-warning/40 text-warning-soft", + success: "border-success bg-success/40 text-success-soft", + info: "border-accent bg-accent/40 text-accent-soft", +}; + +const TYPE_ICON: Record = { + error: "✕", + warning: "⚠", + success: "✓", + info: "ℹ", +}; + +function computeBadgeCount(notifications: NotificationItem[], fields: NotificationField[]) { + return notifications.length + fields.filter((f) => f.error).length; +} + +/** + * `notification_bell` — navbar alert bell badge. + * + * Accessibility (a11y): + * - Native ` + + + + ); +} From a9573a64bdf8b93086b0da8ce3d56faad2347163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Max-Ow=C3=B3lab=C3=AD?= Date: Sat, 29 Aug 2026 17:41:50 +0100 Subject: [PATCH 2/4] feat: add Storybook interface mocks for notification_bell [closes #328] --- app/components/notification_bell.stories.tsx | 179 +++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 app/components/notification_bell.stories.tsx diff --git a/app/components/notification_bell.stories.tsx b/app/components/notification_bell.stories.tsx new file mode 100644 index 0000000..ed2c6ba --- /dev/null +++ b/app/components/notification_bell.stories.tsx @@ -0,0 +1,179 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import NotificationBell from "./notification_bell"; + +const meta = { + title: "Components/NotificationBell", + component: NotificationBell, + tags: ["autodocs"], + parameters: { + layout: "padded", + backgrounds: { + default: "dark", + values: [ + { name: "dark", value: "#0f1117" }, + { name: "light", value: "#ffffff" }, + ], + }, + }, + argTypes: { + label: { control: "text" }, + }, + args: { + label: "Notifications", + }, +} satisfies Meta; +type Story = StoryObj; + +export default meta; + +// --------------------------------------------------------------------------- +// 1. Default — no notifications, no validation fields +// --------------------------------------------------------------------------- + +export const Default: Story = { + name: "Default — no notifications", + args: { + notifications: [], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 2. With notifications +// --------------------------------------------------------------------------- + +export const WithNotifications: Story = { + name: "With notifications", + args: { + notifications: [ + { id: "1", type: "success", title: "Payment received", message: "10 USDC sent to your wallet" }, + { id: "2", type: "info", title: "New message", message: "You have a new message from buyer" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 3. With error notification +// --------------------------------------------------------------------------- + +export const WithError: Story = { + name: "With error notification", + args: { + notifications: [ + { id: "1", type: "error", title: "Payment failed", message: "Insufficient balance" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 4. With warning notification +// --------------------------------------------------------------------------- + +export const WithWarning: Story = { + name: "With warning notification", + args: { + notifications: [ + { id: "1", type: "warning", title: "Action required", message: "Please verify your email" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 5. With success notification +// --------------------------------------------------------------------------- + +export const WithSuccess: Story = { + name: "With success notification", + args: { + notifications: [ + { id: "1", type: "success", title: "Deposit completed", message: "Your deposit of 50 USDC was successful" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 6. With info notification +// --------------------------------------------------------------------------- + +export const WithInfo: Story = { + name: "With info notification", + args: { + notifications: [ + { id: "1", type: "info", title: "System update", message: "Maintenance scheduled for tonight" }, + ], + fields: [], + }, +}; + +// --------------------------------------------------------------------------- +// 7. With validation fields having errors +// --------------------------------------------------------------------------- + +export const WithValidationErrors: Story = { + name: "With validation field errors", + args: { + notifications: [], + fields: [ + { + name: "amount", + label: "Amount", + error: "Amount must be greater than 0", + }, + { + name: "recipient", + label: "Recipient", + error: "Invalid recipient address", + }, + ], + }, +}; + +// --------------------------------------------------------------------------- +// 8. With both notifications and validation fields +// --------------------------------------------------------------------------- + +export const WithNotificationsAndErrors: Story = { + name: "With notifications and validation errors", + args: { + notifications: [ + { id: "1", type: "error", title: "Payment failed", message: "Insufficient balance" }, + ], + fields: [ + { + name: "amount", + label: "Amount", + error: "Amount must be greater than 0", + }, + ], + }, +}; + +// --------------------------------------------------------------------------- +// 9. Custom label +// --------------------------------------------------------------------------- + +export const CustomLabel: Story = { + name: "Custom label", + args: { + notifications: [], + fields: [], + label: "My Notifications", + }, +}; + +// --------------------------------------------------------------------------- +// 10. Empty state — all caught up +// --------------------------------------------------------------------------- + +export const EmptyState: Story = { + name: "Empty — all caught up", + args: { + notifications: [], + fields: [], + }, +}; \ No newline at end of file From b16371e10a8deb24c67b927378d6b13f41a1dfc6 Mon Sep 17 00:00:00 2001 From: Kingeligma Date: Sat, 29 Aug 2026 18:05:58 +0100 Subject: [PATCH 3/4] feat(notification_bell): add premium interactive states Style hover, active/pressed, focus-visible, and disabled states on the bell trigger with Tailwind utilities: - hover background shift, active press feedback (bg + scale) with a faster press duration - focus-visible keyboard ring preserved with offset - disabled prop: native + aria-disabled, muted opacity, cursor-locked, inert hover/press/focus variants while the unread badge stays visible Adds notification_bell_interactive_states tests covering hover, press, focus-visible, keyboard toggling, and disabled trigger behavior. Also carries the suite-repair changes (SignatureTimeoutAlert useMemo, localStorage shim, freighter XDR fixtures, useWallet stubs) so CI runs green. Closes 321 --- .../freighter_connector.component.test.tsx | 33 ++++++ __tests__/freighter_multisig_hook.test.ts | 27 ++++- ...ification_bell_interactive_states.test.tsx | 107 ++++++++++++++++++ __tests__/signature_timeout_alert.test.tsx | 44 ++++++- app/components/SignatureTimeoutAlert.tsx | 18 ++- app/components/notification_bell.tsx | 16 ++- vitest.setup.ts | 39 +++++++ 7 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 __tests__/notification_bell_interactive_states.test.tsx diff --git a/__tests__/freighter_connector.component.test.tsx b/__tests__/freighter_connector.component.test.tsx index 5b0fdf4..ae075c8 100644 --- a/__tests__/freighter_connector.component.test.tsx +++ b/__tests__/freighter_connector.component.test.tsx @@ -8,6 +8,39 @@ import { HIGH_FEE_THRESHOLD_STROOPS, } from "@/app/lib/freighter_connector"; +// WalletProvider pulls @creit.tech/stellar-wallets-kit at module scope, whose +// bundled UMD dependencies are not Node-ESM-importable. This component suite +// renders FreighterGasWarningBanner inside FreighterConnector via +// GasEstimationWarningBanner, which only reads `gasWarning` from the wallet +// context — stubbing `useWallet` with the real provider's default shape keeps +// the real freighter_connector logic under test. +const walletContextMock = vi.hoisted(() => ({ + useWallet: () => ({ + address: null, + assembleMultiSigTransaction: vi.fn(async () => ({ + uniqueSigners: 0, + splitsValidated: 0, + })), + connect: vi.fn(async () => {}), + disconnect: vi.fn(), + isConnecting: false, + networkMismatchMessage: null, + selectedWalletId: "freighter", + setSelectedWalletId: vi.fn(), + signTransaction: vi.fn(async () => ""), + signatureTimeoutError: null, + signatureTimeoutXdr: null, + clearSignatureTimeout: vi.fn(), + simulationResult: null, + setSimulationResult: vi.fn(), + gasWarning: null, + }), +})); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: walletContextMock.useWallet, +})); + // --------------------------------------------------------------------------- // #112 — React Testing Library assertions for freighter_connector // --------------------------------------------------------------------------- diff --git a/__tests__/freighter_multisig_hook.test.ts b/__tests__/freighter_multisig_hook.test.ts index f4f82bb..608b42c 100644 --- a/__tests__/freighter_multisig_hook.test.ts +++ b/__tests__/freighter_multisig_hook.test.ts @@ -1,5 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { Account, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; import { useFreighterMultiSigAssembly } from "@/app/hooks/useFreighterMultiSigAssembly"; import { WalletMultiSigStructureError } from "@/app/lib/wallet_state_context"; import { @@ -13,13 +14,31 @@ function toBase64(text: string): string { const TEST_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; +// The hook validates XDR against a real @stellar/stellar-sdk parser, so the +// fixtures must be genuine signed transaction envelopes rather than arbitrary +// bytes. Building a real envelope (and signing it so `signatures` is +// populated) exercises the parser instead of failing it. +function buildSignedEnvelope(): string { + const keypair = Keypair.random(); + const account = new Account(keypair.publicKey(), "0"); + const transaction = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.bumpSequence({ bumpTo: "0" })) + .setTimeout(30) + .build(); + transaction.sign(keypair); + return transaction.toEnvelope().toXDR("base64"); +} + describe("useFreighterMultiSigAssembly hook (#109)", () => { it("parseStructure parses a well-formed envelope without errors", () => { const { result } = renderHook(() => useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("a".repeat(200)); + const xdr = buildSignedEnvelope(); const shape = result.current.parseStructure(xdr); expect(shape.baseXdr).toBe(xdr); @@ -42,7 +61,7 @@ describe("useFreighterMultiSigAssembly hook (#109)", () => { useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("a".repeat(200)); + const xdr = buildSignedEnvelope(); const tx = result.current.prepareTransaction(xdr); expect(tx).toBeDefined(); }); @@ -62,7 +81,7 @@ describe("useFreighterMultiSigAssembly hook (#109)", () => { useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("b".repeat(200)); + const xdr = buildSignedEnvelope(); const tx = result.current.prepareTransaction(xdr); const serialized = result.current.serializeTransaction(tx); expect(typeof serialized).toBe("string"); @@ -74,7 +93,7 @@ describe("useFreighterMultiSigAssembly hook (#109)", () => { useFreighterMultiSigAssembly(TEST_NETWORK_PASSPHRASE) ); - const xdr = toBase64("c".repeat(200)); + const xdr = buildSignedEnvelope(); const signFn = vi.fn(async () => "signed-xdr"); const signed = await result.current.signTransaction(xdr, signFn); diff --git a/__tests__/notification_bell_interactive_states.test.tsx b/__tests__/notification_bell_interactive_states.test.tsx new file mode 100644 index 0000000..f5443e6 --- /dev/null +++ b/__tests__/notification_bell_interactive_states.test.tsx @@ -0,0 +1,107 @@ +/** + * Test suite for `notification_bell` premium interactive states (#321). + * + * Covers: + * - Visible hover affordances (background + pointer affordances). + * - Active/pressed feedback on the trigger. + * - Focus-visible ring for keyboard users. + * - Disabled trigger: native + ARIA disabled, no toggle, styled fallback, + * keyboard inertness while the unread badge stays visible. + */ + +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import NotificationBell from "@/app/components/notification_bell"; + +const renderBell = (props = {}) => render(); + +describe("notification_bell — interactive states (#321)", () => { + let trigger: HTMLElement; + + beforeEach(() => { + renderBell({ notifications: [{ id: "n1", type: "info", title: "Hi" }] }); + trigger = screen.getByRole("button"); + }); + + it("restyles the trigger on hover", () => { + expect(trigger.className).toMatch(/hover:bg-gray-700/); + }); + + it("provides pressed/active feedback on the trigger", () => { + expect(trigger.className).toMatch(/active:bg-gray-600/); + expect(trigger.className).toMatch(/active:scale-95/); + }); + + it("keeps the focus-visible keyboard ring on the trigger", () => { + expect(trigger.className).toMatch(/focus-visible:ring-2/); + expect(trigger.className).toMatch(/focus-visible:ring-indigo-400/); + expect(trigger.className).toMatch(/focus-visible:ring-offset-2/); + }); + + it("is still keyboard-operable and toggles the panel on activation", () => { + expect(trigger).toHaveAttribute("aria-expanded", "false"); + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); +}); + +describe("notification_bell — disabled trigger (#321)", () => { + it("reflects the disabled state on native and ARIA attributes", () => { + renderBell({ disabled: true }); + const trigger = screen.getByRole("button"); + expect(trigger).toBeDisabled(); + expect(trigger).toHaveAttribute("aria-disabled", "true"); + }); + + it("applies disabled affordances (muted, inert hover/press/focus)", () => { + renderBell({ disabled: true, notifications: [{ id: "n", type: "info", title: "Hi" }] }); + const trigger = screen.getByRole("button"); + expect(trigger.className).toMatch(/disabled:cursor-not-allowed/); + expect(trigger.className).toMatch(/disabled:opacity-60/); + expect(trigger.className).toMatch(/disabled:hover:bg-gray-800/); + expect(trigger.className).toMatch(/disabled:active:scale-100/); + expect(trigger.className).toMatch(/disabled:focus-visible:ring-0/); + }); + + it("does not open the panel when clicked while disabled", () => { + renderBell({ disabled: true }); + const trigger = screen.getByRole("button"); + const dialog = screen.getByRole("dialog", { hidden: true }); + expect(dialog).toHaveProperty("hidden", true); + + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(dialog).toHaveProperty("hidden", true); + }); + + it("ignores keyboard activation while disabled", () => { + renderBell({ disabled: true }); + const trigger = screen.getByRole("button"); + fireEvent.keyDown(trigger, { key: "Enter" }); + fireEvent.click(trigger); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByRole("dialog", { hidden: true })).toHaveProperty( + "hidden", + true + ); + }); + + it("retains the unread badge and accessible name while disabled", () => { + const label = "Alerts"; + renderBell({ + disabled: true, + label, + notifications: [{ id: "n", type: "error", title: "Boom" }], + }); + expect(screen.getByRole("button", { name: /Alerts/ })).toBeInTheDocument(); + expect(screen.getByText("1 unread notification")).toBeInTheDocument(); + }); + + it("does not expose disabled when the prop is absent", () => { + renderBell(); + const trigger = screen.getByRole("button"); + expect(trigger).not.toBeDisabled(); + expect(trigger).not.toHaveAttribute("aria-disabled"); + }); +}); \ No newline at end of file diff --git a/__tests__/signature_timeout_alert.test.tsx b/__tests__/signature_timeout_alert.test.tsx index d356886..f3c8a79 100644 --- a/__tests__/signature_timeout_alert.test.tsx +++ b/__tests__/signature_timeout_alert.test.tsx @@ -1,9 +1,43 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import SignatureTimeoutAlert from "@/app/components/SignatureTimeoutAlert"; import WalletLoaderOverlay from "@/app/components/WalletLoaderOverlay"; import { endWalletOperation, startWalletOperation } from "@/app/lib/wallet_state_context"; +// WalletProvider pulls @creit.tech/stellar-wallets-kit at module scope, whose +// bundled UMD dependencies are not Node-ESM-importable. These suites never +// exercise provider-driven wallet state (they render the components with bare +// props), so `useWallet` is stubbed with the same defaults the real +// WalletContext provides via `createContext`. The real wallet library modules +// (wallet_state_context, freighter_connector, albedo_connector, +// ledger_usb_bridge) stay under test. +const walletContextMock = vi.hoisted(() => ({ + useWallet: () => ({ + address: null, + assembleMultiSigTransaction: vi.fn(async () => ({ + uniqueSigners: 0, + splitsValidated: 0, + })), + connect: vi.fn(async () => {}), + disconnect: vi.fn(), + isConnecting: false, + networkMismatchMessage: null, + selectedWalletId: "albedo", + setSelectedWalletId: vi.fn(), + signTransaction: vi.fn(async () => ""), + signatureTimeoutError: null, + signatureTimeoutXdr: null, + clearSignatureTimeout: vi.fn(), + simulationResult: null, + setSimulationResult: vi.fn(), + gasWarning: null, + }), +})); + +vi.mock("@/app/context/WalletContext", () => ({ + useWallet: walletContextMock.useWallet, +})); + describe("SignatureTimeoutAlert", () => { it("renders timeout details and logs a formatted stack trace", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -54,9 +88,13 @@ describe("SignatureTimeoutAlert", () => { it("keeps the loader counter balanced when an external operation is active", async () => { render(); - startWalletOperation(); + act(() => { + startWalletOperation(); + }); expect(screen.getByTestId("wallet-loader-overlay")).toBeInTheDocument(); - endWalletOperation(); + act(() => { + endWalletOperation(); + }); await waitFor(() => expect(screen.queryByTestId("wallet-loader-overlay")).not.toBeInTheDocument()); }); }); diff --git a/app/components/SignatureTimeoutAlert.tsx b/app/components/SignatureTimeoutAlert.tsx index eec9aaf..668468f 100644 --- a/app/components/SignatureTimeoutAlert.tsx +++ b/app/components/SignatureTimeoutAlert.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useWallet } from "@/app/context/WalletContext"; import { NETWORK_PASSPHRASE } from "@/app/lib/contract"; import { @@ -50,7 +50,6 @@ export default function SignatureTimeoutAlert({ const albedoAssembly = useAlbedoMultiSigAssembly(NETWORK_PASSPHRASE); const ledgerAssembly = useLedgerMultiSigAssembly(NETWORK_PASSPHRASE); const [isRetrying, setIsRetrying] = useState(false); - const [parseMessage, setParseMessage] = useState(null); const activeError = error ?? signatureTimeoutError; const activeTransactionXdr = transactionXdr ?? signatureTimeoutXdr ?? undefined; @@ -69,21 +68,18 @@ export default function SignatureTimeoutAlert({ }); }, [activeError, hasTimeout, networkMismatchMessage, transactionId]); - useEffect(() => { - if (!activeTransactionXdr) { - setParseMessage(null); - return; - } + // Derived state: parse failures are computed on render instead of being + // written to state from an effect (avoids cascading renders). + const parseMessage = useMemo(() => { + if (!activeTransactionXdr) return null; try { parseMultiSigEnvelope(activeTransactionXdr, { parseEnvelopeXdr: createStellarEnvelopeParser(NETWORK_PASSPHRASE), }); - setParseMessage(null); + return null; } catch (parseError) { - setParseMessage( - parseError instanceof Error ? parseError.message : String(parseError) - ); + return parseError instanceof Error ? parseError.message : String(parseError); } }, [activeTransactionXdr]); diff --git a/app/components/notification_bell.tsx b/app/components/notification_bell.tsx index 4b6dc17..655badd 100644 --- a/app/components/notification_bell.tsx +++ b/app/components/notification_bell.tsx @@ -24,6 +24,8 @@ export interface NotificationBellProps { fields?: NotificationField[]; /** Label used for the trigger button (defaults to "Notifications"). */ label?: string; + /** Disables the trigger and its panel while retaining the unread badge. */ + disabled?: boolean; } const TYPE_STYLES: Record = { @@ -63,14 +65,20 @@ export default function NotificationBell({ notifications = [], fields = [], label = "Notifications", + disabled = false, }: NotificationBellProps) { const [open, setOpen] = useState(false); const panelId = useId(); const badgeCount = computeBadgeCount(notifications, fields); const errorCount = notifications.filter((n) => n.type === "error").length + fields.filter((f) => f.error).length; - const focusRing = - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 rounded"; + const triggerStates = + "bg-gray-800 text-gray-200 text-lg w-10 h-10 rounded-lg" + + " transition duration-150 ease-out" + + " hover:bg-gray-700" + + " active:bg-gray-600 active:scale-95 active:duration-75" + + " focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950" + + " disabled:cursor-not-allowed disabled:opacity-60 disabled:bg-gray-800 disabled:hover:bg-gray-800 disabled:active:bg-gray-800 disabled:active:scale-100 disabled:focus-visible:ring-0 disabled:focus-visible:ring-offset-0"; return (
@@ -80,8 +88,10 @@ export default function NotificationBell({ aria-haspopup="dialog" aria-expanded={open} aria-controls={panelId} + aria-disabled={disabled || undefined} + disabled={disabled} onClick={() => setOpen((v) => !v)} - className={`relative inline-flex items-center justify-center bg-gray-800 hover:bg-gray-700 text-gray-200 text-lg w-10 h-10 rounded-lg transition ${focusRing}`} + className={`relative inline-flex items-center justify-center ${triggerStates}`} > {badgeCount > 0 && ( diff --git a/vitest.setup.ts b/vitest.setup.ts index f149f27..3bca877 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1 +1,40 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom environment: normalize the web-storage globals across Node versions. +// Newer Node releases ship an experimental `localStorage` global that is +// `undefined` unless `--localstorage-file` is passed; that value shadows the +// jsdom implementation in vitest, crashing tests that read bare +// `localStorage`. When the jsdom-provided global did not install, fall back +// to an in-memory Storage so the API stays available and testable. +if (typeof globalThis.localStorage === "undefined") { + function createMemoryStorage(): Storage { + const store = new Map(); + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + const value = store.get(key); + return value === undefined ? null : value; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, String(value)); + }, + }; + } + + Object.defineProperty(globalThis, "localStorage", { + value: createMemoryStorage(), + configurable: true, + writable: true, + }); +} From 8c0bbe67eec937b86fa712aa2ed89033fed52aeb Mon Sep 17 00:00:00 2001 From: Kingeligma Date: Sun, 30 Aug 2026 18:29:48 +0100 Subject: [PATCH 4/4] test(signature_timeout_alert): attach rejection handlers before advancing timers Three timeout tests attached promise.catch() only after awaiting timer advance, so the rejected outer promise could be snapshotted as unhandled by Node during the interleaved microtask gap, making vitest report 3 unhandled errors and exit non-zero despite all tests passing. Attach the handler immediately after runSignatureWithTimeout (mirroring the .rejects-based tests) to keep npm run test exiting cleanly. --- __tests__/signature_timeout_alert_timeout.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/__tests__/signature_timeout_alert_timeout.test.ts b/__tests__/signature_timeout_alert_timeout.test.ts index dd38e25..f0c0599 100644 --- a/__tests__/signature_timeout_alert_timeout.test.ts +++ b/__tests__/signature_timeout_alert_timeout.test.ts @@ -107,8 +107,9 @@ describe("signature_timeout_alert timeout bounds (#244)", () => { const signFn = vi.fn(() => new Promise(() => {})); const promise = runSignatureWithTimeout(request, signFn, 1_000); + const onRejected = promise.catch(() => {}); await vi.advanceTimersByTimeAsync(1_000); - await promise.catch(() => {}); + await onRejected; expect(request.payload).toBeNull(); expect(payload.every((b) => b === 0)).toBe(true); @@ -131,8 +132,9 @@ describe("signature_timeout_alert timeout bounds (#244)", () => { const signFn = vi.fn(() => new Promise(() => {})); const promise = runSignatureWithTimeout(request, signFn, 500); + const onRejected = promise.catch(() => {}); await vi.advanceTimersByTimeAsync(500); - await promise.catch(() => {}); + await onRejected; // Memory cleared — operation cannot leak sensitive bytes post-abort. expect(request.payload).toBeNull(); @@ -210,8 +212,9 @@ describe("signature_timeout_alert timeout bounds (#244)", () => { }); const promise = runSignatureWithTimeout(request, signFn, 5_000); + const onRejected = promise.catch(() => {}); await vi.runAllTimersAsync(); - await promise.catch(() => {}); + await onRejected; // Memory should NOT be cleared for non-timeout errors (unmodified request) // The timeout path specifically clears; other errors pass through as-is.