test(r0.12): ESLint monorepo + parcours démo Playwright — R0.12 clos

- ESLint 10 flat config racine (typescript-eslint, react-hooks) ; règle
  ADR-001 codée : no-restricted-imports interdit « minio » partout sauf
  minio-storage.service.ts (violation vérifiée) ; jobs lint par paquet
- Playwright apps/web/e2e : redirection sans jeton, connexion démo 1 clic,
  coquille (rail actif, chip DÉMO), bascule de rôle chronométrée < 3 s
  (ADR-002), /design, déconnexion avec purge ; webServer api dist + vite,
  seed idempotent en globalSetup, PW_CHANNEL=chrome en local
- CI : jobs lint + e2e (services PostgreSQL/Redis, artefact
  playwright-report sur échec) ; vitest restreint à src/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-15 22:36:17 +01:00
parent a9e51507aa
commit 36de93bb7e
15 changed files with 968 additions and 56 deletions

View File

@@ -3,7 +3,10 @@
# moindre diff entre le contrat committé et la génération ; # moindre diff entre le contrat committé et la génération ;
# api : migration + tests Jest (couverture ≥ 70 % bloquante, jest.config) ; # api : migration + tests Jest (couverture ≥ 70 % bloquante, jest.config) ;
# web : typecheck + vitest + build de production. # web : typecheck + vitest + build de production.
# Le parcours Playwright (connexion démo → coquille) complétera R0.12. # lint : ESLint monorepo (dont la règle ADR-001 « pas d'import MinIO
# hors FileStorage ») ;
# e2e : parcours démo Playwright (connexion 1 clic → coquille →
# bascule de rôle < 3 s → /design) contre l'API construite.
name: CI name: CI
on: on:
@@ -16,6 +19,19 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
lint:
name: lint (ESLint monorepo)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
contract: contract:
name: ci-contract (règle d'or) name: ci-contract (règle d'or)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -93,3 +109,49 @@ jobs:
- run: pnpm --filter @siop/shared build - run: pnpm --filter @siop/shared build
- run: pnpm --filter @siop/web test - run: pnpm --filter @siop/web test
- run: pnpm --filter @siop/web build - run: pnpm --filter @siop/web build
e2e:
name: e2e (parcours démo Playwright)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: siop
POSTGRES_PASSWORD: siop
POSTGRES_DB: siop
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U siop -d siop"
--health-interval 5s --health-timeout 3s --health-retries 10
redis:
image: redis:7.4-alpine
ports: ['6379:6379']
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s --health-timeout 3s --health-retries 10
env:
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
REDIS_URL: redis://localhost:6379
JWT_SECRET: ci-only-secret-0123456789abcdef
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @siop/shared build
- run: pnpm --filter @siop/api prisma:generate
- run: pnpm --filter @siop/api exec prisma migrate deploy
- run: pnpm --filter @siop/api build
- run: pnpm --filter @siop/web exec playwright install --with-deps chromium
- run: pnpm --filter @siop/web e2e
- name: Traces Playwright en cas d'échec
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/web/playwright-report/
retention-days: 7

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ dist/
.turbo/ .turbo/
coverage/ coverage/
*.tsbuildinfo *.tsbuildinfo
test-results/
playwright-report/

View File

