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" ]
pull_request:
branches: [ "main", "feature/mvp-complete" ]
workflow_dispatch: # Allow manual trigger
workflow_dispatch:
jobs:
backend-go:
name: Backend (Go)
# ===========================================================================
# TMT Vital — Backend (Go)
# ===========================================================================
vital-backend:
name: TMT Vital — Backend (Go)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -28,75 +31,37 @@ jobs:
fi
fi
- name: Set up Node
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: '20'
cache: 'npm'
- name: Set up Go
uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0
with:
go-version: '1.24'
cache: true
- name: Install dependencies
run: npm ci
- name: Run govulncheck
- name: Install Go tools
run: |
cd veza-backend-api
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
- name: Vet
run: |
cd veza-backend-api
go vet ./...
- name: Install TMT
run: pip install tmt
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
- name: Run TMT Vital Backend
run: tmt --root tmt run plan --name /vital-backend
- name: Lint
run: npx turbo run lint --filter=veza-backend-api
- name: Test with coverage
run: |
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)
# ===========================================================================
# TMT Vital — Rust Services (Stream)
# ===========================================================================
vital-services:
name: TMT Vital — Rust Services
runs-on: ubuntu-latest
steps:
- 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
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
components: rustfmt, clippy
- name: Install dependencies
run: npm ci
- name: Cache Cargo registry
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
@ -109,22 +74,17 @@ jobs:
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Auditing Stream Server
run: |
cd veza-stream-server
cargo audit
- name: Install TMT
run: pip install tmt
- name: Lint
run: npx turbo run lint --filter=veza-stream-server
- name: Run TMT Vital Services
run: tmt --root tmt run plan --name /vital-services
- name: Build
run: npx turbo run build --filter=veza-stream-server
- name: Test
run: npx turbo run test --filter=veza-stream-server
frontend:
name: Frontend (Web)
# ===========================================================================
# TMT Vital — Frontend (Web)
# ===========================================================================
vital-frontend:
name: TMT Vital — Frontend (Web)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -139,9 +99,6 @@ jobs:
- name: Install Dependencies
run: npm ci
- name: Security audit (npm)
run: npm audit --audit-level=critical
- name: Cache Generated Types
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
@ -150,46 +107,15 @@ jobs:
restore-keys: |
${{ runner.os }}-generated-types-
- name: Generate Types from OpenAPI
run: |
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: Install TMT
run: pip install tmt
- name: Check types sync with OpenAPI spec
run: |
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
- name: Run TMT Vital Frontend
run: tmt --root tmt run plan --name /vital-frontend
# ===========================================================================
# Storybook Audit (kept outside TMT — tier 3 candidate)
# ===========================================================================
storybook:
name: Storybook Audit
runs-on: ubuntu-latest
@ -224,6 +150,9 @@ jobs:
npm run test:storybook
working-directory: apps/web
# ===========================================================================
# E2E (Playwright) — kept outside TMT (complex infra setup)
# ===========================================================================
e2e:
name: E2E (Playwright)
runs-on: ubuntu-latest
@ -324,9 +253,12 @@ jobs:
path: apps/web/playwright-report/
retention-days: 7
# ===========================================================================
# Notify on failure
# ===========================================================================
notify-failure:
name: Notify on failure
needs: [backend-go, rust-services, frontend, storybook, e2e]
needs: [vital-backend, vital-services, vital-frontend, storybook, e2e]
if: failure()
runs-on: ubuntu-latest
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" />
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<UserPlus className="w-4 h-4 text-primary" />
Suggested Accounts
{t('feed.suggestedAccounts')}
</h3>
</div>

View file

