Skip to content
Open
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
5 changes: 4 additions & 1 deletion backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
"node-fetch": "^3.3.2",
"p-queue": "^9.3.0",
"pino": "^9.3.2",

"pino-http": "^10.5.0",
"pino-pretty": "^11.2.2",
"rate-limit-redis": "^6.0.0"
},
"engines": {
"node": ">=22.0.0"
Expand Down
13 changes: 0 additions & 13 deletions backend/src/routes/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,6 @@ function normalizePriceUsdc(value) {
return normalized;
}

/**
* Annotate a service entry with a ttl_warning flag.
* Returns true when the estimated remaining TTL falls below
* SERVICE_TTL_WARNING_LEDGERS. Omits the field when currentLedger
* is unavailable so callers treat absence as "no warning data".
*/
function annotateTtlWarning(service, currentLedger) {
if (currentLedger == null) return service;
const expiry = service.registered_at + SERVICE_MAX_TTL;
const warnOnset = expiry - SERVICE_TTL_WARNING_LEDGERS;
return { ...service, ttl_warning: currentLedger >= warnOnset };
}

function parsePositiveSafeInteger(value) {
if (typeof value === "number") {
return Number.isSafeInteger(value) && value > 0 ? value : null;
Expand Down
30 changes: 24 additions & 6 deletions backend/test/demo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ vi.mock("@x402/stellar/exact/client", () => ({
ExactStellarScheme: vi.fn(),
}));

// demo.js pulls recordActivity/getActivityFeed from services.js, which
// instantiates the real x402 server client and payment middleware at module
// load. Mock them the same way services.test.js does so no facilitator is
// contacted and the payment middleware is bypassed in tests.
vi.mock("@x402/express", () => ({
paymentMiddlewareFromConfig: () => (_req, _res, next) => next(),
}));
vi.mock("@x402/core/server", () => ({
HTTPFacilitatorClient: vi.fn(() => ({})),
}));
vi.mock("@x402/stellar/exact/server", () => ({
ExactStellarScheme: vi.fn(() => ({})),
}));

const app = express();
app.use(express.json());
app.use("/api", demoRouter);
Expand All @@ -48,15 +62,19 @@ describe("POST /api/demo-run", () => {

it("handles AbortError appropriately", async () => {
contract.getService.mockResolvedValue({ name: "Test Service", endpoint: "test", price_usdc: "1" });

// We mock fetchWithTx to throw an AbortError to simulate client cancelling the request
const { x402HTTPClient } = await import("@x402/core/client");
x402HTTPClient.mockImplementationOnce(() => ({
fetchWithTx: vi.fn().mockRejectedValue(Object.assign(new Error("aborted"), { name: "AbortError" })),
}));

// buildHttpClient() always overwrites fetchWithTx with an implementation
// that calls the global fetch(), so simulate the client cancelling by
// making that underlying fetch reject with an AbortError.
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(Object.assign(new Error("aborted"), { name: "AbortError" })),
);

const res = await request(app).post("/api/demo-run").send({ serviceId: 1, category: "weather" });
expect(res.status).toBe(499);
expect(res.body.code).toBe("CANCELLED");

vi.unstubAllGlobals();
});
});
4 changes: 2 additions & 2 deletions contract/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions frontend/__tests__/ActivityFeed.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { axe } from 'jest-axe';
import ActivityFeed from '../components/ActivityFeed';

global.fetch = jest.fn();
Expand Down Expand Up @@ -110,4 +111,20 @@ describe('ActivityFeed Pagination', () => {
expect(logArg).toContain('activity_feed_fetch_failed');
});
});

it('has no accessibility violations', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ activity: mockActivities.slice(0, 3) }),
});

const { container } = render(<ActivityFeed />);

await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
20 changes: 20 additions & 0 deletions frontend/__tests__/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { axe } from 'jest-axe';
import { SWRConfig } from 'swr';
import AgentsPage from '../app/agents/page';
import { PAGE_SIZE } from '../lib/pagination';
Expand Down Expand Up @@ -80,4 +81,23 @@ describe('AgentsPage retry state', () => {
expect(fetchAgents).toHaveBeenCalledTimes(2);
expect(screen.queryByText('Network disconnected')).not.toBeInTheDocument();
});