@@ -37,6 +37,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R0 (1/2)** : playbook 00-vision, 01-cadrage, 02-design **charte + tokens + maquettes HD VALIDÉES par le référent le 15/07/2026** (artefact : maquette-web.html ; 7 écrans, bi-thème). 03-architecture rédigé (ADR-001 stack, ADR-002 démo-login, C4, modèle R0). Racine monorepo posée (package.json/pnpm-workspace/turbo/.nvmrc) + `infra/` (compose PostgreSQL pgvector+PostGIS, Redis, MinIO services préfixés `siop2-`). - **R0 (1/2)** : playbook 00-vision, 01-cadrage, 02-design **charte + tokens + maquettes HD VALIDÉES par le référent le 15/07/2026** (artefact : maquette-web.html ; 7 écrans, bi-thème). 03-architecture rédigé (ADR-001 stack, ADR-002 démo-login, C4, modèle R0). Racine monorepo posée (package.json/pnpm-workspace/turbo/.nvmrc) + `infra/` (compose PostgreSQL pgvector+PostGIS, Redis, MinIO services préfixés `siop2-`).
- **R0.10 `apps/api`** : NestJS + Prisma (migration `r0_identity`), guard JWT global fermé par défaut + `@Public()`, PermissionsGuard (matrice en base, cache 60 s), démo-login ADR-002 (module conditionnel, 404 sinon, double verrou prod), seed idempotent (7 rôles, 70 lignes de matrice, 7 comptes démo), `FileStorage`/health ; `packages/shared` (Zod) + `pnpm contract` `docs/openapi.json` committée ; 19 tests Jest verts + smoke test build prod. - **R0.10 `apps/api`** : NestJS + Prisma (migration `r0_identity`), guard JWT global fermé par défaut + `@Public()`, PermissionsGuard (matrice en base, cache 60 s), démo-login ADR-002 (module conditionnel, 404 sinon, double verrou prod), seed idempotent (7 rôles, 70 lignes de matrice, 7 comptes démo), `FileStorage`/health ; `packages/shared` (Zod) + `pnpm contract` `docs/openapi.json` committée ; 19 tests Jest verts + smoke test build prod.
- **R0.11 `apps/web`** : React 19 + Vite + Tailwind v4, `tokens.css` copié tel quel + classes extraites de la maquette validée, Manrope auto-hébergée ; connexion + sélecteur démo (masqué si 404 une seule source de vérité : l'API), coquille sidebar (rail safran, écrans futurs marqués R1-R3) / topbar (thème, chip DÉMO, sélecteur de rôle), page /design ; client typé `schema.d.ts` généré depuis `docs/openapi.json` et committé ; vérifié en navigateur réel (bascule de rôle 101 ms, bi-thème, 0 erreur console). - **R0.11 `apps/web`** : React 19 + Vite + Tailwind v4, `tokens.css` copié tel quel + classes extraites de la maquette validée, Manrope auto-hébergée ; connexion + sélecteur démo (masqué si 404 une seule source de vérité : l'API), coquille sidebar (rail safran, écrans futurs marqués R1-R3) / topbar (thème, chip DÉMO, sélecteur de rôle), page /design ; client typé `schema.d.ts` généré depuis `docs/openapi.json` et committé ; vérifié en navigateur réel (bascule de rôle 101 ms, bi-thème, 0 erreur console).
- **R0.12 (1/2) — CI GitHub Actions** (`.github/workflows/ci.yml`, 3 jobs verts sur le dépôt) : `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis en services, migrate deploy, Jest couverture 70 % bloquante mesurée 97,5 %), `web` (typecheck + vitest + build). Badge au README. - **R0.12 — tests + CI** (5 jobs verts) : `lint` (ESLint 10 flat config racine, règle ADR-001 anti-import MinIO codée et vérifiée), `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis, migrate deploy, Jest couverture 70 % bloquante mesurée 97,5 %), `web` (typecheck + vitest + build), `e2e` (Playwright : parcours démo, bascule < 3 s chronométrée, déconnexion). Badge au README.
- 🔄 **R0 (2/2) — reprise ici** : R0.12 (2/2) ESLint (dont règle « pas d'import MinIO hors FileStorage ») + parcours Playwright (connexion démo coquille bascule de rôle) R0.13 Dockerfiles + runbook Dokploy (le déploiement réel attend les accès au serveur du partenaire). - 🔄 **R0 (2/2) — reprise ici** : R0.13 Dockerfiles (api, web) + runbook Dokploy (le déploiement réel attend les accès au serveur du partenaire), puis recette R0 avec le référent (revue pixel écrans maquettes).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

@@ -12,7 +12,8 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts",
"lint": "eslint src test prisma"
}, },
"prisma": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"

View File

@@ -0,0 +1,11 @@
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
/** Seed idempotent avant le parcours : mêmes comptes en local et en CI. */
export default function globalSetup() {
execSync('pnpm --filter @siop/api seed', {
cwd: fileURLToPath(new URL('../../..', import.meta.url)),
stdio: 'inherit',
env: process.env,
});
}

View File

@@ -0,0 +1,65 @@
import { expect, test } from '@playwright/test';
/**
* LE parcours de recette R0 : connexion démo 1 clic, coquille fidèle,
* bascule de rôle < 3 s (critère d'acceptation ADR-002), /design accessible.
*/
test('sans jeton, lapplication renvoie sur la connexion (fermée par défaut)', async ({
page,
}) => {
await page.goto('/');
await expect(page).toHaveURL(/\/connexion$/);
});
test('connexion démo → coquille → bascule de rôle < 3 s → /design', async ({
page,
}) => {
await page.goto('/connexion');
// Mode démonstration : 7 comptes seedés, chip d'avertissement
await expect(page.locator('.sep')).toHaveText('Mode démonstration');
await expect(page.locator('.demo-liste button')).toHaveCount(7);
await expect(page.locator('.chip-demo')).toContainText('DEMO_MODE=true');
// Connexion 1 clic (Dispatcher)
await page.getByRole('button', { name: /Salma Idrissi/ }).click();
await expect(
page.getByRole('heading', { name: 'Tableau de bord' }),
).toBeVisible();
await expect(page.locator('.topbar')).toContainText('Salma Idrissi');
await expect(page.locator('.topbar .chip-demo')).toHaveText('DÉMO');
// Rail safran : l'entrée active de la sidebar
await expect(page.locator('.nav a.actif')).toHaveText(/Tableau de bord/);
// Bascule de rôle via la topbar — critère ADR-002 : < 3 s
const debut = Date.now();
await page.locator('.compte').click();
await page.getByRole('menuitem', { name: /Ahmed Benali/ }).click();
await expect(page.locator('.topbar')).toContainText('Ahmed Benali');
await expect(page.locator('.topbar')).toContainText('Technicien');
expect(Date.now() - debut).toBeLessThan(3000);
// Référence vivante du design
await page.goto('/design');
await expect(
page.getByRole('heading', { name: /Design — référence vivante/ }),
).toBeVisible();
await expect(page.locator('.puce-c').first()).toBeVisible();
});
test('la déconnexion ramène à la connexion et ferme la session', async ({
page,
}) => {
await page.goto('/connexion');
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
await expect(page.locator('.topbar')).toContainText('Yasmine Alaoui');
await page.locator('.compte').click();
await page.getByRole('menuitem', { name: 'Se déconnecter' }).click();
await expect(page).toHaveURL(/\/connexion$/);
// Retour direct sur / : toujours redirigé (le jeton est bien purgé)
await page.goto('/');
await expect(page).toHaveURL(/\/connexion$/);
});

View File

@@ -9,7 +9,9 @@
"build": "tsc -p tsconfig.json --noEmit && vite build", "build": "tsc -p tsconfig.json --noEmit && vite build",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run", "test": "vitest run",
"generate:client": "openapi-typescript ../../docs/openapi.json -o src/api/schema.d.ts" "generate:client": "openapi-typescript ../../docs/openapi.json -o src/api/schema.d.ts",
"e2e": "playwright test",
"lint": "eslint src e2e"
}, },
"dependencies": { "dependencies": {
"@fontsource/manrope": "^5.1.0", "@fontsource/manrope": "^5.1.0",
@@ -26,6 +28,7 @@
"tailwind-merge": "^2.6.0" "tailwind-merge": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.1.0", "@tailwindcss/vite": "^4.1.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",

View File

@@ -0,0 +1,46 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Parcours e2e (R0.12) — démarre l'API construite (dist) et Vite, puis joue
* le parcours démo. Localement : PW_CHANNEL=chrome évite le téléchargement
* de Chromium (utilise le Chrome installé) ; en CI, Chromium Playwright.
* Prérequis : PostgreSQL/Redis joignables, `pnpm --filter @siop/api build`.
*/
const API_ENV = {
NODE_ENV: 'test',
PORT: '3000',
DEMO_MODE: 'true',
JWT_SECRET: process.env.JWT_SECRET ?? 'e2e-secret-0123456789abcdef',
DATABASE_URL:
process.env.DATABASE_URL ?? 'postgresql://siop:siop@localhost:5432/siop',
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
};
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
timeout: 30_000,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: 'http://localhost:5173',
trace: 'retain-on-failure',
...(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}),
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: [
{
command: 'node ../api/dist/main.js',
url: 'http://localhost:3000/health',
env: API_ENV,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
{
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
],
});

View File

@@ -14,5 +14,5 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "@/*": ["src/*"] } "paths": { "@/*": ["src/*"] }
}, },
"include": ["src", "vite.config.ts"] "include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
} }

