[DTP-Worker] Sprint 1 · Baseline QA Playwright (4 endpoints) + fix gate rouge

Livrable QA Sprint 1 (roadmap §Sprint 1 · GAP_ANALYSIS §7) : projet Playwright
auto-contenu sous tests/, data-driven sur e2e/routes.json pour /waf-home /crm
/qa /choisir-mon-unite. 4 contrôles/route : status<400 · HTML titré+lang ·
brand luxury (#0a0a12/#f0b429 + Fraunces/Cormorant) · zéro erreur JS/5xx.
Cible via DTP_BASE_URL (aucune URL codée en dur). Zéro invention de chiffres (#6).

- tests/{playwright.config.ts,package.json,tsconfig.json,.gitignore,README.md}
- tests/e2e/{routes.json,smoke.spec.ts,_shared/contract.ts}
- CI : job e2e-baseline manuel (workflow_dispatch) dans .gitea/workflows/ci.yml
  — hors gate push/PR (exige serveur live), Gitea Actions only (#2)
- Fix : gate CI rouge sur HEAD (guard flaguait sa propre doc de test négatif)
  → escape hatch documenté ci-allow. Les 3 scripts du gate repassent verts.
- GAP_ANALYSIS §7 : critère Baseline Playwright →  (exécution VPS différée)
- daily report 2026-07-30 (session 2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 01:02:04 +00:00
parent 1cfb8191f4
commit 1a87b22fd7
11 changed files with 452 additions and 2 deletions
+98
View File
@@ -0,0 +1,98 @@
// ============================================================================
// Baseline QA E2E · smoke + brand · endpoints Sprint 1
// ----------------------------------------------------------------------------
// Livrable Sprint 1 (roadmap §Sprint 1 · GAP_ANALYSIS §7 · QA Agent).
// Data-driven sur tests/e2e/routes.json → /waf-home /crm /qa /choisir-mon-unite.
//
// Philosophie « baseline » : on vérifie la SANTÉ et le BRAND, jamais le contenu
// métier (prix/superficie/dispo) — contrainte #6 (zéro invention de chiffres).
// Exécution réelle : sur le runner Gitea VPS avec DTP_BASE_URL défini
// (voir tests/README.md). Autorable/validable en-repo, run différé côté VPS.
// ============================================================================
import { test, expect } from '@playwright/test';
import routesManifest from './routes.json';
import {
LUXURY,
isReachable,
looksLikeLoginGate,
collectStyleSurface,
containsAny,
type RoutesManifest,
} from './_shared/contract';
const { routes } = routesManifest as RoutesManifest;
for (const route of routes) {
test.describe(`${route.path} · ${route.name}`, () => {
test('répond sans erreur serveur (status < 400)', async ({ page }) => {
const res = await page.goto(route.path, { waitUntil: 'domcontentloaded' });
expect(res, `aucune réponse HTTP pour ${route.path}`).not.toBeNull();
expect(
isReachable(res),
`${route.path} → status ${res?.status()} (attendu < 400)`,
).toBeTruthy();
});
test('sert une page HTML titrée avec attribut lang', async ({ page }) => {
await page.goto(route.path, { waitUntil: 'domcontentloaded' });
const title = (await page.title()).trim();
expect(title.length, `titre vide sur ${route.path}`).toBeGreaterThan(0);
const lang = await page.locator('html').getAttribute('lang');
// Multilingue FR/EN/ES attendu — on vérifie la présence de l'attribut,
// pas une valeur précise (routes servies dans plusieurs langues).
expect(lang, `attribut <html lang> absent sur ${route.path}`).toBeTruthy();
});
test('respecte le brand luxury dark+doré (tokens présents)', async ({ page }) => {
const res = await page.goto(route.path, { waitUntil: 'networkidle' });
const bodyText = await page.locator('body').innerText().catch(() => '');
// Route protégée non authentifiée : le brand du mur de login n'est pas
// représentatif → on annote et on ne bloque pas (baseline tolérante).
if (route.gated && looksLikeLoginGate(page.url(), bodyText)) {
test.info().annotations.push({
type: 'gate',
description: `${route.path} sert un login (non authentifié) — brand non asserté.`,
});
test.skip(true, 'Mur de login — brand vérifié une fois authentifié (couverture ultérieure).');
}
expect(isReachable(res)).toBeTruthy();
const surface = await collectStyleSurface(page);
const hasColor = containsAny(surface, LUXURY.colors);
const hasFont = containsAny(surface, LUXURY.fonts);
expect(
hasColor,
`aucune couleur de marque (${LUXURY.colors.join(' / ')}) trouvée sur ${route.path}`,
).toBeTruthy();
expect(
hasFont,
`aucune police de marque (${LUXURY.fonts.join(' / ')}) trouvée sur ${route.path}`,
).toBeTruthy();
});
test('ne produit ni erreur JS non capturée ni réponse 5xx', async ({ page }) => {
const pageErrors: string[] = [];
const serverErrors: string[] = [];
page.on('pageerror', (err) => pageErrors.push(err.message));
page.on('response', (r) => {
if (r.status() >= 500) serverErrors.push(`${r.status()} ${r.url()}`);
});
await page.goto(route.path, { waitUntil: 'networkidle' });
expect(
pageErrors,
`erreurs JS non capturées sur ${route.path}:\n${pageErrors.join('\n')}`,
).toEqual([]);
expect(
serverErrors,
`réponses 5xx sur ${route.path}:\n${serverErrors.join('\n')}`,
).toEqual([]);
});
});
}