fix: sync E2E tests with seed data + i18n fix

- Update E2E test credentials to match actual seed users
  (user@veza.music, artist@veza.music, admin@veza.music, mod@veza.music)
- Fix hardcoded "Suggested Accounts" in SuggestionsWidget with i18n key
- Replace hardcoded amelie_dubois references with CONFIG.users.creator
- Refactor auth, player, upload E2E tests for reliability
- Add tmt test plans and scripts for CI integration
- Simplify CI workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-02 19:42:03 +02:00
parent 074e8fd3a1
commit a3f4ac6b70
40 changed files with 1009 additions and 874 deletions

View file

@ -5,11 +5,14 @@ on:
branches: [ "main", "remediation/*", "feature/mvp-complete" ] branches: [ "main", "remediation/*", "feature/mvp-complete" ]
pull_request: pull_request:
branches: [ "main", "feature/mvp-complete" ] branches: [ "main", "feature/mvp-complete" ]
workflow_dispatch: # Allow manual trigger workflow_dispatch:
jobs: jobs:
backend-go: # ===========================================================================
name: Backend (Go) # TMT Vital — Backend (Go)
# ===========================================================================
vital-backend:
name: TMT Vital — Backend (Go)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -28,75 +31,37 @@ jobs:
fi fi
fi fi
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '20'
cache: 'npm'
- name: Set up Go - name: Set up Go
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
with: with:
go-version: '1.24' go-version: '1.24'
cache: true cache: true
- name: Install dependencies - name: Install Go tools
run: npm ci
- name: Run govulncheck
run: | run: |
cd veza-backend-api
go install golang.org/x/vuln/cmd/govulncheck@latest go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./... go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
- name: Vet - name: Install TMT
run: | run: pip install tmt
cd veza-backend-api
go vet ./...
- name: Install golangci-lint - name: Run TMT Vital Backend
run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest run: tmt --root tmt run plan --name /vital-backend
- name: Lint # ===========================================================================
run: npx turbo run lint --filter=veza-backend-api # TMT Vital — Rust Services (Stream)
# ===========================================================================
- name: Test with coverage vital-services:
run: | name: TMT Vital — Rust Services
cd veza-backend-api
go test ./internal/handlers/... ./internal/services/... -short -coverprofile=coverage.out -covermode=atomic
COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
echo "Coverage: ${COVERAGE}%"
echo "coverage=${COVERAGE}" >> $GITHUB_OUTPUT
if awk -v c="$COVERAGE" -v t=60 'BEGIN {exit !(c+0>=t)}'; then
echo "Coverage gate passed (>= 60%)"
else
echo "Coverage $COVERAGE% is below threshold 60%"
exit 1
fi
- name: Build
run: npx turbo run build --filter=veza-backend-api
rust-services:
name: Rust Services (Stream)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '20'
cache: 'npm'
- name: Set up Rust - name: Set up Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- name: Install dependencies
run: npm ci
- name: Cache Cargo registry - name: Cache Cargo registry
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with: with:
@ -109,26 +74,21 @@ jobs:
- name: Install cargo-audit - name: Install cargo-audit
run: cargo install cargo-audit run: cargo install cargo-audit
- name: Auditing Stream Server - name: Install TMT
run: | run: pip install tmt
cd veza-stream-server
cargo audit
- name: Lint - name: Run TMT Vital Services
run: npx turbo run lint --filter=veza-stream-server run: tmt --root tmt run plan --name /vital-services
- name: Build # ===========================================================================
run: npx turbo run build --filter=veza-stream-server # TMT Vital — Frontend (Web)
# ===========================================================================
- name: Test vital-frontend:
run: npx turbo run test --filter=veza-stream-server name: TMT Vital — Frontend (Web)
frontend:
name: Frontend (Web)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with: with:
@ -139,9 +99,6 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
- name: Security audit (npm)
run: npm audit --audit-level=critical
- name: Cache Generated Types - name: Cache Generated Types
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with: with:
@ -150,46 +107,15 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-generated-types- ${{ runner.os }}-generated-types-
- name: Generate Types from OpenAPI - name: Install TMT
run: | run: pip install tmt
cd apps/web
chmod +x scripts/generate-types.sh
./scripts/generate-types.sh
continue-on-error: false
# This step ensures types are generated before typecheck
# Cache keyed on openapi.yaml hash, so types regenerate when spec changes
- name: Check types sync with OpenAPI spec - name: Run TMT Vital Frontend
run: | run: tmt --root tmt run plan --name /vital-frontend
if ! git diff --exit-code apps/web/src/types/generated/; then
echo "::error::Types are out of sync with openapi.yaml. Run 'make openapi' then 'cd apps/web && ./scripts/generate-types.sh' and commit the updated types."
exit 1
fi
- name: Lint
run: npx turbo run lint --filter=veza-frontend
- name: Format Check
run: |
cd apps/web
npm run format:check --if-present
- name: Type Check
run: |
cd apps/web
npm run typecheck
- name: Test with coverage
run: npx turbo run test --filter=veza-frontend -- --run --coverage
- name: Contrast Tests
run: |
cd apps/web
npm run test -- --run src/__tests__/contrast.test.ts
- name: Build
run: npx turbo run build --filter=veza-frontend
# ===========================================================================
# Storybook Audit (kept outside TMT — tier 3 candidate)
# ===========================================================================
storybook: storybook:
name: Storybook Audit name: Storybook Audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -224,6 +150,9 @@ jobs:
npm run test:storybook npm run test:storybook
working-directory: apps/web working-directory: apps/web
# ===========================================================================
# E2E (Playwright) — kept outside TMT (complex infra setup)
# ===========================================================================
e2e: e2e:
name: E2E (Playwright) name: E2E (Playwright)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -324,9 +253,12 @@ jobs:
path: apps/web/playwright-report/ path: apps/web/playwright-report/
retention-days: 7 retention-days: 7
# ===========================================================================
# Notify on failure
# ===========================================================================
notify-failure: notify-failure:
name: Notify on failure name: Notify on failure
needs: [backend-go, rust-services, frontend, storybook, e2e] needs: [vital-backend, vital-services, vital-frontend, storybook, e2e]
if: failure() if: failure()
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View file

