Skip to content
Closed
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: 5 additions & 0 deletions .changelog/e2e-harness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
wallet-cli: none
---

Add `pnpm test:e2e`, which runs the CLI against local Docker chains, a stand-in relay, and local paid servers. No user-facing change.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"typecheck": "tsc -b --noEmit",
"test:types": "tsc -p tsconfig.test.json --noEmit",
"test": "vitest run",
"test:e2e": "vitest run --config test/e2e/vitest.config.ts",
"vendor": "scripts/vendor.sh"
},
"dependencies": {
Expand Down
42 changes: 42 additions & 0 deletions test/build-bundles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

import { build } from "esbuild";

const root = resolve(import.meta.dirname, "..");

/**
* Bundles both CLIs into a temporary directory, with the options of the release bundle in
* scripts/package.ts, so subprocess tests run one prebuilt bundle instead of transpiling the
* sources with tsx on every spawn.
*/
export async function buildCliBundles() {
const outdir = await mkdtemp(join(tmpdir(), "wallet-cli-bundle-"));
const { version, dependencies } = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
) as { version: string; dependencies: Record<string, string> };
await build({
entryPoints: {
"tempo-request": join(root, "src/request-cli.ts"),
"tempo-wallet": join(root, "src/cli.ts"),
},
bundle: true,
platform: "node",
format: "cjs",
target: "node22",
outdir,
outExtension: { ".js": ".cjs" },
define: {
"process.env.TEMPO_WALLET_VERSION": JSON.stringify(version),
"process.env.TEMPO_WALLET_INCUR_VERSION": JSON.stringify(dependencies.incur),
"process.env.TEMPO_WALLET_ACCOUNTS_VERSION": JSON.stringify(dependencies.accounts),
},
logLevel: "error",
});
return {
outdir,
request: join(outdir, "tempo-request.cjs"),
wallet: join(outdir, "tempo-wallet.cjs"),
};
}
19 changes: 19 additions & 0 deletions test/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# End-to-end tests

`pnpm test:e2e` runs the bundled `tempo request` and `tempo wallet` against local chains. It needs
Docker; `pnpm test` never runs it.

- `compose.yaml` starts `anvil --network tempo` with testnet's chain ID (42431), so the CLI's
`testnet` network runs against it through `TEMPO_RPC_URL`, and a plain anvil (31338) standing in
for Base. Adapted from machine-fetch's `test/e2e`.
- `localnet.ts` deploys local OUSD and USDC TIP-20s with DEX books and fee AMM pools on the Tempo
node, and EIP-3009 USDC (`contracts/LocalToken.sol`, compiled into `LocalToken.json`) on the EVM
node.
- `wallet.ts` writes a wallet store with an unpublished access key, as `tempo wallet login` leaves
it.
- `relay.ts` is a stand-in for Relay: `POST /quote/v2` and `GET /intents/status/v3` with Relay's
shapes. It takes OUSD at a single-use deposit address and releases USDC from inventory on the
EVM node, and can tamper with quotes or hold deliveries for tests.
- `servers.ts` serves MPP Tempo charges and EVM charges, including x402-only challenges.

Everything runs on loopback, and the loopback guard from `test/fixtures` refuses anything else.
57 changes: 57 additions & 0 deletions test/e2e/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { spawn } from "node:child_process";

import { inject } from "vitest";

import type { Localnet } from "./localnet.js";
import type { LocalWallet } from "./wallet.js";

export type CliRun = {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
};

/** The environment a CLI subprocess runs with: the wallet's HOME on the local Tempo chain. */
export function cliEnv(net: Localnet, wallet: LocalWallet, env: Record<string, string> = {}) {
return {
PATH: process.env.PATH ?? "",
HOME: wallet.home,
NODE_OPTIONS: process.env.NODE_OPTIONS ?? "",
XDG_DATA_HOME: process.env.XDG_DATA_HOME ?? "",
TEMPO_NO_BROWSER: "1",
TEMPO_AUTH_URL: "http://127.0.0.1:9",
TEMPO_RPC_URL: net.tempoUrl,
TEMPO_WALLET_NETWORK: "testnet",
...env,
};
}

/** Starts `tempo request` or `tempo wallet` from the prebuilt bundle. */
export function startCli(
name: "request" | "wallet",
args: readonly string[],
env: Record<string, string>,
) {
const child = spawn(process.execPath, [inject("cliBundles")[name], ...args], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk));
child.stderr.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk));
const done = new Promise<CliRun>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => resolve({ code, signal, stdout, stderr }));
});
return { child, done };
}

export function runCli(
name: "request" | "wallet",
args: readonly string[],
env: Record<string, string>,
) {
return startCli(name, args, env).done;
}
25 changes: 25 additions & 0 deletions test/e2e/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Adapted from machine-fetch test/e2e/compose.yaml. The Tempo node uses testnet's chain ID, so the
# CLI's testnet network (--network testnet with TEMPO_RPC_URL) runs against it unchanged; the plain
# node stands in for Base. Anvil 1.8.1, which machine-fetch pins, never answers an access-key
# transaction over its spending limit, so this pins 1.8.4.
services:
tempo:
image: ghcr.io/foundry-rs/foundry:v1.8.4
entrypoint: anvil
command: --network tempo --chain-id 42431 --host 0.0.0.0 --silent
ports: ["127.0.0.1::8545"]
healthcheck:
test: ["CMD", "cast", "chain-id", "--rpc-url", "http://127.0.0.1:8545"]
interval: 1s
timeout: 5s
retries: 30
evm:
image: ghcr.io/foundry-rs/foundry:v1.8.4
entrypoint: anvil
command: --chain-id 31338 --host 0.0.0.0 --silent
ports: ["127.0.0.1::8545"]
healthcheck:
test: ["CMD", "cast", "chain-id", "--rpc-url", "http://127.0.0.1:8545"]
interval: 1s
timeout: 5s
retries: 30
Loading
Loading