diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fbd677367..30649067a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,16 @@ jobs: run: yarn test working-directory: packages/mcp + ui: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Set up environment + uses: ./.github/actions/setup + - name: Run tests + run: yarn test + working-directory: packages/ui + build: name: build (${{ matrix.package }}, ${{ matrix.variant }}) timeout-minutes: 90 diff --git a/package.json b/package.json index f26a4e00c..4f3757537 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "lint": "eslint", "format:write": "prettier --write \"**/*\"", "format:check": "prettier --check \"**/*\"", + "test:ui": "yarn --cwd ./packages/ui test", "type:check:api": "yarn --cwd ./packages/ui type:check:api", "dev:ui": "yarn --cwd ./packages/ui dev", "dev:api": "yarn --cwd ./packages/ui dev:api", @@ -41,4 +42,4 @@ "@changesets/cli": "^2.29.2", "@changesets/changelog-github": "^0.5.1" } -} +} \ No newline at end of file diff --git a/packages/ui/ava.config.js b/packages/ui/ava.config.js new file mode 100644 index 000000000..756f31fe1 --- /dev/null +++ b/packages/ui/ava.config.js @@ -0,0 +1,6 @@ +module.exports = { + extensions: ['ts'], + require: ['./ts-node-register.cjs'], + timeout: '10m', + workerThreads: false, +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 0b90b9efd..1df072e86 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,9 +9,11 @@ "dev": "node scripts/copy-versions.mjs && rollup -c -w", "dev:api": "ENV=dev deno task dev", "start": "sirv public", - "validate": "svelte-check" + "validate": "svelte-check", + "test": "ava" }, "devDependencies": { + "ava": "^6.1.2", "@rollup/plugin-alias": "^5.0.0", "@rollup/plugin-commonjs": "^28.0.0", "@rollup/plugin-json": "^6.0.0", @@ -38,6 +40,7 @@ "svelte-preprocess": "^5.0.0", "tailwindcss": "^3.0.15", "tslib": "^2.0.0", + "ts-node": "^10.9.2", "typescript": "^5.0.0" }, "dependencies": { @@ -50,4 +53,4 @@ "tippy.js": "^6.3.1", "util": "^0.12.4" } -} +} \ No newline at end of file diff --git a/packages/ui/src/solidity/remix.test.ts b/packages/ui/src/solidity/remix.test.ts new file mode 100644 index 000000000..fce31fe7f --- /dev/null +++ b/packages/ui/src/solidity/remix.test.ts @@ -0,0 +1,66 @@ +import test from 'ava'; +import { remixURL } from './remix'; + +// Decoder used in Remix +const decodeBase64 = (b64Payload: string) => { + const raw = atob(decodeURIComponent(b64Payload)); + const bytes = Uint8Array.from(raw, c => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +}; + +test('remixURL encodes code param decodable by decodeBase64', t => { + const contractSource = 'contract A{}'; + + const url = remixURL(contractSource); + const codeParam = url.searchParams.get('code'); + t.truthy(codeParam, 'Expected code search param to be set'); + + const decoded = decodeBase64(codeParam!); + t.is(decoded, contractSource, 'Decoded code should equal original source'); +}); + +test('remixURL sets deployProxy flag when upgradeable', t => { + const contractSource = 'contract A{}'; + + const urlTrue = remixURL(contractSource, true); + t.is(urlTrue.searchParams.get('deployProxy'), 'true'); + + const urlFalse = remixURL(contractSource, false); + t.is(urlFalse.searchParams.get('deployProxy'), null); +}); + +test('remixURL encodes code param with special characters decodable by decodeBase64', t => { + // not a valid contract + const contractSource = `// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts ^5.4.0 +pragma solidity ^0.8.27; + +import {AccountERC7579} from "@openzeppelin/contracts/account/extensions/draft-AccountERC7579.sol"; +import {ERC7739} from "@openzeppelin/contracts/utils/cryptography/signers/draft-ERC7739.sol"; + +contract MyAccount is ERC7739, AccountERC7579 { + constructor(address signer) + EIP712(unicode"MyAccount🌾", "1") + SignerECDSA(signer) + {} + + function isValidSignature(bytes32 hash, bytes calldata signature) + public + view + override(AccountERC7579, ERC7739) + returns (bytes4) + { + // ERC-7739 can return the ERC-1271 magic value, 0xffffffff (invalid) or 0x77390001 (detection). + // If the returned value is 0xffffffff, fallback to ERC-7579 validation. + bytes4 erc7739magic = ERC7739.isValidSignature(hash, signature); + return erc7739magic == bytes4(0xffffffff) ? AccountERC7579.isValidSignature(hash, signature) : erc7739magic; + } +}`; + + const url = remixURL(contractSource); + const codeParam = url.searchParams.get('code'); + t.truthy(codeParam, 'Expected code search param to be set'); + + const decoded = decodeBase64(codeParam!); + t.is(decoded, contractSource, 'Decoded code should equal original source'); +}); diff --git a/packages/ui/src/solidity/remix.ts b/packages/ui/src/solidity/remix.ts index a4907e17b..e1d795bdf 100644 --- a/packages/ui/src/solidity/remix.ts +++ b/packages/ui/src/solidity/remix.ts @@ -1,11 +1,9 @@ export function remixURL(code: string, upgradeable = false, overrideRemixURL?: string): URL { const remix = new URL(overrideRemixURL ?? 'https://remix.ethereum.org'); - const codeWithEscapedSpecialCharacters = Array.from(new TextEncoder().encode(code), b => String.fromCharCode(b)).join( - '', - ); + const encodedCode = btoa(String.fromCharCode(...new TextEncoder().encode(code))).replace(/=*$/, ''); - remix.searchParams.set('code', btoa(codeWithEscapedSpecialCharacters).replace(/=*$/, '')); + remix.searchParams.set('code', encodedCode); if (upgradeable) { remix.searchParams.set('deployProxy', upgradeable.toString()); diff --git a/packages/ui/ts-node-register.cjs b/packages/ui/ts-node-register.cjs new file mode 100644 index 000000000..14302dc81 --- /dev/null +++ b/packages/ui/ts-node-register.cjs @@ -0,0 +1,9 @@ +/* eslint-disable */ + +// Configure ts-node to use a test-specific tsconfig +const path = require('path'); + +require('ts-node').register({ + transpileOnly: true, + project: path.join(__dirname, 'tsconfig.test.json'), +}); diff --git a/packages/ui/tsconfig.test.json b/packages/ui/tsconfig.test.json new file mode 100644 index 000000000..23e4b49e6 --- /dev/null +++ b/packages/ui/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "lib": [ + "es2020", + "dom", + "dom.iterable" + ] + }, + "include": [ + "src/**/*.ts" + ] +} + diff --git a/yarn.lock b/yarn.lock index 7dca4b0f8..aa364791b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1523,7 +1523,7 @@ autoprefixer@^10.4.2: picocolors "^1.1.1" postcss-value-parser "^4.2.0" -ava@^6.0.0: +ava@^6.0.0, ava@^6.1.2: version "6.4.1" resolved "https://registry.yarnpkg.com/ava/-/ava-6.4.1.tgz#89ce905d73bcd6c1d55bbba2598df3dc74e957dd" integrity sha512-vxmPbi1gZx9zhAjHBgw81w/iEDKcrokeRk/fqDTyA2DQygZ0o+dUGRHFOtX8RA5N0heGJTTsIk7+xYxitDb61Q== @@ -5936,7 +5936,7 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -ts-node@^10.4.0: +ts-node@^10.4.0, ts-node@^10.9.2: version "10.9.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==