Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 0bc8c00ea6 test(server): identify the phase of Windows setup timeouts 2026-11-06 21:05:17 +00:00
7 changed files with 27 additions and 194 deletions
@@ -1,9 +1,5 @@
import { expect, test, type Page } from "../support/fixtures";
import {
pasteRichTextIntoComposer,
selectComposerDraftRange,
undoComposerEdit,
redoComposerEdit,
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
@@ -32,7 +28,6 @@ import {
openMobileAgentSidebarWithKeyboard,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
import {
expectTimelinePromptPositionPreserved,
@@ -134,7 +129,6 @@ test("submitting a message leaves the composer ready for the next message", asyn
title: "Composer focus",
});
const connection = await installDaemonWebSocketGate(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
@@ -142,50 +136,8 @@ test("submitting a message leaves the composer ready for the next message", asyn
await submitMessage(page, "First message");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
await selectComposerDraftRange(page, 13, 25);
await pasteRichTextIntoComposer(
page,
'<a href="https://example.com/issues/39150">this FR</a>',
"this FR",
);
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await undoComposerEdit(page);
await expectComposerDraft(page, "Investigate: replace this. Thanks");
await redoComposerEdit(page);
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await typeIntoFocusedComposer(page, " next");
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
);
await connection.drop();
await connection.waitForBlockedConnection();
await pasteRichTextIntoComposer(
page,
'<a href="https://example.com/offline">offline link</a>',
"offline link",
);
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
);
const copiedTable =
"| Resource | Status |\n| --- | --- |\n| [issue](https://example.com/1) | ~~closed~~ |";
await composerLocator(page).fill("");
await pasteRichTextIntoComposer(
page,
'<table><tr><th>Resource</th><th>Status</th></tr><tr><td><a href="https://example.com/1">issue</a></td><td><s>closed</s></td></tr></table>',
copiedTable,
);
await expectComposerDraft(page, copiedTable);
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Second message");
await expectComposerDraft(page, "Second message");
} finally {
await agent.cleanup();
}
@@ -566,31 +566,3 @@ export async function roundTripComposerTabBeforePaint(
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
/** Paste both clipboard representations through the browser's normal paste command. */
export async function pasteRichTextIntoComposer(
page: Page,
html: string,
text: string,
): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(
async ({ html: richHtml, text: plainText }) => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([richHtml], { type: "text/html" }),
"text/plain": new Blob([plainText], { type: "text/plain" }),
}),
]);
},
{ html, text },
);
await composerInput(page).press("ControlOrMeta+v");
}
export async function undoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+z");
}
export async function redoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+Shift+z");
}
+6 -11
View File
@@ -88,7 +88,6 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeight } from "./height";
import { ComposerInputPresentation } from "./presentation";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import { pasteLinkedClipboardText } from "@/composer/pasted-text";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
EditingTextInput,
@@ -514,7 +513,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
return native instanceof HTMLElement ? native : null;
}
interface PasteEffectArgs {
interface PasteImagesEffectArgs {
textarea: HTMLElement | null;
isConnected: boolean;
disabled: boolean;
@@ -523,7 +522,7 @@ interface PasteEffectArgs {
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
}
function usePasteEffect(args: PasteEffectArgs): void {
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
const {
textarea,
isConnected,
@@ -534,20 +533,16 @@ function usePasteEffect(args: PasteEffectArgs): void {
} = args;
useEffect(() => {
if (!isWeb) return;
if (!isWeb || !onAddImages) return;
if (!textarea) return;
let disposed = false;
const handlePaste = (event: ClipboardEvent) => {
if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
if (imageFiles.length === 0) {
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
return;
}
if (!isConnected || !onAddImages) return;
if (imageFiles.length === 0) return;
event.preventDefault();
@@ -1816,7 +1811,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteEffect({
usePasteImagesEffect({
textarea: webTextArea,
isConnected,
disabled,
@@ -1,69 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { createMarkdownClipboardContent } from "@/utils/rich-clipboard";
import { pasteLinkedClipboardText } from "./pasted-text.web";
const inputs: HTMLTextAreaElement[] = [];
function editor(): HTMLTextAreaElement {
const input = document.createElement("textarea");
document.body.append(input);
inputs.push(input);
return input;
}
afterEach(() => {
for (const input of inputs.splice(0)) input.remove();
});
function clipboard(html: string, text: string): DataTransfer {
const data = new DataTransfer();
data.setData("text/html", html);
data.setData("text/plain", text);
return data;
}
describe("pasted prompt links", () => {
it("keeps complete Markdown from Copy without reconverting its linked table or strikethrough", () => {
const source =
"| Resource | Status |\n| --- | --- |\n| [issue](https://example.com/1) | ~~closed~~ |";
const content = createMarkdownClipboardContent(source);
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard(content.html, content.plainText))).toBe(false);
expect(input.value).toBe("");
});
it("preserves a labeled resource URL as Markdown", () => {
const data = clipboard(
'Hey can you take a look at <a href="https://example.com/issues/39150">this FR</a> and...',
"Hey can you take a look at this FR and...",
);
const input = editor();
expect(pasteLinkedClipboardText(input, data)).toBe(true);
expect(input.value).toBe(
"Hey can you take a look at [this FR](https://example.com/issues/39150) and...",
);
});
it("leaves plain text and rich text without links to native paste", () => {
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard("", "[link](https://example.com)"))).toBe(
false,
);
expect(pasteLinkedClipboardText(input, clipboard("<b>hello</b>", "hello"))).toBe(false);
expect(pasteLinkedClipboardText(input, null)).toBe(false);
expect(input.value).toBe("");
});
it("keeps paragraphs and multiple links", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<p>See <a href="https://example.com/a">one</a></p><p>and <a href="https://example.com/b">two</a></p>',
"See one\n\nand two",
),
),
).toBe(true);
expect(input.value).toBe(
"See [one](https://example.com/a)\n\nand [two](https://example.com/b)",
);
});
});
-7
View File
@@ -1,7 +0,0 @@
/** Native inputs retain their platform clipboard handling. */
export function pasteLinkedClipboardText(
_input: HTMLElement,
_clipboard: Pick<DataTransfer, "getData"> | null,
): boolean {
return false;
}
@@ -1,28 +0,0 @@
import TurndownService from "turndown";
const markdown = new TurndownService({ codeBlockStyle: "fenced", bulletListMarker: "-" });
/** Paste linked HTML as Markdown through the browser's undoable editing command. */
export function pasteLinkedClipboardText(
input: HTMLElement,
clipboard: Pick<DataTransfer, "getData"> | null,
): boolean {
const html = clipboard?.getData("text/html");
if (!html) return false;
// Parse in a detached document. Clipboard markup is never mounted in the app.
const fragment = new DOMParser().parseFromString(html, "text/html");
const destinations = Array.from(
fragment.querySelectorAll("a[href]"),
(link) => link.getAttribute("href")!,
);
const plainText = clipboard!.getData("text/plain");
// Copy actions already put complete Markdown on the clipboard. Keep that
// representation, including tables and formatting, whenever it carries the URLs.
if (destinations.every((url) => plainText.includes(url))) return false;
const text = markdown.turndown(fragment.body);
input.focus();
// Direct value assignment drops the textarea's undo history. This also emits
// the normal input event so the editor publishes its new draft as for typing.
return input.ownerDocument.execCommand("insertText", false, text);
}
@@ -562,6 +562,13 @@ describe("runWorktreeSetupInBackground", () => {
});
function startSubdirectorySetup(command: string) {
const startedAt = performance.now();
const recordPhase = (phase: string) => {
process.stdout.write(
`${JSON.stringify({ phase, elapsedMs: performance.now() - startedAt })}\n`,
);
};
recordPhase("fixture-started");
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
cleanupPaths.push(tempDir);
const repoDir = path.join(tempDir, "source");
@@ -574,6 +581,7 @@ describe("runWorktreeSetupInBackground", () => {
// Setup consumes an existing workspace. Git creation is covered by the workflow tests.
mkdirSync(sourceWorkspaceCwd, { recursive: true });
mkdirSync(workspaceCwd, { recursive: true });
recordPhase("git-init-started");
execFileSync("git", ["init", "-b", worktree.branchName], {
cwd: worktree.worktreePath,
stdio: "pipe",
@@ -588,12 +596,20 @@ describe("runWorktreeSetupInBackground", () => {
);
const paseoHome = path.join(tempDir, ".paseo");
recordPhase("fixture-ready");
const controller = new AbortController();
const completion = runWorktreeSetupInBackground(
{
paseoHome,
emitWorkspaceUpdateForWorkspaceId: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
cacheWorkspaceSetupSnapshot: (_workspaceId, snapshot) => {
const lastCommand = snapshot.detail.commands.at(-1);
recordPhase(
lastCommand
? `setup-${snapshot.status}:command-${lastCommand.status}`
: `setup-${snapshot.status}`,
);
},
emit: () => {},
sessionLogger: createLogger(),
terminalManager: null,
@@ -611,8 +627,10 @@ describe("runWorktreeSetupInBackground", () => {
controller.signal,
);
const stop = async () => {
recordPhase("cleanup-started");
controller.abort();
await completion;
recordPhase("cleanup-joined");
};
stopSetup = stop;
return { tempDir, workspaceCwd, worktreePath: worktree.worktreePath, completion, stop };