diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32ced14..11e90d1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,9 +12,24 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: lts/* cache: 'npm' - run: npm ci - run: npm run lint - run: npm run check - run: npm run test + + e2e_test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + - run: npm ci + - run: npm run build + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - run: npx playwright test diff --git a/.gitignore b/.gitignore index 31e2bca..2868e80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules dist +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/package-lock.json b/package-lock.json index 5e537b2..e2cd97d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ }, "devDependencies": { "@eslint/js": "^9.8.0", + "@playwright/test": "^1.46.0", + "@types/node": "^22.1.0", "@typescript/lib-dom": "npm:@types/web@^0.0.151", "@vitejs/plugin-legacy": "^5.4.1", "eslint": "^9.8.0", @@ -2059,6 +2061,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", + "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.46.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.2.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", @@ -2393,14 +2411,13 @@ "license": "MIT" }, "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.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", + "integrity": "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==", "dev": true, - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.13.0" } }, "node_modules/@types/resolve": { @@ -5385,6 +5402,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", + "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.46.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", + "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", + "dev": true, + "license": "Apache-2.0", + "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, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -6504,12 +6568,11 @@ } }, "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==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz", + "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==", "dev": true, - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", @@ -9187,6 +9250,15 @@ "fastq": "^1.6.0" } }, + "@playwright/test": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", + "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", + "dev": true, + "requires": { + "playwright": "1.46.0" + } + }, "@rollup/plugin-node-resolve": { "version": "15.2.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", @@ -9373,14 +9445,12 @@ "dev": true }, "@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.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", + "integrity": "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==", "dev": true, - "optional": true, - "peer": true, "requires": { - "undici-types": "~5.26.4" + "undici-types": "~6.13.0" } }, "@types/resolve": { @@ -11425,6 +11495,31 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "playwright": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", + "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.46.0" + }, + "dependencies": { + "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 + } + } + }, + "playwright-core": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", + "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", + "dev": true + }, "possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -12165,12 +12260,10 @@ } }, "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, - "peer": true + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz", + "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==", + "dev": true }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", diff --git a/package.json b/package.json index ef2c654..c8b43de 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "format": "prettier . --ignore-path .gitignore --write", "postformat": "eslint . --fix", "test": "npm run check && vitest", + "e2e": "playwright test --project chromium", "check": "tsc --noEmit", "dev": "vite", "build": "vite build", @@ -19,6 +20,8 @@ }, "devDependencies": { "@eslint/js": "^9.8.0", + "@playwright/test": "^1.46.0", + "@types/node": "^22.1.0", "@typescript/lib-dom": "npm:@types/web@^0.0.151", "@vitejs/plugin-legacy": "^5.4.1", "eslint": "^9.8.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..2f84ccb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,58 @@ +import { defineConfig, devices } from '@playwright/test'; + +const webServer = false; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e', + /* 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, + use: { + baseURL: webServer ? 'http://127.0.0.1:4173' : 'https://maskable.app/', + /* 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'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: webServer + ? { + command: 'npm run preview', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + } + : undefined, +}); diff --git a/tests/e2e/export.spec.ts b/tests/e2e/export.spec.ts new file mode 100644 index 0000000..ff410fa --- /dev/null +++ b/tests/e2e/export.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Editor Export', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/editor'); + }); + + test('export selected size and max size', async ({ page }) => { + // Open export dialog + await page.getByRole('button', { name: 'Export' }).click(); + await expect(page.getByRole('dialog', { name: 'Export' })).toBeVisible(); + + // Check 128x128 in addition to the default Max size + await page.getByLabel('128x128').check(); + + const downloadMaxSize = page.waitForEvent( + 'download', + (download) => download.suggestedFilename() === 'maskable_icon.png', + ); + const download128 = page.waitForEvent( + 'download', + (download) => download.suggestedFilename() === 'maskable_icon_x128.png', + ); + await page.getByRole('button', { name: 'Download' }).click(); + + await downloadMaxSize; + await download128; + }); + + test('JSON preview shows manifest corresponding to selected checkboxes', async ({ + page, + }) => { + // Open export dialog + await page.getByRole('button', { name: 'Export' }).click(); + + const details = page.getByRole('group').filter({ hasText: 'Show JSON' }); + await expect(details).not.toHaveAttribute('open'); + + // Open JSON preview + await page.getByText('Show JSON').click(); + await expect(details).toHaveAttribute('open'); + + await expect(details).toContainText( + JSON.stringify( + [ + { + purpose: 'maskable', + sizes: '1024x1024', + src: 'maskable_icon.png', + type: 'image/png', + }, + ], + undefined, + 2, + ), + { useInnerText: true }, + ); + + // Check 128x128 and 48x48 in addition to the default Max size + await page.getByLabel('128x128').check(); + await page.getByLabel('48x48').check(); + + await expect(details).toContainText( + JSON.stringify( + [ + { + purpose: 'maskable', + sizes: '1024x1024', + src: 'maskable_icon.png', + type: 'image/png', + }, + { + purpose: 'maskable', + sizes: '48x48', + src: 'maskable_icon_x48.png', + type: 'image/png', + }, + { + purpose: 'maskable', + sizes: '128x128', + src: 'maskable_icon_x128.png', + type: 'image/png', + }, + ], + undefined, + 2, + ), + { useInnerText: true }, + ); + }); + + test('x button closes dialog', async ({ page }) => { + // Open export dialog + await page.getByRole('button', { name: 'Export' }).click(); + await expect(page.getByRole('dialog', { name: 'Export' })).toBeVisible(); + + // Close export dialog + await page.getByRole('button', { name: 'Close export dialog' }).click(); + await expect( + page.getByRole('dialog', { name: 'Export' }), + ).not.toBeVisible(); + }); + + test('Cancel button closes dialog', async ({ page }) => { + // Open export dialog + await page.getByRole('button', { name: 'Export' }).click(); + await expect(page.getByRole('dialog', { name: 'Export' })).toBeVisible(); + + // Close export dialog + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect( + page.getByRole('dialog', { name: 'Export' }), + ).not.toBeVisible(); + }); +}); diff --git a/tests/e2e/viewer.spec.ts b/tests/e2e/viewer.spec.ts new file mode 100644 index 0000000..d9c5c1d --- /dev/null +++ b/tests/e2e/viewer.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Viewer', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('default preview', async ({ page }) => { + await expect(page).toHaveTitle('Maskable.app'); + + await expect( + page.getByRole('img', { name: 'Preview of maskable icon' }), + ).toHaveAttribute('src', /demo\/spec\.svg$/); + }); + + [ + { name: 'W3C Example', file: 'spec.svg' }, + { name: 'Color Breakdown', file: 'color-breakdown.png' }, + { name: 'Insightful Energy', file: 'insightful-energy.svg' }, + { name: 'Big Island Buses', file: 'big-island-buses.png' }, + { name: 'PROXX', file: 'proxx.png' }, + { name: 'SVGOMG', file: 'svgomg.svg' }, + ].forEach(({ name, file }) => { + test(`demo preview ${name}`, async ({ page }) => { + await page.getByRole('list').getByRole('link', { name }).click(); + + const expectedSrc = new RegExp(`demo/${file.replace('.', '\\.')}$`); + + await expect( + page.getByRole('img', { name: 'Preview of maskable icon' }), + ).toHaveAttribute('src', expectedSrc); + await expect( + page.getByRole('img', { name: 'Preview of original icon' }), + ).toHaveAttribute('src', expectedSrc); + }); + }); +}); diff --git a/vite.config.js b/vite.config.js index e49551e..5de1ca7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -56,5 +56,7 @@ export default defineConfig({ ], test: { environment: 'jsdom', + include: ['tests/**/*.spec.ts'], + exclude: ['tests/e2e/**/*'], }, });