Add Playwright E2E testing framework (#106)

# Add End-to-End Testing with Playwright

This PR adds comprehensive end-to-end testing capabilities using Playwright to ensure the application's core functionality works as expected.

## Key Changes:

- Implemented Playwright for automated browser testing
- Added test scripts for core functionality:
  - QR code creation and customization
  - QR code scanning from file uploads
  - Config saving and loading
  - Batch export functionality
  - Visual regression testing with snapshots

## Testing Details:

- Tests verify that:
  - QR codes can be created with various settings
  - Generated QR codes can be scanned correctly
  - Configuration can be saved and loaded
  - Batch export works with CSV files
  - UI elements behave correctly (disabled/enabled states)

## Development Notes:

- Added `test:e2e` npm script to run the tests
- Updated `.gitignore` to exclude Playwright test results
- Added detailed testing documentation to CONTRIBUTING.md
- Included test fixtures and baseline snapshots for visual regression testing

The tests provide a safety net for future development and help ensure the application remains stable as new features are added.
This commit is contained in:
Estee Tey 2025-04-05 21:55:39 +08:00 committed by GitHub
parent c2e4863efa
commit 9150dd785e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 442 additions and 1 deletions

6
.gitignore vendored
View File

@ -28,4 +28,8 @@ coverage
*.sw?
dev-dist
.env
.env
# Playwright
test-results/
playwright-report/

View File

@ -44,6 +44,40 @@ Don't touch the other components in `src/components/` as they are generated by r
- Presets are found in `src/utils/presets.ts`. For adding new presets, you can refer to the existing ones and fill in the necessary properties.
- Assets are found in `src/assets/`.
## End-to-End (E2E) Testing
This project uses [Playwright](https://playwright.dev/) for end-to-end testing.
**Running Tests:**
To run all E2E tests, use the following command:
```bash
pnpm test:e2e
```
**Writing Tests:**
- Tests are located in the `tests/e2e` directory.
- Use clear and descriptive test names.
- Utilize Playwright's locators (e.g., `getByRole`, `getByLabel`, `getByText`, IDs) for selecting elements robustly.
- Avoid relying on brittle selectors like complex CSS paths or auto-generated class names.
- Use `test.beforeEach` to set up common preconditions for tests within a file (e.g., navigating to the page, clearing local storage).
- Prefer testing file uploads over camera interactions for scanning tests, as camera access can be inconsistent in test environments.
- Use snapshot testing (`toHaveScreenshot`) for verifying visual elements like the generated QR code. Remember to commit the baseline snapshot files located in the `tests/e2e/*.spec.ts-snapshots` directory.
**Debugging Failed Tests:**
- Playwright automatically generates an HTML report (`playwright-report/index.html`) after each run.
- For failed tests, the report includes:
- Detailed error messages and stack traces.
- Screenshots taken at the point of failure (configured in `playwright.config.ts`).
- Trace files (`*.zip` in `test-results/`) which can be viewed with `pnpm exec playwright show-trace <trace-file.zip>` for a step-by-step replay.
**Limitations:**
- **Zip File Verification:** While tests verify that batch exports download a zip file, they do not automatically verify the _contents_ (e.g., number of images) of the zip file due to browser sandbox limitations. This requires manual inspection or a separate post-test script if full automation is needed.
## Adding new presets
An easy way to add a new preset is to create the QR code on the website first, and then save the config. The config JSON file will look something like this:

BIN
exported_qr_code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test:e2e": "playwright test",
"type-check": "vue-tsc --noEmit",
"lint": "eslint --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore --ignore-pattern \"*.json\"",
"format": "prettier --write",
@ -46,6 +47,7 @@
"vue-i18n": "^9.14.2"
},
"devDependencies": {
"@playwright/test": "^1.51.1",
"@rushstack/eslint-patch": "^1.10.5",
"@types/dom-to-image": "^2.6.7",
"@types/node": "^20.17.23",

56
playwright.config.ts Normal file
View File

@ -0,0 +1,56 @@
import { defineConfig, devices } from '@playwright/test'
/**
* 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,
/* 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://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Capture screenshot only when a test fails. */
screenshot: 'only-on-failure'
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
stderr: 'pipe'
}
})

38
pnpm-lock.yaml generated
View File