@ -2,7 +2,8 @@
# 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: 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)
@$(ECHO_CMD) "${GREEN}✅ All tests passed.${NC}"
test-tmt: ## [MID] Run Unified TMT Pipeline
@$(ECHO_CMD) "${BLUE}🧪 Running TMT Pipeline...${NC}"
test-tmt: ## [MID] Run Unified TMT Pipeline (all vital tests)
@$(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; }
@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
@$(ECHO_CMD) "${BLUE}🧪 Running Web tests...${NC}"
@(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
@$(ECHO_CMD) "${BLUE}🧪 Running Backend API tests...${NC}"
@(cd $(ROOT)/$(SERVICE_DIR_backend-api) && \
@ -48,9 +72,9 @@ test-stream-server: ## [MID] Run Stream server tests only
lint: ## [MID] Lint everything
@$(ECHO_CMD) "${BLUE}🔍 Linting Codebase...${NC}"
@(cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo clippy -- -D warnings) || true
@(cd $(ROOT)/$(SERVICE_DIR_backend-api) && golangci-lint run ./...) || true
@(cd $(ROOT)/$(SERVICE_DIR_web) && npm run lint) || true
@(cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo clippy -- -D warnings)
@(cd $(ROOT)/$(SERVICE_DIR_backend-api) && golangci-lint run ./...)
@(cd $(ROOT)/$(SERVICE_DIR_web) && npm run lint)
lint-web: ## [MID] Lint web app only
@(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.setTimeout(60_000);
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 uniqueEmail = `e2e-${uniqueSuffix}@veza.test`;
const usernameInput = page.locator('#register-username');
await usernameInput.waitFor({ state: 'visible', timeout: 5_000 });
await usernameInput.fill(`e2e-user-${uniqueSuffix}`);
await page.locator('#register-username').fill(`e2e-user-${uniqueSuffix}`);
await page.locator('#register-email').fill(uniqueEmail);
await page.locator('#register-password').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');
await termsCheckbox.waitFor({ state: 'attached', timeout: 5_000 });
await expect(termsCheckbox).toBeAttached({ timeout: 5_000 });
await termsCheckbox.click({ force: true });
await page.waitForTimeout(300);
const submitBtn = page.getByTestId('register-submit');
await submitBtn.waitFor({ state: 'visible', timeout: 5_000 });
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
await submitBtn.click();
// After registration, the app shows a verification notice (stays on /register)
// with text "Inscription réussie" / "vérification" — OR redirects — OR shows error
await Promise.race([
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 }),
// Also accept rate limit or "already exists" error as valid outcomes
page.getByText(/rate limit|trop de requêtes|existe déjà|already exists|erreur|error/i).waitFor({ timeout: 20_000 }),
// Fallback: the role="status" container of the verification notice
page.locator('[role="status"]').first().waitFor({ state: 'visible', timeout: 20_000 }),
]);
// Must get a success indication: redirect OR verification notice
const successIndicator = page.getByText(/vérification|verification|email envoyé|check your email|inscription réussie/i)
.or(page.locator('[role="status"]').first());
await expect(
successIndicator.first(),
).toBeVisible({ 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 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-email').fill(CONFIG.users.listener.email);
await page.locator('#register-password').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');
await termsCheckbox.waitFor({ state: 'attached', timeout: 5_000 });
await expect(termsCheckbox).toBeAttached({ timeout: 5_000 });
await termsCheckbox.click({ force: true });
await page.waitForTimeout(300);
const submitBtn = page.getByTestId('register-submit');
await submitBtn.waitFor({ state: 'visible', timeout: 5_000 });
await submitBtn.click();
await page.getByTestId('register-submit').click();
// Error message should appear (role="alert" in form, or rate-limit toast)
const errorAlert = page.getByRole('alert');
const errorStatus = page.getByRole('status');
const errorText = page.getByText(/existe déjà|already exists|email.*taken|trop de requêtes|rate limit|erreur/i);
await expect(errorAlert.or(errorStatus).or(errorText).first()).toBeVisible({ timeout: 5_000 });
// Must show an error — not silently succeed
const errorIndicator = page.getByRole('alert')
.or(page.getByText(/existe déjà|already exists|email.*taken/i));
await expect(errorIndicator.first()).toBeVisible({ timeout: 10_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 page.locator('#register-password').fill('123');
// Tab away to trigger blur validation
await page.locator('#register-password').press('Tab');
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-username').fill('testuser');
await page.locator('#register-password_confirm').fill('123');
await page.getByTestId('register-submit').click();
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')
.or(page.getByRole('alert'))
.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 });
});
test('05. Validation côté client — email invalide', async ({ page }) => {
test('05. Validation cote client — email invalide', async ({ page }) => {
await navigateTo(page, '/register');
await page.locator('#register-email').fill('not-an-email');
await page.locator('#register-email').blur();
const errorMsg = page.getByText(/email.*invalide|invalid.*email|format/i);
await expect(errorMsg).toBeVisible({ timeout: 3_000 });
await expect(
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 }) => {
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/);
// Verify authenticated layout elements are visible (sidebar)
const sidebar = page.getByTestId('app-sidebar');
await expect(sidebar).toBeVisible({ timeout: 5_000 });
// Authenticated layout must be visible
await expect(page.getByTestId('app-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);
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"]');
await emailInput.waitFor({ state: 'visible', timeout: 5_000 });
await emailInput.clear();
await emailInput.fill(CONFIG.users.listener.email);
const passwordInput = page.locator('input[type="password"]').first();
@ -141,31 +129,17 @@ test.describe('AUTH — Connexion', () => {
await passwordInput.fill('WrongPassword123!');
await page.getByTestId('login-submit').click();
// Wait for the API call to complete and error to render
await page.waitForTimeout(5_000);
// Error should appear — either as role="alert" in the form, or as a rate-limit toast, or as body text
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
// Must show error AND stay on /login
const errorIndicator = page.getByRole('alert')
.or(page.getByText(/incorrect|invalid|erreur|error|identifiants/i));
await expect(errorIndicator.first()).toBeVisible({ timeout: 15_000 });
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 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 })
.or(page.locator('a[href="/forgot-password"]'));
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 }) => {
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 })
.or(page.locator('a[href="/register"]'));
await expect(registerLink.first()).toBeVisible({ timeout: 8_000 });
@ -188,28 +161,19 @@ test.describe('AUTH — Connexion', () => {
});
});
test.describe('AUTH — Sessions et sécurité', () => {
test('11. Redirection vers /login si non authentifié @critical', async ({ page }) => {
test.describe('AUTH — Sessions et securite', () => {
test('11. Redirection vers /login si non authentifie @critical', async ({ page }) => {
test.setTimeout(60_000);
// Try to access a protected page without auth
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle').catch(() => {});
// The app loads, calls refreshUser(), then redirects if not authenticated.
// This can take a few seconds due to the splash screen and API call.
// Must redirect to login
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);
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
const isAuthenticated = await page.evaluate(() => {
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);
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 logout test');
return;
}
// Try Header user menu sign out first (most reliable path)
// Find and click sign out — try header menu first, then sidebar
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 page.waitForTimeout(800);
// Header dropdown has a "Sign Out" / "Déconnexion" button with class text-destructive
const signOutBtn = page.locator('button.text-destructive').first()
.or(page.locator('button').filter({ hasText: /sign out|déconnexion|logout/i }).first());
if (await signOutBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
await expect(signOutBtn).toBeVisible({ timeout: 3_000 });
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'))
const sidebarLogout = page.locator('[data-testid="app-sidebar"] button[aria-label]').filter({ hasText: /logout|déconnexion|sign out/i }).first()
.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(() => {});
}
// Must redirect to /login
await expect(page).toHaveURL(/login/, { timeout: 20_000 });
// Verify we ended up on /login, or at minimum that auth was cleared
const logoutUrl = page.url();
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(() => {
// Auth state must be cleared
const isAuthenticated = await page.evaluate(() => {
const raw = localStorage.getItem('auth-storage');
if (!raw) 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
// 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)');
try {
return JSON.parse(raw)?.state?.isAuthenticated === true;
} catch {
return false;
}
});
expect(isAuthenticated, 'auth-storage should be cleared after logout').toBeFalsy();
});
test('14. Protection CSRF — la page login charge sans erreur CSRF', async ({ page }) => {
await navigateTo(page, '/login');
// Verify the page loads without CSRF errors
const body = await page.textContent('body') || '';
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 }) => {
await navigateTo(page, '/login');
// Check for OAuth provider buttons
const oauthProviders = ['google', 'github', 'discord', 'spotify'];
for (const provider of oauthProviders) {
const btn = page.getByRole('button', { name: new RegExp(provider, 'i') })
.or(page.locator(`[data-provider="${provider}"]`))
.or(page.locator(`a[href*="${provider}"]`));
// At least one OAuth provider button must be visible
const oauthBtn = page.getByRole('button', { name: /google|github|discord|spotify/i }).first()
.or(page.locator('[data-provider]').first())
.or(page.locator('a[href*="oauth"]').first());
const isVisible = await btn.isVisible().catch(() => false);
console.log(` OAuth ${provider}: ${isVisible ? 'visible' : 'absent'}`);
}
await expect(oauthBtn).toBeVisible({ timeout: 5_000 });
});
});

View file

@ -1,19 +1,6 @@
import { test, expect } from '@chromatic-com/playwright';
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.beforeEach(async ({ page }) => {
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 }) => {
const hasTracks = await navigateToPageWithTracks(page);
test.skip(!hasTracks, 'No tracks in database — seed required');
const trackCard = page.locator('[role="article"]').first();
// Hover the card to reveal the play button overlay
await trackCard.hover();
await page.waitForTimeout(300);
// Play button on the TrackCard cover: aria-label="Lire {title}"
const playBtn = page.getByRole('button', { name: /^Lire /i }).first();
await expect(playBtn).toBeVisible({ timeout: 5_000 });
await playBtn.click();
// The global player bar must appear
await assertPlayerVisible(page);
});
test('02. Le player affiche titre + artiste du track en cours', async ({ 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);
// Track info section has aria-label="Track info"
// Track info must be visible with real content
const trackInfo = player.locator('[aria-label="Track info"]');
await expect(trackInfo).toBeVisible({ timeout: 5_000 });
// Title is an h3 element inside track info
const title = trackInfo.locator('h3');
await expect(title).toBeVisible();
const titleText = await title.textContent();
expect(titleText?.trim().length).toBeGreaterThan(0);
expect(titleText).not.toMatch(/undefined|null|NaN/);
expect(titleText?.trim().length, 'Track title must not be empty').toBeGreaterThan(0);
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');
await expect(artist).toBeVisible();
const artistText = await artist.textContent();
expect(artistText?.trim().length).toBeGreaterThan(0);
expect(artistText).not.toMatch(/undefined|null|NaN/);
expect(artistText?.trim().length, 'Artist name must not be empty').toBeGreaterThan(0);
expect(artistText, 'Artist name must not contain debug text').not.toMatch(/undefined|null|NaN/);
});
test('03. Bouton play/pause toggle fonctionne', async ({ 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);
// DOM vérifié: le bouton play/pause a data-testid="play-button", PAS d'aria-label
const playPauseBtn = player.getByTestId('play-button');
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 page.waitForTimeout(500);
// Click again to toggle back
await playPauseBtn.click();
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 }) => {
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();
await trackCard.hover();
await page.waitForTimeout(300);
@ -95,63 +80,50 @@ test.describe('PLAYER — Lecteur audio', () => {
const player = await assertPlayerVisible(page);
// Progress bar: role="slider" aria-label="Progression"
// Rendered only when a track is loaded (not idle state)
// Progress bar must be visible
const progressBar = player.locator('[role="slider"][aria-label="Progression"]');
await expect(progressBar).toBeVisible({ timeout: 10_000 });
const box = await progressBar.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThan(50);
expect(box, 'Progress bar must have a bounding box').not.toBeNull();
expect(box!.width, 'Progress bar must have substantial width').toBeGreaterThan(50);
// Verify ARIA attributes
const valueMin = await progressBar.getAttribute('aria-valuemin');
// ARIA attributes must be set correctly
await expect(progressBar).toHaveAttribute('aria-valuemin', '0');
const valueMax = await progressBar.getAttribute('aria-valuemax');
expect(valueMin).toBe('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)
expect(Number(valueMax), 'aria-valuemax must be >= 0').toBeGreaterThanOrEqual(0);
});
test('05. Controle du volume fonctionne', async ({ 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);
// Mute button: aria-label="Mute" or "Unmute"
// Mute button must exist
const muteBtn = player.getByRole('button', { name: /^mute$|^unmute$/i }).first();
const muteVisible = await muteBtn.isVisible().catch(() => false);
console.log(` Mute button: ${muteVisible ? 'visible' : 'not visible'}`);
expect(muteVisible).toBe(true);
await expect(muteBtn).toBeVisible({ timeout: 5_000 });
if (muteVisible) {
// Click mute
// Click mute — label must toggle
const initialLabel = await muteBtn.getAttribute('aria-label');
await muteBtn.click();
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');
expect(newLabel).not.toBe(initialLabel);
expect(newLabel, 'Mute button label must change after click').not.toBe(initialLabel);
// Click again to restore
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 playerVisible = await tryPlayAndCheckPlayer(page);
test.skip(!hasTracks, 'No tracks in database — seed required');
await playFirstTrack(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 playBtn = player.getByTestId('play-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(playBtn).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 }) => {
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);
await page.waitForTimeout(2_000);
// DOM vérifié: le temps est dans la section region "Playback controls"
// sous forme de generic elements contenant "0:00", "6:50" etc.
// Time display must show at least one timestamp in X:XX format
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 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();
console.log(` Time displayed: "${text}"`);
expect(text).toMatch(/\d+:\d{2}/);
} else {
console.log(' Time display not found (may be hidden on small viewports)');
}
expect(text, 'Time must match X:XX format').toMatch(/\d+:\d{2}/);
});
test('08. Raccourcis clavier — Espace toggle play/pause', async ({ 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);
// Press Space to toggle play/pause (keyboard shortcuts are handled by useKeyboardShortcuts)
// Press Space — must not crash
await page.keyboard.press('Space');
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') || '';
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 }) => {
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);
// 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();
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');
expect(initialLabel).toMatch(/show queue/i);
// Click to open queue
// Click to open — must change to "Hide queue"
await queueBtn.click();
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');
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);
test.skip(!hasTracks, 'No tracks in database — seed required');
// Find a track card (role="article")
const trackCard = page.locator('[role="article"]').first();
if (await trackCard.isVisible().catch(() => false)) {
// Hover to reveal action buttons
await expect(trackCard).toBeVisible();
await trackCard.hover();
await page.waitForTimeout(300);
// Look for "More options" button: aria-label="Plus d'options pour {title}"
// "More options" button must exist on track cards
const moreBtn = trackCard.getByRole('button', { name: /plus d'options/i }).first();
if (await moreBtn.isVisible().catch(() => false)) {
// Use force:true because the play button overlay can intercept pointer events
await expect(moreBtn).toBeVisible({ timeout: 5_000 });
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');
}
}
// Context menu must appear with queue-related option
const menuItem = page.getByRole('menuitem', { name: /queue|file d'attente|ajouter/i });
await expect(menuItem).toBeVisible({ timeout: 3_000 });
});
});
// ─── PLAYER AVANCE ──────────────────────────────────────────────────────
test.describe('PLAYER — Controles avances @critical', () => {
test.setTimeout(60_000);
test.beforeEach(async ({ page }) => {
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);
if (!hasTracks) return; // No tracks, tests will skip
// Wrap playFirstTrack in try/catch — it may timeout if no play button is found
try {
test.skip(!hasTracks, 'No tracks in database — seed required');
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(() => {});
await assertPlayerVisible(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()
.or(page.getByRole('button', { name: /melanger|shuffle/i }).first());
if (await shuffleBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
// Initial state: off
const initialPressed = await shuffleBtn.getAttribute('aria-pressed');
await expect(shuffleBtn).toBeVisible({ timeout: 5_000 });
// Click to enable
// Toggle on
const initialPressed = await shuffleBtn.getAttribute('aria-pressed');
await shuffleBtn.click();
await page.waitForTimeout(300);
const afterClick = await shuffleBtn.getAttribute('aria-pressed');
// Click again to disable
// Toggle off
await shuffleBtn.click();
await page.waitForTimeout(300);
const afterSecondClick = await shuffleBtn.getAttribute('aria-pressed');
// Verify toggle behavior
if (initialPressed === 'false') {
expect(afterClick).toBe('true');
expect(afterSecondClick).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
expect(afterClick, 'Shuffle should be on after first click').toBe('true');
expect(afterSecondClick, 'Shuffle should be off after second click').toBe('false');
}
});
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
test('Cycle repeat off -> track -> playlist -> off @critical', async ({ page }) => {
let repeatBtn = page.getByRole('button', { name: /repeter|repeat/i }).first();
if (!await repeatBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
// Open expanded player
// If not visible in bar, try expanded player
if (!await repeatBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
const trackInfo = page.locator('[aria-label="Track info"]').first();
if (await trackInfo.isVisible().catch(() => false)) {
await expect(trackInfo).toBeVisible();
await trackInfo.click();
await page.waitForTimeout(500);
}
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');
const label1 = (await repeatBtn.getAttribute('aria-label') || '').toLowerCase();
expect(label1).toContain('desactiv');
// Click -> track
await repeatBtn.click();
await page.waitForTimeout(300);
const label2 = await repeatBtn.getAttribute('aria-label') || '';
expect(label2.toLowerCase()).toMatch(/piste|track/);
const label2 = (await repeatBtn.getAttribute('aria-label') || '').toLowerCase();
expect(label2).toMatch(/piste|track/);
// Click -> playlist
await repeatBtn.click();
await page.waitForTimeout(300);
const label3 = await repeatBtn.getAttribute('aria-label') || '';
expect(label3.toLowerCase()).toMatch(/playlist/);
const label3 = (await repeatBtn.getAttribute('aria-label') || '').toLowerCase();
expect(label3).toMatch(/playlist/);
// Click -> off
await repeatBtn.click();
await page.waitForTimeout(300);
const label4 = await repeatBtn.getAttribute('aria-label') || '';
expect(label4.toLowerCase()).toContain('desactiv');
}
const label4 = (await repeatBtn.getAttribute('aria-label') || '').toLowerCase();
expect(label4).toContain('desactiv');
});
test('Controle vitesse de lecture — changement visible @critical', async ({ page }) => {
const playerVisible = await page.getByTestId('global-player').isVisible().catch(() => false);
// Open expanded player to find speed control
// Open expanded player
const trackInfo = page.locator('[aria-label="Track info"]').first();
if (await trackInfo.isVisible().catch(() => false)) {
await expect(trackInfo).toBeVisible();
await trackInfo.click();
await page.waitForTimeout(500);
}
const speedBtn = page.locator('[aria-label*="Vitesse de lecture"]').first()
.or(page.locator('button:has-text("1x")').first());
const speedVisible = await speedBtn.isVisible({ timeout: 5000 }).catch(() => false);
const speedEnabled = speedVisible && !(await speedBtn.isDisabled().catch(() => true));
if (speedVisible && speedEnabled) {
// Click to open speed menu
await expect(speedBtn).toBeVisible({ timeout: 5_000 });
await expect(speedBtn).toBeEnabled();
await speedBtn.click();
await page.waitForTimeout(300);
// Look for speed options
// Speed option must appear
const option15 = page.locator('text="1.5x"').first();
if (await option15.isVisible({ timeout: 2000 }).catch(() => false)) {
await expect(option15).toBeVisible({ timeout: 2_000 });
await option15.click();
await page.waitForTimeout(300);
// Verify the button now shows 1.5x
// Button must now show 1.5x
const updatedLabel = await speedBtn.getAttribute('aria-label') || '';
expect(updatedLabel).toContain('1.5');
}
}
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 }) => {
const playerVisible = await page.getByTestId('global-player').isVisible().catch(() => false);
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 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()
.or(page.locator('[class*="backdrop-blur-3xl"]').first());
await expect(expandedPlayer).toBeVisible({ timeout: 3_000 });
// Verify key elements: large artwork, controls
const hasExpandedContent = await expandedPlayer.isVisible({ timeout: 3000 }).catch(() => false);
if (hasExpandedContent) {
// Look for close button (ChevronDown)
// Must have a close button
const closeBtn = expandedPlayer.locator('button').first();
expect(closeBtn).toBeTruthy();
// Close expanded player
await expect(closeBtn).toBeVisible();
await closeBtn.click();
await page.waitForTimeout(300);
}
});
test('Reglage crossfade accessible dans le player etendu @critical', async ({ page }) => {
const playerVisible = await page.getByTestId('global-player').isVisible().catch(() => false);
test('Queue — ouvrir et voir le contenu @critical', async ({ page }) => {
const player = await assertPlayerVisible(page);
// Open expanded player
const trackInfo = page.locator('[aria-label="Track info"]').first();
if (await trackInfo.isVisible().catch(() => false)) {
await trackInfo.click();
await page.waitForTimeout(500);
}
// Look for audio settings button (Settings2 icon)
const settingsBtn = page.locator('button').filter({ has: page.locator('[class*="Settings2"], [class*="settings"]') }).first()
.or(page.getByRole('button', { name: /audio settings|parametres audio/i }).first());
if (await settingsBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await settingsBtn.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)) {
const queueBtn = player.getByTestId('queue-button')
.or(player.getByRole('button', { name: /^show queue$/i }));
await expect(queueBtn).toBeVisible({ timeout: 5_000 });
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 });
// Queue panel must be visible with content
const queuePanel = page.locator('text=/play queue|file d.attente|your queue/i').first();
await expect(queuePanel).toBeVisible({ timeout: 3_000 });
// 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 }) => {
// Navigate directly to a known artist profile (seed user amelie_dubois)
await navigateTo(page, '/u/amelie_dubois');
// Navigate directly to a known artist profile (seed user top_artist)
await navigateTo(page, `/u/${CONFIG.users.creator.username}`);
await page.waitForLoadState('networkidle');
// 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 }) => {
// 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');
// 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 }) => {
await navigateTo(page, '/u/amelie_dubois');
await navigateTo(page, `/u/${CONFIG.users.creator.username}`);
await page.waitForLoadState('networkidle');
const body = await page.textContent('body') || '';
@ -97,7 +97,7 @@ test.describe('SOCIAL — Profils', () => {
console.log(` Stats Followers: ${hasFollowersLabel ? '✓' : '✗'}`);
// 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 ? '✓' : '✗'}`);
});
});

View file

@ -3,10 +3,10 @@ import { loginViaAPI, CONFIG, navigateTo } from './helpers';
/**
* 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 {
return Buffer.from(
'4944330300000000000a544954320000000500000054657374fffb90440000000000000000000000000000000000000000',
@ -16,202 +16,176 @@ function createTestMP3Buffer(): Buffer {
test.describe('UPLOAD - Track upload flow @critical', () => {
test.beforeEach(async ({ page }) => {
// Login as creator (has upload permissions)
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');
// Find and click upload button
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
const uploadVisible = await uploadBtn.isVisible({ timeout: 10_000 }).catch(() => false);
if (!uploadVisible) {
console.log(' Upload button not found — skipping');
return;
}
await expect(uploadBtn, 'Upload button must be visible on /library').toBeVisible({ timeout: 10_000 });
});
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 page.waitForTimeout(500);
// Wait for upload modal/dialog
const dialog = page.locator('[role="dialog"]').first();
const dialogVisible = await dialog.isVisible({ timeout: 5000 }).catch(() => false);
if (!dialogVisible) {
console.log(' Upload dialog did not appear — skipping');
return;
}
await expect(dialog, 'Upload dialog must appear after clicking upload').toBeVisible({ timeout: 5_000 });
// 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 fileInputExists = await fileInput.count();
if (fileInputExists === 0) {
console.log(' File input not found in upload dialog — skipping');
return;
}
const uniqueTitle = `E2E Upload ${Date.now()}`;
expect(await fileInput.count(), 'File input must exist').toBeGreaterThan(0);
const uniqueTitle = `E2E Upload ${Date.now()}`;
await fileInput.setInputFiles({
name: 'test-track.mp3',
mimeType: 'audio/mpeg',
buffer: createTestMP3Buffer(),
});
// Wait for file to be processed (dropzone disappears, metadata form appears)
await page.waitForTimeout(1000);
// Fill metadata
// Step 3: Fill metadata — title input must appear after file is processed
const titleInput = dialog.locator('#title').or(dialog.locator('input[name="title"]'));
if (!await titleInput.isVisible({ timeout: 5000 }).catch(() => false)) {
console.log(' Title input not visible after file upload — skipping');
return;
}
await expect(titleInput, 'Title input must appear after file upload').toBeVisible({ timeout: 10_000 });
await titleInput.fill(uniqueTitle);
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');
}
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');
}
// Submit the form
// Step 4: Submit
const submitBtn = dialog.locator('button[type="submit"]')
.or(dialog.locator('button[form="upload-track-form"]'))
.or(dialog.getByRole('button', { name: /uploader/i }));
if (!await submitBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
console.log(' Submit button not visible — skipping');
return;
}
await expect(submitBtn, 'Submit button must be visible').toBeVisible({ timeout: 3_000 });
await submitBtn.click();
// Wait for upload completion (success message or dialog closes)
const success = dialog.locator('text=/upload|success|succ/i').first();
const dialogClosed = page.waitForSelector('[role="dialog"]', { state: 'hidden', timeout: 60_000 }).catch(() => null);
// Step 5: Wait for upload to complete — dialog must close or show success
await expect(dialog, 'Upload dialog must close after successful upload').not.toBeVisible({ timeout: 60_000 });
await Promise.race([
success.waitFor({ state: 'visible', timeout: 60_000 }).catch(() => {}),
dialogClosed,
]);
// Verify track appears in library after reload
// Step 6: Verify track appears in 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 isVisible = await trackInLibrary.isVisible({ timeout: 10_000 }).catch(() => false);
if (isVisible) {
console.log(' Track visible in library');
} else {
console.warn(' Track not yet visible (may still be processing)');
}
await expect(
trackInLibrary,
`Uploaded track "${uniqueTitle}" must be visible in library`,
).toBeVisible({ timeout: 15_000 });
});
test('should show error for invalid file format', async ({ page }) => {
await navigateTo(page, '/library');
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
const uploadVisible = await uploadBtn.isVisible({ timeout: 10_000 }).catch(() => false);
if (!uploadVisible) {
console.log(' Upload button not found — skipping');
return;
}
await expect(uploadBtn).toBeVisible({ timeout: 10_000 });
await uploadBtn.click();
await page.waitForTimeout(500);
const dialog = page.locator('[role="dialog"]').first();
const dialogVisible = await dialog.isVisible({ timeout: 5000 }).catch(() => false);
if (!dialogVisible) {
console.log(' Upload dialog did not appear — skipping');
return;
}
await expect(dialog).toBeVisible({ timeout: 5_000 });
const fileInput = dialog.locator('input[type="file"]').first();
if (await fileInput.count() === 0) {
console.log(' File input not found — skipping');
return;
}
expect(await fileInput.count()).toBeGreaterThan(0);
// Try uploading a text file
// Upload a text file — must be rejected
await fileInput.setInputFiles({
name: 'invalid.txt',
mimeType: 'text/plain',
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 dropzoneStillVisible = dialog.locator('text=/glissez|drag|drop/i').first();
const hasError = await errorMsg.isVisible({ timeout: 3000 }).catch(() => false);
const dropzoneBack = await dropzoneStillVisible.isVisible({ timeout: 3000 }).catch(() => false);
const hasError = await errorMsg.isVisible({ timeout: 3_000 }).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');
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
const uploadVisible = await uploadBtn.isVisible({ timeout: 10_000 }).catch(() => false);
if (!uploadVisible) {
console.log(' Upload button not found — skipping');
return;
}
await expect(uploadBtn).toBeVisible({ timeout: 10_000 });
await uploadBtn.click();
await page.waitForTimeout(500);
const dialog = page.locator('[role="dialog"]').first();
const dialogVisible = await dialog.isVisible({ timeout: 5000 }).catch(() => false);
if (!dialogVisible) {
console.log(' Upload dialog did not appear — skipping');
return;
}
await expect(dialog).toBeVisible({ timeout: 5_000 });
// 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"]')
.or(dialog.locator('button[form="upload-track-form"]'))
.or(dialog.getByRole('button', { name: /uploader/i }));
if (await submitBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
const isDisabled = await submitBtn.isDisabled();
expect(isDisabled).toBeTruthy();
const isVisible = await submitBtn.isVisible({ timeout: 3_000 }).catch(() => false);
if (isVisible) {
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');
const uploadBtn = page.getByRole('button', { name: /upload|uploader/i }).first();
const uploadVisible = await uploadBtn.isVisible({ timeout: 10_000 }).catch(() => false);
if (!uploadVisible) {
console.log(' Upload button not found — skipping');
return;
}
await expect(uploadBtn).toBeVisible({ timeout: 10_000 });
await uploadBtn.click();
await page.waitForTimeout(500);
const dialog = page.locator('[role="dialog"]').first();
const dialogVisible = await dialog.isVisible({ timeout: 5000 }).catch(() => false);
if (!dialogVisible) {
console.log(' Upload dialog did not appear — skipping');
return;
}
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Close via Escape
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
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 expect(dialog).not.toBeVisible({ timeout: 3000 });
} else {
// Close via Escape
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 3000 });
}
await expect(dialog, 'Dialog must close after clicking close button').not.toBeVisible({ timeout: 3_000 });
});
});

View file

@ -3,7 +3,7 @@ import { CONFIG, loginViaAPI } from './helpers';
const BASE = CONFIG.baseURL;
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('Chargement & Rendu', () => {

View file

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

View file

@ -1,7 +1,7 @@
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 = {
@ -14,27 +14,27 @@ export const CONFIG = {
/** Base URL du stream server Rust */
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: {
listener: {
email: 'listener1@veza.fr',
password: 'Password123!',
username: 'music_lover',
email: 'user@veza.music',
password: 'User123!',
username: 'music_fan',
},
creator: {
email: 'amelie@veza.fr',
password: 'Password123!',
username: 'amelie_dubois',
email: 'artist@veza.music',
password: 'Artist123!',
username: 'top_artist',
},
admin: {
email: 'admin@veza.fr',
password: 'Password123!',
email: 'admin@veza.music',
password: 'Admin123!',
username: 'admin_veza',
},
moderator: {
email: 'mod@veza.fr',
password: 'Password123!',
username: 'moderator_veza',
email: 'mod@veza.music',
password: 'Mod123!',
username: 'mod_veza',
},
},
@ -53,13 +53,7 @@ export const CONFIG = {
/**
* Login via l'interface utilisateur (page /login).
* Utilise les vrais sélecteurs du composant LoginPage.tsx.
*
* 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"
* STRICT: echoue si le login ne redirige pas hors de /login.
*/
export async function loginViaUI(
page: Page,
@ -68,17 +62,13 @@ export async function loginViaUI(
options: { rememberMe?: boolean } = {},
): Promise<void> {
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({
state: 'visible',
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"]');
await emailInput.waitFor({ state: 'visible', timeout: CONFIG.timeouts.navigation });
await emailInput.clear();
@ -88,91 +78,56 @@ export async function loginViaUI(
await passwordInput.clear();
await passwordInput.fill(password);
// Remember me checkbox (optionnel)
if (options.rememberMe) {
const rememberMe = page.locator('#remember_me');
if (await rememberMe.isVisible().catch(() => false)) {
if (await rememberMe.isVisible()) {
await rememberMe.check();
}
}
// Soumettre — le bouton est data-testid="login-submit" avec texte "Sign In"
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();
// Attendre la redirection (quitte /login)
const redirected = await page.waitForURL((url) => !url.pathname.includes('/login'), {
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();
// STRICT: must redirect away from /login
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).
* Beaucoup plus rapide que loginViaUI car évite le rendu complet de la SPA.
*
* POST /api/v1/auth/login set cookies + localStorage auth-storage
* STRICT: echoue si l'API retourne une erreur.
*/
export async function loginViaAPI(
page: Page,
email: string,
password: string,
): 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;
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`, {
data: { email, password, remember_me: false },
});
if (!response.ok()) {
// Ne pas throw — le test appelant vérifiera si on est authentifié
console.warn(`loginViaAPI failed: ${response.status()}`);
return;
}
// STRICT: login must succeed
expect(response.ok(), `Login API failed: ${response.status()} for ${email}`).toBeTruthy();
const body = await response.json();
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) => {
await page.evaluate(() => {
const authState = {
state: { isAuthenticated: true, isLoading: false, error: null },
version: 1,
};
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.waitForLoadState('networkidle').catch(() => {});
// Wait for the app to finish auth initialization
await page.waitForTimeout(1_000);
// Wait for auth initialization to complete
await page.locator('main, [role="main"]').first().waitFor({
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).
*
* 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.
* Navigue vers un path et attend que l'app soit prete.
* STRICT: echoue si la page ne charge pas (main element must appear).
*/
export async function navigateTo(page: Page, path: string): Promise<void> {
const url = path.startsWith('http') ? path : `${CONFIG.baseURL}${path}`;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle').catch(() => {});
// Wait for the app to finish initializing (loading splash → actual page)
await page.waitForLoadState('networkidle').catch(() => {
// 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({
state: 'visible',
timeout: 20_000,
}).catch(() => {});
});
}
/**
* Vérifie qu'une page se charge sans erreur critique.
* Retourne les erreurs console collectées.
* Verifie qu'une page se charge sans erreur critique.
* STRICT: fails on 500 errors visible in the page.
*/
export async function assertPageLoads(page: Page, path: string): Promise<string[]> {
const errors: string[] = [];
@ -216,8 +170,7 @@ export async function assertPageLoads(page: Page, path: string): Promise<string[
await navigateTo(page, path);
// Vérifier pas de crash
const body = await page.textContent('body').catch(() => '') || '';
const body = await page.textContent('body') || '';
expect(body).not.toMatch(/500|Internal Server Error/i);
return errors;
@ -228,8 +181,7 @@ export async function assertPageLoads(page: Page, path: string): Promise<string[
// =============================================================================
/**
* Remplit un formulaire avec les champs donnés.
* Les clés sont les labels ou placeholders des champs.
* Remplit un formulaire avec les champs donnes.
*/
export async function fillForm(
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> {
const body = await page.textContent('body').catch(() => '') || '';
// Patterns de debug courants
const body = await page.textContent('body') || '';
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 matches = body.match(suspiciousPatterns);
if (matches && matches.length > 2) {
console.warn(` ⚠ Texte suspect trouvé: ${matches.slice(0, 3).join(', ')}`);
}
expect(
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> {
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.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(
page: Page,
@ -299,14 +251,12 @@ export async function collectNetworkErrors(
/**
* 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> {
const sidebarOverlay = page.locator('div[aria-hidden="true"][role="presentation"].fixed.inset-0');
if (await sidebarOverlay.isVisible({ timeout: 1_000 }).catch(() => false)) {
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.
* Le player a data-testid="global-player" et role="region" aria-label="Global player".
* Verifie que le player global est visible et le retourne.
* STRICT: fails if player is not visible.
*/
export async function assertPlayerVisible(page: Page): Promise<Locator> {
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").
*
* Page rendering details:
* - /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).
* Navigate to a page that displays track cards (role="article").
* Returns true if tracks are found, false if the database has no tracks.
* Does NOT swallow errors navigation failures will throw.
*/
export async function navigateToPageWithTracks(page: Page): Promise<boolean> {
// Fermer la sidebar mobile si ouverte (son FocusTrap intercepte les clics)
await dismissMobileSidebar(page);
// Try /feed first — it uses TrackGrid/TrackCard (role="article")
// and shows tracks from followed users + by_genres section
// Try /feed first
await navigateTo(page, '/feed');
const feedTrack = page.locator('[role="article"]').first();
if (await feedTrack.isVisible({ timeout: 5_000 }).catch(() => false)) {
return true;
}
// Fallback: /discover → genre buttons are <button> with .font-heading.font-bold spans
// Clicking a genre sets ?genre=slug which loads tracks via TrackGrid/TrackCard
// Fallback: /discover -> click first genre
await navigateTo(page, '/discover');
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.
* Navigates to a page with tracks if none are visible on the current page.
* Les TrackCards ont un bouton play avec aria-label="Lire {title}" ou "Play {title}".
* STRICT: fails if no play button is found or if it can't be clicked.
*/
export async function playFirstTrack(page: Page): Promise<void> {
// Fermer la sidebar mobile si ouverte (son FocusTrap intercepte les clics)
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();
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
const trackCard = page.locator('[role="article"]').first()
.or(page.getByRole('button', { name: /piste:/i }).first());
if (await trackCard.isVisible().catch(() => false)) {
// Hover to reveal play button
const trackCard = page.locator('[role="article"]').first();
await expect(trackCard).toBeVisible({ timeout: CONFIG.timeouts.action });
await trackCard.hover();
await page.waitForTimeout(300);
}
// Cliquer le bouton play (aria-label="Lire ...")
// Click play
const playBtn = page.getByRole('button', { name: /^lire |^play /i }).first()
.or(page.locator('[aria-label*="Lire"]').first())
.or(page.locator('[aria-label*="Play"]').first());
await playBtn.waitFor({ state: 'visible', timeout: CONFIG.timeouts.action }).catch(() => {});
if (await playBtn.isVisible().catch(() => false)) {
await expect(playBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await playBtn.click();
// Attendre que le player apparaisse
// Wait for player to appear
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 = {
// Layout (vérifié via DOM snapshot)
sidebar: '[data-testid="app-sidebar"]', // complementary "Main sidebar"
sidebar: '[data-testid="app-sidebar"]',
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"]',
registerForm: '[data-testid="register-form"]',
// Player (vérifié: les boutons n'ont PAS d'aria-labels)
audioElement: '[data-testid="audio-element"]',
progressBar: '[role="slider"][aria-label="Progression"]',
volumeSlider: '[data-testid="volume-control"] [role="slider"]',
// Toast
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"]',
// Search — Header search uses data-testid="search-input" type="search"
searchInput: '[data-testid="search-input"], [role="search"] input, input[type="search"], input[role="searchbox"]',
} as const;
@ -440,15 +366,16 @@ export const SELECTORS = {
/**
* 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> {
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()) || '';
}
/**
* 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 {
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 Contract**: [docs/BUDGETS.md](../docs/BUDGETS.md)
This directory contains the definition of Veza's unified testing pipeline.
It is the **Executive Branch** that enforcing the laws defined in `FRUGALITY.md` and `BUDGETS.md`.
TMT is the **single entry point** for all Veza tests.
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.
2. **Contractual Budgets**: Resource limits are defined in `docs/BUDGETS.md`. Tests verify these limits.
3. **No New Frontend Tests**: By default, new frontend tests are `legacy`. You must prove a test is `vital` to promote it.
1. **Vital tests block everything.** If a tier 1 test fails, the commit is rejected.
2. **Contractual budgets.** Resource limits from `docs/BUDGETS.md` are enforced by tests.
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
- `plans/`:
- **`vital.fmf`**: **TIER 1**. The "must pass" suite. Runs fast, strictly, and enforces budgets.
- **`legacy.fmf`**: **TIER 2**. Slow/Old tests. Informational only.
- `tests/`: Actual test scripts.
- `frontend/`: Linked to `BUDGETS.md`.
- `backend/`: Linked to `BUDGETS.md`.
- `services/`: Strict Rust checks.
```
tmt/
├── .fmf/version # FMF tree root
├── plans/
│ ├── vital.fmf # Tier 1 — all components
│ ├── vital-backend.fmf # Tier 1 — Go only (CI)
│ ├── 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
### Vital Tests (The Standard)
```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)
```bash
tmt run
```
## Test Execution Order
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
The pipeline enforces:
- `GOMAXPROCS=1`: Simulate single-core environment.
- `LIBGL_ALWAYS_SOFTWARE=1`: Disable GPU.
| Variable | Value | Purpose |
|----------|-------|---------|
| `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:
how: fmf
filter: tier: 2
filter: 'tier: 2'
execute:
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: |
These tests are non-negotiable.
They execute the Frugality Manifesto.
Non-negotiable tests. Executes the Frugality Manifesto.
If these fail, the product is broken.
Use this plan for local validation: tmt --root tmt run plan --name /vital
discover:
how: fmf
filter: tier: 1
filter: 'tier: 1'
execute:
how: tmt
environment:
# Forces Low-Power Behavior
GOMAXPROCS: "1"
LIBGL_ALWAYS_SOFTWARE: "1"
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)
tier: 1
component: backend
test: ./unit.sh
duration: 15m
require:
- go
/unit:
summary: Unit Tests (Low Power)
test: ./unit.sh
/govulncheck:
summary: Vulnerability Check (govulncheck)
test: ./govulncheck.sh
tier: 1
order: 10
/integration:
summary: Integration Tests
test: ./integration.sh
tier: 2
/vet:
summary: Go Vet
test: ./vet.sh
tier: 1
order: 20
/lint:
summary: Lint (golangci-lint)
test: ./lint.sh
tier: 1
order: 20
/core-isolation:
summary: Core Isolation Check
test: ./core_isolation.sh
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:
summary: Startup Time Budget
test: ./startup_time.sh
tier: 1
order: 50
/memory-budget:
summary: Memory Budget Check
test: ./memory_budget.sh
tier: 1
order: 50
/integration:
summary: Integration Tests
test: ./integration.sh
tier: 2
order: 50

View file

@ -1,17 +1,30 @@
#!/bin/bash
set -e
# CONTRACT: GOMAXPROCS=1 (Frugality Manifesto)
COVERAGE_THRESHOLD=60
REPO_ROOT=$(git rev-parse --show-toplevel)
BACKEND_DIR="$REPO_ROOT/veza-backend-api"
echo "📍 Backend Unit Tests (Low Power Mode)"
echo "Backend Unit Tests (Low Power Mode)"
cd "$BACKEND_DIR"
export GOMAXPROCS=1
echo "⚙️ Constraint: GOMAXPROCS=1"
echo "Constraint: GOMAXPROCS=1"
echo "🧪 Running Unit Tests..."
go test ./internal/... -v -short
echo "Running Unit Tests with coverage..."
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)
tier: 1
component: frontend
test: ./build.sh
duration: 10m
duration: 15m
require:
- npm
/unit:
summary: Unit Tests (Vitest)
test: ./unit.sh
/security-audit:
summary: Security Audit (npm audit)
test: ./security_audit.sh
tier: 1
order: 10
/build:
summary: Build Test
test: ./build.sh
/types-sync:
summary: Types Sync Check (OpenAPI)
test: ./types_sync.sh
tier: 1
order: 15
/build-perf:
summary: Build Time Budget
test: ./build_perf.sh
/lint:
summary: Lint (ESLint)
test: ./lint.sh
tier: 1
order: 20
/bundle-size:
summary: Bundle Size Check (Strict)
test: ./bundle_size.sh
/format-check:
summary: Format Check (Prettier)
test: ./format_check.sh
tier: 1
order: 20
/no-critical-js:
summary: No Critical JS Check
test: ./no_critical_js.sh
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)
WEB_DIR="$REPO_ROOT/apps/web"
echo "📍 Frontend Unit Tests (Vitest)"
echo "📂 Web Directory: $WEB_DIR"
echo "Frontend Unit Tests (Vitest)"
cd "$WEB_DIR"
if [ ! -d "node_modules" ]; then
echo "📦 Installing dependencies..."
echo "Installing dependencies..."
npm ci
else
echo "✅ Dependencies found"
fi
echo "🧪 Running unit tests..."
npm run test -- --run
echo "Running unit tests with coverage..."
npm run test -- --run --coverage
echo "Unit tests passed."
echo "Unit tests passed."

View file

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