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:
Claude
2026-07-05 22:26:23 +00:00
parent 2e151994d0
commit dc06e3c233
8 changed files with 374 additions and 5 deletions
+56 -5
View File
@@ -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.
+18
View File
@@ -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,
}) => {
+59
View File
@@ -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);
});
});
+39
View File
@@ -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');
});
});
+88
View File
@@ -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);
});
});
+79
View File
@@ -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);
});
});
+29
View File
@@ -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);
+6
View File
@@ -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: {