From c39fd9bf0ee09afd1d233ea8065800579af4875b Mon Sep 17 00:00:00 2001 From: Martin Varmuza Date: Mon, 12 Aug 2024 12:08:44 +0200 Subject: [PATCH] test(transport-test): add webusb tester build --- .depcheckrc.json | 3 +- .github/workflows/test-transport.yml | 57 ++++++++- packages/connect/src/device/Device.ts | 1 + packages/transport-test/.eslintrc.js | 1 + packages/transport-test/e2e/api/api.test.ts | 111 ++++++++++++++++++ packages/transport-test/e2e/api/shared.ts | 33 ++++++ packages/transport-test/e2e/api/utils.ts | 33 ++++++ .../e2e/{tests => bridge}/bridge.test.ts | 4 +- .../e2e/{ => bridge}/controller.ts | 0 .../transport-test/e2e/{ => bridge}/expect.ts | 0 .../e2e/{ => bridge}/jest.config.ts | 2 +- .../{tests => bridge}/multi-client.test.ts | 4 +- .../transport-test/e2e/{ => bridge}/run.ts | 0 packages/transport-test/e2e/ui/api.test.html | 10 ++ packages/transport-test/package.json | 16 ++- yarn.lock | 2 + 16 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 packages/transport-test/e2e/api/api.test.ts create mode 100644 packages/transport-test/e2e/api/shared.ts create mode 100644 packages/transport-test/e2e/api/utils.ts rename packages/transport-test/e2e/{tests => bridge}/bridge.test.ts (96%) rename packages/transport-test/e2e/{ => bridge}/controller.ts (100%) rename packages/transport-test/e2e/{ => bridge}/expect.ts (100%) rename packages/transport-test/e2e/{ => bridge}/jest.config.ts (96%) rename packages/transport-test/e2e/{tests => bridge}/multi-client.test.ts (98%) rename packages/transport-test/e2e/{ => bridge}/run.ts (100%) create mode 100644 packages/transport-test/e2e/ui/api.test.html diff --git a/.depcheckrc.json b/.depcheckrc.json index 789cce217f5..631b39afac9 100644 --- a/.depcheckrc.json +++ b/.depcheckrc.json @@ -10,7 +10,8 @@ "node-libs-browser", "rimraf", "tslib", - "protobufjs-cli" + "protobufjs-cli", + "buffer" ], "skip-missing": true } diff --git a/.github/workflows/test-transport.yml b/.github/workflows/test-transport.yml index 43528649cfe..1e474574819 100644 --- a/.github/workflows/test-transport.yml +++ b/.github/workflows/test-transport.yml @@ -1,5 +1,9 @@ name: "[Test] transport e2e" +permissions: + id-token: write # for fetching the OIDC token + contents: read # for actions/checkout + on: schedule: # Runs at midnight UTC every day at 01:00 AM CET @@ -20,7 +24,7 @@ on: workflow_dispatch: jobs: - transport-e2e: + transport-e2e-test: if: github.repository == 'trezor/trezor-suite' runs-on: ubuntu-latest steps: @@ -49,3 +53,54 @@ jobs: - name: Run E2E tests (new-bridge:emu) run: yarn workspace @trezor/transport-test test:e2e:new-bridge:emu + + extract-branch: + if: github.repository == 'trezor/trezor-suite' + runs-on: ubuntu-latest + outputs: + branch: ${{ steps.extract_branch.outputs.branch }} + steps: + - name: Extract branch name + id: extract_branch + run: | + BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" + echo "branch=$BRANCH_NAME" >> $GITHUB_OUTPUT + + build-deploy: + needs: [extract-branch] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.extract-branch.outputs.branch }} + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Install dependencies + shell: bash + run: | + echo -e "\nenableScripts: false" >> .yarnrc.yml + yarn workspaces focus @trezor/transport -A + + - name: Build transport tester + run: | + yarn workspace @trezor/transport-test build:e2e:api:browser + + - name: Configure aws credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::538326561891:role/gh_actions_trezor_suite_dev_deploy + aws-region: eu-central-1 + + - name: Upload transport tester + shell: bash + env: + DEPLOY_PATH: s3://dev.suite.sldev.cz/transport-test/${{ needs.extract-branch.outputs.branch }} + run: | + echo "DEPLOY_PATH is set to ${DEPLOY_PATH}" + mkdir -p tmp_build_directory + cp -R ./packages/transport-test/e2e/dist/* tmp_build_directory/ + aws s3 sync --delete tmp_build_directory/ "${DEPLOY_PATH}" diff --git a/packages/connect/src/device/Device.ts b/packages/connect/src/device/Device.ts index b081cbbce46..43747fa42c0 100644 --- a/packages/connect/src/device/Device.ts +++ b/packages/connect/src/device/Device.ts @@ -382,6 +382,7 @@ export class Device extends TypedEmitter { return this._runInner(() => Promise.resolve({}), options); } + if (TRANSPORT_ERROR.ABORTED_BY_TIMEOUT === error.message) { this.unreadableError = 'Connection timeout'; } diff --git a/packages/transport-test/.eslintrc.js b/packages/transport-test/.eslintrc.js index e224eb15eb7..8878677817c 100644 --- a/packages/transport-test/.eslintrc.js +++ b/packages/transport-test/.eslintrc.js @@ -1,5 +1,6 @@ module.exports = { rules: { 'no-nested-ternary': 'off', // useful in tests.. + 'no-console': 'off', }, }; diff --git a/packages/transport-test/e2e/api/api.test.ts b/packages/transport-test/e2e/api/api.test.ts new file mode 100644 index 00000000000..e6b9bd86867 --- /dev/null +++ b/packages/transport-test/e2e/api/api.test.ts @@ -0,0 +1,111 @@ +import { UsbApi } from '@trezor/transport/src/api/usb'; + +import { buildMessage, assertMessage, assertSuccess, assertEquals } from './utils'; +import { sharedTest, success, info, debug, error } from './shared'; + +/** + * create api for both node and browser and return it + */ +const setupApisUnderTest = async () => { + let usbInterface: ConstructorParameters[0]['usbInterface']; + + if (typeof window !== 'undefined') { + window.Buffer = (await import('buffer')).Buffer; + usbInterface = window?.navigator?.usb; + } else { + usbInterface = await import('usb').then(lib => { + return new lib.WebUSB({ allowAllDevices: true }); + }); + } + + type ApiTestFixture = { + initArgs: ConstructorParameters[0]; + description: string; + }; + const apiTestFixture: ApiTestFixture[] = []; + if (typeof window !== 'undefined') { + apiTestFixture.push({ initArgs: { usbInterface }, description: 'browser' }); + } else { + apiTestFixture.push( + { + initArgs: { usbInterface, forceReadSerialOnConnect: true }, + description: 'node, forceReadSerialOnConnect: true', + }, + { + initArgs: { usbInterface, forceReadSerialOnConnect: false }, + description: 'node, forceReadSerialOnConnect: false', + }, + ); + } + + return apiTestFixture.map(f => { + return { api: new UsbApi(f.initArgs), description: f.description }; + }); +}; + +const runTests = async () => { + const fixtures = await setupApisUnderTest(); + + for (const f of fixtures) { + info(`Running tests for ${f.description}`); + + const { api } = f; + let path: string; + + const getConnectedDevicePath = async () => { + info('getConnectedDevicePath...'); + const res = await api.enumerate(); + debug('getConnectedDevicePath: discovered devices', res); + + assertSuccess(res); + if (res.payload.length !== 1) { + throw new Error(error('Expected exactly one device to be connected')); + } + debug('getConnectedDevicePath: path set to: ', res.payload[0].path); + path = res.payload[0].path; + }; + + const pingPong = async () => { + await api.openDevice(path, true); + const writeResponse = await api.write(path, buildMessage('PING')); + debug('writeResponse', writeResponse); + assertSuccess(writeResponse); + + const readResponse = await api.read(path); + + debug('readResponse', readResponse); + assertSuccess(readResponse); + debug('readResponse', readResponse.payload.toString('hex')); + assertMessage(readResponse.payload, 'SUCCESS'); + await api.closeDevice(path); + }; + + const concurrentEnumerate = async (concurrent: number) => { + const res = await Promise.all( + new Array(concurrent).fill(undefined).map(() => api.enumerate()), + ); + res.forEach((r, index) => { + assertSuccess(r); + assertSuccess(res[0]); + if (index > 0) { + assertEquals(r.payload, res[0].payload); + } + }); + }; + + // interestingly, in node, concurrent enumeration does not work if triggered as the first interaction with connected device. + // concurrent enumeration triggered couple of lines below works correctly + await sharedTest('concurrent enumerate - as the first operation', () => + // todo: 3 doesn't work in node!!! FIX + concurrentEnumerate(typeof window === 'undefined' ? 3 : 1), + ); + await getConnectedDevicePath(); + + // here concurrent enumeration works correctly, even in node. + await sharedTest('concurrent enumerate', () => concurrentEnumerate(3)); + await sharedTest('ping pong', pingPong); + } + success('All tests passed'); +}; + +runTests(); diff --git a/packages/transport-test/e2e/api/shared.ts b/packages/transport-test/e2e/api/shared.ts new file mode 100644 index 00000000000..a55ebccc69b --- /dev/null +++ b/packages/transport-test/e2e/api/shared.ts @@ -0,0 +1,33 @@ +const log = (level: 'debug' | 'info' | 'success' | 'error', ...args: any) => { + if (typeof window !== 'undefined') { + // append logs to div class "logs" element + const logs = document.querySelector('.logs'); + if (logs) { + logs.innerHTML += `

