From 5ef60b7643db9a26ceac32308ed9049889dc5e8f Mon Sep 17 00:00:00 2001 From: Emmanuel Jones Date: Sun, 14 Jun 2026 21:44:13 -0600 Subject: [PATCH 1/3] Add Playwright happy-path E2E --- package-lock.json | 66 ++++++++++++++++++++- package.json | 6 +- playwright.config.js | 40 +++++++++++++ src/pages/create.html | 37 +++++++++--- src/pages/results.html | 11 ++-- src/pages/vote.html | 18 +++--- test/e2e/README.md | 37 ++++++++++++ test/e2e/db.mjs | 100 ++++++++++++++++++++++++++++++++ test/e2e/global-setup.mjs | 13 +++++ test/e2e/global-teardown.mjs | 18 ++++++ test/e2e/happy-path.spec.js | 42 ++++++++++++++ test/e2e/prepare-php-server.mjs | 47 +++++++++++++++ 12 files changed, 412 insertions(+), 23 deletions(-) create mode 100644 playwright.config.js create mode 100644 test/e2e/README.md create mode 100644 test/e2e/db.mjs create mode 100644 test/e2e/global-setup.mjs create mode 100644 test/e2e/global-teardown.mjs create mode 100644 test/e2e/happy-path.spec.js create mode 100644 test/e2e/prepare-php-server.mjs diff --git a/package-lock.json b/package-lock.json index 6a827b5..efed79a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "less": "^4.2.0", "vite": "^5.0.12", "vite-plugin-static-copy": "^1.0.0", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "@playwright/test": "^1.60.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -3923,6 +3924,69 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "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" + } } } } diff --git a/package.json b/package.json index 2267d49..a1cc6a6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,10 @@ "build:prod": "vite build && cp src/api/config.prod.php dist/api/config.php", "preview": "vite preview", "test": "vitest run && ./vendor/bin/phpunit", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:setup-db": "node test/e2e/db.mjs setup", + "test:e2e:teardown-db": "node test/e2e/db.mjs teardown" }, "dependencies": { "angular": "^1.5.3", @@ -26,6 +29,7 @@ "ng-pattern-restrict": "^0.2.3" }, "devDependencies": { + "@playwright/test": "^1.60.0", "angular-mocks": "^1.8.3", "jsdom": "^29.1.0", "less": "^4.2.0", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..bf534bb --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.E2E_BASE_URL || 'http://127.0.0.1:2460'; + +export default defineConfig({ + testDir: './test/e2e', + globalSetup: './test/e2e/global-setup.mjs', + globalTeardown: './test/e2e/global-teardown.mjs', + fullyParallel: false, + workers: 1, + timeout: 45_000, + expect: { + timeout: 10_000 + }, + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure' + }, + webServer: [ + { + command: 'node test/e2e/prepare-php-server.mjs && php -S 127.0.0.1:2461 -t .cache/e2e-php', + url: 'http://127.0.0.1:2461/api/get-candidates.php', + reuseExistingServer: false, + timeout: 120_000 + }, + { + command: 'npm run dev -- --host 127.0.0.1', + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000 + } + ], + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ] +}); diff --git a/src/pages/create.html b/src/pages/create.html index 7aa0388..076fdff 100644 --- a/src/pages/create.html +++ b/src/pages/create.html @@ -1,4 +1,4 @@ -
+

Edit Ballot: {{ballot.name}}

@@ -16,7 +16,12 @@

Edit Ballot: {{ballot.name}}

>
- {{errors.name}}Edit Ballot: {{ballot.name}} ng-change="checkAvailability()" maxlength="16" class="form-control" + data-testid="ballot-key-input" />
- +
@@ -536,16 +548,22 @@

ng-model="entryInput" ng-change="errorEntry = ''" class="form-control" + data-testid="entry-input" />{{errorEntry}}

- - +

Current Entry List

-
+
Custom Voter Prompts
Submit Voter Prompts
-
+

Thank you for creating a ballot!

Send this url or QR code to people for voting:

{{origin}}/{{ballot.key}}

- Click here to vote yourself +
@@ -38,13 +38,14 @@

Results

-
+
-

Results

+

Results

- The winner (so far): {{elected[0].name}} + The winner (so far): + {{elected[0].name}}

@@ -160,4 +161,4 @@

{{errors.shortcode}}

-
\ No newline at end of file +
diff --git a/src/pages/vote.html b/src/pages/vote.html index ae84881..edf4a69 100644 --- a/src/pages/vote.html +++ b/src/pages/vote.html @@ -1,4 +1,4 @@ -
+
@@ -33,7 +33,7 @@

Vote!

ng-if="candidates && ballot.isSecure && !secureCodeValid" ng-hide="thanks || deviceAlreadyVoted" > -

Ballot: {{ballot.name}}

+

Ballot: {{ballot.name}}

Enter your voter code to proceed

@@ -55,7 +55,7 @@

Enter your voter code to proceed

ng-if="candidates && ballot.allowGrouping && !groupAnswersSubmitted && (!ballot.isSecure || secureCodeValid)" ng-hide="thanks || deviceAlreadyVoted" > -

Ballot: {{ballot.name}}

+

Ballot: {{ballot.name}}

@@ -125,7 +125,7 @@

Ballot: {{ballot.name}}

ng-if="candidates && (!ballot.isSecure || secureCodeValid) && (!ballot.allowGrouping || groupAnswersSubmitted)" ng-hide="thanks || deviceAlreadyVoted" > -

Ballot: {{ballot.name}}

+

Ballot: {{ballot.name}}

ng-repeat="item in candidates track by $index" data-id="{{item.id}}" class="candidate-item" + data-testid="vote-candidate" ng-style="item.color ? {'background-color': '#' + item.color} : {}" > @@ -214,6 +215,7 @@

type="submit" ng-disabled="candidates.length === 0 || (voteCutoffMoment && cutoffSecondsLeft <= 0)" class="btn btn-primary clearfix" + data-testid="vote-submit" > Vote! @@ -222,11 +224,11 @@

Voting URL: {{origin}}/{{ballot.key}}

-
-

Ballot: {{ballot.name}}

+
-

Ballot: {{ballot.name}}

+

Ballot: {{ballot.name}}

This device has already voted on this ballot.

Each device is limited to one vote. If you believe this is an error, please contact the diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 0000000..3121602 --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,37 @@ +# Playwright E2E Tests + +These tests run against the real Vite app and real PHP API, but use an isolated +MySQL database named `rcv_e2e` by default. + +## Commands + +```bash +npm run test:e2e +npm run test:e2e:setup-db +npm run test:e2e:teardown-db +``` + +`npm run test:e2e` drops and recreates the E2E database during global setup and +drops it again during global teardown. Set `E2E_KEEP_DB=1` to leave the database +behind for debugging. + +## MySQL Configuration + +The setup script connects as a MySQL admin user to create the database and grant +the app user access. Defaults match the local setup docs: + +```bash +E2E_DB_ADMIN_USER=root +E2E_DB_ADMIN_PASSWORD= +E2E_DB_ADMIN_HOST=localhost +E2E_DB_ADMIN_PORT=3306 +E2E_DB_NAME=rcv_e2e +E2E_DB_USER=rcv_e2e_user +E2E_DB_PASSWORD=rcv_e2e_password +E2E_DB_HOST=localhost +E2E_DB_PORT=3306 +``` + +The PHP server uses an ignored temporary copy of `src/` at `.cache/e2e-php` +with an E2E-only `api/config.php`, so your normal `src/api/config.php` is not +read or modified. diff --git a/test/e2e/db.mjs b/test/e2e/db.mjs new file mode 100644 index 0000000..863d9e5 --- /dev/null +++ b/test/e2e/db.mjs @@ -0,0 +1,100 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const modulePath = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(modulePath), '../..'); +const schemaPath = path.join(repoRoot, 'src/api/setup-database-prod.sql'); + +const config = { + adminUser: process.env.E2E_DB_ADMIN_USER || 'root', + adminPassword: process.env.E2E_DB_ADMIN_PASSWORD || '', + adminHost: process.env.E2E_DB_ADMIN_HOST || 'localhost', + adminPort: process.env.E2E_DB_ADMIN_PORT || '3306', + appUser: process.env.E2E_DB_USER || 'rcv_e2e_user', + appPassword: process.env.E2E_DB_PASSWORD || 'rcv_e2e_password', + appHost: process.env.E2E_DB_HOST || 'localhost', + appPort: process.env.E2E_DB_PORT || '3306', + dbName: process.env.E2E_DB_NAME || 'rcv_e2e' +}; + +function quoteIdent(value) { + if (!/^[a-zA-Z0-9_]+$/.test(value)) { + throw new Error(`Unsafe MySQL identifier for E2E database: ${value}`); + } + return `\`${value}\``; +} + +function sqlString(value) { + return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; +} + +function mysqlArgs() { + const args = ['-h', config.adminHost, '-P', config.adminPort, '-u', config.adminUser]; + if (config.adminPassword) { + args.push(`-p${config.adminPassword}`); + } + return args; +} + +async function runMysql(sql) { + await new Promise((resolve, reject) => { + const child = spawn(process.env.MYSQL || 'mysql', mysqlArgs(), { + cwd: repoRoot, + stdio: ['pipe', 'inherit', 'inherit'] + }); + + child.stdin.end(sql); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`mysql exited with code ${code}`)); + } + }); + }); +} + +async function schemaTablesSql() { + const schema = await readFile(schemaPath, 'utf8'); + const start = schema.indexOf('/*!40101 SET @OLD_CHARACTER_SET_CLIENT'); + if (start === -1) { + throw new Error('Could not find start of table schema in setup-database-prod.sql'); + } + return schema.slice(start); +} + +export async function setupDatabase() { + const dbName = quoteIdent(config.dbName); + const tables = await schemaTablesSql(); + await runMysql(` +DROP DATABASE IF EXISTS ${dbName}; +CREATE DATABASE ${dbName} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER IF NOT EXISTS ${sqlString(config.appUser)}@${sqlString(config.appHost)} IDENTIFIED BY ${sqlString(config.appPassword)}; +GRANT ALL PRIVILEGES ON ${dbName}.* TO ${sqlString(config.appUser)}@${sqlString(config.appHost)}; +FLUSH PRIVILEGES; +USE ${dbName}; +${tables} +`); +} + +export async function teardownDatabase() { + if (process.env.E2E_KEEP_DB === '1') { + return; + } + + await runMysql(`DROP DATABASE IF EXISTS ${quoteIdent(config.dbName)};`); +} + +if (process.argv[1] === modulePath) { + const command = process.argv[2]; + if (command === 'setup') { + await setupDatabase(); + } else if (command === 'teardown') { + await teardownDatabase(); + } else if (command) { + throw new Error(`Unknown E2E database command: ${command}`); + } +} diff --git a/test/e2e/global-setup.mjs b/test/e2e/global-setup.mjs new file mode 100644 index 0000000..94f910f --- /dev/null +++ b/test/e2e/global-setup.mjs @@ -0,0 +1,13 @@ +import { setupDatabase } from './db.mjs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const readyFile = path.join(repoRoot, '.cache/e2e-db-ready'); + +export default async function globalSetup() { + await setupDatabase(); + await mkdir(path.dirname(readyFile), { recursive: true }); + await writeFile(readyFile, 'ready\n', 'utf8'); +} diff --git a/test/e2e/global-teardown.mjs b/test/e2e/global-teardown.mjs new file mode 100644 index 0000000..13720a0 --- /dev/null +++ b/test/e2e/global-teardown.mjs @@ -0,0 +1,18 @@ +import { teardownDatabase } from './db.mjs'; +import { access, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const readyFile = path.join(repoRoot, '.cache/e2e-db-ready'); + +export default async function globalTeardown() { + try { + await access(readyFile); + } catch { + return; + } + + await teardownDatabase(); + await rm(readyFile, { force: true }); +} diff --git a/test/e2e/happy-path.spec.js b/test/e2e/happy-path.spec.js new file mode 100644 index 0000000..076b607 --- /dev/null +++ b/test/e2e/happy-path.spec.js @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test'; + +test('creates a ballot, casts a vote, and renders results', async ({ page }) => { + await page.route(/^https?:\/\/(?!(127\.0\.0\.1|localhost)(:\d+)?\/).*/, (route) => { + route.abort(); + }); + + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + const shortcode = `e2e${suffix}`; + const ballotName = `E2E Happy Path ${suffix}`; + + await page.goto('/create'); + await expect(page.getByTestId('create-view')).toBeVisible(); + + await page.getByTestId('ballot-name-input').fill(ballotName); + await page.getByTestId('ballot-key-input').fill(shortcode); + await page.getByTestId('ballot-submit').click(); + + const entryInput = page.getByTestId('entry-input'); + await expect(entryInput).toBeVisible(); + for (const entry of ['Ada', 'Grace', 'Katherine']) { + await entryInput.fill(entry); + await page.getByTestId('entry-add').click(); + } + await expect(page.getByTestId('entry-list-item')).toHaveCount(3); + + await page.getByTestId('entries-submit').click(); + await expect(page.getByTestId('ballot-created')).toBeVisible(); + + await page.getByTestId('vote-self-link').click(); + await expect(page.getByTestId('vote-ballot-title').filter({ visible: true })).toContainText(ballotName); + const visibleCandidates = page.getByTestId('vote-candidate').filter({ visible: true }); + await expect(visibleCandidates).toHaveCount(3); + const firstChoice = (await visibleCandidates.first().textContent()).trim(); + await page.getByTestId('vote-submit').click(); + + await expect(page.getByTestId('vote-thanks').filter({ visible: true })).toBeVisible(); + await page.getByTestId('results-link').filter({ visible: true }).click(); + + await expect(page.getByTestId('results-final').filter({ visible: true })).toBeVisible(); + await expect(page.getByTestId('results-winner-name').filter({ visible: true })).toHaveText(firstChoice); +}); diff --git a/test/e2e/prepare-php-server.mjs b/test/e2e/prepare-php-server.mjs new file mode 100644 index 0000000..23c7f4e --- /dev/null +++ b/test/e2e/prepare-php-server.mjs @@ -0,0 +1,47 @@ +import { cp, mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const sourceDir = path.join(repoRoot, 'src'); +const targetDir = path.join(repoRoot, '.cache/e2e-php'); +const apiConfigPath = path.join(targetDir, 'api/config.php'); + +const dbHost = process.env.E2E_DB_HOST || 'localhost'; +const dbPort = process.env.E2E_DB_PORT || '3306'; +const dbUser = process.env.E2E_DB_USER || 'rcv_e2e_user'; +const dbPassword = process.env.E2E_DB_PASSWORD || 'rcv_e2e_password'; +const dbName = process.env.E2E_DB_NAME || 'rcv_e2e'; + +function phpString(value) { + return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +await rm(targetDir, { recursive: true, force: true }); +await cp(sourceDir, targetDir, { + recursive: true, + filter: (source) => path.basename(source) !== 'config.php' && path.basename(source) !== 'config.prod.php' +}); + +await mkdir(path.dirname(apiConfigPath), { recursive: true }); +await writeFile( + apiConfigPath, + ` false)); +} catch (PDOException $e) { + die($e->getMessage()); +} +?> +`, + 'utf8' +); From 077cee4299ec5584bab28baa7cdfdb8b87be75e4 Mon Sep 17 00:00:00 2001 From: Emmanuel Jones Date: Mon, 15 Jun 2026 21:46:39 -0600 Subject: [PATCH 2/3] Harden Playwright E2E isolation --- playwright.config.js | 33 ++++++++++++++++++--- src/pages/vote.html | 3 +- test/e2e/README.md | 14 +++++++++ test/e2e/global-setup.mjs | 2 +- test/e2e/global-teardown.mjs | 2 +- test/e2e/happy-path.spec.js | 51 ++++++++++++++++++--------------- test/e2e/prepare-php-server.mjs | 2 +- vite.config.js | 4 ++- 8 files changed, 79 insertions(+), 32 deletions(-) diff --git a/playwright.config.js b/playwright.config.js index bf534bb..d69e1ac 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,6 +1,31 @@ import { defineConfig, devices } from '@playwright/test'; -const baseURL = process.env.E2E_BASE_URL || 'http://127.0.0.1:2460'; +function sanitizeRunId(value) { + return value.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 32); +} + +function hashToPort(value, offset) { + let hash = 0; + for (const char of value) { + hash = (hash * 31 + char.charCodeAt(0)) % 10_000; + } + return 20_000 + offset + hash; +} + +const runId = sanitizeRunId(process.env.E2E_RUN_ID || `${Date.now()}_${process.pid}`); +const phpPort = process.env.E2E_PHP_PORT || String(hashToPort(runId, 0)); +const vitePort = process.env.E2E_VITE_PORT || String(hashToPort(runId, 10_000)); +const phpRoot = process.env.E2E_PHP_ROOT || `.cache/e2e-php-${runId}`; +const baseURL = process.env.E2E_BASE_URL || `http://127.0.0.1:${vitePort}`; + +Object.assign(process.env, { + E2E_RUN_ID: runId, + E2E_DB_NAME: process.env.E2E_DB_NAME || `rcv_e2e_${runId}`, + E2E_PHP_PORT: phpPort, + E2E_VITE_PORT: vitePort, + E2E_PHP_ROOT: phpRoot, + E2E_BASE_URL: baseURL +}); export default defineConfig({ testDir: './test/e2e', @@ -19,13 +44,13 @@ export default defineConfig({ }, webServer: [ { - command: 'node test/e2e/prepare-php-server.mjs && php -S 127.0.0.1:2461 -t .cache/e2e-php', - url: 'http://127.0.0.1:2461/api/get-candidates.php', + command: `node test/e2e/prepare-php-server.mjs && php -S 127.0.0.1:${phpPort} -t ${phpRoot}`, + url: `http://127.0.0.1:${phpPort}/api/get-candidates.php`, reuseExistingServer: false, timeout: 120_000 }, { - command: 'npm run dev -- --host 127.0.0.1', + command: `npm run dev -- --host 127.0.0.1 --port ${vitePort}`, url: baseURL, reuseExistingServer: !process.env.CI, timeout: 120_000 diff --git a/src/pages/vote.html b/src/pages/vote.html index edf4a69..db4ca9e 100644 --- a/src/pages/vote.html +++ b/src/pages/vote.html @@ -124,6 +124,7 @@

Ballot: {{ballot.name}}

Ballot: {{ballot.name}}

ng-if="item.hyperlink" >{{item.name}} -
{{item.name}}
+
{{item.name}}