diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml new file mode 100644 index 00000000..a5aa6328 --- /dev/null +++ b/.github/actions/setup-e2e-env/action.yml @@ -0,0 +1,29 @@ +name: Setup E2E Environment + +description: Build the CLI and caches Docker layers + +runs: + using: 'composite' + steps: + - name: Build the CLI + run: npm run build + shell: bash + + - name: Link CLI globally + run: npm link + shell: bash + + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /home/runner/.docker + key: ${{ runner.os }}-docker-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-docker- + + - name: Run emulator + shell: bash + run: | + set -e + juno emulator start --headless & + juno emulator wait diff --git a/.github/workflows/tests-screenshots.yml b/.github/workflows/tests-screenshots.yml new file mode 100644 index 00000000..768feabc --- /dev/null +++ b/.github/workflows/tests-screenshots.yml @@ -0,0 +1,32 @@ +name: Update E2E Screenshots + +on: + workflow_dispatch: + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + uses: ./.github/actions/prepare + + - name: Prepare Playwright + run: npm run e2e:playwright:install + + - name: Setup Tests Environment + uses: ./.github/actions/setup-e2e-env + + - name: Run Playwright tests + run: npm run e2e:snapshots + + - name: Commit Playwright updated screenshots + uses: EndBug/add-and-commit@v9 + if: ${{ github.ref != 'refs/heads/main' }} + with: + add: ./e2e + default_author: github_actions + message: '🤖 update tests screenshots' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..a67d23ab --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,47 @@ +name: E2E Tests + +on: + pull_request: + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + uses: ./.github/actions/prepare + + - name: Prepare Playwright + run: npm run e2e:playwright:install + + - name: Setup Tests Environment + uses: ./.github/actions/setup-e2e-env + + - name: Run tests + run: npm run e2e:ci + + - name: Upload Playwright report on failure + uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 3 + - name: Upload Playwright results on failure + uses: actions/upload-artifact@v4 + if: ${{ failure() }} + with: + name: test-results + path: test-results/ + retention-days: 3 + + may-merge: + needs: ['e2e'] + runs-on: ubuntu-latest + steps: + - name: Cleared for merging + run: echo OK diff --git a/.gitignore b/.gitignore index a5e8695b..f58e1354 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,11 @@ dist/ target/ templates/eject/rust/Cargo.lock templates/eject/rust/src/declarations -templates/eject/rust/src/satellite/satellite_extension.did \ No newline at end of file +templates/eject/rust/src/satellite/satellite_extension.did + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/e2e/constants/test-ids.constants.ts b/e2e/constants/test-ids.constants.ts new file mode 100644 index 00000000..e53ed29e --- /dev/null +++ b/e2e/constants/test-ids.constants.ts @@ -0,0 +1,18 @@ +import {TestIds} from '../types/test-id'; + +export const testIds: TestIds = { + auth: { + signIn: 'btn-sign-in' + }, + createSatellite: { + launch: 'btn-launch-satellite', + create: 'btn-create-satellite', + input: 'input-satellite-name', + website: 'input-radio-satellite-website', + application: 'input-radio-satellite-application', + continue: 'btn-continue-overview' + }, + satelliteOverview: { + visit: 'link-visit-satellite' + } +}; diff --git a/e2e/create-website.spec.ts b/e2e/create-website.spec.ts new file mode 100644 index 00000000..f411a845 --- /dev/null +++ b/e2e/create-website.spec.ts @@ -0,0 +1,19 @@ +import {testWithII} from '@dfinity/internet-identity-playwright'; +import {expect} from '@playwright/test'; +import {initTestSuite} from './utils/init.utils'; + +const getConsolePage = initTestSuite(); + +testWithII('should create a satellite for a website', async () => { + const consolePage = getConsolePage(); + + await consolePage.createSatellite({kind: 'website'}); +}); + +testWithII('should visit newly create satellite', async () => { + const consolePage = getConsolePage(); + + const satellitePage = await consolePage.visitSatellite(); + + await expect(satellitePage).toHaveScreenshot({fullPage: true}); +}); diff --git a/e2e/page-objects/console.page.ts b/e2e/page-objects/console.page.ts new file mode 100644 index 00000000..842acdd0 --- /dev/null +++ b/e2e/page-objects/console.page.ts @@ -0,0 +1,69 @@ +import {InternetIdentityPage} from '@dfinity/internet-identity-playwright'; +import {expect} from '@playwright/test'; +import type {Page} from 'playwright-core'; +import {testIds} from '../constants/test-ids.constants'; +import {IdentityPage, type IdentityPageParams} from './identity.page'; + +export class ConsolePage extends IdentityPage { + readonly #consoleIIPage: InternetIdentityPage; + + constructor(params: IdentityPageParams) { + super(params); + + this.#consoleIIPage = new InternetIdentityPage({ + page: this.page, + context: this.context, + browser: this.browser + }); + } + + async goto(): Promise { + await this.page.goto('/'); + } + + async signIn(): Promise { + this.identity = await this.#consoleIIPage.signInWithNewIdentity({ + selector: `[data-tid=${testIds.auth.signIn}]` + }); + } + + async waitReady(): Promise { + const CONTAINER_URL = 'http://127.0.0.1:5987'; + const INTERNET_IDENTITY_ID = 'rdmx6-jaaaa-aaaaa-aaadq-cai'; + + await this.#consoleIIPage.waitReady({url: CONTAINER_URL, canisterId: INTERNET_IDENTITY_ID}); + } + + async createSatellite({kind}: {kind: 'website' | 'application'}): Promise { + await expect(this.page.getByTestId(testIds.createSatellite.launch)).toBeVisible(); + + await this.page.getByTestId(testIds.createSatellite.launch).click(); + + await expect(this.page.getByTestId(testIds.createSatellite.create)).toBeVisible(); + + await this.page.getByTestId(testIds.createSatellite.input).fill('Test'); + await this.page.getByTestId(testIds.createSatellite[kind]).click(); + + await this.page.getByTestId(testIds.createSatellite.create).click(); + + await expect(this.page.getByTestId(testIds.createSatellite.continue)).toBeVisible({ + timeout: 20000 + }); + + await this.page.getByTestId(testIds.createSatellite.continue).click(); + } + + async visitSatellite(): Promise { + await expect(this.page.getByTestId(testIds.satelliteOverview.visit)).toBeVisible(); + + const satellitePagePromise = this.context.waitForEvent('page'); + + await this.page.getByTestId(testIds.satelliteOverview.visit).click(); + + const satellitePage = await satellitePagePromise; + + await expect(satellitePage).toHaveTitle('Juno / Satellite'); + + return satellitePage; + } +} diff --git a/e2e/page-objects/identity.page.ts b/e2e/page-objects/identity.page.ts new file mode 100644 index 00000000..5bfa9162 --- /dev/null +++ b/e2e/page-objects/identity.page.ts @@ -0,0 +1,25 @@ +import type {Browser, BrowserContext, Page} from '@playwright/test'; + +export interface IdentityPageParams { + page: Page; + context: BrowserContext; + browser: Browser; +} + +export abstract class IdentityPage { + protected identity: number | undefined; + + protected readonly page: Page; + protected readonly context: BrowserContext; + protected readonly browser: Browser; + + protected constructor({page, context, browser}: IdentityPageParams) { + this.page = page; + this.context = context; + this.browser = browser; + } + + async close(): Promise { + await this.page.close(); + } +} diff --git a/e2e/types/test-id.ts b/e2e/types/test-id.ts new file mode 100644 index 00000000..b0998cde --- /dev/null +++ b/e2e/types/test-id.ts @@ -0,0 +1,9 @@ +type TestCTAType = 'btn' | 'link' | 'input'; + +type TestAction = string; + +export type TestId = `${TestCTAType}-${TestAction}`; + +type TestSuite = string; + +export type TestIds = Record>; diff --git a/e2e/utils/init.utils.ts b/e2e/utils/init.utils.ts new file mode 100644 index 00000000..91a33410 --- /dev/null +++ b/e2e/utils/init.utils.ts @@ -0,0 +1,35 @@ +import {testWithII} from '@dfinity/internet-identity-playwright'; +import {ConsolePage} from '../page-objects/console.page'; + +export const initTestSuite = (): (() => ConsolePage) => { + testWithII.describe.configure({mode: 'serial'}); + + let consolePage: ConsolePage; + + testWithII.beforeAll(async ({playwright}) => { + testWithII.setTimeout(120000); + + const browser = await playwright.chromium.launch(); + + const context = await browser.newContext(); + const page = await context.newPage(); + + consolePage = new ConsolePage({ + page, + context, + browser + }); + + await consolePage.waitReady(); + + await consolePage.goto(); + + await consolePage.signIn(); + }); + + testWithII.afterAll(async () => { + await consolePage.close(); + }); + + return (): ConsolePage => consolePage; +}; diff --git a/juno.config.ts b/juno.config.ts new file mode 100644 index 00000000..90cddcb0 --- /dev/null +++ b/juno.config.ts @@ -0,0 +1,30 @@ +import {defineConfig} from '@junobuild/config'; + +export default defineConfig({ + satellite: { + ids: { + development: '', + production: '' + }, + source: 'build', + predeploy: ['npm run build'], + collections: { + datastore: [ + { + collection: 'notes', + read: 'managed', + write: 'managed', + memory: 'stable' + } + ], + storage: [ + { + collection: 'images', + read: 'managed', + write: 'managed', + memory: 'stable' + } + ] + } + } +}); diff --git a/package-lock.json b/package-lock.json index f9eb0e21..9f34f70b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,9 +41,11 @@ "juno": "dist/index.js" }, "devDependencies": { + "@dfinity/internet-identity-playwright": "^2.0.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.36.0", "@junobuild/functions": "^0.3.3", + "@playwright/test": "^1.55.1", "@types/node": "^24.5.2", "@types/prompts": "^2.4.9", "@types/semver": "^7.7.1", @@ -639,6 +641,19 @@ "@noble/hashes": "^1.8.0" } }, + "node_modules/@dfinity/internet-identity-playwright": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@dfinity/internet-identity-playwright/-/internet-identity-playwright-2.0.0.tgz", + "integrity": "sha512-8dqxxKEqNhM+dozEHFB3Dn54lXsFMjuDKy4qT4ALWypWDM61Ey1KFfvI34gyn70xWLLTLxEiWb6CrDnzJu3NGQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@playwright/test": "^1.52.0" + } + }, "node_modules/@dfinity/principal": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@dfinity/principal/-/principal-3.2.6.tgz", @@ -1670,6 +1685,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", + "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3797,6 +3828,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5242,6 +5288,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", + "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -6866,6 +6944,13 @@ "integrity": "sha512-18ecTwtz4Yv8coaNM4ooCzqlib9ooP20JFHJ2RVAtlWPaVcRC/4nzXTEJiUH+TytC7ZbBkuRYlJ/eLeIhyYqaA==", "requires": {} }, + "@dfinity/internet-identity-playwright": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@dfinity/internet-identity-playwright/-/internet-identity-playwright-2.0.0.tgz", + "integrity": "sha512-8dqxxKEqNhM+dozEHFB3Dn54lXsFMjuDKy4qT4ALWypWDM61Ey1KFfvI34gyn70xWLLTLxEiWb6CrDnzJu3NGQ==", + "dev": true, + "requires": {} + }, "@dfinity/principal": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@dfinity/principal/-/principal-3.2.6.tgz", @@ -7400,6 +7485,15 @@ "fastq": "^1.6.0" } }, + "@playwright/test": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", + "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "dev": true, + "requires": { + "playwright": "1.55.1" + } + }, "@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -8790,6 +8884,13 @@ "is-callable": "^1.2.7" } }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, "function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9681,6 +9782,22 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "playwright": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", + "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.55.1" + } + }, + "playwright-core": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "dev": true + }, "portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", diff --git a/package.json b/package.json index ad38b942..8e68863b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,12 @@ "format:check": "prettier --check .", "build": "tsc --noEmit && node ./scripts/rmdir.mjs && node ./scripts/esbuild.mjs", "lint": "eslint --max-warnings 0 \"src/**/*\"", - "prepublishOnly": "./scripts/prepublish.sh" + "prepublishOnly": "./scripts/prepublish.sh", + "e2e": "NODE_ENV=development playwright test", + "e2e:ci": "playwright test --reporter=html", + "e2e:snapshots": "playwright test --update-snapshots --reporter=list", + "e2e:report": "playwright show-report", + "e2e:playwright:install": "playwright install chromium --with-deps" }, "dependencies": { "@dfinity/agent": "^3.2.6", @@ -52,9 +57,11 @@ "zod": "^4.1.11" }, "devDependencies": { + "@dfinity/internet-identity-playwright": "^2.0.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.36.0", "@junobuild/functions": "^0.3.3", + "@playwright/test": "^1.55.1", "@types/node": "^24.5.2", "@types/prompts": "^2.4.9", "@types/semver": "^7.7.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..a6ded49c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,30 @@ +import {defineConfig, devices} from '@playwright/test'; + +const DEV = (process.env.NODE_ENV ?? 'production') === 'development'; + +export default defineConfig({ + testDir: './e2e', + snapshotDir: `./${DEV ? 'tmp' : 'e2e'}/snapshots`, + testMatch: ['**/*.e2e.ts', '**/*.spec.ts'], + fullyParallel: true, + forbidOnly: !!process.env.CI, + workers: process.env.CI ? 1 : undefined, + expect: { + toHaveScreenshot: { + animations: 'disabled', + caret: 'hide' + } + }, + use: { + testIdAttribute: 'data-tid', + baseURL: 'http://localhost:5866', + trace: 'on', + ...(DEV && {headless: false}) + }, + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']} + } + ] +});