From 04b58e28b026699053aeb55f97893d4f50767bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nedim=20Salki=C4=87?= Date: Mon, 24 Jun 2024 16:53:50 +0200 Subject: [PATCH] chore: add browser testing infrastructure (#2378) --- .changeset/serious-dogs-wash.md | 5 + packages/account/src/test-utils/launchNode.ts | 3 +- .../setup-test-provider-and-wallets.ts | 24 +++- .../fuel-gauge/src/call-test-contract.test.ts | 47 ++++--- .../src/setup-launch-node-server.test.ts | 131 ++++++++++++++++++ .../fuels/src/setup-launch-node-server.ts | 121 ++++++++++++++++ scripts/tests-ci.sh | 3 +- vitest.browser.config.mts | 7 + vitest.global-browser-setup.ts | 61 ++++++++ 9 files changed, 376 insertions(+), 26 deletions(-) create mode 100644 .changeset/serious-dogs-wash.md create mode 100644 packages/fuels/src/setup-launch-node-server.test.ts create mode 100644 packages/fuels/src/setup-launch-node-server.ts create mode 100644 vitest.global-browser-setup.ts diff --git a/.changeset/serious-dogs-wash.md b/.changeset/serious-dogs-wash.md new file mode 100644 index 00000000000..98709abc3cf --- /dev/null +++ b/.changeset/serious-dogs-wash.md @@ -0,0 +1,5 @@ +--- +"@fuel-ts/account": patch +--- + +chore: add browser testing infrastructure diff --git a/packages/account/src/test-utils/launchNode.ts b/packages/account/src/test-utils/launchNode.ts index a2bf58f1dcb..4f89ab09041 100644 --- a/packages/account/src/test-utils/launchNode.ts +++ b/packages/account/src/test-utils/launchNode.ts @@ -3,7 +3,6 @@ import { randomBytes } from '@fuel-ts/crypto'; import type { SnapshotConfigs } from '@fuel-ts/utils'; import { defaultConsensusKey, hexlify, defaultSnapshotConfigs } from '@fuel-ts/utils'; import type { ChildProcessWithoutNullStreams } from 'child_process'; -import { spawn } from 'child_process'; import { randomUUID } from 'crypto'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import os from 'os'; @@ -217,6 +216,8 @@ export const launchNode = async ({ snapshotDirToUse = tempDir; } + const { spawn } = await import('child_process'); + const child = spawn( command, [ diff --git a/packages/account/src/test-utils/setup-test-provider-and-wallets.ts b/packages/account/src/test-utils/setup-test-provider-and-wallets.ts index 2e03d5fa8d0..e96963923ab 100644 --- a/packages/account/src/test-utils/setup-test-provider-and-wallets.ts +++ b/packages/account/src/test-utils/setup-test-provider-and-wallets.ts @@ -23,6 +23,7 @@ export interface LaunchCustomProviderAndGetWalletsOptions { snapshotConfig: PartialDeep; } >; + launchNodeServerPort?: string; } const defaultWalletConfigOptions: WalletsConfigOptions = { @@ -52,6 +53,7 @@ export async function setupTestProviderAndWallets({ walletsConfig: walletsConfigOptions = {}, providerOptions, nodeOptions = {}, + launchNodeServerPort = process.env.LAUNCH_NODE_SERVER_PORT || undefined, }: Partial = {}): Promise { // @ts-expect-error this is a polyfill (see https://devblogs.microsoft.com/typescript/announcing-typescript-5-2/#using-declarations-and-explicit-resource-management) Symbol.dispose ??= Symbol('Symbol.dispose'); @@ -64,7 +66,7 @@ export async function setupTestProviderAndWallets({ } ); - const { cleanup, url } = await launchNode({ + const launchNodeOptions: LaunchNodeOptions = { loggingEnabled: false, ...nodeOptions, snapshotConfig: mergeDeepRight( @@ -72,7 +74,25 @@ export async function setupTestProviderAndWallets({ walletsConfig.apply(nodeOptions?.snapshotConfig) ), port: '0', - }); + }; + + let cleanup: () => void; + let url: string; + if (launchNodeServerPort) { + const serverUrl = `http://localhost:${launchNodeServerPort}`; + url = await ( + await fetch(serverUrl, { method: 'POST', body: JSON.stringify(launchNodeOptions) }) + ).text(); + + cleanup = () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetch(`${serverUrl}/cleanup/${url}`); + }; + } else { + const settings = await launchNode(launchNodeOptions); + url = settings.url; + cleanup = settings.cleanup; + } let provider: Provider; diff --git a/packages/fuel-gauge/src/call-test-contract.test.ts b/packages/fuel-gauge/src/call-test-contract.test.ts index 53eb58ab080..e47cdfe0540 100644 --- a/packages/fuel-gauge/src/call-test-contract.test.ts +++ b/packages/fuel-gauge/src/call-test-contract.test.ts @@ -1,27 +1,30 @@ import { ASSET_A } from '@fuel-ts/utils/test-utils'; import type { Contract } from 'fuels'; import { BN, bn, toHex } from 'fuels'; +import { launchTestNode } from 'fuels/test-utils'; -import type { CallTestContractAbi } from '../test/typegen/contracts'; import { CallTestContractAbi__factory } from '../test/typegen/contracts'; -import binHexlified from '../test/typegen/contracts/CallTestContractAbi.hex'; - -import { createSetupConfig } from './utils'; - -const setupContract = createSetupConfig({ - contractBytecode: binHexlified, - abi: CallTestContractAbi__factory.abi, - cache: true, -}); +import bytecode from '../test/typegen/contracts/CallTestContractAbi.hex'; + +const setupContract = async () => { + const { + contracts: [contract], + cleanup, + } = await launchTestNode({ + contractsConfigs: [{ deployer: CallTestContractAbi__factory, bytecode }], + }); + return Object.assign(contract, { [Symbol.dispose]: cleanup }); +}; const U64_MAX = bn(2).pow(64).sub(1); /** * @group node + * @group browser */ describe('CallTestContract', () => { it.each([0, 1337, U64_MAX.sub(1)])('can call a contract with u64 (%p)', async (num) => { - const contract = await setupContract(); + using contract = await setupContract(); const { value } = await contract.functions.foo(num).call(); expect(value.toHex()).toEqual(bn(num).add(1).toHex()); }); @@ -34,14 +37,14 @@ describe('CallTestContract', () => { [{ a: false, b: U64_MAX.sub(1) }], [{ a: true, b: U64_MAX.sub(1) }], ])('can call a contract with structs (%p)', async (struct) => { - const contract = await setupContract(); + using contract = await setupContract(); const { value } = await contract.functions.boo(struct).call(); expect(value.a).toEqual(!struct.a); expect(value.b.toHex()).toEqual(bn(struct.b).add(1).toHex()); }); it('can call a function with empty arguments', async () => { - const contract = await setupContract(); + using contract = await setupContract(); const { value: empty } = await contract.functions.empty().call(); expect(empty.toHex()).toEqual(toHex(63)); @@ -59,7 +62,7 @@ describe('CallTestContract', () => { }); it('function with empty return should resolve undefined', async () => { - const contract = await setupContract(); + using contract = await setupContract(); // Call method with no params but with no result and no value on config const { value } = await contract.functions.return_void().call(); @@ -136,9 +139,9 @@ describe('CallTestContract', () => { async (method, { values, expected }) => { // Type cast to Contract because of the dynamic nature of the test // But the function names are type-constrained to correct Contract's type - const contract = (await setupContract()) as Contract; + using contract = await setupContract(); - const { value } = await contract.functions[method](...values).call(); + const { value } = await (contract as Contract).functions[method](...values).call(); if (BN.isBN(value)) { expect(toHex(value)).toBe(toHex(expected)); @@ -149,7 +152,7 @@ describe('CallTestContract', () => { ); it('Forward amount value on contract call', async () => { - const contract = await setupContract(); + using contract = await setupContract(); const baseAssetId = contract.provider.getBaseAssetId(); const { value } = await contract.functions .return_context_amount() @@ -161,7 +164,7 @@ describe('CallTestContract', () => { }); it('Forward asset_id on contract call', async () => { - const contract = await setupContract(); + using contract = await setupContract(); const assetId = ASSET_A; const { value } = await contract.functions @@ -174,7 +177,7 @@ describe('CallTestContract', () => { }); it('Forward asset_id on contract simulate call', async () => { - const contract = await setupContract(); + using contract = await setupContract(); const assetId = ASSET_A; const { value } = await contract.functions @@ -187,7 +190,7 @@ describe('CallTestContract', () => { }); it('can make multiple calls', async () => { - const contract = await setupContract(); + using contract = await setupContract(); const num = 1337; const numC = 10; @@ -222,14 +225,14 @@ describe('CallTestContract', () => { }); it('Calling a simple contract function does only one dry run', async () => { - const contract = await setupContract(); + using contract = await setupContract(); const dryRunSpy = vi.spyOn(contract.provider.operations, 'dryRun'); await contract.functions.no_params().call(); expect(dryRunSpy).toHaveBeenCalledOnce(); }); it('Simulating a simple contract function does two dry runs', async () => { - const contract = await setupContract(); + using contract = await setupContract(); const dryRunSpy = vi.spyOn(contract.provider.operations, 'dryRun'); await contract.functions.no_params().simulate(); diff --git a/packages/fuels/src/setup-launch-node-server.test.ts b/packages/fuels/src/setup-launch-node-server.test.ts new file mode 100644 index 00000000000..92479b00e54 --- /dev/null +++ b/packages/fuels/src/setup-launch-node-server.test.ts @@ -0,0 +1,131 @@ +import { Provider } from '@fuel-ts/account'; +import { waitUntilUnreachable } from '@fuel-ts/utils/test-utils'; +import { spawn } from 'node:child_process'; + +import { launchTestNode } from './test-utils'; + +interface ServerInfo extends Disposable { + serverUrl: string; + closeServer: () => Promise; +} + +function startServer(port: number = 0): Promise { + return new Promise((resolve, reject) => { + const cp = spawn(`pnpm tsx packages/fuels/src/setup-launch-node-server.ts ${port}`, { + detached: true, + shell: 'sh', + }); + + const server = { + killed: false, + url: undefined as string | undefined, + }; + + const closeServer = async () => { + if (server.killed) { + return; + } + server.killed = true; + await fetch(`${server.url}/close-server`); + }; + + cp.stderr?.on('data', (chunk) => { + // eslint-disable-next-line no-console + console.log(chunk.toString()); + }); + + cp.stdout?.on('data', (chunk) => { + // first message is server url and we resolve immediately because that's what we care about + const message: string[] = chunk.toString().split('\n'); + const serverUrl = message[0]; + server.url ??= serverUrl; + resolve({ + serverUrl, + closeServer, + [Symbol.dispose]: closeServer, + }); + }); + + cp.on('error', async (err) => { + await closeServer(); + reject(err); + }); + + cp.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Server process exited with code ${code}`)); + } + }); + + process.on('SIGINT', closeServer); + process.on('SIGUSR1', closeServer); + process.on('SIGUSR2', closeServer); + process.on('uncaughtException', closeServer); + process.on('unhandledRejection', closeServer); + process.on('beforeExit', closeServer); + }); +} + +/** + * @group node + */ +describe( + 'setup-launch-node-server', + () => { + test('can start server on specific port', async () => { + using launched = await startServer(9876); + expect(launched.serverUrl).toEqual('http://localhost:9876'); + }); + + test('the /close-server endpoint closes the server', async () => { + const { serverUrl } = await startServer(); + await fetch(`${serverUrl}/close-server`); + + await waitUntilUnreachable(serverUrl); + }); + + test('returns a valid fuel-core node url on request', async () => { + using launched = await startServer(); + + const url = await (await fetch(launched.serverUrl)).text(); + // fetches node-related data + // would fail if fuel-core node is not running on url + await Provider.create(url); + }); + + test('the /cleanup endpoint kills the node', async () => { + using launched = await startServer(); + const url = await (await fetch(launched.serverUrl)).text(); + + await fetch(`${launched.serverUrl}/cleanup/${url}`); + + // if the node remained live then the test would time out + await waitUntilUnreachable(url); + }); + + test('kills all nodes when the server is shut down', async () => { + const { serverUrl, closeServer: killServer } = await startServer(); + const url1 = await (await fetch(serverUrl)).text(); + const url2 = await (await fetch(serverUrl)).text(); + + await killServer(); + + // if the nodes remained live then the test would time out + await waitUntilUnreachable(url1); + await waitUntilUnreachable(url2); + }); + + test('launchTestNode launches and kills node ', async () => { + using launchedServer = await startServer(); + const port = launchedServer.serverUrl.split(':')[2]; + const { cleanup, provider } = await launchTestNode({ + launchNodeServerPort: port, + }); + + cleanup(); + + await waitUntilUnreachable(provider.url); + }); + }, + { timeout: 25000 } +); diff --git a/packages/fuels/src/setup-launch-node-server.ts b/packages/fuels/src/setup-launch-node-server.ts new file mode 100644 index 00000000000..f8d09deac96 --- /dev/null +++ b/packages/fuels/src/setup-launch-node-server.ts @@ -0,0 +1,121 @@ +/* eslint-disable no-console */ +import type { LaunchNodeOptions } from '@fuel-ts/account/test-utils'; +import { launchNode } from '@fuel-ts/account/test-utils'; +import { waitUntilUnreachable } from '@fuel-ts/utils/test-utils'; +import http from 'http'; +import type { AddressInfo } from 'net'; + +process.setMaxListeners(Infinity); + +async function parseBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const body: Buffer[] = []; + req.on('data', (chunk) => { + body.push(chunk); + }); + req.on('end', () => { + try { + resolve(JSON.parse(body.length === 0 ? '{}' : Buffer.concat(body).toString())); + } catch (err) { + reject(err); + } + }); + req.on('error', reject); + }); +} + +const cleanupFns: Map Promise> = new Map(); + +const server = http.createServer(async (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + + if (req.url === '/') { + try { + const body = await parseBody(req); + + const node = await launchNode({ + port: '0', + loggingEnabled: false, + debugEnabled: false, + ...body, + fuelCorePath: 'fuels-core', + }); + + cleanupFns.set(node.url, async () => { + node.cleanup(); + await waitUntilUnreachable(node.url); + cleanupFns.delete(node.url); + }); + + res.end(node.url); + } catch (err) { + console.error(err); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end(JSON.stringify(err)); + } + return; + } + + if (req.url?.startsWith('/cleanup')) { + const nodeUrl = req.url?.match(/\/cleanup\/(.+)/)?.[1]; + if (nodeUrl) { + const cleanupFn = cleanupFns.get(nodeUrl); + await cleanupFn?.(); + res.end(); + } + } +}); + +const closeServer = (event?: string) => (reason?: unknown) => { + console.log(event); + if (reason) { + console.log(reason); + } + return new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + + server.close(async () => { + const cleanupCalls: Promise[] = []; + cleanupFns.forEach((fn) => cleanupCalls.push(fn())); + await Promise.all(cleanupCalls); + process.exit(); + }); + + resolve(); + }); +}; + +server.on('request', async (req, res) => { + if (req.url === '/close-server') { + await closeServer('request to /close-server')(); + res.end(); + } +}); + +const port = process.argv[2] ? parseInt(process.argv[2], 10) : 49342; + +server.listen(port); + +server.on('listening', () => { + const usedPort = (server.address() as AddressInfo).port; + const serverUrl = `http://localhost:${usedPort}`; + console.log(serverUrl); + console.log(`Server is listening on: ${serverUrl}`); + console.log("To launch a new fuel-core node and get its url, make a POST request to '/'."); + console.log( + "To kill the node, make a POST request to '/cleanup/' where is the url of the node you want to kill." + ); + console.log('All nodes will be killed when the server is closed.'); + console.log('You can close the server by sending a request to /close-server.'); +}); + +process.on('exit', closeServer('exit')); +process.on('SIGINT', closeServer('SIGINT')); +process.on('SIGUSR1', closeServer('SIGUSR1')); +process.on('SIGUSR2', closeServer('SIGUSR2')); +process.on('uncaughtException', closeServer('uncaughtException')); +process.on('unhandledRejection', closeServer('unhandledRejection')); +process.on('beforeExit', closeServer('beforeExit')); diff --git a/scripts/tests-ci.sh b/scripts/tests-ci.sh index 1ecfac1a748..930fa37cf58 100755 --- a/scripts/tests-ci.sh +++ b/scripts/tests-ci.sh @@ -4,11 +4,12 @@ pkill fuel-core pnpm node:clean -pnpm node:run > /dev/null 2>&1 & +pnpm node:run >/dev/null 2>&1 & echo "Started Fuel-Core node in background." if [[ "$*" == *"--browser"* ]]; then + pnpm pretest pnpm test:browser TEST_RESULT=$? elif [[ "$*" == *"--node"* ]]; then diff --git a/vitest.browser.config.mts b/vitest.browser.config.mts index 8c7b3b0132f..48852149d53 100644 --- a/vitest.browser.config.mts +++ b/vitest.browser.config.mts @@ -20,6 +20,9 @@ const config: UserConfig = { "timers/promises", "util", "stream", + "path", + "fs", + "os", ], overrides: { fs: "memfs", @@ -31,6 +34,10 @@ const config: UserConfig = { include: ["events", "timers/promises"], }, test: { + env: { + LAUNCH_NODE_SERVER_PORT: "49342", + }, + globalSetup: ["./vitest.global-browser-setup.ts"], coverage: { reportsDirectory: "coverage/environments/browser", }, diff --git a/vitest.global-browser-setup.ts b/vitest.global-browser-setup.ts new file mode 100644 index 00000000000..d227e092f36 --- /dev/null +++ b/vitest.global-browser-setup.ts @@ -0,0 +1,61 @@ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { spawn } from 'node:child_process'; + +export default function setup() { + return new Promise((resolve, reject) => { + const server = { + closed: false, + }; + const teardown = async () => { + if (server.closed) { + return; + } + server.closed = true; + const serverUrl = `http://localhost:49342`; + try { + await fetch(`${serverUrl}/close-server`); + } catch (e) { + console.log('closing of server failed', e); + } + process.exit(); + }; + + const cp = spawn('pnpm tsx packages/fuels/src/setup-launch-node-server.ts', { + detached: true, + shell: 'sh', + }); + + cp.stderr?.on('data', (chunk) => { + console.log(chunk.toString()); + }); + + cp.stdout?.on('data', (data) => { + console.log(data.toString()); + // Return teardown function to be called when tests finish + // It will kill the server + resolve(teardown); + }); + + cp.on('error', (err) => { + console.log(err); + // Ensure server is killed if there's an error + teardown(); + reject(err); + }); + + cp.on('exit', (code, signal) => { + console.log('error code', code, signal); + if (code !== 0) { + reject(new Error(`Server process exited with code ${code}`)); + } + }); + + process.on('SIGINT', teardown); + process.on('SIGUSR1', teardown); + process.on('SIGUSR2', teardown); + process.on('uncaughtException', teardown); + process.on('unhandledRejection', teardown); + process.on('beforeExit', teardown); + }); +}