Skip to content

Commit

Permalink
chore: add browser testing infrastructure (#2378)
Browse files Browse the repository at this point in the history
  • Loading branch information
nedsalk authored Jun 24, 2024
1 parent 6ce6af4 commit 04b58e2
Show file tree
Hide file tree
Showing 9 changed files with 376 additions and 26 deletions.
5 changes: 5 additions & 0 deletions .changeset/serious-dogs-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fuel-ts/account": patch
---

chore: add browser testing infrastructure
3 changes: 2 additions & 1 deletion packages/account/src/test-utils/launchNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -217,6 +216,8 @@ export const launchNode = async ({
snapshotDirToUse = tempDir;
}

const { spawn } = await import('child_process');

const child = spawn(
command,
[
Expand Down
24 changes: 22 additions & 2 deletions packages/account/src/test-utils/setup-test-provider-and-wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface LaunchCustomProviderAndGetWalletsOptions {
snapshotConfig: PartialDeep<SnapshotConfigs>;
}
>;
launchNodeServerPort?: string;
}

const defaultWalletConfigOptions: WalletsConfigOptions = {
Expand Down Expand Up @@ -52,6 +53,7 @@ export async function setupTestProviderAndWallets({
walletsConfig: walletsConfigOptions = {},
providerOptions,
nodeOptions = {},
launchNodeServerPort = process.env.LAUNCH_NODE_SERVER_PORT || undefined,
}: Partial<LaunchCustomProviderAndGetWalletsOptions> = {}): Promise<SetupTestProviderAndWalletsReturn> {
// @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');
Expand All @@ -64,15 +66,33 @@ export async function setupTestProviderAndWallets({
}
);

const { cleanup, url } = await launchNode({
const launchNodeOptions: LaunchNodeOptions = {
loggingEnabled: false,
...nodeOptions,
snapshotConfig: mergeDeepRight(
defaultSnapshotConfigs,
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;

Expand Down
47 changes: 25 additions & 22 deletions packages/fuel-gauge/src/call-test-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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<CallTestContractAbi>({
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());
});
Expand All @@ -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));
Expand All @@ -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();
Expand Down Expand Up @@ -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));
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
131 changes: 131 additions & 0 deletions packages/fuels/src/setup-launch-node-server.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

function startServer(port: number = 0): Promise<ServerInfo> {
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 }
);
Loading

0 comments on commit 04b58e2

Please sign in to comment.