View File

@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'node:path'; import path from 'node:path';
@@ -5,6 +6,10 @@ import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
test: {
// e2e/ appartient à Playwright — vitest ne joue que les tests unitaires
include: ['src/**/*.test.{ts,tsx}'],
},
resolve: { resolve: {
alias: { '@': path.resolve(__dirname, 'src') }, alias: { '@': path.resolve(__dirname, 'src') },
}, },

View File

@@ -4,6 +4,22 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
--- ---
## 2026-07-15 — Pr. Daaif (+ Claude) — R0.12 (2/2) : ESLint + parcours Playwright — R0.12 CLOS
**Actions**
- **ESLint 10** (flat config unique à la racine, typescript-eslint, react-hooks sur le web) + scripts `lint` par paquet et job CI dédié. La règle d'architecture ADR-001 est codée : `no-restricted-imports` interdit `minio` partout **sauf** `minio-storage.service.ts` — violation vérifiée par un fichier-test (détectée puis retiré). Monorepo lint : 0 erreur.
- **Playwright** (`apps/web/e2e`) : 3 tests — redirection sans jeton (fermée par défaut), parcours complet connexion démo → coquille (rail actif, chip DÉMO) → **bascule de rôle chronométrée < 3 s** (ADR-002) → /design, et déconnexion avec purge de session. `webServer` démarre l'API construite + Vite ; seed idempotent en globalSetup ; localement `PW_CHANNEL=chrome` (pas de téléchargement).
- CI : jobs `lint` et `e2e` (services PostgreSQL/Redis, artefact playwright-report en cas d'échec). Vitest restreint à `src/` (e2e appartient à Playwright).
**Décisions**
- Une seule config ESLint à la racine (pas une par app) : les règles d'architecture sont transversales et la config est le lieu du cours.
**Prochaine étape** : R0.13 — Dockerfiles (api, web) + runbook Dokploy (`docs/04-*`), puis recette R0 avec le référent (revue pixel écrans ↔ maquettes).
---
## 2026-07-15 — Pr. Daaif (+ Claude) — R0.12 (1/2) : pipeline GitHub Actions ## 2026-07-15 — Pr. Daaif (+ Claude) — R0.12 (1/2) : pipeline GitHub Actions
**Actions** **Actions**

56
eslint.config.mjs Normal file
View File

@@ -0,0 +1,56 @@
// ESLint (R0.12) — config unique du monorepo (flat config, ESLint 9).
// Porte notamment la règle d'architecture ADR-001 : le SDK MinIO ne
// s'importe QUE dans l'implémentation de FileStorage.
import eslint from '@eslint/js';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/*.d.ts',
'**/coverage/**',
'docs/**',
'**/*.config.{js,mjs,ts}',
'apps/api/jest.config.js',
],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
// ADR-001 : MinIO derrière l'interface FileStorage, jamais en direct
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'minio',
message:
'Import direct du SDK MinIO interdit (ADR-001) : passez par linterface FileStorage (apps/api/src/files).',
},
],
},
],
},
},
{
// Seul fichier autorisé : l'implémentation MinIO de FileStorage
files: ['apps/api/src/files/minio-storage.service.ts'],
rules: { 'no-restricted-imports': 'off' },
},
{
files: ['apps/web/**/*.{ts,tsx}'],
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
},
},
);

View File

@@ -19,6 +19,10 @@
"infra:reset": "docker compose -f infra/docker-compose.yml down -v" "infra:reset": "docker compose -f infra/docker-compose.yml down -v"
}, },
"devDependencies": { "devDependencies": {
"turbo": "^2.5.0" "@eslint/js": "^10.0.1",
"eslint": "^10.7.0",
"eslint-plugin-react-hooks": "^7.1.1",
"turbo": "^2.5.0",
"typescript-eslint": "^8.64.0"
} }
} }

View File

@@ -8,7 +8,8 @@
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"contract": "tsx scripts/generate-openapi.ts" "contract": "tsx scripts/generate-openapi.ts",
"lint": "eslint src scripts"
}, },
"dependencies": { "dependencies": {
"zod": "^4.0.0" "zod": "^4.0.0"

736
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff