diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 1b90a249..67ef1cf5 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -24,4 +24,4 @@ jobs: - name: E2E run: | npx playwright install - xvfb-run --auto-servernum npm run test -w @nexus-wallet/e2e + xvfb-run --auto-servernum npm run e2e diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..cfaf5515 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,34 @@ +# E2E + +## 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); + }); +}); +``` diff --git a/e2e/helpers/DefaultTestEnv.ts b/e2e/helpers/DefaultTestEnv.ts new file mode 100644 index 00000000..bdf69eda --- /dev/null +++ b/e2e/helpers/DefaultTestEnv.ts @@ -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; + + getNotificationPage(): Promise; + + approveForEnable(page?: Page): Promise; + + dispose(): Promise; + + 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; +} + +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; + + 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 { + 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 { + const pages = this.context.pages(); + let target = pages.find(predicate); + if (!target) { + target = await this.context.waitForEvent('page', { predicate }); + } + + return target; + } + + async getNotificationPage(): Promise { + 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 { + const notificationPage = page ?? (await this.getNotificationPage()); + + const button = await notificationPage.getByRole('button', { name: 'Connect' }); + await button.click(); + } + + getInjectedCkb(page?: Page): InjectedCkb { + 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, + removeListener: errors.unimplemented, + }; + + return ckb; + } + + dispose(): Promise { + return this.context.close(); + } +} + +async function asyncSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts index 3f53f0c3..a74253e7 100644 --- a/e2e/helpers/index.ts +++ b/e2e/helpers/index.ts @@ -1,179 +1 @@ -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; - - getNotificationPage(): Promise; - - approveForEnable(page?: Page): Promise; - - dispose(): Promise; - - defaultE2eData: { - localServerUrl: string; - nickname: string; - mnemonic: string; - password: string; - }; -} - -interface Options { - headless?: boolean; - autoApprove?: boolean; - initWalletWithDefaults?: boolean; - notificationTimeout?: number; - extensionDirPath?: string; - persistentDirPath?: string; -} - -declare global { - interface Window { - ckb: InjectedCkb; - } -} - -export class DefaultTestEnv implements TestEnv { - 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; - - private _context: BrowserContext | undefined; - private _extensionId: string | undefined; - - constructor(options: Options = {}) { - this.options = { - headless: options.headless ?? process.env.HEADLESS === 'true', - autoApprove: options.autoApprove ?? false, - initWalletWithDefaults: options.initWalletWithDefaults ?? false, - notificationTimeout: options.notificationTimeout ?? 500, - 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 { - 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 { - const pages = this.context.pages(); - let target = pages.find(predicate); - if (!target) { - target = await this.context.waitForEvent('page', { predicate }); - } - - return target; - } - - async getNotificationPage(): Promise { - 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 { - const notificationPage = page ?? (await this.getNotificationPage()); - - const button = await notificationPage.getByRole('button', { name: /connect/i }); - await button.click(); - } - - getInjectedCkb(page?: Page): InjectedCkb { - 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.autoApprove && payload.method === 'wallet_enable') { - await this.approveForEnable(); - } - - return await res; - }, - - on: errors.unimplemented, - removeListener: errors.unimplemented, - }; - - return ckb; - } - - dispose(): Promise { - return this.context.close(); - } -} - -async function asyncSleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +export { DefaultTestEnv, TestEnv } from './DefaultTestEnv'; diff --git a/e2e/tests/wallet-enable.test.ts b/e2e/tests/wallet-enable.test.ts index 11aa826a..4893b486 100644 --- a/e2e/tests/wallet-enable.test.ts +++ b/e2e/tests/wallet-enable.test.ts @@ -1,47 +1,27 @@ import { DefaultTestEnv } from '../helpers'; -jest.setTimeout(40 * 1000); +DefaultTestEnv.setupTest({ initWalletWithDefaults: true }); describe('Enable wallet', function () { - let env: DefaultTestEnv; - - beforeEach(async () => { - env = new DefaultTestEnv({ initWalletWithDefaults: true }); - await env.init(); - }); - - afterEach(async () => { - await env.dispose(); - }); - test('should get the nickname when approved', async () => { - const page = await env.context.newPage(); - await page.goto(env.defaultE2eData.localServerUrl); - - const ckb = await env.getInjectedCkb(); - const enableTask = ckb.request({ method: 'wallet_enable' }); - await env.approveForEnable(); + const notificationPage = await testEnv.getNotificationPage(); + await notificationPage.getByRole('button', { name: 'Connect' }).click(); const res = await enableTask; - expect(res.nickname).toBe(env.defaultE2eData.nickname); + expect(res.nickname).toBe(testEnv.defaultE2eData.nickname); // should not ask again when already approved const res2 = await ckb.request({ method: 'wallet_enable' }); - expect(res2.nickname).toBe(env.defaultE2eData.nickname); + expect(res2.nickname).toBe(testEnv.defaultE2eData.nickname); }); test('should throw when user reject approval', async () => { - const page = await env.context.newPage(); - await page.goto(env.defaultE2eData.localServerUrl); - - const ckb = await env.getInjectedCkb(); - const enableTask = ckb.request({ method: 'wallet_enable' }); - const notificationPage = await env.getNotificationPage(); - await notificationPage.getByRole('button', { name: /cancel/i }).click(); + const notificationPage = await testEnv.getNotificationPage(); + await notificationPage.getByRole('button', { name: 'Cancel' }).click(); await expect(enableTask).rejects.toThrowError(/reject/); }); diff --git a/package.json b/package.json index ce8e5450..29b10f66 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "build:lumos": "cd lumos && npm run build", "build:extension-chrome": "npm run build -w @nexus-wallet/extension-chrome", "test": "jest", + "e2e": "npm run test -w=@nexus-wallet/e2e", "start": "npm run start -w @nexus-wallet/extension-chrome", "lint": "eslint 'packages/*/{src,__tests__}/**/*.{js,ts,jsx,tsx}'", "check": "npm run check:types",