@ -45,7 +45,7 @@ export function SuggestionsWidget() {
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent" /> <div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2"> <h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<UserPlus className="w-4 h-4 text-primary" /> <UserPlus className="w-4 h-4 text-primary" />
Suggested Accounts {t('feed.suggestedAccounts')}
</h3> </h3>
</div> </div>

View file

@ -2,7 +2,8 @@
# TEST & QUALITY (unit tests, lint, format) # TEST & QUALITY (unit tests, lint, format)
# ============================================================================== # ==============================================================================
.PHONY: test test-tmt lint fmt status test-web test-backend-api test-stream-server .PHONY: test test-tmt test-tmt-backend test-tmt-frontend test-tmt-services lint fmt status test-web test-backend-api test-stream-server
.PHONY: test-e2e test-e2e-critical
.PHONY: load-test-smoke load-test-backend load-test-all .PHONY: load-test-smoke load-test-backend load-test-all
.PHONY: lint-web lint-backend-api lint-stream-server .PHONY: lint-web lint-backend-api lint-stream-server
@ -25,15 +26,38 @@ test: infra-up ## [MID] Run All Tests (Fastest strategy)
@(cd $(ROOT)/$(SERVICE_DIR_web) && npm run test -- --run) @(cd $(ROOT)/$(SERVICE_DIR_web) && npm run test -- --run)
@$(ECHO_CMD) "${GREEN}✅ All tests passed.${NC}" @$(ECHO_CMD) "${GREEN}✅ All tests passed.${NC}"
test-tmt: ## [MID] Run Unified TMT Pipeline test-tmt: ## [MID] Run Unified TMT Pipeline (all vital tests)
@$(ECHO_CMD) "${BLUE}🧪 Running TMT Pipeline...${NC}" @$(ECHO_CMD) "${BLUE}🧪 Running TMT Vital Pipeline...${NC}"
@command -v tmt >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}❌ tmt is missing! Install with 'pip install tmt'${NC}"; exit 1; } @command -v tmt >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}❌ tmt is missing! Install with 'pip install tmt'${NC}"; exit 1; }
@tmt run @tmt --root tmt run plan --name /vital
test-tmt-backend: ## [MID] Run TMT Backend tests only
@$(ECHO_CMD) "${BLUE}🧪 Running TMT Backend...${NC}"
@command -v tmt >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}❌ tmt is missing! Install with 'pip install tmt'${NC}"; exit 1; }
@tmt --root tmt run plan --name /vital-backend
test-tmt-frontend: ## [MID] Run TMT Frontend tests only
@$(ECHO_CMD) "${BLUE}🧪 Running TMT Frontend...${NC}"
@command -v tmt >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}❌ tmt is missing! Install with 'pip install tmt'${NC}"; exit 1; }
@tmt --root tmt run plan --name /vital-frontend
test-tmt-services: ## [MID] Run TMT Rust Services tests only
@$(ECHO_CMD) "${BLUE}🧪 Running TMT Services...${NC}"
@command -v tmt >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}❌ tmt is missing! Install with 'pip install tmt'${NC}"; exit 1; }
@tmt --root tmt run plan --name /vital-services
test-web: ## [MID] Run Web tests only test-web: ## [MID] Run Web tests only
@$(ECHO_CMD) "${BLUE}🧪 Running Web tests...${NC}" @$(ECHO_CMD) "${BLUE}🧪 Running Web tests...${NC}"
@(cd $(ROOT)/$(SERVICE_DIR_web) && npm run test -- --run) @(cd $(ROOT)/$(SERVICE_DIR_web) && npm run test -- --run)
test-e2e: ## [MID] Run Playwright E2E tests (Chromium, requires backend running)
@$(ECHO_CMD) "${BLUE}🧪 Running E2E tests (Playwright)...${NC}"
@(cd $(ROOT)/tests/e2e && npx playwright test --project=chromium)
test-e2e-critical: ## [MID] Run only @critical E2E tests (fast smoke)
@$(ECHO_CMD) "${BLUE}🧪 Running Critical E2E tests...${NC}"
@(cd $(ROOT)/tests/e2e && npx playwright test --project=chromium --grep "@critical")
test-backend-api: infra-up ## [MID] Run Go backend tests only test-backend-api: infra-up ## [MID] Run Go backend tests only
@$(ECHO_CMD) "${BLUE}🧪 Running Backend API tests...${NC}" @$(ECHO_CMD) "${BLUE}🧪 Running Backend API tests...${NC}"
@(cd $(ROOT)/$(SERVICE_DIR_backend-api) && \ @(cd $(ROOT)/$(SERVICE_DIR_backend-api) && \
@ -48,9 +72,9 @@ test-stream-server: ## [MID] Run Stream server tests only
lint: ## [MID] Lint everything lint: ## [MID] Lint everything
@$(ECHO_CMD) "${BLUE}🔍 Linting Codebase...${NC}" @$(ECHO_CMD) "${BLUE}🔍 Linting Codebase...${NC}"
@(cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo clippy -- -D warnings) || true @(cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo clippy -- -D warnings)
@(cd $(ROOT)/$(SERVICE_DIR_backend-api) && golangci-lint run ./...) || true @(cd $(ROOT)/$(SERVICE_DIR_backend-api) && golangci-lint run ./...)
@(cd $(ROOT)/$(SERVICE_DIR_web) && npm run lint) || true @(cd $(ROOT)/$(SERVICE_DIR_web) && npm run lint)
lint-web: ## [MID] Lint web app only lint-web: ## [MID] Lint web app only
@(cd $(ROOT)/$(SERVICE_DIR_web) && npm run lint) @(cd $(ROOT)/$(SERVICE_DIR_web) && npm run lint)

View file

@ -14,96 +14,87 @@ test.describe('AUTH — Inscription', () => {
test('02. Inscription avec email + mot de passe valides', async ({ page }) => { test('02. Inscription avec email + mot de passe valides', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await navigateTo(page, '/register'); await navigateTo(page, '/register');
await page.getByTestId('register-form').waitFor({ state: 'visible', timeout: 10_000 }); await expect(page.getByTestId('register-form')).toBeVisible({ timeout: 10_000 });
const uniqueSuffix = Date.now(); const uniqueSuffix = Date.now();
const uniqueEmail = `e2e-${uniqueSuffix}@veza.test`; const uniqueEmail = `e2e-${uniqueSuffix}@veza.test`;
const usernameInput = page.locator('#register-username'); await page.locator('#register-username').fill(`e2e-user-${uniqueSuffix}`);
await usernameInput.waitFor({ state: 'visible', timeout: 5_000 });
await usernameInput.fill(`e2e-user-${uniqueSuffix}`);
await page.locator('#register-email').fill(uniqueEmail); await page.locator('#register-email').fill(uniqueEmail);
await page.locator('#register-password').fill('SecurePass123!@#'); await page.locator('#register-password').fill('SecurePass123!@#');
await page.locator('#register-password_confirm').fill('SecurePass123!@#'); await page.locator('#register-password_confirm').fill('SecurePass123!@#');
// Accept terms — Radix Checkbox renders a visible <button role="checkbox"> with the id // Accept terms
const termsCheckbox = page.locator('#register-terms'); const termsCheckbox = page.locator('#register-terms');
await termsCheckbox.waitFor({ state: 'attached', timeout: 5_000 }); await expect(termsCheckbox).toBeAttached({ timeout: 5_000 });
await termsCheckbox.click({ force: true }); await termsCheckbox.click({ force: true });
await page.waitForTimeout(300); await page.waitForTimeout(300);
const submitBtn = page.getByTestId('register-submit'); const submitBtn = page.getByTestId('register-submit');
await submitBtn.waitFor({ state: 'visible', timeout: 5_000 }); await expect(submitBtn).toBeVisible({ timeout: 5_000 });
await submitBtn.click(); await submitBtn.click();
// After registration, the app shows a verification notice (stays on /register) // Must get a success indication: redirect OR verification notice
// with text "Inscription réussie" / "vérification" — OR redirects — OR shows error const successIndicator = page.getByText(/vérification|verification|email envoyé|check your email|inscription réussie/i)
await Promise.race([ .or(page.locator('[role="status"]').first());
page.waitForURL(url => !url.pathname.includes('/register'), { timeout: 20_000 }),
page.getByText(/vérification|verification|email envoyé|check your email|lien.*envoyé|inscription réussie|réussie/i).waitFor({ timeout: 20_000 }), await expect(
// Also accept rate limit or "already exists" error as valid outcomes successIndicator.first(),
page.getByText(/rate limit|trop de requêtes|existe déjà|already exists|erreur|error/i).waitFor({ timeout: 20_000 }), ).toBeVisible({ timeout: 20_000 });
// Fallback: the role="status" container of the verification notice
page.locator('[role="status"]').first().waitFor({ state: 'visible', timeout: 20_000 }),
]);
}); });
test('03. Inscription avec email déjà existant → erreur claire', async ({ page }) => { test('03. Inscription avec email deja existant -> erreur claire', async ({ page }) => {
await navigateTo(page, '/register'); await navigateTo(page, '/register');
await page.getByTestId('register-form').waitFor({ state: 'visible', timeout: 10_000 }); await expect(page.getByTestId('register-form')).toBeVisible({ timeout: 10_000 });
await page.locator('#register-username').fill('duplicate-user'); await page.locator('#register-username').fill('duplicate-user');
await page.locator('#register-email').fill(CONFIG.users.listener.email); await page.locator('#register-email').fill(CONFIG.users.listener.email);
await page.locator('#register-password').fill('SecurePass123!@#'); await page.locator('#register-password').fill('SecurePass123!@#');
await page.locator('#register-password_confirm').fill('SecurePass123!@#'); await page.locator('#register-password_confirm').fill('SecurePass123!@#');
// Accept terms — Radix Checkbox renders a visible <button role="checkbox"> with the id
const termsCheckbox = page.locator('#register-terms'); const termsCheckbox = page.locator('#register-terms');
await termsCheckbox.waitFor({ state: 'attached', timeout: 5_000 }); await expect(termsCheckbox).toBeAttached({ timeout: 5_000 });
await termsCheckbox.click({ force: true }); await termsCheckbox.click({ force: true });
await page.waitForTimeout(300); await page.waitForTimeout(300);
const submitBtn = page.getByTestId('register-submit'); await page.getByTestId('register-submit').click();
await submitBtn.waitFor({ state: 'visible', timeout: 5_000 });
await submitBtn.click();
// Error message should appear (role="alert" in form, or rate-limit toast) // Must show an error — not silently succeed
const errorAlert = page.getByRole('alert'); const errorIndicator = page.getByRole('alert')
const errorStatus = page.getByRole('status'); .or(page.getByText(/existe déjà|already exists|email.*taken/i));
const errorText = page.getByText(/existe déjà|already exists|email.*taken|trop de requêtes|rate limit|erreur/i); await expect(errorIndicator.first()).toBeVisible({ timeout: 10_000 });
await expect(errorAlert.or(errorStatus).or(errorText).first()).toBeVisible({ timeout: 5_000 });
}); });
test('04. Validation côté client — mot de passe trop court', async ({ page }) => { test('04. Validation cote client — mot de passe trop court', async ({ page }) => {
await navigateTo(page, '/register'); await navigateTo(page, '/register');
await page.locator('#register-password').fill('123'); await page.locator('#register-password').fill('123');
// Tab away to trigger blur validation
await page.locator('#register-password').press('Tab'); await page.locator('#register-password').press('Tab');
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Try submitting the form to also trigger validation if blur doesn't // Also submit to trigger validation
await page.locator('#register-email').fill('valid@test.com'); await page.locator('#register-email').fill('valid@test.com');
await page.locator('#register-username').fill('testuser'); await page.locator('#register-username').fill('testuser');
await page.locator('#register-password_confirm').fill('123'); await page.locator('#register-password_confirm').fill('123');
await page.getByTestId('register-submit').click(); await page.getByTestId('register-submit').click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Should display a validation error — error element has id="register-password-error" role="alert" // Must display a validation error
const errorMsg = page.locator('#register-password-error') const errorMsg = page.locator('#register-password-error')
.or(page.getByRole('alert')) .or(page.getByRole('alert'))
.or(page.getByText(/trop court|too short|minimum|au moins|at least|caractères|doit contenir/i)); .or(page.getByText(/trop court|too short|minimum|au moins|at least|caractères|doit contenir/i));
await expect(errorMsg.first()).toBeVisible({ timeout: 5_000 }); await expect(errorMsg.first()).toBeVisible({ timeout: 5_000 });
}); });
test('05. Validation côté client — email invalide', async ({ page }) => { test('05. Validation cote client — email invalide', async ({ page }) => {
await navigateTo(page, '/register'); await navigateTo(page, '/register');
await page.locator('#register-email').fill('not-an-email'); await page.locator('#register-email').fill('not-an-email');
await page.locator('#register-email').blur(); await page.locator('#register-email').blur();
const errorMsg = page.getByText(/email.*invalide|invalid.*email|format/i); await expect(
await expect(errorMsg).toBeVisible({ timeout: 3_000 }); page.getByText(/email.*invalide|invalid.*email|format/i),
).toBeVisible({ timeout: 3_000 });
}); });
}); });
@ -118,22 +109,19 @@ test.describe('AUTH — Connexion', () => {
test('07. Connexion avec identifiants valides @critical', async ({ page }) => { test('07. Connexion avec identifiants valides @critical', async ({ page }) => {
await loginViaUI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await loginViaUI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
// Verify we are logged in (no longer on /login) // Must not be on /login anymore
await expect(page).not.toHaveURL(/login/); await expect(page).not.toHaveURL(/login/);
// Verify authenticated layout elements are visible (sidebar) // Authenticated layout must be visible
const sidebar = page.getByTestId('app-sidebar'); await expect(page.getByTestId('app-sidebar')).toBeVisible({ timeout: 5_000 });
await expect(sidebar).toBeVisible({ timeout: 5_000 });
}); });
test('08. Connexion avec mauvais mot de passe erreur claire', async ({ page }) => { test('08. Connexion avec mauvais mot de passe -> erreur claire', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await navigateTo(page, '/login'); await navigateTo(page, '/login');
await page.getByTestId('login-form').waitFor({ state: 'visible', timeout: 10_000 }); await expect(page.getByTestId('login-form')).toBeVisible({ timeout: 10_000 });
// Clear pre-filled values (from "Remember me") and fill wrong credentials
const emailInput = page.locator('input[type="email"]'); const emailInput = page.locator('input[type="email"]');
await emailInput.waitFor({ state: 'visible', timeout: 5_000 });
await emailInput.clear(); await emailInput.clear();
await emailInput.fill(CONFIG.users.listener.email); await emailInput.fill(CONFIG.users.listener.email);
const passwordInput = page.locator('input[type="password"]').first(); const passwordInput = page.locator('input[type="password"]').first();
@ -141,31 +129,17 @@ test.describe('AUTH — Connexion', () => {
await passwordInput.fill('WrongPassword123!'); await passwordInput.fill('WrongPassword123!');
await page.getByTestId('login-submit').click(); await page.getByTestId('login-submit').click();
// Wait for the API call to complete and error to render // Must show error AND stay on /login
await page.waitForTimeout(5_000); const errorIndicator = page.getByRole('alert')
.or(page.getByText(/incorrect|invalid|erreur|error|identifiants/i));
// Error should appear — either as role="alert" in the form, or as a rate-limit toast, or as body text await expect(errorIndicator.first()).toBeVisible({ timeout: 15_000 });
const errorAlert = page.getByRole('alert');
const errorText = page.getByText(/incorrect|invalid|erreur|trop de requêtes|rate limit|error|connexion/i);
const hasError = await errorAlert.or(errorText).first().isVisible({ timeout: 10_000 }).catch(() => false);
// Fallback: if no visible error element, just verify we stayed on /login
// (which proves the login was rejected — the error message may be styled differently)
if (!hasError) {
const body = await page.textContent('body') || '';
const hasBodyError = /incorrect|invalid|erreur|error|rate limit|trop de|failed|fetch/i.test(body);
// Either error text is in body, or we're still on /login (both valid outcomes)
expect(hasBodyError || page.url().includes('/login')).toBeTruthy();
}
// Should stay on /login
await expect(page).toHaveURL(/login/); await expect(page).toHaveURL(/login/);
}); });
test('09. Lien mot de passe oublié fonctionne', async ({ page }) => { test('09. Lien mot de passe oublie fonctionne', async ({ page }) => {
await navigateTo(page, '/login'); await navigateTo(page, '/login');
await page.getByTestId('login-form').waitFor({ state: 'visible', timeout: 10_000 }); await expect(page.getByTestId('login-form')).toBeVisible({ timeout: 10_000 });
// The link text is "Forgot password?" rendered as a <Link> (→ <a>)
const forgotLink = page.getByRole('link', { name: /forgot password/i }) const forgotLink = page.getByRole('link', { name: /forgot password/i })
.or(page.locator('a[href="/forgot-password"]')); .or(page.locator('a[href="/forgot-password"]'));
await expect(forgotLink.first()).toBeVisible({ timeout: 8_000 }); await expect(forgotLink.first()).toBeVisible({ timeout: 8_000 });
@ -176,9 +150,8 @@ test.describe('AUTH — Connexion', () => {
test('10. Lien vers inscription depuis la page login', async ({ page }) => { test('10. Lien vers inscription depuis la page login', async ({ page }) => {
await navigateTo(page, '/login'); await navigateTo(page, '/login');
await page.getByTestId('login-form').waitFor({ state: 'visible', timeout: 10_000 }); await expect(page.getByTestId('login-form')).toBeVisible({ timeout: 10_000 });
// The link text is "Don't have an account? Sign up" in AuthLayout footer
const registerLink = page.getByRole('link', { name: /sign up/i }) const registerLink = page.getByRole('link', { name: /sign up/i })
.or(page.locator('a[href="/register"]')); .or(page.locator('a[href="/register"]'));
await expect(registerLink.first()).toBeVisible({ timeout: 8_000 }); await expect(registerLink.first()).toBeVisible({ timeout: 8_000 });
@ -188,28 +161,19 @@ test.describe('AUTH — Connexion', () => {
}); });
}); });
test.describe('AUTH — Sessions et sécurité', () => { test.describe('AUTH — Sessions et securite', () => {
test('11. Redirection vers /login si non authentifié @critical', async ({ page }) => { test('11. Redirection vers /login si non authentifie @critical', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
// Try to access a protected page without auth
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }); await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle').catch(() => {});
// The app loads, calls refreshUser(), then redirects if not authenticated. // Must redirect to login
// This can take a few seconds due to the splash screen and API call.
await expect(page).toHaveURL(/login/, { timeout: 20_000 }); await expect(page).toHaveURL(/login/, { timeout: 20_000 });
}); });
test('12. L\'utilisateur est authentifié après connexion (auth-storage)', async ({ page }) => { test('12. L\'utilisateur est authentifie apres connexion (auth-storage)', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await loginViaUI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await loginViaUI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
// If still on login, skip
if (page.url().includes('/login')) {
console.log(' Login did not redirect — skipping auth-storage check');
return;
}
// Verify isAuthenticated is true in the Zustand auth-storage // Verify isAuthenticated is true in the Zustand auth-storage
const isAuthenticated = await page.evaluate(() => { const isAuthenticated = await page.evaluate(() => {
const raw = localStorage.getItem('auth-storage'); const raw = localStorage.getItem('auth-storage');
@ -222,69 +186,45 @@ test.describe('AUTH — Sessions et sécurité', () => {
} }
}); });
expect(isAuthenticated).toBeTruthy(); expect(isAuthenticated, 'auth-storage should have isAuthenticated=true after login').toBeTruthy();
}); });
test('13. Déconnexion fonctionne correctement', async ({ page }) => { test('13. Deconnexion fonctionne correctement', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await loginViaUI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await loginViaUI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
// If still on login, skip // Find and click sign out — try header menu first, then sidebar
if (page.url().includes('/login')) {
console.log(' Login did not redirect — skipping logout test');
return;
}
// Try Header user menu sign out first (most reliable path)
const userMenu = page.getByTestId('user-menu'); const userMenu = page.getByTestId('user-menu');
if (await userMenu.isVisible({ timeout: 5_000 }).catch(() => false)) { await expect(userMenu).toBeVisible({ timeout: 5_000 });
await userMenu.click(); await userMenu.click();
await page.waitForTimeout(800); await page.waitForTimeout(800);
// Header dropdown has a "Sign Out" / "Déconnexion" button with class text-destructive const signOutBtn = page.locator('button.text-destructive').first()
const signOutBtn = page.locator('button.text-destructive').first() .or(page.locator('button').filter({ hasText: /sign out|déconnexion|logout/i }).first());
.or(page.locator('button').filter({ hasText: /sign out|déconnexion|logout/i }).first()); await expect(signOutBtn).toBeVisible({ timeout: 3_000 });
if (await signOutBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { await signOutBtn.click();
await signOutBtn.click();
// Header logout does window.location.href = '/login' (full page reload)
await page.waitForURL(/login/, { timeout: 20_000 }).catch(() => {});
if (page.url().includes('/login')) return;
}
}
// Fallback: sidebar logout button (aria-label from t('nav.logout')) // Must redirect to /login
const sidebarLogout = page.locator('[data-testid="app-sidebar"] button[aria-label]').filter({ hasText: /logout|déconnexion|sign out/i }).first() await expect(page).toHaveURL(/login/, { timeout: 20_000 });
.or(page.locator('[data-testid="app-sidebar"] button').filter({ hasText: /logout|déconnexion|sign out/i }).first());
if (await sidebarLogout.isVisible({ timeout: 5_000 }).catch(() => false)) {
await sidebarLogout.click();
await page.waitForURL(/login/, { timeout: 20_000 }).catch(() => {});
}
// Verify we ended up on /login, or at minimum that auth was cleared // Auth state must be cleared
const logoutUrl = page.url(); const isAuthenticated = await page.evaluate(() => {
if (logoutUrl.includes('/login')) return;
// If still not on /login, check that auth state was cleared
await page.waitForTimeout(2_000);
const isStillAuth = await page.evaluate(() => {
const raw = localStorage.getItem('auth-storage'); const raw = localStorage.getItem('auth-storage');
if (!raw) return false; if (!raw) return false;
try { return JSON.parse(raw)?.state?.isAuthenticated === true; } catch { return false; } try {
return JSON.parse(raw)?.state?.isAuthenticated === true;
} catch {
return false;
}
}); });
// If auth is still set, the logout didn't work — but we don't hard-fail if expect(isAuthenticated, 'auth-storage should be cleared after logout').toBeFalsy();
// the sign out button was never found (UI may differ between runs)
if (isStillAuth) {
console.log(' Warning: logout did not clear auth state (sign out button may not have been found)');
}
}); });
test('14. Protection CSRF — la page login charge sans erreur CSRF', async ({ page }) => { test('14. Protection CSRF — la page login charge sans erreur CSRF', async ({ page }) => {
await navigateTo(page, '/login'); await navigateTo(page, '/login');
// Verify the page loads without CSRF errors
const body = await page.textContent('body') || ''; const body = await page.textContent('body') || '';
expect(body).not.toMatch(/csrf.*error|forbidden/i); expect(body).not.toMatch(/csrf.*error|forbidden/i);
expect(true).toBeTruthy(); // Pass if no crash
}); });
}); });
@ -292,15 +232,11 @@ test.describe('AUTH — OAuth', () => {
test('15. Boutons OAuth visibles sur la page login', async ({ page }) => { test('15. Boutons OAuth visibles sur la page login', async ({ page }) => {
await navigateTo(page, '/login'); await navigateTo(page, '/login');
// Check for OAuth provider buttons // At least one OAuth provider button must be visible
const oauthProviders = ['google', 'github', 'discord', 'spotify']; const oauthBtn = page.getByRole('button', { name: /google|github|discord|spotify/i }).first()
for (const provider of oauthProviders) { .or(page.locator('[data-provider]').first())
const btn = page.getByRole('button', { name: new RegExp(provider, 'i') }) .or(page.locator('a[href*="oauth"]').first());
.or(page.locator(`[data-provider="${provider}"]`))
.or(page.locator(`a[href*="${provider}"]`));
const isVisible = await btn.isVisible().catch(() => false); await expect(oauthBtn).toBeVisible({ timeout: 5_000 });
console.log(` OAuth ${provider}: ${isVisible ? 'visible' : 'absent'}`);
}
}); });
}); });

View file

@ -1,19 +1,6 @@
import { test, expect } from '@chromatic-com/playwright'; import { test, expect } from '@chromatic-com/playwright';
import { loginViaAPI, CONFIG, navigateTo, navigateToPageWithTracks, assertPlayerVisible, playFirstTrack } from './helpers'; import { loginViaAPI, CONFIG, navigateTo, navigateToPageWithTracks, assertPlayerVisible, playFirstTrack } from './helpers';
/**
* Helper: attempt to play a track and check if the global player appeared.
* Returns true if player is visible, false otherwise.
*/
async function tryPlayAndCheckPlayer(page: import('@playwright/test').Page): Promise<boolean> {
await playFirstTrack(page);
const player = page.getByTestId('global-player');
return await player.isVisible({ timeout: 5_000 }).catch(() => false);
}
// BUG APP: Le feed crashe avec "Cannot convert object to primitive value" (FeedPage.tsx).
// Aucune page ne rend de TrackCard [role="article"], donc tous les tests player échouent au beforeEach.
// TODO: Corriger le bug de rendu feed pour que les tests player puissent trouver des tracks à jouer.
test.describe('PLAYER — Lecteur audio', () => { test.describe('PLAYER — Lecteur audio', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
@ -21,71 +8,69 @@ test.describe('PLAYER — Lecteur audio', () => {
test('01. Clic sur play lance la lecture d\'un track @critical', async ({ page }) => { test('01. Clic sur play lance la lecture d\'un track @critical', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
test.skip(!hasTracks, 'No tracks in database — seed required');
const trackCard = page.locator('[role="article"]').first(); const trackCard = page.locator('[role="article"]').first();
// Hover the card to reveal the play button overlay
await trackCard.hover(); await trackCard.hover();
await page.waitForTimeout(300); await page.waitForTimeout(300);
// Play button on the TrackCard cover: aria-label="Lire {title}"
const playBtn = page.getByRole('button', { name: /^Lire /i }).first(); const playBtn = page.getByRole('button', { name: /^Lire /i }).first();
await expect(playBtn).toBeVisible({ timeout: 5_000 }); await expect(playBtn).toBeVisible({ timeout: 5_000 });
await playBtn.click(); await playBtn.click();
// The global player bar must appear
await assertPlayerVisible(page); await assertPlayerVisible(page);
}); });
test('02. Le player affiche titre + artiste du track en cours', async ({ page }) => { test('02. Le player affiche titre + artiste du track en cours', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
const playerVisible = await tryPlayAndCheckPlayer(page); test.skip(!hasTracks, 'No tracks in database — seed required');
await playFirstTrack(page);
const player = await assertPlayerVisible(page); const player = await assertPlayerVisible(page);
// Track info section has aria-label="Track info" // Track info must be visible with real content
const trackInfo = player.locator('[aria-label="Track info"]'); const trackInfo = player.locator('[aria-label="Track info"]');
await expect(trackInfo).toBeVisible({ timeout: 5_000 }); await expect(trackInfo).toBeVisible({ timeout: 5_000 });
// Title is an h3 element inside track info
const title = trackInfo.locator('h3'); const title = trackInfo.locator('h3');
await expect(title).toBeVisible(); await expect(title).toBeVisible();
const titleText = await title.textContent(); const titleText = await title.textContent();
expect(titleText?.trim().length).toBeGreaterThan(0); expect(titleText?.trim().length, 'Track title must not be empty').toBeGreaterThan(0);
expect(titleText).not.toMatch(/undefined|null|NaN/); expect(titleText, 'Track title must not contain debug text').not.toMatch(/undefined|null|NaN/);
// Artist is a p element with text-muted-foreground
const artist = trackInfo.locator('p'); const artist = trackInfo.locator('p');
await expect(artist).toBeVisible(); await expect(artist).toBeVisible();
const artistText = await artist.textContent(); const artistText = await artist.textContent();
expect(artistText?.trim().length).toBeGreaterThan(0); expect(artistText?.trim().length, 'Artist name must not be empty').toBeGreaterThan(0);
expect(artistText).not.toMatch(/undefined|null|NaN/); expect(artistText, 'Artist name must not contain debug text').not.toMatch(/undefined|null|NaN/);
}); });
test('03. Bouton play/pause toggle fonctionne', async ({ page }) => { test('03. Bouton play/pause toggle fonctionne', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
const playerVisible = await tryPlayAndCheckPlayer(page); test.skip(!hasTracks, 'No tracks in database — seed required');
await playFirstTrack(page);
const player = await assertPlayerVisible(page); const player = await assertPlayerVisible(page);
// DOM vérifié: le bouton play/pause a data-testid="play-button", PAS d'aria-label
const playPauseBtn = player.getByTestId('play-button'); const playPauseBtn = player.getByTestId('play-button');
await expect(playPauseBtn).toBeVisible({ timeout: 5_000 }); await expect(playPauseBtn).toBeVisible({ timeout: 5_000 });
// Click to toggle — the button switches between Play and Pause SVG icons // Toggle should not crash
await playPauseBtn.click(); await playPauseBtn.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Click again to toggle back
await playPauseBtn.click(); await playPauseBtn.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
// No crash = success
// Button must still be interactive after toggling
await expect(playPauseBtn).toBeVisible();
await expect(playPauseBtn).toBeEnabled();
}); });
test('04. La barre de progression est visible et interactive', async ({ page }) => { test('04. La barre de progression est visible et interactive', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
test.skip(!hasTracks, 'No tracks in database — seed required');
// Must actually play a track — the progress bar only renders when a track is loaded (!isIdle) // Play a track to activate the progress bar
const trackCard = page.locator('[role="article"]').first(); const trackCard = page.locator('[role="article"]').first();
await trackCard.hover(); await trackCard.hover();
await page.waitForTimeout(300); await page.waitForTimeout(300);
@ -95,63 +80,50 @@ test.describe('PLAYER — Lecteur audio', () => {
const player = await assertPlayerVisible(page); const player = await assertPlayerVisible(page);
// Progress bar: role="slider" aria-label="Progression" // Progress bar must be visible
// Rendered only when a track is loaded (not idle state)
const progressBar = player.locator('[role="slider"][aria-label="Progression"]'); const progressBar = player.locator('[role="slider"][aria-label="Progression"]');
await expect(progressBar).toBeVisible({ timeout: 10_000 }); await expect(progressBar).toBeVisible({ timeout: 10_000 });
const box = await progressBar.boundingBox(); const box = await progressBar.boundingBox();
expect(box).not.toBeNull(); expect(box, 'Progress bar must have a bounding box').not.toBeNull();
expect(box!.width).toBeGreaterThan(50); expect(box!.width, 'Progress bar must have substantial width').toBeGreaterThan(50);
// Verify ARIA attributes // ARIA attributes must be set correctly
const valueMin = await progressBar.getAttribute('aria-valuemin'); await expect(progressBar).toHaveAttribute('aria-valuemin', '0');
const valueMax = await progressBar.getAttribute('aria-valuemax'); const valueMax = await progressBar.getAttribute('aria-valuemax');
expect(valueMin).toBe('0'); expect(Number(valueMax), 'aria-valuemax must be >= 0').toBeGreaterThanOrEqual(0);
expect(Number(valueMax)).toBeGreaterThanOrEqual(0);
// Test keyboard interaction: ArrowRight should change aria-valuenow
const valueBefore = Number(await progressBar.getAttribute('aria-valuenow') || '0');
await progressBar.focus();
await progressBar.press('ArrowRight');
// The progress bar responds to ArrowRight with +2% seek
// (value may or may not change depending on playback state, but no crash)
}); });
test('05. Controle du volume fonctionne', async ({ page }) => { test('05. Controle du volume fonctionne', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
const playerVisible = await tryPlayAndCheckPlayer(page); test.skip(!hasTracks, 'No tracks in database — seed required');
await playFirstTrack(page);
const player = await assertPlayerVisible(page); const player = await assertPlayerVisible(page);
// Mute button: aria-label="Mute" or "Unmute" // Mute button must exist
const muteBtn = player.getByRole('button', { name: /^mute$|^unmute$/i }).first(); const muteBtn = player.getByRole('button', { name: /^mute$|^unmute$/i }).first();
const muteVisible = await muteBtn.isVisible().catch(() => false); await expect(muteBtn).toBeVisible({ timeout: 5_000 });
console.log(` Mute button: ${muteVisible ? 'visible' : 'not visible'}`);
expect(muteVisible).toBe(true);
if (muteVisible) { // Click mute — label must toggle
// Click mute const initialLabel = await muteBtn.getAttribute('aria-label');
const initialLabel = await muteBtn.getAttribute('aria-label'); await muteBtn.click();
await muteBtn.click(); await page.waitForTimeout(300);
await page.waitForTimeout(300);
// The label should toggle between Mute and Unmute const newLabel = await player.getByRole('button', { name: /^mute$|^unmute$/i }).first().getAttribute('aria-label');
const newLabel = await player.getByRole('button', { name: /^mute$|^unmute$/i }).first().getAttribute('aria-label'); expect(newLabel, 'Mute button label must change after click').not.toBe(initialLabel);
expect(newLabel).not.toBe(initialLabel);
// Click again to restore // Click again to restore
await player.getByRole('button', { name: /^mute$|^unmute$/i }).first().click(); await player.getByRole('button', { name: /^mute$|^unmute$/i }).first().click();
}
}); });
test('06. Boutons next/previous sont presents', async ({ page }) => { test('06. Boutons next/previous sont presents et actifs', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
const playerVisible = await tryPlayAndCheckPlayer(page); test.skip(!hasTracks, 'No tracks in database — seed required');
await playFirstTrack(page);
const player = await assertPlayerVisible(page); const player = await assertPlayerVisible(page);
// DOM vérifié: les boutons ont data-testid="prev-button", "play-button", "next-button"
const prevBtn = player.getByTestId('prev-button'); const prevBtn = player.getByTestId('prev-button');
const playBtn = player.getByTestId('play-button'); const playBtn = player.getByTestId('play-button');
const nextBtn = player.getByTestId('next-button'); const nextBtn = player.getByTestId('next-button');
@ -159,45 +131,48 @@ test.describe('PLAYER — Lecteur audio', () => {
await expect(prevBtn).toBeVisible({ timeout: 5_000 }); await expect(prevBtn).toBeVisible({ timeout: 5_000 });
await expect(playBtn).toBeVisible(); await expect(playBtn).toBeVisible();
await expect(nextBtn).toBeVisible(); await expect(nextBtn).toBeVisible();
console.log(' Prev/Play/Next buttons all visible');
// All transport buttons must be enabled
await expect(prevBtn).toBeEnabled();
await expect(playBtn).toBeEnabled();
await expect(nextBtn).toBeEnabled();
}); });
test('07. Affichage du temps actuel / duree totale', async ({ page }) => { test('07. Affichage du temps actuel / duree totale', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
const playerVisible = await tryPlayAndCheckPlayer(page); test.skip(!hasTracks, 'No tracks in database — seed required');
await playFirstTrack(page);
const player = await assertPlayerVisible(page); const player = await assertPlayerVisible(page);
await page.waitForTimeout(2_000); await page.waitForTimeout(2_000);
// DOM vérifié: le temps est dans la section region "Playback controls" // Time display must show at least one timestamp in X:XX format
// sous forme de generic elements contenant "0:00", "6:50" etc.
const playbackControls = player.locator('[aria-label="Playback controls"]'); const playbackControls = player.locator('[aria-label="Playback controls"]');
await expect(playbackControls).toBeVisible();
// Look for time format "X:XX" — time elements are direct children of playback controls
const timeTexts = playbackControls.locator(':text-matches("\\\\d+:\\\\d{2}")'); const timeTexts = playbackControls.locator(':text-matches("\\\\d+:\\\\d{2}")');
const count = await timeTexts.count(); const count = await timeTexts.count();
expect(count, 'At least one time display must be present').toBeGreaterThanOrEqual(1);
if (count >= 1) { const text = await timeTexts.first().textContent();
const text = await timeTexts.first().textContent(); expect(text, 'Time must match X:XX format').toMatch(/\d+:\d{2}/);
console.log(` Time displayed: "${text}"`);
expect(text).toMatch(/\d+:\d{2}/);
} else {
console.log(' Time display not found (may be hidden on small viewports)');
}
}); });
test('08. Raccourcis clavier — Espace toggle play/pause', async ({ page }) => { test('08. Raccourcis clavier — Espace toggle play/pause', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
const playerVisible = await tryPlayAndCheckPlayer(page); test.skip(!hasTracks, 'No tracks in database — seed required');
await playFirstTrack(page);
await page.waitForTimeout(1_000); await page.waitForTimeout(1_000);
// Press Space to toggle play/pause (keyboard shortcuts are handled by useKeyboardShortcuts) // Press Space — must not crash
await page.keyboard.press('Space'); await page.keyboard.press('Space');
await page.waitForTimeout(500); await page.waitForTimeout(500);
// At minimum, no crash should occur // Page must still be functional (no crash, no error)
const body = await page.textContent('body') || ''; const body = await page.textContent('body') || '';
expect(body).not.toMatch(/error|crash/i); expect(body).not.toMatch(/error|crash/i);
await assertPlayerVisible(page);
}); });
}); });
@ -208,275 +183,179 @@ test.describe('PLAYER — Queue de lecture', () => {
test('09. Ouvrir la queue de lecture', async ({ page }) => { test('09. Ouvrir la queue de lecture', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
const playerVisible = await tryPlayAndCheckPlayer(page); test.skip(!hasTracks, 'No tracks in database — seed required');
await playFirstTrack(page);
const player = await assertPlayerVisible(page); const player = await assertPlayerVisible(page);
// Queue toggle button: aria-label="Show queue" or "Hide queue" // Queue toggle must exist
const queueBtn = player.getByRole('button', { name: /^show queue$|^hide queue$/i }).first(); const queueBtn = player.getByRole('button', { name: /^show queue$|^hide queue$/i }).first();
await expect(queueBtn).toBeVisible({ timeout: 5_000 }); await expect(queueBtn).toBeVisible({ timeout: 5_000 });
// Verify initial state is "Show queue" // Must start as "Show queue"
const initialLabel = await queueBtn.getAttribute('aria-label'); const initialLabel = await queueBtn.getAttribute('aria-label');
expect(initialLabel).toMatch(/show queue/i); expect(initialLabel).toMatch(/show queue/i);
// Click to open queue // Click to open — must change to "Hide queue"
await queueBtn.click(); await queueBtn.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
// After opening, the button label should change to "Hide queue"
const updatedLabel = await player.getByRole('button', { name: /^hide queue$/i }).first().getAttribute('aria-label'); const updatedLabel = await player.getByRole('button', { name: /^hide queue$/i }).first().getAttribute('aria-label');
expect(updatedLabel).toMatch(/hide queue/i); expect(updatedLabel).toMatch(/hide queue/i);
}); });
test('10. Ajouter un track a la queue ("play next" / "add to queue")', async ({ page }) => { test('10. Menu contextuel track — option ajouter a la queue', async ({ page }) => {
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
test.skip(!hasTracks, 'No tracks in database — seed required');
// Find a track card (role="article")
const trackCard = page.locator('[role="article"]').first(); const trackCard = page.locator('[role="article"]').first();
await expect(trackCard).toBeVisible();
await trackCard.hover();
await page.waitForTimeout(300);
if (await trackCard.isVisible().catch(() => false)) { // "More options" button must exist on track cards
// Hover to reveal action buttons const moreBtn = trackCard.getByRole('button', { name: /plus d'options/i }).first();
await trackCard.hover(); await expect(moreBtn).toBeVisible({ timeout: 5_000 });
await page.waitForTimeout(300); await moreBtn.click({ force: true });
// Look for "More options" button: aria-label="Plus d'options pour {title}" // Context menu must appear with queue-related option
const moreBtn = trackCard.getByRole('button', { name: /plus d'options/i }).first(); const menuItem = page.getByRole('menuitem', { name: /queue|file d'attente|ajouter/i });
if (await moreBtn.isVisible().catch(() => false)) { await expect(menuItem).toBeVisible({ timeout: 3_000 });
// Use force:true because the play button overlay can intercept pointer events
await moreBtn.click({ force: true });
// Look for queue-related menu item
const addToQueueOption = page.getByRole('menuitem', { name: /queue|file d'attente|ajouter/i });
const isVisible = await addToQueueOption.isVisible().catch(() => false);
console.log(` Option "Add to queue": ${isVisible ? 'found' : 'not found'}`);
} else {
console.log(' More options button not visible');
}
}
}); });
}); });
// ─── PLAYER AVANCE ──────────────────────────────────────────────────────
test.describe('PLAYER — Controles avances @critical', () => { test.describe('PLAYER — Controles avances @critical', () => {
test.setTimeout(60_000); test.setTimeout(60_000);
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password); await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
if (page.url().includes('/login')) return; // Login failed, tests will skip
const hasTracks = await navigateToPageWithTracks(page); const hasTracks = await navigateToPageWithTracks(page);
if (!hasTracks) return; // No tracks, tests will skip test.skip(!hasTracks, 'No tracks in database — seed required');
// Wrap playFirstTrack in try/catch — it may timeout if no play button is found await playFirstTrack(page);
try { await assertPlayerVisible(page);
await playFirstTrack(page);
} catch {
// Player may not be available, tests will check and skip
}
// Wait for player to appear
await page.getByTestId('global-player').waitFor({ state: 'visible', timeout: 15_000 }).catch(() => {});
}); });
test('Toggle shuffle — le bouton change d\'etat visuel @critical', async ({ page }) => { test('Toggle shuffle — le bouton change d\'etat visuel @critical', async ({ page }) => {
const playerVisible = await page.getByTestId('global-player').isVisible().catch(() => false);
const shuffleBtn = page.locator('button').filter({ has: page.locator('[aria-label*="elanger" i]') }).first() const shuffleBtn = page.locator('button').filter({ has: page.locator('[aria-label*="elanger" i]') }).first()
.or(page.getByRole('button', { name: /melanger|shuffle/i }).first()); .or(page.getByRole('button', { name: /melanger|shuffle/i }).first());
if (await shuffleBtn.isVisible({ timeout: 5000 }).catch(() => false)) { await expect(shuffleBtn).toBeVisible({ timeout: 5_000 });
// Initial state: off
const initialPressed = await shuffleBtn.getAttribute('aria-pressed');
// Click to enable // Toggle on
await shuffleBtn.click(); const initialPressed = await shuffleBtn.getAttribute('aria-pressed');
await page.waitForTimeout(300); await shuffleBtn.click();
const afterClick = await shuffleBtn.getAttribute('aria-pressed'); await page.waitForTimeout(300);
const afterClick = await shuffleBtn.getAttribute('aria-pressed');
// Click again to disable // Toggle off
await shuffleBtn.click(); await shuffleBtn.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
const afterSecondClick = await shuffleBtn.getAttribute('aria-pressed'); const afterSecondClick = await shuffleBtn.getAttribute('aria-pressed');
// Verify toggle behavior // Verify toggle behavior
if (initialPressed === 'false') { if (initialPressed === 'false') {
expect(afterClick).toBe('true'); expect(afterClick, 'Shuffle should be on after first click').toBe('true');
expect(afterSecondClick).toBe('false'); expect(afterSecondClick, 'Shuffle should be off after second click').toBe('false');
}
// At minimum, verify the button is interactive
expect(shuffleBtn).toBeTruthy();
} else {
// Shuffle might only be in expanded player or queue
const queueBtn = page.getByTestId('queue-button');
if (await queueBtn.isVisible().catch(() => false)) {
await queueBtn.click();
await page.waitForTimeout(500);
}
// Try expanded player
const trackInfo = page.locator('[aria-label="Track info"]').first();
if (await trackInfo.isVisible().catch(() => false)) {
await trackInfo.click();
await page.waitForTimeout(500);
}
const shuffleBtnExpanded = page.getByRole('button', { name: /melanger|shuffle/i }).first();
const expandedVisible = await shuffleBtnExpanded.or(page.locator('button:has([class*="Shuffle"])')).isVisible({ timeout: 5000 }).catch(() => false);
console.log(` Shuffle in expanded player: ${expandedVisible ? 'visible' : 'not found'}`);
// Soft assertion: shuffle may not be available in all player states
} }
}); });
test('Cycle repeat off → track → playlist → off @critical', async ({ page }) => { test('Cycle repeat off -> track -> playlist -> off @critical', async ({ page }) => {
const playerVisible = await page.getByTestId('global-player').isVisible().catch(() => false);
// Try finding repeat button in the player bar or expanded player
let repeatBtn = page.getByRole('button', { name: /repeter|repeat/i }).first(); let repeatBtn = page.getByRole('button', { name: /repeter|repeat/i }).first();
if (!await repeatBtn.isVisible({ timeout: 3000 }).catch(() => false)) { // If not visible in bar, try expanded player
// Open expanded player if (!await repeatBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
const trackInfo = page.locator('[aria-label="Track info"]').first(); const trackInfo = page.locator('[aria-label="Track info"]').first();
if (await trackInfo.isVisible().catch(() => false)) { await expect(trackInfo).toBeVisible();
await trackInfo.click(); await trackInfo.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
}
repeatBtn = page.getByRole('button', { name: /repeter|repeat/i }).first(); repeatBtn = page.getByRole('button', { name: /repeter|repeat/i }).first();
} }
if (await repeatBtn.isVisible({ timeout: 5000 }).catch(() => false)) { await expect(repeatBtn).toBeVisible({ timeout: 5_000 });
// State 1: off
const label1 = await repeatBtn.getAttribute('aria-label') || '';
expect(label1.toLowerCase()).toContain('desactiv');
// Click -> track // State 1: off
await repeatBtn.click(); const label1 = (await repeatBtn.getAttribute('aria-label') || '').toLowerCase();
await page.waitForTimeout(300); expect(label1).toContain('desactiv');
const label2 = await repeatBtn.getAttribute('aria-label') || '';
expect(label2.toLowerCase()).toMatch(/piste|track/);
// Click -> playlist // Click -> track
await repeatBtn.click(); await repeatBtn.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
const label3 = await repeatBtn.getAttribute('aria-label') || ''; const label2 = (await repeatBtn.getAttribute('aria-label') || '').toLowerCase();
expect(label3.toLowerCase()).toMatch(/playlist/); expect(label2).toMatch(/piste|track/);
// Click -> off // Click -> playlist
await repeatBtn.click(); await repeatBtn.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
const label4 = await repeatBtn.getAttribute('aria-label') || ''; const label3 = (await repeatBtn.getAttribute('aria-label') || '').toLowerCase();
expect(label4.toLowerCase()).toContain('desactiv'); expect(label3).toMatch(/playlist/);
}
// Click -> off
await repeatBtn.click();
await page.waitForTimeout(300);
const label4 = (await repeatBtn.getAttribute('aria-label') || '').toLowerCase();
expect(label4).toContain('desactiv');
}); });
test('Controle vitesse de lecture — changement visible @critical', async ({ page }) => { test('Controle vitesse de lecture — changement visible @critical', async ({ page }) => {
const playerVisible = await page.getByTestId('global-player').isVisible().catch(() => false); // Open expanded player
// Open expanded player to find speed control
const trackInfo = page.locator('[aria-label="Track info"]').first(); const trackInfo = page.locator('[aria-label="Track info"]').first();
if (await trackInfo.isVisible().catch(() => false)) { await expect(trackInfo).toBeVisible();
await trackInfo.click(); await trackInfo.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
}
const speedBtn = page.locator('[aria-label*="Vitesse de lecture"]').first() const speedBtn = page.locator('[aria-label*="Vitesse de lecture"]').first()
.or(page.locator('button:has-text("1x")').first()); .or(page.locator('button:has-text("1x")').first());
const speedVisible = await speedBtn.isVisible({ timeout: 5000 }).catch(() => false); await expect(speedBtn).toBeVisible({ timeout: 5_000 });
const speedEnabled = speedVisible && !(await speedBtn.isDisabled().catch(() => true)); await expect(speedBtn).toBeEnabled();
if (speedVisible && speedEnabled) {
// Click to open speed menu
await speedBtn.click();
await page.waitForTimeout(300);
// Look for speed options await speedBtn.click();
const option15 = page.locator('text="1.5x"').first(); await page.waitForTimeout(300);
if (await option15.isVisible({ timeout: 2000 }).catch(() => false)) {
await option15.click();
await page.waitForTimeout(300);
// Verify the button now shows 1.5x // Speed option must appear
const updatedLabel = await speedBtn.getAttribute('aria-label') || ''; const option15 = page.locator('text="1.5x"').first();
expect(updatedLabel).toContain('1.5'); await expect(option15).toBeVisible({ timeout: 2_000 });
} await option15.click();
} await page.waitForTimeout(300);
// Button must now show 1.5x
const updatedLabel = await speedBtn.getAttribute('aria-label') || '';
expect(updatedLabel, 'Speed button should show 1.5x after selection').toContain('1.5');
}); });
test('Clic sur track info ouvre le player en vue etendue @critical', async ({ page }) => { test('Clic sur track info ouvre le player en vue etendue @critical', async ({ page }) => {
const playerVisible = await page.getByTestId('global-player').isVisible().catch(() => false);
const trackInfo = page.locator('[aria-label="Track info"]').first(); const trackInfo = page.locator('[aria-label="Track info"]').first();
await expect(trackInfo).toBeVisible({ timeout: 5000 }); await expect(trackInfo).toBeVisible({ timeout: 5_000 });
// Click to open expanded player
await trackInfo.click(); await trackInfo.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Verify expanded player is visible (fixed inset-0 overlay) // Expanded player overlay must appear
const expandedPlayer = page.locator('.fixed.inset-0').filter({ hasText: /.+/ }).first() const expandedPlayer = page.locator('.fixed.inset-0').filter({ hasText: /.+/ }).first()
.or(page.locator('[class*="backdrop-blur-3xl"]').first()); .or(page.locator('[class*="backdrop-blur-3xl"]').first());
await expect(expandedPlayer).toBeVisible({ timeout: 3_000 });
// Verify key elements: large artwork, controls // Must have a close button
const hasExpandedContent = await expandedPlayer.isVisible({ timeout: 3000 }).catch(() => false); const closeBtn = expandedPlayer.locator('button').first();
await expect(closeBtn).toBeVisible();
if (hasExpandedContent) { await closeBtn.click();
// Look for close button (ChevronDown) await page.waitForTimeout(300);
const closeBtn = expandedPlayer.locator('button').first();
expect(closeBtn).toBeTruthy();
// Close expanded player
await closeBtn.click();
await page.waitForTimeout(300);
}
}); });
test('Reglage crossfade accessible dans le player etendu @critical', async ({ page }) => { test('Queue — ouvrir et voir le contenu @critical', async ({ page }) => {
const playerVisible = await page.getByTestId('global-player').isVisible().catch(() => false); const player = await assertPlayerVisible(page);
// Open expanded player const queueBtn = player.getByTestId('queue-button')
const trackInfo = page.locator('[aria-label="Track info"]').first(); .or(player.getByRole('button', { name: /^show queue$/i }));
if (await trackInfo.isVisible().catch(() => false)) { await expect(queueBtn).toBeVisible({ timeout: 5_000 });
await trackInfo.click(); await queueBtn.click();
await page.waitForTimeout(500); await page.waitForTimeout(500);
}
// Look for audio settings button (Settings2 icon) // Queue panel must be visible with content
const settingsBtn = page.locator('button').filter({ has: page.locator('[class*="Settings2"], [class*="settings"]') }).first() const queuePanel = page.locator('text=/play queue|file d.attente|your queue/i').first();
.or(page.getByRole('button', { name: /audio settings|parametres audio/i }).first()); await expect(queuePanel).toBeVisible({ timeout: 3_000 });
if (await settingsBtn.isVisible({ timeout: 3000 }).catch(() => false)) { // Close queue
await settingsBtn.click(); await queueBtn.click();
await page.waitForTimeout(500);
}
// Find crossfade control
const crossfadeSlider = page.locator('[aria-label="Crossfade duration"]').first()
.or(page.locator('text=/crossfade/i').first());
const hasCrossfade = await crossfadeSlider.isVisible({ timeout: 5000 }).catch(() => false);
if (hasCrossfade) {
expect(crossfadeSlider).toBeTruthy();
}
// Also check for normalization toggle
const normToggle = page.locator('[role="switch"]').first();
if (await normToggle.isVisible({ timeout: 2000 }).catch(() => false)) {
const checked = await normToggle.getAttribute('aria-checked');
expect(checked).toBeTruthy(); // Should have a value
}
});
test('Queue — ajouter, voir, reordonner, vider @critical', async ({ page }) => {
const playerVisible = await page.getByTestId('global-player').isVisible().catch(() => false);
// Open queue
const queueBtn = page.getByTestId('queue-button');
if (await queueBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await queueBtn.click();
await page.waitForTimeout(500);
// Queue should be visible
const queuePanel = page.locator('text=/play queue|file d.attente/i').first()
.or(page.locator('text=/your queue is empty/i').first());
await expect(queuePanel).toBeVisible({ timeout: 3000 });
// Close queue
await queueBtn.click();
}
}); });
}); });

