Files
ObsiGate/tests/e2e/xlsx-viewer.spec.js
T
bruno 31d4616baf feat: garde-fous d'écriture des classeurs Excel #153 (P0)
L'édition d'un .xlsx pouvait détruire une partie du classeur, le
concurrencer en silence, ou diffuser une injection de formule.

- BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd
  (valeurs calculées en cache, slicers, contrôles, connexions, custom
  XML, signature, commentaires enrichis, macros) → xlsx_lossy_features
  exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans
  `force` (confirmation explicite puis reprise). Périmètre réel
  revalidé : graphiques, images et TCD survivent au round-trip.
- BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage
  ne peut plus tronquer le classeur, le backup reste intact.
- BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s,
  409 conflict) ; endpoint xlsx/save devenu synchrone pour que
  l'attente s'exécute dans le threadpool.
- BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in
  `allow_formula` ou le bouton f(x) de la visionneuse. Le handler
  ServiceError expose désormais code + details, que api() propage.
- BUG-084 : la suppression d'une vault purge enfin l'index inversé
  (documents fantômes qui continuaient de matcher) et is_stale() devient
  is_ready(), le nom étant trompeur (la staleness n'existe plus).

Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI),
3 E2E Playwright, suite E2E complète verte, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-27 20:39:12 -04:00

121 lines
4.7 KiB
JavaScript

/**
* E2E tests — Excel viewer, write guards (ROADMAP #153 P0).
*
* Fixture : `test_vault/sample-xlsx-lossy.xlsx` — a plain 2x2 workbook whose
* sheet XML carries a cached formula result (`<f>B1*2</f><v>200</v>`) and whose
* package contains `xl/slicers/slicer1.xml`. Both are dropped by an openpyxl
* round-trip, so the read response must report
* `xlsx_lossy_features: ["cached_values", "slicers"]` (BUG-085 A1).
*
* Covered :
* - the warning banner lists both features ;
* - saving a cell on that workbook asks for confirmation (native dialog) and
* then succeeds (the client retries with `force: true`) ;
* - the f(x) toggle is off by default, so "=B1*3" is stored as text.
*
* The fixture is restored byte-for-byte in `afterAll` so a local run never
* dirties the working copy.
*
* Run (local) : BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xlsx-viewer.spec.js
*/
import { test, expect } from '@playwright/test';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import path from 'node:path';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const VAULT = 'TestVault';
const FIXTURE = 'sample-xlsx-lossy.xlsx';
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
let originalBytes = null;
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', process.env.OBSIGATE_USER || 'admin');
await page.fill('#login-password', process.env.OBSIGATE_PASS || 'test123');
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function openFixture(page) {
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${FIXTURE}"]`);
if (!(await treeItem.count())) {
await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click();
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
}
await treeItem.dblclick({ timeout: 5000 });
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
}
test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => {
test.beforeAll(() => {
if (existsSync(FIXTURE_PATH)) originalBytes = readFileSync(FIXTURE_PATH);
});
test.afterAll(() => {
if (originalBytes) writeFileSync(FIXTURE_PATH, originalBytes);
});
test('affiche la bannière listant les éléments non préservés', async ({ page }) => {
await login(page);
await openFixture(page);
const banner = page.locator('#content-area .xlsx-warning');
await expect(banner).toBeVisible();
// 2 features : valeurs calculées + segments (jamais de couleur codée en dur,
// les libellés viennent bien des locales).
await expect(banner.locator('.xlsx-warning-tag')).toHaveCount(2);
await expect(banner).toContainText('segments');
await expect(banner).toContainText('valeurs calculées');
});
test('demande confirmation puis enregistre la cellule', async ({ page }) => {
await login(page);
await openFixture(page);
let dialogMessage = null;
page.on('dialog', async (dialog) => {
dialogMessage = dialog.message();
await dialog.accept();
});
const cell = page.locator('#content-area td[data-cell="A2"]');
await cell.click();
await cell.fill('Total confirmé');
await cell.press('Enter');
const save = page.locator('#xlsx-save-btn');
await expect(save).toBeEnabled();
await save.click();
await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments');
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
// La cellule reste modifiée côté UI (plus de marque « sale »).
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
});
test('le toggle f(x) est désactivé par défaut (formule stockée en texte)', async ({ page }) => {
await login(page);
await openFixture(page);
const toggle = page.locator('#xlsx-formula-btn');
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
// 409 → confirmation, puis reprise avec force (le toggle reste désactivé).
page.on('dialog', (dialog) => dialog.accept());
const cell = page.locator('#content-area td[data-cell="B2"]');
await cell.click();
await cell.fill('=B1*3');
await cell.press('Enter');
await page.locator('#xlsx-save-btn').click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
});
});