@ -81,6 +81,9 @@ importers:
specifier: ^9.14.2
version: 9.14.2(vue@3.5.13(typescript@5.8.2))
devDependencies:
'@playwright/test':
specifier: ^1.51.1
version: 1.51.1
'@rushstack/eslint-patch':
specifier: ^1.10.5
version: 1.10.5
@ -1001,6 +1004,11 @@ packages:
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.51.1':
resolution: {integrity: sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==}
engines: {node: '>=18'}
hasBin: true
'@rollup/plugin-babel@5.3.1':
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
engines: {node: '>= 10.0.0'}
@ -1890,6 +1898,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
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}
@ -2513,6 +2526,16 @@ packages:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
playwright-core@1.51.1:
resolution: {integrity: sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==}
engines: {node: '>=18'}
hasBin: true
playwright@1.51.1:
resolution: {integrity: sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@ -4161,6 +4184,10 @@ snapshots:
'@pkgr/core@0.1.1': {}
'@playwright/test@1.51.1':
dependencies:
playwright: 1.51.1
'@rollup/plugin-babel@5.3.1(@babel/core@7.26.9)(rollup@2.79.2)':
dependencies:
'@babel/core': 7.26.9
@ -5195,6 +5222,9 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -5775,6 +5805,14 @@ snapshots:
pirates@4.0.6: {}
playwright-core@1.51.1: {}
playwright@1.51.1:
dependencies:
playwright-core: 1.51.1
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.1.0: {}
postcss-import@15.1.0(postcss@8.5.3):

24
tests/e2e/app.spec.ts Normal file
View File

@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test'
test('has title', async ({ page }) => {
await page.goto('/')
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Mini QR/)
})
test('scans a QR code from file', async ({ page }) => {
await page.goto('/')
// Switch to Scan mode - select the first matching element (likely the desktop one)
await page.getByLabel('Switch to Scan Mode').first().click()
// Locate the hidden file input
const fileInput = page.locator('input[type="file"][accept="image/*"]')
// Set the input file using the fixture
await fileInput.setInputFiles('tests/e2e/fixtures/test-qrcode.png')
// Wait for the result text to appear
await expect(page.getByText('Test QR Data')).toBeVisible()
})

283
tests/e2e/create.spec.ts Normal file
View File