it('has no accessibility violations', async () => {
(fetchAgents as jest.Mock).mockResolvedValue({
agents: [mockAgent],
total: 1,
page: 0,
pageSize: PAGE_SIZE,
});
(fetchAgentStats as jest.Mock).mockResolvedValue(mockStats);

const { container } = renderPage();

await waitFor(() => {
expect(screen.getAllByText('Demo Agent').length).toBeGreaterThan(0);
});

const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
12 changes: 12 additions & 0 deletions frontend/__tests__/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import Navbar from '../components/Navbar';

let mockPathname = '/';
Expand Down Expand Up @@ -31,6 +32,10 @@ jest.mock('../components/WalletConnect', () => {
return MockWalletConnect;
});

jest.mock('../components/ThemeProvider', () => ({
useTheme: () => ({ theme: 'light', toggleTheme: jest.fn() }),
}));

function setPathname(path: string) {
mockPathname = path;
}
Expand Down Expand Up @@ -80,4 +85,11 @@ describe('Navbar active-link highlighting', () => {
).not.toHaveAttribute('aria-current');
}
});

it('has no accessibility violations', async () => {
setPathname('/registry');
const { container } = render(<Navbar />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
4 changes: 2 additions & 2 deletions frontend/__tests__/RegisterForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ describe('RegisterForm validation', () => {
});

it('trims whitespace when validating name', async () => {
render(<RegisterForm walletAddress="GTESTADDRESS1234567890ABCDEFGHIJ" });
render(<RegisterForm walletAddress="GTESTADDRESS1234567890ABCDEFGHIJ" />);

const nameInput = screen.getByLabelText(/service name/i);
fireEvent.change(nameInput, { target: { value: ' ab ' } });
Expand All @@ -239,7 +239,7 @@ describe('RegisterForm validation', () => {
});

it('trims whitespace when validating endpoint', async () => {
render(<RegisterForm walletAddress="GTESTADDRESS1234567890ABCDEFGHIJ" });
render(<RegisterForm walletAddress="GTESTADDRESS1234567890ABCDEFGHIJ" />);

const endpointInput = screen.getByLabelText(/endpoint url/i);
fireEvent.change(endpointInput, { target: { value: ' http://example.com ' } });
Expand Down
20 changes: 20 additions & 0 deletions frontend/__tests__/RegistryPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { axe } from 'jest-axe';
import RegistryPage from '../app/registry/page';

jest.mock('swr', () => ({
Expand Down Expand Up @@ -93,4 +94,23 @@ describe('RegistryPage', () => {
render(<RegistryPage />);
expect(await screen.findByRole('navigation', { name: /pagination/i })).toBeInTheDocument();
});

it('has no accessibility violations', async () => {
const services = makeServices(5);
(useSWR as jest.Mock).mockReturnValue({
data: services,
isLoading: false,
error: null,
mutate: jest.fn(),
});

const { container } = render(<RegistryPage />);

await waitFor(() => {
expect(screen.getAllByText(/^Service \d+$/).length).toBe(5);
});

const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
90 changes: 84 additions & 6 deletions frontend/__tests__/WalletPickerModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { axe } from 'jest-axe';
import WalletPickerModal from '../components/WalletPickerModal';
import { useWallet } from '../components/WalletContext';
import { WalletError, WalletErrorType } from '../lib/wallet';
Expand Down Expand Up @@ -50,12 +51,82 @@ describe('WalletPickerModal', () => {
expect(screen.getByText('Freighter')).toBeInTheDocument();
});

it('moves focus into the dialog when opened', () => {
render(<WalletPickerModal onClose={mockOnClose} />);
expect(document.activeElement).toBe(screen.getByRole('dialog'));
});

it('traps Tab navigation within the dialog', () => {
render(<WalletPickerModal onClose={mockOnClose} />);
const dialog = screen.getByRole('dialog');
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>('button, a[href]'));
const first = focusable[0];
const last = focusable[focusable.length - 1];

// Shift+Tab from the first focusable wraps to the last
first.focus();
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(last);

// Tab from the last focusable wraps to the first
fireEvent.keyDown(dialog, { key: 'Tab' });
expect(document.activeElement).toBe(first);

// Tab from the dialog itself moves to the first focusable
dialog.focus();
fireEvent.keyDown(dialog, { key: 'Tab' });
expect(document.activeElement).toBe(first);

// Shift+Tab from the dialog itself moves to the last focusable
dialog.focus();
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(last);
});

it('closes the dialog on Escape', () => {
render(<WalletPickerModal onClose={mockOnClose} />);
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
expect(mockOnClose).toHaveBeenCalledTimes(1);
});

it('marks background content inert while open and unmarks it on close', () => {
const { unmount } = render(<WalletPickerModal onClose={mockOnClose} />);
const overlay = screen.getByRole('dialog').parentElement;
const background = Array.from(document.body.children).filter(
(el) => el !== overlay
);

expect(background.length).toBeGreaterThan(0);
background.forEach((el) => expect(el).toHaveAttribute('inert'));

unmount();
background.forEach((el) => expect(el).not.toHaveAttribute('inert'));
});

it('restores focus to the previously focused element when it closes', () => {
const opener = document.createElement('button');
opener.textContent = 'Open';
document.body.appendChild(opener);

try {
opener.focus();

const { unmount } = render(<WalletPickerModal onClose={mockOnClose} />);
expect(document.activeElement).not.toBe(opener);

unmount();
expect(document.activeElement).toBe(opener);
} finally {
opener.remove();
}
});

it('handles WALLET_NOT_FOUND error', async () => {
mockConnect.mockRejectedValue(new WalletError(WalletErrorType.WALLET_NOT_FOUND, 'Wallet missing'));
render(<WalletPickerModal onClose={mockOnClose} />);

fireEvent.click(screen.getByText('Freighter'));

await waitFor(() => {
expect(screen.getByText('Wallet missing')).toBeInTheDocument();
expect(screen.getByText('Install Freighter')).toBeInTheDocument();
Expand All @@ -65,9 +136,9 @@ describe('WalletPickerModal', () => {
it('handles UNSUPPORTED_BROWSER error', async () => {
mockConnect.mockRejectedValue(new WalletError(WalletErrorType.UNSUPPORTED_BROWSER, 'Browser not supported'));
render(<WalletPickerModal onClose={mockOnClose} />);

fireEvent.click(screen.getByText('Freighter'));

await waitFor(() => {
expect(screen.getByText('Browser not supported')).toBeInTheDocument();
expect(screen.getByText('Learn More')).toBeInTheDocument();
Expand All @@ -77,12 +148,19 @@ describe('WalletPickerModal', () => {
it('handles USER_REJECTED error', async () => {
mockConnect.mockRejectedValue(new WalletError(WalletErrorType.USER_REJECTED, 'Cancelled'));
render(<WalletPickerModal onClose={mockOnClose} />);

fireEvent.click(screen.getByText('Freighter'));

await waitFor(() => {
expect(screen.getByText('Cancelled')).toBeInTheDocument();
expect(screen.getByText('Retry Connection')).toBeInTheDocument();
});
});

it('has no accessibility violations', async () => {
render(<WalletPickerModal onClose={mockOnClose} />);
const dialog = screen.getByRole('dialog');
const results = await axe(dialog);
expect(results).toHaveNoViolations();
});
});
2 changes: 1 addition & 1 deletion frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ body {
}

.input {
@apply bg-background border border-border rounded-lg px-4 py-2.5 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1 focus:ring-primary/30 transition-shadow;
@apply bg-background border border-border rounded-lg px-4 py-2.5 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1 focus:ring-primary transition-shadow;
}
}

Expand Down
1 change: 1 addition & 0 deletions frontend/app/registry/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export default function RegistryPage() {
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
aria-label="Sort services"
className="border border-border rounded-lg px-3 py-2 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-primary"
>
{SORTS.map((s) => (
Expand Down
Loading
Loading