Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: e2e test scaffolding #184

Merged
merged 4 commits into from
Apr 11, 2023
Merged
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
27 changes: 27 additions & 0 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: E2E Test

on:
push:
branches: [main, next]
pull_request:
types: [opened, synchronize, reopened]

jobs:
e2e-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive

- uses: ./.github/actions/install-deps

- name: Build extension
run: |
npm run build:libs
npm run build:extension-chrome

- name: E2E
run: |
npx playwright install
xvfb-run --auto-servernum npm run e2e -- --testTimeout=20000
24 changes: 24 additions & 0 deletions e2e/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module.exports = {
extends: ['react-app'],
parserOptions: {
ecmaVersion: 2020,
project: ['tsconfig.json'],
sourceType: 'module',
},
overrides: [
{
files: '*.ts',
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
},
],
'@typescript-eslint/explicit-module-boundary-types': 'error',
'@typescript-eslint/no-floating-promises': 'error',
},
},
],
};
1 change: 1 addition & 0 deletions e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tmp
70 changes: 70 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# E2E

## Quick Start

```sh
npm run test -w @nexus-wallet/e2e

# run in headless mode
HEADLESS=true npm run test -w @nexus-wallet/e2e

# any jest cli options can be passed to the test command, e.g.
# increase timeout, default is 5s, the following example is 20s
npm run test -w @nexus-wallet/e2e -- --testTimeout 20000
```

## Add a New Case

Cases are located in [tests](./tests) directory. We have provided an example
case [wallet-enable.test.ts](./tests/wallet-enable.test.ts)

To quick setup a new case, you can use `DefaultTestEnv.setup`

```ts
import { DefaultTestEnv } from '@nexus-wallet/e2e/helpers';

// To skip the creation of a new wallet, `initWalletWithDefaults` can be set to true
// wallet will be initialized with
// {
// nickname: 'Nexus Dev',
// mnemonic: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
// password: '12345678',
// }
DefaultTestEnv.setup({ initWalletWithDefaults: true });

describe('Some scenario', () => {
it('should do something', async () => {
// a ckb object is available in the global scope for each test case
ckb.request({ method: 'some_method' });
});

it('should do something else', async () => {
// or you can use the playwright via testEnv.context
const page = await testEnv.context.newPage();
const ckbInPage2 = testEnv.getInjectedCkb(page);
});
});
```

The `DefaultTestEnv.setup` will inject a `ckb` and `testEnv` object to the global scope, and also create a new wallet for EACH test case.
If you want to use one wallet in multiple test cases, you can use `new DefaultTestEnv` to instead of `DefaultTestEnv.setup`

```ts
describe('Some scenario', () => {
it('should do something', async () => {
const testEnv = new DefaultTestEnv();
const ckb = testEnv.getInjectedCkb();
await ckb.request({ method: 'some_method' });

testEnv.dispose();
});

it('should do something else', async () => {
const testEnv = new DefaultTestEnv();
const ckb = testEnv.getInjectedCkb();
await ckb.request({ method: 'some_method' });

testEnv.dispose();
});
});
```
6 changes: 6 additions & 0 deletions e2e/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import path from 'path';

export const PROJECT_ROOT_PATH = path.join(__dirname, '../../');
export const NEXUS_BUILD_PATH = path.join(PROJECT_ROOT_PATH, 'packages/extension-chrome/build');
export const E2E_ROOT_PATH = path.join(PROJECT_ROOT_PATH, 'e2e');
export const PERSISTENT_PATH = path.join(E2E_ROOT_PATH, 'tmp/test-user-data-dir');
212 changes: 212 additions & 0 deletions e2e/helpers/DefaultTestEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { BrowserContext, chromium, Page } from 'playwright';
import { InjectedCkb, RpcMethods } from '@nexus-wallet/protocol';
import { errors, LIB_VERSION } from '@nexus-wallet/utils';
import { NEXUS_BUILD_PATH, PERSISTENT_PATH } from '../constants';
import path from 'path';
import { getDefaultStorageData } from './storage';

