Files
ObsiGate/tests/e2e/split-view-matrix.spec.js
T
bruno 2add24a9c1
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
feat: onglet Accueil, menus contextuels de navigation et racine vault #158
- clic sur le titre: la page d'accueil s'ouvre dans un onglet Accueil (icone Maison) epingle en tete de barre, reactivable d'un clic (onglet propre au panneau actif en split)
- page de navigation: menus contextuels des repertoires et des fichiers identiques a ceux de la sidebar, clic sur la racine d'un vault qui ouvre la vue, icone dossier a la couleur de l'arbre de navigation
- BUG-101: bloc « Keyboard shortcuts for tabs » duplique dans ui.js (un seul Ctrl+W fermait deux onglets)
- BUG-102: le menu contextuel se refermait 10 ms apres son ouverture (scroll de reflow des icones lucide)
2026-10-02 14:05:01 -04:00

408 lines
18 KiB
JavaScript

/**
* E2E tests for ObsiGate Split View (#75) — COMPLETION MATRIX.
*
* split-view.spec.js covers the core flows; this file closes the remaining
* gaps of the open/close usage matrix:
* Ouverture : boutons ⊞→/⊞↓ (C3), menu contextuel (splitDown, moveTo),
* drag-to-split BAS (D3b), palette (C→, ↓, focus next/prev, reset)
* Fermeture : .tab-close (X), dblclick, Ctrl+W single-pane, menu contextuel
* Fermer / Fermer les autres / Fermer à droite / Fermer tout /
* Fermer le panneau, Ctrl+Alt+W via menu
* Edge : plafond 4 panneaux, auto-collapse d'un panneau vidé,
* focus voisin à la fermeture, dashboard si dernier onglet fermé
*
* Run: ./scripts/run-e2e-local.sh -g "Split View Matrix"
*/
import { test, expect } from '@playwright/test';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto('/');
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
await page.waitForSelector('#app:not(.hidden)', { timeout: 10000 });
}
await page.evaluate(() => localStorage.removeItem('obsigate-panes'));
}
async function openFile(page, vault, filePath) {
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
// Expand the vault root if not expanded
if (!(await treeItem.isVisible().catch(() => false))) {
const vaultItem = page.locator(`.vault-item[data-vault="${vault}"]`);
if (await vaultItem.count() > 0) {
await vaultItem.click();
await page.waitForTimeout(700);
}
}
// Expand parent folders one level at a time (lazy tree)
const parts = filePath.split('/');
for (let i = 1; i < parts.length; i++) {
if (await treeItem.isVisible().catch(() => false)) break;
const dirPath = parts.slice(0, i).join('/');
const dir = page.locator(`.tree-item[data-vault="${vault}"][data-path="${dirPath}"]`);
if (await dir.count() > 0) {
await dir.click();
await page.waitForTimeout(600);
}
}
// Re-expand vault if clicking a folder collapsed the tree
if (!(await treeItem.isVisible().catch(() => false))) {
const vaultItem = page.locator(`.vault-item[data-vault="${vault}"]`);
await vaultItem.click().catch(() => {});
await page.waitForTimeout(500);
for (let i = 1; i < parts.length; i++) {
const dirPath = parts.slice(0, i).join('/');
const dir = page.locator(`.tree-item[data-vault="${vault}"][data-path="${dirPath}"]`);
if (await dir.count() > 0) { await dir.click(); await page.waitForTimeout(500); }
}
}
await treeItem.dblclick({ timeout: 5000 });
await page.waitForTimeout(500);
}
// Open a file programmatically in a given pane (robust setup for pane>0)
async function openInPane(page, paneId, vault, path) {
await page.evaluate(([pid, v, f]) => {
const tm = window.PaneManager.tabManagers[pid];
if (!tm) throw new Error('no pane ' + pid);
tm.open(v, f);
}, [paneId, vault, path]);
await page.waitForTimeout(800);
}
async function paneCount(page) {
return page.locator('.pane-container').count();
}
async function splitVia(page) {
await page.keyboard.press('Control+Alt+\\');
await page.waitForSelector('.pane-grid', { timeout: 3000 });
}
test.describe('Split View Matrix — Boutons & menu contextuel', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('⊞→ button in tab bar splits right (C3)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
const splitBtn = page.locator('#tab-bar .tab-split-btn[data-action="split-right"]');
await expect(splitBtn).toBeAttached();
await splitBtn.click({ force: true });
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
});
test('⊞↓ button in tab bar splits down (C3)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
const splitBtn = page.locator('#tab-bar .tab-split-btn[data-action="split-down"]');
await expect(splitBtn).toBeAttached();
await splitBtn.click({ force: true });
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
});
test('context menu → Diviser en bas (C1)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await page.locator('.tab-item').first().click({ button: 'right' });
const menuItem = page.locator('#tab-context-menu .context-menu-item', { hasText: 'Diviser en bas' });
await expect(menuItem).toBeVisible();
await menuItem.click();
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
});
test('context menu → Fermer le panneau returns to single pane', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await splitVia(page);
await page.waitForTimeout(600);
// put a tab into pane 1 so the pane actually holds content
await openInPane(page, 1, 'TestVault', 'Accueil.md');
// right-click pane 1's tab — active pane becomes 1 — use the contextual close
const pane1Tab = page.locator('.pane-container').nth(1).locator('.tab-item').first();
await pane1Tab.click({ button: 'right' });
const closePaneItem = page.locator('#tab-context-menu [data-action="closePane"]');
await expect(closePaneItem).toBeVisible();
await closePaneItem.click();
await page.waitForTimeout(600);
await expect(page.locator('.pane-container')).toHaveCount(0);
// pane 0 tabs survived (single mode)
await expect(page.locator('#tab-list .tab-item').first()).toBeVisible();
});
test('context menu → Déplacer vers... flyout transfers tab (C1b)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await splitVia(page);
await page.waitForTimeout(600);
// pane 1 starts empty after split — put a file there via API
await openInPane(page, 1, 'TestVault', 'note2.md');
const pane1Tabs = page.locator('.pane-container').nth(1).locator('.tab-item');
await expect(pane1Tabs.first()).toBeVisible({ timeout: 5000 });
await pane1Tabs.first().click({ button: 'right' });
const moveTo = page.locator('#tab-context-menu [data-action="moveTo"]');
await expect(moveTo).toBeVisible();
await moveTo.hover();
const flyout = page.locator('.context-submenu .context-menu-item', { hasText: 'Panneau 1' });
await expect(flyout).toBeVisible({ timeout: 3000 });
await flyout.click();
await page.waitForTimeout(600);
// Pane 0 (single) holds both files now; empty source pane auto-collapsed
// (+ onglet navigation ouvert au clic sur la racine du vault — #158)
await expect(page.locator('.pane-container')).toHaveCount(0);
await expect(page.locator('#tab-list .tab-item')).toHaveCount(3);
});
});
test.describe('Split View Matrix — Palette de commandes', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('palette command ⊞→ splits (G3/H1)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await page.keyboard.press('Control+Shift+Space');
const input = page.locator('.cp-input');
await expect(input).toBeVisible({ timeout: 5000 });
await input.fill('>Diviser à droite');
await page.waitForSelector('.cp-item', { timeout: 3000 });
await page.keyboard.press('Enter');
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
});
test('palette Panneau suivant / Panneau précédent navigate', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await splitVia(page);
await page.waitForTimeout(600);
await page.keyboard.press('Control+Shift+Space');
const input = page.locator('.cp-input');
await expect(input).toBeVisible({ timeout: 5000 });
await input.fill('>Panneau suivant');
await page.waitForSelector('.cp-item', { timeout: 3000 });
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
await expect(page.locator('.pane-container').nth(1)).toHaveClass(/pane-active/);
});
});
test.describe('Split View Matrix — Fermeture', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('X button on tab closes it', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
await page.locator('.tab-item').first().locator('.tab-close').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(2);
});
test('double-click on tab closes it', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('.tab-item').first().dblclick();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(2); // reste : onglet navigation (#158)
});
test('Ctrl+W closes active tab (single pane)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md'); // ouvre aussi l'onglet navigation (#158)
await page.keyboard.press('Control+w'); // ferme l'onglet fichier
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(1);
await page.keyboard.press('Control+w'); // ferme l'onglet navigation
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(0);
await expect(page.locator('#dashboard-home')).toBeVisible();
});
test('context menu Fermer (close single)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="close"]').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(2); // reste navigation + fichier (#158)
});
test('context menu Fermer les autres keeps one', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('#header-logo').click(); await page.waitForTimeout(300);
await openFile(page, 'TestVault', 'Accueil.md');
await page.waitForTimeout(300);
const tabs = page.locator('.tab-item');
await expect(tabs).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
await tabs.nth(1).click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeOthers"]').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
});
test('context menu Fermer à droite', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('#header-logo').click(); await page.waitForTimeout(300);
await openFile(page, 'TestVault', 'Accueil.md');
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeRight"]').click();
await page.waitForTimeout(300);
await expect(page.locator('.tab-item')).toHaveCount(1);
});
test('context menu Fermer tout → dashboard', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.locator('.tab-item').first().click({ button: 'right' });
await page.locator('#tab-context-menu [data-action="closeAll"]').click();
await page.waitForTimeout(400);
// closeAll hides the tab bar and shows dashboard (tabs list not re-rendered)
await expect(page.locator('#tab-bar')).toBeHidden();
await expect(page.locator('#dashboard-home')).toBeVisible();
});
test('closing last tab of a non-last pane auto-collapses pane (C4)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await splitVia(page);
await page.waitForTimeout(600);
await openInPane(page, 1, 'TestVault', 'note2.md');
const pane1 = page.locator('.pane-container').nth(1);
await expect(pane1.locator('.tab-item')).toHaveCount(1);
// close pane 1's only tab → pane itself auto-collapses
await pane1.locator('.tab-item').first().locator('.tab-close').click({ force: true });
await page.waitForTimeout(700);
await expect(page.locator('.pane-container')).toHaveCount(0);
// pane 0 file + onglet navigation still open in single mode (#158)
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
});
test('closing pane 0 of 2 focuses neighbour then collapses correctly', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await splitVia(page);
await page.waitForTimeout(600);
// give pane 1 its own tab so the neighbour survives pane-0 closure
await openInPane(page, 1, 'TestVault', 'Accueil.md');
// activate pane 0 (both migrated tabs live there) then close it —
// documented C4 behaviour: tabs of the closed pane are LOST, neighbour survives
await page.locator('.pane-container').nth(0).click({ position: { x: 50, y: 200 } });
await page.waitForTimeout(200);
await page.keyboard.press('Control+Alt+w');
await page.waitForTimeout(700);
// collapsed back to single-pane grid with only the surviving Accueil tab
await expect(page.locator('.pane-container')).toHaveCount(0);
const tabs = page.locator('#tab-list .tab-item');
await expect(tabs).toHaveCount(1);
await expect(tabs.first()).toContainText('Accueil');
});
});
test.describe('Split View Matrix — Edge cases', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('PANE_MAX: split is capped at 4 panes', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
// grow to 4 through the API (fast, deterministic — the cap lives in splitRight/splitDown)
await page.evaluate(() => {
window.PaneManager.splitRight();
window.PaneManager.splitDown();
window.PaneManager.splitRight();
});
await page.waitForTimeout(800);
await expect(page.locator('.pane-container')).toHaveCount(4);
// 5th attempt must be a no-op
await page.evaluate(() => window.PaneManager.splitRight());
await page.waitForTimeout(400);
await expect(page.locator('.pane-container')).toHaveCount(4);
});
test('split down then Ctrl+Alt+Down navigates vertically', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await page.keyboard.press('Control+Alt+Shift+\\');
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await page.waitForTimeout(600);
await page.keyboard.press('Control+Alt+ArrowDown');
await page.waitForTimeout(300);
await expect(page.locator('.pane-container').nth(1)).toHaveClass(/pane-active/);
await page.keyboard.press('Control+Alt+ArrowUp');
await page.waitForTimeout(300);
await expect(page.locator('.pane-container').nth(0)).toHaveClass(/pane-active/);
});
test('drag-to-split BOTTOM zone creates vertical split (D3)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
const tabBox = await page.locator('.tab-item').first().boundingBox();
const areaBox = await page.locator('#content-area').boundingBox();
expect(tabBox && areaBox).toBeTruthy();
await page.mouse.move(tabBox.x + tabBox.width / 2, tabBox.y + tabBox.height / 2);
await page.mouse.down();
await page.mouse.move(
areaBox.x + areaBox.width / 2,
areaBox.y + areaBox.height * 0.9,
{ steps: 15 }
);
await page.waitForTimeout(300);
await page.mouse.up();
await page.waitForSelector('.pane-grid', { timeout: 3000 });
await expect(page.locator('.pane-container')).toHaveCount(2);
// vertical layout → gridTemplateRows defines 3 rows
const rows = await page.evaluate(() =>
document.querySelector('.pane-grid')?.style.gridTemplateRows || '');
expect(rows).toContain('1fr');
});
test('reopening an open file focuses existing tab — no duplicate (E4)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
// dblclick note1 again
await page.locator('.tree-item[data-path="note1.md"]').dblclick();
await page.waitForTimeout(400);
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
// it should be the active one
const active = page.locator('.tab-item.active');
await expect(active).toHaveCount(1);
await expect(active).toContainText('note1');
});
test('reset panes from palette fully collapses split (G3)', async ({ page }) => {
await openFile(page, 'TestVault', 'note1.md');
await openFile(page, 'TestVault', 'note2.md');
await splitVia(page);
await page.waitForTimeout(600);
await page.keyboard.press('Control+Shift+Space');
const input = page.locator('.cp-input');
await expect(input).toBeVisible({ timeout: 5000 });
await input.fill('>Réinitialiser les panneaux');
await page.waitForSelector('.cp-item', { timeout: 3000 });
await page.keyboard.press('Enter');
await page.waitForTimeout(600);
await expect(page.locator('.pane-container')).toHaveCount(0);
const saved = await page.evaluate(() => localStorage.getItem('obsigate-panes'));
expect(saved).toBeNull();
});
});