mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* fix(app): render local relative images in markdown file preview File preview dropped relative images because it only accepted http/data URIs and never resolved paths against the opened markdown file. * test(app): cover local relative images in markdown preview e2e * fix(app): show local images in Markdown file previews Carry forward document-relative image loading and preserve the host home directory when resolving images beside home-relative Markdown files. Decode file URIs once, and require decoded images in the file-pane regression. Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com> * fix(app): resolve local Markdown image URL suffixes --------- Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
882 lines
35 KiB
TypeScript
882 lines
35 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "../support/fixtures";
|
|
import {
|
|
expandFolder,
|
|
openFileExplorer,
|
|
openFileFromExplorer,
|
|
expectFileTabOpen,
|
|
} from "../support/helpers/file-explorer";
|
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
|
import { gotoAppShell } from "../support/helpers/app";
|
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
|
|
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
|
|
|
const RED_PIXEL = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
|
"base64",
|
|
);
|
|
const BLUE_PIXEL = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
|
"base64",
|
|
);
|
|
const BLOCKED_PREVIEW_URL = "https://html-preview.invalid/leak";
|
|
|
|
interface LinkedFile {
|
|
target: string;
|
|
fileName: string;
|
|
content: string;
|
|
}
|
|
|
|
function editor(page: Page) {
|
|
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
|
}
|
|
|
|
function hasHorizontalOverflow(element: HTMLElement): boolean {
|
|
return element.scrollWidth > element.clientWidth;
|
|
}
|
|
|
|
function fitsViewportWidth(element: HTMLElement): boolean {
|
|
return element.scrollWidth === element.clientWidth;
|
|
}
|
|
|
|
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
|
await editor(page).fill(content);
|
|
}
|
|
|
|
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
|
const tree = page.getByTestId("file-explorer-tree-scroll");
|
|
if (!(await tree.isVisible())) await openFileExplorer(page);
|
|
await openFileFromExplorer(page, filename);
|
|
await expectFileTabOpen(page, filename);
|
|
}
|
|
|
|
function htmlPreview(page: Page) {
|
|
return {
|
|
host: page.getByTestId("file-html-preview"),
|
|
document: page.frameLocator('[data-testid="file-html-preview"]'),
|
|
};
|
|
}
|
|
|
|
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
|
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
|
name: view,
|
|
exact: true,
|
|
});
|
|
await option.click();
|
|
await expect(option).toHaveAttribute("aria-selected", "true");
|
|
}
|
|
|
|
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
|
|
const image = page.getByRole("img", { name, exact: true });
|
|
await expect(image).toBeVisible();
|
|
await expect
|
|
.poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
|
|
.toBeGreaterThan(0);
|
|
}
|
|
|
|
function watchRequestsTo(page: Page, origin: string): string[] {
|
|
const requests: string[] = [];
|
|
page.on("request", (request) => {
|
|
if (request.url().startsWith(origin)) requests.push(request.url());
|
|
});
|
|
return requests;
|
|
}
|
|
|
|
async function seedAgentWithFileLink(input: LinkedFile) {
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "file-editing-chat-link-",
|
|
title: "Chat file link e2e",
|
|
initialPrompt: [
|
|
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
|
"Return JSON only with fields 'title' and 'branch'.",
|
|
"",
|
|
"<user-prompt>",
|
|
`Open \`${input.target}\` now`,
|
|
"</user-prompt>",
|
|
].join("\n"),
|
|
});
|
|
await writeFile(path.join(session.cwd, input.fileName), input.content, "utf8");
|
|
return session;
|
|
}
|
|
|
|
test.describe("CodeMirror workspace file editing", () => {
|
|
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "encoded-markdown-link-",
|
|
title: "Non-ASCII file link",
|
|
initialPrompt: "Show the report link",
|
|
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
|
|
});
|
|
try {
|
|
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
|
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
|
await openAgentRoute(page, session);
|
|
await openAssistantMarkdownLink(page, "报告");
|
|
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
|
await expect(
|
|
page
|
|
.getByTestId("workspace-file-pane")
|
|
.filter({ visible: true })
|
|
.getByText("Report opened", { exact: true }),
|
|
).toBeVisible();
|
|
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
for (const order of ["raw-first", "href-first"] as const) {
|
|
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "percent-link-cache-",
|
|
title: "Distinct file links",
|
|
initialPrompt: "Show both filenames",
|
|
featureValues: {
|
|
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
|
|
},
|
|
});
|
|
try {
|
|
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
|
|
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
|
|
await openAgentRoute(page, session);
|
|
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
|
|
for (const link of links) {
|
|
await returnToAssistant(page, session.agentId);
|
|
if (link === "raw") {
|
|
await openAssistantInlinePath(page, "literal%20.md");
|
|
await expectFileTabOpen(page, "literal%20.md");
|
|
await expectPreviewText(page, "Literal filename");
|
|
} else {
|
|
await openAssistantMarkdownLink(page, "Decoded");
|
|
await expectFileTabOpen(page, "literal .md");
|
|
await expectPreviewText(page, "Decoded filename");
|
|
}
|
|
}
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
}
|
|
|
|
test("jumps to each relative reference to the same file", async ({ page }) => {
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "relative-reference-qa-",
|
|
title: "Relative line references",
|
|
initialPrompt: "Show both references",
|
|
featureValues: {
|
|
mockAssistantResponse: "First `src/router.go:46`. Then `src/router.go:403`.",
|
|
},
|
|
});
|
|
try {
|
|
await mkdir(path.join(session.cwd, "src"));
|
|
await writeFile(
|
|
path.join(session.cwd, "src/router.go"),
|
|
Array.from({ length: 450 }, (_, n) => `// line ${n + 1}`).join("\n"),
|
|
);
|
|
await openAgentRoute(page, session);
|
|
for (const line of [46, 403, 46]) {
|
|
await page
|
|
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
|
.filter({ visible: true })
|
|
.click();
|
|
await page.getByText(`src/router.go:${line}`, { exact: true }).click();
|
|
await expect(page.getByLabel(`Line ${line}, column 1`)).toBeVisible();
|
|
}
|
|
await page.screenshot({ path: test.info().outputPath("relative-line-target.png") });
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
|
|
const filePath = path.join(workspace.repoPath, "src", "target.ts");
|
|
try {
|
|
const agent = await workspace.client.createAgent({
|
|
provider: "mock",
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title: "Absolute file link tooltip",
|
|
modeId: "load-test",
|
|
model: "e2e-fast-stream",
|
|
initialPrompt: "Show the file link",
|
|
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
|
|
});
|
|
await gotoAppShell(page);
|
|
await waitForSidebarHydration(page);
|
|
const workspaceRow = page.getByTestId(
|
|
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
|
);
|
|
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
|
await workspaceRow.click();
|
|
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
|
|
|
|
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
|
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
|
await fileLink.hover();
|
|
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
|
page,
|
|
}) => {
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "file-source-lockfile-",
|
|
title: "Large source",
|
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
|
});
|
|
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
|
|
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
|
|
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
await openWorkspaceFile(page, "package-lock.json");
|
|
|
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
|
await expect(editor(page)).toContainText('"package-0"');
|
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "file-source-plain-",
|
|
title: "Plain large source",
|
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
|
});
|
|
await writeFile(
|
|
path.join(session.cwd, "plain.txt"),
|
|
"plain source\n".repeat(1_050_000),
|
|
"utf8",
|
|
);
|
|
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
await openWorkspaceFile(page, "plain.txt");
|
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
|
await expect(editor(page)).toContainText("plain source");
|
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
|
|
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
|
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
|
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test("refuses a file above the display budget and keeps its tab recoverable", async ({
|
|
page,
|
|
}) => {
|
|
const session = await seedMockAgentWorkspace({
|
|
repoPrefix: "file-source-unsupported-",
|
|
title: "Unsupported large source",
|
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
|
});
|
|
await writeFile(
|
|
path.join(session.cwd, "too-large.txt"),
|
|
Buffer.alloc(51 * 1024 * 1024),
|
|
"utf8",
|
|
);
|
|
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
await openWorkspaceFile(page, "too-large.txt");
|
|
await expect(page.getByTestId("file-source-too-large")).toContainText(
|
|
"This file is too large to display",
|
|
);
|
|
|
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
|
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
|
|
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
|
const target = "target.ts:42";
|
|
const session = await seedAgentWithFileLink({
|
|
target,
|
|
fileName: "target.ts",
|
|
content: Array.from(
|
|
{ length: 80 },
|
|
(_, index) => `export const line${index + 1} = ${index + 1};`,
|
|
).join("\n"),
|
|
});
|
|
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
|
|
const fileLink = page.getByText(target, { exact: true });
|
|
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
|
await fileLink.click();
|
|
|
|
await expectFileTabOpen(page, "target.ts");
|
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
|
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
|
).toBeVisible();
|
|
|
|
const sourceEditor = editor(page);
|
|
await sourceEditor.click();
|
|
await sourceEditor.press("ControlOrMeta+Home");
|
|
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
|
|
|
await page
|
|
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
|
.filter({ visible: true })
|
|
.click();
|
|
await expect(fileLink).toBeVisible();
|
|
await fileLink.click();
|
|
|
|
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
|
).toBeVisible();
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
|
await page.addInitScript((settingsKey) => {
|
|
localStorage.setItem(
|
|
settingsKey,
|
|
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
|
|
);
|
|
}, APP_SETTINGS_KEY);
|
|
const target = "target.ts:42";
|
|
const session = await seedAgentWithFileLink({
|
|
target,
|
|
fileName: "target.ts",
|
|
content: Array.from(
|
|
{ length: 80 },
|
|
(_, index) => `export const line${index + 1} = ${index + 1};`,
|
|
).join("\n"),
|
|
});
|
|
|
|
try {
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await openAgentRoute(page, session);
|
|
|
|
await openWorkspaceFile(page, "target.ts");
|
|
await expect(
|
|
page
|
|
.locator('[data-testid^="workspace-pane-"]')
|
|
.getByTestId("workspace-tabs-row")
|
|
.filter({ visible: true }),
|
|
).toHaveCount(2);
|
|
|
|
await page
|
|
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
|
.filter({ visible: true })
|
|
.click();
|
|
await editor(page).click();
|
|
await page.keyboard.press("Alt+Shift+W");
|
|
|
|
await expect(page.getByTestId("workspace-tab-file_target.ts")).not.toBeVisible();
|
|
await expect(
|
|
page.getByTestId(`workspace-tab-agent_${session.agentId}`).filter({ visible: true }),
|
|
).toBeVisible();
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test("opens an HTML line target as source", async ({ page }) => {
|
|
const target = "plan.html:2";
|
|
const session = await seedAgentWithFileLink({
|
|
target,
|
|
fileName: "plan.html",
|
|
content: [
|
|
"<!doctype html>",
|
|
"<h1>Review this source line</h1>",
|
|
'<script>document.body.textContent = "This HTML executed";</script>',
|
|
].join("\n"),
|
|
});
|
|
|
|
try {
|
|
await openAgentRoute(page, session);
|
|
await page.getByText(target, { exact: true }).click();
|
|
|
|
await expectFileTabOpen(page, "plan.html");
|
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
|
await expect(page.getByLabel("Line 2, column 1")).toBeVisible();
|
|
await expect(page.getByTestId("file-html-preview")).toHaveCount(0);
|
|
} finally {
|
|
await session.cleanup();
|
|
}
|
|
});
|
|
|
|
test("shows the full file path and keeps editor controls stable", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
await page.emulateMedia({ colorScheme: "dark" });
|
|
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
|
|
const relativePath = "src/deep/visuals.md";
|
|
const sourcePath = path.join(workspace.repoPath, relativePath);
|
|
await mkdir(path.dirname(sourcePath), { recursive: true });
|
|
await writeFile(
|
|
sourcePath,
|
|
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
|
|
"\n",
|
|
),
|
|
"utf8",
|
|
);
|
|
await workspace.navigateTo();
|
|
await openFileExplorer(page);
|
|
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
|
|
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
|
|
await openFileFromExplorer(page, "visuals.md");
|
|
await expectFileTabOpen(page, relativePath);
|
|
|
|
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
|
|
await fileTab.hover();
|
|
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
|
|
relativePath,
|
|
);
|
|
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
|
const modeControl = page.getByTestId("file-preview-mode");
|
|
await expect(modeControl).toBeVisible();
|
|
await selectFileView(page, "Source");
|
|
|
|
const editorHost = page.getByTestId("file-source-editor");
|
|
const content = editor(page);
|
|
await expect(editorHost).toHaveAttribute("data-pmono", "");
|
|
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
|
|
|
|
await content.click();
|
|
const cursor = editorHost.locator(".cm-cursor-primary");
|
|
await expect(cursor).toBeVisible();
|
|
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
|
|
|
|
const initialModeBox = await modeControl.boundingBox();
|
|
expect(initialModeBox).not.toBeNull();
|
|
const initialModeX = initialModeBox!.x;
|
|
await content.press("ControlOrMeta+End");
|
|
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
|
const movedModeBox = await modeControl.boundingBox();
|
|
expect(movedModeBox).not.toBeNull();
|
|
expect(movedModeBox!.x).toBe(initialModeX);
|
|
|
|
await content.press("ControlOrMeta+a");
|
|
const selection = editorHost.locator(".cm-selectionBackground").first();
|
|
await expect(selection).toBeVisible();
|
|
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
|
});
|
|
|
|
test("applies the interface font to portaled tooltips", async ({ page, withWorkspace }) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "monospace" }));
|
|
});
|
|
const workspace = await withWorkspace({ prefix: "file-tooltip-font-" });
|
|
const relativePath = "tooltip-font.txt";
|
|
await writeFile(path.join(workspace.repoPath, relativePath), "tooltip font\n", "utf8");
|
|
await workspace.navigateTo();
|
|
await openFileExplorer(page);
|
|
await openFileFromExplorer(page, relativePath);
|
|
await expectFileTabOpen(page, relativePath);
|
|
|
|
await page.getByTestId(`workspace-tab-file_${relativePath}`).first().hover();
|
|
|
|
await expect(
|
|
page
|
|
.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)
|
|
.getByText(relativePath, { exact: true }),
|
|
).toHaveCSS("font-family", "monospace");
|
|
});
|
|
|
|
test("wraps Markdown while source code remains horizontally scrollable", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
const workspace = await withWorkspace({ prefix: "file-editing-wrap-" });
|
|
const longLine = "word ".repeat(300);
|
|
await writeFile(path.join(workspace.repoPath, "notes.md"), `${longLine}\n`, "utf8");
|
|
await writeFile(
|
|
path.join(workspace.repoPath, "source.ts"),
|
|
`const value = "${longLine}";\n`,
|
|
"utf8",
|
|
);
|
|
await workspace.navigateTo();
|
|
await openWorkspaceFile(page, "notes.md");
|
|
await selectFileView(page, "Source");
|
|
|
|
const markdownScroller = page
|
|
.getByTestId("file-source-editor")
|
|
.filter({ visible: true })
|
|
.locator(".cm-scroller");
|
|
await expect.poll(() => markdownScroller.evaluate(fitsViewportWidth)).toBe(true);
|
|
|
|
await openWorkspaceFile(page, "source.ts");
|
|
const sourceScroller = page
|
|
.getByTestId("file-source-editor")
|
|
.filter({ visible: true })
|
|
.locator(".cm-scroller");
|
|
await expect.poll(() => sourceScroller.evaluate(hasHorizontalOverflow)).toBe(true);
|
|
});
|
|
|
|
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
const gate = await installDaemonWebSocketGate(page);
|
|
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
|
|
const sourcePath = path.join(workspace.repoPath, "source.ts");
|
|
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
|
await Promise.all(
|
|
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
|
|
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
|
|
),
|
|
);
|
|
await workspace.navigateTo();
|
|
await openWorkspaceFile(page, "source.ts");
|
|
|
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
|
await expect(page.getByLabel(/File size/)).toBeVisible();
|
|
await expect(page.getByLabel(/lines/)).toBeVisible();
|
|
|
|
await replaceEditorText(page, "const autosaved = 2;\n");
|
|
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
|
|
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
|
|
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
|
|
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
|
|
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
|
|
|
|
await replaceEditorText(page, "const immediate = 3;\n");
|
|
await editor(page).press("Control+s");
|
|
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
|
|
|
|
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
|
|
await expect(editor(page)).toContainText("const external = 4;");
|
|
await expect(page.getByLabel("3 lines")).toBeVisible();
|
|
|
|
await replaceEditorText(page, "const localWins = 5;\n");
|
|
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
|
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
|
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
|
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
|
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
|
|
await openWorkspaceFile(page, fileName);
|
|
}
|
|
await openWorkspaceFile(page, "source.ts");
|
|
await expect(editor(page)).toContainText("const localWins = 5;");
|
|
|
|
await replaceEditorText(page, "const discarded = 7;\n");
|
|
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
|
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
|
page.once("dialog", (dialog) => dialog.accept());
|
|
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
|
await expect(editor(page)).toContainText("const diskWins = 8;");
|
|
|
|
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
|
|
await gate.drop();
|
|
gate.restore();
|
|
await expect
|
|
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
|
|
.toBeGreaterThan(subscriptionCount);
|
|
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
|
|
await expect(editor(page)).toContainText("const afterReconnect = 9;");
|
|
});
|
|
|
|
test("preserves a UTF-8 BOM and uses the first line separator after saving", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
const workspace = await withWorkspace({ prefix: "file-editing-encoding-" });
|
|
const sourcePath = path.join(workspace.repoPath, "windows.ts");
|
|
await writeFile(
|
|
sourcePath,
|
|
Buffer.from("\uFEFFconst initial = true;\r\nconst mixed = true;\n", "utf8"),
|
|
);
|
|
await workspace.navigateTo();
|
|
await openWorkspaceFile(page, "windows.ts");
|
|
|
|
await replaceEditorText(page, "const saved = true;\nconst normalized = true;\n");
|
|
await editor(page).press("Control+s");
|
|
|
|
const expected = Buffer.from(
|
|
"\uFEFFconst saved = true;\r\nconst normalized = true;\r\n",
|
|
"utf8",
|
|
).toString("hex");
|
|
await expect.poll(async () => (await readFile(sourcePath)).toString("hex")).toBe(expected);
|
|
});
|
|
|
|
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
|
|
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
|
|
const sourcePath = path.join(workspace.repoPath, "draft.ts");
|
|
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
|
await workspace.navigateTo();
|
|
await openWorkspaceFile(page, "draft.ts");
|
|
|
|
await replaceEditorText(page, "const local = 2;\n");
|
|
await writeFile(sourcePath, "const external = 3;\n", "utf8");
|
|
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
|
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
|
|
|
let closePrompt = "";
|
|
page.once("dialog", async (dialog) => {
|
|
closePrompt = dialog.message();
|
|
await dialog.dismiss();
|
|
});
|
|
await page
|
|
.getByTestId("workspace-tab-file_draft.ts")
|
|
.filter({ visible: true })
|
|
.first()
|
|
.click({ button: "right" });
|
|
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
|
expect(closePrompt).toContain("Closing it will discard the draft.");
|
|
|
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
|
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
|
});
|
|
|
|
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
|
|
const markdownPath = path.join(workspace.repoPath, "notes.md");
|
|
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
|
await writeFile(markdownPath, "# First heading\n", "utf8");
|
|
await writeFile(imagePath, RED_PIXEL);
|
|
await workspace.navigateTo();
|
|
await openWorkspaceFile(page, "notes.md");
|
|
|
|
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
|
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
|
|
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
|
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
|
|
|
|
await selectFileView(page, "Source");
|
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
|
await replaceEditorText(page, "# Saved from source\n");
|
|
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
|
await selectFileView(page, "Preview");
|
|
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
|
|
|
|
await openWorkspaceFile(page, "pixel.png");
|
|
const image = visibleFilePane.locator("img");
|
|
await expect(image).toBeVisible();
|
|
const imageCanvas = page.getByTestId("image-file-preview-canvas");
|
|
await expect(imageCanvas).toBeVisible();
|
|
await imageCanvas.hover();
|
|
const transformedContent = imageCanvas.locator(":scope > div").first();
|
|
const fittedImageBox = await transformedContent.boundingBox();
|
|
expect(fittedImageBox).not.toBeNull();
|
|
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
|
|
await expect
|
|
.poll(async () => (await transformedContent.boundingBox())?.width ?? 0)
|
|
.toBeGreaterThan(fittedImageBox!.width * 1.2);
|
|
const initialSource = await image.getAttribute("src");
|
|
await writeFile(imagePath, BLUE_PIXEL);
|
|
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
|
});
|
|
|
|
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
|
test.setTimeout(90_000);
|
|
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
|
|
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
|
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
|
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
|
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
|
await writeFile(
|
|
path.join(workspace.repoPath, "docs", "guide.md"),
|
|
[
|
|
"# Guide",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
await workspace.navigateTo();
|
|
await openFileExplorer(page);
|
|
await expandFolder(page, "docs");
|
|
await openFileFromExplorer(page, "guide.md");
|
|
await expectFileTabOpen(page, "docs/guide.md");
|
|
|
|
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
|
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
|
await expectMarkdownImageLoaded(page, "sibling");
|
|
await expectMarkdownImageLoaded(page, "hero");
|
|
await visibleFilePane.screenshot({
|
|
path: test.info().outputPath("markdown-relative-images.png"),
|
|
});
|
|
});
|
|
|
|
test("previews and refreshes an HTML plan while preserving source access", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const workspace = await withWorkspace({ prefix: "file-editing-html-preview-" });
|
|
const htmlPath = path.join(workspace.repoPath, "plan.html");
|
|
await writeFile(
|
|
htmlPath,
|
|
"<!doctype html><html><body><h1>Visual plan</h1></body></html>",
|
|
"utf8",
|
|
);
|
|
await workspace.navigateTo();
|
|
await openWorkspaceFile(page, "plan.html");
|
|
|
|
const preview = htmlPreview(page);
|
|
await expect(preview.host).toBeVisible();
|
|
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
|
|
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
|
|
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
|
|
|
|
await writeFile(
|
|
htmlPath,
|
|
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
|
|
"utf8",
|
|
);
|
|
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
|
|
|
await selectFileView(page, "Source");
|
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
|
await expect(preview.host).toHaveCount(0);
|
|
await selectFileView(page, "Preview");
|
|
await expect(preview.host).toBeVisible();
|
|
});
|
|
|
|
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
|
test.setTimeout(90_000);
|
|
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
|
await writeFile(
|
|
path.join(workspace.repoPath, "probe.html"),
|
|
`<!doctype html><html><head><title>probe</title></head><body>
|
|
<h1 id="script-result">Inline script did not run</h1>
|
|
<p id="network-result">Network request not attempted</p>
|
|
<p id="document-mode">Standards mode not detected</p>
|
|
<script>
|
|
document.getElementById("script-result").textContent = "Inline script ran";
|
|
if (document.compatMode === "CSS1Compat") {
|
|
document.getElementById("document-mode").textContent = "Standards mode enabled";
|
|
}
|
|
var networkResult = document.getElementById("network-result");
|
|
fetch("${BLOCKED_PREVIEW_URL}", { method: "POST", body: "repo-content" })
|
|
.then(function () { networkResult.textContent = "Network request allowed"; })
|
|
.catch(function () { networkResult.textContent = "Network request blocked"; });
|
|
</script>
|
|
</body></html>`,
|
|
"utf8",
|
|
);
|
|
await workspace.navigateTo();
|
|
|
|
const blockedRequests = watchRequestsTo(page, BLOCKED_PREVIEW_URL);
|
|
|
|
await openWorkspaceFile(page, "probe.html");
|
|
|
|
const preview = htmlPreview(page);
|
|
await expect(
|
|
preview.document.getByRole("heading", { name: "Inline script ran" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
preview.document.getByText("Standards mode enabled", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
preview.document.getByText("Network request blocked", { exact: true }),
|
|
).toBeVisible();
|
|
expect(blockedRequests).toEqual([]);
|
|
});
|
|
|
|
test("isolates HTML plans from the app origin and storage", async ({ page, withWorkspace }) => {
|
|
test.setTimeout(90_000);
|
|
const workspace = await withWorkspace({ prefix: "file-editing-html-origin-" });
|
|
await writeFile(
|
|
path.join(workspace.repoPath, "origin.html"),
|
|
`<!doctype html><html><body>
|
|
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
|
|
<script>
|
|
function report(id, label, probe) {
|
|
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
|
|
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
|
|
}
|
|
report("parent", "Parent DOM", function () { return parent.document.body; });
|
|
report("storage", "Storage", function () { return localStorage.length; });
|
|
report("cookie", "Cookies", function () { return document.cookie; });
|
|
</script>
|
|
</body></html>`,
|
|
"utf8",
|
|
);
|
|
await workspace.navigateTo();
|
|
await openWorkspaceFile(page, "origin.html");
|
|
|
|
const preview = htmlPreview(page);
|
|
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
|
|
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
|
|
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
|
page,
|
|
withWorkspace,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
|
|
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
|
|
|
|
await page.goto("/settings/editor");
|
|
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
|
|
await expect(toggle).toBeVisible();
|
|
await toggle.click();
|
|
await expect(toggle).toBeChecked();
|
|
await page.reload();
|
|
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
|
|
|
|
await workspace.navigateTo();
|
|
await openWorkspaceFile(page, "vim.ts");
|
|
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
|
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
|
|
await editor(page).click();
|
|
await editor(page).press("i");
|
|
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
|
|
await editor(page).press("Escape");
|
|
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
|
});
|
|
});
|
|
|
|
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
|
|
await page.getByRole("link", { name, exact: true }).first().click();
|
|
}
|
|
|
|
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
|
|
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
|
|
}
|
|
|
|
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
|
|
await page.getByText(token, { exact: true }).click();
|
|
}
|
|
|
|
async function expectPreviewText(page: Page, text: string): Promise<void> {
|
|
await expect(
|
|
page
|
|
.getByTestId("workspace-file-pane")
|
|
.filter({ visible: true })
|
|
.getByText(text, { exact: true }),
|
|
).toBeVisible();
|
|
}
|