export interface TestEnv {
context: BrowserContext;
extensionId: string;

/**
* Get the injected ckb object
* @param page - If not provided, the last page in the context will be used
*/
getInjectedCkb(page?: Page): InjectedCkb<RpcMethods>;

getNotificationPage(): Promise<Page | undefined>;

approveForEnable(page?: Page): Promise<void>;

dispose(): Promise<void>;

defaultE2eData: {
localServerUrl: string;
nickname: string;
mnemonic: string;
password: string;
};
}

interface Options {
headless?: boolean;
/**
* when true, the wallet_enable request will be auto approved
*/
autoApproveEnable?: boolean;
/**
* init wallet with default data
*/
initWalletWithDefaults?: boolean;

extensionDirPath?: string;
persistentDirPath?: string;
}

declare global {
interface Window {
ckb: InjectedCkb;
}

const testEnv: DefaultTestEnv;
const page: Page;
const ckb: InjectedCkb<RpcMethods>;
}

export class DefaultTestEnv implements TestEnv {
/**
* Setup the test environment, a `testEnv` object will be injected into the global scope.
* should be called in the test file
* @param options
*/
static setupTest(options: Options = {}): void {
let testEnv: DefaultTestEnv;

beforeEach(async () => {
testEnv = new DefaultTestEnv(options);
await testEnv.init();

const page = await testEnv.context.newPage();
await page.goto(testEnv.defaultE2eData.localServerUrl);
const ckb = await testEnv.getInjectedCkb(page);

Object.assign(global, { testEnv, page, ckb });
});

afterEach(async () => {
await testEnv.dispose();
});
}

defaultE2eData: TestEnv['defaultE2eData'] = {
// TODO: this should be a local server
localServerUrl: 'https://github.com',
nickname: 'Nexus Dev',
mnemonic: 'abandon abandon able about above absent absorb abstract absurd abuse access accident',
password: '12345678',
};

// public readonly context: BrowserContext;
private readonly options: Required<Options>;

private _context: BrowserContext | undefined;
private _extensionId: string | undefined;

constructor(options: Options = {}) {
this.options = {
headless: options.headless ?? process.env.HEADLESS === 'true',
autoApproveEnable: options.autoApproveEnable ?? false,
initWalletWithDefaults: options.initWalletWithDefaults ?? false,
extensionDirPath: options.extensionDirPath ?? NEXUS_BUILD_PATH,
persistentDirPath: options.persistentDirPath ?? PERSISTENT_PATH,
};
}

get context(): BrowserContext {
if (!this._context) {
throw new Error('TestEnv not initialized');
}
return this._context;
}

get extensionId(): string {
if (!this._extensionId) {
throw new Error('TestEnv not initialized');
}
return this._extensionId;
}

async init(): Promise<void> {
const persistentPath = path.join(this.options.persistentDirPath, Date.now().toString());
this._context = await chromium.launchPersistentContext(persistentPath, {
headless: this.options.headless,
slowMo: 10,
args: [
...(this.options.headless ? ['--headless=new'] : []),
`--disable-extensions-except=${this.options.extensionDirPath}`,
`--load-extension=${this.options.extensionDirPath}`,
],
});

let [background] = this.context.serviceWorkers();
if (!background) background = await this.context.waitForEvent('serviceworker');
this._extensionId = background.url().split('/')[2];

if (this.options.initWalletWithDefaults) {
// wait for the extension storage to be ready
await asyncSleep(200);

await background.evaluate(async (data) => {
// @ts-ignore
await chrome.storage.local.set(data);
}, getDefaultStorageData());
}
}

private async findPage({ predicate }: { predicate: (page: Page) => boolean }): Promise<Page | undefined> {
const pages = this.context.pages();
let target = pages.find(predicate);
if (!target) {
target = await this.context.waitForEvent('page', { predicate });
}

return target;
}

async getNotificationPage(): Promise<Page> {
const notificationPage = await this.findPage({
predicate: (page) => page.url().includes('notification.html'),
});
if (!notificationPage) {
throw new Error('Notification page not found');
}

return notificationPage;
}

async approveForEnable(page?: Page): Promise<void> {
IronLu233 marked this conversation as resolved.
Show resolved Hide resolved
const notificationPage = page ?? (await this.getNotificationPage());

const button = await notificationPage.getByRole('button', { name: 'Connect' });
await button.click();
}

getInjectedCkb(page?: Page): InjectedCkb<RpcMethods> {
const pages = this.context.pages();
const thePage = page ?? pages[pages.length - 1];

if (!thePage) throw new Error('The page is required');

const ckb: InjectedCkb = {
// TODO: fetch this from window.ckb
version: LIB_VERSION,
request: async (payload) => {
const res = thePage.evaluate(async (payload) => {
await new Promise((resolve) => setTimeout(resolve, 10));
// @ts-ignore
return window.ckb.request(payload);
}, payload);

if (this.options.autoApproveEnable && payload.method === 'wallet_enable') {
await this.approveForEnable();
}

return await res;
},

on: errors.unimplemented,
IronLu233 marked this conversation as resolved.
Show resolved Hide resolved
removeListener: errors.unimplemented,
};

return ckb;
}

dispose(): Promise<void> {
return this.context.close();
}
}

