"""Garde-fous anti-blocage du harnais E2E local (BUG-080). Contexte : un run `npm run test:e2e:ps` est resté pendu toute la nuit — serveurs orphelins sur le port 2029, `npx` sans `--yes` (prompt interactif qui attend indéfiniment), installation des navigateurs systématique et suite Playwright (~130 tests, workers: 1) sans aucun timeout global. Ces tests statiques vérifient que chaque couche du harnais possède son garde-fou, afin qu'un run E2E échoue vite au lieu de bloquer indéfiniment. """ from __future__ import annotations import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent def _read(rel: str) -> str: return (REPO_ROOT / rel).read_text(encoding="utf-8") class TestE2ELocalPs: SCRIPT = "scripts/run-e2e-local.ps1" def test_playwright_via_node_no_npx(self): """Playwright est lancé via `node` direct, jamais via `npx`. `Start-Process` ne peut pas exécuter `npx` (ni le `.ps1` ni le `.cmd` ne sont des applications Win32 directes : "%1 is not a valid Win32 application"), et `npx` sans `--yes` peut pendre sur un prompt interactif. Seules les mentions en commentaires/logs sont tolérées. """ content = _read(self.SCRIPT) bare = [ line.strip() for line in content.splitlines() if re.match(r"^\s*(?:&\s*)?npx\s", line) ] assert not bare, f"invocations npx nues : {bare}" assert "node_modules/@playwright/test/cli.js" in content, ( "CLI Playwright locale attendue (via node)" ) # `$Args` est une variable automatique PowerShell : un paramètre de # ce nom serait écrasé (helper lancé sans arguments → exit 0 muet). # (commentaires `#` exclus : la mise en garde elle-même le cite). code_lines = [ line for line in content.splitlines() if not line.strip().startswith("#") ] assert not re.search(r"\$Args\b", "\n".join(code_lines)), ( "BUG-080 : paramètre `$Args` interdit (shadowing par $args automatique)" ) def test_browser_install_skippable(self): """Install navigateurs sautée si chromium déjà présent (sauf forçage).""" content = _read(self.SCRIPT) assert "Test-ChromiumInstalled" in content assert "E2E_INSTALL_BROWSERS" in content def test_test_step_has_timeout(self): """L'étape `playwright test` est bornée (E2E_TIMEOUT_SEC, défaut 1800). Le défaut dépasse le globalTimeout Playwright (25 min en local) pour que ce soit Playwright qui abandonne proprement (avec rapport) en premier. """ content = _read(self.SCRIPT) assert "E2E_TIMEOUT_SEC" in content assert "Wait-Process -Timeout" in content assert re.search(r"E2E_TIMEOUT_SEC.*else\s*\{\s*1800\s*\}", content), ( "défaut E2E_TIMEOUT_SEC=1800 attendu" ) class TestE2ELocalSh: SCRIPT = "scripts/run-e2e-local.sh" def test_npx_never_prompts(self): content = _read(self.SCRIPT) for line in content.splitlines(): stripped = line.strip() if stripped.startswith("#") or stripped.startswith("echo") or "npx" not in stripped: continue if "playwright" in stripped: assert "--yes" in stripped, f"appel npx sans --yes : {stripped}" def test_test_step_has_timeout(self): content = _read(self.SCRIPT) assert "E2E_TIMEOUT_SEC" in content assert "run_with_timeout" in content class TestE2EServerPs: SCRIPT = "scripts/e2e-server.ps1" def test_pidfile_refreshed_with_port_owner(self): """Le pidfile est resynchronisé sur le vrai PID d'écoute après READY.""" content = _read(self.SCRIPT) assert "Get-PortOwner" in content ready_pos = content.find("[OK] READY") assert ready_pos != -1 assert "Set-Content -LiteralPath $PidFile" in content[ready_pos - 600:ready_pos] def test_stop_kills_process_tree(self): """`stop` tue aussi les enfants du PID enregistré (pas d'orphelins).""" content = _read(self.SCRIPT) assert "ParentProcessId=$srvPid" in content class TestPlaywrightConfig: CONFIG = "playwright.config.ts" def test_global_timeout_set(self): """Timeout global : la suite abandonne au lieu de pendre toute la nuit.""" content = _read(self.CONFIG) assert "globalTimeout" in content assert "E2E_GLOBAL_TIMEOUT_MS" in content