/** * 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 await expect(page.locator('.pane-container')).toHaveCount(0); await expect(page.locator('#tab-list .tab-item')).toHaveCount(2); }); }); 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(2); await page.locator('.tab-item').first().locator('.tab-close').click(); await page.waitForTimeout(300); await expect(page.locator('.tab-item')).toHaveCount(1); }); 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(1); }); test('Ctrl+W closes active tab (single pane)', async ({ page }) => { await openFile(page, 'TestVault', 'note1.md'); await page.keyboard.press('Control+w'); 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(1); }); 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(3); 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(3); 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 is still open in single mode await expect(page.locator('#tab-list .tab-item')).toHaveCount(1); }); 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(2); // 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(); }); });