Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/issue-evidence/pr-865/desktop-shell-comparison.html

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3603,6 +3603,11 @@ it("shows Pi's queued messages above the composer after reload and in a backgrou
expect(queued.querySelector("ol")).toBeNull();
expect(queued.textContent).toContain("First follow-up");
expect(queued.textContent).toContain(i18n.t("queuedImage"));
const summary = screen.getByRole("button", {
name: i18n.t("pendingFollowUpsHint", { count: 2 }),
});
expect(summary.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(summary);
expect(
screen.getByRole("button", {
name: `${i18n.t("queuedMessageExpand")}: ${i18n.t("queuedImage")}`,
Expand Down
5 changes: 5 additions & 0 deletions tests/web/composer-drafts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ it("shows steering and next-turn queues from exact Session execution facts", ()
name: `${i18n.t("queuedMessageExpand")}: use this tool`,
}),
).toBeDefined();
fireEvent.click(
screen.getByRole("button", {
name: i18n.t("pendingFollowUpsHint", { count: 1 }),
}),
);
expect(
screen.getByRole("button", {
name: `${i18n.t("queuedMessageExpand")}: next task`,
Expand Down
10 changes: 8 additions & 2 deletions tests/web/composer-file-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,27 @@ function open(index = 0) {
fireEvent.click(trigger);
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
expect(dialog.open).toBe(true);
expect(document.activeElement).toBe(dialog);
expect(document.activeElement).toBe(
screen.getByRole("button", { name: i18n.t("closePreview") }),
);
return { trigger, dialog };
}
function cancel(dialog: HTMLDialogElement) {
fireEvent(dialog, new Event("cancel", { cancelable: true }));
}

it.each(["cancel", "escape", "backdrop"])(
it.each(["cancel", "escape", "backdrop", "button"])(
"closes with %s and returns focus to the same owner's file chip",
(gesture) => {
const view = setup();
const { trigger, dialog } = open();
expect(dialog.textContent).toContain("Preview file-a");
if (gesture === "cancel") cancel(dialog);
else if (gesture === "escape") fireEvent.keyDown(dialog, { key: "Escape" });
else if (gesture === "button")
fireEvent.click(
screen.getByRole("button", { name: i18n.t("closePreview") }),
);
else fireEvent.click(dialog);
expect(dialog.open).toBe(false);
expect(document.activeElement).toBe(trigger);
Expand Down
97 changes: 97 additions & 0 deletions tests/web/composer-image-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { I18nextProvider } from "react-i18next";
import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest";
import type { WebSnapshot } from "../../web/protocol/types.ts";
import { Composer } from "../../web/ui/src/features/composer/Composer.tsx";
import { ImageViewport } from "../../web/ui/src/components/ImageViewport.tsx";
import { i18n } from "../../web/ui/src/i18n.ts";
import { WebClient } from "../../web/ui/src/protocol/client.ts";
import { createWebStore } from "../../web/ui/src/store/web-store.ts";
Expand Down Expand Up @@ -269,6 +270,102 @@ it.each(["same", "another"])(
},
);

it("navigates draft images in the preview and returns focus to its opening attachment", async () => {
const { view } = setup();
await addImage(view);
await addImage(view, "other.png");
const { trigger, dialog } = openPreview();
fireEvent.click(
screen.getByRole("button", { name: i18n.t("originalImageSize") }),
);
fireEvent.click(screen.getByRole("button", { name: i18n.t("nextImage") }));
expect(dialog.querySelector("img")?.alt).toBe("other.png");
expect(
screen.getByRole("region", { name: i18n.t("imageViewMode") }).dataset.mode,
).toBe("fit");
fireEvent.keyDown(
screen.getByRole("region", { name: i18n.t("imageViewMode") }),
{ key: "ArrowLeft" },
);
expect(dialog.querySelector("img")?.alt).toBe("screenshot.png");
fireEvent.click(
screen.getByRole("button", { name: i18n.t("closeImagePreview") }),
);
expect(document.activeElement).toBe(trigger);
});

it("keeps image zoom anchored, bounds panning, and restores a cancelled drag", () => {
const view = render(
createElement(
I18nextProvider,
{ i18n },
createElement(
ImageViewport,
{ original: false },
createElement("img", {
src: `data:image/png;base64,${png}`,
alt: "Preview",
}),
),
),
);
const viewport = screen.getByRole("region", {
name: i18n.t("imageViewMode"),
});
const layer = view.container.querySelector<HTMLElement>(".image-zoom-layer")!;
const image = screen.getByAltText("Preview");
Object.defineProperties(image, {
offsetWidth: { value: 600 },
offsetHeight: { value: 400 },
naturalWidth: { value: 1800 },
});
Object.defineProperties(layer, {
clientWidth: { value: 600 },
clientHeight: { value: 400 },
});
vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue(
new DOMRect(0, 0, 600, 400),
);
const capture = vi.fn();
Object.assign(viewport, {
setPointerCapture: capture,
hasPointerCapture: () => true,
releasePointerCapture: vi.fn(),
});
fireEvent.load(image);
fireEvent.doubleClick(image, { clientX: 450, clientY: 200 });
expect(layer.style.transform).toBe("translate(-150px, 0px) scale(2)");
const pointer = (type: string, x: number, y: number) => {
const event = new MouseEvent(type, {
bubbles: true,
clientX: x,
clientY: y,
button: 0,
});
Object.defineProperty(event, "pointerId", { value: 1 });
fireEvent(image, event);
};
pointer("pointerdown", 100, 100);
pointer("pointermove", 102, 100);
expect(layer.style.transform).toBe("translate(-150px, 0px) scale(2)");
pointer("pointermove", 2000, 2000);
expect(layer.style.transform).toBe("translate(300px, 200px) scale(2)");
fireEvent.keyDown(viewport, { key: "Escape" });
expect(layer.style.transform).toBe("translate(-150px, 0px) scale(2)");
expect(capture).toHaveBeenCalledWith(1);
const browserZoom = new KeyboardEvent("keydown", {
key: "0",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
fireEvent(viewport, browserZoom);
expect(browserZoom.defaultPrevented).toBe(false);
expect(layer.style.transform).toBe("translate(-150px, 0px) scale(2)");
fireEvent.keyDown(viewport, { key: "0" });
expect(layer.style.transform).toBe("translate(0px, 0px) scale(1)");
});

it("keeps decode failure and close focus intact in original-size mode", async () => {
const { view } = setup();
await addImage(view);
Expand Down
6 changes: 3 additions & 3 deletions tests/web/conversation-reading.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,8 @@ for (const touch of [false, true]) {
await expect(summary).toHaveAccessibleName(/2.*工具调用/);
if (!touch) {
await page.mouse.move(0, 0);
await expect(counts).toHaveCSS("opacity", "0");
await expect(arrow).toHaveCSS("opacity", "0");
await expect(counts).toHaveCSS("opacity", "1");
await expect(arrow).toHaveCSS("opacity", "1");
await page.evaluate(() => document.fonts.ready);
const shell = page.locator(".app-shell");
// Observe the saved width before waiting, so a not-yet-started startup
Expand All @@ -457,7 +457,7 @@ for (const touch of [false, true]) {
await expect(arrow).toHaveCSS("opacity", "1");
expect(await summary.boundingBox()).toEqual(before);
await page.mouse.move(0, 0);
await expect(counts).toHaveCSS("opacity", "0");
await expect(counts).toHaveCSS("opacity", "1");
await page.keyboard.press("Tab");
await summary.focus();
await expect(counts).toHaveCSS("opacity", "1");
Expand Down
11 changes: 8 additions & 3 deletions tests/web/diff-line-feedback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ it("does not invent actionable line numbers for malformed hunk tails, binary fil
{ id: 2, kind: "added", newLine: 5, marker: "+", code: "new" },
]);
const reference = vi.fn();
const { rerender } = render(
const { rerender, container } = render(
createElement(
I18nextProvider,
{ i18n },
Expand All @@ -190,7 +190,9 @@ it("does not invent actionable line numbers for malformed hunk tails, binary fil
}),
),
);
expect(screen.queryByRole("button")).toBeNull();
expect(container.querySelectorAll(".review-diff-line-select")).toHaveLength(
0,
);
rerender(
createElement(
I18nextProvider,
Expand All @@ -201,5 +203,8 @@ it("does not invent actionable line numbers for malformed hunk tails, binary fil
}),
),
);
expect(screen.queryByRole("button")).toBeNull();
expect(container.querySelectorAll(".review-diff-line-select")).toHaveLength(
0,
);
expect(reference).not.toHaveBeenCalled();
});
30 changes: 23 additions & 7 deletions tests/web/openpi-web.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,7 @@ test("side conversation shows one editor in narrow layouts and preserves both dr
const workbar = await openWorkbarTool(page, "侧边对话");
const sideInput = workbar.getByPlaceholder("提出一个侧边问题…");
await sideInput.fill("Keep the side conversation draft");
const mainForm = mainInput.locator("..");
const mainForm = page.locator("form").filter({ has: mainInput });
const sideForm = workbar.locator(".side-conversation-composer");
await page.addStyleTag({
content: ".composer { transition: none !important; }",
Expand Down Expand Up @@ -1878,7 +1878,7 @@ for (const theme of ["light", "dark"]) {
const expand = page.getByRole("button", { name: "展开侧边栏" });
await expect(expand).toBeVisible();
await expect(create).toBeVisible();
await expect(create).toHaveAttribute("title", "新建会话");
await expect(create).toHaveAccessibleDescription("新建会话");
await expect(current).toBeHidden();
await expect(archived).toBeVisible();
await expect(
Expand Down Expand Up @@ -2047,6 +2047,19 @@ test("discovers and completes Pi commands without submitting unsupported command
await expect(unavailableOption).toBeDisabled();
await expect(unavailableOption).toContainText("此扩展命令尚未接入 Web");
const menu = page.locator(".slash-command-menu");
// The entry transition moves the menu; measure its settled placement.
await expect
.poll(() =>
menu.evaluate((element) =>
element
.getAnimations()
.some(
(animation) =>
animation.pending || animation.playState === "running",
),
),
)
.toBe(false);
const menuBox = await menu.boundingBox();
const composerBox = await page.locator(".composer").boundingBox();
expect(menuBox).not.toBeNull();
Expand Down Expand Up @@ -2156,6 +2169,8 @@ test.describe("touch viewport", () => {
expect(dialogBox!.x).toBeGreaterThanOrEqual(0);
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(390);
await page.getByRole("button", { name: "取消" }).click();
// Native close removes [open] before the discrete exit transition ends.
await expect(page.locator("dialog:visible")).toHaveCount(0);

const accessibility = await new AxeBuilder({ page }).analyze();
expect(accessibility.violations).toEqual([]);
Expand Down Expand Up @@ -2287,7 +2302,8 @@ for (const width of [320, 390]) {
expect(dialogBox?.x).toBeGreaterThanOrEqual(0);
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(width);
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
// Closing resets the dialog label before its discrete exit finishes.
await expect(page.locator("dialog:visible")).toHaveCount(0);
await expect(menu).toBeFocused();
await expect(sidebar).toBeVisible();
await expect(menu).toHaveCSS("outline-style", "solid");
Expand Down Expand Up @@ -4051,7 +4067,7 @@ test.describe("thinking picker", () => {
await page.keyboard.press("Escape");
await expect(
page.locator('[role="dialog"]:has(.thinking-controls)'),
).toHaveCount(0);
).toBeHidden();
await expect(picker).toBeFocused();
expect(fixture.posts).toHaveLength(1);
});
Expand Down Expand Up @@ -4230,7 +4246,7 @@ test.describe("thinking picker", () => {
await page.mouse.up();
await expect(
page.locator('[role="dialog"]:has(.thinking-controls)'),
).toHaveCount(0);
).toBeHidden();
expect(fixture.posts).toEqual([]);
await page.locator(".model-picker > button").click();
await page.mouse.move(bounds.x + 14, y);
Expand Down Expand Up @@ -4271,12 +4287,12 @@ test.describe("thinking picker", () => {
await trigger.click();
await expect(popup).toBeVisible();
await trigger.click();
await expect(popup).toHaveCount(0);
await expect(popup).toBeHidden();
await trigger.click();
await expect(popup).toBeVisible();
const composer = page.getByRole("textbox", { name: "描述任务" });
await composer.click();
await expect(popup).toHaveCount(0);
await expect(popup).toBeHidden();
await expect(composer).toBeFocused();
await page.keyboard.type("Keep composing");
await expect(composer).toHaveValue("Keep composing");
Expand Down
68 changes: 66 additions & 2 deletions tests/web/review-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import type {
WebGitReviewSource,
} from "../../web/protocol/types.ts";
import { Providers } from "../../web/ui/src/app/providers.tsx";
import { parseDiffRows } from "../../web/ui/src/features/review/DiffCodePreview.tsx";
import {
DiffCodePreview,
parseDiffRows,
} from "../../web/ui/src/features/review/DiffCodePreview.tsx";
import { ReviewPanel } from "../../web/ui/src/features/review/ReviewPanel.tsx";
import { WorkbarPanel } from "../../web/ui/src/features/workbar/WorkbarPanel.tsx";
import {
Expand Down Expand Up @@ -595,6 +598,64 @@ it("does not invent a numbered context row from a trailing diff newline", () =>
]);
});

it("pairs changed lines and quotes the exact old or new side of a renamed file", () => {
const quote = vi.fn();
const { container } = render(
withI18n(
createElement(DiffCodePreview, {
file: {
...snapshot.files[0]!,
path: "src/new.ts",
previousPath: "src/old.ts",
diff: "@@ -8,3 +12,3 @@\n same\n-old\n+new\n tail\n",
},
onReferenceLine: quote,
}),
),
);
fireEvent.click(screen.getByRole("button", { name: i18n.t("diffSplit") }));
expect(
container.querySelectorAll(
".review-diff-pair:not(.review-diff-column-headings)",
),
).toHaveLength(4);
const old = screen.getByRole("button", {
name: i18n.t("reviewSelectOldLine", { line: 8 }),
});
const next = screen.getByRole("button", {
name: i18n.t("reviewSelectNewLine", { line: 12 }),
});
fireEvent.click(old);
expect(next.tabIndex).toBe(0);
fireEvent.click(
screen.getByRole("button", {
name: i18n.t("reviewQuoteLine"),
}),
);
expect(quote).toHaveBeenLastCalledWith({
filePath: "src/old.ts",
side: "old",
line: 8,
code: "same",
});
fireEvent.click(next);
fireEvent.keyDown(next, { key: "ArrowDown" });
expect(document.activeElement?.getAttribute("aria-label")).toBe(
i18n.t("reviewSelectNewLine", { line: 13 }),
);
fireEvent.click(
screen.getByRole("button", {
name: i18n.t("reviewQuoteLine"),
}),
);
expect(quote).toHaveBeenLastCalledWith({
filePath: "src/new.ts",
side: "new",
line: 13,
code: "new",
});
});

it("switches from the changed-file list to a separate full-height diff view", async () => {
const onClose = vi.fn();
const refresh = vi.fn(async () => {});
Expand Down Expand Up @@ -930,7 +991,10 @@ it("refreshes the list and preview, retains its diff on failure, and disables du
const refreshButton = screen.getByRole<HTMLButtonElement>("button", {
name: i18n.t("gitReviewRefresh"),
});
expect(refreshButton.title).toBe(i18n.t("gitReviewRefresh"));
expect(
document.getElementById(refreshButton.getAttribute("aria-describedby")!)
?.textContent,
).toBe(i18n.t("gitReviewRefresh"));
fireEvent.click(refreshButton);
expect(refresh).toHaveBeenCalledOnce();
fireEvent.click(
Expand Down
Loading
Loading