diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml new file mode 100644 index 00000000..b0f110a6 --- /dev/null +++ b/.github/workflows/playwright.yaml @@ -0,0 +1,24 @@ +name: Playwright Tests + +on: + pull_request: + +jobs: + test-tutorials: + timeout-minutes: 20 + runs-on: ubuntu-latest + strategy: + matrix: + tutorial: + - "tests/how-to-test-contracts.spec.ts" + + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + - name: Install Dependencies + run: bun install --frozen-lockfile + - uses: actions/setup-node@v4 + - name: Install Playwright Browsers + run: bun playwright install chromium --with-deps + - name: Run test for ${{ matrix.tutorial }} + run: bun test:github ${{ matrix.tutorial }} diff --git a/.gitignore b/.gitignore index ec3ec23e..0d687db8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ logs .DS_Store .fleet .idea +tests-output +test-results +playwright-report # Local env files .env diff --git a/bun.lockb b/bun.lockb index 37e6a8cd..11c0277e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/content/tutorials/how-to-test-contracts/10.index.md b/content/tutorials/how-to-test-contracts/10.index.md index 11ba47a2..50578da8 100644 --- a/content/tutorials/how-to-test-contracts/10.index.md +++ b/content/tutorials/how-to-test-contracts/10.index.md @@ -12,7 +12,6 @@ To facilitate this process of running tests on the **ZKsync Era Test Node**, you - Node.js installed (version 14.x or later) - Either yarn or npm installed -- Initialized Hardhat TypeScript project ## Era-test-node plugin @@ -25,8 +24,20 @@ During the alpha phase, ZKsync Era Test Nodes are currently undergoing developme ### Installation +First, initialize a new Hardhat TypeScript project: + + + +```bash +npx hardhat init +``` + +Select the `Create a TypeScript project` option and install the sample project's dependencies `hardhat` and `@nomicfoundation/hardhat-toolbox`. + To install the `hardhat-zksync-node` plugin and additional necessary packages, execute the following command: + + ::code-group ```bash [npm] @@ -41,6 +52,9 @@ yarn add -D @matterlabs/hardhat-zksync-node Once installed, add the plugin at the top of your `hardhat.config.ts` file. + + ```ts [hardhat.config.ts] import "@matterlabs/hardhat-zksync-node"; ``` @@ -49,10 +63,12 @@ import "@matterlabs/hardhat-zksync-node"; You can now safely run the **ZKsync Era Test Node** with the following command: + + ::code-group -```bash [npm] -npm hardhat node-zksync +```bash [npx] +npx hardhat node-zksync ``` ```bash [yarn] @@ -61,6 +77,10 @@ yarn hardhat node-zksync :: + + + + ::callout{icon="i-heroicons-exclamation-circle"} We'll want to verify the correctness of our installations and test if we can run a **ZKsync Era Test Node**, without further use of this command in the tutorial. @@ -83,9 +103,17 @@ we can shut it down and continue with the tutorial. ### Integration with Hardhat -To enable the usage of ZKsync Era Test Node in Hardhat, add the `zksync:true` option to the hardhat network in the `hardhat.config.ts` file: +To enable the usage of ZKsync Era Test Node in Hardhat, +add the `zksync:true` option to the hardhat network in the `hardhat.config.ts` file +and the `latest` version of `zksolc`: + + -```json +```ts +zksolc: { + version: "latest", + }, networks: { hardhat: { zksync: true, @@ -100,34 +128,45 @@ it's necessary to use the `hardhat-chai-matchers` plugin. In the root directory of your project, execute this command: + + ::code-group ```bash [npm] -npm i -D @nomicfoundation/hardhat-chai-matchers chai@4.3.6 @nomiclabs/hardhat-ethers +npm i -D @nomicfoundation/hardhat-chai-matchers chai@4.3.6 @matterlabs/hardhat-zksync ``` ```bash [yarn] -yarn add -D @nomicfoundation/hardhat-chai-matchers chai@4.3.6 @nomiclabs/hardhat-ethers +yarn add -D @nomicfoundation/hardhat-chai-matchers chai@4.3.6 @matterlabs/hardhat-zksync ``` :: -After installing it, add the plugin at the top of your `hardhat.config.ts` file: +After installing it, add the plugins at the top of your `hardhat.config.ts` file: + + ```ts [hardhat.config.ts] -import "@nomicfoundation/hardhat-chai-matchers" +import "@matterlabs/hardhat-zksync"; +import "@nomicfoundation/hardhat-chai-matchers"; ``` ## Smart contract example To set up the environment for using chai matchers and writing tests, you'll need to create some contracts. -Follow these steps: -1. Navigate to the root of your project. -1. Create a folder named **contracts**. -1. Inside the **contracts** folder, create a file named **Greeter.sol**. +Inside the **contracts** folder, rename the example contract file to **Greeter.sol**. -Now we should add some code to the new **Greeter.sol** file: + + +```bash +mv contracts/Lock.sol contracts/Greeter.sol +``` + +Now replace the example contract in **Greeter.sol** with the new `Greeter` contract below: + + ```solidity [Greeter.sol] // SPDX-License-Identifier: MIT @@ -158,42 +197,50 @@ contract Greeter { With the previous steps completed, your `hardhat.config.ts` file should now be properly configured to include settings for local testing. + + ```ts [hardhat.config.ts] import { HardhatUserConfig } from "hardhat/config"; - -import "@matterlabs/hardhat-zksync" +import "@nomicfoundation/hardhat-toolbox"; +import "@matterlabs/hardhat-zksync-node"; +import "@matterlabs/hardhat-zksync"; import "@nomicfoundation/hardhat-chai-matchers"; const config: HardhatUserConfig = { + solidity: "0.8.24", zksolc: { version: "latest", }, - solidity: "0.8.19", networks: { hardhat: { zksync: true, }, }, }; + export default config; ``` -Here are the steps to create test cases with the `hardhat-chai-matchers` plugin: +Now you can create a test with the `hardhat-chai-matchers` plugin: + +Inside the `/test` folder, renamefile named `test.ts`. -1. Navigate to your project's root directory. -1. Create a new folder named `/test`. -1. Inside the `/test` folder, create a file named `test.ts`. + -Once you've completed these steps, you'll be ready to write your tests using the `hardhat-chai-matchers` plugin. +```bash +mv test/Lock.ts test/test.ts +``` + +Replace the old test with this example showcasing the functionalities of the contract: -Here's a brief example showcasing the functionalities of the contract: + ```typescript import * as hre from "hardhat"; import { expect } from "chai"; import { Wallet, Provider, Contract } from "zksync-ethers"; import { Deployer } from "@matterlabs/hardhat-zksync"; -import { ZkSyncArtifact } from "@matterlabs/hardhat-zksync/src/types"; +import { ZkSyncArtifact } from "@matterlabs/hardhat-zksync-deploy/src/types"; const RICH_PRIVATE_KEY = "0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110"; @@ -239,14 +286,16 @@ describe("Greeter", function () { Execute the following command in your terminal to run the tests: + + ::code-group -```bash [yarn] -yarn hardhat test +```bash [npx] +npx hardhat test ``` -```bash [npm] -npm hardhat test +```bash [yarn] +yarn hardhat test ``` :: diff --git a/cspell-config/cspell-zksync.txt b/cspell-config/cspell-zksync.txt index 61c5b716..d13800fa 100644 --- a/cspell-config/cspell-zksync.txt +++ b/cspell-config/cspell-zksync.txt @@ -8,6 +8,7 @@ zkcast ZKEVM zkevm zkforge +zknode zkout zksolc zkstack diff --git a/package.json b/package.json index f8c4e11a..85afd076 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "lint:eslint": "eslint .", "lint:prettier": "prettier --check .", "fix:prettier": "prettier --write .", + "test:github": "playwright test", + "test:local": "playwright test --headed", "prepare": "node .husky/install.mjs", "postinstall": "nuxt prepare", "ci:check": "bun run lint:eslint && bun run lint:prettier && bun run lint:spelling && bun run lint:markdown", @@ -27,24 +29,29 @@ "@nuxt/ui-pro": "^1.1.0", "@nuxtjs/seo": "^2.0.0-rc.10", "dayjs": "^1.11.10", + "ethers": "^6.0.0", "nuxt": "^3.11.2", "nuxt-gtag": "^2.0.6", "nuxt-headlessui": "^1.2.0", "nuxt-og-image": "^3.0.0-rc.53", "rehype-katex": "^7.0.0", "remark-math": "^6.0.0", - "vue-tsc": "^2.0.16" + "vue-tsc": "^2.0.16", + "zksync-ethers": "^6.10.0" }, "devDependencies": { "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", + "@playwright/test": "^1.45.2", "@vue/test-utils": "^2.4.5", "cspell": "^8.7.0", "eslint": "^8.57.0", + "hardhat": "^2.22.6", "husky": "^9.0.11", "lint-staged": "^15.2.4", "markdownlint": "^0.34.0", "markdownlint-cli2": "^0.13.0", + "pm2": "^5.4.2", "prettier": "^3.2.5", "prettier-eslint": "^16.3.0", "prettier-plugin-tailwindcss": "^0.5.14" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..ca3ff957 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,80 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 1 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + timeout: 10 * 60 * 1000, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'bun run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/tests/how-to-test-contracts.spec.ts b/tests/how-to-test-contracts.spec.ts new file mode 100644 index 00000000..94e29447 --- /dev/null +++ b/tests/how-to-test-contracts.spec.ts @@ -0,0 +1,16 @@ +import { test } from '@playwright/test'; +import { setupFolders, stopServers, startLocalServer } from './utils/setup'; +import { runTest } from './utils/runTest'; + +test('how-to-test-contracts-with-hardhat', async ({ page, context }) => { + // SETUP + await startLocalServer(page); + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await setupFolders('hardhat-test-example'); + + // TEST + await runTest(page, 'http://localhost:3000/tutorials/how-to-test-contracts'); + + // SHUT DOWN ANY RUNNING PROJECTS + stopServers(); +}); diff --git a/tests/utils/button.ts b/tests/utils/button.ts new file mode 100644 index 00000000..5aeafae4 --- /dev/null +++ b/tests/utils/button.ts @@ -0,0 +1,14 @@ +import { type Page } from '@playwright/test'; + +export async function clickButtonByText(page: Page, selector: string | RegExp) { + await page.locator('button').getByText(selector).click(); +} + +export async function clickCopyButton(page: Page, id: string) { + const buttonAriaLabel = 'Copy code to clipboard'; + const selector = `//*[@id='${id}']//following::button[@aria-label='${buttonAriaLabel}'][1]`; + const button = page.locator(selector); + await button.click(); + const rawText: string = await page.evaluate('navigator.clipboard.readText()'); + return rawText; +} diff --git a/tests/utils/files.ts b/tests/utils/files.ts new file mode 100644 index 00000000..c4be9197 --- /dev/null +++ b/tests/utils/files.ts @@ -0,0 +1,68 @@ +import { writeFileSync, appendFileSync, readFileSync } from 'node:fs'; +import { clickCopyButton } from './button'; +import { expect, type Page } from '@playwright/test'; +import { EOL } from 'os'; + +export async function writeToFile(page: Page, buttonName: string, filePath: string) { + const content = await clickCopyButton(page, buttonName); + writeFileSync(filePath, `${content}\n\n`); +} + +export async function modifyFile( + page: Page, + buttonName: string, + filePath: string, + addSpacesBefore?: number, + addSpacesAfter?: number, + atLine?: number, + removeLines?: string, + useSetData?: string +) { + let contentText = useSetData; + if (!contentText) { + contentText = await clickCopyButton(page, buttonName); + } + contentText = contentText.trim().replace(/\u00A0/g, ' '); + const spacesBefore = addSpacesBefore ? '\n'.repeat(addSpacesBefore) : ''; + const spacesAfter = addSpacesAfter ? '\n'.repeat(addSpacesAfter) : ''; + if (!atLine && !removeLines) { + const finalContent = spacesBefore + contentText + spacesAfter; + appendFileSync(filePath, `${finalContent}\n\n`); + } else { + const lines = readFileSync(filePath, 'utf8').split('\n'); + if (removeLines) { + const removeLinesArray = JSON.parse(removeLines); + removeLinesArray.forEach((lineNumber: string) => { + lines[Number.parseInt(lineNumber) - 1] = '~~~REMOVE~~~'; + }); + } + if (atLine) { + lines.splice(atLine - 1, 0, contentText); + } + let finalContent = lines.filter((line: string) => line !== '~~~REMOVE~~~').join('\n'); + finalContent = spacesBefore + finalContent + spacesAfter; + writeFileSync(filePath, finalContent, 'utf8'); + } +} + +export async function compareToFile(page: Page, buttonName: string, pathName: string) { + const expected = await clickCopyButton(page, buttonName); + const actual = readFileSync(pathName, { encoding: 'utf8' }); + compareOutputs(expected, actual); +} + +export function compareOutputs(expected: string, actual: string) { + const split1 = expected.trim().split(EOL); + const split2 = actual.trim().split(EOL); + expect(split1.length === split2.length).toBeTruthy(); + split1.forEach((line, i) => { + const trimmedLineA = line.trim().replace(/\u00A0/g, ' '); + const trimmedLineB = split2[i].trim().replace(/\u00A0/g, ' '); + if (trimmedLineA !== trimmedLineB) { + console.log('DIFFERENT LINES'); + console.log('LINE A:', trimmedLineA); + console.log('LINE B:', trimmedLineB); + } + expect(trimmedLineA).toEqual(trimmedLineB); + }); +} diff --git a/tests/utils/getTestActions.ts b/tests/utils/getTestActions.ts new file mode 100644 index 00000000..aba344ba --- /dev/null +++ b/tests/utils/getTestActions.ts @@ -0,0 +1,20 @@ +import { type Page, expect } from '@playwright/test'; + +export async function getTestActions(page: Page) { + const testActions = await page.$$eval('span[data-name]', (elements: Element[]) => { + return elements.map((el) => { + const dataAttributes: { + [key: string]: string; + } = {}; + const attributesArray = Array.from(el.attributes); + for (const attr of attributesArray) { + dataAttributes[attr.name] = attr.value; + } + dataAttributes['id'] = el.id; + return dataAttributes; + }); + }); + console.log('GOT TEST ACTIONS:', testActions); + expect(testActions.length).toBeGreaterThan(0); + return testActions; +} diff --git a/tests/utils/queries.ts b/tests/utils/queries.ts new file mode 100644 index 00000000..0b309b3f --- /dev/null +++ b/tests/utils/queries.ts @@ -0,0 +1,8 @@ +import { expect } from '@playwright/test'; +import { Provider } from 'zksync-ethers'; + +export async function checkIfBalanceIsZero(networkUrl: string, address: string) { + const provider = new Provider(networkUrl); + const balance = await provider.getBalance(address); + expect(balance).toBeGreaterThan(0); +} diff --git a/tests/utils/runCommand.ts b/tests/utils/runCommand.ts new file mode 100644 index 00000000..0f70ea57 --- /dev/null +++ b/tests/utils/runCommand.ts @@ -0,0 +1,79 @@ +import type { Page } from '@playwright/test'; +import { execSync } from 'node:child_process'; +import { clickCopyButton } from './button'; +import fs from 'fs'; +import { join } from 'path'; + +export async function runCommand( + page: Page, + buttonName: string, + goToFolder: string = 'tests-output', + projectFolder: string = 'hardhat-project', + preCommand?: string +) { + const copied = await clickCopyButton(page, buttonName); + console.log('COPIED', copied); + let command = copied; + const newHardhatProject = command.includes('npx hardhat init'); + + if (newHardhatProject) { + createNewHHProject(goToFolder, projectFolder); + } else { + if (preCommand) { + if (preCommand.includes('')) { + command = preCommand.replace('', copied); + } else { + command = preCommand + copied; + } + } + + if (goToFolder) { + command = `cd ${goToFolder} && ${command}`; + } + + run(command); + } +} + +function run(command: string) { + console.log('COMMAND', command); + + const commandOutput = execSync(command, { + encoding: 'utf-8', + }); + console.log('COMMAND OUTPUT', commandOutput); +} + +function createNewHHProject(goToFolder: string, projectFolder: string) { + const repoDir = 'hardhat'; + if (!fs.existsSync(join(goToFolder, repoDir))) { + const command = `cd ${goToFolder} && git clone https://github.com/NomicFoundation/hardhat.git`; + run(command); + } + const folderToCopy = 'packages/hardhat-core/sample-projects/typescript'; + + const sourceFolder = join(goToFolder, repoDir, folderToCopy); + const destinationFolder = join(goToFolder, projectFolder); + copyFolder(sourceFolder, destinationFolder); + const installCommand = `cd ${destinationFolder} && npm init -y && npm install --save-dev "hardhat@^2.22.6" "@nomicfoundation/hardhat-toolbox@^5.0.0" `; + run(installCommand); +} + +function copyFolder(source: string, destination: string) { + fs.mkdirSync(destination, { recursive: true }); + + const copyRecursive = (src: string, dest: string) => { + if (fs.statSync(src).isDirectory()) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest); + } + fs.readdirSync(src).forEach((item) => { + copyRecursive(join(src, item), join(dest, item)); + }); + } else { + fs.copyFileSync(src, dest); + } + }; + + copyRecursive(source, destination); +} diff --git a/tests/utils/runTest.ts b/tests/utils/runTest.ts new file mode 100644 index 00000000..90ef725e --- /dev/null +++ b/tests/utils/runTest.ts @@ -0,0 +1,57 @@ +import type { Page } from '@playwright/test'; + +import { runCommand } from './runCommand'; +import { getTestActions } from './getTestActions'; +import { visit } from './visit'; +import { compareToFile, modifyFile, writeToFile } from './files'; +import { checkIfBalanceIsZero } from './queries'; + +export async function runTest(page: Page, url: string) { + await visit(page, url); + console.log('GETTING TEST ACTIONS'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const steps: any[] = await getTestActions(page); + + console.log('STARTING TEST'); + for (const step of steps) { + console.log('STEP:', step); + await page.waitForTimeout(1000); + switch (step['data-name']) { + case 'runCommand': + await runCommand( + page, + step.id, + step['data-command-folder'], + step['data-project-folder'], + step['data-pre-command'] + ); + break; + case 'wait': + await page.waitForTimeout(Number.parseInt(step['data-timeout'])); + break; + case 'writeToFile': + await writeToFile(page, step.id, step['data-filepath']); + break; + case 'modifyFile': + await modifyFile( + page, + step.id, + step['data-filepath'], + Number.parseInt(step['data-add-spaces-before']), + step['data-add-spaces-after'], + Number.parseInt(step['data-at-line']), + step['data-remove-lines'], + step['data-use-set-data'] + ); + break; + case 'compareToFile': + await compareToFile(page, step.id, step['data-filepath']); + break; + case 'checkIfBalanceIsZero': + await checkIfBalanceIsZero(step['data-network-url'], step['data-address']); + break; + default: + console.log('STEP NOT FOUND:', step); + } + } +} diff --git a/tests/utils/setup.ts b/tests/utils/setup.ts new file mode 100644 index 00000000..3fc5d3ae --- /dev/null +++ b/tests/utils/setup.ts @@ -0,0 +1,44 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; +import type { Page } from '@playwright/test'; + +export async function startLocalServer(page: Page) { + console.log('STARTING...'); + await page.waitForTimeout(15000); + console.log('WAITED 15 SECONDS FOR LOCAL SERVER TO START'); +} + +export function stopServers() { + const isRunning = checkIfServersRunning(); + if (isRunning) { + console.log('STOPPING SERVERS'); + // stop & delete pm2 servers + const STOP_SERVERS = 'bun pm2 delete all'; + execSync(STOP_SERVERS, { + encoding: 'utf-8', + }); + console.log('DONE STOPPING SERVERS'); + } +} + +export function checkIfServersRunning() { + try { + const output = execSync('bun pm2 list --no-color').toString(); + return output.includes('online'); + } catch (error) { + console.error('Error checking PM2 servers:', error); + return false; + } +} + +export async function setupFolders(projectFolder: string) { + console.log('SETTING UP FOLDERS'); + fs.mkdirSync('tests-output', { recursive: true }); + const projectPath = `tests-output/${projectFolder}`; + if (fs.existsSync(projectPath)) { + await fs.promises.rm(projectPath, { + recursive: true, + force: true, + }); + } +} diff --git a/tests/utils/visit.ts b/tests/utils/visit.ts new file mode 100644 index 00000000..0363193a --- /dev/null +++ b/tests/utils/visit.ts @@ -0,0 +1,9 @@ +import type { Page } from '@playwright/test'; + +export async function visit(page: Page, pathname: string) { + console.log('GOING TO URL:', pathname); + await page.waitForTimeout(2000); + const pageFinal = await page.goto(`${pathname}`); + await page.waitForTimeout(2000); + return pageFinal; +}