mirror of
https://github.com/mathuo/dockview
synced 2026-09-16 02:48:51 +00:00
test(e2e): broaden v8 feature coverage
Add real-browser coverage for v8 features that the existing e2e suite only touched partially: - Layout history: in-grid undo/redo of a group split, a panel close, and a maximize (new layout-history-grid.spec.ts) — complements the existing cross-window popout history tests. - Pinned tabs: pinned-ahead-of-unpinned ordering, pinned state round-tripping through toJSON/fromJSON, and pin/unpin driven from the tab context menu. - Tab context menu: the "Close All" built-in item. - Keyboard docking: the Ctrl+Shift+F "float" terminal action. - Custom dropPositionResolver: overriding the compass so a centre drop splits instead of merging (new drop-position-resolver.spec.ts). - Smart guides: the public onDidSnapFloat event fires on a magnetic snap. Harness: extend the window.__dv handle with addPanelAt/maximizeGroup/ isMaximized/canRedo/snapshot/restore/tabTitles/recordSnaps, an opt-in `?resolver=` dropPositionResolver and `?pinmenu=1` tab-menu variant. Add an opt-in PLAYWRIGHT_CHROMIUM_EXECUTABLE env override so a pre-installed browser can be used when the CI image's Chromium differs from Playwright's download. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01831Rcw8gUFezZwrrEJ8uCq
This commit is contained in:
+56
-5
@@ -68,6 +68,18 @@
|
||||
params.get('pinned') === 'separate-row'
|
||||
? 'separate-row'
|
||||
: 'inline';
|
||||
// `?resolver=left` (or any Position) installs a custom
|
||||
// `dropPositionResolver` that always returns that position,
|
||||
// overriding the pointer-quadrant / compass resolution — inert
|
||||
// unless the param is present so other DnD specs are unaffected.
|
||||
const resolverPosition = params.get('resolver');
|
||||
const dropPositionResolver = resolverPosition
|
||||
? { resolve: () => ({ position: resolverPosition }) }
|
||||
: undefined;
|
||||
// `?pinmenu=1` adds the built-in `'pin'` tab context-menu item so
|
||||
// the pin/unpin flow can be driven from the menu without changing
|
||||
// the item count the base context-menu spec asserts.
|
||||
const pinMenu = params.get('pinmenu') === '1';
|
||||
const dockview = new lib.DockviewComponent(el, {
|
||||
keyboardNavigation: true,
|
||||
overflow,
|
||||
@@ -98,14 +110,16 @@
|
||||
// drivable in the harness. Inert unless a drag occurs.
|
||||
dndGuide: true,
|
||||
dndStrategy: 'pointer',
|
||||
// Custom drop-position resolver (opt-in via `?resolver=`),
|
||||
// which takes precedence over the compass at the same seam.
|
||||
dropPositionResolver,
|
||||
// Tab context menu (ContextMenuModule) — inert until a tab
|
||||
// is right-clicked. Safe to set globally: no other spec
|
||||
// right-clicks a tab.
|
||||
getTabContextMenuItems: () => [
|
||||
'close',
|
||||
'closeOthers',
|
||||
'closeAll',
|
||||
],
|
||||
getTabContextMenuItems: () =>
|
||||
pinMenu
|
||||
? ['pin', 'separator', 'close']
|
||||
: ['close', 'closeOthers', 'closeAll'],
|
||||
// Tab-group chip context menu (TabGroupChipsModule) — inert
|
||||
// until a chip is right-clicked, which only the tab-group
|
||||
// chip spec does.
|
||||
@@ -182,6 +196,33 @@
|
||||
}
|
||||
},
|
||||
closePanel: (id) => panels[id] && panels[id].api.close(),
|
||||
// Add a panel, optionally splitting a new group off in a
|
||||
// direction (records an 'add' mutation for layout history).
|
||||
addPanelAt: (id, direction) => {
|
||||
panels[id] = dockview.addPanel({
|
||||
id,
|
||||
component: 'default',
|
||||
title: id,
|
||||
position: direction ? { direction } : undefined,
|
||||
});
|
||||
},
|
||||
// Maximize/restore a panel's group + read the flag, for the
|
||||
// layout-history 'maximize' mutation.
|
||||
maximizeGroup: (id) =>
|
||||
panels[id] && panels[id].api.maximize(),
|
||||
isMaximized: (id) =>
|
||||
!!(panels[id] && panels[id].api.isMaximized()),
|
||||
// Serialization round-trip (e.g. pinned state persistence).
|
||||
snapshot: () => dockview.toJSON(),
|
||||
restore: (state) => dockview.fromJSON(state),
|
||||
// Tab titles in strip order for the first (or only) group —
|
||||
// lets a spec assert pinned tabs sort ahead of unpinned.
|
||||
tabTitles: () =>
|
||||
Array.from(
|
||||
document.querySelectorAll(
|
||||
'.dv-tabs-container .dv-tab'
|
||||
)
|
||||
).map((t) => t.textContent.trim()),
|
||||
// Create a new panel and move it to the right of the
|
||||
// popped-out group — the move lands in the popout's own
|
||||
// gridview, producing a second group (and so a sash) there.
|
||||
@@ -206,6 +247,16 @@
|
||||
undo: () => dockview.undo(),
|
||||
redo: () => dockview.redo(),
|
||||
canUndo: () => dockview.canUndo,
|
||||
canRedo: () => dockview.canRedo,
|
||||
// Subscribe to the Smart Guides snap events so a spec can
|
||||
// assert the public event API fires on a magnetic snap.
|
||||
recordSnaps: () => {
|
||||
window.__snaps = [];
|
||||
dockview.api.onDidSnapFloat((e) =>
|
||||
window.__snaps.push({ axes: e.axes })
|
||||
);
|
||||
},
|
||||
snaps: () => window.__snaps || [],
|
||||
awaitPopoutRestore: () => dockview.popoutRestorationPromise,
|
||||
// Two side-by-side groups for Drop Guide: dragging the left
|
||||
// group's tab over the right group shows the compass.
|
||||
|
||||
@@ -78,6 +78,24 @@ test.describe('tab context menu', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking "Close All" closes every panel in the group', async ({
|
||||
page,
|
||||
}) => {
|
||||
await setup(page);
|
||||
await page
|
||||
.locator('.dv-tab', { hasText: 'bravo' })
|
||||
.click({ button: 'right' });
|
||||
await expect(page.locator('.dv-context-menu')).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.dv-context-menu-item', { hasText: 'Close All' })
|
||||
.click();
|
||||
|
||||
// The whole group is emptied — no tabs remain.
|
||||
await expect(page.locator('.dv-context-menu')).toHaveCount(0);
|
||||
await expect(page.locator('.dv-tab')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('clicking outside the menu dismisses it without closing anything', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Custom `dropPositionResolver` — overrides how a pointer location maps to a
|
||||
* drop position on the group / layout-edge targets, occupying the same seam the
|
||||
* Drop Guide compass otherwise fills. Real-browser only: the resolver runs
|
||||
* inside the live pointer-drag loop. Here `?resolver=right` forces every drop to
|
||||
* 'right', so dropping a tab on the *centre* of another group (a merge by
|
||||
* default) instead splits it to the right — proving the pointer position was
|
||||
* overridden.
|
||||
*/
|
||||
test.describe('drop position resolver (override)', () => {
|
||||
test('a custom resolver forces the drop position regardless of pointer', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/e2e/fixtures/index.html?resolver=right');
|
||||
await page.waitForFunction(() => (window as any).__ready === true);
|
||||
await page.evaluate(() => (window as any).__dv.setupDropGuide());
|
||||
expect(
|
||||
await page.evaluate(() => (window as any).__dv.groupCount())
|
||||
).toBe(2);
|
||||
|
||||
const tab = (await page
|
||||
.locator('.dv-tab', { hasText: 'left' })
|
||||
.boundingBox())!;
|
||||
const rightContent = page.locator('.dv-content-container', {
|
||||
has: page.locator('.dv-test-panel', { hasText: 'right' }),
|
||||
});
|
||||
const content = (await rightContent.boundingBox())!;
|
||||
const cx = content.x + content.width / 2;
|
||||
const cy = content.y + content.height / 2;
|
||||
|
||||
// Drag the 'left' tab onto the dead-centre of the right group — a centre
|
||||
// drop merges by default, but the resolver forces 'right'.
|
||||
await page.mouse.move(tab.x + tab.width / 2, tab.y + tab.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
tab.x + tab.width / 2 + 6,
|
||||
tab.y + tab.height / 2
|
||||
);
|
||||
await page.mouse.move(cx, cy, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Still two groups — a split, not the centre-merge the pointer aimed at…
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as any).__dv.groupCount())
|
||||
)
|
||||
.toBe(2);
|
||||
// …and 'left' docked to the right of 'right' (it started on its left).
|
||||
const leftAfter = (await page
|
||||
.locator('.dv-tab', { hasText: 'left' })
|
||||
.boundingBox())!;
|
||||
const rightAfter = (await page
|
||||
.locator('.dv-tab', { hasText: 'right' })
|
||||
.boundingBox())!;
|
||||
expect(leftAfter.x).toBeGreaterThan(rightAfter.x);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Keyboard docking — the 'float' terminal action. Arming a move with `Ctrl+M`
|
||||
* enters the PICK-TARGET phase; from there `Ctrl+Shift+F` pulls the moving panel
|
||||
* out into a new floating group instead of docking it, and narrates the result
|
||||
* into the live region. Real-browser only: it needs a genuine focused element to
|
||||
* arm the move and real floating-group geometry for the result.
|
||||
*/
|
||||
test.describe('keyboard docking (float action)', () => {
|
||||
test('Ctrl+Shift+F floats the moving panel', async ({ page }) => {
|
||||
await page.goto('/e2e/fixtures/index.html');
|
||||
await page.waitForFunction(() => (window as any).__ready === true);
|
||||
// Two panels in one group so the active one can be pulled out into a
|
||||
// float (a lone group can't be floated).
|
||||
await page.evaluate(() => {
|
||||
(window as any).__dv.addPanel('alpha');
|
||||
(window as any).__dv.addPanel('beta'); // beta active
|
||||
});
|
||||
expect(
|
||||
await page.evaluate(() => (window as any).__dv.floatingCount())
|
||||
).toBe(0);
|
||||
|
||||
// Focus the active panel, arm keyboard docking, then float.
|
||||
await page.locator('.dv-test-panel').first().click();
|
||||
await page.keyboard.press('Control+m');
|
||||
await page.keyboard.press('Control+Shift+F');
|
||||
|
||||
// A floating group now exists (the moving panel was pulled out)…
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as any).__dv.floatingCount())
|
||||
)
|
||||
.toBe(1);
|
||||
await expect(page.locator('.dv-resize-container')).toHaveCount(1);
|
||||
// …and the float was narrated into the live region.
|
||||
await expect(page.locator('.dv-live-region')).toContainText('floated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Layout history (undo/redo) for in-grid structural mutations — the counterpart
|
||||
* to the cross-window popout coverage in layout-history.spec.ts. These run in a
|
||||
* single window but still exercise the real recorder: each mutation snapshots
|
||||
* the live layout (`toJSON`) and undo/redo re-applies it (`fromJSON`). The
|
||||
* harness drives the component directly, so its mutations carry the default
|
||||
* `'user'` origin the recorder keeps on the undo stack (programmatic-origin
|
||||
* mutations are ignored by default).
|
||||
*/
|
||||
test.describe('layout history (in-grid undo/redo)', () => {
|
||||
const ready = async (page: Page) => {
|
||||
await page.goto('/e2e/fixtures/index.html');
|
||||
await page.waitForFunction(() => (window as any).__ready === true);
|
||||
};
|
||||
const groupCount = (page: Page) =>
|
||||
page.evaluate(() => (window as any).__dv.groupCount());
|
||||
const canUndo = (page: Page) =>
|
||||
page.evaluate(() => (window as any).__dv.canUndo());
|
||||
const canRedo = (page: Page) =>
|
||||
page.evaluate(() => (window as any).__dv.canRedo());
|
||||
|
||||
test('undo and redo a group split', async ({ page }) => {
|
||||
await ready(page);
|
||||
await page.evaluate(() => (window as any).__dv.addPanel('alpha'));
|
||||
await page.evaluate(() =>
|
||||
(window as any).__dv.addPanelAt('beta', 'right')
|
||||
);
|
||||
|
||||
// Two side-by-side groups, and the split is on the undo stack.
|
||||
expect(await groupCount(page)).toBe(2);
|
||||
expect(await canUndo(page)).toBe(true);
|
||||
expect(await canRedo(page)).toBe(false);
|
||||
|
||||
// Undo → beta's add is reverted, back to a single group.
|
||||
await page.evaluate(() => (window as any).__dv.undo());
|
||||
await expect.poll(() => groupCount(page)).toBe(1);
|
||||
expect(await canRedo(page)).toBe(true);
|
||||
|
||||
// Redo → the split returns.
|
||||
await page.evaluate(() => (window as any).__dv.redo());
|
||||
await expect.poll(() => groupCount(page)).toBe(2);
|
||||
});
|
||||
|
||||
test('undo restores a closed panel', async ({ page }) => {
|
||||
await ready(page);
|
||||
await page.evaluate(() => {
|
||||
(window as any).__dv.addPanel('alpha');
|
||||
(window as any).__dv.addPanel('beta');
|
||||
});
|
||||
await expect(page.locator('.dv-tab')).toHaveCount(2);
|
||||
|
||||
await page.evaluate(() => (window as any).__dv.closePanel('beta'));
|
||||
await expect(page.locator('.dv-tab')).toHaveCount(1);
|
||||
|
||||
// Undo the close → beta comes back.
|
||||
await page.evaluate(() => (window as any).__dv.undo());
|
||||
await expect(page.locator('.dv-tab')).toHaveCount(2);
|
||||
await expect(
|
||||
page.locator('.dv-tab', { hasText: 'beta' })
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('undo reverts a maximize', async ({ page }) => {
|
||||
await ready(page);
|
||||
await page.evaluate(() => {
|
||||
(window as any).__dv.addPanel('alpha');
|
||||
(window as any).__dv.addPanelAt('beta', 'right');
|
||||
});
|
||||
expect(await groupCount(page)).toBe(2);
|
||||
|
||||
await page.evaluate(() => (window as any).__dv.maximizeGroup('beta'));
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as any).__dv.isMaximized('beta'))
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Undo → the maximize is lifted.
|
||||
await page.evaluate(() => (window as any).__dv.undo());
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as any).__dv.isMaximized('beta'))
|
||||
)
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -143,4 +143,83 @@ test.describe('pinned tabs', () => {
|
||||
// …but the pinned panel is never listed there.
|
||||
await expect(overflow).not.toContainText('panel-7');
|
||||
});
|
||||
|
||||
test('a pinned tab sorts ahead of unpinned tabs', async ({ page }) => {
|
||||
await setup(page);
|
||||
await page.evaluate(() =>
|
||||
(window as any).__dv.setupPinned(['a', 'b', 'c'], [])
|
||||
);
|
||||
// Added in order, nothing pinned yet.
|
||||
expect(
|
||||
await page.evaluate(() => (window as any).__dv.tabTitles())
|
||||
).toEqual(['a', 'b', 'c']);
|
||||
|
||||
// Pinning the last tab jumps it to the front of the strip; the other
|
||||
// two keep their relative order behind it.
|
||||
await page.evaluate(() => (window as any).__dv.setPinned('c', true));
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as any).__dv.tabTitles()))
|
||||
.toEqual(['c', 'a', 'b']);
|
||||
|
||||
// Unpinning clears the pin marker; the tab keeps its current slot
|
||||
// (unpinning removes pinned status, it does not re-sort the strip).
|
||||
await page.evaluate(() => (window as any).__dv.setPinned('c', false));
|
||||
await expect(page.locator('.dv-tab--pinned')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as any).__dv.tabTitles()))
|
||||
.toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('pinned state round-trips through serialization', async ({ page }) => {
|
||||
await setup(page);
|
||||
await page.evaluate(() =>
|
||||
(window as any).__dv.setupPinned(
|
||||
['alpha', 'bravo', 'charlie'],
|
||||
['bravo']
|
||||
)
|
||||
);
|
||||
await expect(page.locator('.dv-tab--pinned')).toContainText('bravo');
|
||||
|
||||
// The serialized layout carries the pinned flag…
|
||||
const json = await page.evaluate(() =>
|
||||
JSON.stringify((window as any).__dv.snapshot())
|
||||
);
|
||||
expect(json).toContain('"pinned":true');
|
||||
|
||||
// …and re-loading that snapshot rebuilds the pinned tab as pinned.
|
||||
await page.evaluate(
|
||||
(state) => (window as any).__dv.restore(JSON.parse(state)),
|
||||
json
|
||||
);
|
||||
await expect(page.locator('.dv-tab--pinned')).toHaveCount(1);
|
||||
await expect(page.locator('.dv-tab--pinned')).toContainText('bravo');
|
||||
});
|
||||
|
||||
test('pinning and unpinning from the tab context menu', async ({ page }) => {
|
||||
// `?pinmenu=1` swaps the harness tab menu to `['pin', 'separator',
|
||||
// 'close']` so the built-in pin item is drivable.
|
||||
await page.goto('/e2e/fixtures/index.html?pinmenu=1');
|
||||
await page.waitForFunction(() => (window as any).__ready === true);
|
||||
await page.evaluate(() =>
|
||||
(window as any).__dv.setupPinned(['alpha', 'bravo'], [])
|
||||
);
|
||||
await expect(page.locator('.dv-tab--pinned')).toHaveCount(0);
|
||||
|
||||
// Right-click "bravo" → the menu offers "Pin tab"; clicking it pins.
|
||||
await page
|
||||
.locator('.dv-tab', { hasText: 'bravo' })
|
||||
.click({ button: 'right' });
|
||||
await expect(page.locator('.dv-context-menu')).toBeVisible();
|
||||
await page
|
||||
.locator('.dv-context-menu-item', { hasText: 'Pin tab' })
|
||||
.click();
|
||||
await expect(page.locator('.dv-tab--pinned')).toContainText('bravo');
|
||||
|
||||
// Right-clicking the now-pinned tab offers "Unpin tab"; clicking unpins.
|
||||
await page.locator('.dv-tab--pinned').click({ button: 'right' });
|
||||
await page
|
||||
.locator('.dv-context-menu-item', { hasText: 'Unpin tab' })
|
||||
.click();
|
||||
await expect(page.locator('.dv-tab--pinned')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,6 +161,35 @@ test.describe('smart guides (floating snap)', () => {
|
||||
await page.keyboard.up('Alt');
|
||||
});
|
||||
|
||||
test('a magnetic snap fires the onDidSnapFloat event', async ({ page }) => {
|
||||
await setup(page);
|
||||
// Subscribe to the public snap event before dragging.
|
||||
await page.evaluate(() => (window as any).__dv.recordSnaps());
|
||||
|
||||
const mover = overlayWith(page, 'mover');
|
||||
const target = overlayWith(page, 'target');
|
||||
const handle = mover.locator('.dv-floating-titlebar');
|
||||
|
||||
const targetBox = (await target.boundingBox())!;
|
||||
const moverBox = (await mover.boundingBox())!;
|
||||
const tb = (await handle.boundingBox())!;
|
||||
const startX = tb.x + tb.width / 2;
|
||||
const startY = tb.y + tb.height / 2;
|
||||
const grab = startX - moverBox.x;
|
||||
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX + 1, startY);
|
||||
// Snap the left edge onto the target's (the X axis).
|
||||
await page.mouse.move(targetBox.x + grab + 5, startY, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
// The event fired at least once, reporting an X-axis snap.
|
||||
const snaps = await page.evaluate(() => (window as any).__dv.snaps());
|
||||
expect(snaps.length).toBeGreaterThan(0);
|
||||
expect(snaps.some((s: any) => s.axes.includes('x'))).toBe(true);
|
||||
});
|
||||
|
||||
test('no guide while dragging away from every edge', async ({ page }) => {
|
||||
await setup(page);
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ export default defineConfig({
|
||||
baseURL: 'http://localhost:4321',
|
||||
headless: true,
|
||||
trace: 'on-first-retry',
|
||||
// Allow a pre-installed browser to be used when the CI image ships a
|
||||
// Chromium that does not match the version Playwright would download
|
||||
// (opt-in; no effect when the env var is unset).
|
||||
launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||
? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE }
|
||||
: undefined,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
webServer: {
|
||||
|
||||
Reference in New Issue
Block a user