diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..d851393 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,25 @@ +name: Tram-Deco Playwright Tests + +on: push + +jobs: + test: + timeout-minutes: 15 + 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: npm run test:ci + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 653de15..a6d0a82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ # Dependency Directory node_modules -# cypress artifacts -cypress - # minified output *.min.js + +# playwrite +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/cypress.config.js b/cypress.config.js deleted file mode 100644 index 7534196..0000000 --- a/cypress.config.js +++ /dev/null @@ -1,10 +0,0 @@ -const { defineConfig } = require('cypress'); - -module.exports = defineConfig({ - e2e: { - specPattern: '**.cy.js', - supportFile: false, - includeShadowDom: true, - experimentalWebKitSupport: true, - }, -}); diff --git a/example/example.spec.js b/example/example.spec.js new file mode 100644 index 0000000..5add724 --- /dev/null +++ b/example/example.spec.js @@ -0,0 +1,76 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const path = require('path'); + +const getTextContent = async (element) => { + return await element.evaluate((el) => el.textContent); +}; + +test.describe('Tram-Deco Example Components', () => { + test('should validate all Tram-Deco APIs and Use Cases', async ({ page }) => { + // Construct the absolute file path and use the file:// protocol + const filePath = path.resolve(__dirname, '../example/index.html'); + await page.goto(`file://${filePath}`); + + // validate that the document title is set + await expect(page).toHaveTitle('Tram-Deco is Cool!'); + + // validate that the title shadowDOM is rendered as expected + const customTitle = page.locator('custom-title'); + await expect(customTitle.locator('h1')).toBeVisible(); + const renderedText = await getTextContent(customTitle); + expect(renderedText).toBe('Tram-Deco is Cool!'); + + // validate that the callout-alert can be collapsed and expanded + const calloutAlert = page.locator('callout-alert'); + await expect(calloutAlert).toHaveAttribute('collapsed', ''); + await expect(calloutAlert.locator('button')).toHaveText('expand'); + await expect(calloutAlert.locator('#content')).not.toBeVisible(); + await calloutAlert.locator('button').click(); + await expect(calloutAlert.locator('button')).toHaveText('collapse'); + await expect(calloutAlert.locator('#content')).toBeVisible(); + await expect(calloutAlert).not.toHaveAttribute('collapsed', ''); + + // validate that the individual counters can be incremented + const counterA = page.locator('my-counter#a'); + await expect(counterA).toHaveAttribute('count', '0'); + await expect(counterA.locator('button')).toHaveText('Counter: 0'); + await counterA.click(); + await expect(counterA).toHaveAttribute('count', '1'); + await expect(counterA.locator('button')).toHaveText('Counter: 1'); + + const counterB = page.locator('my-counter#b'); + await expect(counterB).toHaveAttribute('count', '12'); + await expect(counterB.locator('button')).toHaveText('Counter: 12'); + + // validate that exported components are rendered as expected + const spoilerTag = page.locator('spoiler-tag'); + await expect(spoilerTag.locator('[aria-hidden="true"]')).toBeVisible(); + await spoilerTag.click(); + await expect(spoilerTag.locator('[aria-hidden="false"]')).toBeVisible(); + + // validate that button that implements a shadow DOM from a parent with none works as expected + const removableButton = page.locator('red-removable-button#r'); + await expect(removableButton).toBeVisible(); + await removableButton.click(); + await expect(removableButton).not.toBeVisible(); + + // validate that extended counters with different shadow DOM work as expected + const redCounter = page.locator('my-red-counter#d'); + await expect(redCounter).toHaveAttribute('count', '10'); + await redCounter.click(); + await expect(redCounter).toHaveAttribute('count', '11'); + + // validate that extended counters with nothing different work as expected + const copiedCounter = page.locator('my-copied-counter#c'); + await expect(copiedCounter).toHaveAttribute('count', '15'); + await copiedCounter.click(); + await expect(copiedCounter).toHaveAttribute('count', '16'); + + // validate that extended counters with different callbacks work as expected + const decrementingCounter = page.locator('my-decrementing-counter#e'); + await expect(decrementingCounter).toHaveAttribute('count', '5'); + await decrementingCounter.click(); + await expect(decrementingCounter).toHaveAttribute('count', '4'); + }); +}); diff --git a/example/spec.cy.js b/example/spec.cy.js deleted file mode 100644 index ad2090d..0000000 --- a/example/spec.cy.js +++ /dev/null @@ -1,64 +0,0 @@ -describe('Tram-Deco Example Components', () => { - // per Cypress best practices (https://docs.cypress.io/guides/references/best-practices#Creating-Tiny-Tests-With-A-Single-Assertion) - // it is often better to run all tests together, rather than having unit-like tests... so we'll comment the intent of each test, - // rather than doing a reset between each test. The results should still be just as obvious in the cypress runner! - it('should validate all Tram-Deco APIs and Use Cases', () => { - // visit index.html (this works because the test page doesn't need to be hosted to work!) - cy.visit('../example/index.html'); - - /* validate that the document title is set - (side-effect of td-method="connectedCallback") */ - cy.title().should('eq', 'Tram-Deco is Cool!'); - - /* validate that the title shadowDOM is rendered as expected */ - cy.get('custom-title').shadow().find('h1').should('exist'); - cy.get('custom-title').should('have.text', 'Tram-Deco is Cool!'); - - /* validate that the callout-alert can be collapsed and expanded - (side-effect of td-property="observedAttributes" and td-method="attributeChangedCallback") */ - cy.get('callout-alert').should('have.attr', 'collapsed'); - cy.get('callout-alert').shadow().find('button').should('have.text', 'expand'); - cy.get('callout-alert').shadow().find('#content').should('not.be.visible'); - cy.get('callout-alert').shadow().find('button').click(); - cy.get('callout-alert').shadow().find('button').should('have.text', 'collapse'); - cy.get('callout-alert').shadow().find('#content').should('be.visible'); - cy.get('callout-alert').should('not.have.attr', 'collapsed'); - - /* validate that the individual counters can be incremented - (similar to previous example, testing observed attributes and attributeChangedCallback) */ - cy.get('my-counter#a').should('have.attr', 'count', '0'); - cy.get('my-counter#a').shadow().find('button').should('have.text', 'Counter: 0'); - cy.get('my-counter#a').click(); - cy.get('my-counter#a').should('have.attr', 'count', '1'); - cy.get('my-counter#a').shadow().find('button').should('have.text', 'Counter: 1'); - cy.get('my-counter#b').should('have.attr', 'count', '12'); - cy.get('my-counter#b').shadow().find('button').should('have.text', 'Counter: 12'); - - /* validate that exported components are rendered as expected */ - cy.get('spoiler-tag').shadow().find('[aria-hidden="true"]').should('exist'); - cy.get('spoiler-tag').click(); - cy.get('spoiler-tag').shadow().find('[aria-hidden="false"]').should('exist'); - - /* validate that button that implements a shadow DOM from a parent with none works as expected */ - cy.get('red-removable-button#r').should('exist'); - cy.get('red-removable-button#r').click(); - cy.get('red-removable-button#r').should('not.exist'); - - /* validate that extended counters with different shadow DOM work as expected */ - cy.get('my-red-counter#d').should('have.attr', 'count', '10'); - cy.get('my-red-counter#d').click(); - cy.get('my-red-counter#d').should('have.attr', 'count', '11'); - - /* validate that extended counters with nothing different work as expected */ - cy.get('my-copied-counter#c').should('have.attr', 'count', '15'); - // position: top to resolve issue with elementsFromPoint resolution issue with web-components - // see: https://github.com/cypress-io/cypress/issues/19260 - cy.get('my-copied-counter#c').click({ position: 'top' }); - cy.get('my-copied-counter#c').should('have.attr', 'count', '16'); - - /* validate that extended counters with different callbacks work as expected */ - cy.get('my-decrementing-counter#e').should('have.attr', 'count', '5'); - cy.get('my-decrementing-counter#e').click({ position: 'top' }); - cy.get('my-decrementing-counter#e').should('have.attr', 'count', '4'); - }); -}); diff --git a/package-lock.json b/package-lock.json index 5c47dbf..d0917b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "export-components": "scripts/export-components.js" }, "devDependencies": { + "@playwright/test": "^1.46.1", + "@types/node": "^22.5.0", "cypress": "^13.10.0", "playwright-webkit": "^1.46.1", "prettier": "^3.2.4", @@ -77,14 +79,28 @@ "ms": "^2.1.1" } }, + "node_modules/@playwright/test": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.1.tgz", + "integrity": "sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==", + "dev": true, + "dependencies": { + "playwright": "1.46.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { - "version": "20.11.16", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", - "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", + "version": "22.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.0.tgz", + "integrity": "sha512-DkFrJOe+rfdHTqqMg0bSNlGlQ85hSoh2TPzZyhHsXnMtligRWpxUySiyw8FY14ITt24HVCiQPWxS3KO/QlGmWg==", "dev": true, - "optional": true, "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/sinonjs__fake-timers": { @@ -1236,6 +1252,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "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/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1981,6 +2011,24 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.1.tgz", + "integrity": "sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==", + "dev": true, + "dependencies": { + "playwright-core": "1.46.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, "node_modules/playwright-core": { "version": "1.46.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.1.tgz", @@ -2592,11 +2640,10 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "optional": true + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true }, "node_modules/universalify": { "version": "2.0.1", diff --git a/package.json b/package.json index e30b673..dcabf5b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "build": "uglifyjs tram-deco.js -o tram-deco.min.js -c -m", "test-export": "node scripts/export-components.js example/spoiler-tag.html -o example/spoiler-tag.js", "pretest": "npm run build && npm run test-export", - "test": "cypress open" + "test": "playwright test --ui", + "test:ci": "playwright test" }, "keywords": [], "author": { @@ -27,6 +28,8 @@ }, "license": "MIT", "devDependencies": { + "@playwright/test": "^1.46.1", + "@types/node": "^22.5.0", "cypress": "^13.10.0", "playwright-webkit": "^1.46.1", "prettier": "^3.2.4", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..b5be375 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,78 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config({ path: path.resolve(__dirname, '.env') }); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: './', + /* 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 ? 2 : 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', + }, + + /* 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: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +});