diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..cd17d58 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: Browser E2E + +on: + pull_request: + branches: [master] + +jobs: + playwright: + name: Playwright Chromium + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, xml, curl, intl, json, sockets, sqlite3 + coverage: none + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Enable pnpm + run: corepack enable + + - name: Install JavaScript dependencies + run: pnpm install --frozen-lockfile + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run browser tests + run: pnpm e2e + + - name: Upload Playwright report + if: failure() && !cancelled() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 538debb..65c4a5d 100644 --- a/.gitignore +++ b/.gitignore @@ -60,13 +60,15 @@ writable/session/* writable/uploads/* !writable/uploads/index.html -writable/debugbar/* -!writable/debugbar/.gitkeep - -writable/database/* -!writable/database/.gitkeep - -php_errors.log +writable/debugbar/* +!writable/debugbar/.gitkeep + +writable/database/* +!writable/database/.gitkeep + +writable/websocket_tokens.json + +php_errors.log # Legacy CI3 paths application/config/database.php @@ -146,8 +148,10 @@ _/ .claude CLAUDE.md -/results/ -/phpunit*.xml +/results/ +/phpunit*.xml +/playwright-report/ +/test-results/ #------------------------- # Static Analysis Cache diff --git a/app/Commands/ChatWebSocketServer.php b/app/Commands/ChatWebSocketServer.php index f2b6887..f620ed7 100644 --- a/app/Commands/ChatWebSocketServer.php +++ b/app/Commands/ChatWebSocketServer.php @@ -23,42 +23,42 @@ class ChatWebSocketServer extends BaseCommand * * @var string */ - protected string $group = 'Chat'; + protected $group = 'Chat'; /** * The Command's Name * * @var string */ - protected string $name = 'chat:websocket'; + protected $name = 'chat:websocket'; /** * The Command's Description * * @var string */ - protected string $description = 'Starts the WebSocket server for the chat application'; + protected $description = 'Starts the WebSocket server for the chat application'; /** * The Command's Usage * * @var string */ - protected string $usage = 'chat:websocket [options]'; + protected $usage = 'chat:websocket [options]'; /** * The Command's Arguments * * @var array */ - protected array $arguments = []; + protected $arguments = []; /** * The Command's Options * * @var array */ - protected array $options = [ + protected $options = [ '--port' => 'Port to run the WebSocket server on (default: 8080)', ]; diff --git a/app/Views/chat/chatView.php b/app/Views/chat/chatView.php index c002041..00d6f22 100644 --- a/app/Views/chat/chatView.php +++ b/app/Views/chat/chatView.php @@ -1,5 +1,5 @@ - + CodeIgniter Shoutbox @@ -28,7 +28,7 @@

Loading...

- Message: +
diff --git a/app/Views/chat/htmlView.php b/app/Views/chat/htmlView.php index af7cd39..5126667 100644 --- a/app/Views/chat/htmlView.php +++ b/app/Views/chat/htmlView.php @@ -1,5 +1,5 @@ - + CodeIgniter Shoutbox " method="post">
- Name: +

- Message: +
diff --git a/app/Views/chat/jsonView.php b/app/Views/chat/jsonView.php index d90433b..ae1459a 100644 --- a/app/Views/chat/jsonView.php +++ b/app/Views/chat/jsonView.php @@ -1,5 +1,5 @@ - + CodeIgniter Shoutbox - JSON edition @@ -23,12 +23,12 @@

Loading...

- Name: +

- Message: +
diff --git a/app/Views/chat/svelteView.php b/app/Views/chat/svelteView.php index a5ae70f..bca5c2c 100644 --- a/app/Views/chat/svelteView.php +++ b/app/Views/chat/svelteView.php @@ -1,5 +1,5 @@ - + CodeIgniter Chat - Svelte Edition diff --git a/app/Views/chat/vueView.php b/app/Views/chat/vueView.php index c2caf27..5108b84 100644 --- a/app/Views/chat/vueView.php +++ b/app/Views/chat/vueView.php @@ -1,5 +1,5 @@ - + CodeIgniter Chat - Vue.js Edition diff --git a/docs/testing.md b/docs/testing.md index 7c107fc..7486315 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -35,3 +35,29 @@ composer test:integration:websocket ``` Keep actual browser and network-server end-to-end coverage separate from this suite. + +## Browser end-to-end tests + +Playwright covers the XML, JSON, HTML, Vue, and Svelte chat implementations in Chromium. Each scenario signs in through the real login page, posts a message, verifies that the message appears, checks the frontend's network payload when applicable, fails on browser errors, and runs axe against serious and critical accessibility violations. + +Install the PHP and JavaScript dependencies, then install the Playwright browser once: + +```shell +composer install +pnpm install +pnpm exec playwright install chromium +``` + +Run the complete browser suite with: + +```shell +pnpm e2e +``` + +The harness starts the CodeIgniter HTTP server, Vite development server, and WebSocket server automatically. It migrates and seeds an isolated SQLite database and uses a dedicated cache directory, then removes those test artifacts and stops every process during teardown. The fixed `e2euser` credentials exist only in this isolated test database. + +Use `pnpm e2e:ui` for Playwright's interactive runner. `pnpm e2e:codegen` opens the application for locator exploration after the stack is running. Traces, screenshots, and video are retained for failures in `test-results/`; the HTML report is written to `playwright-report/` in CI and uploaded when a pull-request run fails. + +By default, the suite refuses to reuse processes already listening on its HTTP, Vite, or WebSocket ports so it cannot accidentally write through a non-test application stack. Set `PLAYWRIGHT_REUSE_SERVER=1` only when you intentionally started compatible HTTP and Vite servers with the same isolated E2E environment; WebSocket server reuse is never allowed. + +Shared behavior belongs in `e2e/support/chat-scenarios.ts`; keep one small spec in `e2e/` for each frontend route. Pull requests run the suite through `.github/workflows/e2e.yml`. diff --git a/e2e/chat-html.spec.ts b/e2e/chat-html.spec.ts new file mode 100644 index 0000000..343d844 --- /dev/null +++ b/e2e/chat-html.spec.ts @@ -0,0 +1,9 @@ +import { chatScenario } from './support/chat-scenarios'; + +chatScenario({ + name: 'HTML chat', + path: '/chat/html', + messageInput: '#content', + submitButton: 'input[type="submit"]', + authorInput: '#name', +}); diff --git a/e2e/chat-json.spec.ts b/e2e/chat-json.spec.ts new file mode 100644 index 0000000..246b2f1 --- /dev/null +++ b/e2e/chat-json.spec.ts @@ -0,0 +1,10 @@ +import { chatScenario } from './support/chat-scenarios'; + +chatScenario({ + name: 'JSON chat', + path: '/chat/json', + messageInput: '#content', + submitButton: 'input[type="submit"]', + authorInput: '#name', + network: { path: '/api/v1/messages', format: 'json' }, +}); diff --git a/e2e/chat-svelte.spec.ts b/e2e/chat-svelte.spec.ts new file mode 100644 index 0000000..6e2f6bd --- /dev/null +++ b/e2e/chat-svelte.spec.ts @@ -0,0 +1,8 @@ +import { chatScenario } from './support/chat-scenarios'; + +chatScenario({ + name: 'Svelte chat', + path: '/chat/svelte', + messageInput: '#message-input', + submitButton: 'button[type="submit"]', +}); diff --git a/e2e/chat-vue.spec.ts b/e2e/chat-vue.spec.ts new file mode 100644 index 0000000..e7db92d --- /dev/null +++ b/e2e/chat-vue.spec.ts @@ -0,0 +1,8 @@ +import { chatScenario } from './support/chat-scenarios'; + +chatScenario({ + name: 'Vue chat', + path: '/chat/vue', + messageInput: '#message-input', + submitButton: 'button[type="submit"]', +}); diff --git a/e2e/chat-xml.spec.ts b/e2e/chat-xml.spec.ts new file mode 100644 index 0000000..b498e8d --- /dev/null +++ b/e2e/chat-xml.spec.ts @@ -0,0 +1,9 @@ +import { chatScenario } from './support/chat-scenarios'; + +chatScenario({ + name: 'XML chat', + path: '/chat', + messageInput: '#content', + submitButton: 'input[type="submit"]', + network: { path: '/chat/backend', format: 'xml' }, +}); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..d37abc6 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,142 @@ +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { applicationEnvironment, cachePath, databasePath, rootDir } from './support/environment'; + +const tokenPath = path.join(rootDir, 'writable/websocket_tokens.json'); + +export default async function globalSetup(): Promise<() => Promise> { + const tokenBackup = fs.existsSync(tokenPath) ? fs.readFileSync(tokenPath) : null; + + removeDatabaseFiles(); + removeCacheFiles(); + fs.mkdirSync(cachePath, { recursive: true }); + execFileSync('php', ['spark', 'migrate', '--all'], { + cwd: rootDir, + env: applicationEnvironment, + stdio: 'inherit', + }); + execFileSync('php', ['spark', 'db:seed', 'Tests\\Support\\Database\\Seeds\\E2eSeeder'], { + cwd: rootDir, + env: applicationEnvironment, + stdio: 'inherit', + }); + + await assertPortFree(8080); + const webSocketServer = spawn('php', ['spark', 'chat:websocket', '--port', '8080'], { + cwd: rootDir, + env: applicationEnvironment, + stdio: 'ignore', + }); + try { + await waitForPort(webSocketServer, 8080); + } catch (error) { + await stopProcess(webSocketServer); + throw error; + } + + return async () => { + await stopProcess(webSocketServer); + removeDatabaseFiles(); + removeCacheFiles(); + + if (tokenBackup === null) { + fs.rmSync(tokenPath, { force: true }); + } else { + fs.writeFileSync(tokenPath, tokenBackup); + } + }; +} + +function assertPortFree(port: number): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + + server.once('error', () => { + reject(new Error(`Port ${port} is already in use; refusing to run E2E against an unknown server`)); + }); + server.listen(port, '127.0.0.1', () => { + server.close(error => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + }); +} + +function waitForPort(child: ChildProcess, port: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void): void => { + if (settled) { + return; + } + + settled = true; + clearTimeout(timeout); + callback(); + }; + const timeout = setTimeout( + () => finish(() => reject(new Error(`WebSocket server did not open port ${port}`))), + 15_000, + ); + const poll = (): void => { + if (settled) { + return; + } + + if (child.exitCode !== null) { + finish(() => reject(new Error(`WebSocket server exited with code ${child.exitCode}`))); + return; + } + + const socket = net.connect({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + finish(resolve); + }); + socket.once('error', () => { + socket.destroy(); + if (!settled) { + setTimeout(poll, 100); + } + }); + }; + + poll(); + }); +} + +function stopProcess(child: ChildProcess): Promise { + if (child.exitCode !== null) { + return Promise.resolve(); + } + + return new Promise(resolve => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, 5_000); + + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + +function removeCacheFiles(): void { + fs.rmSync(cachePath, { recursive: true, force: true }); +} + +function removeDatabaseFiles(): void { + for (const suffix of ['', '-shm', '-wal']) { + fs.rmSync(`${databasePath}${suffix}`, { force: true }); + } +} diff --git a/e2e/support/chat-scenarios.ts b/e2e/support/chat-scenarios.ts new file mode 100644 index 0000000..89b2450 --- /dev/null +++ b/e2e/support/chat-scenarios.ts @@ -0,0 +1,103 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page, type Response } from '@playwright/test'; + +type NetworkFormat = 'json' | 'xml'; + +interface ChatScenario { + name: string; + path: string; + messageInput: string; + submitButton: string; + authorInput?: string; + network?: { + path: string; + format: NetworkFormat; + }; +} + +export function chatScenario(scenario: ChatScenario): void { + test(`${scenario.name} posts and displays a message without browser errors`, async ({ page }) => { + await login(page); + + const browserErrors: string[] = []; + page.on('pageerror', error => browserErrors.push(error.message)); + page.on('console', message => { + if (message.type() === 'error') { + browserErrors.push(message.text()); + } + }); + + const payloadPromise = scenario.network + ? captureNetworkPayload(page, scenario.network) + : null; + + await page.goto(scenario.path); + + if (payloadPromise && scenario.network) { + assertNetworkPayload(await payloadPromise, scenario.network.format); + } + + await expect(page.locator(scenario.messageInput)).toBeVisible(); + await assertNoSeriousAccessibilityViolations(page); + + const message = `${scenario.name} message ${Date.now()}`; + if (scenario.authorInput) { + await page.locator(scenario.authorInput).fill('e2euser'); + } + await page.locator(scenario.messageInput).fill(message); + await page.locator(scenario.submitButton).click(); + + await expect(page.locator('#messagewindow')).toContainText(message); + expect(browserErrors, browserErrors.join('\n')).toEqual([]); + }); +} + +async function login(page: Page): Promise { + await page.goto('/auth/login'); + await page.locator('#username').fill('e2euser'); + await page.locator('#password').fill('Playwright123!'); + await page.getByRole('button', { name: 'Login' }).click(); + await expect(page).toHaveURL(/\/chat$/); +} + +function assertNetworkPayload(payload: unknown, format: NetworkFormat): void { + if (format === 'json') { + expect(payload).toHaveProperty('messages'); + expect(payload).toHaveProperty('pagination'); + return; + } + + const xml = String(payload); + expect(xml).toContain(''); + expect(xml).not.toContain(''); +} + +function captureNetworkPayload(page: Page, network: NonNullable): Promise { + return new Promise((resolve, reject) => { + const handleResponse = async (response: Response): Promise => { + if (!new URL(response.url()).pathname.endsWith(network.path)) { + return; + } + + page.off('response', handleResponse); + + try { + expect(response.ok()).toBeTruthy(); + resolve(network.format === 'json' ? await response.json() : await response.text()); + } catch (error) { + reject(error); + } + }; + + page.on('response', handleResponse); + }); +} + +async function assertNoSeriousAccessibilityViolations(page: Page): Promise { + const results = await new AxeBuilder({ page }).analyze(); + const blocking = results.violations.filter(violation => + violation.impact === 'critical' || violation.impact === 'serious', + ); + + expect(blocking, blocking.map(violation => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]); +} diff --git a/e2e/support/environment.ts b/e2e/support/environment.ts new file mode 100644 index 0000000..7cabb7f --- /dev/null +++ b/e2e/support/environment.ts @@ -0,0 +1,22 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +export const baseURL = 'http://127.0.0.1:8085'; +export const databasePath = path.join(rootDir, 'writable/database/e2e.sqlite'); +export const cachePath = path.join(rootDir, 'writable/cache/e2e'); + +const inheritedEnvironment = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), +); + +export const applicationEnvironment: Record = { + ...inheritedEnvironment, + CI_ENVIRONMENT: 'development', + DB_DRIVER: 'SQLite3', + app_baseURL: `${baseURL}/`, + app_CSPEnabled: 'false', + cookie_secure: 'false', + cache_file_storePath: cachePath, + database_sqlite_database: databasePath, +}; diff --git a/package.json b/package.json index 0507cfe..c83f465 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,14 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:codegen": "playwright codegen http://127.0.0.1:8085" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@playwright/test": "^1.63.0", "@sveltejs/vite-plugin-svelte": "^7.3.0", "@vitejs/plugin-vue": "^6.0.8", "sass": "^1.104.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6171629 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,46 @@ +import { defineConfig, devices } from '@playwright/test'; +import fs from 'node:fs'; +import { applicationEnvironment, baseURL, cachePath, rootDir } from './e2e/support/environment'; + +fs.mkdirSync(cachePath, { recursive: true }); +const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER === '1'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 30_000, + expect: { timeout: 7_500 }, + globalSetup: './e2e/global-setup.ts', + reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: [ + { + command: 'php spark serve --host 127.0.0.1 --port 8085', + cwd: rootDir, + env: applicationEnvironment, + port: 8085, + reuseExistingServer, + timeout: 30_000, + }, + { + command: 'pnpm dev --host 127.0.0.1 --port 5173 --strictPort', + cwd: rootDir, + port: 5173, + reuseExistingServer, + timeout: 30_000, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 318d053..5214835 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: .: devDependencies: + '@axe-core/playwright': + specifier: ^4.13.0 + version: 4.13.0(playwright-core@1.63.0) + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 '@sveltejs/vite-plugin-svelte': specifier: ^7.3.0 version: 7.3.0(svelte@5.57.0)(vite@8.2.2(sass@1.104.0)) @@ -32,6 +38,11 @@ importers: packages: + '@axe-core/playwright@4.13.0': + resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -153,6 +164,11 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/binding-android-arm-eabi@1.2.5': resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -310,6 +326,10 @@ packages: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -488,6 +508,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -582,6 +612,11 @@ packages: snapshots: + '@axe-core/playwright@4.13.0(playwright-core@1.63.0)': + dependencies: + axe-core: 4.13.0 + playwright-core: 1.63.0 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -675,6 +710,10 @@ snapshots: '@parcel/watcher-win32-x64': 2.6.0 optional: true + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@rolldown/binding-android-arm-eabi@1.2.5': optional: true @@ -801,6 +840,8 @@ snapshots: aria-query@5.3.1: {} + axe-core@4.13.0: {} + axobject-query@4.1.0: {} chokidar@5.0.0: @@ -921,6 +962,12 @@ snapshots: picomatch@4.0.7: optional: true + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + postcss@8.5.26: dependencies: nanoid: 3.3.18 diff --git a/src/css/chat.css b/src/css/chat.css index 42a3dd9..d859ff5 100644 --- a/src/css/chat.css +++ b/src/css/chat.css @@ -33,7 +33,7 @@ .logout-btn { float: right; - color: #d9534f; + color: #a52834; text-decoration: none; } diff --git a/src/css/chat.scss b/src/css/chat.scss index 0349ba0..9f93eb4 100644 --- a/src/css/chat.scss +++ b/src/css/chat.scss @@ -8,7 +8,7 @@ $error-color: red; $background-light: #f0f0f0; $background-lighter: #e0e0e0; $border-color: #ddd; -$logout-color: #d9534f; +$logout-color: #a52834; $border-radius: 4px; // Message window @@ -82,4 +82,4 @@ $border-radius: 4px; opacity: 0.5; cursor: not-allowed; } -} \ No newline at end of file +} diff --git a/src/js/chat.js b/src/js/chat.js index 6fcbf42..34e557f 100644 --- a/src/js/chat.js +++ b/src/js/chat.js @@ -326,7 +326,8 @@ function postMessage(message) { method: 'POST', headers: { // This header tells the server we're sending form data - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' }, body: formData.toString() }) diff --git a/src/svelte/App.svelte b/src/svelte/App.svelte index c567e49..5746459 100644 --- a/src/svelte/App.svelte +++ b/src/svelte/App.svelte @@ -760,12 +760,12 @@ @use "sass:color"; /* Variables for consistent theming */ - $primary-color: #4a6fa5; + $primary-color: #3f6398; $secondary-color: #6c757d; $background-color: #f8f9fa; $border-color: #dee2e6; $text-color: #343a40; - $light-text-color: #6c757d; + $light-text-color: #5c6268; $error-color: #dc3545; $success-color: #28a745; $hover-color: #e9ecef; diff --git a/src/vue/App.vue b/src/vue/App.vue index 7a95b22..05c59f8 100644 --- a/src/vue/App.vue +++ b/src/vue/App.vue @@ -546,12 +546,12 @@ export default { @use "sass:color"; // Variables -$primary-color: #4a6fa5; +$primary-color: #3f6398; $secondary-color: #6c757d; $background-color: #f8f9fa; $border-color: #dee2e6; $text-color: #343a40; -$light-text-color: #6c757d; +$light-text-color: #5c6268; $error-color: #dc3545; $success-color: #28a745; $hover-color: #e9ecef; @@ -599,7 +599,7 @@ $transition-speed: 0.2s; font-size: 16px; } -.logout-btn { +.chat-header .logout-btn { color: white; text-decoration: none; font-size: 14px; diff --git a/tests/_support/Database/Seeds/E2eSeeder.php b/tests/_support/Database/Seeds/E2eSeeder.php new file mode 100644 index 0000000..3f00a50 --- /dev/null +++ b/tests/_support/Database/Seeds/E2eSeeder.php @@ -0,0 +1,19 @@ +db->table('users')->insert([ + 'username' => 'e2euser', + 'email' => 'e2e@example.com', + 'password' => password_hash('Playwright123!', PASSWORD_DEFAULT), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } +}