- 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)
408 lines
18 KiB
JavaScript
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();
|
|
});
|
|
});
|