Files
89bf02f5f7 feat(fetch): x402 Payment Required handling + first unified-tool tests (#5)
* feat(fetch): x402 Payment Required handling + first unified-tool tests

Adds HTTP 402 (x402) parsing to the unified fetch tool so AI agents can
surface payment quotes from x402-gated endpoints and replay with X-PAYMENT.

- request / fetch / head detect status === 402 and return a structured
  payment_required envelope (parsed accepts[], x402-version, www-authenticate)
- new optional 'payment' input: pre-encoded base64 string sent verbatim, or
  a JSON object that is base64-encoded into X-PAYMENT before retry
- adds the first Jest test file for the HIP-0300 unified surface
  (test/tools/unified/fetch.test.ts) covering 200, 402, payment object, and
  payment string paths against an in-process http.createServer fixture

Also fixes test/setup.ts so the existing test suite actually runs:
- imports 'jest' from @jest/globals (jest is not a global under ESM)
- ESM-safe __dirname via fileURLToPath(import.meta.url)
Net effect: 169 previously-unrunnable tests can now start; 7/7 new fetch
tests pass; remaining failures are pre-existing test bugs unrelated to this
PR (CommonJS require() in ESM mode, missing mocks, etc.).

No new dependencies, no breaking changes to fetch's existing semantics.

* test: anchor setup.ts on process.cwd() to fix Node 18/20 ESM CI

`test/setup.ts` used `fileURLToPath(import.meta.url)` to derive its own
directory, but `ts-jest`'s handling of `import.meta` inside
`setupFilesAfterEnv` is fragile across Node versions — every test suite
failed on Node 18.x and 20.x with TS1343 ("\'import.meta\' meta-property
is only allowed when \'--module\' is es2020/...\"), even though the
root tsconfig and ts-jest's transform tsconfig both set
`module: ESNext`.

Tests always run from the project root, so `path.join(process.cwd(),
'test', ...)` is a stable anchor that doesn't depend on how the setup
file is loaded.

Local: 15/16 suites pass after the fix (2 unrelated shell.test.ts timing
flakes remain).

* fix(ci): suppress TS2307 on optional jsautogui dynamic import

The Node 20 CI job was failing two test suites — test/config/
configuration.test.ts and test/integration/mcp-server.test.ts — with:

    src/autogui/adapters/jsautogui.ts:37:37 - error TS2307: Cannot find
    module 'jsautogui' or its corresponding type declarations.

while the actual jest run itself reported 326/326 tests passing in the
suites it could load. Root cause: jsautogui is in optionalDependencies
and ships native bindings that npm may skip on some platforms. When
the package (and its bundled .d.ts) isn't present, ts-jest's transform
pass fails the whole suite at type-check time even though the runtime
code path is already guarded by try/catch + AutoGUINotAvailableError.

Adds a localised `// @ts-ignore` on the single dynamic-import line,
with a comment explaining why. The runtime behaviour is unchanged —
if jsautogui isn't installed, the catch arm still throws
AutoGUINotAvailableError as before. Confirmed locally that the
previously-failing test/config/configuration.test.ts now passes both
with jsautogui present and with node_modules/jsautogui removed
(simulating the Ubuntu CI install).

No code logic changed; package.json and package-lock.json untouched.

---------

Co-authored-by: Abhishek Krishna <[email protected]>
2026-05-19 11:33:09 -07:00

169 lines
5.8 KiB
TypeScript

/**
* Tests for the unified `fetch` tool (HIP-0300).
*
* Covers the standard 200 path plus the new x402 (HTTP 402 Payment Required)
* surfacing — the calling agent should receive a structured `payment_required`
* envelope on 402, and supplying a `payment` argument should re-issue the
* request with the `X-PAYMENT` header.
*/
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
import * as http from 'http';
import { AddressInfo } from 'net';
import { fetchTool } from '../../../src/tools/unified/fetch.js';
interface RecordedRequest {
method: string;
url: string;
headers: http.IncomingHttpHeaders;
body: string;
}
let server: http.Server;
let baseUrl: string;
const recorded: RecordedRequest[] = [];
beforeAll(async () => {
server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
recorded.push({
method: req.method || '',
url: req.url || '',
headers: req.headers,
body,
});
// /paid — gates on X-PAYMENT, otherwise 402 with x402 envelope.
if (req.url?.startsWith('/paid')) {
if (req.headers['x-payment']) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, paid: true }));
return;
}
const envelope = {
x402Version: 1,
accepts: [{
scheme: 'exact',
network: 'base-sepolia',
maxAmountRequired: '1000',
asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
payTo: '0x0000000000000000000000000000000000000001',
resource: 'https://example.test/paid',
description: 'kcolbchain test resource',
mimeType: 'application/json',
}],
};
res.writeHead(402, {
'content-type': 'application/json',
'x402-version': '1',
'www-authenticate': 'x402',
});
res.end(JSON.stringify(envelope));
return;
}
// /ok — plain 200 JSON.
if (req.url?.startsWith('/ok')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ hello: 'world' }));
return;
}
res.writeHead(404);
res.end();
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const addr = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function parseResult(result: any) {
expect(result.content).toHaveLength(1);
return JSON.parse(result.content[0].text);
}
describe('fetchTool', () => {
test('has expected metadata', () => {
expect(fetchTool.name).toBe('fetch');
expect(fetchTool.inputSchema.required).toContain('action');
});
test('request returns body for 200 JSON', async () => {
const result = await fetchTool.handler({ action: 'request', url: `${baseUrl}/ok` });
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(200);
expect(parsed.data.body).toEqual({ hello: 'world' });
});
test('head returns headers for 200', async () => {
const result = await fetchTool.handler({ action: 'head', url: `${baseUrl}/ok` });
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(200);
});
test('request surfaces x402 payment_required envelope on 402', async () => {
const result = await fetchTool.handler({ action: 'request', url: `${baseUrl}/paid` });
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(402);
expect(parsed.data.payment_required).toBeDefined();
expect(parsed.data.payment_required.x402_version).toBe('1');
expect(parsed.data.payment_required.www_authenticate).toBe('x402');
expect(parsed.data.payment_required.accepts).toHaveLength(1);
expect(parsed.data.payment_required.accepts[0].network).toBe('base-sepolia');
});
test('fetch surfaces x402 payment_required envelope on 402', async () => {
const result = await fetchTool.handler({ action: 'fetch', url: `${baseUrl}/paid` });
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(402);
expect(parsed.data.payment_required.accepts[0].asset)
.toBe('0x036CbD53842c5426634e7929541eC2318f3dCF7e');
});
test('payment object is base64-encoded into X-PAYMENT and unlocks the resource', async () => {
const before = recorded.length;
const payment = { scheme: 'exact', network: 'base-sepolia', signature: '0xdeadbeef' };
const result = await fetchTool.handler({
action: 'request',
url: `${baseUrl}/paid`,
payment,
});
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(200);
expect(parsed.data.body).toEqual({ ok: true, paid: true });
const last = recorded[recorded.length - 1];
const sent = last.headers['x-payment'] as string | undefined;
expect(sent).toBeDefined();
const decoded = JSON.parse(Buffer.from(sent!, 'base64').toString('utf8'));
expect(decoded).toEqual(payment);
expect(recorded.length).toBeGreaterThan(before);
});
test('payment string is forwarded verbatim as X-PAYMENT', async () => {
const presigned = 'eyJzaWduYXR1cmUiOiJ0ZXN0In0=';
const result = await fetchTool.handler({
action: 'request',
url: `${baseUrl}/paid`,
payment: presigned,
});
const parsed = parseResult(result);
expect(parsed.ok).toBe(true);
expect(parsed.data.status).toBe(200);
const last = recorded[recorded.length - 1];
expect(last.headers['x-payment']).toBe(presigned);
});
});