diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cc43139d..efdefd71 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,6 +40,17 @@ jobs: - run: pnpm build + - uses: actions/cache@v4 + id: pw-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + - if: steps.pw-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install chromium + # OS deps are not cacheable; the runner image must not be relied on. + - run: pnpm exec playwright install-deps chromium + - run: pnpm exec playwright test + - uses: actions/upload-pages-artifact@v5.0.0 with: path: dist diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0bd30e23..9d774c2f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,3 +36,14 @@ jobs: - run: pnpm test - run: pnpm build + + - uses: actions/cache@v4 + id: pw-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + - if: steps.pw-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install chromium + # OS deps are not cacheable; the runner image must not be relied on. + - run: pnpm exec playwright install-deps chromium + - run: pnpm exec playwright test diff --git a/.gitignore b/.gitignore index a527ae05..30833808 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,7 @@ tmp/ CLAUDE.md *.tar.gz + +# playwright +test-results/ +playwright-report/ diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 00000000..01a05849 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,57 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** Guards the one otherwise-unwatched class: the production bundle in a real + * browser. DOM-only; canvas interactions are out of scope (brittle). */ + +// Non-default dimensions (default label is 100x60), so the resize assertion +// actually proves ^PW/^LL were applied. +const SAMPLE_ZPL = '^XA\n^PW640\n^LL320\n^FO50,50^A0N,30,0^FDHello World^FS\n^XZ'; + +const pageErrors: Error[] = []; + +test.beforeEach(({ page }) => { + pageErrors.length = 0; + page.on('pageerror', (e) => pageErrors.push(e)); +}); + +const openFileMenu = async (page: Page) => { + await page.getByRole('button', { name: 'File', exact: true }).click(); +}; + +test('boots, imports ZPL, adds a page, and regenerates output', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('button', { name: 'File', exact: true })).toBeVisible(); + + await openFileMenu(page); + await page.getByRole('button', { name: 'Import ZPL' }).click(); + await page.getByRole('textbox').fill(SAMPLE_ZPL); + await page.getByRole('button', { name: 'Import', exact: true }).click(); + // A clean import closes the dialog; a summary/choice view staying open + // would mean the sample produced findings. + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(page.getByText('80 × 40 mm')).toBeVisible(); + + // The output panel defaults to collapsed. + await page.getByRole('button', { name: 'Expand' }).click(); + const output = page.locator('pre').first(); + await expect(output).toContainText('^FDHello World'); + await expect(output).toContainText('^PW640'); + + await openFileMenu(page); + await page.getByRole('button', { name: 'Add page' }).click(); + // Focus moves onto the inserted page (2 / 2) and it starts empty: exactly + // two pages, and the imported field exactly once (a duplicated or in-place + // mutated page would double it or keep the pager on 1). + await expect(page.getByText('2 / 2')).toBeVisible(); + await expect + .poll(async () => { + const text = (await page.locator('pre').allInnerTexts()).join(''); + return { + pages: text.split('^XA').length - 1, + fields: text.split('^FDHello World').length - 1, + }; + }) + .toEqual({ pages: 2, fields: 1 }); + + expect(pageErrors, pageErrors.map((e) => e.message).join('\n')).toHaveLength(0); +}); diff --git a/package.json b/package.json index 87cfcf2e..8def6539 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "dev": "vite", - "build": "tsc -b && tsc -p tsconfig.test.json --noEmit && tsc -p packages/mcp-server/tsconfig.json --noEmit && vite build", + "build": "tsc -b && tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.e2e.json && tsc -p packages/mcp-server/tsconfig.json --noEmit && vite build", "typecheck:test": "tsc -p tsconfig.test.json --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -21,11 +21,11 @@ "coverage:gen": "node scripts/gen-coverage.mjs", "coverage:check": "node scripts/gen-coverage.mjs --check", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "e2e": "pnpm build && playwright test" }, "dependencies": { "@dnd-kit/core": "^6.3.1", - "@zplab/core": "workspace:*", "@dnd-kit/sortable": "^10.0.0", "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", @@ -37,6 +37,7 @@ "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@zplab/core": "workspace:*", "bwip-js": "catalog:", "fflate": "catalog:", "konva": "^10.3.0", @@ -52,6 +53,7 @@ "@babel/core": "^8.0.1", "@eslint/js": "^10.0.1", "@napi-rs/canvas": "^1.0.2", + "@playwright/test": "^1.62.0", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4.3.3", "@tauri-apps/cli": "^2.11.4", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..e277add5 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: 'e2e', + timeout: 30_000, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + // The app boots in navigator.language; the spec selects English UI text. + locale: 'en-US', + trace: 'retain-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'pnpm preview --port 4173 --strictPort --host 127.0.0.1', + url: 'http://127.0.0.1:4173', + // Never adopt a foreign/stale server; strictPort makes a busy port loud. + reuseExistingServer: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec56d5f0..ac48f741 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@napi-rs/canvas': specifier: ^1.0.2 version: 1.0.2 + '@playwright/test': + specifier: ^1.62.0 + version: 1.62.0 '@rolldown/plugin-babel': specifier: ^0.2.3 version: 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) @@ -854,6 +857,11 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + '@react-aria/focus@3.22.1': resolution: {integrity: sha512-CPxtkyrBi/HYY5P3lE/57sQ6qfa0lN8E55TOm89H0kNGv0lKt+/0zP7lWERzBjRr5IxBVrQX4gFEowBN52LPaA==} peerDependencies: @@ -1764,6 +1772,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2246,6 +2259,16 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -3282,6 +3305,10 @@ snapshots: '@oxc-project/types@0.139.0': {} + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + '@react-aria/focus@3.22.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@swc/helpers': 0.5.23 @@ -4177,6 +4204,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4564,6 +4594,14 @@ snapshots: pkce-challenge@5.0.1: {} + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + pngjs@7.0.0: {} postcss@8.5.22: diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 00000000..71357272 --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["e2e", "playwright.config.ts"] +}