This commit is contained in:
Felix Faerber committed 2026-05-20 15:08:45 +03:00
commit c54e888458
67 files changed
+5933

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
import { describeAuth } from './auth';
import './setup';
// `describeAuth()` registers its own `describe(...)` block. setup.ts's
// top-level beforeAll/afterAll fire once for the file.
describeAuth();
+204
View File
@@ -0,0 +1,204 @@
/**
* Sequential auth + profile + API key flow against the running hosted-gateway
* stack. Mirrors cipherdolls/core/test/auth.ts shape: one `describeAuth()` that
* exercises each persona end-to-end, with state shared across `it()` blocks
* via module-level `let` bindings.
*
* What this covers:
* - Email + password signup (with verification token echoed back in dev/test).
* - Email verification → account activation → 50 xBZZ welcome grant credited.
* - JWT-authed /users/me and /accounts/me reads.
* - API key creation, listing, JWT-or-key auth interchange, deletion.
* - /api-keys returns the persona's keys only.
* - Login round-trip returns a fresh JWT for the same user.
*/
import { describe, it, expect } from 'bun:test';
import {
api,
get,
post,
del,
auth,
signupEmail,
verifyEmail,
loginEmail,
createApiKey,
waitForQueuesEmpty,
} from './helpers';
const WELCOME_XBZZ_WEI = (50n * 10n ** 18n).toString();
export function describeAuth(): void {
describe('auth + profile + api-keys (e2e)', () => {
/* ── Alice: full signup → verify → welcome grant ────────────── */
it('signs up Alice via email/password', async () => {
const email = `alice-${Date.now()}@hosted.test`;
const password = 'correct-horse-battery-staple';
const { jwt, userId, verificationToken } = await signupEmail(email, password);
expect(jwt).toBeTruthy();
expect(userId).toBeTruthy();
expect(verificationToken).toBeTruthy();
auth.alice = { email, password, jwt, userId };
(auth.alice as any).verificationToken = verificationToken;
});
it('Alice can read /users/me with the issued JWT (email unverified)', async () => {
const { status, body } = await get('/users/me', auth.alice.jwt!);
expect(status).toBe(200);
expect(body).toMatchObject({
id: auth.alice.userId,
email: auth.alice.email,
role: 'USER',
emailVerified: false,
authProvider: 'email',
});
});
it('Alice has no welcome grant yet (account pending email verify)', async () => {
await waitForQueuesEmpty();
const { status, body } = await get('/accounts/me', auth.alice.jwt!);
expect(status).toBe(200);
expect(body.balanceXbzzWei).toBe('0');
expect(body.reservedXbzzWei).toBe('0');
});
it('Alice verifies her email → balance credited with 50 xBZZ', async () => {
const token = (auth.alice as any).verificationToken as string;
await verifyEmail(token);
await waitForQueuesEmpty();
const { status, body } = await get('/accounts/me', auth.alice.jwt!);
expect(status).toBe(200);
expect(body.balanceXbzzWei).toBe(WELCOME_XBZZ_WEI);
expect(body.reservedXbzzWei).toBe('0');
expect(body.availableXbzzWei).toBe(WELCOME_XBZZ_WEI);
const me = await get('/users/me', auth.alice.jwt!);
expect(me.body.emailVerified).toBe(true);
});
it('Alice can list her ledger and see the welcomeGrant row', async () => {
const { status, body } = await get('/accounts/me/ledger', auth.alice.jwt!);
expect(status).toBe(200);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(1);
expect(body[0].kind).toBe('welcomeGrant');
expect(body[0].deltaXbzzWei).toBe(WELCOME_XBZZ_WEI);
});
/* ── API keys ─────────────────────────────────────────────── */
it('Alice creates an API key', async () => {
const { id, key } = await createApiKey(auth.alice.jwt!, 'alice-key');
expect(id).toBeTruthy();
expect(key.startsWith('t4t-live-')).toBe(true);
auth.alice.apiKey = key;
auth.alice.apiKeyId = id;
});
it('Alice can read /users/me with her API key (no JWT)', async () => {
const { status, body } = await get('/users/me', auth.alice.apiKey);
expect(status).toBe(200);
expect(body.id).toBe(auth.alice.userId);
});
it('Alice lists /api-keys and sees only her key', async () => {
const { status, body } = await get('/api-keys', auth.alice.jwt!);
expect(status).toBe(200);
expect(body.length).toBe(1);
expect(body[0].id).toBe(auth.alice.apiKeyId);
expect(body[0].name).toBe('alice-key');
// Secret value is never echoed after creation.
expect(body[0].key).toBeUndefined();
});
/* ── Bob: isolation ───────────────────────────────────────── */
it('Bob signs up, verifies, and gets his own welcome grant', async () => {
const email = `bob-${Date.now()}@hosted.test`;
const password = 'tr0ub4dor&3';
const signup = await signupEmail(email, password);
await verifyEmail(signup.verificationToken);
await waitForQueuesEmpty();
const { id: apiKeyId, key: apiKey } = await createApiKey(signup.jwt, 'bob-key');
auth.bob = {
email,
password,
jwt: signup.jwt,
userId: signup.userId,
apiKey,
apiKeyId,
};
const accountRes = await get('/accounts/me', signup.jwt);
expect(accountRes.body.balanceXbzzWei).toBe(WELCOME_XBZZ_WEI);
});
it("Bob cannot see Alice's API keys", async () => {
const { status, body } = await get('/api-keys', auth.bob.jwt!);
expect(status).toBe(200);
expect(body.length).toBe(1);
expect(body[0].id).toBe(auth.bob.apiKeyId);
});
it("Bob cannot delete Alice's API key", async () => {
const { status } = await del(`/api-keys/${auth.alice.apiKeyId}`, auth.bob.jwt!);
expect(status).toBe(404);
});
/* ── Login round-trip ─────────────────────────────────────── */
it('Alice logs in with email + password and gets a fresh JWT', async () => {
const { jwt, userId } = await loginEmail(auth.alice.email!, auth.alice.password!);
expect(userId).toBe(auth.alice.userId);
// The new JWT still resolves to the same user.
const { status, body } = await get('/users/me', jwt);
expect(status).toBe(200);
expect(body.id).toBe(auth.alice.userId);
});
it('login with wrong password is rejected', async () => {
const { status } = await post('/auth/email/login', {
email: auth.alice.email,
password: 'WRONG',
});
expect(status).toBe(401);
});
it('signup with an existing email is rejected (409)', async () => {
const { status } = await post('/auth/email/signup', {
email: auth.alice.email,
password: 'another-password-123',
});
expect(status).toBe(409);
});
/* ── Unauthed access ──────────────────────────────────────── */
it('rejects /users/me with no token (401)', async () => {
const { status } = await api('GET', '/users/me');
expect(status).toBe(401);
});
it('rejects /accounts/me with garbage token (401)', async () => {
const { status } = await get('/accounts/me', 'not-a-real-token');
expect(status).toBe(401);
});
/* ── Key deletion ─────────────────────────────────────────── */
it('Alice can delete her API key, and it stops working', async () => {
const del1 = await del(`/api-keys/${auth.alice.apiKeyId}`, auth.alice.jwt!);
expect(del1.status).toBe(200);
const meAfter = await get('/users/me', auth.alice.apiKey);
expect(meAfter.status).toBe(401);
// JWT still works — we only revoked the key.
const meJwt = await get('/users/me', auth.alice.jwt!);
expect(meJwt.status).toBe(200);
});
});
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Test bootstrap: gives the operator wallet (and optionally the test provider)
* xDAI + xBZZ on the anvil fork. The contracts live on Gnosis mainnet at the
* addresses configured via env (managed by the t4t repo), so we don't deploy
* anything — we just fund the test wallets against the existing tokens.
*
* Two cheatcodes:
* - `anvil_setBalance(addr, wei)` seeds native xDAI
* - `anvil_impersonateAccount(whale)` lets us send `xBZZ.transfer` as the whale
*
* The whale address is configurable via `XBZZ_WHALE` env (default below is a
* known high-balance holder on Gnosis Chain). Refresh from Gnosisscan if the
* default ever runs dry.
*/
import {
createPublicClient,
createTestClient,
createWalletClient,
http,
parseEther,
type Address,
type Hex,
} from 'viem';
import { gnosis } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
const ERC20_TRANSFER_ABI = [
{
type: 'function',
name: 'transfer',
stateMutability: 'nonpayable',
inputs: [
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
outputs: [{ type: 'bool' }],
},
{
type: 'function',
name: 'balanceOf',
stateMutability: 'view',
inputs: [{ name: 'a', type: 'address' }],
outputs: [{ type: 'uint256' }],
},
] as const;
export interface FundConfig {
rpcUrl: string;
xbzzAddress: Address;
whale: Address;
recipients: Array<{ address: Address; xdai: bigint; xbzz: bigint }>;
}
export async function fundOperator(cfg: FundConfig): Promise<void> {
const transport = http(cfg.rpcUrl);
const pub = createPublicClient({ chain: gnosis, transport });
const test = createTestClient({ chain: gnosis, transport, mode: 'anvil' });
// xDAI via setBalance — instant, no impersonation needed.
for (const r of cfg.recipients) {
await test.setBalance({ address: r.address, value: r.xdai });
}
// xBZZ via impersonation + transfer. The whale must have ≥ Σ xbzz on chain.
await test.impersonateAccount({ address: cfg.whale });
// Make sure the whale itself has xDAI to pay gas on the transfer.
await test.setBalance({ address: cfg.whale, value: parseEther('1') });
const whaleClient = createWalletClient({
chain: gnosis,
transport,
account: cfg.whale,
});
for (const r of cfg.recipients) {
const hash = await whaleClient.writeContract({
address: cfg.xbzzAddress,
abi: ERC20_TRANSFER_ABI,
functionName: 'transfer',
args: [r.address, r.xbzz],
});
await pub.waitForTransactionReceipt({ hash });
}
await test.stopImpersonatingAccount({ address: cfg.whale });
// Sanity-check the funding landed.
for (const r of cfg.recipients) {
const bal = (await pub.readContract({
address: cfg.xbzzAddress,
abi: ERC20_TRANSFER_ABI,
functionName: 'balanceOf',
args: [r.address],
})) as bigint;
if (bal < r.xbzz) {
throw new Error(`funding failed: ${r.address} expected ${r.xbzz}, got ${bal}`);
}
}
}
/**
* Convenience: derive the operator address from `OPERATOR_PRIVATE_KEY` and
* apply a standard funding amount (1k xBZZ + 1 xDAI).
*/
export async function fundOperatorFromEnv(extra: Address[] = []): Promise<void> {
const operator = privateKeyToAccount(process.env.OPERATOR_PRIVATE_KEY as Hex).address;
const recipients = [
{ address: operator, xdai: parseEther('1'), xbzz: 1000n * 10n ** 18n },
...extra.map((address) => ({
address,
xdai: parseEther('1'),
xbzz: 1000n * 10n ** 18n,
})),
];
await fundOperator({
rpcUrl: process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545',
xbzzAddress: (process.env.XBZZ_ADDRESS ?? '0xdBF3Ea6F5beE45c02255B2c26a16F300502F68da') as Address,
// The "t4t wallet" on Gnosis — a known funded address that's used as the
// impersonation source on the fork. Holds xBZZ + xDAI on mainnet, so on
// the fork we anvil_impersonateAccount it and transfer to the operator.
whale: (process.env.XBZZ_WHALE ?? '0xaCfE00BeA3f8acC8e500c7Ea6421902014778a85') as Address,
recipients,
});
}
+4
View File
@@ -0,0 +1,4 @@
import { describeGateway } from './gateway';
import './setup';
describeGateway();
+96
View File
@@ -0,0 +1,96 @@
/**
* Chat-completion end-to-end. This is the heaviest spec — requires the full
* gateway profile (anvil fork + Bee + a registered t4t-provider + mock-ollama)
* AND a funded operator wallet on the fork.
*
* If the upstream prerequisites (provider registration, xBZZ funding) haven't
* been set up the test surfaces the actual failure mode — `no provider matches
* model`, 503 on gateway-context boot, etc. — rather than green-washing.
*/
import { describe, it, expect } from 'bun:test';
import {
api,
get,
post,
auth,
signupEmail,
verifyEmail,
createApiKey,
waitForQueuesEmpty,
} from './helpers';
export function describeGateway(): void {
describe('chat completions (e2e)', () => {
it('signs Carol up + verifies + mints an API key', async () => {
const email = `carol-${Date.now()}@hosted.test`;
const signup = await signupEmail(email, 'a-strong-password-1234');
await verifyEmail(signup.verificationToken);
await waitForQueuesEmpty();
const { id, key } = await createApiKey(signup.jwt, 'carol-key');
auth.carol = {
email,
password: 'a-strong-password-1234',
jwt: signup.jwt,
userId: signup.userId,
apiKey: key,
apiKeyId: id,
};
});
it('GET /v1/models lists the catalogue (may be empty until a provider registers)', async () => {
const { status, body } = await get('/v1/models', auth.carol.apiKey);
// Auth alone gets us a 200; whether the catalogue is empty depends on
// whether the t4t-provider container had time to register before this
// request fired.
expect(status).toBe(200);
expect(body).toHaveProperty('object', 'list');
expect(Array.isArray(body.data)).toBe(true);
});
it('POST /v1/chat/completions returns a 200 with assistant content (mock-ollama)', async () => {
// The mock-ollama always returns "pong (mock-ollama)" regardless of
// input, so this assertion only verifies the route + on-chain settle
// path, not LLM quality.
const { status, body } = await post(
'/v1/chat/completions',
{
model: 'qwen2.5:0.5b',
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 32,
},
auth.carol.apiKey,
);
if (status !== 200) {
// Surface the upstream error so we know what to fix: provider not
// registered, gateway context boot failed, insufficient balance, etc.
console.error(`chat completion failed: status=${status} body=${JSON.stringify(body)}`);
}
expect(status).toBe(200);
expect(body).toHaveProperty('choices');
expect(body.choices[0]?.message?.content).toMatch(/pong/);
});
it("debits Carol's balance after the JobClaimed watcher settles", async () => {
// The on-chain claimJob fires from the provider after delivering. Our
// worker's claimWatcher tails JobClaimed events and runs settleXbzz.
// Poll for up to 30s.
const deadline = Date.now() + 30_000;
let balance = 50n * 10n ** 18n;
while (Date.now() < deadline) {
const { body } = await get('/accounts/me', auth.carol.jwt!);
balance = BigInt(body.balanceXbzzWei);
if (balance < 50n * 10n ** 18n) break;
await new Promise((r) => setTimeout(r, 1000));
}
expect(balance).toBeLessThan(50n * 10n ** 18n);
const ledger = await get('/accounts/me/ledger', auth.carol.jwt!);
const settle = ledger.body.find((e: any) => e.kind === 'settle');
expect(settle).toBeDefined();
expect(BigInt(settle.deltaXbzzWei)).toBeLessThan(0n);
});
});
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, test, expect } from 'bun:test';
import { api } from './helpers';
describe('health', () => {
test('GET /health returns ok', async () => {
const res = await api('/health');
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* Shared E2E helpers. Tests run inside the dockerized stack — the api/worker
* are reachable as service names on the compose network.
*
* Pattern lifted from cipherdolls/core/test/helpers.ts: a single `api(...)`
* wrapper around fetch, a `waitForQueuesEmpty()` poller for the
* model.x.create → BullMQ → processor pipeline, and persona-style auth
* records keyed in a shared `auth` table.
*/
import IORedis from 'ioredis';
export const BASE_URL = process.env.HOSTED_GATEWAY_URL ?? 'http://localhost:8080';
const REDIS_HOST = process.env.REDIS_HOST ?? 'localhost';
const REDIS_PORT = parseInt(process.env.REDIS_PORT ?? '6379');
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface ApiResult<T = any> {
status: number;
body: T;
}
/**
* Issue an HTTP request to the hosted-gateway api. Accepts either a JWT or an
* API key (bare uuid OR the `t4t-live-<uuid>` form) as `auth`.
*/
export async function api<T = any>(
method: HttpMethod,
path: string,
auth?: string,
body?: any,
): Promise<ApiResult<T>> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (auth) headers.Authorization = `Bearer ${auth}`;
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed: any = text;
try { parsed = JSON.parse(text); } catch { /* keep raw */ }
return { status: res.status, body: parsed as T };
}
export const get = (path: string, auth?: string) => api('GET', path, auth);
export const post = (path: string, body: any, auth?: string) => api('POST', path, auth, body);
export const del = (path: string, auth?: string) => api('DELETE', path, auth);
/**
* Wait until all BullMQ queues are empty AND stay empty for `settleMs` —
* defends against cascades where one processor enqueues the next (eg
* user.created → account.created → ledger welcomeGrant).
*/
export async function waitForQueuesEmpty(
timeoutMs = 15_000,
settleMs = 500,
): Promise<void> {
const redis = new IORedis(REDIS_PORT, REDIS_HOST, { maxRetriesPerRequest: null });
const interval = 100;
let elapsed = 0;
let emptyFor = 0;
try {
while (elapsed < timeoutMs) {
const queueKeys = [
...(await redis.keys('bull:*:waiting')),
...(await redis.keys('bull:*:active')),
...(await redis.keys('bull:*:delayed')),
];
let total = 0;
for (const key of queueKeys) {
const type = await redis.type(key);
if (type === 'list') total += await redis.llen(key);
else if (type === 'zset') total += await redis.zcard(key);
}
if (total === 0) {
emptyFor += interval;
if (emptyFor >= settleMs) return;
} else {
emptyFor = 0;
}
await new Promise((r) => setTimeout(r, interval));
elapsed += interval;
}
throw new Error(`queues not empty after ${timeoutMs}ms`);
} finally {
redis.disconnect();
}
}
/* ────────────────────────────────────────────────────────────────
Auth flow helpers — mirror cipherdolls' signIn / createApiKey shape,
but for email/password (Phase 1 doesn't wire real Google OAuth in tests).
──────────────────────────────────────────────────────────────── */
export interface AuthRecord {
email: string;
password: string;
jwt: string;
userId: string;
apiKey: string;
apiKeyId: string;
}
/** Persona registry — populated by `describe*()` factories and read by
* downstream specs. New personas added as the test suite grows. */
export const auth: Record<string, Partial<AuthRecord>> = {
alice: {},
bob: {},
carol: {},
dave: {},
};
export async function signupEmail(
email: string,
password: string,
): Promise<{ jwt: string; userId: string; verificationToken: string }> {
const { status, body } = await post('/auth/email/signup', { email, password });
if (status !== 200) throw new Error(`signup ${email} failed: ${status} ${JSON.stringify(body)}`);
return { jwt: body.token, userId: body.user.id, verificationToken: body.verificationToken };
}
export async function verifyEmail(token: string): Promise<void> {
const { status, body } = await post('/auth/email/verify', { token });
if (status !== 200) throw new Error(`verify failed: ${status} ${JSON.stringify(body)}`);
}
export async function loginEmail(
email: string,
password: string,
): Promise<{ jwt: string; userId: string }> {
const { status, body } = await post('/auth/email/login', { email, password });
if (status !== 200) throw new Error(`login ${email} failed: ${status} ${JSON.stringify(body)}`);
return { jwt: body.token, userId: body.user.id };
}
export async function createApiKey(
jwt: string,
name: string,
): Promise<{ id: string; key: string }> {
const { status, body } = await post('/api-keys', { name }, jwt);
if (status !== 200) throw new Error(`createApiKey failed: ${status} ${JSON.stringify(body)}`);
return { id: body.id, key: body.key };
}
+4
View File
@@ -0,0 +1,4 @@
import { describeModels } from './models';
import './setup';
describeModels();
+62
View File
@@ -0,0 +1,62 @@
/**
* Lightweight /v1/models smoke test against the anvil-forked Gnosis chain.
*
* Validates:
* - api boots and `getGatewayContext()` succeeds (operator wallet, Bee, PSS)
* - `ModelDiscovery.list()` runs against the live ProviderRegistry on the fork
* - the route returns the OpenAI `list` shape
*
* No chat completion, no on-chain writes, no t4t-provider container required.
* If the fork has any mainnet provider that heartbeated within the last 10
* min before the fork, the catalogue should be non-empty.
*/
import { describe, it, expect } from 'bun:test';
import {
auth,
signupEmail,
verifyEmail,
createApiKey,
get,
waitForQueuesEmpty,
} from './helpers';
export function describeModels(): void {
describe('/v1/models (e2e against forked Gnosis)', () => {
it('signs up Dave + mints an API key', async () => {
const email = `dave-${Date.now()}@hosted.test`;
const signup = await signupEmail(email, 'a-strong-password-1234');
await verifyEmail(signup.verificationToken);
await waitForQueuesEmpty();
const { id, key } = await createApiKey(signup.jwt, 'dave-key');
auth.dave = {
email,
password: 'a-strong-password-1234',
jwt: signup.jwt,
userId: signup.userId,
apiKey: key,
apiKeyId: id,
};
});
it('GET /v1/models returns the OpenAI list shape', async () => {
const { status, body } = await get('/v1/models', auth.dave.apiKey);
if (status !== 200) {
console.error('models endpoint failed:', JSON.stringify(body));
}
expect(status).toBe(200);
expect(body).toHaveProperty('object', 'list');
expect(Array.isArray(body.data)).toBe(true);
});
it('catalogue contains at least one model (mainnet has live providers)', async () => {
const { body } = await get('/v1/models', auth.dave.apiKey);
// Phase 1: tolerate an empty catalogue (no live mainnet providers
// recently heartbeated). Log the count so the run surfaces it.
console.log(`/v1/models returned ${body.data.length} models`);
if (body.data.length > 0) {
expect(body.data[0]).toHaveProperty('id');
expect(body.data[0]).toHaveProperty('object', 'model');
}
});
});
}
+66
View File
@@ -0,0 +1,66 @@
/**
* E2E test bootstrap. Runs inside the `bun` test container (see
* devops/local/docker-compose-test.yaml) against the dockerized stack:
*
* - `prisma migrate reset` so the schema matches HEAD.
* - `redis-cli FLUSHALL` clears queues + locks + nonce.
* - Fund the operator wallet on the anvil fork with xBZZ + xDAI via
* `fundOperatorFromEnv()` (anvil_setBalance + xBZZ whale impersonation).
* The contracts themselves already exist on the fork — they're managed
* by the t4t repo and live on Gnosis mainnet; the fork inherits them.
* - `evm_snapshot` so each spec can revert to a clean fork.
*/
import { $ } from 'bun';
import { redisConnection } from '../src/queue/connection';
import { fundOperatorFromEnv } from './fixtures/fundOperator';
let snapshotId: string | null = null;
export async function setBeforeAll(): Promise<void> {
// The compose `prisma` service applied migrations on stack-up. Postgres uses
// tmpfs in the test stack so each compose run starts fresh — within one
// run we just need to clear Redis (BullMQ jobs + the operator nonce).
await redisConnection.flushall();
// Fund the operator wallet on the fork. Only needed for specs that settle a
// chat completion on-chain (gateway / concurrency / cancellation). Auth /
// accounts specs don't touch chain, so a funding failure is non-fatal here —
// the chain specs will surface a clearer error later if they actually need it.
try {
await fundOperatorFromEnv();
} catch (err: any) {
console.warn(`[setup] fundOperatorFromEnv failed (non-fatal for non-chain specs): ${err.message}`);
}
snapshotId = await anvilSnapshot().catch(() => null);
}
export async function setAfterAll(): Promise<void> {
if (snapshotId) await anvilRevert(snapshotId).catch(() => undefined);
await redisConnection.quit();
}
async function anvilSnapshot(): Promise<string> {
const res = await fetch(process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_snapshot', params: [], id: 1 }),
});
const json = (await res.json()) as { result?: string };
if (!json.result) throw new Error('evm_snapshot returned no id');
return json.result;
}
async function anvilRevert(id: string): Promise<void> {
await fetch(process.env.GNOSIS_RPC_URL ?? 'http://localhost:8545', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'evm_revert', params: [id], id: 1 }),
});
}
// Bun auto-invokes the top-level `beforeAll` / `afterAll` from the preloaded
// setup file when called as `--preload`.
import { afterAll, beforeAll } from 'bun:test';
beforeAll(setBeforeAll);
afterAll(setAfterAll);