229 lines
9.2 KiB
TypeScript
229 lines
9.2 KiB
TypeScript
import { expect, type Page } from "@playwright/test";
|
|
import type { SeedData } from "../fixtures/test-base";
|
|
import type { ApiClient } from "../helpers/api-client";
|
|
import { SessionPage } from "../pages/session-page";
|
|
|
|
/** Bounding-box info Playwright returns. Re-declared to avoid pulling the
|
|
* full Locator type just for one shape. */
|
|
type Box = { x: number; y: number; width: number; height: number };
|
|
|
|
export const WIDE_VIEWPORT = { width: 1600, height: 900 };
|
|
|
|
/** Open a fresh task at the given (or wide-default) viewport and wait for
|
|
* the desktop dockview layout to settle. Shared by every pane-resize spec. */
|
|
export async function openWideTask(
|
|
page: Page,
|
|
apiClient: ApiClient,
|
|
seedData: SeedData,
|
|
title: string,
|
|
viewport: { width: number; height: number } = WIDE_VIEWPORT,
|
|
): Promise<SessionPage> {
|
|
await page.setViewportSize(viewport);
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
title,
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
await page.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(page);
|
|
await session.waitForLoad();
|
|
await session.waitForDockviewReady();
|
|
return session;
|
|
}
|
|
|
|
/** Read the live pixel width of a dockview group containing a given panel. */
|
|
export async function getDockviewGroupWidth(page: Page, panelId: string): Promise<number> {
|
|
return page.evaluate((id) => {
|
|
type Group = { width: number };
|
|
type Panel = { group: Group };
|
|
type Api = { getPanel: (id: string) => Panel | undefined };
|
|
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
|
|
if (!api) throw new Error("dockview api not exposed");
|
|
const pnl = api.getPanel(id);
|
|
if (!pnl) throw new Error(`panel ${id} not found`);
|
|
return pnl.group.width;
|
|
}, panelId);
|
|
}
|
|
|
|
/** Read the live pixel width of a dockview group by group ID. */
|
|
export async function getDockviewGroupWidthById(page: Page, groupId: string): Promise<number> {
|
|
return page.evaluate((id) => {
|
|
type Group = { id: string; width: number };
|
|
type Api = { groups: Group[] };
|
|
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
|
|
if (!api) throw new Error("dockview api not exposed");
|
|
const g = api.groups.find((grp) => grp.id === id);
|
|
if (!g) throw new Error(`group ${id} not found`);
|
|
return g.width;
|
|
}, groupId);
|
|
}
|
|
|
|
async function sashBoxAt(page: Page, index: number): Promise<Box> {
|
|
const sashes = page.locator(".dv-sash");
|
|
const count = await sashes.count();
|
|
if (count === 0) throw new Error("no .dv-sash elements found");
|
|
if (index >= count) {
|
|
throw new Error(`sash index ${index} out of range (${count} sashes)`);
|
|
}
|
|
const box = await sashes.nth(index).boundingBox();
|
|
if (!box) throw new Error(`sash ${index} has no bounding box`);
|
|
return box;
|
|
}
|
|
|
|
/**
|
|
* Drag a horizontal-direction sash (between two columns) by deltaX pixels.
|
|
* sashIndex is the dockview sash order (0 = left-most). Reserved for tests
|
|
* that exercise real pointer motion (double-click smoke tests, etc.); most
|
|
* resize tests should prefer {@link resizeColumnViaSplitview} for stability
|
|
* in headless CI.
|
|
*/
|
|
export async function dragHorizontalSash(
|
|
page: Page,
|
|
sashIndex: number,
|
|
deltaX: number,
|
|
steps = 20,
|
|
): Promise<void> {
|
|
const box = await sashBoxAt(page, sashIndex);
|
|
const cx = box.x + box.width / 2;
|
|
const cy = box.y + box.height / 2;
|
|
await page.mouse.move(cx, cy);
|
|
await page.mouse.down();
|
|
await page.mouse.move(cx + deltaX, cy, { steps });
|
|
await page.mouse.up();
|
|
// Give the debounced layout-save 350ms to fire so subsequent reload assertions
|
|
// see the new width.
|
|
await page.waitForTimeout(400);
|
|
}
|
|
|
|
/**
|
|
* Programmatically resize a column via dockview's internal splitview API.
|
|
*
|
|
* `targetWidth` is the desired width in pixels; dockview clamps it against
|
|
* the constraints applied by `setConstraints`, so the returned actual width
|
|
* reflects what was permitted (cap-enforcement is what we want to verify).
|
|
*
|
|
* Avoids the flakiness of real pointer motion in headless CI — `page.mouse`
|
|
* drags are sensitive to viewport-overlap and pointer-events targeting,
|
|
* which produce intermittent failures across sharded browser instances.
|
|
*/
|
|
async function loosenPinnedConstraints(
|
|
page: Page,
|
|
column: "sidebar" | "right",
|
|
runtimeCap: number,
|
|
): Promise<void> {
|
|
await page.evaluate(
|
|
({ col, cap }) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const api = (window as any).__dockviewApi__;
|
|
if (!api) return;
|
|
const constraint = { maximumWidth: cap, minimumWidth: 50 };
|
|
if (col === "sidebar") {
|
|
api.getPanel("sidebar")?.group.api.setConstraints(constraint);
|
|
return;
|
|
}
|
|
const r = api.getPanel("files") ?? api.getPanel("changes");
|
|
r?.group.api.setConstraints(constraint);
|
|
for (const gid of ["group-right-top", "group-right-bottom"]) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
api.groups.find((g: any) => g.id === gid)?.api.setConstraints(constraint);
|
|
}
|
|
},
|
|
{ col: column, cap: runtimeCap },
|
|
);
|
|
}
|
|
|
|
export async function resizeColumnViaSplitview(
|
|
page: Page,
|
|
column: "sidebar" | "right",
|
|
targetWidth: number,
|
|
): Promise<number> {
|
|
// Production locks pinned-column maxWidth to the current width to prevent
|
|
// dockview's proportional rebalance from growing them. Real users bypass
|
|
// that lock via the sash-drag handler (mousedown widens the cap to the
|
|
// runtime viewport-proportional cap). Tests simulate the same widening —
|
|
// to the same runtime cap, NOT unlimited — so cap-enforcement assertions
|
|
// still work end-to-end. The bound is computed in Node and passed in so
|
|
// the browser-side script stays simple enough to satisfy the linter.
|
|
const viewport = page.viewportSize();
|
|
const vw = viewport?.width ?? 1440;
|
|
// Mirror `caps.ts` exactly: `viewportBound(value, vw)` clamps the ratio-based
|
|
// cap to `vw - VIEWPORT_RESERVE_PX (300)` so the center column always has
|
|
// room. Without this the test helper diverges from production on narrow
|
|
// viewports (e.g. vw=900 → sidebar cap would be 350 here but 300 in app).
|
|
const PINNED_MIN = 180;
|
|
const VIEWPORT_RESERVE = 300;
|
|
const rawCap =
|
|
column === "sidebar"
|
|
? Math.max(350, Math.round(vw * 0.3))
|
|
: Math.max(800, Math.round(vw * 0.7));
|
|
const runtimeCap = Math.max(PINNED_MIN, Math.min(rawCap, vw - VIEWPORT_RESERVE));
|
|
await loosenPinnedConstraints(page, column, runtimeCap);
|
|
const result = await page.evaluate(
|
|
({ col, target }) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const w = window as any;
|
|
const api = w.__dockviewApi__;
|
|
const sv = api?.component?.gridview?.root?.splitview;
|
|
if (!api || !sv) throw new Error("dockview splitview not exposed");
|
|
if (sv.length < 2) throw new Error("dockview has fewer than 2 columns");
|
|
if (col === "sidebar" && !api.getPanel("sidebar")) {
|
|
throw new Error("cannot resize sidebar when hidden (index 0 is center column)");
|
|
}
|
|
const idx = col === "sidebar" ? 0 : sv.length - 1;
|
|
sv.resizeView(idx, target);
|
|
const actual = sv.getViewSize(idx) as number;
|
|
// Mirror the production sash-drag mouseup behavior: update the pinned
|
|
// target so `enforcePinnedTargets` doesn't restore the old size on the
|
|
// next layout-change tick.
|
|
if (typeof w.__setPinnedTarget__ === "function") {
|
|
w.__setPinnedTarget__(col, actual);
|
|
}
|
|
// The sidebar width is a global pref persisted on a real drag's mouseup.
|
|
// Mirror that so cross-task / reload assertions see the same width.
|
|
if (col === "sidebar" && typeof w.__setGlobalSidebarWidth__ === "function") {
|
|
w.__setGlobalSidebarWidth__(actual);
|
|
}
|
|
// `sv.resizeView` alone does not fire `onDidLayoutChange` in dockview
|
|
// 4.x — without that, the debounced layout-persistence handler never
|
|
// saves the new width. Call the exposed test helper to force-flush the
|
|
// current layout into storage so reload assertions see the new state.
|
|
if (typeof w.__persistDockviewLayout__ === "function") {
|
|
w.__persistDockviewLayout__();
|
|
}
|
|
return actual;
|
|
},
|
|
{ col: column, target: targetWidth },
|
|
);
|
|
// Allow the debounced layout persistence to fire.
|
|
await page.waitForTimeout(400);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Return the index of the sash bordering the sidebar / right column.
|
|
* - sidebar sash: between groups[0] and groups[1] (= sash 0)
|
|
* - right sash: between groups[N-2] and groups[N-1] (= last sash)
|
|
*/
|
|
export async function getColumnSashIndex(page: Page, column: "sidebar" | "right"): Promise<number> {
|
|
if (column === "sidebar") return 0;
|
|
return page.evaluate(() => {
|
|
const count = document.querySelectorAll(".dv-sash").length;
|
|
if (count === 0) throw new Error("no .dv-sash elements found");
|
|
return count - 1;
|
|
});
|
|
}
|
|
|
|
/** Expect a width to be approximately equal (±slack px) to a target. */
|
|
export function expectApproxWidth(actual: number, target: number, slack = 8): void {
|
|
expect(
|
|
Math.abs(actual - target) <= slack,
|
|
`width ${actual} not within ±${slack} of target ${target}`,
|
|
).toBe(true);
|
|
}
|