diff --git a/CHANGELOG.md b/CHANGELOG.md index be4addc..d142f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog - FlowDeck +## v7.36.0 (2026-10-01) — Fondations E2E + 2 bugs trouvés au passage + +### Added + +- **`e2e/smoke.spec.js`** — 2 gates vert contre l'instance de test : + · **gate A39** : bascule de vues d'une collection (clic onglet Calendar → + `?view_type=calendar`, grille `.calendar` + `.cal-header` rendue, + collection créée puis **supprimée** = répétable) + · **gate A20** : palette Ctrl+K (ouverture Alpine `.open`, recherche GET + rend `.cmd-palette-item`, fermeture Échap) + · **filet console** : 0 erreur JS/CSP (les violations atterrissent ici ; + le bruit `Failed to load resource` 401/403 est filtré) + · **Service Workers bloqués** dans le smoke : `/sw.js` sert sa page + « hors ligne » sur les navigations redirigées (`redirect:'manual'`) — + bruit PWA hors sujet, `pwa_offline.spec.js` couvre le SW + · bootstrap autonome : login OU création du compte e2e documenté, + workspace si absent — lecture seule sur les données existantes +- Commande : `cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js` + +### Fixed (trouvés par les gates) + +- **Bascule de vues standalone jamais fonctionnelle** : les onglets + émettaient `?view=…` mais la route lit `view_type` (FastAPI) → l'onglet + restait sur Table quel que soit le clic (bug pré-existant, non introduit + par A28). Onglets → `?view_type=` + assertion comportementale ajoutée à + `test_all_view_tabs_present` +- **Inter bloqué par la CSP depuis v7.27** : `app.css` importait encore + Google Fonts (`@import` raté par le grep de v7.27) → violation + `style-src` sur chaque page + police tombée en fallback. Inter + **auto-hébergé** : 2 faces variables (100-900, latin + latin-ext) dans + `static/fonts/`, `@import` supprimé + +### Notes + +- Suite complète : **1093/1093** · ruff OK · E2E **2/2** +- Portes : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la + bascule collection (pas la bascule htmx board → décision « rien » + maintenue) ; A38 twins reste conditionné à une couverture élargie + ## v7.35.0 (2026-10-01) — Audit : A35 TERMINÉ (Python 3.13 aligné + rebuild) ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 4550ac3..3ba75c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1155,7 +1155,23 @@ Quality DB views, Agent IA Palette → Realtime + E - [x] **A32 — Couverture de tests par trou — TERMINÉ 2026-10-01** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante).* — **phase 1 faite 2026-10-01** : les **4 routers à 0 test sont couverts** — `tests/test_smoke_uncovered.py`, 10 tests : webhooks (receive ok sans secret, 401 HMAC via monkeypatch, register 400 sans appel réseau, status avec `gitea.list_webhooks` stubbé), notes (GET HTML + roundtrip upsert avec échappement), sidebar_config (GET, PUT persisté relu en base, 400 sans config + remise en état), github_routes (status/disconnect) — **0 accès réseau réel**. **Phase 2a faite 2026-10-01** : `library.py` **1/10 → 8 routes couvertes** (5 listes en 1 test + private seedé, children avec parent/enfant seedé, repository vide/clé, + test de non-régression post-suppression). **Découverte** : `/api/library/local-workspace-children/{id}` (toujours 500) et `/api/library/local-workspace` (500 dès qu'un workspace existe) lisaient `local_workspace_items` — **une table inexistante nulle part dans le codebase**, zéro référence front → **2 routes + `_format_size` supprimés** (test 404 anti-réintroduction). **Phase 2b faite 2026-10-01 (partielle — api.py)** : **3/23 → 16/22 routes couvertes** (mesure sur les `@router` du fichier) : board-config (défauts + roundtrip création), col-mapping (404 sans board, upsert + suppression), card (404 + ok), collaborators (**`gitea.get_collaborators` stubbé**, zéro réseau), frontend-error (capture, JSON invalide → ignored, dédup `count=2`, lecture qui purge), checklist-items PATCH/DELETE + checklists DELETE (mutations **vérifiées en base** avec seed + cleanup). **Reste api.py** : 6 routes **Gitea** (issues ×4, checklists/checklist-items création avec owner/repo) → stub de transport httpx + formes de réponse par endpoint. **Phase 2c faite 2026-10-01 (api_v2)** : scan strict des 115 routes vs tests → **5 routes à 0 référence** couvertes : `evaluate-formula` (200 + 400 sans expression ; le moteur renvoie `1 + 2` tel quel — on teste le câble, pas le moteur), `compute-rollup` (400 `collection_id required` + 401 sans bearer), `admin/audit-logs` (**portail vérifié** : attendu calculé depuis `/users/me` car le tout premier utilisateur du worker est admin, + token scope `admin` → 200), `webhooks/events` (catalogue + wildcards), `webhooks/verify-signature` (**valid True avec `sign_payload`, False avec signature bidon**). **Phase 2d faite 2026-10-01 (dashboard, partiel)** : scan strict → 44 routes à 0 ref stricte sur 63 ; **10 couvertes** cette passe : tags CRUD complet (création lowercasée, liste, couleur relue, suppression), page content→rename(+400 titre vide)→trash (title/parent_section/deleted_at **relus en base**), sidebar workspace-tree (fragment HTML « No pages yet »), avatar-color (couleur **relue sur l'utilisateur de la session** puis restaurée), workspace members (shape). **Phase 2e faite 2026-10-01** : +**9 routes** (cumul dashboard : 27→36 des 63) : pages `/accounts` + `/accounts/settings` (HTML, « password_hash » **absent** du rendu — whitelist A2), `PUT /api/user/profile` (persistance relue + restauration), `PUT /api/user/password` (**403 sans mdp actuel correct** = A3 ; quirk assumé validé : longueur avant auth en 200+erreur), `POST /api/user/token` (format `fd_`+64 hex, `user_tokens` nettoyé), `DELETE /api/user/forge/{provider}`, `PUT /api/settings/account` (persistance + **400 mdp court**, restauration), `POST /api/workspaces/{id}/select` (cookie `Set-Cookie` vérifié), `GET /api/local-workspace/breadcrumb`. **Phase 2f faite 2026-10-01** : +**7 routes** (cumul dashboard **36→43/63**) centrées sur les **garde-fous A16** : `/api/files/{ws}/{path}` — traversal `%2e%2e%2f` → **403 « Path traversal denied »**, 404 inexistant, 200 + octets pour un vrai fichier écrit dans le data_dir de test (nettoyé) ; `download` (page markdown → 404 propre) et `file-content` (page « file » dont le chemin sort de la racine → **jamais 200**, 404/415) ; `local-workspace/page-content` (contenu relu + 404) ; `/api/avatar/{id}` — **302 avec `follow_redirects=False`** (aucune requête réelle vers l'URL externe) + 404 ; `collections/{id}/table-data` (404 + shape) et `POST collections/{id}/pages` (ligne créée **retrouvée dans table-data**). **Phase 2g faite 2026-10-01** : +**13 routes** (cumul dashboard **43→56/63**) : `/gitea-workspace` (HTML), projects GET/POST (shape + création retrouvée dans `builtin` + quirk `error` sans nom), **cycle complet des items local-workspace** (POST → rename relu → move → soft-delete `deleted_at` relu → restore `NULL` relu = 5 routes), **cycle des tags d'item** (POST lowercasé → lecture item → liste workspace → search → suppression, avec utilisateur + workspace **créés et nettoyés dans le test** : `/api/local-workspace/tags` exige un workspace actif, fallback « premier workspace du user »). **Phase 2h faite 2026-10-01 → DASHBOARD BLOQUÉ** : +5 routes (members invite **avec quirk tuple FastAPI documenté** → rôle invalides/emails inconnus répondent 200+tableau, role relu en base, membre supprimé ; upload-folder **validations seules** via structure absente/cassée = 400, zéro fichier écrit ; convert-to-database = collection + propriété title + vue Table + page en `content_format='collection'` vérifiés, cleanup dans l'ordre FK). **Recoupement final** : les 44 routes strictement à 0 ref de `dashboard.py` sont TOUTES exercées — le scan résiduel affiche 19 faux positifs (paths en f-string dans les tests : `/api/workspace/1/…`, `f"/api/pages/{id}/…"`, etc.) rapprochés manuellement un par un. **TERMINÉ 2026-10-01** : les **6 routes Gitea** d'`api.py` stubbées à la main (`gitea_client.gitea.create_issue/update_issue/get_issue/get_issue_comments` → canevas **mutable partagé** car le PATCH re-fetch via `get_issue`) : create avec **carte insérée sur le board**, PATCH avec colonne recalculée sans perdre la carte, détail JSON + **HTML** (`?format=html` — le segment `/html` ne fixe pas le paramètre, le handler le lit dans la query), 404 quand le stub lève, checklists POST ×2 vérifiées en base + 404 sans board. **BUG PROD TROUVÉ ET CORRIGÉ** : `card_detail.html` utilisait `fd_icon` sans l'importer → **500 systématique** sur `GET /api/issues/...?format=html` (route unique à rendre ce template) → `{% from '_icons.html' import fd_icon %}` ajouté. **A32 complet** : 0-router-à-0-test restant (webhooks/notes/sidebar/github/library/api/dashboard/api_v2 tous couverts). - [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.* - [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.* -- [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).* — **drift aligné + rebuild validé 2026-10-01 → A35 TERMINÉ** : `python:3.13-slim` (builder + runtime), CI `python-version: '3.13'` ×2, README ×2, ruff `target-version = "py313"` (0 retour ruff) — plus aucune référence 3.12 hors uv.lock (`requires-python >=3.13` déjà bon) ; **validation réelle** : `docker build` vert sur `python:3.13-slim` (image `flowdeck:a35-py313`) + `python -V` = **3.13.16** dans le conteneur + `import app.main` OK (v7.35.0) → les wheels de `requirements.txt` se construisent bien sur 3.13. → suite 1093/1093, version 7.35.0. +- [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28) + +> **2026-10-01 — Fondations E2E posées** (`e2e/smoke.spec.js`, 2 gates verts +> contre l'instance de test : `cd e2e && node node_modules/@playwright/test/cli.js +> test smoke.spec.js`) — **2 bugs réels trouvés et corrigés au passage** : +> (1) les onglets de vues standalone émettaient `?view=` alors que la route +> lit `view_type` → **la bascule de vues ne marchait jamais** (l'onglet +> restait sur Table) → `?view_type=` + assertion comportementale dans +> `test_all_view_tabs_present` ; (2) `app.css` importait encore Google +> Fonts (`@import` Inter) **bloqué par la CSP** depuis v7.27 → Inter +> **auto-hébergé** (`static/fonts/`, 2 faces variables 100-900, +> latin+latin-ext). Le SW est bloqué dans le smoke (sa page « hors ligne » +> sert les navigations redirigées — `pwa_offline.spec.js` couvre le SW). +> **Portes** : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la +> bascule collection standalone (la bascule htmx des vues board reste non +> couverte → décision « rien » maintenue) ; A38 twins reste conditionné à +> une couverture élargie.` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).* — **drift aligné + rebuild validé 2026-10-01 → A35 TERMINÉ** : `python:3.13-slim` (builder + runtime), CI `python-version: '3.13'` ×2, README ×2, ruff `target-version = "py313"` (0 retour ruff) — plus aucune référence 3.12 hors uv.lock (`requires-python >=3.13` déjà bon) ; **validation réelle** : `docker build` vert sur `python:3.13-slim` (image `flowdeck:a35-py313`) + `python -V` = **3.13.16** dans le conteneur + `import app.main` OK (v7.35.0) → les wheels de `requirements.txt` se construisent bien sur 3.13. → suite 1093/1093, version 7.35.0. - [x] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.* - [x] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.* diff --git a/VERSION b/VERSION index e319f92..0391f8e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.35.0 +7.36.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index b228b72..8a41d8c 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.35.0 (audit — A35 TERMINÉ : Python 3.13 aligné + rebuild image validé | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.36.0 (E2E fondations : smoke A39/A20 vert + 2 bugs trouvés (onglets ?view=, Inter CSP) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/main.py b/app/main.py index b5b118e..f6fdb1f 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.35.0", + version="7.36.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/collections/_renderers.py b/app/routers/collections/_renderers.py index b38d1c5..18b4cc9 100644 --- a/app/routers/collections/_renderers.py +++ b/app/routers/collections/_renderers.py @@ -55,17 +55,17 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}

