Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ on:
branches: [master]

jobs:
frontend:
name: Frontend checks
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19'
cache: pnpm

- name: Install frontend dependencies
run: pnpm install --frozen-lockfile

- name: Run frontend checks
run: pnpm check

test:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
Expand Down
22 changes: 13 additions & 9 deletions bench/lib/auth.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import http from 'k6/http';
import { check, fail } from 'k6';
import exec from 'k6/execution';
import http from 'k6/http';

export const baseUrl = (__ENV.BASE_URL || 'http://127.0.0.1:8080').replace(/\/$/, '');
export const websocketUrl = (__ENV.WS_URL || 'ws://127.0.0.1:8081').replace(/\/$/, '');
Expand All @@ -20,14 +20,18 @@ export function loginForUser(sequence) {
fail(`Could not read the CSRF token from ${baseUrl}/auth/login`);
}

const response = http.post(`${baseUrl}/auth/processLogin`, {
username,
password,
csrf_test_name: loginCsrf,
}, {
redirects: 5,
tags: { name: 'POST /auth/processLogin' },
});
const response = http.post(
`${baseUrl}/auth/processLogin`,
{
username,
password,
csrf_test_name: loginCsrf,
},
{
redirects: 5,
tags: { name: 'POST /auth/processLogin' },
},
);
const authenticated = check(response, {
'login succeeds': (result) => result.status === 200 && result.url.includes('/chat'),
});
Expand Down
20 changes: 12 additions & 8 deletions bench/post-messages.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import http from 'k6/http';
import { Rate, Trend } from 'k6/metrics';
import { baseUrl, csrfToken, login } from './lib/auth.js';

