mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
* fix(usage): keep details hoverable and restore compact sidebar sheets Share hover-card mechanics with workspace details so context usage can stay open while users press Refresh. Honor Retry-After for rate-limited usage requests, and let both sidebar entry points share the compact sheet. * fix(usage): restore settings cog with a fitted popover and compact sheet Keep shared Usage preferences in the title controls on every surface. Settings rows use the menu surface directly, avoiding the trailing margin and duplicate card border from the page layout. * fix(app): show account usage before the first agent turn Keep the context meter available for existing agents without context readings so account usage can be opened before a turn. Show an empty ring and an explicit no-data state until readings arrive. * fix(usage): preserve keyboard access and hover card positioning Context details must remain reachable by keyboard after replacing the tooltip. Keep focus within the card, restore side flipping, and dismiss detached cards when the viewport moves. Prune expired cooldowns even after credential rotation. * chore: remove QA screenshots from the source tree * fix(app): let hover cards claim Escape before agent shortcuts Register hover surfaces with the shared overlay keyboard owner without moving or trapping focus on pointer hover. Component-local Escape handling ran after the global interrupt shortcut and cancelled the underlying turn. * fix(app): reopen focused hover cards with button keys Keep focused triggers registered with overlay arbitration after dismissal so activation keys are handled before composer shortcuts. * fix(app): release overlay ownership when hover cards close Closed triggers handle activation locally, so a dismissed card does not block pane Find. Open cards retain shared Escape arbitration to protect running turns. * test(app): pin Linux keyboard identity for composer Find * test(app): synchronize context usage hover journeys Return focus to the composer before asserting pointer dismissal, wait for the card to reach the viewport, and park the pointer before reloading so it cannot consume the next scripted usage response early.
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { expect, type Page } from "@playwright/test";
|
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|
import { createTempGitRepo } from "./workspace";
|
|
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
|
import { gotoAppShell } from "./app";
|
|
import { connectWorkspaceSetupClient } from "./workspace-setup";
|
|
import { selectWorkspaceInSidebar } from "./sidebar";
|
|
import { getServerId } from "./server-id";
|
|
import { waitForTabBar } from "./launcher";
|
|
import { waitForSettledPosition } from "./sheet-layout";
|
|
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
|
|
|
export async function controlFileUploadCompletion(page: Page) {
|
|
const gate = await installDaemonWebSocketGate(page);
|
|
return {
|
|
hold: () => gate.holdNextServerMessage("file.upload.response"),
|
|
waitForUpload: () => gate.waitForHeldServerMessage("file.upload.response"),
|
|
complete: () => gate.releaseHeldServerMessage("file.upload.response"),
|
|
};
|
|
}
|
|
|
|
function composerInput(page: Page) {
|
|
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
|
}
|
|
|
|
export function composerLocator(page: Page) {
|
|
return composerInput(page);
|
|
}
|
|
|
|
export async function expectComposerVisible(
|
|
page: Page,
|
|
options?: { timeout?: number },
|
|
): Promise<void> {
|
|
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
|
}
|
|
|
|
export async function expectComposerDisabled(page: Page): Promise<void> {
|
|
// React Native TextInput with editable={false} renders as <textarea readonly> on web,
|
|
// not <textarea disabled>. Use not.toBeEditable() to match either form.
|
|
await expect(composerInput(page)).not.toBeEditable({ timeout: 10_000 });
|
|
}
|
|
|
|
export async function expectComposerDraft(page: Page, text: string): Promise<void> {
|
|
await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 });
|
|
}
|
|
|
|
export async function expectComposerEditable(page: Page): Promise<void> {
|
|
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
|
}
|
|
|
|
export async function expectComposerFocused(page: Page): Promise<void> {
|
|
await expect(composerInput(page)).toBeFocused();
|
|
}
|
|
|
|
export async function expectComposerNotFocused(page: Page): Promise<void> {
|
|
await expect(composerInput(page)).not.toBeFocused();
|
|
}
|
|
|
|
export async function submitMessage(page: Page, text: string): Promise<void> {
|
|
const input = composerInput(page);
|
|
await expect(input).toBeEditable({ timeout: 30_000 });
|
|
await input.fill(text);
|
|
await input.press("Enter");
|
|
}
|
|
|
|
/** The Send button works on compact screens, where Enter inserts a newline. */
|
|
export async function submitMessageWithButton(page: Page, text: string): Promise<void> {
|
|
await fillComposerDraft(page, text);
|
|
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
|
}
|
|
|
|
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
|
|
await composerInput(page).fill(text);
|
|
}
|
|
|
|
export async function typeIntoFocusedComposer(page: Page, text: string): Promise<void> {
|
|
await page.keyboard.type(text);
|
|
}
|
|
|
|
export async function sendDraftToQueue(page: Page): Promise<void> {
|
|
await composerInput(page).press("Control+Enter");
|
|
}
|
|
|
|
export async function expectQueuedMessageButton(page: Page): Promise<void> {
|
|
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
}
|
|
|
|
export async function cancelAgent(page: Page): Promise<void> {
|
|
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
|
|
await expect(stopButton).toBeVisible({ timeout: 10_000 });
|
|
await stopButton.click();
|
|
}
|
|
|
|
/** Escape is bound to the "agent.interrupt" keyboard shortcut. */
|
|
export async function pressInterruptShortcut(page: Page): Promise<void> {
|
|
await page.keyboard.press("Escape");
|
|
}
|
|
|
|
export async function openAttachmentMenu(page: Page): Promise<void> {
|
|
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
|
|
await expect(
|
|
page
|
|
.getByTestId("message-input-attachment-menu")
|
|
.or(page.getByTestId("message-input-attachment-menu-content")),
|
|
).toBeVisible({ timeout: 5_000 });
|
|
}
|
|
|
|
export async function expectAttachmentSheetRowsOnTitleRail(page: Page): Promise<void> {
|
|
const title = page.getByText("Add attachment", { exact: true });
|
|
const firstItemGlyph = page
|
|
.getByRole("menuitem", { name: "Add image", exact: true })
|
|
.locator("svg")
|
|
.first();
|
|
await waitForSettledPosition(title);
|
|
const [titleBox, glyphBox] = await Promise.all([
|
|
title.boundingBox(),
|
|
firstItemGlyph.boundingBox(),
|
|
]);
|
|
if (!titleBox || !glyphBox) {
|
|
throw new Error("Attachment sheet geometry could not be measured");
|
|
}
|
|
expect(Math.abs(glyphBox.x - titleBox.x)).toBeLessThanOrEqual(1);
|
|
}
|
|
|
|
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
|
|
await expect(
|
|
page.getByTestId("message-input-attach-button").filter({ visible: true }).first(),
|
|
).toBeDisabled({ timeout: 10_000 });
|
|
}
|
|
|
|
export async function attachImageFromMenu(
|
|
page: Page,
|
|
file: { name: string; mimeType: string; buffer: Buffer },
|
|
): Promise<void> {
|
|
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
|
|
await openAttachmentMenu(page);
|
|
await page.getByTestId("message-input-attachment-menu-item-image").click();
|
|
const chooser = await chooserPromise;
|
|
await chooser.setFiles([file]);
|
|
}
|
|
|
|
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
|
|
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
export async function attachFileFromMenu(
|
|
page: Page,
|
|
file: { name: string; mimeType: string; buffer: Buffer },
|
|
): Promise<void> {
|
|
await openAttachmentMenu(page);
|
|
const chooserPromise = page.waitForEvent("filechooser");
|
|
await page.getByRole("menuitem", { name: "Upload file", exact: true }).click();
|
|
await (await chooserPromise).setFiles(file);
|
|
}
|
|
|
|
export async function dropFileOnComposer(
|
|
page: Page,
|
|
file: { name: string; mimeType: string; buffer: Buffer },
|
|
): Promise<void> {
|
|
const dataTransfer = await page.evaluateHandle(
|
|
({ name, mimeType, base64 }) => {
|
|
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
const droppedFile = new File([bytes], name, { type: mimeType });
|
|
const transfer = new DataTransfer();
|
|
transfer.items.add(droppedFile);
|
|
return transfer;
|
|
},
|
|
{
|
|
name: file.name,
|
|
mimeType: file.mimeType,
|
|
base64: file.buffer.toString("base64"),
|
|
},
|
|
);
|
|
|
|
const composerRoot = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
|
await expect(composerRoot).toBeVisible({ timeout: 10_000 });
|
|
await composerRoot.dispatchEvent("dragenter", { dataTransfer });
|
|
await composerRoot.dispatchEvent("dragover", { dataTransfer });
|
|
await composerRoot.dispatchEvent("drop", { dataTransfer });
|
|
await dataTransfer.dispose();
|
|
}
|
|
|
|
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
|
|
export async function removeAttachmentPill(
|
|
page: Page,
|
|
pillTestId: string,
|
|
removeAccessibilityLabel: string,
|
|
): Promise<void> {
|
|
await page.getByTestId(pillTestId).first().hover();
|
|
await page.getByRole("button", { name: removeAccessibilityLabel }).first().click();
|
|
}
|
|
|
|
export async function expectGithubAttachmentPill(
|
|
page: Page,
|
|
input: { number: number; title: string },
|
|
): Promise<void> {
|
|
const pill = page.getByTestId("composer-github-attachment-pill").filter({ hasText: input.title });
|
|
await expect(pill).toBeVisible({ timeout: 10_000 });
|
|
await expect(pill).toContainText(`#${input.number}`);
|
|
await expect(pill).toContainText(input.title);
|
|
}
|
|
|
|
export async function openImageLightbox(page: Page): Promise<void> {
|
|
await page.getByRole("button", { name: "Open image attachment" }).first().click();
|
|
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
|
|
}
|
|
|
|
export async function closeImageLightbox(page: Page): Promise<void> {
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
|
|
}
|
|
|
|
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
|
|
await openAttachmentMenu(page);
|
|
await page.getByTestId("message-input-attachment-menu-item-github").click();
|
|
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
|
|
}
|
|
|
|
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */
|
|
export async function selectGithubOption(
|
|
page: Page,
|
|
searchTerm: string,
|
|
optionId: string,
|
|
): Promise<void> {
|
|
await openGithubPickerFromMenu(page);
|
|
const searchInput = page.getByPlaceholder("Search issues and PRs...");
|
|
await expect(searchInput).toBeVisible({ timeout: 5_000 });
|
|
await searchInput.fill(searchTerm);
|
|
const option = page.getByTestId(`composer-github-option-${optionId}`);
|
|
await expect(option).toBeVisible({ timeout: 15_000 });
|
|
await option.click();
|
|
}
|
|
|
|
export interface MockAgentSetup {
|
|
client: SeedDaemonClient;
|
|
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
|
workspaceId: string;
|
|
agentId: string;
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
|
|
export async function startRunningMockAgent(
|
|
page: Page,
|
|
opts: {
|
|
prefix: string;
|
|
model: string;
|
|
prompt: string;
|
|
featureValues?: Record<string, unknown>;
|
|
},
|
|
): Promise<MockAgentSetup> {
|
|
const serverId = getServerId();
|
|
|
|
const repo = await createTempGitRepo(opts.prefix);
|
|
const client = await connectSeedClient();
|
|
const createdWorkspace = await client.createWorkspace({
|
|
source: { kind: "directory", path: repo.path },
|
|
});
|
|
if (!createdWorkspace.workspace) {
|
|
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
|
|
}
|
|
const workspace = createdWorkspace.workspace;
|
|
const agent = await client.createAgent({
|
|
provider: "mock",
|
|
cwd: repo.path,
|
|
workspaceId: workspace.id,
|
|
model: opts.model,
|
|
featureValues: opts.featureValues,
|
|
});
|
|
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
|
await page.goto(agentUrl);
|
|
await expectComposerVisible(page);
|
|
await client.sendAgentMessage(agent.id, opts.prompt);
|
|
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
return {
|
|
client,
|
|
repo,
|
|
workspaceId: workspace.id,
|
|
agentId: agent.id,
|
|
cleanup: async () => {
|
|
await client.removeProject(workspace.projectId).catch(() => undefined);
|
|
await client.close().catch(() => undefined);
|
|
await repo.cleanup().catch(() => undefined);
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface GithubWorkspaceHandle {
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */
|
|
export async function openGithubWorkspace(
|
|
page: Page,
|
|
repoPath: string,
|
|
): Promise<GithubWorkspaceHandle> {
|
|
const client = await connectWorkspaceSetupClient();
|
|
const createdWorkspace = await client.createWorkspace({
|
|
source: { kind: "directory", path: repoPath },
|
|
});
|
|
if (!createdWorkspace.workspace) {
|
|
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
|
|
}
|
|
const workspace = createdWorkspace.workspace;
|
|
await gotoAppShell(page);
|
|
await selectWorkspaceInSidebar(page, workspace.id);
|
|
await waitForTabBar(page);
|
|
return {
|
|
cleanup: async () => {
|
|
await client.removeProject(workspace.projectId).catch(() => undefined);
|
|
await client.close().catch(() => undefined);
|
|
},
|
|
};
|
|
}
|