View file

@ -7,8 +7,8 @@ test.describe('SOCIAL — Follow/Unfollow', () => {
}); });
test('01. Bouton follow visible sur un profil artiste @critical', async ({ page }) => { test('01. Bouton follow visible sur un profil artiste @critical', async ({ page }) => {
// Navigate directly to a known artist profile (seed user amelie_dubois) // Navigate directly to a known artist profile (seed user top_artist)
await navigateTo(page, '/u/amelie_dubois'); await navigateTo(page, `/u/${CONFIG.users.creator.username}`);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// FollowButton renders "Suivre" (unfollowed) or "Abonne" (followed) // FollowButton renders "Suivre" (unfollowed) or "Abonne" (followed)
@ -75,7 +75,7 @@ test.describe('SOCIAL — Profils', () => {
test('05. L\'historique d\'écoute est privé (pas visible par d\'autres)', async ({ page }) => { test('05. L\'historique d\'écoute est privé (pas visible par d\'autres)', async ({ page }) => {
// Navigate to another user's public profile at /u/:username // Navigate to another user's public profile at /u/:username
await navigateTo(page, '/u/amelie_dubois'); await navigateTo(page, `/u/${CONFIG.users.creator.username}`);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Listening history must NOT be visible on someone else's public profile // Listening history must NOT be visible on someone else's public profile
@ -84,7 +84,7 @@ test.describe('SOCIAL — Profils', () => {
}); });
test('06. Profil artiste affiche les stats (tracks, followers)', async ({ page }) => { test('06. Profil artiste affiche les stats (tracks, followers)', async ({ page }) => {
await navigateTo(page, '/u/amelie_dubois'); await navigateTo(page, `/u/${CONFIG.users.creator.username}`);
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
const body = await page.textContent('body') || ''; const body = await page.textContent('body') || '';
@ -97,7 +97,7 @@ test.describe('SOCIAL — Profils', () => {
console.log(` Stats Followers: ${hasFollowersLabel ? '✓' : '✗'}`); console.log(` Stats Followers: ${hasFollowersLabel ? '✓' : '✗'}`);
// Username should be visible (displayed as @username) // Username should be visible (displayed as @username)
const hasUsername = body.includes('amelie_dubois'); const hasUsername = body.includes(CONFIG.users.creator.username);
console.log(` Username visible: ${hasUsername ? '✓' : '✗'}`); console.log(` Username visible: ${hasUsername ? '✓' : '✗'}`);
}); });
}); });

View file

@ -3,10 +3,10 @@ import { loginViaAPI, CONFIG, navigateTo } from './helpers';
/** /**
* UPLOAD - Track upload flow tests * UPLOAD - Track upload flow tests
* Selectors based on UploadModal.tsx, UploadModalDropzone.tsx, UploadModalMetadataForm.tsx * STRICT: every step must succeed or the test fails.
* No silent skips, no console.log fallbacks.
*/ */
// Create a minimal valid MP3 buffer for testing
function createTestMP3Buffer(): Buffer { function createTestMP3Buffer(): Buffer {
return Buffer.from( return Buffer.from(
'4944330300000000000a544954320000000500000054657374fffb90440000000000000000000000000000000000000000', '4944330300000000000a544954320000000500000054657374fffb90440000000000000000000000000000000000000000',
@ -16,202 +16,176 @@ function createTestMP3Buffer(): Buffer {
test.describe('UPLOAD - Track upload flow @critical', () => { test.describe('UPLOAD - Track upload flow @critical', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Login as creator (has upload permissions)
await loginViaAPI(page, CONFIG.users.creator.email, CONFIG.users.creator.password); await loginViaAPI(page, CONFIG.users.creator.email, CONFIG.users.creator.password);
}); });
test('should complete full upload flow: file, metadata, publish, visible in library @critical', async ({ page }) => { test('should show upload button on library page', async ({ page }) => {
await navigateTo(page, '/library'); await navigateTo(page, '/library');
// Find and click upload button
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first(); const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
const uploadVisible = await uploadBtn.isVisible({ timeout: 10_000 }).catch(() => false); await expect(uploadBtn, 'Upload button must be visible on /library').toBeVisible({ timeout: 10_000 });
if (!uploadVisible) { });
console.log(' Upload button not found — skipping');
return; test('should open upload modal when clicking upload button', async ({ page }) => {
} await navigateTo(page, '/library');
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
await expect(uploadBtn).toBeVisible({ timeout: 10_000 });
await uploadBtn.click(); await uploadBtn.click();
await page.waitForTimeout(500);
// Wait for upload modal/dialog
const dialog = page.locator('[role="dialog"]').first(); const dialog = page.locator('[role="dialog"]').first();
const dialogVisible = await dialog.isVisible({ timeout: 5000 }).catch(() => false); await expect(dialog, 'Upload dialog must appear after clicking upload').toBeVisible({ timeout: 5_000 });
if (!dialogVisible) {
console.log(' Upload dialog did not appear — skipping');
return;
}
// Set file via the hidden input inside the dropzone // Dialog must contain a file input
const fileInput = dialog.locator('input[type="file"]');
expect(await fileInput.count(), 'File input must exist in upload dialog').toBeGreaterThan(0);
});
test('should complete full upload flow: file, metadata, publish @critical', async ({ page }) => {
test.setTimeout(120_000);
await navigateTo(page, '/library');
// Step 1: Open upload modal
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
await expect(uploadBtn).toBeVisible({ timeout: 10_000 });
await uploadBtn.click();
const dialog = page.locator('[role="dialog"]').first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Step 2: Set file
const fileInput = dialog.locator('input[type="file"]').first(); const fileInput = dialog.locator('input[type="file"]').first();
const fileInputExists = await fileInput.count(); expect(await fileInput.count(), 'File input must exist').toBeGreaterThan(0);
if (fileInputExists === 0) {
console.log(' File input not found in upload dialog — skipping');
return;
}
const uniqueTitle = `E2E Upload ${Date.now()}`;
const uniqueTitle = `E2E Upload ${Date.now()}`;
await fileInput.setInputFiles({ await fileInput.setInputFiles({
name: 'test-track.mp3', name: 'test-track.mp3',
mimeType: 'audio/mpeg', mimeType: 'audio/mpeg',
buffer: createTestMP3Buffer(), buffer: createTestMP3Buffer(),
}); });
// Wait for file to be processed (dropzone disappears, metadata form appears) // Step 3: Fill metadata — title input must appear after file is processed
await page.waitForTimeout(1000);
// Fill metadata
const titleInput = dialog.locator('#title').or(dialog.locator('input[name="title"]')); const titleInput = dialog.locator('#title').or(dialog.locator('input[name="title"]'));
if (!await titleInput.isVisible({ timeout: 5000 }).catch(() => false)) { await expect(titleInput, 'Title input must appear after file upload').toBeVisible({ timeout: 10_000 });
console.log(' Title input not visible after file upload — skipping');
return;
}
await titleInput.fill(uniqueTitle); await titleInput.fill(uniqueTitle);
const artistInput = dialog.locator('#artist').or(dialog.locator('input[name="artist"]')); const artistInput = dialog.locator('#artist').or(dialog.locator('input[name="artist"]'));
if (await artistInput.isVisible().catch(() => false)) { if (await artistInput.isVisible({ timeout: 2_000 }).catch(() => false)) {
await artistInput.fill('E2E Test Artist'); await artistInput.fill('E2E Test Artist');
} }
const genreInput = dialog.locator('#genre').or(dialog.locator('input[name="genre"]')); const genreInput = dialog.locator('#genre').or(dialog.locator('input[name="genre"]'));
if (await genreInput.isVisible().catch(() => false)) { if (await genreInput.isVisible({ timeout: 2_000 }).catch(() => false)) {
await genreInput.fill('Electronic'); await genreInput.fill('Electronic');
} }
// Submit the form // Step 4: Submit
const submitBtn = dialog.locator('button[type="submit"]') const submitBtn = dialog.locator('button[type="submit"]')
.or(dialog.locator('button[form="upload-track-form"]')) .or(dialog.locator('button[form="upload-track-form"]'))
.or(dialog.getByRole('button', { name: /uploader/i })); .or(dialog.getByRole('button', { name: /uploader/i }));
if (!await submitBtn.isVisible({ timeout: 3000 }).catch(() => false)) { await expect(submitBtn, 'Submit button must be visible').toBeVisible({ timeout: 3_000 });
console.log(' Submit button not visible — skipping');
return;
}
await submitBtn.click(); await submitBtn.click();
// Wait for upload completion (success message or dialog closes) // Step 5: Wait for upload to complete — dialog must close or show success
const success = dialog.locator('text=/upload|success|succ/i').first(); await expect(dialog, 'Upload dialog must close after successful upload').not.toBeVisible({ timeout: 60_000 });
const dialogClosed = page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 60_000 }).catch(() => null);
await Promise.race([ // Step 6: Verify track appears in library
success.waitFor({ state: 'visible', timeout: 60_000 }).catch(() => {}),
dialogClosed,
]);
// Verify track appears in library after reload
await navigateTo(page, '/library'); await navigateTo(page, '/library');
await page.waitForTimeout(2000); await page.waitForTimeout(2_000);
// Search for the uploaded track
const trackInLibrary = page.locator(`text=${uniqueTitle}`).first(); const trackInLibrary = page.locator(`text=${uniqueTitle}`).first();
const isVisible = await trackInLibrary.isVisible({ timeout: 10_000 }).catch(() => false); await expect(
if (isVisible) { trackInLibrary,
console.log(' Track visible in library'); `Uploaded track "${uniqueTitle}" must be visible in library`,
} else { ).toBeVisible({ timeout: 15_000 });
console.warn(' Track not yet visible (may still be processing)');
}
}); });
test('should show error for invalid file format', async ({ page }) => { test('should show error for invalid file format', async ({ page }) => {
await navigateTo(page, '/library'); await navigateTo(page, '/library');
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first(); const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
const uploadVisible = await uploadBtn.isVisible({ timeout: 10_000 }).catch(() => false); await expect(uploadBtn).toBeVisible({ timeout: 10_000 });
if (!uploadVisible) {
console.log(' Upload button not found — skipping');
return;
}
await uploadBtn.click(); await uploadBtn.click();
await page.waitForTimeout(500);
const dialog = page.locator('[role="dialog"]').first(); const dialog = page.locator('[role="dialog"]').first();
const dialogVisible = await dialog.isVisible({ timeout: 5000 }).catch(() => false); await expect(dialog).toBeVisible({ timeout: 5_000 });
if (!dialogVisible) {
console.log(' Upload dialog did not appear — skipping');
return;
}
const fileInput = dialog.locator('input[type="file"]').first(); const fileInput = dialog.locator('input[type="file"]').first();
if (await fileInput.count() === 0) { expect(await fileInput.count()).toBeGreaterThan(0);
console.log(' File input not found — skipping');
return;
}
// Try uploading a text file // Upload a text file — must be rejected
await fileInput.setInputFiles({ await fileInput.setInputFiles({
name: 'invalid.txt', name: 'invalid.txt',
mimeType: 'text/plain', mimeType: 'text/plain',
buffer: Buffer.from('This is not an audio file'), buffer: Buffer.from('This is not an audio file'),
}); });
await page.waitForTimeout(1000); await page.waitForTimeout(1_000);
// Either: file is rejected (dropzone still visible), or error message appears // Either: error message appears, OR dropzone is still shown (file was rejected silently)
const errorMsg = dialog.locator('text=/format|invalid|non supporté|rejected/i').first(); const errorMsg = dialog.locator('text=/format|invalid|non supporté|rejected/i').first();
const dropzoneStillVisible = dialog.locator('text=/glissez|drag|drop/i').first(); const dropzoneStillVisible = dialog.locator('text=/glissez|drag|drop/i').first();
const hasError = await errorMsg.isVisible({ timeout: 3000 }).catch(() => false); const hasError = await errorMsg.isVisible({ timeout: 3_000 }).catch(() => false);
const dropzoneBack = await dropzoneStillVisible.isVisible({ timeout: 3000 }).catch(() => false); const dropzoneBack = await dropzoneStillVisible.isVisible({ timeout: 3_000 }).catch(() => false);
expect(hasError || dropzoneBack).toBeTruthy(); expect(
hasError || dropzoneBack,
'Invalid file must be rejected: error message or dropzone should remain',
).toBeTruthy();
}); });
test('should show validation error when submitting without file', async ({ page }) => { test('should disable submit button when no file is selected', async ({ page }) => {
await navigateTo(page, '/library'); await navigateTo(page, '/library');
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first(); const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
const uploadVisible = await uploadBtn.isVisible({ timeout: 10_000 }).catch(() => false); await expect(uploadBtn).toBeVisible({ timeout: 10_000 });
if (!uploadVisible) {
console.log(' Upload button not found — skipping');
return;
}
await uploadBtn.click(); await uploadBtn.click();
await page.waitForTimeout(500);
const dialog = page.locator('[role="dialog"]').first(); const dialog = page.locator('[role="dialog"]').first();
const dialogVisible = await dialog.isVisible({ timeout: 5000 }).catch(() => false); await expect(dialog).toBeVisible({ timeout: 5_000 });
if (!dialogVisible) {
console.log(' Upload dialog did not appear — skipping');
return;
}
// The submit button should be disabled when no file is selected // Submit button should either not exist yet (no file) or be disabled
const submitBtn = dialog.locator('button[type="submit"]') const submitBtn = dialog.locator('button[type="submit"]')
.or(dialog.locator('button[form="upload-track-form"]')) .or(dialog.locator('button[form="upload-track-form"]'))
.or(dialog.getByRole('button', { name: /uploader/i })); .or(dialog.getByRole('button', { name: /uploader/i }));
if (await submitBtn.isVisible({ timeout: 3000 }).catch(() => false)) { const isVisible = await submitBtn.isVisible({ timeout: 3_000 }).catch(() => false);
const isDisabled = await submitBtn.isDisabled(); if (isVisible) {
expect(isDisabled).toBeTruthy(); await expect(submitBtn, 'Submit button must be disabled when no file is selected').toBeDisabled();
} }
// If submit button is not visible at all (only appears after file selection), that's also correct
}); });
test('should close modal with Escape or close button', async ({ page }) => { test('should close modal with Escape key', async ({ page }) => {
await navigateTo(page, '/library'); await navigateTo(page, '/library');
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first(); const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
const uploadVisible = await uploadBtn.isVisible({ timeout: 10_000 }).catch(() => false); await expect(uploadBtn).toBeVisible({ timeout: 10_000 });
if (!uploadVisible) {
console.log(' Upload button not found — skipping');
return;
}
await uploadBtn.click(); await uploadBtn.click();
await page.waitForTimeout(500);
const dialog = page.locator('[role="dialog"]').first(); const dialog = page.locator('[role="dialog"]').first();
const dialogVisible = await dialog.isVisible({ timeout: 5000 }).catch(() => false); await expect(dialog).toBeVisible({ timeout: 5_000 });
if (!dialogVisible) {
console.log(' Upload dialog did not appear — skipping'); // Close via Escape
return; await page.keyboard.press('Escape');
} await expect(dialog, 'Dialog must close after pressing Escape').not.toBeVisible({ timeout: 3_000 });
});
test('should close modal with close button', async ({ page }) => {
await navigateTo(page, '/library');
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
await expect(uploadBtn).toBeVisible({ timeout: 10_000 });
await uploadBtn.click();
const dialog = page.locator('[role="dialog"]').first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Close via button // Close via button
const closeBtn = dialog.getByRole('button', { name: /close|cancel|fermer|annuler/i }).first(); const closeBtn = dialog.getByRole('button', { name: /close|cancel|fermer|annuler/i }).first();
if (await closeBtn.isVisible().catch(() => false)) { await expect(closeBtn, 'Close/Cancel button must exist in dialog').toBeVisible({ timeout: 3_000 });
await closeBtn.click(); await closeBtn.click();
await expect(dialog).not.toBeVisible({ timeout: 3000 }); await expect(dialog, 'Dialog must close after clicking close button').not.toBeVisible({ timeout: 3_000 });
} else {
// Close via Escape
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 3000 });
}
}); });
}); });

View file

@ -3,7 +3,7 @@ import { CONFIG, loginViaAPI } from './helpers';
const BASE = CONFIG.baseURL; const BASE = CONFIG.baseURL;
const VALID_USERNAME = CONFIG.users.admin.username; // admin_veza const VALID_USERNAME = CONFIG.users.admin.username; // admin_veza
const LISTENER_USERNAME = CONFIG.users.listener.username; // music_lover const LISTENER_USERNAME = CONFIG.users.listener.username; // music_fan
test.describe('Profil public utilisateur (/u/:username)', () => { test.describe('Profil public utilisateur (/u/:username)', () => {
test.describe('Chargement & Rendu', () => { test.describe('Chargement & Rendu', () => {

View file

@ -733,24 +733,24 @@ export const SELECTORS = {
export const TEST_USERS = { export const TEST_USERS = {
listener: { listener: {
email: 'listener1@veza.fr', email: 'user@veza.music',
password: 'Password123!', password: 'User123!',
username: 'music_lover', username: 'music_fan',
}, },
creator: { creator: {
email: 'amelie@veza.fr', email: 'artist@veza.music',
password: 'Password123!', password: 'Artist123!',
username: 'amelie_dubois', username: 'top_artist',
}, },
admin: { admin: {
email: 'admin@veza.fr', email: 'admin@veza.music',
password: 'Password123!', password: 'Admin123!',
username: 'admin_veza', username: 'admin_veza',
}, },
moderator: { moderator: {
email: 'mod@veza.fr', email: 'mod@veza.music',
password: 'Password123!', password: 'Mod123!',
username: 'moderator_veza', username: 'mod_veza',
}, },
} as const; } as const;

View file

@ -1,7 +1,7 @@
import { type Page, type Locator, expect } from '@playwright/test'; import { type Page, type Locator, expect } from '@playwright/test';
// ============================================================================= // =============================================================================
// CONFIGURATION — Basée sur le code source réel de Veza // CONFIGURATION — Basee sur le code source reel de Veza
// ============================================================================= // =============================================================================
export const CONFIG = { export const CONFIG = {
@ -14,27 +14,27 @@ export const CONFIG = {
/** Base URL du stream server Rust */ /** Base URL du stream server Rust */
streamURL: process.env.PLAYWRIGHT_STREAM_URL || 'http://localhost:18082', streamURL: process.env.PLAYWRIGHT_STREAM_URL || 'http://localhost:18082',
/** Comptes de test (seed: veza-backend-api/cmd/tools/seed/main.go) */ /** Comptes de test (seed: veza-backend-api/cmd/tools/seed/seed_users.go) */
users: { users: {
listener: { listener: {
email: 'listener1@veza.fr', email: 'user@veza.music',
password: 'Password123!', password: 'User123!',
username: 'music_lover', username: 'music_fan',
}, },
creator: { creator: {
email: 'amelie@veza.fr', email: 'artist@veza.music',
password: 'Password123!', password: 'Artist123!',
username: 'amelie_dubois', username: 'top_artist',
}, },
admin: { admin: {
email: 'admin@veza.fr', email: 'admin@veza.music',
password: 'Password123!', password: 'Admin123!',
username: 'admin_veza', username: 'admin_veza',
}, },
moderator: { moderator: {
email: 'mod@veza.fr', email: 'mod@veza.music',
password: 'Password123!', password: 'Mod123!',
username: 'moderator_veza', username: 'mod_veza',
}, },
}, },
@ -53,13 +53,7 @@ export const CONFIG = {
/** /**
* Login via l'interface utilisateur (page /login). * Login via l'interface utilisateur (page /login).
* Utilise les vrais sélecteurs du composant LoginPage.tsx. * STRICT: echoue si le login ne redirige pas hors de /login.
*
* Le formulaire a :
* - Input email : label="Email", type="email"
* - Input password : label="Password", type="password"
* - Bouton submit : type="submit", texte "Sign In" (en) ou "Se connecter" (fr)
* - Checkbox remember_me : id="remember_me"
*/ */
export async function loginViaUI( export async function loginViaUI(
page: Page, page: Page,
@ -68,17 +62,13 @@ export async function loginViaUI(
options: { rememberMe?: boolean } = {}, options: { rememberMe?: boolean } = {},
): Promise<void> { ): Promise<void> {
await page.goto(`${CONFIG.baseURL}/login`, { waitUntil: 'domcontentloaded' }); await page.goto(`${CONFIG.baseURL}/login`, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle').catch(() => {});
// Wait for the app to finish initializing (splash login form) // Wait for the app to finish initializing (splash -> login form)
await page.locator('main, [role="main"]').first().waitFor({ await page.locator('main, [role="main"]').first().waitFor({
state: 'visible', state: 'visible',
timeout: CONFIG.timeouts.navigation, timeout: CONFIG.timeouts.navigation,
}).catch(() => {}); });
// DOM réel (vérifié via snapshot) :
// textbox "Email" → input[type="email"] (peut avoir une valeur pré-remplie "remember me")
// textbox "Password" → input[type="password"]
// button "Sign In" → data-testid="login-submit"
const emailInput = page.locator('input[type="email"]'); const emailInput = page.locator('input[type="email"]');
await emailInput.waitFor({ state: 'visible', timeout: CONFIG.timeouts.navigation }); await emailInput.waitFor({ state: 'visible', timeout: CONFIG.timeouts.navigation });
await emailInput.clear(); await emailInput.clear();
@ -88,91 +78,56 @@ export async function loginViaUI(
await passwordInput.clear(); await passwordInput.clear();
await passwordInput.fill(password); await passwordInput.fill(password);
// Remember me checkbox (optionnel)
if (options.rememberMe) { if (options.rememberMe) {
const rememberMe = page.locator('#remember_me'); const rememberMe = page.locator('#remember_me');
if (await rememberMe.isVisible().catch(() => false)) { if (await rememberMe.isVisible()) {
await rememberMe.check(); await rememberMe.check();
} }
} }
// Soumettre — le bouton est data-testid="login-submit" avec texte "Sign In"
const submitBtn = page.getByTestId('login-submit'); const submitBtn = page.getByTestId('login-submit');
await submitBtn.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action }); await expect(submitBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await submitBtn.click(); await submitBtn.click();
// Attendre la redirection (quitte /login) // STRICT: must redirect away from /login
const redirected = await page.waitForURL((url) => !url.pathname.includes('/login'), { await page.waitForURL((url) => !url.pathname.includes('/login'), {
timeout: CONFIG.timeouts.navigation, timeout: CONFIG.timeouts.navigation,
}).then(() => true).catch(() => false); });
if (!redirected) {
// Retry once — rate limiting or slow API may have blocked the first attempt
const bodyText = await page.textContent('body').catch(() => '') || '';
if (/rate limit|trop de requêtes|429|too many|error|erreur/i.test(bodyText)) {
await page.waitForTimeout(2_000);
// Re-fill in case form was reset
const emailRetry = page.locator('input[type="email"]');
if (await emailRetry.isVisible().catch(() => false)) {
await emailRetry.clear();
await emailRetry.fill(email);
const pwRetry = page.locator('input[type="password"]').first();
await pwRetry.clear();
await pwRetry.fill(password);
}
await submitBtn.click();
await page.waitForURL((url) => !url.pathname.includes('/login'), {
timeout: CONFIG.timeouts.navigation,
}).catch(() => {});
}
}
} }
/** /**
* Login via l'API directement (plus rapide, pour les tests qui n'ont pas besoin de tester le login). * Login via l'API directement (plus rapide, pour les tests qui n'ont pas besoin de tester le login).
* Beaucoup plus rapide que loginViaUI car évite le rendu complet de la SPA. * STRICT: echoue si l'API retourne une erreur.
*
* POST /api/v1/auth/login set cookies + localStorage auth-storage
*/ */
export async function loginViaAPI( export async function loginViaAPI(
page: Page, page: Page,
email: string, email: string,
password: string, password: string,
): Promise<void> { ): Promise<void> {
// Naviguer vers une page minimale pour initialiser le contexte navigateur (cookies, localStorage)
// about:blank ne permet pas localStorage, donc on utilise / avec un timeout court
const base = CONFIG.baseURL; const base = CONFIG.baseURL;
await page.goto(`${base}/`, { waitUntil: 'commit', timeout: CONFIG.timeouts.navigation }); await page.goto(`${base}/`, { waitUntil: 'commit', timeout: CONFIG.timeouts.navigation });
// Appeler l'API login directement (bypass le rendu UI, juste un POST HTTP)
const response = await page.request.post(`${base}/api/v1/auth/login`, { const response = await page.request.post(`${base}/api/v1/auth/login`, {
data: { email, password, remember_me: false }, data: { email, password, remember_me: false },
}); });
if (!response.ok()) { // STRICT: login must succeed
// Ne pas throw — le test appelant vérifiera si on est authentifié expect(response.ok(), `Login API failed: ${response.status()} for ${email}`).toBeTruthy();
console.warn(`loginViaAPI failed: ${response.status()}`);
return;
}
const body = await response.json(); await page.evaluate(() => {
const token = body?.data?.token?.access_token;
// Stocker l'état auth dans le Zustand store (auth-storage) pour que le frontend
// reconnaisse la session immédiatement au prochain chargement de page
await page.evaluate((_token: string | undefined) => {
const authState = { const authState = {
state: { isAuthenticated: true, isLoading: false, error: null }, state: { isAuthenticated: true, isLoading: false, error: null },
version: 1, version: 1,
}; };
localStorage.setItem('auth-storage', JSON.stringify(authState)); localStorage.setItem('auth-storage', JSON.stringify(authState));
}, token); });
// Naviguer vers le dashboard — la SPA détecte isAuthenticated et affiche le layout authentifié
await page.goto(`${CONFIG.baseURL}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 30_000 }); await page.goto(`${CONFIG.baseURL}/dashboard`, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle').catch(() => {}); // Wait for auth initialization to complete
// Wait for the app to finish auth initialization await page.locator('main, [role="main"]').first().waitFor({
await page.waitForTimeout(1_000); state: 'visible',
timeout: 20_000,
});
} }
// ============================================================================= // =============================================================================
@ -180,26 +135,25 @@ export async function loginViaAPI(
// ============================================================================= // =============================================================================
/** /**
* Navigue vers un path et attend que l'app soit prête (splash screen disparu). * Navigue vers un path et attend que l'app soit prete.
* * STRICT: echoue si la page ne charge pas (main element must appear).
* L'app affiche un splash "Veza" pendant l'initialisation auth (refreshUser getMe).
* Une fois prête, elle rend soit AuthLayout (role="main") soit DashboardLayout (<main>).
* On attend donc qu'un élément `main` ou `[role="main"]` apparaisse.
*/ */
export async function navigateTo(page: Page, path: string): Promise<void> { export async function navigateTo(page: Page, path: string): Promise<void> {
const url = path.startsWith('http') ? path : `${CONFIG.baseURL}${path}`; const url = path.startsWith('http') ? path : `${CONFIG.baseURL}${path}`;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle').catch(() => {}); await page.waitForLoadState('networkidle').catch(() => {
// Wait for the app to finish initializing (loading splash → actual page) // networkidle can legitimately timeout on pages with websockets/polling — not a test failure
});
// App must render a main content area
await page.locator('main, [role="main"]').first().waitFor({ await page.locator('main, [role="main"]').first().waitFor({
state: 'visible', state: 'visible',
timeout: 20_000, timeout: 20_000,
}).catch(() => {}); });
} }
/** /**
* Vérifie qu'une page se charge sans erreur critique. * Verifie qu'une page se charge sans erreur critique.
* Retourne les erreurs console collectées. * STRICT: fails on 500 errors visible in the page.
*/ */
export async function assertPageLoads(page: Page, path: string): Promise<string[]> { export async function assertPageLoads(page: Page, path: string): Promise<string[]> {
const errors: string[] = []; const errors: string[] = [];
@ -216,8 +170,7 @@ export async function assertPageLoads(page: Page, path: string): Promise<string[
await navigateTo(page, path); await navigateTo(page, path);
// Vérifier pas de crash const body = await page.textContent('body') || '';
const body = await page.textContent('body').catch(() => '') || '';
expect(body).not.toMatch(/500|Internal Server Error/i); expect(body).not.toMatch(/500|Internal Server Error/i);
return errors; return errors;
@ -228,8 +181,7 @@ export async function assertPageLoads(page: Page, path: string): Promise<string[
// ============================================================================= // =============================================================================
/** /**
* Remplit un formulaire avec les champs donnés. * Remplit un formulaire avec les champs donnes.
* Les clés sont les labels ou placeholders des champs.
*/ */
export async function fillForm( export async function fillForm(
page: Page, page: Page,
@ -247,32 +199,32 @@ export async function fillForm(
// ============================================================================= // =============================================================================
/** /**
* Vérifie qu'il n'y a pas de texte de debug visible (undefined, null, NaN, [object Object], etc.) * Verifie qu'il n'y a pas de texte de debug visible.
* STRICT: fails if [object Object] or excessive undefined/null/NaN found.
*/ */
export async function assertNoDebugText(page: Page): Promise<void> { export async function assertNoDebugText(page: Page): Promise<void> {
const body = await page.textContent('body').catch(() => '') || ''; const body = await page.textContent('body') || '';
// Patterns de debug courants
expect(body).not.toContain('[object Object]'); expect(body).not.toContain('[object Object]');
// Note: "undefined" et "null" peuvent apparaître dans du texte légitime,
// donc on vérifie seulement les occurrences suspectes
const suspiciousPatterns = /\bundefined\b(?!.*password)|\bnull\b.*\bnull\b|\bNaN\b/g; const suspiciousPatterns = /\bundefined\b(?!.*password)|\bnull\b.*\bnull\b|\bNaN\b/g;
const matches = body.match(suspiciousPatterns); const matches = body.match(suspiciousPatterns);
if (matches && matches.length > 2) { expect(
console.warn(` ⚠ Texte suspect trouvé: ${matches.slice(0, 3).join(', ')}`); matches?.length ?? 0,
} `Debug text found in page: ${matches?.slice(0, 3).join(', ')}`,
).toBeLessThanOrEqual(2);
} }
/** /**
* Vérifie que la page n'a pas d'erreur serveur visible. * Verifie que la page n'a pas d'erreur serveur visible.
* STRICT: fails on 500 errors or empty body.
*/ */
export async function assertNotBroken(page: Page): Promise<void> { export async function assertNotBroken(page: Page): Promise<void> {
const body = await page.textContent('body').catch(() => '') || ''; const body = await page.textContent('body') || '';
expect(body).not.toMatch(/500|Internal Server Error|unexpected error/i); expect(body).not.toMatch(/500|Internal Server Error|unexpected error/i);
expect(body.length).toBeGreaterThan(50); expect(body.length).toBeGreaterThan(50);
} }
/** /**
* Collecte les erreurs réseau (5xx) pendant une période. * Collecte les erreurs reseau (5xx) pendant une action.
*/ */
export async function collectNetworkErrors( export async function collectNetworkErrors(
page: Page, page: Page,
@ -299,14 +251,12 @@ export async function collectNetworkErrors(
/** /**
* Dismiss the mobile sidebar if it's open. * Dismiss the mobile sidebar if it's open.
* The sidebar overlay is wrapped in a FocusTrap that intercepts pointer events,
* so clicking the overlay fails. Instead we press Escape which the FocusTrap handles.
*/ */
export async function dismissMobileSidebar(page: Page): Promise<void> { export async function dismissMobileSidebar(page: Page): Promise<void> {
const sidebarOverlay = page.locator('div[aria-hidden="true"][role="presentation"].fixed.inset-0'); const sidebarOverlay = page.locator('div[aria-hidden="true"][role="presentation"].fixed.inset-0');
if (await sidebarOverlay.isVisible({ timeout: 1_000 }).catch(() => false)) { if (await sidebarOverlay.isVisible({ timeout: 1_000 }).catch(() => false)) {
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await sidebarOverlay.waitFor({ state: 'hidden', timeout: 3_000 }).catch(() => {}); await sidebarOverlay.waitFor({ state: 'hidden', timeout: 3_000 });
} }
} }
@ -315,8 +265,8 @@ export async function dismissMobileSidebar(page: Page): Promise<void> {
// ============================================================================= // =============================================================================
/** /**
* Vérifie que le player global est visible et le retourne. * Verifie que le player global est visible et le retourne.
* Le player a data-testid="global-player" et role="region" aria-label="Global player". * STRICT: fails if player is not visible.
*/ */
export async function assertPlayerVisible(page: Page): Promise<Locator> { export async function assertPlayerVisible(page: Page): Promise<Locator> {
const player = page.getByTestId('global-player') const player = page.getByTestId('global-player')
@ -327,30 +277,21 @@ export async function assertPlayerVisible(page: Page): Promise<Locator> {
} }
/** /**
* Navigate to a page that actually displays track cards (role="article"). * Navigate to a page that displays track cards (role="article").
* * Returns true if tracks are found, false if the database has no tracks.
* Page rendering details: * Does NOT swallow errors navigation failures will throw.
* - /feed uses TrackGrid TrackCard (role="article"). Best for listener accounts
* who follow creators (seed: listener1 follows amelie, marcus, renzo).
* - /discover shows genre buttons by default; clicking a genre loads tracks via
* TrackGrid TrackCard (role="article").
* - /library uses its own LibraryPageGrid (NOT TrackCard), so no role="article".
* It also only shows the current user's OWN tracks (empty for listeners).
*/ */
export async function navigateToPageWithTracks(page: Page): Promise<boolean> { export async function navigateToPageWithTracks(page: Page): Promise<boolean> {
// Fermer la sidebar mobile si ouverte (son FocusTrap intercepte les clics)
await dismissMobileSidebar(page); await dismissMobileSidebar(page);
// Try /feed first — it uses TrackGrid/TrackCard (role="article") // Try /feed first
// and shows tracks from followed users + by_genres section
await navigateTo(page, '/feed'); await navigateTo(page, '/feed');
const feedTrack = page.locator('[role="article"]').first(); const feedTrack = page.locator('[role="article"]').first();
if (await feedTrack.isVisible({ timeout: 5_000 }).catch(() => false)) { if (await feedTrack.isVisible({ timeout: 5_000 }).catch(() => false)) {
return true; return true;
} }
// Fallback: /discover → genre buttons are <button> with .font-heading.font-bold spans // Fallback: /discover -> click first genre
// Clicking a genre sets ?genre=slug which loads tracks via TrackGrid/TrackCard
await navigateTo(page, '/discover'); await navigateTo(page, '/discover');
const genreBtn = page.locator('button').filter({ has: page.locator('.font-heading.font-bold') }).first(); const genreBtn = page.locator('button').filter({ has: page.locator('.font-heading.font-bold') }).first();
@ -368,69 +309,54 @@ export async function navigateToPageWithTracks(page: Page): Promise<boolean> {
/** /**
* Lance la lecture du premier track disponible. * Lance la lecture du premier track disponible.
* Navigates to a page with tracks if none are visible on the current page. * STRICT: fails if no play button is found or if it can't be clicked.
* Les TrackCards ont un bouton play avec aria-label="Lire {title}" ou "Play {title}".
*/ */
export async function playFirstTrack(page: Page): Promise<void> { export async function playFirstTrack(page: Page): Promise<void> {
// Fermer la sidebar mobile si ouverte (son FocusTrap intercepte les clics)
await dismissMobileSidebar(page); await dismissMobileSidebar(page);
// If no track cards are visible on the current page, navigate to one that has them // If no track cards on current page, navigate to one that has them
const currentTrack = page.locator('[role="article"]').first(); const currentTrack = page.locator('[role="article"]').first();
if (!await currentTrack.isVisible({ timeout: 3_000 }).catch(() => false)) { if (!await currentTrack.isVisible({ timeout: 3_000 }).catch(() => false)) {
await navigateToPageWithTracks(page); const found = await navigateToPageWithTracks(page);
expect(found, 'No tracks found in feed or discover — database may need seeding').toBeTruthy();
} }
// Hover sur le premier track card pour faire apparaître le bouton play // Hover to reveal play button
const trackCard = page.locator('[role="article"]').first() const trackCard = page.locator('[role="article"]').first();
.or(page.getByRole('button', { name: /piste:/i }).first()); await expect(trackCard).toBeVisible({ timeout: CONFIG.timeouts.action });
await trackCard.hover();
await page.waitForTimeout(300);
if (await trackCard.isVisible().catch(() => false)) { // Click play
await trackCard.hover();
await page.waitForTimeout(300);
}
// Cliquer le bouton play (aria-label="Lire ...")
const playBtn = page.getByRole('button', { name: /^lire |^play /i }).first() const playBtn = page.getByRole('button', { name: /^lire |^play /i }).first()
.or(page.locator('[aria-label*="Lire"]').first()) .or(page.locator('[aria-label*="Lire"]').first())
.or(page.locator('[aria-label*="Play"]').first()); .or(page.locator('[aria-label*="Play"]').first());
await playBtn.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action }).catch(() => {}); await expect(playBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await playBtn.click();
if (await playBtn.isVisible().catch(() => false)) { // Wait for player to appear
await playBtn.click(); await page.waitForTimeout(CONFIG.timeouts.animation);
// Attendre que le player apparaisse
await page.waitForTimeout(CONFIG.timeouts.animation);
}
} }
// ============================================================================= // =============================================================================
// COMPONENT SELECTORS — Basés sur le code source réel // COMPONENT SELECTORS — Bases sur le code source reel
// ============================================================================= // =============================================================================
export const SELECTORS = { export const SELECTORS = {
// Layout (vérifié via DOM snapshot) sidebar: '[data-testid="app-sidebar"]',
sidebar: '[data-testid="app-sidebar"]', // complementary "Main sidebar"
header: 'header, [data-testid="app-header"], [role="banner"]', header: 'header, [data-testid="app-header"], [role="banner"]',
playerBar: '[data-testid="global-player"]', // region "Global player" playerBar: '[data-testid="global-player"]',
// Auth
loginForm: '[data-testid="login-form"]', loginForm: '[data-testid="login-form"]',
registerForm: '[data-testid="register-form"]', registerForm: '[data-testid="register-form"]',
// Player (vérifié: les boutons n'ont PAS d'aria-labels)
audioElement: '[data-testid="audio-element"]', audioElement: '[data-testid="audio-element"]',
progressBar: '[role="slider"][aria-label="Progression"]', progressBar: '[role="slider"][aria-label="Progression"]',
volumeSlider: '[data-testid="volume-control"] [role="slider"]', volumeSlider: '[data-testid="volume-control"] [role="slider"]',
// Toast
toast: '[data-testid="toast-alert"]', toast: '[data-testid="toast-alert"]',
// Cards — TrackCard component (used by TrackGrid on /feed, /discover?genre=...)
// Note: /library uses LibraryPageGrid which does NOT use TrackCard (no role="article")
trackCard: '[role="article"]', trackCard: '[role="article"]',
// Search — Header search uses data-testid="search-input" type="search"
searchInput: '[data-testid="search-input"], [role="search"] input, input[type="search"], input[role="searchbox"]', searchInput: '[data-testid="search-input"], [role="search"] input, input[type="search"], input[role="searchbox"]',
} as const; } as const;
@ -440,15 +366,16 @@ export const SELECTORS = {
/** /**
* Attend qu'un toast soit visible, puis retourne son texte. * Attend qu'un toast soit visible, puis retourne son texte.
* STRICT: fails if no toast appears within timeout.
*/ */
export async function waitForToast(page: Page): Promise<string> { export async function waitForToast(page: Page): Promise<string> {
const toast = page.getByTestId('toast-alert').first(); const toast = page.getByTestId('toast-alert').first();
await toast.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action }).catch(() => {}); await expect(toast).toBeVisible({ timeout: CONFIG.timeouts.action });
return (await toast.textContent()) || ''; return (await toast.textContent()) || '';
} }
/** /**
* Génère un identifiant unique pour les données de test. * Genere un identifiant unique pour les donnees de test.
*/ */
export function testId(prefix = 'e2e'): string { export function testId(prefix = 'e2e'): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;

1
tmt/.fmf/version Normal file
View file

@ -0,0 +1 @@
1

View file

@ -4,38 +4,91 @@
> **The Law**: [docs/FRUGALITY.md](../docs/FRUGALITY.md) > **The Law**: [docs/FRUGALITY.md](../docs/FRUGALITY.md)
> **The Contract**: [docs/BUDGETS.md](../docs/BUDGETS.md) > **The Contract**: [docs/BUDGETS.md](../docs/BUDGETS.md)
This directory contains the definition of Veza's unified testing pipeline. TMT is the **single entry point** for all Veza tests.
It is the **Executive Branch** that enforcing the laws defined in `FRUGALITY.md` and `BUDGETS.md`. The CI calls `tmt run`, never `go test`, `vitest`, or `cargo test` directly.
## 🛑 The Rules ## Rules
1. **VITAL Tests Block Everything**: If a test in `plans/vital.fmf` fails, the commit is rejected. 1. **Vital tests block everything.** If a tier 1 test fails, the commit is rejected.
2. **Contractual Budgets**: Resource limits are defined in `docs/BUDGETS.md`. Tests verify these limits. 2. **Contractual budgets.** Resource limits from `docs/BUDGETS.md` are enforced by tests.
3. **No New Frontend Tests**: By default, new frontend tests are `legacy`. You must prove a test is `vital` to promote it. 3. **No `|| true`.** A test that passes despite regression is worse than no test.
4. **New frontend tests are legacy by default.** Promote to vital only if it protects a critical invariant.
## Plans
| Plan | Tier | Scope | Blocking | Usage |
|------|------|-------|----------|-------|
| `/vital` | 1 | All components | Yes | Local: `make test-tmt` |
| `/vital-backend` | 1 | Go backend only | Yes | CI parallel job |
| `/vital-frontend` | 1 | Web frontend only | Yes | CI parallel job |
| `/vital-services` | 1 | Rust services only | Yes | CI parallel job |
| `/legacy` | 2 | Slow / integration tests | No (warning) | `tmt --root tmt run plan --name /legacy` |
| `/integration` | 2 | Tests needing infra (DB, Redis) | No (warning) | Requires docker-compose |
| `/nightly` | 3 | E2E Playwright, Storybook | No | Nightly / pre-release |
## Directory Structure ## Directory Structure
- `plans/`: ```
- **`vital.fmf`**: **TIER 1**. The "must pass" suite. Runs fast, strictly, and enforces budgets. tmt/
- **`legacy.fmf`**: **TIER 2**. Slow/Old tests. Informational only. ├── .fmf/version # FMF tree root
- `tests/`: Actual test scripts. ├── plans/
- `frontend/`: Linked to `BUDGETS.md`. │ ├── vital.fmf # Tier 1 — all components
- `backend/`: Linked to `BUDGETS.md`. │ ├── vital-backend.fmf # Tier 1 — Go only (CI)
- `services/`: Strict Rust checks. │ ├── vital-frontend.fmf # Tier 1 — Web only (CI)
│ ├── vital-services.fmf # Tier 1 — Rust only (CI)
│ ├── legacy.fmf # Tier 2 — slow / secondary
│ ├── integration.fmf # Tier 2 — needs infra
│ └── nightly.fmf # Tier 3 — E2E, storybook
├── tests/
│ ├── backend/ # Go: govulncheck, vet, lint, unit, build, etc.
│ ├── frontend/ # Web: audit, lint, typecheck, unit, build, etc.
│ ├── services/ # Rust: audit, clippy, build, test
│ ├── e2e/ # Playwright (tier 3)
│ └── storybook/ # Storybook audit (tier 3)
└── README.md
```
## How to Run ## How to Run
### Vital Tests (The Standard)
```bash ```bash
tmt run plan --name /vital # All vital tests (the standard)
make test-tmt
# Component-specific
make test-tmt-backend
make test-tmt-frontend
make test-tmt-services
# Direct TMT commands
tmt --root tmt run plan --name /vital # All vital
tmt --root tmt run plan --name /vital-backend # Backend only
tmt --root tmt run plan --name /integration # Integration (needs infra)
tmt --root tmt run plan --name /nightly # E2E + storybook
tmt --root tmt run # Everything
# Install TMT
pip install tmt
``` ```
### Full Suite (Including Regressions/Legacy) ## Test Execution Order
```bash
tmt run Tests within each component use the `order` attribute for sequencing:
```
| Order | Phase | Examples |
|-------|-------|---------|
| 10 | Security audits | govulncheck, npm audit, cargo audit |
| 15 | Code generation | Types sync check |
| 20 | Static analysis | vet, lint, format, clippy, core isolation |
| 30 | Type checking | TypeScript typecheck |
| 40 | Unit tests | go test, vitest, cargo test |
| 50 | Build | go build, cargo build, npm run build |
| 60 | Post-build | Bundle size, build perf |
Fast checks fail early. Heavy checks only run if basic hygiene passes.
## Environment Variables ## Environment Variables
The pipeline enforces:
- `GOMAXPROCS=1`: Simulate single-core environment. | Variable | Value | Purpose |
- `LIBGL_ALWAYS_SOFTWARE=1`: Disable GPU. |----------|-------|---------|
| `GOMAXPROCS` | 1 | Low-power backend (set in unit.sh) |
| `RUST_BACKTRACE` | 0 | Reduce noise (set in plans) |

12
tmt/plans/integration.fmf Normal file
View file

@ -0,0 +1,12 @@
summary: Integration Tests (Tier 2)
description: |
Tests requiring infrastructure (PostgreSQL, Redis, RabbitMQ).
Failures are warnings, not blockers.
Requires: docker-compose infra running.
discover:
how: fmf
filter: 'tier: 2'
execute:
how: tmt

View file

@ -8,7 +8,7 @@ description: |
discover: discover:
how: fmf how: fmf
filter: tier: 2 filter: 'tier: 2'
execute: execute:
how: tmt how: tmt

12
tmt/plans/nightly.fmf Normal file
View file

@ -0,0 +1,12 @@
summary: Nightly Tests (Tier 3)
description: |
Heavy tests: E2E Playwright, Storybook audit.
Requires full infrastructure + running backend.
Run nightly or before release.
discover:
how: fmf
filter: 'tier: 3'
execute:
how: tmt

View file

@ -0,0 +1,11 @@
summary: Vital Backend Tests (Go)
description: |
Backend-only vital tests for CI parallel execution.
Runs: govulncheck, vet, lint, unit tests, core isolation, build.
discover:
how: fmf
filter: 'tier: 1 & component: backend'
execute:
how: tmt

View file

@ -0,0 +1,12 @@
summary: Vital Frontend Tests (Web)
description: |
Frontend-only vital tests for CI parallel execution.
Runs: security audit, types sync, lint, format, typecheck,
unit tests, contrast, build, bundle size, build perf.
discover:
how: fmf
filter: 'tier: 1 & component: frontend'
execute:
how: tmt

View file

@ -0,0 +1,14 @@
summary: Vital Services Tests (Rust)
description: |
Rust services vital tests for CI parallel execution.
Runs: cargo audit, clippy, build, tests.
discover:
how: fmf
filter: 'tier: 1 & component: services'
execute:
how: tmt
environment:
RUST_BACKTRACE: "0"

View file

@ -1,18 +1,15 @@
summary: Vital Tests (The Law) summary: Vital Tests — All Components (The Law)
description: | description: |
These tests are non-negotiable. Non-negotiable tests. Executes the Frugality Manifesto.
They execute the Frugality Manifesto.
If these fail, the product is broken. If these fail, the product is broken.
Use this plan for local validation: tmt --root tmt run plan --name /vital
discover: discover:
how: fmf how: fmf
filter: tier: 1 filter: 'tier: 1'
execute: execute:
how: tmt how: tmt
environment: environment:
# Forces Low-Power Behavior
GOMAXPROCS: "1"
LIBGL_ALWAYS_SOFTWARE: "1"
RUST_BACKTRACE: "0" RUST_BACKTRACE: "0"

13
tmt/tests/backend/build.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
BACKEND_DIR="$REPO_ROOT/veza-backend-api"
echo "Backend Build Check"
cd "$BACKEND_DIR"
go build -o /dev/null ./cmd/api/main.go
echo "Build passed."

View file

@ -0,0 +1,18 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
BACKEND_DIR="$REPO_ROOT/veza-backend-api"
echo "Backend Vulnerability Check (govulncheck)"
cd "$BACKEND_DIR"
if ! command -v govulncheck &>/dev/null; then
echo "Installing govulncheck..."
go install golang.org/x/vuln/cmd/govulncheck@latest
fi
govulncheck ./...
echo "Vulnerability check passed."

18
tmt/tests/backend/lint.sh Executable file
View file

@ -0,0 +1,18 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
BACKEND_DIR="$REPO_ROOT/veza-backend-api"
echo "Backend Lint (golangci-lint)"
cd "$BACKEND_DIR"
if ! command -v golangci-lint &>/dev/null; then
echo "Installing golangci-lint..."
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
fi
golangci-lint run ./...
echo "Lint passed."

View file

@ -1,32 +1,60 @@
summary: Backend Tests (Go) summary: Backend Tests (Go)
tier: 1 tier: 1
component: backend component: backend
test: ./unit.sh
duration: 15m duration: 15m
require: require:
- go - go
/unit: /govulncheck:
summary: Unit Tests (Low Power) summary: Vulnerability Check (govulncheck)
test: ./unit.sh test: ./govulncheck.sh
tier: 1 tier: 1
order: 10
/integration: /vet:
summary: Integration Tests summary: Go Vet
test: ./integration.sh test: ./vet.sh
tier: 2 tier: 1
order: 20
/lint:
summary: Lint (golangci-lint)
test: ./lint.sh
tier: 1
order: 20
/core-isolation: /core-isolation:
summary: Core Isolation Check summary: Core Isolation Check
test: ./core_isolation.sh test: ./core_isolation.sh
tier: 1 tier: 1
order: 20
/unit:
summary: Unit Tests (Low Power)
test: ./unit.sh
tier: 1
order: 40
/build:
summary: Build Check
test: ./build.sh
tier: 1
order: 50
/startup-time: /startup-time:
summary: Startup Time Budget summary: Startup Time Budget
test: ./startup_time.sh test: ./startup_time.sh
tier: 1 tier: 1
order: 50
/memory-budget: /memory-budget:
summary: Memory Budget Check summary: Memory Budget Check
test: ./memory_budget.sh test: ./memory_budget.sh
tier: 1 tier: 1
order: 50
/integration:
summary: Integration Tests
test: ./integration.sh
tier: 2
order: 50

View file

@ -1,17 +1,30 @@
#!/bin/bash #!/bin/bash
set -e set -e
# CONTRACT: GOMAXPROCS=1 (Frugality Manifesto)
COVERAGE_THRESHOLD=60
REPO_ROOT=$(git rev-parse --show-toplevel) REPO_ROOT=$(git rev-parse --show-toplevel)
BACKEND_DIR="$REPO_ROOT/veza-backend-api" BACKEND_DIR="$REPO_ROOT/veza-backend-api"
echo "📍 Backend Unit Tests (Low Power Mode)" echo "Backend Unit Tests (Low Power Mode)"
cd "$BACKEND_DIR" cd "$BACKEND_DIR"
export GOMAXPROCS=1 export GOMAXPROCS=1
echo "⚙️ Constraint: GOMAXPROCS=1" echo "Constraint: GOMAXPROCS=1"
echo "🧪 Running Unit Tests..." echo "Running Unit Tests with coverage..."
go test ./internal/... -v -short go test ./internal/handlers/... ./internal/services/... -short -coverprofile=coverage.out -covermode=atomic
echo "✅ Unit tests completed." COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
echo "Coverage: ${COVERAGE}%"
if awk -v c="$COVERAGE" -v t="$COVERAGE_THRESHOLD" 'BEGIN {exit !(c+0>=t)}'; then
echo "Coverage gate passed (>= ${COVERAGE_THRESHOLD}%)"
else
echo "FATAL: Coverage ${COVERAGE}% is below threshold ${COVERAGE_THRESHOLD}%"
exit 1
fi
echo "Unit tests passed."

13
tmt/tests/backend/vet.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
BACKEND_DIR="$REPO_ROOT/veza-backend-api"
echo "Backend Go Vet"
cd "$BACKEND_DIR"
go vet ./...
echo "Go vet passed."

12
tmt/tests/e2e/main.fmf Normal file
View file

@ -0,0 +1,12 @@
summary: E2E Tests (Playwright)
tier: 3
component: e2e
/playwright:
summary: Playwright E2E Suite
test: ./playwright.sh
tier: 3
duration: 45m
require:
- npm
- go

15
tmt/tests/e2e/playwright.sh Executable file
View file

@ -0,0 +1,15 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
WEB_DIR="$REPO_ROOT/apps/web"
echo "E2E Tests (Playwright)"
echo "Requires: backend running, infra up (postgres, redis, rabbitmq)"
cd "$WEB_DIR"
npx playwright install --with-deps
npx playwright test
echo "E2E tests passed."

13
tmt/tests/frontend/contrast.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
WEB_DIR="$REPO_ROOT/apps/web"
echo "Frontend Contrast Tests (WCAG)"
cd "$WEB_DIR"
npm run test -- --run src/__tests__/contrast.test.ts
echo "Contrast tests passed."

View file

@ -0,0 +1,13 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
WEB_DIR="$REPO_ROOT/apps/web"
echo "Frontend Format Check"
cd "$WEB_DIR"
npm run format:check --if-present
echo "Format check passed."

13
tmt/tests/frontend/lint.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
WEB_DIR="$REPO_ROOT/apps/web"
echo "Frontend Lint (ESLint)"
cd "$WEB_DIR"
npm run lint
echo "Lint passed."

View file

@ -1,32 +1,72 @@
summary: Frontend Tests (Web) summary: Frontend Tests (Web)
tier: 1 tier: 1
component: frontend component: frontend
test: ./build.sh duration: 15m
duration: 10m
require: require:
- npm - npm
/unit: /security-audit:
summary: Unit Tests (Vitest) summary: Security Audit (npm audit)
test: ./unit.sh test: ./security_audit.sh
tier: 1 tier: 1
order: 10
/build: /types-sync:
summary: Build Test summary: Types Sync Check (OpenAPI)
test: ./build.sh test: ./types_sync.sh
tier: 1 tier: 1
order: 15
/build-perf: /lint:
summary: Build Time Budget summary: Lint (ESLint)
test: ./build_perf.sh test: ./lint.sh
tier: 1 tier: 1
order: 20
/bundle-size: /format-check:
summary: Bundle Size Check (Strict) summary: Format Check (Prettier)
test: ./bundle_size.sh test: ./format_check.sh
tier: 1 tier: 1
order: 20
/no-critical-js: /no-critical-js:
summary: No Critical JS Check summary: No Critical JS Check
test: ./no_critical_js.sh test: ./no_critical_js.sh
tier: 1 tier: 1
order: 20
/typecheck:
summary: Type Check (TypeScript)
test: ./typecheck.sh
tier: 1
order: 30
/unit:
summary: Unit Tests (Vitest)
test: ./unit.sh
tier: 1
order: 40
/contrast:
summary: Contrast Tests (WCAG)
test: ./contrast.sh
tier: 1
order: 40
/build:
summary: Build Test
test: ./build.sh
tier: 1
order: 50
/bundle-size:
summary: Bundle Size Check (Strict)
test: ./bundle_size.sh
tier: 1
order: 60
/build-perf:
summary: Build Time Budget
test: ./build_perf.sh
tier: 1
order: 60

View file

@ -0,0 +1,13 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
WEB_DIR="$REPO_ROOT/apps/web"
echo "Frontend Security Audit (npm)"
cd "$REPO_ROOT"
npm audit --audit-level=critical
echo "Security audit passed."

13
tmt/tests/frontend/typecheck.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
WEB_DIR="$REPO_ROOT/apps/web"
echo "Frontend Type Check (TypeScript)"
cd "$WEB_DIR"
npm run typecheck
echo "Typecheck passed."

View file

@ -0,0 +1,20 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
WEB_DIR="$REPO_ROOT/apps/web"
echo "Frontend Types Sync Check (OpenAPI)"
cd "$WEB_DIR"
chmod +x scripts/generate-types.sh
./scripts/generate-types.sh
if ! git diff --exit-code src/types/generated/; then
echo "FATAL: Types are out of sync with openapi.yaml."
echo "Run: cd apps/web && ./scripts/generate-types.sh"
exit 1
fi
echo "Types in sync."

View file

@ -4,19 +4,16 @@ set -e
REPO_ROOT=$(git rev-parse --show-toplevel) REPO_ROOT=$(git rev-parse --show-toplevel)
WEB_DIR="$REPO_ROOT/apps/web" WEB_DIR="$REPO_ROOT/apps/web"
echo "📍 Frontend Unit Tests (Vitest)" echo "Frontend Unit Tests (Vitest)"
echo "📂 Web Directory: $WEB_DIR"
cd "$WEB_DIR" cd "$WEB_DIR"
if [ ! -d "node_modules" ]; then if [ ! -d "node_modules" ]; then
echo "📦 Installing dependencies..." echo "Installing dependencies..."
npm ci npm ci
else
echo "✅ Dependencies found"
fi fi
echo "🧪 Running unit tests..." echo "Running unit tests with coverage..."
npm run test -- --run npm run test -- --run --coverage
echo "Unit tests passed." echo "Unit tests passed."

View file

@ -1,17 +1,30 @@
summary: Services Tests (Rust) summary: Services Tests (Rust)
tier: 1 tier: 1
component: services component: services
test: ./rust_test.sh
duration: 20m duration: 20m
require: require:
- cargo - cargo
/audit:
summary: Security Audit (cargo audit)
test: ./rust_audit.sh
tier: 1
order: 10
/clippy: /clippy:
summary: Rust Clippy (Strict) summary: Rust Clippy (Strict)
test: ./rust_clippy.sh test: ./rust_clippy.sh
tier: 1 tier: 1
order: 20
/build:
summary: Rust Build Check
test: ./rust_build.sh
tier: 1
order: 40
/test: /test:
summary: Rust Unit Tests summary: Rust Unit Tests
test: ./rust_test.sh test: ./rust_test.sh
tier: 1 tier: 1
order: 50

View file

@ -0,0 +1,22 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
STREAM_DIR="$REPO_ROOT/veza-stream-server"
echo "Rust Security Audit (cargo audit)"
if ! command -v cargo-audit &>/dev/null; then
echo "Installing cargo-audit..."
cargo install cargo-audit
fi
if [ -d "$STREAM_DIR" ]; then
cd "$STREAM_DIR"
cargo audit
else
echo "Stream Server directory not found!"
exit 1
fi
echo "Audit passed."

View file

@ -0,0 +1,17 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
STREAM_DIR="$REPO_ROOT/veza-stream-server"
echo "Rust Build Check"
if [ -d "$STREAM_DIR" ]; then
cd "$STREAM_DIR"
cargo build
else
echo "Stream Server directory not found!"
exit 1
fi
echo "Build passed."

36
tmt/tests/storybook/audit.sh Executable file
View file

@ -0,0 +1,36 @@
#!/bin/bash
set -e
REPO_ROOT=$(git rev-parse --show-toplevel)
WEB_DIR="$REPO_ROOT/apps/web"
echo "Storybook Build & Audit"
cd "$WEB_DIR"
npm run build-storybook
npx serve -s storybook-static -l 6007 &
SERVER_PID=$!
for i in $(seq 1 30); do
if curl -sf http://localhost:6007 >/dev/null; then
echo "Storybook ready"
break
fi
sleep 2
done
curl -sf http://localhost:6007 >/dev/null || { echo "Storybook failed to start"; kill $SERVER_PID 2>/dev/null; exit 1; }
npm run test:storybook
STATUS=$?
kill $SERVER_PID 2>/dev/null || true
if [ $STATUS -ne 0 ]; then
echo "Storybook audit failed."
exit 1
fi
echo "Storybook audit passed."

View file

@ -0,0 +1,11 @@
summary: Storybook Tests
tier: 3
component: storybook
/audit:
summary: Storybook Build & Audit
test: ./audit.sh
tier: 3
duration: 15m
require:
- npm