async function asyncSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
1 change: 1 addition & 0 deletions e2e/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DefaultTestEnv, TestEnv } from './DefaultTestEnv';
44 changes: 44 additions & 0 deletions e2e/helpers/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export function getDefaultStorageData(): unknown {
return {
config: {
version: '0.0.1',
whitelist: [],
nickname: 'Nexus Dev',
networks: [
{
id: 'mainnet',
networkName: 'ckb',
displayName: 'Mainnet',
rpcUrl: 'https://mainnet.ckb.dev',
},
{
id: 'testnet',
networkName: 'ckb_testnet',
displayName: 'Testnet',
rpcUrl: 'https://testnet.ckb.dev',
},
],
selectedNetwork: 'testnet',
},
keystore: {
publicInfos: [
{
path: "m/44'/309'/0'/0",
publicKey: '0x03a82e301972d5b2f7b9bf5b904f9b491db9f46bb059da60c88222b75ef892c665',
chainCode: '0x4c2acb8da2a47eac2ac703246d0392960b58830c769e985f822201269e8cc94f',
},
{
path: "m/44'/309'/0'/1",
publicKey: '0x02f77ad091450c9d63d99325f41ba5aeddc880d22b63e77e94865318e1f64b0688',
chainCode: '0xa7e643821843cc1c856db4cfaa6752d3afb99321b2a349c2434a9f5961341535',
},
{
path: "m/4410179'/0'",
publicKey: '0x02ae6dc25eb4244648868b01c50bed8435820f72d629c0a34a9014b4440d98d5fb',
chainCode: '0x8a6e51b51b16c304ee50daca76bef6b6cda970c5a7916eeb3f555f34ea82380d',
},
],
wss: '{"version":3,"crypto":{"ciphertext":"9e2be7be3eeff3154e1c677f7ff96ca0f740a44a1d4c045cd25358734631597a13289d787ca6088205548bd306c1fcfa328a4d6ee67381690344b61195bc998f","cipherparams":{"iv":"3343a3ed7f49fc5a34af4a7e5f5015bb"},"cipher":"aes-128-ctr","kdf":"scrypt","kdfparams":{"dklen":32,"salt":"f96cd71b809d2d3ddf2e3957fe46f2c20dda388417af314b17a7bfcb2dbdf6c3","n":262144,"r":8,"p":1},"mac":"011743cb5fa963afaf30e199811e8a05e9a96c10102ac8adf5b1cc1e418e2bdc"},"id":"3be07a3b-01ca-46b6-910a-9d581475459d"}',
},
};
}
Loading