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
+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 " 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 }) => {