From f203e94d648d88fad5678ea8144f6b928775e83d Mon Sep 17 00:00:00 2001 From: Cody McCabe Date: Fri, 27 Feb 2026 13:12:27 -0500 Subject: [PATCH] test: add deterministic mock E2E coverage and safe local overrides Add a dedicated mock E2E test suite and CI gate so command behavior is validated through the built CLI without relying on live APIs. Introduce localhost-only RPC/Admin base URL overrides to support test routing while preserving secure default endpoint behavior. Made-with: Cursor --- .github/workflows/ci.yml | 2 + README.md | 13 + package.json | 3 + pnpm-lock.yaml | 144 +++++++ src/lib/admin-client.ts | 49 ++- src/lib/client.ts | 52 ++- tests/commands/integration.test.ts | 598 +++++++++++++++++++++++++++++ tests/e2e/cli.e2e.test.ts | 157 ++++++++ tests/e2e/fixtures.ts | 46 +++ tests/e2e/helpers/mock-server.ts | 97 +++++ tests/e2e/helpers/run-cli.ts | 49 +++ vitest.config.ts | 17 + vitest.e2e.config.ts | 7 + 13 files changed, 1229 insertions(+), 5 deletions(-) create mode 100644 tests/commands/integration.test.ts create mode 100644 tests/e2e/cli.e2e.test.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/helpers/mock-server.ts create mode 100644 tests/e2e/helpers/run-cli.ts create mode 100644 vitest.config.ts create mode 100644 vitest.e2e.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0186655..45fe656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,5 @@ jobs: - run: pnpm lint - run: pnpm test + + - run: pnpm test:e2e diff --git a/README.md b/README.md index decaf7e..b9efa88 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,19 @@ pnpm build pnpm test ``` +### Test/debug endpoint overrides + +The following env vars are intended for local testing/debugging (for example, mock E2E servers). They are **not** normal production configuration: + +- `ALCHEMY_RPC_BASE_URL` +- `ALCHEMY_ADMIN_API_BASE_URL` + +Safety constraints: + +- Only localhost targets are accepted (`localhost`, `127.0.0.1`, `::1`) +- Non-HTTPS transport is only allowed for localhost targets +- Default production behavior is unchanged when these vars are unset + ### Type check ```bash diff --git a/package.json b/package.json index bceff0d..9d73db7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "build": "tsup", "dev": "tsup --watch", "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:e2e": "pnpm build && vitest run --config vitest.e2e.config.ts", "test:watch": "vitest", "lint": "tsc --noEmit" }, @@ -41,6 +43,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@vitest/coverage-v8": "^4.0.18", "@types/node": "^25.3.0", "tsup": "^8.5.1", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f6fa15..2e416a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@types/node': specifier: ^25.3.0 version: 25.3.0 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(tsx@4.21.0)) tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) @@ -39,6 +42,27 @@ importers: packages: + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@clack/core@1.0.1': resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==} @@ -358,6 +382,15 @@ packages: '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -403,6 +436,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -487,14 +523,36 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -509,6 +567,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -585,6 +650,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -618,6 +688,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -769,6 +843,21 @@ packages: snapshots: + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@clack/core@1.0.1': dependencies: picocolors: 1.1.1 @@ -965,6 +1054,20 @@ snapshots: dependencies: undici-types: 7.18.2 + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(tsx@4.21.0))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.3.0)(tsx@4.21.0) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -1012,6 +1115,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + bundle-require@5.1.0(esbuild@0.27.3): dependencies: esbuild: 0.27.3 @@ -1099,10 +1208,29 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + is-fullwidth-code-point@3.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + joycon@3.1.1: {} + js-tokens@10.0.0: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -1113,6 +1241,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + mlly@1.8.0: dependencies: acorn: 8.16.0 @@ -1198,6 +1336,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + semver@7.7.4: {} + siginfo@2.0.0: {} sisteransi@1.0.5: {} @@ -1230,6 +1370,10 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 diff --git a/src/lib/admin-client.ts b/src/lib/admin-client.ts index b5cd8a2..9cd47ed 100644 --- a/src/lib/admin-client.ts +++ b/src/lib/admin-client.ts @@ -58,6 +58,8 @@ interface ListAppsResponse { export class AdminClient { private static readonly ADMIN_API_HOST = "admin-api.alchemy.com"; + // Test/debug only: used by mock E2E to route admin requests locally. + private static readonly ADMIN_API_BASE_URL_ENV = "ALCHEMY_ADMIN_API_BASE_URL"; private accessKey: string; constructor(accessKey: string) { @@ -66,15 +68,56 @@ export class AdminClient { } protected baseURL(): string { + const override = this.baseURLOverride(); + if (override) return override.toString().replace(/\/$/, ""); return "https://admin-api.alchemy.com"; } protected allowedHosts(): Set { - return new Set([AdminClient.ADMIN_API_HOST]); + const hosts = new Set([AdminClient.ADMIN_API_HOST]); + const override = this.baseURLOverride(); + if (override) hosts.add(override.hostname); + return hosts; } - protected allowInsecureTransport(_hostname: string): boolean { - return false; + protected allowInsecureTransport(hostname: string): boolean { + return this.isLocalhost(hostname); + } + + private isLocalhost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + } + + private baseURLOverride(): URL | null { + const raw = process.env[AdminClient.ADMIN_API_BASE_URL_ENV]; + if (!raw) return null; + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw errInvalidArgs(`Invalid ${AdminClient.ADMIN_API_BASE_URL_ENV} value.`); + } + + if (!this.isLocalhost(parsed.hostname)) { + throw errInvalidArgs( + `${AdminClient.ADMIN_API_BASE_URL_ENV} must target localhost or 127.0.0.1.`, + ); + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw errInvalidArgs( + `${AdminClient.ADMIN_API_BASE_URL_ENV} must use http:// or https://.`, + ); + } + + if (parsed.protocol === "http:" && !this.isLocalhost(parsed.hostname)) { + throw errInvalidArgs( + `${AdminClient.ADMIN_API_BASE_URL_ENV} can only use non-HTTPS for localhost targets.`, + ); + } + + return parsed; } private validateAccessKey(accessKey: string): void { diff --git a/src/lib/client.ts b/src/lib/client.ts index c3299c0..4a5ef30 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -25,6 +25,8 @@ export interface RPCResponse { export class Client { apiKey: string; network: string; + // Test/debug only: used by mock E2E to route CLI requests locally. + private static readonly RPC_BASE_URL_ENV = "ALCHEMY_RPC_BASE_URL"; constructor(apiKey: string, network: string) { this.apiKey = apiKey; @@ -33,6 +35,10 @@ export class Client { } private validateNetwork(network: string): void { + if (this.rpcBaseURLOverride()) { + return; + } + // Ensure the network value cannot redirect requests to an arbitrary host. // A valid Alchemy network slug produces a hostname like "eth-mainnet.g.alchemy.com". const hostname = `${network}.g.alchemy.com`; @@ -47,12 +53,54 @@ export class Client { } } + private isLocalhost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + } + + private rpcBaseURLOverride(): URL | null { + const raw = process.env[Client.RPC_BASE_URL_ENV]; + if (!raw) return null; + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw errInvalidArgs(`Invalid ${Client.RPC_BASE_URL_ENV} value.`); + } + + if (!this.isLocalhost(parsed.hostname)) { + throw errInvalidArgs( + `${Client.RPC_BASE_URL_ENV} must target localhost or 127.0.0.1.`, + ); + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw errInvalidArgs( + `${Client.RPC_BASE_URL_ENV} must use http:// or https://.`, + ); + } + + if (parsed.protocol === "http:" && !this.isLocalhost(parsed.hostname)) { + throw errInvalidArgs( + `${Client.RPC_BASE_URL_ENV} can only use non-HTTPS for localhost targets.`, + ); + } + + return parsed; + } + + private rpcBaseURL(): URL { + const override = this.rpcBaseURLOverride(); + if (override) return override; + return new URL(`https://${this.network}.g.alchemy.com`); + } + rpcURL(): string { - return `https://${this.network}.g.alchemy.com/v2/${this.apiKey}`; + return new URL(`/v2/${this.apiKey}`, this.rpcBaseURL()).toString(); } enhancedURL(): string { - return `https://${this.network}.g.alchemy.com/nft/v3/${this.apiKey}`; + return new URL(`/nft/v3/${this.apiKey}`, this.rpcBaseURL()).toString(); } async call(method: string, params: unknown[] = []): Promise { diff --git a/tests/commands/integration.test.ts b/tests/commands/integration.test.ts new file mode 100644 index 0000000..dc0f56e --- /dev/null +++ b/tests/commands/integration.test.ts @@ -0,0 +1,598 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; + +const ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; +const HASH = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +describe("command integration coverage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("balance reads stdin arg and prints JSON", async () => { + const call = vi.fn().mockResolvedValue("0x10"); + const readStdinArg = vi.fn().mockResolvedValue(ADDRESS); + const validateAddress = vi.fn(); + const printJSON = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + weiToEth: () => "0.000000000000000016", + printKeyValueBox: vi.fn(), + green: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress, + readStdinArg, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerBalance } = await import("../../src/commands/balance.js"); + const program = new Command(); + registerBalance(program); + + await program.parseAsync(["node", "test", "balance"], { from: "node" }); + + expect(readStdinArg).toHaveBeenCalledWith("address"); + expect(validateAddress).toHaveBeenCalledWith(ADDRESS); + expect(call).toHaveBeenCalledWith("eth_getBalance", [ADDRESS, "latest"]); + expect(printJSON).toHaveBeenCalledWith({ + address: ADDRESS, + wei: "16", + eth: "0.000000000000000016", + network: "eth-mainnet", + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("nfts forwards pagination options in JSON mode", async () => { + const callEnhanced = vi.fn().mockResolvedValue({ + ownedNfts: [], + totalCount: 0, + pageKey: "next-page", + }); + const printJSON = vi.fn(); + const validateAddress = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ callEnhanced }), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + dim: (s: string) => s, + printTable: vi.fn(), + emptyState: vi.fn(), + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress, + readStdinArg: vi.fn(), + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerNFTs } = await import("../../src/commands/nfts.js"); + const program = new Command(); + registerNFTs(program); + + await program.parseAsync( + [ + "node", + "test", + "nfts", + ADDRESS, + "--limit", + "10", + "--page-key", + "pk_123", + ], + { from: "node" }, + ); + + expect(validateAddress).toHaveBeenCalledWith(ADDRESS); + expect(callEnhanced).toHaveBeenCalledWith("getNFTsForOwner", { + owner: ADDRESS, + withMetadata: "true", + pageSize: "10", + pageKey: "pk_123", + }); + expect(printJSON).toHaveBeenCalledWith({ + ownedNfts: [], + totalCount: 0, + pageKey: "next-page", + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("tokens filters zero balances and prints table rows", async () => { + const call = vi.fn().mockResolvedValue({ + address: ADDRESS, + tokenBalances: [ + { contractAddress: "0xzero", tokenBalance: "0x0" }, + { contractAddress: "0xnonzero", tokenBalance: "0x1234" }, + ], + }); + const printTable = vi.fn(); + const emptyState = vi.fn(); + const validateAddress = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + dim: (s: string) => s, + printTable, + emptyState, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress, + readStdinArg: vi.fn(), + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerTokens } = await import("../../src/commands/tokens.js"); + const program = new Command(); + registerTokens(program); + + await program.parseAsync(["node", "test", "tokens", ADDRESS], { + from: "node", + }); + + expect(call).toHaveBeenCalledWith("alchemy_getTokenBalances", [ADDRESS]); + expect(emptyState).not.toHaveBeenCalled(); + expect(printTable).toHaveBeenCalledWith( + ["Contract", "Balance"], + [["0xnonzero", "0x1234"]], + ); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("apps create supports dry-run JSON payload", async () => { + const printJSON = vi.fn(); + const adminClientFromFlags = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + adminClientFromFlags, + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + withSpinner: vi.fn(), + printTable: vi.fn(), + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerApps } = await import("../../src/commands/apps.js"); + const program = new Command(); + registerApps(program); + + await program.parseAsync( + [ + "node", + "test", + "apps", + "create", + "--name", + "Demo App", + "--networks", + "eth-mainnet,polygon-mainnet", + "--dry-run", + ], + { from: "node" }, + ); + + expect(printJSON).toHaveBeenCalledWith({ + dryRun: true, + action: "create", + payload: { + name: "Demo App", + networks: ["eth-mainnet", "polygon-mainnet"], + }, + }); + expect(adminClientFromFlags).not.toHaveBeenCalled(); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("config set verbose persists normalized boolean", async () => { + const load = vi.fn().mockReturnValue({ api_key: "k" }); + const save = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/config.js", () => ({ + load, + save, + get: vi.fn(), + toMap: vi.fn(), + })); + vi.doMock("../../src/lib/admin-client.js", () => ({ + AdminClient: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printHuman: vi.fn(), + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerConfig } = await import("../../src/commands/config.js"); + const program = new Command(); + registerConfig(program); + + await program.parseAsync(["node", "test", "config", "set", "verbose", "TRUE"], { + from: "node", + }); + + expect(load).toHaveBeenCalled(); + expect(save).toHaveBeenCalledWith({ api_key: "k", verbose: true }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("tx emits combined JSON transaction + receipt payload", async () => { + const call = vi + .fn() + .mockResolvedValueOnce({ hash: HASH, from: "0xfrom", to: "0xto", value: "0x1" }) + .mockResolvedValueOnce({ status: "0x1", gasUsed: "0x5208" }); + const printJSON = vi.fn(); + const validateTxHash = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateTxHash, + readStdinArg: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + successBadge: () => "✓", + failBadge: () => "✗", + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + weiToEth: (wei: bigint) => wei.toString(), + etherscanTxURL: vi.fn(), + printKeyValueBox: vi.fn(), + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerTx } = await import("../../src/commands/tx.js"); + const program = new Command(); + registerTx(program); + + await program.parseAsync(["node", "test", "tx", HASH], { from: "node" }); + + expect(validateTxHash).toHaveBeenCalledWith(HASH); + expect(call).toHaveBeenNthCalledWith(1, "eth_getTransactionByHash", [HASH]); + expect(call).toHaveBeenNthCalledWith(2, "eth_getTransactionReceipt", [HASH]); + expect(printJSON).toHaveBeenCalledWith({ + transaction: { hash: HASH, from: "0xfrom", to: "0xto", value: "0x1" }, + receipt: { status: "0x1", gasUsed: "0x5208" }, + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("balance forwards validation failures to exitWithError", async () => { + const err = new Error("bad address"); + const validateAddress = vi.fn(() => { + throw err; + }); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call: vi.fn() }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + weiToEth: () => "0", + printKeyValueBox: vi.fn(), + green: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress, + readStdinArg: vi.fn().mockResolvedValue(ADDRESS), + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerBalance } = await import("../../src/commands/balance.js"); + const program = new Command(); + registerBalance(program); + + await program.parseAsync(["node", "test", "balance"], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledWith(err); + }); + + it("nfts shows empty state in human mode", async () => { + const callEnhanced = vi.fn().mockResolvedValue({ + ownedNfts: [], + totalCount: 0, + }); + const emptyState = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ callEnhanced }), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + dim: (s: string) => s, + printTable: vi.fn(), + emptyState, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + readStdinArg: vi.fn(), + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerNFTs } = await import("../../src/commands/nfts.js"); + const program = new Command(); + registerNFTs(program); + + await program.parseAsync(["node", "test", "nfts", ADDRESS], { from: "node" }); + + expect(callEnhanced).toHaveBeenCalled(); + expect(emptyState).toHaveBeenCalledWith("No NFTs found."); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("tokens forwards page-key params to RPC", async () => { + const call = vi.fn().mockResolvedValue({ + address: ADDRESS, + tokenBalances: [], + pageKey: "p2", + }); + const emptyState = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + dim: (s: string) => s, + printTable: vi.fn(), + emptyState, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + readStdinArg: vi.fn(), + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerTokens } = await import("../../src/commands/tokens.js"); + const program = new Command(); + registerTokens(program); + + await program.parseAsync( + ["node", "test", "tokens", ADDRESS, "--page-key", "pk_next"], + { from: "node" }, + ); + + expect(call).toHaveBeenCalledWith("alchemy_getTokenBalances", [ + ADDRESS, + "erc20", + { pageKey: "pk_next" }, + ]); + expect(emptyState).toHaveBeenCalledWith("No token balances found."); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("apps update requires at least one field", async () => { + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + adminClientFromFlags: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + withSpinner: vi.fn(), + printTable: vi.fn(), + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerApps } = await import("../../src/commands/apps.js"); + const program = new Command(); + registerApps(program); + + await program.parseAsync(["node", "test", "apps", "update", "app_1"], { + from: "node", + }); + + expect(exitWithError).toHaveBeenCalledTimes(1); + }); + + it("config set verbose rejects invalid values", async () => { + const load = vi.fn().mockReturnValue({ api_key: "k" }); + const save = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/config.js", () => ({ + load, + save, + get: vi.fn(), + toMap: vi.fn(), + })); + vi.doMock("../../src/lib/admin-client.js", () => ({ + AdminClient: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printHuman: vi.fn(), + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerConfig } = await import("../../src/commands/config.js"); + const program = new Command(); + registerConfig(program); + + await program.parseAsync(["node", "test", "config", "set", "verbose", "yes"], { + from: "node", + }); + + expect(load).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + expect(exitWithError).toHaveBeenCalledTimes(1); + }); + + it("tx forwards not-found case to exitWithError", async () => { + const call = vi.fn().mockResolvedValueOnce(null); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateTxHash: vi.fn(), + readStdinArg: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + successBadge: () => "✓", + failBadge: () => "✗", + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + weiToEth: (wei: bigint) => wei.toString(), + etherscanTxURL: vi.fn(), + printKeyValueBox: vi.fn(), + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerTx } = await import("../../src/commands/tx.js"); + const program = new Command(); + registerTx(program); + + await program.parseAsync(["node", "test", "tx", HASH], { from: "node" }); + + expect(call).toHaveBeenCalledWith("eth_getTransactionByHash", [HASH]); + expect(exitWithError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/e2e/cli.e2e.test.ts b/tests/e2e/cli.e2e.test.ts new file mode 100644 index 0000000..7b56fdd --- /dev/null +++ b/tests/e2e/cli.e2e.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { FIXTURES } from "./fixtures.js"; +import { runCLI } from "./helpers/run-cli.js"; +import { startMockServer, type MockServer } from "./helpers/mock-server.js"; + +const ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; +const HASH = + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +function parseJSON(text: string): unknown { + return JSON.parse(text.trim()); +} + +describe("CLI mock E2E", () => { + let server: MockServer | null = null; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + }); + + it("returns JSON balance and sends expected RPC payload", async () => { + server = await startMockServer((request) => { + if (request.path === "/v2/test-api-key") { + return { status: 200, json: FIXTURES.rpc.balanceSuccess }; + } + return { status: 404, text: "unknown path" }; + }); + + const result = await runCLI( + [ + "--json", + "--api-key", + "test-api-key", + "--network", + "eth-mainnet", + "balance", + ADDRESS, + ], + { ALCHEMY_RPC_BASE_URL: server.baseURL }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(parseJSON(result.stdout)).toEqual({ + address: ADDRESS, + wei: "16", + eth: "0.000000000000000016", + network: "eth-mainnet", + }); + expect(server.requests).toHaveLength(1); + expect(server.requests[0].method).toBe("POST"); + expect(server.requests[0].path).toBe("/v2/test-api-key"); + expect(server.requests[0].bodyJSON).toMatchObject({ + method: "eth_getBalance", + params: [ADDRESS, "latest"], + }); + }); + + it("returns RPC error contract and exit code on JSON-RPC errors", async () => { + server = await startMockServer((request) => { + if (request.path === "/v2/test-api-key") { + return { status: 200, json: FIXTURES.rpc.rpcError }; + } + return { status: 404, text: "unknown path" }; + }); + + const result = await runCLI( + ["--json", "--api-key", "test-api-key", "balance", ADDRESS], + { ALCHEMY_RPC_BASE_URL: server.baseURL }, + ); + + expect(result.exitCode).toBe(7); + expect(parseJSON(result.stderr)).toMatchObject({ + error: { + code: "RPC_ERROR", + }, + }); + }); + + it("returns NOT_FOUND contract for tx lookup misses", async () => { + server = await startMockServer((request) => { + if (request.path === "/v2/test-api-key") { + return { status: 200, json: FIXTURES.rpc.txNotFound }; + } + return { status: 404, text: "unknown path" }; + }); + + const result = await runCLI( + ["--json", "--api-key", "test-api-key", "tx", HASH], + { ALCHEMY_RPC_BASE_URL: server.baseURL }, + ); + + expect(result.exitCode).toBe(4); + expect(parseJSON(result.stderr)).toMatchObject({ + error: { + code: "NOT_FOUND", + }, + }); + expect(server.requests).toHaveLength(1); + expect(server.requests[0].bodyJSON).toMatchObject({ + method: "eth_getTransactionByHash", + params: [HASH], + }); + }); + + it("lists apps via admin API and sends bearer auth header", async () => { + server = await startMockServer((request) => { + if (request.path === "/v1/apps") { + return { status: 200, json: FIXTURES.admin.listAppsSuccess }; + } + return { status: 404, text: "unknown path" }; + }); + + const result = await runCLI( + ["--json", "--access-key", "test-access-key", "apps", "list"], + { ALCHEMY_ADMIN_API_BASE_URL: server.baseURL }, + ); + + expect(result.exitCode).toBe(0); + expect(parseJSON(result.stdout)).toMatchObject({ + apps: [ + { + id: "app-123", + name: "E2E App", + }, + ], + }); + expect(server.requests).toHaveLength(1); + expect(server.requests[0].headers.authorization).toBe( + "Bearer test-access-key", + ); + }); + + it("returns INVALID_ACCESS_KEY contract on admin auth failures", async () => { + server = await startMockServer((request) => { + if (request.path === "/v1/apps") { + return { status: 401, text: "unauthorized" }; + } + return { status: 404, text: "unknown path" }; + }); + + const result = await runCLI( + ["--json", "--access-key", "test-access-key", "apps", "list"], + { ALCHEMY_ADMIN_API_BASE_URL: server.baseURL }, + ); + + expect(result.exitCode).toBe(3); + expect(parseJSON(result.stderr)).toMatchObject({ + error: { + code: "INVALID_ACCESS_KEY", + }, + }); + }); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..80fa18f --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,46 @@ +import type { App } from "../../src/lib/admin-client.js"; +import type { RPCResponse } from "../../src/lib/client.js"; + +// Keep these fixtures aligned with documented Alchemy API contract shapes. +// When endpoint docs change, update fixtures and E2E assertions together. +export const FIXTURES = { + rpc: { + balanceSuccess: { + jsonrpc: "2.0", + id: 1, + result: "0x10", + } satisfies RPCResponse, + txNotFound: { + jsonrpc: "2.0", + id: 1, + result: null, + } satisfies RPCResponse, + rpcError: { + jsonrpc: "2.0", + id: 1, + error: { code: -32601, message: "Method not found" }, + } satisfies RPCResponse, + }, + admin: { + listAppsSuccess: { + data: { + apps: [ + { + id: "app-123", + name: "E2E App", + apiKey: "api-key-123", + webhookApiKey: "webhook-key-123", + chainNetworks: [ + { + id: "ETH_MAINNET", + name: "ETH_MAINNET", + rpcUrl: "https://eth-mainnet.g.alchemy.com/v2/api-key-123", + }, + ], + createdAt: "2026-01-01T00:00:00Z", + } satisfies App, + ], + }, + }, + }, +} as const; diff --git a/tests/e2e/helpers/mock-server.ts b/tests/e2e/helpers/mock-server.ts new file mode 100644 index 0000000..f444dd9 --- /dev/null +++ b/tests/e2e/helpers/mock-server.ts @@ -0,0 +1,97 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; + +export interface MockRequest { + method: string; + path: string; + query: URLSearchParams; + headers: IncomingMessage["headers"]; + bodyText: string; + bodyJSON: unknown; +} + +export interface MockResponse { + status: number; + json?: unknown; + text?: string; + headers?: Record; +} + +export interface MockServer { + baseURL: string; + requests: MockRequest[]; + close: () => Promise; +} + +export type MockHandler = (request: MockRequest) => MockResponse | Promise; + +function parseJSONSafe(raw: string): unknown { + if (!raw) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +export async function startMockServer(handler: MockHandler): Promise { + const requests: MockRequest[] = []; + let server: Server; + + const onRequest = async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + const bodyText = Buffer.concat(chunks).toString("utf8"); + const requestURL = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const request: MockRequest = { + method: req.method ?? "GET", + path: requestURL.pathname, + query: requestURL.searchParams, + headers: req.headers, + bodyText, + bodyJSON: parseJSONSafe(bodyText), + }; + requests.push(request); + + const response = await handler(request); + res.writeHead(response.status, { + ...(response.json !== undefined ? { "Content-Type": "application/json" } : {}), + ...response.headers, + }); + + if (response.json !== undefined) { + res.end(JSON.stringify(response.json)); + return; + } + + res.end(response.text ?? ""); + }; + + const baseURL = await new Promise((resolve) => { + server = createServer((req, res) => { + void onRequest(req, res); + }); + server.listen(0, () => { + const address = server.address(); + if (typeof address === "object" && address) { + resolve(`http://127.0.0.1:${address.port}`); + } + }); + }); + + return { + baseURL, + requests, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} diff --git a/tests/e2e/helpers/run-cli.ts b/tests/e2e/helpers/run-cli.ts new file mode 100644 index 0000000..9b00893 --- /dev/null +++ b/tests/e2e/helpers/run-cli.ts @@ -0,0 +1,49 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; + +export interface RunCLIResult { + exitCode: number | null; + stdout: string; + stderr: string; + homeDir: string; +} + +export async function runCLI( + args: string[], + env: Record = {}, +): Promise { + const homeDir = mkdtempSync(join(tmpdir(), "alchemy-cli-e2e-")); + const child = spawn(process.execPath, ["dist/index.js", ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: homeDir, + NO_COLOR: "1", + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); + }); + + return { + exitCode, + stdout, + stderr, + homeDir, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6d8ef4b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { configDefaults, defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude, "tests/e2e/**"], + coverage: { + provider: "v8", + reporter: ["text", "html", "json-summary"], + thresholds: { + lines: 35, + functions: 40, + branches: 30, + statements: 35, + }, + }, + }, +}); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..93e8afe --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/e2e/**/*.test.ts"], + }, +});