@ -0,0 +1,283 @@
import { test, expect } from '@playwright/test'
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'
// Recreate __dirname and __filename for ES Modules
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
test.beforeEach(async ({ page }) => {
// Go to the page before each test
await page.goto('/')
// Clear localStorage to prevent state from previous tests
await page.evaluate(() => localStorage.clear())
// Reload the page to apply the cleared storage (optional but safer)
await page.reload()
// Make sure we are in Create mode (though it's the default after reload)
const createModeButton = page.getByLabel('Switch to Create Mode').first()
await expect(createModeButton).toHaveClass(/bg-white/) // Or check another attribute indicating selection
// Clear the data input field to ensure a clean state
const textInput = page.locator('textarea[id="data"]')
await textInput.fill('')
await expect(textInput).toHaveValue('') // Verify it's empty
})
test('creates a QR code', async ({ page }) => {
// Find the text area and fill it.
const textInput = page.locator('textarea[id="data"]')
await textInput.fill('Hello Playwright!')
// Check if the QR code image is visible using its role and name.
const qrCodeImage = page.getByRole('img', { name: 'QR code' })
await expect(qrCodeImage).toBeVisible()
})
test('copy and export buttons are disabled when data is empty', async ({ page }) => {
// Check initial state (data should be empty)
const textInput = page.locator('textarea[id="data"]')
await expect(textInput).toHaveValue('')
// Check buttons are disabled
await expect(page.locator('#copy-qr-image-button')).toBeDisabled()
await expect(page.locator('#download-qr-image-button-png')).toBeDisabled()
await expect(page.locator('#download-qr-image-button-jpg')).toBeDisabled()
await expect(page.locator('#download-qr-image-button-svg')).toBeDisabled()
// Enter some data
await textInput.fill('Test Data')
// Check buttons are now enabled
await expect(page.locator('#copy-qr-image-button')).toBeEnabled()
await expect(page.locator('#download-qr-image-button-png')).toBeEnabled()
await expect(page.locator('#download-qr-image-button-jpg')).toBeEnabled()
await expect(page.locator('#download-qr-image-button-svg')).toBeEnabled()
})
test('save and load QR code config works', async ({ page }) => {
const testData = 'Config Test Data'
const textInput = page.locator('textarea[id="data"]')
const dotsColorInput = page.locator('#dots-color') // Corrected ID
const initialColor = await dotsColorInput.inputValue()
const newColor = '#ff0000'
// Set initial data and change color
await textInput.fill(testData)
await dotsColorInput.fill(newColor)
await expect(dotsColorInput).toHaveValue(newColor)
// Start waiting for the download before clicking the button
const downloadPromise = page.waitForEvent('download')
await page.locator('#save-qr-code-config-button').click()
const download = await downloadPromise
// Ensure the test-results directory exists
const resultsDir = path.resolve(__dirname, '../../test-results')
if (!fs.existsSync(resultsDir)) {
fs.mkdirSync(resultsDir, { recursive: true })
}
// Save the downloaded file path
const configPath = path.join(resultsDir, download.suggestedFilename())
await download.saveAs(configPath)
// Clear the input and reset color (or reload the page)
await textInput.fill('')
await dotsColorInput.fill(initialColor) // Reset color to simulate loading
await expect(textInput).toHaveValue('')
await expect(dotsColorInput).toHaveValue(initialColor)
// Locate the load button and set the input file
const loadConfigButton = page.locator('#load-qr-code-config-button')
// Playwright needs a file chooser listener BEFORE the click that triggers it
const fileChooserPromise = page.waitForEvent('filechooser')
await loadConfigButton.click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(configPath)
// Verify data and color are restored
await expect(textInput).toHaveValue(testData)
await expect(dotsColorInput).toHaveValue(newColor)
})
test('save and load QR code config works with frame', async ({ page }) => {
const testData = 'Frame Config Test'
const frameTextData = 'Scan Me!'
const textInput = page.locator('textarea[id="data"]')
const frameAccordionTrigger = page.getByRole('button', { name: /Frame settings/ })
const showFrameCheckbox = page.locator('input[id="show-frame"]')
const frameTextInput = page.locator('textarea[id="frame-text"]')
const framePositionTopRadio = page.locator('input[id="frameTextPosition-top"]')
// Expand the Frame settings accordion
await frameAccordionTrigger.click()
// Set initial data, enable frame, set text, and change position
await textInput.fill(testData)
await showFrameCheckbox.check()
await frameTextInput.fill(frameTextData)
await framePositionTopRadio.check() // Change from default bottom to top
// Verify initial set state
await expect(showFrameCheckbox).toBeChecked()
await expect(frameTextInput).toHaveValue(frameTextData)
await expect(framePositionTopRadio).toBeChecked()
// Save config
const downloadPromise = page.waitForEvent('download')
await page.locator('#save-qr-code-config-button').click()
const download = await downloadPromise
const configPath = './test-results/' + download.suggestedFilename()
await download.saveAs(configPath)
// Clear inputs and reset frame settings
await textInput.fill('')
await showFrameCheckbox.uncheck()
// frameTextInput and framePositionTopRadio might be hidden now, no need to explicitly clear/reset
// Verify cleared state (frame checkbox is unchecked)
await expect(textInput).toHaveValue('')
await expect(showFrameCheckbox).not.toBeChecked()
// Load config
const loadConfigButton = page.locator('#load-qr-code-config-button')
const fileChooserPromise = page.waitForEvent('filechooser')
await loadConfigButton.click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(configPath)
// Ensure accordion is open again after load if needed (config load might close it)
// Click trigger only if checkbox isn't visible after loading
if (!(await showFrameCheckbox.isVisible())) {
await frameAccordionTrigger.click()
}
// Verify data and frame settings are restored
await expect(textInput).toHaveValue(testData)
await expect(showFrameCheckbox).toBeChecked()
await expect(frameTextInput).toHaveValue(frameTextData)
await expect(framePositionTopRadio).toBeChecked() // Check if the non-default position was restored
})
test('QR code element with frame matches snapshot', async ({ page }) => {
const testData = 'Frame Snapshot Test'
const frameTextData = 'Scan Frame!'
const textInput = page.locator('textarea[id="data"]')
const frameAccordionTrigger = page.getByRole('button', { name: /Frame settings/ })
const showFrameCheckbox = page.locator('input[id="show-frame"]')
const frameTextInput = page.locator('textarea[id="frame-text"]')
// Expand the Frame settings accordion
await frameAccordionTrigger.click()
// Set data, enable frame, and add frame text
await textInput.fill(testData)
await showFrameCheckbox.check()
await frameTextInput.fill(frameTextData)
// Locate the element containing the QR code and frame
const qrCodeExportElement = page.locator('#element-to-export')
// Ensure the element is visible before taking a snapshot
await expect(qrCodeExportElement).toBeVisible()
// Take a snapshot of the QR code element with the frame
await expect(qrCodeExportElement).toHaveScreenshot('qr-code-with-frame-snapshot.png')
})
test('QR code element matches snapshot', async ({ page }) => {
const testData = 'Snapshot Test'
const textInput = page.locator('textarea[id="data"]')
await textInput.fill(testData)
// Locate the element containing the QR code to be exported
const qrCodeExportElement = page.locator('#element-to-export')
// Ensure the element is visible before taking a snapshot
await expect(qrCodeExportElement).toBeVisible()
// Take a snapshot of the QR code element
await expect(qrCodeExportElement).toHaveScreenshot('qr-code-snapshot.png')
// We don't need to test the actual download functionality extensively here,
// as the snapshot confirms the visual output. Testing downloads can be flaky.
// But we can check if the buttons exist and are clickable.
await expect(page.locator('#download-qr-image-button-png')).toBeEnabled()
await expect(page.locator('#download-qr-image-button-jpg')).toBeEnabled()
})
test('exported PNG can be scanned', async ({ page }) => {
const testData = 'Export-Scan Test'
const textInput = page.locator('textarea[id="data"]')
await textInput.fill(testData)
// Wait for QR code to render
await expect(page.getByRole('img', { name: 'QR code' })).toBeVisible()
// Start waiting for download and click PNG export button
const downloadPromise = page.waitForEvent('download')
await page.locator('#download-qr-image-button-png').click()
const download = await downloadPromise
// Save to a predictable location for inspection
const exportedPngPath = './exported_qr_code.png'
await download.saveAs(exportedPngPath)
// Switch to Scan mode
await page.getByLabel('Switch to Scan Mode').first().click()
// Locate the hidden file input for scanning
const scanFileInput = page.locator('input[type="file"][accept="image/*"]')
// Upload the exported PNG
await scanFileInput.setInputFiles(exportedPngPath)
// Wait for the result container to appear first (keep increased timeout for now)
const resultContainer = page.locator('.capture-result')
await expect(resultContainer).toBeVisible({ timeout: 10000 })
// Then verify the content within the container
await expect(resultContainer.getByText(testData)).toBeVisible()
})
test('batch export works with CSV file', async ({ page }) => {
const csvFilePath = 'public/6_strings_batch.csv' // Path to the test CSV
const expectedZipFilename = 'qr-codes.zip'
// Switch to Batch export mode
await page.getByRole('button', { name: 'Batch export' }).click()
// Locate the correct hidden file input for batch CSV upload
const batchFileInput = page.locator('input[type="file"][accept=".csv,.txt"]')
// Upload the CSV file
await batchFileInput.setInputFiles(csvFilePath)
// Check the "Ignore header row" checkbox
const ignoreHeaderCheckbox = page.locator('input[id="ignore-header"]')
await ignoreHeaderCheckbox.check()
await expect(ignoreHeaderCheckbox).toBeChecked()
// Wait for the UI to potentially update after file processing/checkbox click
// Check for the preview text as an indicator
await expect(page.getByText('5 piece(s) of data detected')).toBeVisible() // rows - 1 header
await expect(page.getByText('First row preview:')).toBeVisible()
await expect(page.locator('pre').getByText('https://www.esteetey.dev/')).toBeVisible()
// Start waiting for the download before clicking the PNG export button
const downloadPromise = page.waitForEvent('download')
await page.locator('#download-qr-image-button-png').click()
// Wait for the download to complete
const download = await downloadPromise
// Assert the downloaded file is a zip file with the expected name
expect(download.suggestedFilename()).toBe(expectedZipFilename)
const downloadedPath = './test-results/' + download.suggestedFilename()
await download.saveAs(downloadedPath)
// TODO: Optionally add steps here to unzip and verify file count/contents
// For now, we just check the download happened and was a zip file.
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB