[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:
@@ -0,0 +1,76 @@
|
||||
// ============================================================================
|
||||
// Contrat de baseline QA · tokens de marque + helpers partagés
|
||||
// ----------------------------------------------------------------------------
|
||||
// Ces constantes matérialisent les NON-NÉGOCIABLES de CLAUDE.md (#4 design
|
||||
// luxury) sous forme d'assertions vérifiables. AUCUN chiffre projet (prix,
|
||||
// superficie, disponibilité) n'est asserté ici — contrainte #6 (zéro invention).
|
||||
// ============================================================================
|
||||
import type { Page, Response } from '@playwright/test';
|
||||
|
||||
/** Design luxury dark+doré — CLAUDE.md contrainte #4. */
|
||||
export const LUXURY = {
|
||||
/** Couleurs de marque (hex, insensible à la casse). */
|
||||
colors: ['#0a0a12', '#f0b429'],
|
||||
/** Familles de police officielles. */
|
||||
fonts: ['Fraunces', 'Cormorant Garamond', 'Cormorant'],
|
||||
} as const;
|
||||
|
||||
export interface RouteSpec {
|
||||
path: string;
|
||||
name: string;
|
||||
gated: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface RoutesManifest {
|
||||
comment?: string;
|
||||
routes: RouteSpec[];
|
||||
}
|
||||
|
||||
/** Une page est « accessible » si le serveur ne renvoie pas d'erreur (< 400).
|
||||
* Une redirection login (3xx→200 sur /login) reste accessible. */
|
||||
export function isReachable(res: Response | null): boolean {
|
||||
if (!res) return false;
|
||||
return res.status() < 400;
|
||||
}
|
||||
|
||||
/** Détecte un mur de login (route gated non authentifiée) sans échouer le test. */
|
||||
export function looksLikeLoginGate(url: string, bodyText: string): boolean {
|
||||
const u = url.toLowerCase();
|
||||
if (u.includes('/login') || u.includes('/app/login')) return true;
|
||||
const b = bodyText.toLowerCase();
|
||||
return /\b(login|se connecter|sign in|mot de passe|password)\b/.test(b)
|
||||
&& b.length < 4000; // heuristique : page de login = courte
|
||||
}
|
||||
|
||||
/** Récupère le HTML rendu + le texte de toutes les feuilles de style liées,
|
||||
* pour chercher les tokens de marque dans le CSS réel servi. */
|
||||
export async function collectStyleSurface(page: Page): Promise<string> {
|
||||
const html = await page.content();
|
||||
const cssHrefs = await page.$$eval(
|
||||
'link[rel="stylesheet"]',
|
||||
(links) => links.map((l) => (l as HTMLLinkElement).href).filter(Boolean),
|
||||
);
|
||||
const inlineCss = await page.$$eval('style', (els) =>
|
||||
els.map((e) => e.textContent || '').join('\n'),
|
||||
);
|
||||
|
||||
const parts = [html, inlineCss];
|
||||
// Feuilles de style externes : on tente de les lire via le contexte réseau
|
||||
// de la page (même origine / mêmes cookies). Best-effort, tolérant aux échecs.
|
||||
for (const href of cssHrefs.slice(0, 12)) {
|
||||
try {
|
||||
const r = await page.request.get(href);
|
||||
if (r.ok()) parts.push(await r.text());
|
||||
} catch {
|
||||
/* réseau/CORS — ignoré, best-effort */
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/** Vrai si `haystack` contient au moins un des `needles` (insensible à la casse). */
|
||||
export function containsAny(haystack: string, needles: readonly string[]): boolean {
|
||||
const h = haystack.toLowerCase();
|
||||
return needles.some((n) => h.includes(n.toLowerCase()));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"comment": "Baseline QA · endpoints Sprint 1 (roadmap §Sprint 1 · GAP_ANALYSIS §7). Voir tests/README.md. Data-contract consommé par tests/e2e/smoke.spec.ts.",
|
||||
"routes": [
|
||||
{
|
||||
"path": "/waf-home",
|
||||
"name": "WAF Home · dashboard holding",
|
||||
"gated": false,
|
||||
"note": "Page portail luxury de reference — brand tokens exiges."
|
||||
},
|
||||
{
|
||||
"path": "/crm",
|
||||
"name": "CRM · pipeline vente (ERPNext natif)",
|
||||
"gated": true,
|
||||
"note": "Peut rediriger vers /login si non authentifie — considere accessible si status < 400."
|
||||
},
|
||||
{
|
||||
"path": "/qa",
|
||||
"name": "QA console · audit 5D",
|
||||
"gated": true,
|
||||
"note": "Console interne — login possible."
|
||||
},
|
||||
{
|
||||
"path": "/choisir-mon-unite",
|
||||
"name": "Choisir mon unite · selection unite premium",
|
||||
"gated": false,
|
||||
"note": "Parcours acheteur public (spec CHOISIR_MON_UNITE)."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user