diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..8e3bf18 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests + +on: + - push + - pull_request + +jobs: + test: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 58ad090..8a105f0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ /dist/*.mts /dist/stats.json /dist/example-assets/migrations.js +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/specs/OrejimePage.ts b/e2e/specs/OrejimePage.ts new file mode 100644 index 0000000..b900008 --- /dev/null +++ b/e2e/specs/OrejimePage.ts @@ -0,0 +1,114 @@ +import {expect, BrowserContext, Page} from '@playwright/test'; +import Cookie from 'js-cookie'; +import {Config} from '../../src/ui'; + +export class OrejimePage { + constructor( + public readonly page: Page, + public readonly context: BrowserContext + ) {} + + async load(config: Partial) { + await this.page.route('/', async (route) => { + await route.fulfill({ + body: ` + + + + + Orejime + + + + + + + + + ` + }); + }); + + await this.page.goto('/'); + } + + get banner() { + return this.page.locator('.orejime-Banner'); + } + + get leanMoreBannerButton() { + return this.page.locator('.orejime-Banner-learnMoreButton'); + } + + get firstFocusableElementFromBanner() { + return this.page.locator('.orejime-Banner :is(a, button)').first(); + } + + get modal() { + return this.page.locator('.orejime-Modal'); + } + + purposeCheckbox(purposeId: string) { + return this.page.locator(`#orejime-purpose-${purposeId}`); + } + + async focusNext() { + await this.page.keyboard.press('Tab'); + } + + async acceptAllFromBanner() { + await this.page.locator('.orejime-Banner-saveButton').click(); + } + + async declineAllFromBanner() { + await this.page.locator('.orejime-Banner-declineButton').click(); + } + + async openModalFromBanner() { + await this.leanMoreBannerButton.click(); + } + + async enableAllFromModal() { + await this.page.locator('.orejime-PurposeToggles-enableAll').click(); + } + + async disableAllFromModal() { + await this.page.locator('.orejime-PurposeToggles-disableAll').click(); + } + + async saveFromModal() { + await this.page.locator('.orejime-Modal-saveButton').click(); + } + + async closeModalByClickingButton() { + await this.page.locator('.orejime-Modal-closeButton').click(); + } + + async closeModalByClickingOutside() { + // We're clicking in a corner to avoid clicking on the + // modal itself, which has no effect. + await this.page.locator('.orejime-ModalOverlay').click({ + position: { + x: 1, + y: 1 + } + }); + } + + async closeModalByPressingEscape() { + await this.page.keyboard.press('Escape'); + } + + async expectConsents(consents: Record) { + expect(await this.getConsentsFromCookies()).toEqual(consents); + } + + async getConsentsFromCookies() { + const name = 'eu-consent'; + const cookies = await this.context.cookies(); + const {value} = cookies.find((cookie) => cookie.name === name)!; + return JSON.parse(Cookie.converter.read(value, name)); + } +} diff --git a/e2e/specs/orejime.spec.ts b/e2e/specs/orejime.spec.ts new file mode 100644 index 0000000..6fa64bd --- /dev/null +++ b/e2e/specs/orejime.spec.ts @@ -0,0 +1,169 @@ +import {test, expect} from '@playwright/test'; +import {Config} from '../../src/ui'; +import {OrejimePage} from './OrejimePage'; + +test.describe('Orejime', () => { + const BaseConfig: Partial = { + privacyPolicyUrl: 'https://example.org/privacy', + purposes: [ + { + id: 'mandatory', + title: 'Mandatory', + cookies: ['mandatory'], + isMandatory: true + }, + { + id: 'group', + title: 'Group', + purposes: [ + { + id: 'child-1', + title: 'First child', + cookies: ['child-1'] + }, + { + id: 'child-2', + title: 'Second child', + cookies: ['child-2'] + } + ] + } + ] + }; + + let orejimePage: OrejimePage; + + test.beforeEach(async ({page, context}) => { + orejimePage = new OrejimePage(page, context); + await orejimePage.load(BaseConfig); + }); + + test('should show a banner', async () => { + await expect(orejimePage.banner).toBeVisible(); + }); + + test('should navigate to the banner first', async () => { + await orejimePage.focusNext(); + await expect(orejimePage.firstFocusableElementFromBanner).toBeFocused(); + }); + + test('should accept all purposes from the banner', async () => { + await orejimePage.acceptAllFromBanner(); + await expect(orejimePage.banner).not.toBeVisible(); + + orejimePage.expectConsents({ + 'mandatory': true, + 'child-1': true, + 'child-2': true + }); + }); + + test('should decline all purposes from the banner', async () => { + await orejimePage.declineAllFromBanner(); + await expect(orejimePage.banner).not.toBeVisible(); + + orejimePage.expectConsents({ + 'mandatory': true, + 'child-1': false, + 'child-2': false + }); + }); + + test('should open a modal', async () => { + await orejimePage.openModalFromBanner(); + + await expect(orejimePage.banner).toBeVisible(); + await expect(orejimePage.modal).toBeVisible(); + }); + + test('should close the modal via the close button', async () => { + await orejimePage.openModalFromBanner(); + await expect(orejimePage.modal).toBeVisible(); + + await orejimePage.closeModalByClickingButton(); + await expect(orejimePage.modal).toHaveCount(0); + await expect(orejimePage.banner).toBeVisible(); + }); + + test('should close the modal via the overlay', async () => { + await orejimePage.openModalFromBanner(); + await expect(orejimePage.modal).toBeVisible(); + + await orejimePage.closeModalByClickingOutside(); + await expect(orejimePage.modal).toHaveCount(0); + await expect(orejimePage.banner).toBeVisible(); + }); + + test('should close the modal via `Escape` key', async () => { + await orejimePage.openModalFromBanner(); + await expect(orejimePage.modal).toBeVisible(); + + await orejimePage.closeModalByPressingEscape(); + await expect(orejimePage.modal).toHaveCount(0); + await expect(orejimePage.banner).toBeVisible(); + }); + + test('should move focus after closing the modal', async () => { + await orejimePage.openModalFromBanner(); + await expect(orejimePage.modal).toBeVisible(); + + await orejimePage.closeModalByPressingEscape(); + await expect(orejimePage.leanMoreBannerButton).toBeFocused(); + }); + + test('should accept all purposes from the modal', async () => { + await orejimePage.openModalFromBanner(); + await orejimePage.enableAllFromModal(); + await expect(orejimePage.purposeCheckbox('child-1')).toBeChecked(); + await expect(orejimePage.purposeCheckbox('mandatory')).toBeChecked(); + await orejimePage.saveFromModal(); + + orejimePage.expectConsents({ + 'mandatory': true, + 'child-1': true, + 'child-2': true + }); + }); + + test('should decline all purposes from the modal', async () => { + await orejimePage.openModalFromBanner(); + await orejimePage.enableAllFromModal(); + await orejimePage.disableAllFromModal(); + await expect(orejimePage.purposeCheckbox('child-1')).not.toBeChecked(); + await expect(orejimePage.purposeCheckbox('mandatory')).toBeChecked(); + await orejimePage.saveFromModal(); + + orejimePage.expectConsents({ + 'mandatory': true, + 'child-1': false, + 'child-2': false + }); + }); + + test('should sync grouped purposes', async () => { + await orejimePage.openModalFromBanner(); + + const checkbox = orejimePage.purposeCheckbox('child-1'); + await expect(checkbox).not.toBeChecked(); + + const checkbox2 = orejimePage.purposeCheckbox('child-2'); + await expect(checkbox2).not.toBeChecked(); + + const groupCheckbox = orejimePage.purposeCheckbox('group'); + await groupCheckbox.check(); + await expect(groupCheckbox).toBeChecked(); + await expect(checkbox).toBeChecked(); + await expect(checkbox2).toBeChecked(); + + await checkbox.uncheck(); + await expect(groupCheckbox).not.toBeChecked(); + await expect(groupCheckbox).toHaveJSProperty('indeterminate', true); + await expect(checkbox).not.toBeChecked(); + await expect(checkbox2).toBeChecked(); + + await checkbox2.uncheck(); + await expect(groupCheckbox).not.toBeChecked(); + await expect(checkbox).not.toBeChecked(); + await expect(checkbox2).not.toBeChecked(); + }); +}); diff --git a/package-lock.json b/package-lock.json index a36ad07..077097f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "js-cookie": "^3.0.1" }, "devDependencies": { + "@playwright/test": "^1.48.0", "@rspack/cli": "^1.0.3", "@rspack/core": "^1.0.3", "@swc/core": "^1.7.23", @@ -21,6 +22,7 @@ "@types/jest": "^27.5.0", "@types/js-cookie": "^3.0.2", "@types/micromodal": "^0.3.5", + "@types/node": "^22.7.5", "cross-env": "^5.2.0", "css-loader": "^0.28.11", "jest": "^28.1.3", @@ -37,8 +39,8 @@ "uneval.js": "^5.7.2" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/@ampproject/remapping": { @@ -2406,6 +2408,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.0.tgz", + "integrity": "sha512-W5lhqPUVPqhtc/ySvZI5Q8X2ztBOUgZ8LbAFy0JQgrXZs2xaILrUcNO3rQjwbLPfGK13+rZsDa1FpG+tqYkT5w==", + "dev": true, + "dependencies": { + "playwright": "1.48.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.25", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", @@ -3174,11 +3191,10 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.5.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", - "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", "dev": true, - "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } @@ -10856,6 +10872,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11114,6 +11131,50 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.0.tgz", + "integrity": "sha512-qPqFaMEHuY/ug8o0uteYJSRfMGFikhUysk8ZvAtfKmUK3kc/6oNl/y3EczF8OFGYIi/Ex2HspMfzYArk6+XQSA==", + "dev": true, + "dependencies": { + "playwright-core": "1.48.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.0.tgz", + "integrity": "sha512-RBvzjM9rdpP7UUFrQzRwR8L/xR4HyC1QXMzGYTbf1vjw25/ya9NRAVnXi/0fvFopjebvyPzsmoK58xxeEOaVvA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.45", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", @@ -13729,18 +13790,6 @@ "node": ">= 6" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -13934,43 +13983,30 @@ } }, "node_modules/react": { - "version": "16.14.0", - "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", - "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", - "license": "MIT", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "peer": true, "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2" + "loose-envify": "^1.1.0" }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "16.14.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", - "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", - "license": "MIT", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "peer": true, "dependencies": { "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.19.1" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^16.14.0" + "react": "^18.3.1" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true - }, "node_modules/readable-stream": { "version": "2.3.6", "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", @@ -14493,14 +14529,12 @@ } }, "node_modules/scheduler": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", - "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", - "license": "MIT", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "peer": true, "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "node_modules/schema-utils": { diff --git a/package.json b/package.json index bb5d9da..9ee2a24 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,10 @@ "scripts": { "start": "rspack --watch", "serve": "rspack serve", - "test": "NODE_OPTIONS=--experimental-vm-modules jest src", + "test-unit": "NODE_OPTIONS=--experimental-vm-modules jest src", + "test-e2e": "playwright test", + "test-e2e-ui": "npm run test-e2e -- --ui", + "test": "npm run test-unit && npm run test-e2e", "clean-build": "shx rm -Rf ./lib ./es ./dist/*.js ./dist/*.css ./dist/*.scss ./dist/stats.json", "build-commonjs": "tsc -p ./build/tsconfig.commonjs.json", "build-module": "tsc -p ./build/tsconfig.module.json", @@ -53,6 +56,7 @@ "js-cookie": "^3.0.1" }, "devDependencies": { + "@playwright/test": "^1.48.0", "@rspack/cli": "^1.0.3", "@rspack/core": "^1.0.3", "@swc/core": "^1.7.23", @@ -61,6 +65,7 @@ "@types/jest": "^27.5.0", "@types/js-cookie": "^3.0.2", "@types/micromodal": "^0.3.5", + "@types/node": "^22.7.5", "cross-env": "^5.2.0", "css-loader": "^0.28.11", "jest": "^28.1.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..ae4320b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,29 @@ +import {defineConfig, devices} from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://127.0.0.1:3000', + trace: 'on-first-retry' + }, + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']} + }, + { + name: 'firefox', + use: {...devices['Desktop Firefox']} + } + ], + webServer: { + command: 'npm run serve', + url: 'http://127.0.0.1:3000', + reuseExistingServer: !process.env.CI + } +});