feat: A20 phase 3 LOT 3b — gitea + agent + éditeur verts en CSP (v7.41.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s

Changed :
- gitea_workspace : x-data="giteaWorkspace" → appel giteaWorkspace(),
  new Date(…) → fmtGwDate(pp), x-html icône arbre → bindGwIcon (x-init +
  Alpine.effect).
- agent_panel : x-html markdown → bindMarkdown($el, m) (effet reactif).
- page_editor : les 12 sites window.E du topbar right_actions →
  délégués appState (edCall('…') x6, edTimeAgo, edCommentCount, edShared,
  bindStar — les 2 branches du ternaire favorited étaient identiques) ;
  + 3 sites dans _page_editor_content (edCall commentOnSelection,
  openBacklink, fmtImportSize, bindIconHtml). Garde Jinja : quotes \' dans
  le set délimité par ' (quote nue = 500).
- Gate éditeur (csp_preview) : création collection → /pages/{id},
  délégués + editorState liés, filet 0-erreur.

Fixed :
- x-html iconHtml() du contenu éditeur = directive INTERDITE sous build
  CSP (attrapé par le filet) → x-init + Alpine.effect.

⚠️ BUG pre-existant identifie (pas introduit ici) : les right_actions du
topbar sont servis ÉCHAPPÉS sur TOUTES les pages (entities "/< —
boutons Share/Star/Settings en texte brut). _header:141 a bien |safe,
ENV standard, rendu local = PARSED ; cause serveur à cerner → suivi
ROADMAP dédié. Le gate éditeur n'asserte donc pas la présence boutons.

suite **1093/1093** · ruff OK · E2E **7/7** (5 csp_preview + 2 smoke)
· docs a jour
This commit is contained in:
2026-10-02 15:15:26 -04:00
parent d7d9966edf
commit 6914780f24
17 changed files with 213 additions and 16 deletions
+39
View File
@@ -1,5 +1,44 @@
# Changelog - FlowDeck
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
### Changed
- **gitea_workspace** : `x-data="giteaWorkspace"` → appel `giteaWorkspace()`,
`new Date(…)` → `fmtGwDate(pp)`, x-html icône d'arbre → `bindGwIcon`
(x-init + `Alpine.effect`).
- **agent_panel** : x-html markdown → `bindMarkdown($el, m)` (effet réactif
sur `m.content`).
- **page_editor (les 12 sites `window.E` du topbar `right_actions`)** →
délégués `appState` : `edCall('…')` (6 appels, arg littéral = seule forme
parsable), `edTimeAgo`, `edCommentCount`, `edShared`, `bindStar` (les 2
branches du ternaire favorited étaient identiques → rendu 1×) — les deux
templates `page_editor.html` + `page_editor_collection.html` + garde
Jinja : les quotes insérées doivent être `\'` (le `set` est délimité par
`'`, une quote nue casse le template = 500).
- **_page_editor_content** : +3 sites (`window.E.commentOnSelection` →
`edCall`, `backlinksOpen…location.href` → `openBacklink(b)`,
`Math.round(importFile…)` → `fmtImportSize(f)`) + x-html icône de page →
`bindIconHtml` (effet sur `iconHtml()`).
- **Gate éditeur** (`csp_preview`) : création collection → `/pages/{id}`,
délégués `appState` liés + `editorState` lié + filet 0-erreur.
### Fixed
- x-html `iconHtml()` du contenu éditeur : directive **interdite** sous
build CSP (le filet l'a attrapé) → `x-init` + `Alpine.effect`.
### ⚠️ Nouveau bug pré-existant identifié (PAS introdui par ce lot)
- **Les `right_actions` du topbar sont servi ÉCHAPPÉS sur TOUTES les
pages** (entities `"`/`<` — les boutons Share/Star/Settings/…
s'affichent en texte brut). Reproductible : `curl /workspaces` →
`"topbar-btn"`. Le `{{ right_actions|safe … }}` de `_header:141`
EST présent, l'ENV Jinja est standard, un rendu local du même motif sort
PARSED — la cause exacte côté serveur reste à cerner (piste : valeur déjà
échappée à la construction). Roadmap = suivi dédié ; le gate éditeur
n'asserte donc pas la présence des boutons.
## v7.40.0 (2026-10-01) — A20 phase 3 LOT 3a : 5 surfaces de plus vertes
### Added
+3 -1
View File
@@ -1141,7 +1141,9 @@ Quality DB views, Agent IA Palette → Realtime + E
**LOT 1 fait 2026-10-01 (v7.38.0)** : (1) **harnais `e2e/csp_preview.spec.js`** — sert le build CSP à la place de `alpine.min.js` par interception Playwright (0 déploiement, tout parse-error = pageerror) ; (2) **les 16 composants `x-data="fn()"` non enregistrés → `Alpine.data`** (registre = seule résolution du build CSP) ; (3) **shell base.html migré** : x-effect document → `syncSidebarClass()`, $nextTick arrow → `initSidebarSort()`, `window.FlowDeck.*` → méthodes, Object/Math/window dans x-for/:style → `sidebarSections()`/`sectionMenuPos()` ; (4) **x-html du shell migrés** → `x-init`+`Alpine.effect` (agent, carte projet, library ×3) + recherche library (`toggleSearch`) ; (5) **eslint 0/0 restauré** (globals `getCsrf` A38 + `;;` résiduels) ; **gate library VERT** (0 pageerror, icônes + recherche vérifiées) → 1093/1093.
**LOT 2 fait 2026-10-01 (v7.39.0)** : **settings + local_workspace VERTS** en csp_preview (3/5 surfaces) — voir CHANGELOG pour les 2 pièges structurels résolus (snapshot `ji` = objet `window` pré-boot banni → const lexicale partagée ; bloc preview hors racine → composant `wsPreview` déléguant, wrapper unique à cause des magics) + `RATE_LIMIT_REQUESTS=600` en `.env` local (rafales E2E vs 60/min).
**LOT 3a fait 2026-10-01 (v7.40.0)** : gate « surfaces simples » = **welcome, trash, accounts, workspace, import VERTS** (4 sans aucune modification — scan propre + registres lot 1) + **fix bug pré-existant `/import`** (x-text `report.relations` non garde → pageerror sous les 2 builds). **8 surfaces couvertes** au total.
**Reste ph3** : page_editor (12 sites `window.E` dans `right_actions` Jinja), gitea_workspace (`new Date`), agent_panel (1 x-text + 1 x-html markdown), board/table_view/teamload/card_detail (scan propre, liés au contexte Gitea pour les gates) → csp_preview vert partout, puis bascule réelle (retrait unsafe-eval). `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
**LOT 3b fait 2026-10-01 (v7.41.0)** : **gitea_workspace + agent_panel + page_editor VERTS** (éditeur = 15 sites : 12 `window.E` → délégués `edCall`/`edTimeAgo`/`edCommentCount`/`edShared`/`bindStar` dans appState + 3 dans `_page_editor_content` → `openBacklink`/`fmtImportSize`/`bindIconHtml` ; piège Jinja : quotes `\'` dans le `set`). **12 surfaces vertes** au total (7 gates).
**Reste ph3** : board/table_view/teamload/card_detail (scan propre, gates liés au contexte Gitea — gitea actuellement down) → csp_preview vert partout, puis bascule réelle (retrait unsafe-eval).
**🐛 BUG pré-existant à analyser (trouvé v7.41.0)** : **`right_actions` du topbar servi ÉCHAPPÉ sur TOUTES les pages** (entities `"`/`<`, boutons Share/Star/Settings en texte brut — repro `curl /workspaces`); `_header:141` a bien `|safe`, l'ENV est standard, rendu local du même motif = PARSED → cause serveur à cerner (piste : valeur échappée à la construction du set). Effort : **M**. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait).
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
+1 -1
View File
@@ -1 +1 @@
7.40.0
7.41.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.40.0 (A20 ph3 LOT 3a : welcome/trash/accounts/workspace/import verts — 8 surfaces CSP couvertes, bug x-text import corrigé) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.41.0 (A20 ph3 LOT 3b : gitea + agent + éditeur verts — 12 sites window.E délégués; BUG pré-existant : right_actions servi échappé partout) | **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
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.40.0",
version="7.41.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+4 -4
View File
@@ -546,7 +546,7 @@
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
</div>
<div class="page-title-block">
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-init="bindIconHtml($el)"></span>
<div
class="page-title-input"
contenteditable="true"
@@ -583,7 +583,7 @@
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
@click="window.E && window.E.commentOnSelection()">
@click="edCall('commentOnSelection')">
💬 Comment
</button>
@@ -820,7 +820,7 @@
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
</template>
<template x-for="b in backlinksList" :key="b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="openBacklink(b)">
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
</a>
@@ -856,7 +856,7 @@
<template x-if="importFile">
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
<span x-text="importFile.name"></span>
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
<span x-text="fmtImportSize(importFile)"></span>
</div>
</template>
<div style="display:flex;gap:8px;margin-top:10px;">
+1 -1
View File
@@ -305,7 +305,7 @@
</div>
</div>
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-init="bindMarkdown($el, m)"></div>
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
<template x-if="m.proposal">
<div class="fd-proposal-bar">
+13
View File
@@ -1574,6 +1574,19 @@
bindProjectIcon(el, p) {
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
},
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
// scope du topbar = appState) — voir right_actions de page_editor ──
edCall(name) {
if (window.E && typeof window.E[name] === 'function') window.E[name]();
},
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
edCommentCount() {
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
},
edShared() { return !!(window.E && window.E.pageIsShared); },
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
bindAgentIcon(el, a) {
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
},
+3 -3
View File
@@ -36,7 +36,7 @@
</style>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
<div class="gw-main" x-data="giteaWorkspace">
<div class="gw-main" x-data="giteaWorkspace()">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
<div class="gw-file-toolbar">
@@ -120,7 +120,7 @@
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)"
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
</div>
@@ -165,7 +165,7 @@
@click="openPrivate(pp.id)">
<div>
<div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
</div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %}
{% set nav_page_id = page.id %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="edCall(\'toggleActivityOpen\')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall(\'toggleComments\')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall(\'toggleShareOpen\')"><span x-show="!edShared()">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall(\'copyPageLink\')" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="edCall(\'toggleFavorite\')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall(\'toggleMoreOpen\')">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_page_editor_content.html" %}
+1 -1
View File
@@ -2,7 +2,7 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" %}
{% set page_title = page.title %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="edCall(\'toggleActivityOpen\')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall(\'toggleShareOpen\')"><span x-show="!edShared()">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall(\'copyPageLink\')" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="edCall(\'toggleFavorite\')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall(\'toggleMoreOpen\')">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.40.0"
"version": "7.41.0"
},
"paths": {
"/auth/register": {
+57
View File
@@ -0,0 +1,57 @@
// Où atterrit right_actions sur /pages/{id} ? (parsé ou texte ?)
const { chromium } = require('playwright-core');
const path = require('path');
const fs = require('fs');
function findChromium() {
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
dirs.sort();
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
}
const BASE = 'http://localhost:8080';
const CSP_JS = path.join(__dirname, 'fixtures', 'alpine.csp.js');
(async () => {
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
const ctx = await browser.newContext({ serviceWorkers: 'block' });
const page = await ctx.newPage();
const errs = [];
page.on('pageerror', (e) => errs.push(e.message.slice(0, 110)));
await page.goto(`${BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
if (await page.locator('#email').count()) {
await page.fill('#email', process.env.FD_USER || '[email protected]');
await page.fill('#password', process.env.FD_PASS || 'e2e-secret-123');
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 15000 }).catch(() => {});
}
await page.route('**/static/js/alpine.min.js', (r) =>
r.fulfill({ path: CSP_JS, contentType: 'application/javascript' })
);
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-probe-editor' }),
});
return r.json();
});
await page.goto(`${BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
const info = await page.evaluate(() => {
const star = document.querySelector('.star-btn');
const anyText = document.body.innerHTML.includes('edCall(');
const parsed = document.querySelector('button.star-btn') ? 'parse' : 'pas-de-bouton';
const parent = star ? star.parentElement.className : null;
const txt = document.body.innerHTML.indexOf('toggleActivityOpen');
const ctx = txt >= 0 ? document.body.innerHTML.slice(Math.max(0, txt - 160), txt + 60) : null;
return { parsed: parsed, starParent: parent, edCallInHTML: anyText, ctx: ctx };
});
console.log(JSON.stringify(info, null, 1));
console.log('erreurs:', errs.length ? errs : 'aucune');
// cleanup
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);
await browser.close();
})();
+65 -1
View File
@@ -19,6 +19,7 @@ const errors = [];
let currentUrl = '';
test.beforeEach(async ({ page }) => {
errors.length = 0;
currentUrl = '';
await page.route('**/static/js/alpine.min.js', (route) =>
route.fulfill({
path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'),
@@ -74,12 +75,75 @@ test('A20-ph3 : surfaces simples sous build CSP (welcome/trash/accounts/workspac
// les échecs RUNTIME (globales, timing de registre, scope de structure)
// /welcome est anonyme (avant login aussi) mais login() ne gêne pas
await login(page);
for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import']) {
for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import',
'/gitea-workspace']) {
currentUrl = url;
await page.goto(FD_BASE + url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
expect(await assertBound(page), `x-data non lié sur ${url}`).toBe('ok');
}
// panneau agent (composant de base, x-html markdown migré via bindMarkdown)
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
const agent = await page.evaluate(() => {
const el = document.querySelector('#fd-agent-panel');
if (!el || !window.Alpine) return 'absent';
try {
const d = window.Alpine.$data(el);
return d && typeof d === 'object' ? 'ok' : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
expect(agent).toBe('ok');
});
test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page }) => {
// le topbar vit dans le scope appState : les12 sites window.E ont été
// remplacés par edCall/edTimeAgo/edCommentCount/edShared/bindStar —
// toute expression non parsable = pageerror (filet).
await login(page);
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-csp-editor' }),
});
return r.json();
});
expect(coll.id, JSON.stringify(coll)).toBeTruthy();
try {
await page.goto(`${FD_BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
// le composant éditeur est lié
const editor = await page.evaluate(() => {
const el = document.querySelector('#page-editor, .page-editor, [x-data]');
const root = document.querySelector('.app-layout');
const d1 = root && window.Alpine ? window.Alpine.$data(root) : null;
const hasEd = d1 && typeof d1.edCall === 'function';
let ed = 'absent';
try {
const cand = Array.from(document.querySelectorAll('[x-data]'))
.map((e) => e.getAttribute('x-data'))
.filter((a) => a && a.startsWith('editorState'));
ed = cand.length ? 'ok' : 'aucun-editorState';
} catch (e) { ed = 'throw'; }
return { ed: ed, delegates: hasEd ? 'ok' : 'absent', el: !!el };
});
expect(editor.delegates).toBe('ok');
expect(editor.ed).toBe('ok');
// (le rendu des boutons right_actions est cassé côté SERVEUR —
// entities &#34; sur TOUTES les pages, régression pré-existante
// documentée au ROADMAP — donc on n'asserte pas leur présence ;
// le filet 0-erreur = les expressions évaluées sont parsables.)
} 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('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
+4
View File
@@ -265,6 +265,10 @@
});
}).catch(function(){});
},
// ── A20 ph3 : x-html (markdown) → x-init + effet réactif ──
bindMarkdown(el, m){
Alpine.effect(() => { el.innerHTML = this.renderMarkdown(m.content); });
},
approveProposal(m){
if(!m || m.applied || m.offline) return;
var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false;
+9
View File
@@ -367,6 +367,15 @@ document.addEventListener('alpine:init', () => {
this.loadSidebarTree();
},
// ── A20 ph3 : new Date / x-html interdits sous build CSP ──
fmtGwDate(pp) {
return pp.updated_at ? new Date(pp.updated_at + 'Z').toLocaleString() : '';
},
bindGwIcon(el, item) {
Alpine.effect(() => {
el.innerHTML = item.type === 'folder' ? getSvgIcon('folder', 16) : getSvgIcon('file', 16);
});
},
formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
+9
View File
@@ -1421,6 +1421,15 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
}catch(e){this.showToast('Remove failed','error');}
},
toggleIcon(){this.iconOpen=!this.iconOpen;},
openBacklink(b){
this.backlinksOpen = false;
window.location.href = '/pages/' + b.id;
},
fmtImportSize(f){ return Math.round(f.size / 1024) + ' KB'; },
// A20 ph3 : x-html interdit sous build CSP → effet réactif
bindIconHtml(el){
Alpine.effect(() => { el.innerHTML = this.iconHtml(); });
},
iconHtml(){
var v=this.iconValue;
if(!v)v=(this.contentFormat==='file')?'paperclip':'file';