Expand All @@ -25,13 +25,17 @@ export const options = {

export default function () {
session ||= login();
const response = http.post(`${baseUrl}/api/v1/messages`, {
message: `k6 post ${session.username} ${Date.now()}`,
csrf_test_name: csrfToken(),
}, {
headers: { Accept: 'application/json' },
tags: { name: 'POST /api/v1/messages' },
});
const response = http.post(
`${baseUrl}/api/v1/messages`,
{
message: `k6 post ${session.username} ${Date.now()}`,
csrf_test_name: csrfToken(),
},
{
headers: { Accept: 'application/json' },
tags: { name: 'POST /api/v1/messages' },
},
);
const succeeded = check(response, {
'message post succeeds': (result) => result.status === 200 && result.json('success') === true,
});
Expand Down
6 changes: 3 additions & 3 deletions bench/read-messages.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import http from 'k6/http';
import { Rate, Trend } from 'k6/metrics';
import { baseUrl, login } from './lib/auth.js';

const failures = new Rate('read_message_failures');
const latency = new Trend('read_message_duration', true);
let session;
let _session;

export const options = {
noCookiesReset: true,
Expand All @@ -24,7 +24,7 @@ export const options = {
};

export default function () {
session ||= login();
_session ||= login();
const response = http.get(`${baseUrl}/api/v1/messages?page=1&per_page=50`, {
headers: { Accept: 'application/json' },
tags: { name: 'GET /api/v1/messages' },
Expand Down
14 changes: 8 additions & 6 deletions bench/websocket-fanout.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { check } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import exec from 'k6/execution';
import { Rate, Trend } from 'k6/metrics';
import { WebSocket } from 'k6/websockets';
import { loginForUser, websocketUrl } from './lib/auth.js';

Expand Down Expand Up @@ -48,11 +48,13 @@ export default function (sessions) {
socket.addEventListener('open', () => {
setTimeout(() => {
sentAt = Date.now();
socket.send(JSON.stringify({
action: 'sendMessage',
username: session.username,
message: marker,
}));
socket.send(
JSON.stringify({
action: 'sendMessage',
username: session.username,
message: marker,
}),
);
}, 2_000);
});

Expand Down
36 changes: 36 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.13/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["*.js", "*.ts", "src/**/*.js", "src/**/*.vue", "src/**/*.svelte", "e2e/**/*.ts", "bench/**/*.js"],
"ignoreUnknown": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"html": {
"experimentalFullSupportEnabled": true,
"formatter": {
"enabled": true,
"indentScriptAndStyle": true
}
}
}
18 changes: 13 additions & 5 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Thank you for your interest in contributing to this project! This is a learning
php spark serve

# In a separate terminal, start Vite for frontend assets
npm run dev
pnpm dev

# Optional: Start the WebSocket server for real-time features
php spark websocket:start
Expand All @@ -43,7 +43,7 @@ composer test:coverage

### Code Quality

This project uses PHPStan for static analysis and PHP-CS-Fixer for code style:
This project uses PHPStan and PHP-CS-Fixer for PHP, plus Biome, `svelte-check`, and `vue-tsc` for frontend code:

```bash
# Run static analysis
Expand All @@ -54,13 +54,20 @@ composer cs-check

# Automatically fix code style issues
composer cs-fix

# Lint and format-check JavaScript, then check Svelte and Vue components
pnpm check

# Apply safe Biome formatting and lint fixes
pnpm format
```

Please ensure your code passes both static analysis and code style checks before submitting a pull request.
Please ensure the PHP and frontend checks pass before submitting a pull request. Biome covers repository JavaScript and TypeScript; framework-aware diagnostics are handled separately by `svelte-check` and `vue-tsc`.

## Code Style Guidelines

- Follow PSR-12 coding standards (enforced by PHP-CS-Fixer)
- Keep JavaScript and TypeScript formatted and lint-clean with Biome
- Use meaningful variable and function names
- Add comments for complex logic
- Keep functions focused and small
Expand All @@ -70,8 +77,9 @@ Please ensure your code passes both static analysis and code style checks before
1. Ensure all tests pass (`composer test`)
2. Run static analysis (`composer analyse`)
3. Fix any code style issues (`composer cs-fix`)
4. Update documentation if needed
5. Write a clear description of your changes
4. Run frontend checks (`pnpm check`)
5. Update documentation if needed
6. Write a clear description of your changes

## Project Structure

Expand Down
6 changes: 3 additions & 3 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFileSync, spawn, type ChildProcess } from 'node:child_process';
import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
Expand Down Expand Up @@ -57,7 +57,7 @@ function assertPortFree(port: number): Promise<void> {
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 => {
server.close((error) => {
if (error) {
reject(error);
return;
Expand Down Expand Up @@ -117,7 +117,7 @@ function stopProcess(child: ChildProcess): Promise<void> {
return Promise.resolve();
}

return new Promise(resolve => {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
child.kill('SIGKILL');
resolve();
Expand Down
16 changes: 7 additions & 9 deletions e2e/support/chat-scenarios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page, type Response } from '@playwright/test';
import { expect, type Page, type Response, test } from '@playwright/test';

type NetworkFormat = 'json' | 'xml';

Expand All @@ -20,16 +20,14 @@ export function chatScenario(scenario: ChatScenario): void {
await login(page);

const browserErrors: string[] = [];
page.on('pageerror', error => browserErrors.push(error.message));
page.on('console', message => {
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;
const payloadPromise = scenario.network ? captureNetworkPayload(page, scenario.network) : null;

await page.goto(scenario.path);

Expand Down Expand Up @@ -95,9 +93,9 @@ function captureNetworkPayload(page: Page, network: NonNullable<ChatScenario['ne

async function assertNoSeriousAccessibilityViolations(page: Page): Promise<void> {
const results = await new AxeBuilder({ page }).analyze();
const blocking = results.violations.filter(violation =>
violation.impact === 'critical' || violation.impact === 'serious',
const blocking = results.violations.filter(
(violation) => violation.impact === 'critical' || violation.impact === 'serious',
);

expect(blocking, blocking.map(violation => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
expect(blocking, blocking.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
}
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "CodeIgniter Chat Application",
"private": true,
"type": "module",
"packageManager": "pnpm@10.34.5",
"engines": {
"node": ">=20.19"
},
Expand All @@ -14,18 +15,27 @@
"bench:post": "k6 run bench/post-messages.js",
"bench:read": "k6 run bench/read-messages.js",
"bench:ws": "k6 run bench/websocket-fanout.js",
"check": "biome check --error-on-warnings . && svelte-check --config ./svelte.config.js --fail-on-warnings && vue-tsc --noEmit -p tsconfig.vue.json",
"check:biome": "biome check --error-on-warnings .",
"check:svelte": "svelte-check --config ./svelte.config.js --fail-on-warnings",
"check:vue": "vue-tsc --noEmit -p tsconfig.vue.json",
"format": "biome check --write .",
"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",
"@biomejs/biome": "^2.5.13",
"@playwright/test": "^1.63.0",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@vitejs/plugin-vue": "^6.0.8",
"sass": "^1.104.0",
"svelte": "^5.57.0",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^8.2.2",
"vue": "^3.5.42"
"vue": "^3.5.42",
"vue-tsc": "^3.3.11"
}
}
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import fs from 'node:fs';
import { defineConfig, devices } from '@playwright/test';
import { applicationEnvironment, baseURL, cachePath, rootDir } from './e2e/support/environment';

fs.mkdirSync(cachePath, { recursive: true });
Expand Down
Loading
Loading