${level === 'success' ? '✅ ' : level === 'error' ? '❌ ' : ''}${args.map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a))}

`; + } + } + console.log(args); + + return args.join(' '); +}; + +export const debug = (...args: any) => log('debug', ...args); +export const info = (...args: any) => log('info', ...args); +export const error = (...args: any) => log('error', ...args); +export const success = (...args: any) => log('success', ...args); + +export const sharedTest = async (description: string, callback: () => any) => { + const timeout = 5000; + try { + info(`🔍 ${description}`); + await Promise.race([ + callback(), + new Promise((_, reject) => + setTimeout(() => reject(`Timeout after ${timeout}`), timeout), + ), + ]); + success(description); + } catch (e) { + throw new Error(error(e)); + } +}; diff --git a/packages/transport-test/e2e/api/utils.ts b/packages/transport-test/e2e/api/utils.ts new file mode 100644 index 00000000000..cd85a2ab59d --- /dev/null +++ b/packages/transport-test/e2e/api/utils.ts @@ -0,0 +1,33 @@ +import { error } from './shared'; + +export const MAGIC = '3f232300'; + +export const MESSAGES = { + PING: '01', + SUCCESS: '02', + INITIALIZE: '00', + FEATURES: '11', +} as const; + +export const buildMessage = (message: keyof typeof MESSAGES) => { + return Buffer.from(MAGIC + MESSAGES[message], 'hex'); +}; + +export const assertMessage = (message: Buffer, expected: keyof typeof MESSAGES) => { + const assertedChunk = message.toString('hex').substring(0, MAGIC.length + 2); + if (assertedChunk !== `${MAGIC}${MESSAGES[expected]}`) { + throw new Error(error(`Expected message ${expected} but got ${assertedChunk}`)); + } +}; +export function assertSuccess(result: any): asserts result is { success: true; payload: any } { + if (!result.success) { + throw new Error(error(result.error)); + } +} +export const assertEquals = (a: any, b: any) => { + const strA = JSON.stringify(a); + const strB = JSON.stringify(b); + if (strA !== strB) { + throw new Error(error(`Expected ${strA} to be equal to ${strB}`)); + } +}; diff --git a/packages/transport-test/e2e/tests/bridge.test.ts b/packages/transport-test/e2e/bridge/bridge.test.ts similarity index 96% rename from packages/transport-test/e2e/tests/bridge.test.ts rename to packages/transport-test/e2e/bridge/bridge.test.ts index d0657a38466..0deeb3c837e 100644 --- a/packages/transport-test/e2e/tests/bridge.test.ts +++ b/packages/transport-test/e2e/bridge/bridge.test.ts @@ -1,8 +1,8 @@ import * as messages from '@trezor/protobuf/messages.json'; import { BridgeTransport } from '@trezor/transport'; -import { controller as TrezorUserEnvLink } from '../controller'; -import { pathLength, descriptor as expectedDescriptor } from '../expect'; +import { controller as TrezorUserEnvLink } from './controller'; +import { pathLength, descriptor as expectedDescriptor } from './expect'; // todo: introduce global jest config for e2e jest.setTimeout(60000); diff --git a/packages/transport-test/e2e/controller.ts b/packages/transport-test/e2e/bridge/controller.ts similarity index 100% rename from packages/transport-test/e2e/controller.ts rename to packages/transport-test/e2e/bridge/controller.ts diff --git a/packages/transport-test/e2e/expect.ts b/packages/transport-test/e2e/bridge/expect.ts similarity index 100% rename from packages/transport-test/e2e/expect.ts rename to packages/transport-test/e2e/bridge/expect.ts diff --git a/packages/transport-test/e2e/jest.config.ts b/packages/transport-test/e2e/bridge/jest.config.ts similarity index 96% rename from packages/transport-test/e2e/jest.config.ts rename to packages/transport-test/e2e/bridge/jest.config.ts index 511bf013c92..5e57f986e94 100644 --- a/packages/transport-test/e2e/jest.config.ts +++ b/packages/transport-test/e2e/bridge/jest.config.ts @@ -5,7 +5,7 @@ export const config: Config = { moduleFileExtensions: ['ts', 'js'], modulePathIgnorePatterns: ['node_modules'], watchPathIgnorePatterns: ['/libDev', '/lib'], - testPathIgnorePatterns: ['/libDev/', '/lib/', '/tests/'], + testPathIgnorePatterns: ['/libDev/', '/lib/', '/e2e/api'], transform: { '\\.(js|ts)$': [ 'babel-jest', diff --git a/packages/transport-test/e2e/tests/multi-client.test.ts b/packages/transport-test/e2e/bridge/multi-client.test.ts similarity index 98% rename from packages/transport-test/e2e/tests/multi-client.test.ts rename to packages/transport-test/e2e/bridge/multi-client.test.ts index 8054c69190b..9658e690237 100644 --- a/packages/transport-test/e2e/tests/multi-client.test.ts +++ b/packages/transport-test/e2e/bridge/multi-client.test.ts @@ -1,8 +1,8 @@ import * as messages from '@trezor/protobuf/messages.json'; import { BridgeTransport, Descriptor } from '@trezor/transport'; -import { controller as TrezorUserEnvLink } from '../controller'; -import { descriptor as fixtureDescriptor } from '../expect'; +import { controller as TrezorUserEnvLink } from './controller'; +import { descriptor as fixtureDescriptor } from './expect'; // todo: introduce global jest config for e2e jest.setTimeout(60000); diff --git a/packages/transport-test/e2e/run.ts b/packages/transport-test/e2e/bridge/run.ts similarity index 100% rename from packages/transport-test/e2e/run.ts rename to packages/transport-test/e2e/bridge/run.ts diff --git a/packages/transport-test/e2e/ui/api.test.html b/packages/transport-test/e2e/ui/api.test.html new file mode 100644 index 00000000000..f96095f95da --- /dev/null +++ b/packages/transport-test/e2e/ui/api.test.html @@ -0,0 +1,10 @@ + + + +

Webusb tester

+
connect device, pair device and reload page.
+ +
+
+ + diff --git a/packages/transport-test/package.json b/packages/transport-test/package.json index 3763dee0a67..233df6f1183 100644 --- a/packages/transport-test/package.json +++ b/packages/transport-test/package.json @@ -8,11 +8,15 @@ "depcheck": "yarn g:depcheck", "lint:js": "yarn g:eslint '**/*.{ts,tsx,js}'", "type-check": "yarn g:tsc --build", - "test:e2e": "ts-node -O '{\"module\": \"commonjs\", \"esModuleInterop\": true}' ./e2e/run.ts", - "test:e2e:old-bridge:hw": "USE_HW=true USE_NODE_BRIDGE=false yarn test:e2e", - "test:e2e:old-bridge:emu": "USE_HW=false USE_NODE_BRIDGE=false yarn test:e2e", - "test:e2e:new-bridge:hw": "USE_HW=true USE_NODE_BRIDGE=true yarn test:e2e", - "test:e2e:new-bridge:emu": "USE_HW=false USE_NODE_BRIDGE=true yarn test:e2e" + "test:e2e:bridge": "ts-node -O '{\"module\": \"commonjs\", \"esModuleInterop\": true}' ./e2e/bridge/run.ts", + "test:e2e:old-bridge:hw": "USE_HW=true USE_NODE_BRIDGE=false yarn test:e2e:bridge", + "test:e2e:old-bridge:emu": "USE_HW=false USE_NODE_BRIDGE=false yarn test:e2e:bridge", + "test:e2e:new-bridge:hw": "USE_HW=true USE_NODE_BRIDGE=true yarn test:e2e:bridge", + "test:e2e:new-bridge:emu": "USE_HW=false USE_NODE_BRIDGE=true yarn test:e2e:bridge", + "build:e2e:api:node": "yarn esbuild ./e2e/api/api.test.ts --bundle --outfile=./e2e/dist/api.test.node.js --platform=node --target=node18 --external:usb", + "build:e2e:api:browser": "yarn esbuild ./e2e/api/api.test.ts --bundle --outfile=./e2e/dist/api.test.browser.js --platform=browser --external:usb && cp e2e/ui/api.test.html e2e/dist/index.html", + "test:e2e:api:node:hw": "yarn build:e2e:api:node && node ./e2e/dist/api.test.node.js", + "test:e2e:api:browser:hw": "yarn build:e2e:api:browser && npx http-serve ./e2e/dist" }, "devDependencies": { "@jest/types": "^29.6.3", @@ -20,6 +24,8 @@ "@trezor/transport-bridge": "workspace:*", "@trezor/trezor-user-env-link": "workspace:^", "@trezor/utils": "workspace:*", + "buffer": "^6.0.3", + "esbuild": "^0.20.0", "jest": "^29.7.0", "ts-node": "^10.9.1", "usb": "^2.11.0" diff --git a/yarn.lock b/yarn.lock index 06eeb709040..0a82d1c5d2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11654,6 +11654,8 @@ __metadata: "@trezor/transport-bridge": "workspace:*" "@trezor/trezor-user-env-link": "workspace:^" "@trezor/utils": "workspace:*" + buffer: "npm:^6.0.3" + esbuild: "npm:^0.20.0" jest: "npm:^29.7.0" ts-node: "npm:^10.9.1" usb: "npm:^2.11.0"