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:
parent
074e8fd3a1
commit
a3f4ac6b70
40 changed files with 1009 additions and 874 deletions
148
.github/workflows/ci.yml
vendored
148
.github/workflows/ci.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
38
make/test.mk
38
make/test.mk
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? '✓' : '✗'}`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
tmt/.fmf/version
Normal file
|
|
@ -0,0 +1 @@
|
|||
1
|
||||
|
|
@ -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
12
tmt/plans/integration.fmf
Normal 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
|
||||
|
|
@ -8,7 +8,7 @@ description: |
|
|||
|
||||
discover:
|
||||
how: fmf
|
||||
filter: tier: 2
|
||||
filter: 'tier: 2'
|
||||
|
||||
execute:
|
||||
how: tmt
|
||||
|
|
|
|||
12
tmt/plans/nightly.fmf
Normal file
12
tmt/plans/nightly.fmf
Normal 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
|
||||
11
tmt/plans/vital-backend.fmf
Normal file
11
tmt/plans/vital-backend.fmf
Normal 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
|
||||
12
tmt/plans/vital-frontend.fmf
Normal file
12
tmt/plans/vital-frontend.fmf
Normal 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
|
||||
14
tmt/plans/vital-services.fmf
Normal file
14
tmt/plans/vital-services.fmf
Normal 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"
|
||||
|
|
@ -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
13
tmt/tests/backend/build.sh
Executable 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."
|
||||
18
tmt/tests/backend/govulncheck.sh
Executable file
18
tmt/tests/backend/govulncheck.sh
Executable 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
18
tmt/tests/backend/lint.sh
Executable 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."
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
13
tmt/tests/backend/vet.sh
Executable 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
12
tmt/tests/e2e/main.fmf
Normal 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
15
tmt/tests/e2e/playwright.sh
Executable 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
13
tmt/tests/frontend/contrast.sh
Executable 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."
|
||||
13
tmt/tests/frontend/format_check.sh
Executable file
13
tmt/tests/frontend/format_check.sh
Executable 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
13
tmt/tests/frontend/lint.sh
Executable 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."
|
||||
|
|
@ -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
|
||||
|
|
|
|||
13
tmt/tests/frontend/security_audit.sh
Executable file
13
tmt/tests/frontend/security_audit.sh
Executable 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
13
tmt/tests/frontend/typecheck.sh
Executable 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."
|
||||
20
tmt/tests/frontend/types_sync.sh
Executable file
20
tmt/tests/frontend/types_sync.sh
Executable 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."
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
tmt/tests/services/rust_audit.sh
Executable file
22
tmt/tests/services/rust_audit.sh
Executable 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."
|
||||
17
tmt/tests/services/rust_build.sh
Executable file
17
tmt/tests/services/rust_build.sh
Executable 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
36
tmt/tests/storybook/audit.sh
Executable 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."
|
||||
11
tmt/tests/storybook/main.fmf
Normal file
11
tmt/tests/storybook/main.fmf
Normal 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
|
||||
Loading…
Reference in a new issue