mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ad116b10 | ||
|
|
b344a3c874 |
+3
-346
@@ -1,6 +1,6 @@
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
@@ -25,103 +25,6 @@ const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
|
||||
interface HorizontalInkBounds {
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
async function readSvgInkBounds(svgLocator: Locator): Promise<HorizontalInkBounds> {
|
||||
return svgLocator.evaluate((svg) => {
|
||||
const graphics = Array.from(svg.querySelectorAll<SVGGraphicsElement>("path, line, polyline"));
|
||||
const bounds = graphics.map((graphic) => {
|
||||
const box = graphic.getBBox();
|
||||
const matrix = graphic.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("SVG glyph has no screen transform");
|
||||
}
|
||||
const strokeInset = Number.parseFloat(getComputedStyle(graphic).strokeWidth) / 2 || 0;
|
||||
const corners = [
|
||||
new DOMPoint(box.x - strokeInset, box.y - strokeInset),
|
||||
new DOMPoint(box.x + box.width + strokeInset, box.y - strokeInset),
|
||||
new DOMPoint(box.x - strokeInset, box.y + box.height + strokeInset),
|
||||
new DOMPoint(box.x + box.width + strokeInset, box.y + box.height + strokeInset),
|
||||
].map((point) => point.matrixTransform(matrix));
|
||||
return {
|
||||
left: Math.min(...corners.map((point) => point.x)),
|
||||
right: Math.max(...corners.map((point) => point.x)),
|
||||
};
|
||||
});
|
||||
return {
|
||||
left: Math.min(...bounds.map((bound) => bound.left)),
|
||||
right: Math.max(...bounds.map((bound) => bound.right)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readTextInkBounds(
|
||||
container: Locator,
|
||||
edge: "first" | "last" = "first",
|
||||
): Promise<HorizontalInkBounds> {
|
||||
return container.evaluate((root, requestedEdge) => {
|
||||
const textElements = [root, ...Array.from(root.querySelectorAll("*"))].filter((element) =>
|
||||
Array.from(element.childNodes).some(
|
||||
(node) => node.nodeType === Node.TEXT_NODE && Boolean(node.textContent?.trim()),
|
||||
),
|
||||
);
|
||||
const element = textElements[requestedEdge === "first" ? 0 : textElements.length - 1];
|
||||
const text = Array.from(element.childNodes)
|
||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.textContent ?? "")
|
||||
.join("")
|
||||
.trim();
|
||||
const style = getComputedStyle(element);
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context || !text) {
|
||||
throw new Error("Text glyph could not be measured");
|
||||
}
|
||||
context.font = style.font;
|
||||
const metrics = context.measureText(text);
|
||||
const origin = element.getBoundingClientRect().left;
|
||||
return {
|
||||
left: origin - metrics.actualBoundingBoxLeft,
|
||||
right: origin + metrics.actualBoundingBoxRight,
|
||||
};
|
||||
}, edge);
|
||||
}
|
||||
|
||||
async function readScrollbarGutter(scrollContainer: Locator): Promise<number> {
|
||||
return scrollContainer.evaluate((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
return htmlElement.offsetWidth - htmlElement.clientWidth;
|
||||
});
|
||||
}
|
||||
|
||||
async function dragOverlayScrollbarDown(page: Page, scrollContainer: Locator): Promise<void> {
|
||||
const thumb = page.getByTestId("workspace-overlay-scrollbar-grab");
|
||||
const thumbBounds = await thumb.boundingBox();
|
||||
expect(thumbBounds).not.toBeNull();
|
||||
const initialOffset = await scrollContainer.evaluate((element) => element.scrollTop);
|
||||
await page.mouse.move(
|
||||
thumbBounds!.x + thumbBounds!.width / 2,
|
||||
thumbBounds!.y + thumbBounds!.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
thumbBounds!.x + thumbBounds!.width / 2,
|
||||
thumbBounds!.y + thumbBounds!.height / 2 + 40,
|
||||
{ steps: 4 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(() => scrollContainer.evaluate((element) => element.scrollTop))
|
||||
.toBeGreaterThan(initialOffset);
|
||||
}
|
||||
|
||||
function expectSameRail(actual: number, expected: number): void {
|
||||
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface UseMountedTabSetInput {
|
||||
@@ -298,28 +201,6 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 9);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expectDiffCodeFontSize(page, 9);
|
||||
await expectVisibleDiffRowsAligned(page);
|
||||
await expectDiffCodeTextAlignedWithGutterText(page, [
|
||||
{
|
||||
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
|
||||
lineNumber: "20",
|
||||
},
|
||||
{ codeText: "return next;", lineNumber: "55" },
|
||||
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
|
||||
]);
|
||||
await expectHoverCommentButtonAlignedWithCodeLine(page, {
|
||||
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
|
||||
lineNumber: "20",
|
||||
});
|
||||
});
|
||||
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -463,138 +344,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expectFlatFileList(page);
|
||||
});
|
||||
|
||||
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const diffScroll = page.getByTestId("git-diff-scroll");
|
||||
await diffScroll.evaluate((element) => {
|
||||
element.style.scrollbarGutter = "stable";
|
||||
});
|
||||
expect(await readScrollbarGutter(diffScroll)).toBe(0);
|
||||
const overlayScrollbarBounds = await page
|
||||
.getByTestId("workspace-overlay-scrollbar")
|
||||
.boundingBox();
|
||||
const overlayThumbBounds = await page
|
||||
.getByTestId("workspace-overlay-scrollbar-thumb")
|
||||
.boundingBox();
|
||||
expect(overlayScrollbarBounds?.width).toBe(8);
|
||||
expect(overlayThumbBounds?.width).toBe(4);
|
||||
const flatFileStat = await readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last");
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("diff-file-0-actions")).toHaveCount(0);
|
||||
|
||||
const folderRow = page.getByTestId("diff-folder-src-toggle");
|
||||
const fileRow = page.getByTestId("diff-file-0-toggle");
|
||||
await folderRow.hover();
|
||||
const folderHoverColor = await folderRow.evaluate((row) => getComputedStyle(row).backgroundColor);
|
||||
await fileRow.hover();
|
||||
const fileHoverColor = await fileRow.evaluate((row) => getComputedStyle(row).backgroundColor);
|
||||
expect(fileHoverColor).toBe(folderHoverColor);
|
||||
|
||||
const diffFolderName = folderRow.getByText("src", { exact: true });
|
||||
const diffFileName = fileRow.getByText("use-mounted-tab-set.ts", { exact: true });
|
||||
const diffFolderIconLocator = folderRow.locator("svg").first();
|
||||
const diffFileIconLocator = fileRow.locator("svg").first();
|
||||
const [diffFolderIcon, diffFileIcon, diffFolderNameBounds, diffFileNameBounds] =
|
||||
await Promise.all([
|
||||
readSvgInkBounds(diffFolderIconLocator),
|
||||
readSvgInkBounds(diffFileIconLocator),
|
||||
diffFolderName.boundingBox(),
|
||||
diffFileName.boundingBox(),
|
||||
]);
|
||||
expect(diffFolderNameBounds).not.toBeNull();
|
||||
expect(diffFileNameBounds).not.toBeNull();
|
||||
const diffTreeIconNameGap = diffFolderNameBounds!.x - diffFolderIcon.right;
|
||||
expectSameRail(diffTreeIconNameGap, diffFileNameBounds!.x - diffFileIcon.right);
|
||||
|
||||
const [folderStat, fileStat, optionsChevron, explorerCloseIcon] = await Promise.all([
|
||||
readTextInkBounds(page.getByTestId("diff-folder-src-stat"), "last"),
|
||||
readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last"),
|
||||
readSvgInkBounds(page.getByTestId("changes-options-menu").locator("svg")),
|
||||
readSvgInkBounds(page.getByTestId("explorer-close").locator("svg")),
|
||||
]);
|
||||
expectSameRail(folderStat.right, fileStat.right);
|
||||
expectSameRail(flatFileStat.right, fileStat.right);
|
||||
expectSameRail(fileStat.right, explorerCloseIcon.right);
|
||||
expectSameRail(fileStat.right, optionsChevron.right);
|
||||
expectSameRail(optionsChevron.right, explorerCloseIcon.right);
|
||||
await folderRow.click();
|
||||
await folderRow.click();
|
||||
await dragOverlayScrollbarDown(page, diffScroll);
|
||||
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
|
||||
const filesScroll = page.getByTestId("file-explorer-tree-scroll");
|
||||
await filesScroll.evaluate((element) => {
|
||||
element.style.scrollbarGutter = "stable";
|
||||
});
|
||||
expect(await readScrollbarGutter(filesScroll)).toBe(0);
|
||||
|
||||
await expect(page.getByTestId("file-explorer-row-0-actions")).toHaveCount(0);
|
||||
const fileExplorerRow = page.getByTestId("file-explorer-row-0");
|
||||
const fileExplorerRowBounds = await fileExplorerRow.boundingBox();
|
||||
expect(fileExplorerRowBounds).not.toBeNull();
|
||||
await fileExplorerRow.click({ button: "right", position: { x: 80, y: 10 } });
|
||||
await expect(page.getByTestId("file-explorer-row-0-context-menu")).toBeVisible();
|
||||
const fileMenuBounds = await page.getByTestId("file-explorer-row-0-context-menu").boundingBox();
|
||||
expect(fileMenuBounds).not.toBeNull();
|
||||
expect(fileMenuBounds!.x).toBeCloseTo(fileExplorerRowBounds!.x + 80, 0);
|
||||
expect(fileMenuBounds!.y).toBeGreaterThan(fileExplorerRowBounds!.y + 10);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const directoryRow = page.getByTestId(/^file-explorer-row-\d+$/).filter({ hasText: "src" });
|
||||
const directoryName = directoryRow.getByText("src", { exact: true });
|
||||
const [collapsedDirectoryIcon, collapsedDirectoryNameBounds] = await Promise.all([
|
||||
readSvgInkBounds(directoryRow.locator("svg").first()),
|
||||
directoryName.boundingBox(),
|
||||
]);
|
||||
expect(collapsedDirectoryNameBounds).not.toBeNull();
|
||||
|
||||
await directoryRow.click();
|
||||
const nestedFileRow = page
|
||||
.getByTestId(/^file-explorer-row-\d+$/)
|
||||
.filter({ hasText: "use-mounted-tab-set.ts" });
|
||||
await expect(nestedFileRow).toBeVisible();
|
||||
const [expandedDirectoryIcon, expandedDirectoryNameBounds, fileIcon, fileNameBounds] =
|
||||
await Promise.all([
|
||||
readSvgInkBounds(directoryRow.locator("svg").first()),
|
||||
directoryName.boundingBox(),
|
||||
readSvgInkBounds(nestedFileRow.locator("svg").first()),
|
||||
nestedFileRow.getByText("use-mounted-tab-set.ts", { exact: true }).boundingBox(),
|
||||
]);
|
||||
expect(expandedDirectoryNameBounds).not.toBeNull();
|
||||
expect(fileNameBounds).not.toBeNull();
|
||||
expect(expandedDirectoryNameBounds!.x).toBeCloseTo(collapsedDirectoryNameBounds!.x, 1);
|
||||
expectSameRail(
|
||||
collapsedDirectoryNameBounds!.x - collapsedDirectoryIcon.right,
|
||||
fileNameBounds!.x - fileIcon.right,
|
||||
);
|
||||
expectSameRail(
|
||||
expandedDirectoryNameBounds!.x - expandedDirectoryIcon.right,
|
||||
fileNameBounds!.x - fileIcon.right,
|
||||
);
|
||||
expectSameRail(diffTreeIconNameGap, fileNameBounds!.x - fileIcon.right);
|
||||
|
||||
const readmeRow = page.getByTestId(/^file-explorer-row-\d+$/).filter({ hasText: "README.md" });
|
||||
const [sortLabel, fileRowIcon, treeBounds, rowBounds] = await Promise.all([
|
||||
readTextInkBounds(page.getByTestId("files-sort-label")),
|
||||
readSvgInkBounds(readmeRow.locator("svg").first()),
|
||||
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").boundingBox(),
|
||||
]);
|
||||
expect(treeBounds).not.toBeNull();
|
||||
expect(rowBounds).not.toBeNull();
|
||||
expectSameRail(fileRowIcon.left, sortLabel.left);
|
||||
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
|
||||
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 12);
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -607,7 +357,6 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
|
||||
|
||||
await expectDiffCodeFontSize(page, 18);
|
||||
await expectVisibleDiffRowsShareTypography(page);
|
||||
await expectVisibleDiffRowsAligned(page);
|
||||
});
|
||||
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
@@ -668,24 +417,17 @@ async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<voi
|
||||
.toBe(fontSize);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
|
||||
const geometry = await readVisibleDiffRowGeometry(page);
|
||||
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
|
||||
const geometry = await readVisibleDiffRowGeometry(page);
|
||||
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
|
||||
}
|
||||
|
||||
async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
maxDelta: number;
|
||||
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
|
||||
rows: {
|
||||
index: number;
|
||||
gutterTop: number;
|
||||
codeTop: number;
|
||||
delta: number;
|
||||
gutterLineHeight: number;
|
||||
codeLineHeight: number;
|
||||
}[];
|
||||
@@ -720,7 +462,6 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
index: code.index,
|
||||
gutterTop: gutter.top,
|
||||
codeTop: code.top,
|
||||
delta: Math.abs(code.top - gutter.top),
|
||||
gutterLineHeight: gutter.lineHeight,
|
||||
codeLineHeight: code.lineHeight,
|
||||
};
|
||||
@@ -728,7 +469,6 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
|
||||
|
||||
return {
|
||||
maxDelta: Math.max(...rows.map((row) => row.delta)),
|
||||
mismatchedTypography: rows
|
||||
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
|
||||
.map((row) => ({
|
||||
@@ -748,7 +488,7 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
if (options.includeDeletedFile) {
|
||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||
}
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", { files });
|
||||
const repo = await createTempGitRepo("changes-pane-", { files });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
@@ -837,86 +577,3 @@ async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
|
||||
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectDiffCodeTextAlignedWithGutterText(
|
||||
page: Page,
|
||||
lines: { codeText: string; lineNumber: string }[],
|
||||
): Promise<void> {
|
||||
const geometries = await readDiffTextGeometry(page, lines);
|
||||
for (const geometry of geometries) {
|
||||
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHoverCommentButtonAlignedWithCodeLine(
|
||||
page: Page,
|
||||
line: { codeText: string; lineNumber: string },
|
||||
): Promise<void> {
|
||||
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
|
||||
if (!target) {
|
||||
throw new Error(`Could not find target line ${line.lineNumber}`);
|
||||
}
|
||||
await page.getByTestId(`diff-code-row-${target.index}`).hover();
|
||||
const geometry = await page
|
||||
.getByTestId(`diff-gutter-action-${target.index}`)
|
||||
.evaluate((action, expectedCodeCenterY) => {
|
||||
const rect = action.getBoundingClientRect();
|
||||
return {
|
||||
actionCenterY: rect.top + rect.height / 2,
|
||||
codeCenterY: expectedCodeCenterY,
|
||||
};
|
||||
}, target.codeCenterY);
|
||||
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
|
||||
}
|
||||
|
||||
async function readDiffTextGeometry(
|
||||
page: Page,
|
||||
lines: { codeText: string; lineNumber: string }[],
|
||||
): Promise<
|
||||
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
|
||||
> {
|
||||
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
|
||||
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
|
||||
if (!root) {
|
||||
throw new Error("Changes panel is not mounted");
|
||||
}
|
||||
|
||||
const readIndexedElements = (prefix: string) =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
|
||||
(element) => {
|
||||
const testId = element.getAttribute("data-testid") ?? "";
|
||||
return { index: Number(testId.slice(prefix.length)), element };
|
||||
},
|
||||
);
|
||||
|
||||
const gutterTexts = readIndexedElements("diff-gutter-text-");
|
||||
const codeTexts = readIndexedElements("diff-code-text-");
|
||||
|
||||
return targets.map((target) => {
|
||||
const gutter = gutterTexts.find(
|
||||
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
|
||||
);
|
||||
if (!gutter) {
|
||||
throw new Error(`Could not find gutter line ${target.lineNumber}`);
|
||||
}
|
||||
const code = codeTexts.find(
|
||||
({ index, element }) =>
|
||||
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
|
||||
);
|
||||
if (!code) {
|
||||
throw new Error(`Could not find code row ${target.codeText}`);
|
||||
}
|
||||
|
||||
const codeRect = code.element.getBoundingClientRect();
|
||||
const gutterRect = gutter.element.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
index: gutter.index,
|
||||
codeText: target.codeText,
|
||||
codeTop: codeRect.top,
|
||||
gutterTop: gutterRect.top,
|
||||
codeCenterY: codeRect.top + codeRect.height / 2,
|
||||
};
|
||||
});
|
||||
}, lines);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
expectActiveChatOutlinePrompt,
|
||||
expectChatOutlinePreview,
|
||||
expectChatOutlinePrompts,
|
||||
expectChatOutlineAlignedWithActiveTabGlyph,
|
||||
expectChatOutlinePromptToRemainBare,
|
||||
expectLiveTurnPromptAboveFoldAndActive,
|
||||
expectNoChatOutlinePreviewWhileCrossingToSidebar,
|
||||
@@ -140,10 +139,6 @@ test.describe("desktop chat outline", () => {
|
||||
await expectChatOutlinePromptToRemainBare(page, 4);
|
||||
});
|
||||
|
||||
test("aligns the ticks with the active tab glyph rail", async ({ page }) => {
|
||||
await expectChatOutlineAlignedWithActiveTabGlyph(page);
|
||||
});
|
||||
|
||||
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -79,6 +79,7 @@ interface WorkspaceStatusGroupEvent {
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
addOfflineHostAndReload,
|
||||
expectHostFilterRow,
|
||||
openSidebarDisplayPreferences,
|
||||
openSidebarHostFilter,
|
||||
selectAllHostsFilter,
|
||||
toggleHostFilter,
|
||||
} from "../support/helpers/hosts";
|
||||
@@ -29,7 +29,7 @@ test.describe("Sidebar host filter (multi-select)", () => {
|
||||
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await openSidebarHostFilter(page);
|
||||
await expectHostFilterRow(page, serverId);
|
||||
await expectHostFilterRow(page, SECONDARY_HOST_ID);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
@@ -8,29 +7,6 @@ import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
async function paintedSvgRight(locator: Locator): Promise<number> {
|
||||
return locator.evaluateAll((svgs) => {
|
||||
let right = Number.NEGATIVE_INFINITY;
|
||||
for (const element of svgs) {
|
||||
const svg = element as SVGSVGElement;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const viewBox = svg.viewBox.baseVal;
|
||||
if (svgRect.right <= 0 || svgRect.left >= window.innerWidth || viewBox.width === 0) continue;
|
||||
const scaleX = svgRect.width / viewBox.width;
|
||||
for (const child of svg.querySelectorAll<SVGGraphicsElement>(
|
||||
"path, circle, ellipse, line, polyline, polygon, rect",
|
||||
)) {
|
||||
const box = child.getBBox();
|
||||
const strokeWidth = Number.parseFloat(getComputedStyle(child).strokeWidth) || 0;
|
||||
const localRight = box.x + box.width + strokeWidth / 2;
|
||||
right = Math.max(right, svgRect.left + (localRight - viewBox.x) * scaleX);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(right)) throw new Error("SVG has no measurable painted children");
|
||||
return right;
|
||||
});
|
||||
}
|
||||
|
||||
async function closeMenuSheet(page: Page): Promise<void> {
|
||||
const backdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
await backdrop.click({ position: { x: 12, y: 12 } });
|
||||
@@ -45,12 +21,6 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const closeGlyphRight = await paintedSvgRight(page.getByTestId("sidebar-close").locator("svg"));
|
||||
const trailingRail = await paintedSvgRight(
|
||||
page.getByTestId("sidebar-display-preferences-menu").locator("svg"),
|
||||
);
|
||||
expect(closeGlyphRight).toBeCloseTo(trailingRail, 0);
|
||||
|
||||
await page
|
||||
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
|
||||
.click();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher";
|
||||
@@ -26,35 +25,6 @@ function projectNewWorktreeIcon(page: Page, projectKey: string) {
|
||||
return page.getByTestId(`sidebar-project-new-worktree-${projectEquivalenceViewKey(projectKey)}`);
|
||||
}
|
||||
|
||||
async function paintedSvgRight(locator: Locator): Promise<number> {
|
||||
return locator.evaluate((svg) => {
|
||||
let right = Number.NEGATIVE_INFINITY;
|
||||
for (const child of svg.querySelectorAll<SVGGraphicsElement>(
|
||||
"path, circle, ellipse, line, polyline, polygon, rect",
|
||||
)) {
|
||||
const box = child.getBBox();
|
||||
const matrix = child.getScreenCTM();
|
||||
if (!matrix) continue;
|
||||
const geometryRight = new DOMPoint(box.x + box.width, box.y).matrixTransform(matrix).x;
|
||||
const strokeWidth = Number.parseFloat(getComputedStyle(child).strokeWidth) || 0;
|
||||
const scaleX = Math.hypot(matrix.a, matrix.b);
|
||||
right = Math.max(right, geometryRight + (strokeWidth * scaleX) / 2);
|
||||
}
|
||||
if (!Number.isFinite(right)) throw new Error("SVG has no measurable painted children");
|
||||
return right;
|
||||
});
|
||||
}
|
||||
|
||||
async function paintedTextRight(locator: Locator): Promise<number> {
|
||||
return locator.evaluate((label) => {
|
||||
const context = document.createElement("canvas").getContext("2d");
|
||||
if (!context) throw new Error("Canvas text measurement is unavailable");
|
||||
context.font = getComputedStyle(label).font;
|
||||
const metrics = context.measureText(label.textContent ?? "");
|
||||
return label.getBoundingClientRect().left + metrics.actualBoundingBoxRight;
|
||||
});
|
||||
}
|
||||
|
||||
async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> {
|
||||
const created = await seeded.client.createWorkspace({
|
||||
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
|
||||
@@ -116,53 +86,6 @@ test.describe("Model B sidebar shape", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar trailing glyphs share the workspace content rail", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "model-b-trailing-rail-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-show").click();
|
||||
await page.getByTestId("sidebar-workspace-trailing-timestamp").click();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const project = projectRow(page, seeded.projectKey);
|
||||
const workspace = workspaceRow(page, seeded.workspaceId);
|
||||
const displayPreferencesGlyph = page
|
||||
.getByTestId("sidebar-display-preferences-menu")
|
||||
.locator("svg");
|
||||
|
||||
await project.hover();
|
||||
const projectKebabGlyph = page
|
||||
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
|
||||
.locator("svg");
|
||||
await expect(projectKebabGlyph).toBeVisible();
|
||||
const projectKebabRight = await paintedSvgRight(projectKebabGlyph);
|
||||
|
||||
await workspace.hover();
|
||||
const workspaceKebabGlyph = page
|
||||
.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${seeded.workspaceId}`)
|
||||
.locator("svg");
|
||||
const timestamp = workspace.getByTestId("sidebar-workspace-timestamp");
|
||||
await expect(workspaceKebabGlyph).toBeVisible();
|
||||
await expect(timestamp).toBeVisible();
|
||||
|
||||
const rail = await paintedTextRight(timestamp);
|
||||
for (const glyphRight of await Promise.all([
|
||||
paintedSvgRight(displayPreferencesGlyph),
|
||||
Promise.resolve(projectKebabRight),
|
||||
paintedSvgRight(workspaceKebabGlyph),
|
||||
])) {
|
||||
expect(glyphRight).toBeCloseTo(rail, 0);
|
||||
}
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("no tab, agent, or terminal ever renders as a sidebar row", async ({ page }) => {
|
||||
const mock = await seedMockAgentWorkspace({
|
||||
repoPrefix: "model-b-leaf-",
|
||||
@@ -210,6 +133,7 @@ test.describe("Model B sidebar shape", () => {
|
||||
|
||||
// Switch to status grouping.
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
|
||||
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
|
||||
|
||||
@@ -40,6 +40,7 @@ async function collapseProjectSection(page: Page, project: SeededWorkspace): Pro
|
||||
|
||||
async function switchToStatusGrouping(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -228,19 +227,18 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
|
||||
|
||||
try {
|
||||
// Pinning behavior has its own E2E suite. Seed this test's precondition directly so it only
|
||||
// exercises whether the retained compact sidebar keeps the pinned row mounted while closed.
|
||||
await workspace.client.setWorkspacePinned(workspace.workspaceId, true);
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
|
||||
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -398,6 +398,7 @@ async function expectWorkspaceRowInStatusBucket(
|
||||
input: { serverId: string; workspaceId: string; bucket: string },
|
||||
) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -125,26 +125,6 @@ export async function expectChatOutlinePromptToRemainBare(
|
||||
await expect(chatOutlinePrompt(page, position)).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
|
||||
export async function expectChatOutlineAlignedWithActiveTabGlyph(page: Page): Promise<void> {
|
||||
const outlinePill = chatOutlinePrompt(page, 1).locator("div").first();
|
||||
const activeTabGlyph = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true })
|
||||
.locator('[role="button"][aria-selected="true"]')
|
||||
.locator("svg")
|
||||
.first();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [pillBox, glyphBox] = await Promise.all([
|
||||
requireBoundingBox(outlinePill),
|
||||
requireBoundingBox(activeTabGlyph),
|
||||
]);
|
||||
return Math.abs(pillBox.x - glyphBox.x);
|
||||
})
|
||||
.toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
/** Exactly one prompt is marked, without saying which — the reader always has a "you are here". */
|
||||
export async function expectOneActiveChatOutlinePrompt(page: Page): Promise<void> {
|
||||
await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveCount(1);
|
||||
|
||||
@@ -123,11 +123,12 @@ export async function waitForConnectedHost(
|
||||
await expect(host).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
export async function openSidebarHostFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.getByTestId("sidebar-display-host-filter").click();
|
||||
}
|
||||
|
||||
// A host's filter row carries a status dot on the left next to its label.
|
||||
@@ -144,32 +145,6 @@ export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-host-filter-all").click();
|
||||
}
|
||||
|
||||
// Playwright reports resolved colors, so the expected value is derived from the same identity
|
||||
// table the app reads. A hex literal in the test would be a second copy of the palette.
|
||||
function toRgb(hex: string): string {
|
||||
const { r, g, b } = parseHex(hex);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function toRgba(hex: string, alpha: number): string {
|
||||
const { r, g, b } = parseHex(hex);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
interface RgbChannels {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
function parseHex(hex: string): RgbChannels {
|
||||
return {
|
||||
r: Number.parseInt(hex.slice(1, 3), 16),
|
||||
g: Number.parseInt(hex.slice(3, 5), 16),
|
||||
b: Number.parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export async function openHostAppearanceSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, serverId);
|
||||
@@ -248,11 +223,6 @@ export async function expectHostBadgeTinted(
|
||||
): Promise<void> {
|
||||
const badge = hostBadge(page, target);
|
||||
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||
await expect(badge.getByText(target.hostName)).toHaveCSS(
|
||||
"color",
|
||||
toRgb(identityColor(target.color)),
|
||||
);
|
||||
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(target.color), 0.1));
|
||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
|
||||
}
|
||||
|
||||
@@ -264,11 +234,6 @@ export async function expectHostAppearancePreview(
|
||||
await expect(preview).toBeVisible();
|
||||
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
||||
await expect(badge).toHaveText(input.hostName);
|
||||
await expect(badge.getByText(input.hostName)).toHaveCSS(
|
||||
"color",
|
||||
toRgb(identityColor(input.color)),
|
||||
);
|
||||
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(input.color), 0.1));
|
||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface SeedDaemonClient {
|
||||
}>;
|
||||
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
||||
renameProject(projectId: string, customName: string | null): Promise<void>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export interface Rails {
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
interface Box {
|
||||
x: number;
|
||||
@@ -18,30 +13,6 @@ async function readBox(locator: Locator): Promise<Box> {
|
||||
return box as Box;
|
||||
}
|
||||
|
||||
/** Leading and trailing edges of an element, in whole pixels. */
|
||||
export async function readRails(locator: Locator): Promise<Rails> {
|
||||
const box = await readBox(locator);
|
||||
return { left: Math.round(box.x), right: Math.round(box.x + box.width) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The two rails an AdaptiveModalSheet header indents to: the title's leading
|
||||
* glyph and the close button's trailing edge. Sheet content sits on the same
|
||||
* two rails, in every presentation.
|
||||
*/
|
||||
export async function readSheetHeaderRails(
|
||||
page: Page,
|
||||
sheetTestID: string,
|
||||
title: string,
|
||||
): Promise<Rails> {
|
||||
const header = page.getByTestId(sheetTestID);
|
||||
const [titleRails, closeRails] = await Promise.all([
|
||||
readRails(header.getByText(title, { exact: true })),
|
||||
readRails(header.getByRole("button", { name: "Close" })),
|
||||
]);
|
||||
return { left: titleRails.left, right: closeRails.right };
|
||||
}
|
||||
|
||||
/** Vertical space between the bottom of one element and the top of the next. */
|
||||
export async function readVerticalGap(upper: Locator, lower: Locator): Promise<number> {
|
||||
const [above, below] = await Promise.all([readBox(upper), readBox(lower)]);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell } from "../../app/e2e/support/helpers/app";
|
||||
import {
|
||||
readRails,
|
||||
readSheetHeaderRails,
|
||||
readVerticalGap,
|
||||
waitForSettledPosition,
|
||||
} from "../../app/e2e/support/helpers/sheet-layout";
|
||||
@@ -40,10 +38,6 @@ async function openChooseSkillsOnDesktop(page: Page): Promise<void> {
|
||||
await openSkillSelection(page);
|
||||
}
|
||||
|
||||
function sheetHeaderRails(page: Page) {
|
||||
return readSheetHeaderRails(page, "skill-selection-sheet", "Choose skills");
|
||||
}
|
||||
|
||||
function allSkillsCard(page: Page) {
|
||||
return page.getByTestId("skill-selection-all-card");
|
||||
}
|
||||
@@ -59,15 +53,6 @@ function bundledSkillsLabel(page: Page) {
|
||||
test.describe("Choose skills sheet on a compact form factor", () => {
|
||||
test.use({ viewport: COMPACT_VIEWPORT });
|
||||
|
||||
test("keeps its cards on the sheet header rails", async ({ page }) => {
|
||||
await openChooseSkillsOnPhone(page);
|
||||
|
||||
const header = await sheetHeaderRails(page);
|
||||
|
||||
expect(await readRails(allSkillsCard(page))).toEqual(header);
|
||||
expect(await readRails(skillListCard(page))).toEqual(header);
|
||||
});
|
||||
|
||||
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
|
||||
await openChooseSkillsOnPhone(page);
|
||||
|
||||
@@ -81,15 +66,6 @@ test.describe("Choose skills sheet on a compact form factor", () => {
|
||||
test.describe("Choose skills sheet on a desktop form factor", () => {
|
||||
test.use({ viewport: DESKTOP_VIEWPORT });
|
||||
|
||||
test("keeps its cards on the sheet header rails", async ({ page }) => {
|
||||
await openChooseSkillsOnDesktop(page);
|
||||
|
||||
const header = await sheetHeaderRails(page);
|
||||
|
||||
expect(await readRails(allSkillsCard(page))).toEqual(header);
|
||||
expect(await readRails(skillListCard(page))).toEqual(header);
|
||||
});
|
||||
|
||||
test("gives the Bundled skills label more room above it than below", async ({ page }) => {
|
||||
await openChooseSkillsOnDesktop(page);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user