{icon} {title}

-📊 Table -📋 Board -📅 Calendar -🖼️ Gallery -📝 List -📈 Timeline -📊 Gantt -📉 Chart -📋 Form -🗺️ Map -📰 Feed +📊 Table +📋 Board +📅 Calendar +🖼️ Gallery +📝 List +📈 Timeline +📊 Gantt +📉 Chart +📋 Form +🗺️ Map +📰 Feed
{body} """ @@ -130,9 +130,9 @@ def _render_calendar(view_type: str, collection: dict, pages: list[dict], config .cal-nav span{{font-size:16px;font-weight:600}}
-← {prev.strftime('%B')} +← {prev.strftime('%B')} {first.strftime('%B %Y')} -{next_month.strftime('%B')} → +{next_month.strftime('%B')} →
Mon
Tue
Wed
diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 4f930a5..370f1b7 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.35.0" + "version": "7.36.0" }, "paths": { "/auth/register": { diff --git a/e2e/smoke.spec.js b/e2e/smoke.spec.js new file mode 100644 index 0000000..6be29d9 --- /dev/null +++ b/e2e/smoke.spec.js @@ -0,0 +1,153 @@ +const { test, expect } = require('@playwright/test'); + +/** + * Smoke E2E — fondations vérifiant les portes des reports d'audit : + * - A39 : bascule de vues (création d'une vue Board depuis la barre de + * vues d'une collection → rendu de la grille) + * - A20 : Alpine + palette de commandes (Ctrl+K, recherche GET, fermeture) + * - filet : 0 erreur console (les violations CSP atterrissent ici) + * + * READ-ONLY sur les données existantes : crée puis SUPPRIME sa collection + * (répétable). Instance de test attendue sur FD_BASE_URL (défaut 8080). + */ +const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080'; +const USER = process.env.FD_USER || 'e2e@flowdeck.local'; +const PASS = process.env.FD_PASS || 'e2e-secret-123'; + +// Service Workers BLOQUÉS : /sw.js sert sa page « hors ligne » quand la +// réponse de navigation est une redirection (redirect:'manual' sur les +// requêtes navigate) — bruit PWA hors sujet ici (pwa_offline.spec.js +// couvre le SW). On interroge le serveur directement. +test.use({ serviceWorkers: 'block' }); + +const consoleErrors = []; +test.beforeEach(async ({ page }) => { + consoleErrors.length = 0; + page.on('console', (m) => { + if (m.type() !== 'error') return; + // les 401 de ressources (checks de session sur login) sont du bruit + // navigateur, pas une erreur JS/CSP — le reste compte + if (/Failed to load resource/.test(m.text())) return; + consoleErrors.push(m.text()); + }); + page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + e.message)); +}); + +test.afterEach(() => { + // Aucune erreur JS/CSP pendant le scénario + expect(consoleErrors).toEqual([]); +}); + +async function login(page) { + await page.goto(`${FD_BASE}/auth/login?provider=local`, { + waitUntil: 'domcontentloaded', + }); + await page.fill('#email', USER); + await page.fill('#password', PASS); + await page.click('.btn-primary'); + const ok = await Promise.race([ + page + .waitForURL('**/workspaces', { timeout: 8000 }) + .then(() => true) + .catch(() => false), + ]); + if (!ok) { + // Compte absent de l'instance de test → création (bootstrap du harness, + // pas un mot de passe deviné : c'est le compte e2e documenté du repo). + const resp = await page.request.post(`${FD_BASE}/auth/register`, { + data: { email: USER, password: PASS, name: 'E2E' }, + }); + if (resp.status() === 409) { + throw new Error( + 'compte e2e existant mais mot de passe refusé — définir FD_USER/FD_PASS' + ); + } + if (!resp.ok()) { + throw new Error(`register ${resp.status()}: ${await resp.text()}`); + } + await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); + await page.waitForURL('**/workspaces', { timeout: 15000 }); + } + // workspace requis pour créer une collection (compte neuf = aucun ws) + const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json()); + if (!ws.workspaces || ws.workspaces.length === 0) { + await page.evaluate(async () => { + const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + const r = await fetch('/api/workspaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ name: 'E2E workspace' }), + }); + const w = await r.json(); + await fetch(`/api/workspaces/${w.id}/select`, { method: 'POST' }); + }); + } +} + +test('gate A39 : bascule de vues (table → Calendar, rendu par onglet)', async ({ page }) => { + await login(page); + + // collection jetable (créée puis supprimée = répétable). /db/{id} est une + // page STANDALONE (hors base.html) : les onglets .view-tabs naviguent en + // ?view=… et le corps est rendu côté serveur par _render_view(). + const coll = await page.evaluate(async () => { + const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + const r = await fetch('/db/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ name: 'e2e-view-switch' }), + }); + return r.json(); + }); + expect(coll.id, `création collection: ${JSON.stringify(coll)}`).toBeTruthy(); + try { + await page.goto(`${FD_BASE}/db/${coll.id}`, { waitUntil: 'domcontentloaded' }); + await expect(page.locator('.view-tabs a.tab')).toHaveCount(11); + await expect(page.locator('.calendar')).toHaveCount(0); // vue table par défaut + + // bascule réelle : clic sur l'onglet Calendar → navigation ?view=calendar + await page.click('.view-tabs a.tab:has-text("Calendar")'); + await page.waitForURL(/view_type=calendar/, { timeout: 10000 }); + await expect(page.locator('.view-tabs a.tab.active')).toContainText('Calendar'); + // corps Calendar rendu par _render_calendar (grille 6×7) + await expect(page.locator('.calendar')).toHaveCount(1); + expect(await page.locator('.cal-header').count()).toBeGreaterThanOrEqual(7); + } finally { + await page.evaluate(async (id) => { + const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''; + await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } }); + }, coll.id); + } +}); + +test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => { + await login(page); + await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(800); + + await page.keyboard.press('Control+k'); + await page.waitForTimeout(400); + // overlay ouvert (classe .open pilotée par l'IIFE de base.html) + const open = await page.evaluate(() => { + const ov = document.querySelector('#fd-command-palette'); + return !!ov && ov.classList.contains('open'); + }); + expect(open).toBe(true); + + // tape une requête → la recherche GET répond et rend des résultats + await page.keyboard.type('a'); + await page.waitForTimeout(900); + const items = await page.evaluate( + () => document.querySelectorAll('.cmd-palette-item').length + ); + expect(items).toBeGreaterThan(0); + + // Échap ferme la palette + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + const closed = await page.evaluate(() => { + const ov = document.querySelector('#fd-command-palette'); + return !ov || !ov.classList.contains('open'); + }); + expect(closed).toBe(true); +}); diff --git a/static/css/app.css b/static/css/app.css index f7915b6..55d23c3 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -3,7 +3,89 @@ Référence: Notion Light Mode (par défaut) ═══════════════════════════════════════════════════════════ */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +/* Inter auto-hébergé — remplace l'@import Google Fonts que la CSP bloque + (style-src sans fonts.googleapis depuis v7.27). Police variable v20 : + une face par sous-ensemble, font-weight 100-900. */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/static/fonts/inter-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/static/fonts/inter-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/static/fonts/inter-400-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/static/fonts/inter-500-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/static/fonts/inter-500-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/static/fonts/inter-600-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/static/fonts/inter-600-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/static/fonts/inter-700-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/static/fonts/inter-700-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + /* ===== LIGHT THEME (default) ===== */ :root { diff --git a/static/fonts/inter-latin-ext.woff2 b/static/fonts/inter-latin-ext.woff2 new file mode 100644 index 0000000..479d010 Binary files /dev/null and b/static/fonts/inter-latin-ext.woff2 differ diff --git a/static/fonts/inter-latin.woff2 b/static/fonts/inter-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/static/fonts/inter-latin.woff2 differ diff --git a/tests/test_app.py b/tests/test_app.py index 4f1b549..72091d5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2554,7 +2554,12 @@ def test_all_view_tabs_present(client): resp = client.get(f"/db/{coll_id}/view/table") assert resp.status_code == 200 for vt in ["table", "board", "calendar", "gallery", "list", "timeline", "gantt", "chart", "form", "map", "feed"]: - assert f"?view={vt}" in resp.text, f"Missing view tab: {vt}" + # A39/E2E : le nom du paramètre doit être `view_type` (celui de la + # route) — `?view=` était ignoré et l'onglet restait sur Table. + assert f"?view_type={vt}" in resp.text, f"Missing view tab: {vt}" + # et la query commute réellement la vue rendue + resp = client.get(f"/db/{coll_id}?view_type=calendar") + assert 'class="calendar"' in resp.text def test_view_unknown_falls_back_to_table(client):