-
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..450813d
--- /dev/null
+++ b/test/e2e/README.md
@@ -0,0 +1,55 @@
+# 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
+```
+
+`npm run test:e2e` overrides `E2E_DB_NAME` with a run-specific value by
+default, while `npm run test:e2e:setup-db` and `npm run test:e2e:teardown-db`
+still use the config values you set here.
+
+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.
+
+## CI and Parallel Runs
+
+`npm run test:e2e` generates a run-specific database name, PHP copy directory,
+and local ports by default. CI can set these values explicitly when a runner
+needs deterministic names:
+
+```bash
+E2E_RUN_ID=ci_123
+E2E_DB_NAME=rcv_e2e_ci_123
+E2E_PHP_PORT=2461
+E2E_VITE_PORT=2460
+E2E_PHP_ROOT=.cache/e2e-php-ci-123
+```
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..a697cd5
--- /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-${process.env.E2E_RUN_ID || 'default'}`);
+
+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..aee0a38
--- /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-${process.env.E2E_RUN_ID || 'default'}`);
+
+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..82a42af
--- /dev/null
+++ b/test/e2e/happy-path.spec.js
@@ -0,0 +1,47 @@
+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');
+ const createView = page.getByTestId('create-view');
+ await expect(createView).toBeVisible();
+
+ await createView.getByTestId('ballot-name-input').fill(ballotName);
+ await createView.getByTestId('ballot-key-input').fill(shortcode);
+ await createView.getByTestId('ballot-submit').click();
+
+ const entryInput = createView.getByTestId('entry-input');
+ await expect(entryInput).toBeVisible();
+ for (const entry of ['Ada', 'Grace', 'Katherine']) {
+ await entryInput.fill(entry);
+ await createView.getByTestId('entry-add').click();
+ }
+ await expect(createView.getByTestId('entry-list-item')).toHaveCount(3);
+
+ await createView.getByTestId('entries-submit').click();
+ await expect(createView.getByTestId('ballot-created')).toBeVisible();
+
+ await createView.getByTestId('vote-self-link').click();
+ const voteView = page.getByTestId('vote-view');
+ const ballotForm = voteView.getByTestId('vote-ballot-form');
+ await expect(ballotForm.getByTestId('vote-ballot-title')).toContainText(ballotName);
+ const candidates = ballotForm.getByTestId('vote-candidate');
+ await expect(candidates).toHaveCount(3);
+ const firstChoice = await candidates.first().getByTestId('vote-candidate-name').innerText();
+ await ballotForm.getByTestId('vote-submit').click();
+
+ await expect(voteView.getByTestId('vote-thanks')).toBeVisible();
+ await voteView.getByTestId('results-link').click();
+
+ const resultsView = page.getByTestId('results-view');
+ const finalResults = resultsView.getByTestId('results-final');
+ await expect(finalResults).toBeVisible();
+ await expect(finalResults.getByTestId('results-winner-name')).toHaveText(firstChoice);
+});
diff --git a/test/e2e/prepare-php-server.mjs b/test/e2e/prepare-php-server.mjs
new file mode 100644
index 0000000..a2bc2ce
--- /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.resolve(repoRoot, process.env.E2E_PHP_ROOT || '.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'
+);
diff --git a/vite.config.js b/vite.config.js
index 4318748..cd20130 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -3,6 +3,8 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
import htmlIncludes from './vite-plugin-html-includes.js';
import path from 'path';
+const phpProxyPort = process.env.E2E_PHP_PORT || '2461';
+
export default defineConfig({
root: 'src',
base: '/',
@@ -163,7 +165,7 @@ export default defineConfig({
proxy: {
// Proxy API requests to PHP backend
'/api': {
- target: 'http://localhost:2461',
+ target: `http://localhost:${phpProxyPort}`,
changeOrigin: true,
secure: false
}