mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Merge remote-tracking branch 'origin/main' into diagnose/3589-contributor-6b87ee9f
This commit is contained in:
@@ -142,6 +142,7 @@ export default {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"./modules/paseo-hardware-keyboard/app.plugin",
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidScroll,
|
||||
|
||||
@@ -94,3 +94,21 @@ it("allows all orientations on iPhone and iPad", async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Scene startup is the integration boundary for the iOS keyboard window.
|
||||
describe("hardware keyboard scene integration", () => {
|
||||
it("installs observation after Expo creates the scene window and is idempotent", () => {
|
||||
const {
|
||||
configureHardwareKeyboardAppDelegate,
|
||||
} = require("./modules/paseo-hardware-keyboard/app.plugin.js");
|
||||
const source =
|
||||
"import Expo\nclass Scene {\n super.scene(scene, willConnectTo: session, options: connectionOptions)\n}\n";
|
||||
const configured = configureHardwareKeyboardAppDelegate(source);
|
||||
expect(configured).toContain("import PaseoHardwareKeyboard");
|
||||
expect(configured).toContain("PaseoHardwareKeyboardWindow.observing(window)");
|
||||
expect(configured.indexOf("super.scene")).toBeLessThan(
|
||||
configured.indexOf(".observing(window)"),
|
||||
);
|
||||
expect(configureHardwareKeyboardAppDelegate(configured)).toBe(configured);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
addProjectFlow,
|
||||
chooseAddHostFromProjectFlow,
|
||||
addProjectFlowBack,
|
||||
addProjectFlowHost,
|
||||
addProjectFlowInput,
|
||||
@@ -14,11 +15,14 @@ import {
|
||||
expectNewWorkspaceForAddedProject,
|
||||
expectAddProjectResultVisible,
|
||||
navigateAddProjectResults,
|
||||
navigateAndDismissAddProject,
|
||||
watchAddProjectRenderWarnings,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
disconnectAllHostsAndReload,
|
||||
addConnectedHostAndReload,
|
||||
addConnectedHostsAndReload,
|
||||
addOfflineHostAndReload,
|
||||
@@ -69,6 +73,19 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await disconnectAllHostsAndReload(page);
|
||||
await openAddProjectHostSelection(page);
|
||||
const originalUrl = page.url();
|
||||
await chooseAddHostFromProjectFlow(page);
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
});
|
||||
|
||||
test("method selection shows the daemon's available project sources without search", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -131,6 +148,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
test("Escape and Back restore searchable page input before closing at the root", async ({
|
||||
page,
|
||||
}) => {
|
||||
const renderWarnings = watchAddProjectRenderWarnings(page);
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
@@ -168,8 +186,9 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expectAddProjectPage(page, "host");
|
||||
await page.keyboard.press("Escape");
|
||||
await navigateAndDismissAddProject(page);
|
||||
await expect(addProjectFlow(page)).not.toBeVisible();
|
||||
expect(renderWarnings).toEqual([]);
|
||||
});
|
||||
|
||||
test("New directory creates a Project on the selected remote host", async ({ page }) => {
|
||||
|
||||
@@ -235,6 +235,7 @@ async function beginTimelineRowStabilityCheck(
|
||||
lastTop: initialRow.getBoundingClientRect().top,
|
||||
largestDownwardShift: 0,
|
||||
sawMissing: false,
|
||||
sawHidden: false,
|
||||
};
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
windowState.__messageSubmissionTimelineRowStability = state;
|
||||
@@ -245,6 +246,9 @@ async function beginTimelineRowStabilityCheck(
|
||||
requestAnimationFrame(checkFrame);
|
||||
return;
|
||||
}
|
||||
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
|
||||
state.sawHidden = true;
|
||||
}
|
||||
const top = initialRow.getBoundingClientRect().top;
|
||||
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
|
||||
state.lastTop = top;
|
||||
@@ -257,14 +261,24 @@ async function beginTimelineRowStabilityCheck(
|
||||
const result = await page.evaluate(() => {
|
||||
const windowState = window as unknown as Record<string, unknown>;
|
||||
const state = windowState.__messageSubmissionTimelineRowStability as
|
||||
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
|
||||
| {
|
||||
active: boolean;
|
||||
largestDownwardShift: number;
|
||||
sawMissing: boolean;
|
||||
sawHidden: boolean;
|
||||
}
|
||||
| undefined;
|
||||
if (!state) throw new Error("Timeline-row stability check was not started");
|
||||
state.active = false;
|
||||
delete windowState.__messageSubmissionTimelineRowStability;
|
||||
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
|
||||
return {
|
||||
largestDownwardShift: state.largestDownwardShift,
|
||||
sawMissing: state.sawMissing,
|
||||
sawHidden: state.sawHidden,
|
||||
};
|
||||
});
|
||||
expect(result.sawMissing).toBe(false);
|
||||
expect(result.sawHidden).toBe(false);
|
||||
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
|
||||
};
|
||||
}
|
||||
@@ -1178,7 +1192,13 @@ test.describe("Agent message submission", () => {
|
||||
test.setTimeout(120_000);
|
||||
const toasts = await recordPanelToasts(page);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
|
||||
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
|
||||
page,
|
||||
pending.userMessage,
|
||||
);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
await finishTimelineRowStabilityCheck();
|
||||
// A chat this client just created is current by construction; it is never out of date.
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
@@ -18,8 +18,10 @@ test.describe("Agent timeline sync retry", () => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 2 });
|
||||
try {
|
||||
gate.holdNextClientRequest("clear_agent_attention");
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
gate.failTimelineResponses(agent.agentId);
|
||||
await disconnectViewedTimeline(page, gate);
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test as baseTest } from "../support/fixtures";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
|
||||
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
|
||||
// within a few milliseconds of each other. These tests hold all three, let the running
|
||||
// turn paint, then release them together and sample every painted frame.
|
||||
|
||||
interface Box {
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TurnEndFrame {
|
||||
at: number;
|
||||
running: boolean;
|
||||
footer: Box | null;
|
||||
userRow: Box | null;
|
||||
replyVisible: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
|
||||
}
|
||||
}
|
||||
|
||||
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
|
||||
agent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-end-",
|
||||
title: "Turn end",
|
||||
initialPrompt: "emit 4 tool agent stream updates",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await provide(agent);
|
||||
await agent.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function recordTurnEndFrames(
|
||||
page: Page,
|
||||
input: { prompt: string; reply: string | null },
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ prompt, reply }) => {
|
||||
const state = { active: true, frames: [] as TurnEndFrame[] };
|
||||
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
|
||||
const boxOf = (element: Element | null | undefined): Box | null => {
|
||||
if (!element || !isVisible(element)) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
|
||||
};
|
||||
const sample = () => {
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => isVisible(candidate));
|
||||
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.map(boxOf)
|
||||
.filter((box): box is Box => box !== null)
|
||||
.sort((left, right) => left.top - right.top);
|
||||
const userRow = Array.from(
|
||||
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
|
||||
).find((candidate) => candidate.textContent?.includes(prompt));
|
||||
const replyRow =
|
||||
reply === null
|
||||
? undefined
|
||||
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
|
||||
(candidate) => candidate.textContent?.includes(reply),
|
||||
);
|
||||
state.frames.push({
|
||||
at: performance.now(),
|
||||
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
|
||||
footer: footers.at(-1) ?? null,
|
||||
userRow: boxOf(userRow),
|
||||
replyVisible: boxOf(replyRow) !== null,
|
||||
});
|
||||
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
|
||||
};
|
||||
window.__turnEndFrames = state;
|
||||
requestAnimationFrame(() => setTimeout(sample, 0));
|
||||
}, input);
|
||||
}
|
||||
|
||||
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
|
||||
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
|
||||
.toBeGreaterThanOrEqual(until);
|
||||
}
|
||||
|
||||
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__turnEndFrames;
|
||||
if (!state) throw new Error("Turn end frames were not recorded");
|
||||
state.active = false;
|
||||
return state.frames;
|
||||
});
|
||||
}
|
||||
|
||||
async function recordTurnEnd(
|
||||
page: Page,
|
||||
agent: MockAgentWorkspace,
|
||||
input: { prompt: string; reply: string | null },
|
||||
): Promise<TurnEndFrame[]> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
await expectAgentIdle(page);
|
||||
|
||||
if (input.reply === null) {
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
} else {
|
||||
gate.holdNextAgentStreamItem("assistant_message");
|
||||
}
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await recordTurnEndFrames(page, input);
|
||||
await submitMessage(page, input.prompt);
|
||||
await Promise.all([
|
||||
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await recordPaintsFor(page, 200);
|
||||
|
||||
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
|
||||
gate.releaseHeldAgentStreamEvent("turn_completed");
|
||||
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
await expectAgentIdle(page);
|
||||
await recordPaintsFor(page, 300);
|
||||
return stopRecording(page);
|
||||
}
|
||||
|
||||
function describeFrames(frames: TurnEndFrame[]): string {
|
||||
return frames
|
||||
.map(
|
||||
(frame, index) =>
|
||||
`${index}: ${frame.running ? "running " : "complete"} footer=${
|
||||
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
|
||||
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
|
||||
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
|
||||
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
|
||||
const record = `frames:\n${describeFrames(frames)}`;
|
||||
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
|
||||
expect(firstComplete, record).toBeGreaterThan(firstRunning);
|
||||
|
||||
const transition = frames.slice(firstRunning);
|
||||
const runningHeight = transition[0].footer?.height;
|
||||
expect(
|
||||
transition.map((frame) => frame.footer?.height ?? null),
|
||||
`the footer must stay painted at one height\n${record}`,
|
||||
).toEqual(transition.map(() => runningHeight));
|
||||
if (input.reply !== null) {
|
||||
expect(
|
||||
frames.slice(firstComplete).every((frame) => frame.replyVisible),
|
||||
`a completed turn must show its reply in the same frame\n${record}`,
|
||||
).toBe(true);
|
||||
}
|
||||
// Tool rows land while the turn runs; only the turn end itself must be one step.
|
||||
const ending = frames.slice(firstComplete - 1);
|
||||
const promptMoves = ending
|
||||
.slice(1)
|
||||
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
|
||||
expect(
|
||||
promptMoves.length,
|
||||
`the prompt must move at most once as the turn ends\n${record}`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
test.describe("Turn end", () => {
|
||||
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
|
||||
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
|
||||
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
|
||||
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
});
|
||||
|
||||
async function expectTimingInsideFooter(page: Page): Promise<void> {
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => {
|
||||
const labelBounds = element.parentElement!.getBoundingClientRect();
|
||||
const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect();
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
const lines: boolean[] = [];
|
||||
while (walker.nextNode()) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(walker.currentNode);
|
||||
for (const rect of range.getClientRects()) {
|
||||
lines.push(
|
||||
rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 &&
|
||||
rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 &&
|
||||
rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 &&
|
||||
rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
expect(paintedLines.length).toBeGreaterThan(0);
|
||||
expect(paintedLines.every(Boolean)).toBe(true);
|
||||
}
|
||||
|
||||
async function expectFooterWithinWidth(page: Page, width: number): Promise<void> {
|
||||
const footer = page.getByTestId("turn-footer").last();
|
||||
await footer.evaluate((element, availableWidth) => {
|
||||
element.style.width = `${availableWidth}px`;
|
||||
}, width);
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
await expect(label).toBeVisible();
|
||||
await expectTimingInsideFooter(page);
|
||||
const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ });
|
||||
const before = await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
);
|
||||
const footerBefore = await footer.boundingBox();
|
||||
const labelBefore = await label.boundingBox();
|
||||
await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } });
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/);
|
||||
expect(await label.boundingBox()).toMatchObject({
|
||||
x: labelBefore!.x,
|
||||
width: labelBefore!.width,
|
||||
height: labelBefore!.height,
|
||||
});
|
||||
expect(
|
||||
await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
),
|
||||
).toEqual(before);
|
||||
expect(await footer.boundingBox()).toMatchObject({
|
||||
x: footerBefore!.x,
|
||||
y: footerBefore!.y,
|
||||
width: footerBefore!.width,
|
||||
});
|
||||
await expectTimingInsideFooter(page);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for");
|
||||
}
|
||||
|
||||
test("footer timing fits the chat column and swapping labels keeps controls in place", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectFooterWithinWidth(page, 140);
|
||||
await expectFooterWithinWidth(page, 150);
|
||||
await expectFooterWithinWidth(page, 300);
|
||||
await expectFooterWithinWidth(page, 880);
|
||||
});
|
||||
|
||||
async function visibleFooterLeftEdges(page: Page): Promise<number[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => candidate.checkVisibility());
|
||||
return Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.filter((footer) => footer.checkVisibility())
|
||||
.map((footer) => Math.round(footer.getBoundingClientRect().left));
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFootersInOneColumn(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const edges = await visibleFooterLeftEdges(page);
|
||||
return edges.length >= 2 && new Set(edges).size === 1 ? "one column" : edges;
|
||||
})
|
||||
.toBe("one column");
|
||||
}
|
||||
|
||||
test("keeps the latest turn's footer in the chat column while it runs and after it ends", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
// Wider than the chat's max width, so the column is centered with space on both sides.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates");
|
||||
await Promise.all([
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expectFootersInOneColumn(page);
|
||||
|
||||
gate.releaseHeldAgentStreamEvent("turn_completed");
|
||||
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
await expectAgentIdle(page);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expectFootersInOneColumn(page);
|
||||
});
|
||||
@@ -9,9 +9,11 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const themeTrigger = page.getByLabel("Theme: System", { exact: true });
|
||||
await themeTrigger.click();
|
||||
await expect(page.getByText("Pure black", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole("radiogroup", { name: "Theme" }).getByRole("radio").first(),
|
||||
).toHaveAccessibleName("System");
|
||||
await expect(page.getByRole("radio", { name: "Pure black", exact: true })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("appearance-theme-picker.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
closeActiveAgentWithShortcut,
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
@@ -20,8 +21,12 @@ import {
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
resetSeededPageState,
|
||||
unarchiveAgentFromConversation,
|
||||
reloadWorkspace,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectComposerDraft, fillComposerDraft, submitMessage } from "../support/helpers/composer";
|
||||
import { awaitAssistantMessage, expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
@@ -51,6 +56,34 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("restoring a closed agent retains its unsent composer draft after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "archived-composer-",
|
||||
title: "Restore unsent draft",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await submitMessage(page, "A completed conversation before closing");
|
||||
await awaitAssistantMessage(page);
|
||||
await expectAgentIdle(page);
|
||||
await fillComposerDraft(page, "Keep this unsent message after closing");
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
await closeActiveAgentWithShortcut(page, agent.agentId);
|
||||
await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull();
|
||||
await page.reload();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, "Restore unsent draft");
|
||||
await clickSessionRow(page, "Restore unsent draft");
|
||||
await expectArchivedAgentFocused(page, agent.agentId);
|
||||
await unarchiveAgentFromConversation(page);
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
|
||||
await page.getByRole("link", { name: linkName, exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
async function returnToNestedLinks(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "nested-file-link-",
|
||||
title: "Nested file links",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse:
|
||||
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "repo-a/schema/x.md"),
|
||||
"# Nested file content\n\nDetails\n",
|
||||
);
|
||||
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
|
||||
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
|
||||
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
|
||||
await readLinkedGuide(page, "nested guide", "Nested file content");
|
||||
await returnToNestedLinks(page);
|
||||
await readLinkedGuide(page, "exact guide", "Exact file content");
|
||||
await returnToNestedLinks(page);
|
||||
await page.getByRole("link", { name: "missing guide", exact: true }).click();
|
||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -723,3 +723,46 @@ test("copying a selection across an assistant image keeps its Markdown source an
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("on a touch-capable desktop browser", () => {
|
||||
// A touch screen registers the navigation pan's pointer listeners over the whole workspace.
|
||||
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-drag-",
|
||||
title: "Assistant selection drag",
|
||||
initialPrompt: "Render three paragraphs.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
const blocks = assistantMessageBlocks(page);
|
||||
const first = await blocks.getByText("First paragraph.").boundingBox();
|
||||
const third = await blocks.getByText("Third paragraph.").boundingBox();
|
||||
if (!first || !third) {
|
||||
throw new Error("Expected rendered paragraphs");
|
||||
}
|
||||
|
||||
await page.mouse.move(first.x + 1, first.y + first.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(third.x + third.width - 1, third.y + third.height / 2, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
|
||||
expect(selected).toContain("First paragraph.");
|
||||
expect(selected).toContain("Third paragraph.");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectWorkspaceShownIdle,
|
||||
finishAndArchiveAgent,
|
||||
startBackgroundAgent,
|
||||
} from "../support/helpers/background-agents";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentsTrack,
|
||||
seedParentWithSubagent,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Background agents", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "background-agents-",
|
||||
title: "Classifier workspace",
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("a background agent runs in an open workspace without announcing itself", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Feature agent",
|
||||
childTitle: "Visible helper",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
const classifier = await test.step("a plugin starts a background agent", () =>
|
||||
startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" }));
|
||||
// A visible child created after the classifier reaches the app on the same stream, so once
|
||||
// its row shows, the app has processed the classifier too.
|
||||
const marker = await seedSubagent(workspace, {
|
||||
parentId: agents.parent.id,
|
||||
title: "Ordering marker",
|
||||
});
|
||||
|
||||
await test.step("it stays out of the track, the tabs and the sidebar status", async () => {
|
||||
await expectSubagentRowVisible(page, marker.id);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectWorkspaceShownIdle(page, "Classifier workspace");
|
||||
});
|
||||
|
||||
await test.step("the plugin reads its finish and archives it", async () => {
|
||||
await finishAndArchiveAgent(workspace, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { writeFile, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
expectNoBranchSwitcherInWorkspaceHeader,
|
||||
@@ -14,6 +17,95 @@ import {
|
||||
waitForSidebarHydration,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
// Hold the real daemon response until the test has observed the pending UI.
|
||||
async function holdBranchSuggestions(page: Page) {
|
||||
let requestCount = 0;
|
||||
let release!: () => void;
|
||||
let gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||
requestCount++;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage(async (message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "branch_suggestions_response"
|
||||
) {
|
||||
await gate;
|
||||
}
|
||||
}
|
||||
browser.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
release: () => release(),
|
||||
holdNextResponse: () => {
|
||||
gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
requestCount: () => requestCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
|
||||
await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click();
|
||||
const picker = page.getByTestId("combobox-desktop-container");
|
||||
await expect(picker.getByText("Loading...", { exact: true })).toBeVisible();
|
||||
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
||||
release();
|
||||
await expect(picker.getByText("dev", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(picker).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function expectFailedRefreshRecovery(
|
||||
page: Page,
|
||||
repoPath: string,
|
||||
responses: Awaited<ReturnType<typeof holdBranchSuggestions>>,
|
||||
) {
|
||||
// Expire the actual query freshness window before reopening the cached picker.
|
||||
const started = Date.now();
|
||||
await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000);
|
||||
const picker = page.getByTestId("combobox-desktop-container");
|
||||
const packedRefs = path.join(repoPath, ".git", "packed-refs");
|
||||
await writeFile(packedRefs, "invalid packed refs\n");
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Current branch: dev. Press to switch branch." })
|
||||
.click();
|
||||
await expect(picker.getByRole("alert")).toHaveText(
|
||||
"Could not load branches. Reopen the picker to try again.",
|
||||
);
|
||||
} finally {
|
||||
await unlink(packedRefs);
|
||||
}
|
||||
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
|
||||
responses.holdNextResponse();
|
||||
const beforeRetry = responses.requestCount();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click();
|
||||
await expect(picker.getByRole("alert")).toHaveText("Loading...");
|
||||
await expect.poll(responses.requestCount).toBe(beforeRetry + 1);
|
||||
await expect(picker).toBeVisible();
|
||||
responses.release();
|
||||
await expect(picker.getByRole("alert")).toHaveCount(0);
|
||||
await expect(picker.getByText("main", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
expect(responses.requestCount()).toBe(beforeRetry + 1);
|
||||
}
|
||||
|
||||
async function renameWorkspaceViaSidebar(
|
||||
page: Page,
|
||||
input: { workspaceId: string; title: string },
|
||||
@@ -59,6 +151,7 @@ test.describe("Branch switcher", () => {
|
||||
try {
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
|
||||
const releaseBranches = await holdBranchSuggestions(page);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
||||
@@ -82,8 +175,10 @@ test.describe("Branch switcher", () => {
|
||||
// and switching it checks out the real branch on disk.
|
||||
await openChangesPanel(page);
|
||||
await expectWorkspaceBranch(page, "main");
|
||||
await expectInitialBranchLoading(page, releaseBranches.release);
|
||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches);
|
||||
|
||||
// The custom title is unaffected by the branch switch.
|
||||
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
|
||||
|
||||
@@ -8,7 +8,11 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
openChangesPanel,
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
@@ -262,6 +266,126 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes without a comparison base explains how to view pending changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev");
|
||||
await openWorkspaceChangesTree(page, workspace);
|
||||
await expect(
|
||||
page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."),
|
||||
).toBeVisible();
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesTreePanel(page);
|
||||
}
|
||||
|
||||
test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await createLargeStreamedWorkspace();
|
||||
await addOversizedSibling(workspace);
|
||||
const stream = await holdDiffCompletion(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
await expectAnnouncedTreeAndLoader(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tree-loading.png") });
|
||||
stream.finish();
|
||||
await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
const connections = stream.connections;
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1");
|
||||
await addOneLineToFirstFile(workspace);
|
||||
await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1);
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1");
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
expect(stream.connections).toBe(connections);
|
||||
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
|
||||
});
|
||||
|
||||
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(252);
|
||||
await Promise.all(
|
||||
Array.from({ length: 252 }, (_, file) =>
|
||||
writeFile(
|
||||
path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`),
|
||||
Array.from(
|
||||
{ length: 90 },
|
||||
(_unused, line) =>
|
||||
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
|
||||
).join("\n") + "\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
async function addOversizedSibling(workspace: DirtyWorkspace): Promise<void> {
|
||||
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
|
||||
}
|
||||
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
|
||||
const file = path.join(workspace.repoPath, "src/file-0000.ts");
|
||||
await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n");
|
||||
}
|
||||
async function holdDiffCompletion(page: Page) {
|
||||
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
|
||||
let holding = true;
|
||||
let connections = 0;
|
||||
let completed = 0;
|
||||
const completions: Array<() => void> = [];
|
||||
const changedBodies: number[] = [];
|
||||
const bodyCounts = new Map<number, number>();
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
connections++;
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (
|
||||
typeof message !== "string" &&
|
||||
message[0] >= DiffOpcode.Begin &&
|
||||
message[0] <= DiffOpcode.End
|
||||
) {
|
||||
const frame = decodeDiffFrame(message);
|
||||
if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0);
|
||||
else if (frame.opcode === DiffOpcode.Bodies)
|
||||
bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length);
|
||||
else {
|
||||
completed++;
|
||||
changedBodies.push(bodyCounts.get(frame.slot) ?? 0);
|
||||
}
|
||||
}
|
||||
if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) {
|
||||
completions.push(() => browserSocket.send(message));
|
||||
return;
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
changedBodies,
|
||||
get connections() {
|
||||
return connections;
|
||||
},
|
||||
get completed() {
|
||||
return completed;
|
||||
},
|
||||
finish: () => {
|
||||
holding = false;
|
||||
completions.splice(0).forEach((send) => send());
|
||||
},
|
||||
};
|
||||
}
|
||||
async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise<void> {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout });
|
||||
}
|
||||
async function expectAnnouncedTreeAndLoader(page: Page): Promise<void> {
|
||||
await expect(changesTree(page).first()).toBeVisible();
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("Changes opens All changes for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
@@ -323,6 +447,119 @@ test("All changes combines committed and pending edits and preserves an unfinish
|
||||
});
|
||||
});
|
||||
|
||||
test("Changes selects exact local and remote bases and retains the selection after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "main"], {
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const picker = tree.getByTestId("workspace-base-branch-trigger");
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.click();
|
||||
await expect(page.getByRole("button", { name: "release", exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("base-picker.png") });
|
||||
await page.getByRole("button", { name: "release", exact: true }).click();
|
||||
await expect(picker).toContainText("release");
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await picker.click();
|
||||
await page.getByRole("button", { name: "upstream/release", exact: true }).click();
|
||||
await expect(picker).toContainText("upstream/release");
|
||||
await expect(tree.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("base-selected.png") });
|
||||
await page.reload();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(picker).toContainText("upstream/release");
|
||||
expect(execFileSync("git", ["rev-parse", "HEAD"], { cwd: workspace.repoPath }).toString()).toBe(
|
||||
head,
|
||||
);
|
||||
});
|
||||
|
||||
test("Changes lets a compact workspace change its base in a sheet", async ({ page }, testInfo) => {
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await openCompactChanges(page, workspace);
|
||||
const picker = page.getByRole("button", { name: "Change base" }).filter({ visible: true });
|
||||
await picker.click();
|
||||
await expect(page.getByText("Change base", { exact: true })).toBeVisible();
|
||||
const release = page.getByRole("button", { name: "release", exact: true });
|
||||
await expect(release).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("compact-base-picker.png") });
|
||||
await release.click();
|
||||
await expect(picker).toContainText("release");
|
||||
await page.screenshot({ path: testInfo.outputPath("compact-base-selected.png") });
|
||||
});
|
||||
|
||||
test("Changes shows a failed base selection and lets the user retry", async ({ page }) => {
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const picker = page
|
||||
.getByTestId("workspace-base-branch-trigger")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await picker.click();
|
||||
const option = page.getByRole("button", { name: "release", exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
execFileSync("git", ["branch", "-D", "release"], { cwd: workspace.repoPath });
|
||||
await option.click();
|
||||
await expect(page.getByTestId("workspace-base-branch-error")).toContainText(
|
||||
"Base branch not found",
|
||||
);
|
||||
await expect(picker).toContainText("main");
|
||||
execFileSync("git", ["branch", "release"], { cwd: workspace.repoPath });
|
||||
await option.click();
|
||||
await expect(page.getByTestId("workspace-base-branch-error")).toHaveCount(0);
|
||||
await expect(picker).toContainText("release");
|
||||
});
|
||||
|
||||
test("Changes shows the base as text on a host without the base-branch capability", async ({
|
||||
page,
|
||||
}) => {
|
||||
let baseRequests = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (typeof frame === "string") {
|
||||
const envelope = JSON.parse(frame) as { message?: { type?: string } };
|
||||
if (envelope.message?.type === "workspace.base_branch.set.request") baseRequests += 1;
|
||||
}
|
||||
server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => {
|
||||
if (typeof frame === "string") {
|
||||
const envelope = JSON.parse(frame) as {
|
||||
message?: { payload?: { status?: string; features?: Record<string, unknown> } };
|
||||
};
|
||||
const payload = envelope.message?.payload;
|
||||
if (payload?.status === "server_info" && payload.features) {
|
||||
delete payload.features.workspaceBaseBranch;
|
||||
browser.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
const workspace = await createWorktreeWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
|
||||
expect(baseRequests).toBe(0);
|
||||
});
|
||||
|
||||
test("Changes shows no base on a checkout that is not a Paseo worktree", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
const header = page.getByTestId("changes-repository-header").filter({ visible: true });
|
||||
await expect(header).toContainText("feature");
|
||||
await expect(header).not.toContainText("main");
|
||||
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
@@ -386,6 +623,35 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("legacy diff hosts keep All, Committed and Uncommitted comparisons", async ({ page }) => {
|
||||
await useLegacyDiffHost(page);
|
||||
const workspace = await createWorkspaceWithScrollableComparisons();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await expect(changesTree(page).getByText("committed", { exact: true })).toBeVisible();
|
||||
await expect(changesTree(page).getByText("uncommitted", { exact: true })).toBeVisible();
|
||||
await selectChangesComparison(page, "Committed");
|
||||
await expectWorkingComparisonFiles(page, "committed");
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expectWorkingComparisonFiles(page, "uncommitted");
|
||||
});
|
||||
async function useLegacyDiffHost(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.message?.payload?.status === "server_info") {
|
||||
delete envelope.message.payload.features.diffSubscription;
|
||||
browserSocket.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -928,11 +1194,11 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
throw new Error("Compact Changes toolbar geometry could not be measured");
|
||||
}
|
||||
expect(actionsBox.width).toBe(48);
|
||||
expect(actionsBox.height).toBe(28);
|
||||
expect(optionsBox.width).toBe(32);
|
||||
expect(optionsBox.height).toBe(32);
|
||||
expect(glyphBox.width).toBe(18);
|
||||
expect(glyphBox.height).toBe(18);
|
||||
expect(actionsBox.height).toBe(32);
|
||||
expect(optionsBox.width).toBe(44);
|
||||
expect(optionsBox.height).toBe(44);
|
||||
expect(glyphBox.width).toBe(20);
|
||||
expect(glyphBox.height).toBe(20);
|
||||
|
||||
await expect(actions).not.toContainText("Commit");
|
||||
await expect(actions.locator("svg")).toHaveCount(2);
|
||||
@@ -1198,11 +1464,13 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
test.setTimeout(300_000);
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
// Measure document bounds after delivery, independently of the cold Git read.
|
||||
await expectWorkingDiffLoaded(page, 240_000);
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
@@ -1657,6 +1925,28 @@ async function expectRenderedAddedLine(page: Page, contents: string): Promise<vo
|
||||
}).toPass({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function createWorktreeWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-base-worktree-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
});
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "worktree", cwd: repo.path, worktreeSlug: "base-picker", baseBranch: "main" },
|
||||
});
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create worktree");
|
||||
const cwd = created.workspace.workspaceDirectory;
|
||||
await writeFile(path.join(cwd, "committed-only.ts"), "export const committed = true;\n");
|
||||
execFileSync("git", ["add", "committed-only.ts"], { cwd });
|
||||
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd });
|
||||
return { id: created.workspace.id, repoPath: cwd };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-committed-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||
@@ -1759,12 +2049,16 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
async function createWorkspaceWithManyTinyDiffs(
|
||||
fileCount: number,
|
||||
branch = "main",
|
||||
): Promise<DirtyWorkspace> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||
if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
holdDirectorySuggestionResponses,
|
||||
expectOneLineTruncatedFileResult,
|
||||
expectStableCommandCenterLayout,
|
||||
expectLayoutObservationToCatchPanelShift,
|
||||
failDirectorySuggestionRequests,
|
||||
startCommandCenterLayoutObservation,
|
||||
shiftOutsideCommandCenter,
|
||||
} from "../support/helpers/command-center-file-search";
|
||||
import {
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
@@ -84,7 +86,9 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
||||
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await shiftOutsideCommandCenter(page);
|
||||
await expectStableCommandCenterLayout(page);
|
||||
await expectLayoutObservationToCatchPanelShift(page);
|
||||
|
||||
await row.click();
|
||||
await expectFileTabOpen(page, FILE_PATH);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
@@ -59,6 +60,14 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function expectActionsInOrder(menu: Locator, labels: readonly string[]) {
|
||||
const items = menu.getByRole("menuitem");
|
||||
await expect(items).toHaveCount(labels.length);
|
||||
for (const [index, label] of labels.entries()) {
|
||||
await expect(items.nth(index).getByText(label, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
||||
cleanupTasks.push(async () => {
|
||||
@@ -83,12 +92,7 @@ test("compact git actions menu lists a promoted Pull once", async ({ page }) =>
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Pull",
|
||||
"Push",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
await expectActionsInOrder(menu, ["Pull", "Push", "Pull and push", "Archive workspace"]);
|
||||
});
|
||||
|
||||
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
||||
@@ -106,10 +110,5 @@ test("compact git actions menu lists a promoted Push once", async ({ page }) =>
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Push",
|
||||
"Pull",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
await expectActionsInOrder(menu, ["Push", "Pull", "Pull and push", "Archive workspace"]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { pauseWorkspaceTerminalWorker } from "../support/helpers/workspace-setup";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
@@ -190,3 +191,49 @@ test("compact header menu runs, shows, and stops workspace scripts", async ({ pa
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function restartScriptAcrossHeaderRemoval(page: Page, terminalId: string) {
|
||||
await openRunningScriptPage(page, "lint");
|
||||
const resumeWorker = await pauseWorkspaceTerminalWorker(terminalId);
|
||||
try {
|
||||
await menuRow(page, "Restart").click();
|
||||
await page.setViewportSize({ width: 1280, height: 844 });
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveCount(0);
|
||||
await expect(page.getByTestId("workspace-scripts-button")).toBeVisible();
|
||||
} finally {
|
||||
resumeWorker();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectScriptTerminalReplaced(
|
||||
workspace: Awaited<ReturnType<typeof openWorkspaceWithScripts>>,
|
||||
previousId: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const after = await workspace.client.listTerminals(undefined, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
return after.terminals.filter((terminal) => terminal.id !== previousId).length;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(1);
|
||||
}
|
||||
|
||||
test("pending script restart survives removal of the compact header", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const workspace = await openWorkspaceWithScripts(page);
|
||||
try {
|
||||
await runScriptFromScriptsPage(page, "lint");
|
||||
const before = await workspace.client.listTerminals(undefined, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
expect(before.terminals).toHaveLength(1);
|
||||
await restartScriptAcrossHeaderRemoval(page, before.terminals[0]!.id);
|
||||
await expectScriptTerminalReplaced(workspace, before.terminals[0]!.id);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
attachFileFromMenu,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
} from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
@@ -101,7 +105,21 @@ interface PopoverFrameRecorderWindow extends Window {
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
interface ListCommandsReply {
|
||||
commands: readonly { name: string; description: string; argumentHint: string }[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null };
|
||||
|
||||
/**
|
||||
* Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds
|
||||
* the response until it settles, standing in for a slow host.
|
||||
*/
|
||||
async function installListCommandsStub(
|
||||
page: Page,
|
||||
reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => TEST_COMMANDS_REPLY,
|
||||
): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
@@ -126,14 +144,20 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
||||
};
|
||||
};
|
||||
};
|
||||
const payload = parsed.message?.payload;
|
||||
if (
|
||||
parsed.type === "session" &&
|
||||
parsed.message?.type === "list_commands_response" &&
|
||||
parsed.message.payload
|
||||
payload
|
||||
) {
|
||||
parsed.message.payload.commands = TEST_COMMANDS;
|
||||
parsed.message.payload.error = null;
|
||||
ws.send(JSON.stringify(parsed));
|
||||
const send = (result: ListCommandsReply) => {
|
||||
payload.commands = result.commands;
|
||||
payload.error = result.error;
|
||||
ws.send(JSON.stringify(parsed));
|
||||
};
|
||||
const result = reply();
|
||||
if (result instanceof Promise) void result.then(send);
|
||||
else send(result);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -145,6 +169,17 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function createHeldReply(): {
|
||||
reply: () => Promise<ListCommandsReply>;
|
||||
release: () => void;
|
||||
} {
|
||||
let release = () => {};
|
||||
const held = new Promise<ListCommandsReply>((resolve) => {
|
||||
release = () => resolve(TEST_COMMANDS_REPLY);
|
||||
});
|
||||
return { reply: () => held, release };
|
||||
}
|
||||
|
||||
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
||||
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||
@@ -365,6 +400,56 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
|
||||
const requests = recordCommandRequestsByAgentId(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "suggested-command-",
|
||||
title: "Suggested commands",
|
||||
initialPrompt: "Suggest `/mock` without running it",
|
||||
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
|
||||
await expect(message).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
await attachFileFromMenu(page, {
|
||||
name: "note.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("attachment"),
|
||||
});
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await message.getByRole("link", { name: "Run mock" }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await composerLocator(page).fill("draft");
|
||||
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
|
||||
element.setSelectionRange(0, 0),
|
||||
);
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await composerLocator(page).pressSequentially("argument");
|
||||
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
|
||||
await composerLocator(page).fill("/");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toContainText("/mock");
|
||||
expect(requests).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -491,16 +576,17 @@ test.describe("Composer autocomplete", () => {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/he");
|
||||
// `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows.
|
||||
await input.fill("/hea");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const beforeDelete = await visiblePopoverBox(page);
|
||||
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/h");
|
||||
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
|
||||
await expect(input).toHaveValue("/he");
|
||||
await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const afterDelete = await visiblePopoverBox(page);
|
||||
@@ -679,6 +765,116 @@ test.describe("Composer autocomplete", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("forgets an arrow-key choice once the search changes", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-stale-choice-",
|
||||
title: "Autocomplete stale choice",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Down wraps from the default bottom row to the top: /clear, then /exit.
|
||||
await input.press("ArrowDown");
|
||||
await input.press("ArrowDown");
|
||||
await input.press("h");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveValue("/tdd ");
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows loading inside the popover until a slow host answers", async ({ page }) => {
|
||||
const host = createHeldReply();
|
||||
await installListCommandsStub(page, host.reply);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill("/");
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const loading = await visiblePopoverBox(page);
|
||||
|
||||
host.release();
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0);
|
||||
const results = await visiblePopoverBox(page);
|
||||
|
||||
expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({
|
||||
page,
|
||||
}) => {
|
||||
let reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => ({
|
||||
commands: [],
|
||||
error: "Timed out waiting for the agent session",
|
||||
});
|
||||
await installListCommandsStub(page, () => reply());
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-failing-host-",
|
||||
title: "Autocomplete failing host",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill("/");
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
const errorRow = popover.getByTestId("composer-autocomplete-error");
|
||||
await expect(errorRow).toContainText("Timed out waiting for the agent session", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await input.press("Enter");
|
||||
await page.waitForTimeout(500);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
|
||||
await input.fill("/");
|
||||
await expect(errorRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const host = createHeldReply();
|
||||
reply = host.reply;
|
||||
await errorRow.getByRole("button", { name: "Retry" }).click();
|
||||
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible();
|
||||
|
||||
host.release();
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("compact sidebar layering", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function box(locator: Locator) {
|
||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
||||
const rect = await locator.boundingBox();
|
||||
expect(rect).not.toBeNull();
|
||||
return { top: rect!.y, bottom: rect!.y + rect!.height };
|
||||
}
|
||||
|
||||
test.describe("Composer bottom spacing", () => {
|
||||
test("the chat composer rests one content spacing above the window edge", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-bottom-spacing-",
|
||||
title: "Bottom spacing",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
const surface = await box(
|
||||
page.getByTestId("message-input-root").filter({ visible: true }).first(),
|
||||
);
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight);
|
||||
expect(viewportHeight - surface.bottom).toBe(16);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the wide new workspace form keeps one content spacing above Import session", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-bottom-spacing-form-" });
|
||||
try {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
const composer = await box(page.locator('[data-testid="message-input-root"]:visible'));
|
||||
const importButton = await box(page.getByTestId("new-workspace-import-session"));
|
||||
expect(importButton.top - composer.bottom).toBe(16);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { fillComposerDraft } from "../support/helpers/composer";
|
||||
import {
|
||||
expectComposerToolbarOnTextRails,
|
||||
expectNoCollapsedComposerToolbarFrame,
|
||||
expectNoFrameTruncatesModelBesideThinking,
|
||||
readSettledModelTriggerLabels,
|
||||
recordComposerToolbarFrames,
|
||||
recordModelTriggerFramesFromLoad,
|
||||
sweepModelTriggerLabels,
|
||||
} from "../support/helpers/composer-control-density";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const SETTLE_MS = 1_000;
|
||||
@@ -92,3 +99,159 @@ test.describe("Composer control density across tab switches", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Composer controls folding on a touch device", () => {
|
||||
test.use({ viewport: { width: 1194, height: 834 }, isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("an open menu stays closed after the controls fold and unfold", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-density-fold-",
|
||||
title: "Folding chat",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
const thinking = page.getByTestId("agent-thinking-selector").filter({ visible: true });
|
||||
const thinkingMenu = page.getByTestId("combobox-desktop-container");
|
||||
|
||||
await thinking.click();
|
||||
await expect(thinkingMenu).toBeVisible();
|
||||
|
||||
// Still a wide layout, but too narrow for separate controls: they fold into the model control.
|
||||
await page.setViewportSize({ width: 640, height: 834 });
|
||||
await expect(thinking).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 1194, height: 834 });
|
||||
await expect(thinking).toBeVisible();
|
||||
await expect(thinkingMenu).toHaveCount(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Folded model control on a phone", () => {
|
||||
// Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit.
|
||||
test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("hides the thinking label before the model label truncates", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-density-labels-",
|
||||
title: "Label priority chat",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await recordModelTriggerFramesFromLoad(page);
|
||||
await openAgentRoute(page, session);
|
||||
await expect(
|
||||
page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }),
|
||||
).toHaveText("Low");
|
||||
await readSettledModelTriggerLabels(page);
|
||||
// From the first frame on, the control never guesses: it paints both labels only once it
|
||||
// has measured that they fit.
|
||||
await expectNoFrameTruncatesModelBesideThinking(page);
|
||||
|
||||
const sweep = await sweepModelTriggerLabels(page, {
|
||||
from: 480,
|
||||
to: 280,
|
||||
step: 4,
|
||||
height: 844,
|
||||
});
|
||||
|
||||
expect(sweep.widest).toMatchObject({
|
||||
modelText: "Ten second stream",
|
||||
thinkingShown: true,
|
||||
modelTruncated: false,
|
||||
});
|
||||
expect(sweep.thinkingHidden).toMatchObject({
|
||||
modelText: "Ten second stream",
|
||||
modelTruncated: false,
|
||||
});
|
||||
expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width);
|
||||
// A clean tail: the label ends on a whole word and the ellipsis, never on a space.
|
||||
expect(sweep.modelTruncated).toMatchObject({
|
||||
modelText: "Ten second…",
|
||||
thinkingShown: false,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Composer toolbar rails on touch", () => {
|
||||
test.use({ isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
for (const viewport of [
|
||||
{ device: "phone", width: 390, height: 844 },
|
||||
{ device: "tablet", width: 1024, height: 1366 },
|
||||
]) {
|
||||
test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
const idle = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-rails-idle-",
|
||||
title: "Rails idle chat",
|
||||
});
|
||||
let running: Awaited<ReturnType<typeof seedMockAgentWorkspace>> | null = null;
|
||||
try {
|
||||
await openAgentRoute(page, idle);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await test.step("empty composer: attach on the left, voice mode on the right", async () => {
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
await test.step("draft: the send disc on the right", async () => {
|
||||
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||
await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible();
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
|
||||
running = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-rails-running-",
|
||||
title: "Rails running chat",
|
||||
model: "one-minute-stream",
|
||||
initialPrompt: "stream for a while",
|
||||
});
|
||||
await openAgentRoute(page, running);
|
||||
await test.step("running agent: the stop disc on the right", async () => {
|
||||
await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerToolbarOnTextRails(page);
|
||||
});
|
||||
} finally {
|
||||
await idle.cleanup();
|
||||
await running?.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Composer trailing disc on a new chat", () => {
|
||||
test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } });
|
||||
|
||||
test("an empty draft holds a disabled send disc until there is something to send", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
const send = page
|
||||
.getByTestId("message-input-root")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Send message", exact: true });
|
||||
await expect(send).toBeDisabled({ timeout: 30_000 });
|
||||
await fillComposerDraft(page, "Refactor the composer toolbar");
|
||||
await expect(send).toBeEnabled();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import type { CDPSession, Locator } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedSubagent } from "../support/helpers/subagents";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
@@ -18,7 +20,7 @@ function visibleMainPane(page: Page) {
|
||||
}
|
||||
|
||||
function composerChangesPill(page: Page) {
|
||||
return page.getByTestId("composer-diff-stat-pill");
|
||||
return page.getByRole("button", { name: "Open Changes tab", exact: true });
|
||||
}
|
||||
|
||||
async function revealComposerChangesInExplorer(page: Page) {
|
||||
@@ -78,7 +80,8 @@ test("composer diff stat reveals Changes, then opens the diff in the configured
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
// Keep both workspace rails visible alongside app navigation and Explorer.
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
@@ -498,6 +501,7 @@ test.describe("send review feedback", () => {
|
||||
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const workspace = await seedChangedAgent("review-feedback-multiple-");
|
||||
const other = await seedChangedAgent("review-feedback-other-");
|
||||
try {
|
||||
@@ -858,3 +862,91 @@ async function expectReviewEditorBody(page: Page, body: string) {
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
async function swipeComposerTracks(page: Page, client: CDPSession, direction: "left" | "right") {
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
const box = await track.boundingBox();
|
||||
if (!box) throw new Error("Composer tracks are not visible");
|
||||
const start = direction === "left" ? box.x + box.width - 5 : box.x + 5;
|
||||
const end = direction === "left" ? box.x + 5 : box.x + box.width - 5;
|
||||
await swipeTouch(client, start, end, box.y + box.height / 2);
|
||||
}
|
||||
|
||||
async function swipeTouch(client: CDPSession, start: number, end: number, y: number) {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: start, y }],
|
||||
});
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: start + ((end - start) * step) / 8, y }],
|
||||
});
|
||||
}
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
}
|
||||
|
||||
async function trackScrollOffset(track: Locator): Promise<number> {
|
||||
return track.evaluate((node) => node.scrollLeft);
|
||||
}
|
||||
|
||||
test.describe("touch composer tracks", () => {
|
||||
test.use({ hasTouch: true, isMobile: true });
|
||||
test("compact composer scrolls its task, subagent and changes tracks without squeezing them", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("composer-tracks-overflow-");
|
||||
const touch = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Track child" },
|
||||
);
|
||||
await page.setViewportSize({ width: 1000, height: 844 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await submitMessage(page, "emit 1 agent stream updates");
|
||||
const tasks = page.getByTestId("agent-task-list-header");
|
||||
const subagents = page.getByTestId("subagents-track-header");
|
||||
const changes = page.getByTestId("composer-diff-stat-pill");
|
||||
await expect(tasks).toBeVisible();
|
||||
await expect(subagents).toBeVisible();
|
||||
await expect(changes).toBeVisible();
|
||||
const wideWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
await page.setViewportSize({ width: 200, height: 844 });
|
||||
const narrowWidths = await Promise.all(
|
||||
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
|
||||
);
|
||||
for (const [index, width] of wideWidths.entries())
|
||||
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
|
||||
const track = page.getByTestId("composer-track-scroll");
|
||||
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
|
||||
await track.evaluate((node) => node.clientWidth),
|
||||
);
|
||||
await swipeComposerTracks(page, touch, "left");
|
||||
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
|
||||
await swipeComposerTracks(page, touch, "right");
|
||||
await expect.poll(() => trackScrollOffset(track)).toBeLessThanOrEqual(1);
|
||||
await swipeComposerTracks(page, touch, "left");
|
||||
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport();
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1 });
|
||||
await page.getByTestId("sidebar-close").click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
|
||||
await expect(changes).toBeInViewport();
|
||||
await changes.click();
|
||||
await expect(
|
||||
page.getByTestId("changes-header").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await touch.detach();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
pasteRichTextIntoComposer,
|
||||
fillComposerDraft,
|
||||
copyLatestTurnIntoComposer,
|
||||
selectComposerDraftRange,
|
||||
undoComposerEdit,
|
||||
redoComposerEdit,
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
@@ -13,6 +19,7 @@ import {
|
||||
expectWorkspaceHeaderVisible,
|
||||
resizeComposerViewport,
|
||||
submitMessage,
|
||||
submitMessageWithButton,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -25,9 +32,10 @@ import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectMobileExplorerHidden,
|
||||
expectMobileExplorerVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
swipeOpenMobileExplorer,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
|
||||
import {
|
||||
expectTimelinePromptPositionPreserved,
|
||||
@@ -55,7 +63,9 @@ test("clicking chat text or background focuses the feed for keyboard paging", as
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).offsetY)
|
||||
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("chat-keyboard-paging.png"),
|
||||
});
|
||||
await page.keyboard.press("PageDown");
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
|
||||
@@ -124,20 +134,86 @@ test("focusing the feed preserves assistant text selection and code copy control
|
||||
});
|
||||
|
||||
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
|
||||
const copiedTable =
|
||||
"| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space](<https://example.com/a b>) | **open** |\n| [ASCII](https://example.com/1) | closed |";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-",
|
||||
title: "Composer focus",
|
||||
featureValues: { mockAssistantResponse: copiedTable },
|
||||
});
|
||||
|
||||
const connection = await installDaemonWebSocketGate(page);
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await submitMessageWithButton(page, "Pointer submission");
|
||||
await expectComposerDraft(page, "");
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, "Ready after Send");
|
||||
await expectComposerDraft(page, "Ready after Send");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await submitMessage(page, "First message");
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await typeIntoFocusedComposer(page, "Second message");
|
||||
await expectComposerDraft(page, "Second message");
|
||||
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
|
||||
await selectComposerDraftRange(page, 13, 25);
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<a href="https://example.com/issues/39150">this FR</a>',
|
||||
text: "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 agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible();
|
||||
await connection.drop();
|
||||
await connection.waitForBlockedConnection();
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<a href="https://example.com/offline">offline link</a>',
|
||||
text: "offline link",
|
||||
});
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
|
||||
);
|
||||
await fillComposerDraft(page, "");
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
|
||||
text: "Resource\tStatus\nIssue\tclosed",
|
||||
});
|
||||
await expectComposerDraft(
|
||||
page,
|
||||
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
|
||||
);
|
||||
await fillComposerDraft(page, "");
|
||||
await pasteRichTextIntoComposer({
|
||||
page,
|
||||
html: '<a href="https://example.com/">Issue</a><table></table>',
|
||||
text: "Issue",
|
||||
});
|
||||
await expectComposerDraft(page, "[Issue](https://example.com/)");
|
||||
await copyLatestTurnIntoComposer(page);
|
||||
await expectComposerDraft(page, copiedTable);
|
||||
await expectComposerFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
@@ -168,7 +244,11 @@ async function waitForAnimationFrames(page: Page, count: number): Promise<void>
|
||||
test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
|
||||
// composer there raises the on-screen keyboard over the conversation.
|
||||
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
|
||||
test.use({
|
||||
viewport: { width: 1024, height: 768 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
|
||||
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
@@ -192,7 +272,9 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
test("returning to an inactive inline composer does not restore focus on touch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "composer-touch-retained-",
|
||||
});
|
||||
try {
|
||||
const first = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
@@ -240,6 +322,9 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// Autofocus makes its first attempt on the next animation frame.
|
||||
await waitForAnimationFrames(page, 4);
|
||||
await expectComposerNotFocused(page);
|
||||
await submitMessageWithButton(page, "Touch submission");
|
||||
await expectComposerDraft(page, "");
|
||||
await expectComposerNotFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
@@ -249,7 +334,7 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
const COMPACT_PANELS = [
|
||||
{
|
||||
name: "agent list",
|
||||
open: swipeOpenMobileAgentSidebar,
|
||||
open: openMobileAgentSidebarWithKeyboard,
|
||||
expectOpen: expectMobileAgentSidebarVisible,
|
||||
close: closeMobileAgentSidebar,
|
||||
expectClosed: expectMobileAgentSidebarHidden,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
fillComposerDraft,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendDraftWhenReady(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await pressInComposer(page, "Enter");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await submitMessage(page, text);
|
||||
await fillComposerDraft(page, text);
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
swipeOpenMobileAgentSidebar,
|
||||
openMobileAgentSidebarWithKeyboard,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
@@ -541,8 +541,8 @@ test("the compact sidebar over a fullscreen editor leaves the exit action alone"
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
|
||||
await swipeOpenMobileAgentSidebar(page);
|
||||
// Fullscreen hides the header's menu button; the keyboard shortcut opens the list over it.
|
||||
await openMobileAgentSidebarWithKeyboard(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
// The list covers the pane, so the editor's exit action is not armed.
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -429,6 +429,32 @@ test("context details support keyboard entry and dismiss when the viewport moves
|
||||
await expect(details).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("context details stay open while the chat streams", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-streaming-",
|
||||
title: "Context details while streaming",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await submitMessageWithButton(page, "Stream while the context details are open.");
|
||||
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stop).toBeVisible();
|
||||
const details = await pressContextWindowMeter(page, /^Context window/);
|
||||
await expect(stop).toBeVisible();
|
||||
await qaScreenshot(page, "context-details-while-streaming");
|
||||
|
||||
await expect(stop).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(details).toBeVisible();
|
||||
await qaScreenshot(page, "context-details-after-streaming");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expect, test, daemonTest as cleanupTest } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
openDaemonOverview,
|
||||
@@ -13,6 +14,10 @@ import {
|
||||
readWorkerPid,
|
||||
openLongLivedTerminal,
|
||||
listTerminalIds,
|
||||
suspendDaemonProcess,
|
||||
resumeDaemonProcess,
|
||||
daemonProcessCanWrite,
|
||||
readTerminalProcessIds,
|
||||
} from "../support/helpers/daemon-lifecycle";
|
||||
|
||||
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
|
||||
@@ -119,3 +124,51 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise<strin
|
||||
);
|
||||
return paseoHome;
|
||||
}
|
||||
|
||||
// Cleanup has no renderer demand: drive the same isolated daemon owner directly.
|
||||
// A stopped worker deterministically outlives the old helper's five-second
|
||||
// supervisor-only kill and logs into its deleted home when resumed.
|
||||
cleanupTest(
|
||||
"isolated cleanup prevents a stopped worker from recreating its deleted home",
|
||||
async () => {
|
||||
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
const workerPid = await readWorkerPid(daemon);
|
||||
try {
|
||||
suspendDaemonProcess(workerPid);
|
||||
await daemon.close();
|
||||
resumeDaemonProcess(workerPid);
|
||||
await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false);
|
||||
expect(existsSync(daemon.paseoHome)).toBe(false);
|
||||
} finally {
|
||||
resumeDaemonProcess(workerPid);
|
||||
await daemon.close();
|
||||
await rm(daemon.paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cleanupTest(
|
||||
"isolated cleanup force-stops a frozen supervisor and its detached terminal writers",
|
||||
async () => {
|
||||
cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection");
|
||||
const daemon = await startIsolatedHostDaemon(randomUUID());
|
||||
const supervisorPid = daemon.getPid()!;
|
||||
const workerPid = await readWorkerPid(daemon);
|
||||
try {
|
||||
await openLongLivedTerminal(daemon);
|
||||
const terminalProcesses = await readTerminalProcessIds(daemon);
|
||||
expect(terminalProcesses.length).toBeGreaterThanOrEqual(2);
|
||||
suspendDaemonProcess(supervisorPid);
|
||||
await daemon.close();
|
||||
const writerStates = await Promise.all(
|
||||
[supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite),
|
||||
);
|
||||
expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false));
|
||||
expect(existsSync(daemon.paseoHome)).toBe(false);
|
||||
} finally {
|
||||
resumeDaemonProcess(supervisorPid);
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import {
|
||||
@@ -9,6 +11,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Direct terminal shortcut pane placement", () => {
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
|
||||
|
||||
@@ -47,3 +50,88 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
const draftPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId("message-input-root") });
|
||||
const emptyPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||
const draftPaneId = await draftPane.getAttribute("data-testid");
|
||||
const emptyPaneId = await emptyPane.getAttribute("data-testid");
|
||||
expect(draftPaneId).not.toBeNull();
|
||||
expect(emptyPaneId).not.toBeNull();
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
return {
|
||||
originalPane: page.getByTestId(draftPaneId!),
|
||||
otherPane: page.getByTestId(emptyPaneId!),
|
||||
};
|
||||
}
|
||||
|
||||
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-tab-switcher-trigger").click();
|
||||
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
|
||||
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
|
||||
}
|
||||
|
||||
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
|
||||
try {
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await launchTerminalFromCompactPane(page, originalPane);
|
||||
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await maximizeAndRestoreCompactPane(page, originalPane);
|
||||
await expect(otherPane).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectPaneHasWindowDragSurface(pane: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
pane.evaluate((element) =>
|
||||
Array.from(element.querySelectorAll<HTMLElement>("div")).some(
|
||||
(node) => node.style.getPropertyValue("-webkit-app-region") === "drag",
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
|
||||
try {
|
||||
await installLocalDesktopHost(page, workspace.repoPath);
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
|
||||
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expectPaneHasWindowDragSurface(originalPane);
|
||||
await expectPaneHasWindowDragSurface(otherPane);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,9 +56,9 @@ test.describe("New tab keyboard launcher", () => {
|
||||
await expectLauncherSelection(launcher, "Diff");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ function collectConsoleErrors(page: Page): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// These drag and tab-menu journeys need full rails beside the docked Explorer.
|
||||
test.use({ viewport: { width: 1900, height: 900 } });
|
||||
|
||||
test.describe("explorer pane tab placement", () => {
|
||||
test("agents open in the main pane and New-tab splits keep the app usable", async ({
|
||||
page,
|
||||
|
||||
@@ -79,6 +79,12 @@ async function previewExplorerResize(page: Page, distance: number) {
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectCleanChangesLoaded(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
await page.reload();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
@@ -100,6 +106,7 @@ test("Explorer drag previews without rerendering panes or rewriting the shared s
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expectCleanChangesLoaded(page);
|
||||
// Verify that direct pane renders are being counted before resetting them.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
|
||||
@@ -63,6 +63,24 @@ function htmlPreview(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
async function followHtmlPreviewLink(page: Page, name: string): Promise<void> {
|
||||
await htmlPreview(page).document.getByRole("link", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPageHandlesFragment(
|
||||
page: Page,
|
||||
handler: "Document" | "Window",
|
||||
): Promise<void> {
|
||||
const preview = htmlPreview(page);
|
||||
await followHtmlPreviewLink(page, `${handler} handled jump`);
|
||||
await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`);
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
}
|
||||
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||
name: view,
|
||||
@@ -417,7 +435,8 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
// Keep both tab rails available alongside the docked Explorer.
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await openWorkspaceFile(page, "target.ts");
|
||||
@@ -826,6 +845,192 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps HTML fragment links in the preview and scrolls to the target", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragments-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "fragments.html"),
|
||||
`<!doctype html><html><body>
|
||||
<a href="#section">Jump to section</a>
|
||||
<div style="height: 1200px"></div>
|
||||
<h2 id="section">Target section</h2>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "fragments.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") });
|
||||
await preview.document.getByRole("link", { name: "Jump to section" }).click();
|
||||
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") });
|
||||
});
|
||||
|
||||
test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-handler-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "handled.html"),
|
||||
`<!doctype html><html><body>
|
||||
<a href="#section" onclick="event.preventDefault(); document.querySelector('output').textContent = 'Page handled click'">Handled jump</a>
|
||||
<output>Waiting for click</output>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "handled.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await preview.document.getByRole("link", { name: "Handled jump" }).click();
|
||||
await expect(preview.document.locator("output")).toHaveText("Page handled click");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
test("respects delegated HTML fragment handlers and normal URL parsing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "defaults.html"),
|
||||
`<!doctype html><html><body>
|
||||
<nav style="position: fixed; top: 0; background: white">
|
||||
<a href="#section" id="document-handler">Document handled jump</a>
|
||||
<a href="#section" id="window-handler">Window handled jump</a>
|
||||
<a href=" \t#section\n">Whitespace jump</a>
|
||||
<a href="#100%">Percent jump</a>
|
||||
<output>Waiting for click</output>
|
||||
</nav>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
<div style="height: 1200px"></div><h2 id="100%">Percent target</h2>
|
||||
<div style="height: 1200px"></div>
|
||||
<script>
|
||||
document.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'document-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Document handled click';
|
||||
}
|
||||
});
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'window-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Window handled click';
|
||||
}
|
||||
});
|
||||
</script></body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "defaults.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expectPageHandlesFragment(page, "Document");
|
||||
await expectPageHandlesFragment(page, "Window");
|
||||
|
||||
await followHtmlPreviewLink(page, "Whitespace jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
await followHtmlPreviewLink(page, "Percent jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Percent target" }),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-targets-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "targets.html"),
|
||||
`<!doctype html><html><head><style>:target { color: rgb(255, 0, 0); }</style></head><body>
|
||||
<nav style="position: fixed; top: 0; background: white">
|
||||
<a href="#section"><span>Nested jump</span></a>
|
||||
<a href="#legacy">Named jump</a>
|
||||
<a href="#encoded%20section">Encoded jump</a>
|
||||
<a href="#missing">Missing jump</a>
|
||||
<a href="#">Empty jump</a>
|
||||
<output>Waiting for hashchange</output><output id="modified">Waiting for modified click</output>
|
||||
</nav>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
<div style="height: 1200px"></div><a name="legacy">Named target</a>
|
||||
<div style="height: 1200px"></div><h2 id="encoded section">Encoded target</h2>
|
||||
<div style="height: 1200px"></div>
|
||||
<script>
|
||||
window.addEventListener('hashchange', function () { document.querySelector('output').textContent = location.hash || '(empty)'; });
|
||||
function modifiedClick(event) {
|
||||
if (event.button === 1 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||
document.getElementById('modified').textContent = String(event.defaultPrevented);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
window.addEventListener('click', modifiedClick);
|
||||
window.addEventListener('auxclick', modifiedClick);
|
||||
window.addEventListener('contextmenu', modifiedClick);
|
||||
</script></body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "targets.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
const body = preview.document.locator("body");
|
||||
const nested = preview.document.getByText("Nested jump", { exact: true });
|
||||
await nested.click();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#section");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)");
|
||||
|
||||
await body.evaluate(() => window.scrollTo(0, 0));
|
||||
await nested.click();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
|
||||
await preview.document.getByRole("link", { name: "Named jump" }).click();
|
||||
await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#legacy");
|
||||
|
||||
await preview.document.getByRole("link", { name: "Encoded jump" }).click();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Encoded target" }),
|
||||
).toBeInViewport();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section");
|
||||
const scrollY = await body.evaluate(() => window.scrollY);
|
||||
await preview.document.getByRole("link", { name: "Missing jump" }).click();
|
||||
expect(await body.evaluate(() => location.hash)).toBe("#missing");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(scrollY);
|
||||
|
||||
await preview.document.getByRole("link", { name: "Empty jump" }).click();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("(empty)");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||
|
||||
for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) {
|
||||
await nested.click({ modifiers: [modifier] });
|
||||
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||
}
|
||||
await nested.click({ button: "middle" });
|
||||
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||
expect(await body.evaluate(() => location.hash)).toBe("");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
collapseFolder,
|
||||
expandFolder,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { delayDirectoryListingResponse } from "../support/helpers/directory-listing-gate";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
@@ -49,3 +52,66 @@ test.describe("File explorer collapse", () => {
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
});
|
||||
});
|
||||
|
||||
test("Files Refresh reveals new files when a cached collapsed folder is reopened", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
await collapseFolder(page, "docs");
|
||||
await expectExplorerEntryHidden(page, "guide.md");
|
||||
await createFileOutsidePaseo("docs/NEW.md");
|
||||
await refreshFiles(page);
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
await expectExplorerEntryVisible(page, "NEW.md");
|
||||
|
||||
await createFileOutsidePaseo("docs/NEXT.md");
|
||||
await refreshFiles(page);
|
||||
await expectExplorerEntryVisible(page, "NEXT.md");
|
||||
});
|
||||
|
||||
async function refreshFiles(page: import("@playwright/test").Page): Promise<void> {
|
||||
const refresh = page.getByRole("button", { name: "Refresh files", exact: true });
|
||||
await refresh.click();
|
||||
await expect(refresh).toBeEnabled();
|
||||
}
|
||||
|
||||
async function createFileOutsidePaseo(filePath: string): Promise<void> {
|
||||
await writeFile(path.join(workspace.repoPath, filePath), "# New output\n");
|
||||
}
|
||||
|
||||
test("Files Refresh ignores an old reply for a folder collapsed while loading", async ({
|
||||
page,
|
||||
}) => {
|
||||
const reply = await delayDirectoryListingResponse(page, "docs");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await reply.waitUntilHeld();
|
||||
await collapseFolder(page, "docs");
|
||||
await createFileOutsidePaseo("docs/DELAYED.md");
|
||||
await refreshFiles(page);
|
||||
await reply.release();
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "DELAYED.md");
|
||||
});
|
||||
|
||||
test("Files Refresh preserves expanded folders while their saved view is restoring", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
const reply = await delayDirectoryListingResponse(page, "docs");
|
||||
await page.reload();
|
||||
await openFileExplorer(page);
|
||||
await reply.waitUntilHeld();
|
||||
await refreshFiles(page);
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
await reply.release();
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
});
|
||||
|
||||
@@ -294,10 +294,17 @@ test("creates, renames, copies, and deletes entries through the file explorer",
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
||||
const deleteLabelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const labelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
const iconColor = await deleteAction
|
||||
.locator("svg")
|
||||
.evaluate((element) => getComputedStyle(element).stroke);
|
||||
return labelColor !== "" && iconColor === labelColor;
|
||||
})
|
||||
.toBe(true);
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
|
||||
@@ -81,6 +81,8 @@ for (const folder of [
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
"examples/next.js/",
|
||||
"examples/next.js",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -92,6 +94,9 @@ for (const folder of [
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
|
||||
await mkdir(path.dirname(competingFile), { recursive: true });
|
||||
await writeFile(competingFile, "This is a file, not the requested folder.\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
closeDiagramFullscreen,
|
||||
expectInlineDiagramFitsViewport,
|
||||
toggleDiagramSourceAndVerifyControls,
|
||||
closeDiagramFullscreenFromOutside,
|
||||
expectCompletedDiagram,
|
||||
expectFullscreenDiagram,
|
||||
@@ -21,6 +23,36 @@ const MERMAID_DIAGRAM = [
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
test("bounds a tall inline diagram", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const diagram = [
|
||||
"```mermaid",
|
||||
"flowchart TD",
|
||||
...Array.from({ length: 30 }, (_, i) => `N${i} --> N${i + 1}`),
|
||||
"```",
|
||||
].join("\n");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "mermaid-tall-",
|
||||
title: "Tall Mermaid",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: diagram,
|
||||
mockStreamingAssistantIntervalMs: 10,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await requestDiagram(agent);
|
||||
await waitForDiagramTurnToComplete(agent);
|
||||
await expectCompletedDiagram(page, ["N0", "N30"]);
|
||||
await expectInlineDiagramFitsViewport(page);
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["N0", "N30"]);
|
||||
await closeDiagramFullscreen(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
@@ -40,6 +72,10 @@ test("opens a Mermaid diagram fullscreen and closes it again", async ({ page })
|
||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
||||
});
|
||||
|
||||
await test.step("Source toggles preserve the diagram and exclude hidden controls", async () => {
|
||||
await toggleDiagramSourceAndVerifyControls(page);
|
||||
});
|
||||
|
||||
await test.step("The toolbar opens the diagram fullscreen", async () => {
|
||||
await openDiagramFullscreen(page);
|
||||
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chooseModel, startWithoutRememberedModel } from "../support/helpers/model-memory";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
@@ -97,6 +98,7 @@ test.describe("New workspace composer draft", () => {
|
||||
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
|
||||
|
||||
try {
|
||||
await startWithoutRememberedModel(page);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
@@ -104,6 +106,7 @@ test.describe("New workspace composer draft", () => {
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
});
|
||||
|
||||
await chooseModel(page, "mock", "Ten second stream");
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
const createButton = page.getByTestId("workspace-create-submit");
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
@@ -122,10 +122,14 @@ test.describe("New workspace entry points", () => {
|
||||
});
|
||||
const hostTrigger = page.getByTestId("host-picker-trigger");
|
||||
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
|
||||
const originalUrl = page.url();
|
||||
await hostTrigger.click();
|
||||
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
|
||||
await page.getByTestId("new-workspace-host-picker-add-host").click();
|
||||
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, metroTest as test } from "../support/fixtures";
|
||||
import {
|
||||
addProjectFromSetup,
|
||||
continueSetup,
|
||||
expectSetupStep,
|
||||
finishSetup,
|
||||
goToSetupStep,
|
||||
hostGivesAgentsPaseoTools,
|
||||
openAppAsNewUser,
|
||||
openRelayPairingFromSetup,
|
||||
closeRelayPairing,
|
||||
emptyHome,
|
||||
loadingScreen,
|
||||
openTailscaleDocsFromSetup,
|
||||
paseoToolsSwitch,
|
||||
pickTheme,
|
||||
projectSuggestion,
|
||||
projectSuggestionRow,
|
||||
readFirstLaunch,
|
||||
recordFirstLaunch,
|
||||
setupModal,
|
||||
setupPrimaryButton,
|
||||
showSetupAgainFromSettings,
|
||||
skipSetup,
|
||||
startSetupHost,
|
||||
} from "../support/helpers/onboarding";
|
||||
import {
|
||||
chooseCustomSkills,
|
||||
openSkillSelection,
|
||||
saveSkillSelection,
|
||||
} from "../support/helpers/agent-skills";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
|
||||
const MIDNIGHT_MUTED_FOREGROUND = "rgb(154, 157, 176)";
|
||||
|
||||
test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
const host = await startSetupHost("fresh");
|
||||
const repo = await createTempGitRepo("onboarding-folder-");
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
|
||||
await test.step("setup opens on Appearance and a theme applies at once", async () => {
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
await pickTheme(page, "Midnight");
|
||||
await expect(
|
||||
setupModal(page).getByText("Let's start by picking a look", { exact: true }),
|
||||
).toHaveCSS("color", MIDNIGHT_MUTED_FOREGROUND);
|
||||
await page.screenshot({ path: testInfo.outputPath("1-appearance.png") });
|
||||
});
|
||||
|
||||
await test.step("Providers shows the same provider list as Settings", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Your agents");
|
||||
await expect(setupModal(page).getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
|
||||
});
|
||||
|
||||
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
await expect(
|
||||
setupModal(page).getByText("No recent projects found", { exact: true }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await addProjectFromSetup(page, repo.path);
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
await expect(setupModal(page).getByText(repo.path, { exact: true })).toBeVisible();
|
||||
await expect(setupModal(page).getByText("Added", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("3-projects.png") });
|
||||
});
|
||||
|
||||
await test.step("Orchestration starts with its switches on", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Let agents use Paseo");
|
||||
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
|
||||
await expect(
|
||||
setupModal(page).getByRole("switch", { name: "Orchestration skills", exact: true }),
|
||||
).toBeChecked();
|
||||
await page.screenshot({ path: testInfo.outputPath("4-orchestration.png") });
|
||||
});
|
||||
|
||||
await test.step("Skip setup closes it for good", async () => {
|
||||
await skipSetup(page);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-project-row-"]').filter({
|
||||
hasText: path.basename(repo.path),
|
||||
}),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await host.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a new user goes from the loading screen straight into setup", async ({ page }) => {
|
||||
const host = await startSetupHost("first-launch");
|
||||
|
||||
try {
|
||||
await recordFirstLaunch(page);
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
const launch = await readFirstLaunch(page);
|
||||
expect(launch.appBeforeSetup).toBe(false);
|
||||
expect(launch.setupOpacityOnArrival).toBeLessThan(1);
|
||||
await expect(setupModal(page)).toHaveCSS("opacity", "1");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("with reduced motion, setup appears without fading in", async ({ page }) => {
|
||||
const host = await startSetupHost("reduced-motion");
|
||||
|
||||
try {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await recordFirstLaunch(page);
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
expect((await readFirstLaunch(page)).setupOpacityOnArrival).toBe(1);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("changing the theme keeps the setup window in place on every step", async ({ page }) => {
|
||||
const host = await startSetupHost("theme-change");
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
const setupWindow = await setupModal(page).elementHandle();
|
||||
|
||||
for (const title of [
|
||||
"Welcome to Paseo",
|
||||
"Your agents",
|
||||
"Add your projects",
|
||||
"Let agents use Paseo",
|
||||
"Take your agents with you",
|
||||
]) {
|
||||
if (title !== "Welcome to Paseo") await continueSetup(page);
|
||||
await expectSetupStep(page, title);
|
||||
const heading = setupModal(page).getByRole("heading", { name: title });
|
||||
const headingNode = await heading.elementHandle();
|
||||
const lightColor = await heading.evaluate((node) => getComputedStyle(node).color);
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await expect(heading).not.toHaveCSS("color", lightColor);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await expect(heading).toHaveCSS("color", lightColor);
|
||||
expect(await setupWindow?.evaluate((node) => node.isConnected), title).toBe(true);
|
||||
expect(await headingNode?.evaluate((node) => node.isConnected), title).toBe(true);
|
||||
}
|
||||
|
||||
await goToSetupStep(page, "Appearance");
|
||||
await pickTheme(page, "Midnight");
|
||||
await pickTheme(page, "Light");
|
||||
expect(await setupWindow?.evaluate((node) => node.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the loading screen stays up while the setup host loads its workspaces", async ({ page }) => {
|
||||
const host = await startSetupHost("slow-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
await recordFirstLaunch(page);
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(loadingScreen(page)).toBeVisible();
|
||||
await expect(emptyHome(page)).toHaveCount(0);
|
||||
|
||||
gate.releaseHeldClientRequest();
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
expect((await readFirstLaunch(page)).appBeforeSetup).toBe(false);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setup host that never loads its workspaces still lands on the home", async ({ page }) => {
|
||||
const host = await startSetupHost("stuck-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
|
||||
// Setup still opens once the host answers.
|
||||
gate.releaseHeldClientRequest();
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setup host that drops while loading still lands on the home", async ({ page }) => {
|
||||
const host = await startSetupHost("dropped-workspaces");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
|
||||
try {
|
||||
gate.holdNextClientRequest("fetch_workspaces_request");
|
||||
await openAppAsNewUser(page, host);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await gate.drop();
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an unreachable setup host lands on the home without setup", async ({ page }) => {
|
||||
const host = await startSetupHost("unreachable");
|
||||
await host.close();
|
||||
|
||||
await openAppAsNewUser(page, host);
|
||||
await expect(emptyHome(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
|
||||
const repo = await createTempGitRepo("onboarding-history-");
|
||||
const repoName = path.basename(repo.path);
|
||||
const subdirectory = path.join(repo.path, "packages", "web");
|
||||
await mkdir(subdirectory, { recursive: true });
|
||||
const worktree = `${repo.path}-fix`;
|
||||
execFileSync("git", ["worktree", "add", "-b", "fix", worktree], { cwd: repo.path });
|
||||
const scratch = await createTempDirectory("onboarding-scratch-");
|
||||
const scratchName = path.basename(scratch.path);
|
||||
const host = await startSetupHost("history", {
|
||||
claudeSessionsIn: [repo.path, subdirectory, worktree, scratch.path, scratch.path, scratch.path],
|
||||
withBrokenProvider: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
await goToSetupStep(page, "Projects");
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
|
||||
await test.step("the repo, its subfolder and its worktree become one checked row", async () => {
|
||||
await expect(projectSuggestion(page, repoName)).toBeChecked({ timeout: 60_000 });
|
||||
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
||||
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
|
||||
});
|
||||
|
||||
await test.step("a provider that cannot read its history adds nothing and shows no error", async () => {
|
||||
await expect(setupModal(page).getByTestId("project-suggestions-skeleton")).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(setupModal(page).getByText(/Broken ACP/)).toHaveCount(0);
|
||||
await expect(setupModal(page).getByTestId("project-suggestion")).toHaveCount(2);
|
||||
});
|
||||
|
||||
await test.step("the footer adds what is checked; the Add project flow is separate", async () => {
|
||||
await expect(setupPrimaryButton(page, "Add 2 projects")).toBeVisible();
|
||||
await expect(setupModal(page).getByRole("button", { name: "Continue" })).toHaveCount(0);
|
||||
await expect(
|
||||
setupModal(page).getByRole("button", { name: "Add project", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("8-project-suggestions.png") });
|
||||
await projectSuggestion(page, repoName).click();
|
||||
await projectSuggestion(page, scratchName).click();
|
||||
await expect(setupPrimaryButton(page, "Continue")).toBeVisible();
|
||||
await projectSuggestion(page, repoName).click();
|
||||
await projectSuggestion(page, scratchName).click();
|
||||
});
|
||||
|
||||
await test.step("a folder that cannot be added keeps the user on the step", async () => {
|
||||
await scratch.cleanup();
|
||||
await setupPrimaryButton(page, "Add 2 projects").click();
|
||||
await expect(setupPrimaryButton(page, "Add 1 project")).toBeVisible({ timeout: 30_000 });
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
||||
await expect(projectSuggestionRow(page, scratchName)).toContainText("Directory not found");
|
||||
await expect(projectSuggestion(page, repoName)).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("9-project-suggestion-failed.png") });
|
||||
});
|
||||
|
||||
await test.step("with nothing checked, Continue moves on without adding", async () => {
|
||||
await projectSuggestion(page, scratchName).click();
|
||||
await setupPrimaryButton(page, "Continue").click();
|
||||
await expectSetupStep(page, "Let agents use Paseo");
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: repoName }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: scratchName }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await host.close();
|
||||
await rm(worktree, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
await scratch.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an existing user only sees setup when they ask for it", async ({ page }) => {
|
||||
const host = await startSetupHost("existing");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: host.port });
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${host.serverId}:${workspace.workspaceId}`),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
|
||||
await showSetupAgainFromSettings(page);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
await expect(setupModal(page).getByRole("tab", { name: "Appearance" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("setup choices carry over to Settings and the phone step pairs over relay", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const host = await startSetupHost("phone", { mutableRelay: { enabled: false } });
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
await test.step("Paseo tools start on in setup and Continue turns them on", async () => {
|
||||
await goToSetupStep(page, "Orchestration");
|
||||
await expectSetupStep(page, "Let agents use Paseo");
|
||||
await expect(paseoToolsSwitch(setupModal(page))).toBeChecked();
|
||||
await continueSetup(page);
|
||||
});
|
||||
|
||||
await test.step("Phone offers Tailscale docs and pairs over relay in its own modal", async () => {
|
||||
await expectSetupStep(page, "Take your agents with you");
|
||||
await page.screenshot({ path: testInfo.outputPath("5-phone.png") });
|
||||
expect(await openTailscaleDocsFromSetup(page)).toBe(
|
||||
"https://paseo.sh/docs/connectivity#tailscale",
|
||||
);
|
||||
const pairing = await openRelayPairingFromSetup(page);
|
||||
await expect(pairing.getByText("Enable relay?", { exact: true })).toBeVisible();
|
||||
await enableRelayAndExpectOffer(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("6-phone-qr.png") });
|
||||
await closeRelayPairing(pairing);
|
||||
});
|
||||
|
||||
await test.step("Finish closes setup and Settings shows the same Paseo tools value", async () => {
|
||||
await finishSetup(page);
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await openSettingsHostSection(page, host.serverId, "agents");
|
||||
await expect(paseoToolsSwitch(page)).toBeChecked();
|
||||
await page.screenshot({ path: testInfo.outputPath("7-settings-agents.png") });
|
||||
});
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("with no skills picked, skills read as off and Continue moves on", async ({ page }) => {
|
||||
const host = await startSetupHost("no-skills");
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
await goToSetupStep(page, "Orchestration");
|
||||
await expectSetupStep(page, "Let agents use Paseo");
|
||||
|
||||
await openSkillSelection(page);
|
||||
await chooseCustomSkills(page, []);
|
||||
await saveSkillSelection(page);
|
||||
const skillsSwitch = setupModal(page).getByRole("switch", {
|
||||
name: "Orchestration skills",
|
||||
exact: true,
|
||||
});
|
||||
await expect(skillsSwitch).not.toBeChecked();
|
||||
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Take your agents with you");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("in the compact layout, Finish on Orchestration applies its switches", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const host = await startSetupHost("compact");
|
||||
const gate = await installDaemonWebSocketGate(page, { port: host.port });
|
||||
// The compact sheet is the only surface on screen, and Orchestration is its last step.
|
||||
const step = (title: string) =>
|
||||
expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
const primary = (label: string) => page.getByRole("button", { name: label, exact: true });
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await step("Welcome to Paseo");
|
||||
await primary("Continue").click();
|
||||
await step("Your agents");
|
||||
await primary("Continue").click();
|
||||
await step("Add your projects");
|
||||
await primary("Continue").click();
|
||||
|
||||
// The setup host starts with Paseo tools off; the switch starts on.
|
||||
expect(await hostGivesAgentsPaseoTools(host)).toBe(false);
|
||||
await step("Let agents use Paseo");
|
||||
await expect(paseoToolsSwitch(page)).toBeChecked();
|
||||
// Skills are not installed, so leaving them off has nothing to apply.
|
||||
const skillsSwitch = page.getByRole("switch", { name: "Orchestration skills", exact: true });
|
||||
await skillsSwitch.click();
|
||||
await expect(skillsSwitch).not.toBeChecked();
|
||||
gate.holdNextClientRequest("set_daemon_config_request");
|
||||
await primary("Finish").click();
|
||||
|
||||
// While the choices apply, none of them can change.
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(paseoToolsSwitch(page)).toBeDisabled();
|
||||
await expect(skillsSwitch).toBeDisabled();
|
||||
gate.releaseHeldClientRequest();
|
||||
|
||||
await expect.poll(() => hostGivesAgentsPaseoTools(host), { timeout: 30_000 }).toBe(true);
|
||||
await expect(page.getByRole("heading", { name: "Let agents use Paseo" })).toHaveCount(0);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,7 @@ test("plugin header and composer buttons share actions, menus, content, and upda
|
||||
await buttons.verifyObservationLifetime();
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.keepTrackSpaceWithoutPills();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.useCompactWorkspaceMenu();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
returnAndReopenSettings,
|
||||
returnToPluginsPage,
|
||||
expectSettingsUnavailable,
|
||||
openPluginsPage,
|
||||
expectActionsMenuWithoutSettings,
|
||||
expectNoSettingsCogWithoutScreens,
|
||||
openTabbedSettings,
|
||||
selectSettingsTab,
|
||||
openTabbedSettingsLink,
|
||||
expectSettingsTooltip,
|
||||
expectNamedPluginRow,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
test("display preferences survive a reload", async ({ page }) => {
|
||||
@@ -49,6 +57,32 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
|
||||
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
|
||||
await openPluginsPage(page);
|
||||
await test.step("plugin rows show the display name", async () => {
|
||||
await expectNamedPluginRow(page);
|
||||
});
|
||||
await test.step("the cog explains itself", async () => {
|
||||
await expectSettingsTooltip(page);
|
||||
});
|
||||
await test.step("the actions menu keeps only plugin actions", async () => {
|
||||
await expectActionsMenuWithoutSettings(page);
|
||||
});
|
||||
await test.step("tabs switch between the plugin's settings screens", async () => {
|
||||
await openTabbedSettings(page);
|
||||
await selectSettingsTab(page, "Advanced");
|
||||
await selectSettingsTab(page, "General");
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
await test.step("a plugin without settings screens shows no cog", async () => {
|
||||
await expectNoSettingsCogWithoutScreens(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("a settings screen link opens its tab", async ({ page }) => {
|
||||
await openTabbedSettingsLink(page, "Advanced");
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
BOTS_PLUGIN_ID,
|
||||
COMPACT,
|
||||
@@ -27,6 +28,7 @@ import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import { claudeAndCodexReports } from "../support/helpers/usage-sidebar-item";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -133,6 +135,30 @@ function sidebarFooter(page: Page): Locator {
|
||||
return visibleTestId(page, "sidebar-add-project").locator("xpath=..");
|
||||
}
|
||||
|
||||
function isPluginCatalogResponse(message: string | Buffer): boolean {
|
||||
if (typeof message !== "string") return false;
|
||||
const frame = JSON.parse(message);
|
||||
return frame.type === "session" && frame.message?.type === "plugin.catalog.get.response";
|
||||
}
|
||||
|
||||
async function holdNextPluginCatalogResponse(page: Page) {
|
||||
const response = Promise.withResolvers<() => void>();
|
||||
let holdCatalog = true;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
socket.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (holdCatalog && isPluginCatalogResponse(message)) {
|
||||
holdCatalog = false;
|
||||
response.resolve(() => socket.send(message));
|
||||
return;
|
||||
}
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
return { response: response.promise };
|
||||
}
|
||||
|
||||
test.describe("Plugin sidebar items", () => {
|
||||
let workspaceId: string;
|
||||
let cleanup: () => Promise<void>;
|
||||
@@ -151,6 +177,69 @@ test.describe("Plugin sidebar items", () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("a short compact sidebar keeps workspaces usable while navigation scrolls", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 600 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
await expect(botRow(page, "status")).toBeAttached();
|
||||
const workspace = visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
|
||||
await test.step("scrolling navigation reaches its last row without moving workspaces", async () => {
|
||||
const before = await boxOf(workspace);
|
||||
await visibleTestId(page, "sidebar-global-new-workspace").hover();
|
||||
await page.mouse.wheel(0, 800);
|
||||
await expect(botRow(page, "status")).toBeInViewport({ ratio: 1 });
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
expect((await boxOf(workspace)).y).toBe(before.y);
|
||||
await botRow(page, "status").click();
|
||||
await expect(page.getByText("Bot status details", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
|
||||
});
|
||||
|
||||
await test.step("the workspace still opens and closes the sidebar", async () => {
|
||||
await workspace.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(workspace).not.toBeInViewport();
|
||||
});
|
||||
});
|
||||
|
||||
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
const body = page.getByText("Deploys screen body", { exact: true });
|
||||
await expect(body).toBeVisible();
|
||||
const screenUrl = page.url();
|
||||
const catalog = await holdNextPluginCatalogResponse(page);
|
||||
|
||||
await page.reload();
|
||||
const releaseCatalog = await catalog.response;
|
||||
try {
|
||||
await expect(
|
||||
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("This plugin surface is unavailable.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(body).toHaveCount(0);
|
||||
} finally {
|
||||
releaseCatalog();
|
||||
}
|
||||
|
||||
await expect(body).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("progressbar", { name: "Loading plugin", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page).toHaveURL(screenUrl);
|
||||
});
|
||||
|
||||
test("a plugin's screen, header row, footer popover and command work together", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -228,7 +317,10 @@ test.describe("Plugin sidebar items", () => {
|
||||
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
|
||||
await expect(compactSync).toBeInViewport();
|
||||
await expectFooterRow(page, compactSync);
|
||||
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
|
||||
// The compact nav block is capped at half the sidebar and scrolls past it.
|
||||
const compactDeploys = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
await compactDeploys.scrollIntoViewIfNeeded();
|
||||
await expect(compactDeploys).toBeInViewport();
|
||||
await qaScreenshot(page, "phase2-compact-sidebar");
|
||||
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
|
||||
await compactSync.click();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
async function runCommand(page: Page, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
/** Installs a plugin example, opens a seeded workspace, and runs `exercise` against them. */
|
||||
async function withExample(
|
||||
page: Page,
|
||||
example: { name: string; id: string },
|
||||
exercise: (workspace: SeededWorkspace) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const copy = await copyPluginExample(example.name);
|
||||
const workspace = await seedWorkspace({ repoPrefix: `${example.name}-` });
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(copy.directory);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
|
||||
await waitForWorkspaceInSidebar(page, target);
|
||||
await switchWorkspaceViaSidebar({ page, ...target });
|
||||
await exercise(workspace);
|
||||
} finally {
|
||||
try {
|
||||
await client.removePlugin(example.id);
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await workspace.cleanup();
|
||||
await client.close();
|
||||
await copy.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("plugin subscriptions", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("a panel follows events the daemon pushes as files change", async ({ page }) => {
|
||||
await withExample(page, { name: "diff-watch", id: "diff-watch" }, async (workspace) => {
|
||||
await runCommand(page, "Open live changes");
|
||||
await expect(page.getByText("No changes", { exact: true })).toBeVisible();
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "pushed.txt"), "from the test\n");
|
||||
await expect(page.getByText("1 file changed", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("pushed.txt", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("a panel plays streamed bytes and sends bytes back", async ({ page }) => {
|
||||
await withExample(page, { name: "media-stream", id: "media-stream" }, async () => {
|
||||
await runCommand(page, "Open media stream");
|
||||
await expect(page.getByText("live", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/^[1-9]\d* video frames/)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Send this frame back", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(/^The daemon received [1-9]\d* bytes, sha256 [0-9a-f]{12}$/),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
@@ -69,8 +68,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
|
||||
const mochaItem = page.getByRole("radio", { name: "Catppuccin Mocha", exact: true });
|
||||
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker.png"),
|
||||
@@ -79,14 +77,14 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
});
|
||||
|
||||
await test.step("a contributed light theme uses the light palette", async () => {
|
||||
await page.getByText("Catppuccin Latte", { exact: true }).click();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
|
||||
const latteItem = page.getByRole("radio", { name: "Catppuccin Latte", exact: true });
|
||||
await latteItem.click();
|
||||
await expect(latteItem).toBeChecked();
|
||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
|
||||
});
|
||||
|
||||
await mochaItem.click();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
|
||||
await expect(mochaItem).toBeChecked();
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-applied.png"),
|
||||
@@ -95,15 +93,13 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
||||
|
||||
await test.step("the selection survives a reload", async () => {
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mochaItem).toBeChecked({ timeout: 30_000 });
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
});
|
||||
|
||||
await test.step("removing the plugin falls back to the default theme", async () => {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
|
||||
await expect(page.getByRole("radio", { name: "System", exact: true })).toBeChecked({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
@@ -147,15 +143,7 @@ const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function scrollThemeMenuToLastTheme(page: Page) {
|
||||
const firstTheme = page.getByText("Pack theme 1", { exact: true });
|
||||
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
|
||||
await firstTheme.hover();
|
||||
await page.mouse.wheel(0, 4000);
|
||||
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
|
||||
test("reaches the last theme when a plugin contributes more themes than fit", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
|
||||
@@ -175,15 +163,16 @@ test("scrolls to the last theme when a plugin contributes more themes than fit",
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
await scrollThemeMenuToLastTheme(page);
|
||||
const lastTheme = page.getByRole("radio", { name: LAST_THEME_NAME, exact: true });
|
||||
await expect(lastTheme).toBeAttached({ timeout: 30_000 });
|
||||
await lastTheme.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
|
||||
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
|
||||
await lastTheme.click();
|
||||
await expect(lastTheme).toBeChecked();
|
||||
} finally {
|
||||
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
@@ -14,7 +15,11 @@ import {
|
||||
expectTaskListEntries,
|
||||
openCompletedTaskList,
|
||||
} from "../support/helpers/task-list";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { submitMessage, submitMessageWithButton } from "../support/helpers/composer";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
|
||||
interface TaskScenario {
|
||||
provider: RewindFlowProvider;
|
||||
@@ -114,3 +119,425 @@ test.describe("real provider composer task lists", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openBackgroundInventory(
|
||||
page: Page,
|
||||
options: { compact: boolean; count: number; timeout: number },
|
||||
): Promise<Locator> {
|
||||
const pill = page.getByRole("button", {
|
||||
name: options.count === 1 ? "1 background task" : `${options.count} background tasks`,
|
||||
});
|
||||
await expect(pill).toBeVisible({ timeout: options.timeout });
|
||||
await pill.click();
|
||||
const panel = page.getByTestId(
|
||||
options.compact ? "background-work-panel-content" : "background-work-panel",
|
||||
);
|
||||
if (options.compact) await waitForSettledPosition(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
async function openBackgroundWork(page: Page, compact: boolean): Promise<void> {
|
||||
const panel = await openBackgroundInventory(page, { compact, count: 1, timeout: 30_000 });
|
||||
await expect(panel).toContainText(/sleep 120/);
|
||||
}
|
||||
|
||||
async function expectBackgroundItems(
|
||||
panel: Locator,
|
||||
selected: string,
|
||||
sibling: string,
|
||||
): Promise<void> {
|
||||
await expect(panel).toContainText(selected);
|
||||
await expect(panel).toContainText(sibling);
|
||||
}
|
||||
|
||||
async function stopBackgroundItem(panel: Locator, selected: string): Promise<void> {
|
||||
const escaped = selected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await panel.getByRole("button", { name: new RegExp(`Stop .*${escaped}`) }).click();
|
||||
}
|
||||
|
||||
async function expectStoppedItemAndSibling(
|
||||
panel: Locator,
|
||||
selected: string,
|
||||
sibling: string,
|
||||
): Promise<void> {
|
||||
await expect(panel).not.toContainText(selected, { timeout: 40_000 });
|
||||
await expect(panel).toContainText(sibling);
|
||||
}
|
||||
|
||||
async function closeBackgroundWork(page: Page, compact: boolean): Promise<void> {
|
||||
const panel = page.getByTestId(
|
||||
compact ? "background-work-panel-content" : "background-work-panel",
|
||||
);
|
||||
if (compact) {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.click({ position: { x: 12, y: 12 } });
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
|
||||
async function expectBackgroundAgentRunning(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-status-indicator-running").first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectAssistantReply(page: Page, text: string): Promise<void> {
|
||||
await expect(page.getByTestId("assistant-message").filter({ hasText: text }).last()).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectBackgroundPill(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "1 background task" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectBackgroundWorkOnAnotherClient(page: Page): Promise<void> {
|
||||
const another = await page.context().newPage();
|
||||
try {
|
||||
await another.goto(page.url());
|
||||
await openBackgroundWork(another, false);
|
||||
} finally {
|
||||
await another.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function startBackgroundWorkWithoutOpeningPane(workspace: SeededWorkspace): Promise<void> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "claude",
|
||||
model: "haiku",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: workspace.workspaceDirectory,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Background work before opening",
|
||||
});
|
||||
await workspace.client.sendAgentMessage(
|
||||
agent.id,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
const result = await workspace.client.waitForFinish(agent.id, 120_000);
|
||||
expect(result.status).toBe("idle");
|
||||
}
|
||||
|
||||
async function expectWorkspaceRunning(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row.getByTestId("workspace-status-indicator-running")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test("Claude keeps background work visible while accepting another turn", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-work-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase1-claude-background.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, false);
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
await expectBackgroundWorkOnAnotherClient(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude background work remains readable in dark mode", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
|
||||
});
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-dark-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/review-background-dark.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude background work opens in a compact sheet", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-compact-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectAssistantReply(page, "SPAWNED");
|
||||
await openBackgroundWork(page, true);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase1-claude-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, true);
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
"Reply exactly HELLO without stopping the background command.",
|
||||
);
|
||||
await expectAssistantReply(page, "HELLO");
|
||||
await expectBackgroundPill(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("background work updates the sidebar before the agent is opened", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "background-unopened-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`),
|
||||
).toBeVisible();
|
||||
await startBackgroundWorkWithoutOpeningPane(workspace);
|
||||
await expectWorkspaceRunning(page, workspace.workspaceId);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex keeps a live command visible after its turn", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-background-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "gpt-6-astra" },
|
||||
});
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use exec_command to run sleep 120 with yield_time_ms 1000. Do not wait for or stop it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase2-codex-background.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, false);
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
await expectBackgroundWorkOnAnotherClient(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode keeps a live command visible after its turn", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-opencode-background-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
|
||||
});
|
||||
await sendMessage(
|
||||
handle,
|
||||
"Use the shell tool with background true to run sleep 120. Do not wait or stop it. Reply SPAWNED immediately.",
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
await openBackgroundWork(page, false);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase2-opencode-background.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, false);
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
await expectBackgroundWorkOnAnotherClient(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
provider: "claude" as const,
|
||||
prompt:
|
||||
"Use Bash with run_in_background to start sleep 180 and sleep 181 as two separate background commands. Do not wait for them. Reply SPAWNED immediately.",
|
||||
providerConfig: { model: "haiku" },
|
||||
},
|
||||
{
|
||||
provider: "codex" as const,
|
||||
prompt:
|
||||
"Use exec_command twice to start sleep 180 and sleep 181 separately with yield_time_ms 1000. Do not wait for or stop either process. Reply SPAWNED immediately.",
|
||||
providerConfig: { model: "gpt-6-astra" },
|
||||
},
|
||||
{
|
||||
provider: "opencode" as const,
|
||||
prompt:
|
||||
"Use the shell tool twice with background true: start sleep 180, then start sleep 181. Do not wait for or stop either. Reply SPAWNED immediately.",
|
||||
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
|
||||
},
|
||||
]) {
|
||||
test(`${scenario.provider} stops selected background work after the turn`, async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
|
||||
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-qa-workspace-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await sendMessage(handle, scenario.prompt);
|
||||
const panel = await openBackgroundInventory(page, {
|
||||
compact: false,
|
||||
count: 2,
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expectBackgroundItems(panel, "sleep 180", "sleep 181");
|
||||
await stopBackgroundItem(panel, "sleep 180");
|
||||
await expectStoppedItemAndSibling(panel, "sleep 180", "sleep 181");
|
||||
await page.screenshot({
|
||||
path: path.resolve(`../../.artifacts/phase3-${scenario.provider}-stop.png`),
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
|
||||
await expectBackgroundPill(page);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Claude stops a selected monitor in the compact sheet", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
|
||||
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-monitor-workspace-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opus" },
|
||||
});
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
'Use exactly two separate Monitor tool calls, each with command `sleep 150`, timeout_ms 150000, persistent false, descriptions "probe monitor one" and "probe monitor two". Launch both independently, do not wait, then reply SPAWNED. Do not use any other tools.',
|
||||
);
|
||||
await expectAssistantReply(page, "SPAWNED");
|
||||
const panel = await openBackgroundInventory(page, { compact: true, count: 2, timeout: 30_000 });
|
||||
await expectBackgroundItems(panel, "probe monitor one", "probe monitor two");
|
||||
await expect(panel.getByText("sleep 150", { exact: true })).toHaveCount(2);
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/refinement-monitor-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await stopBackgroundItem(panel, "probe monitor one");
|
||||
await expectStoppedItemAndSibling(panel, "probe monitor one", "probe monitor two");
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/phase3-claude-monitor-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await closeBackgroundWork(page, true);
|
||||
await submitMessageWithButton(
|
||||
page,
|
||||
"Reply exactly HELLO without stopping the background monitor.",
|
||||
);
|
||||
await expectAssistantReply(page, "HELLO");
|
||||
await expectBackgroundPill(page);
|
||||
await handle.client.sendAgentMessage(
|
||||
handle.agentId,
|
||||
"Use Bash to run sleep 20 in the foreground, then reply WAITED.",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await handle.client.sendAgentMessage(
|
||||
handle.agentId,
|
||||
"Reply exactly INTERRUPTED without stopping the background monitor.",
|
||||
{ activeTurnBehavior: "interrupt" },
|
||||
);
|
||||
expect((await handle.client.waitForFinish(handle.agentId, 120_000)).status).toBe("idle");
|
||||
await expectBackgroundPill(page);
|
||||
await expect
|
||||
.poll(async () => handle!.client.listAgentBackgroundWork(handle!.agentId))
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ description: "probe monitor two", kind: "monitor" }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
test("Claude shows monitor description and command in the wide track", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.resolve("../../.artifacts/refinement-wide-workspace-")),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opus" },
|
||||
});
|
||||
await sendMessage(
|
||||
handle,
|
||||
'Use Monitor with command `sleep 150`, timeout_ms 150000, persistent false, description "Watch the build". Do not wait; reply SPAWNED. Do not use other tools.',
|
||||
);
|
||||
await expectBackgroundAgentRunning(page);
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${handle.workspaceId}`,
|
||||
);
|
||||
await workspaceRow.scrollIntoViewIfNeeded();
|
||||
await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toBeVisible();
|
||||
const panel = await openBackgroundInventory(page, {
|
||||
compact: false,
|
||||
count: 1,
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(panel).toContainText("Watch the build");
|
||||
await expect(panel).toContainText("sleep 150");
|
||||
await expect(panel.getByRole("button", { name: "Stop Watch the build" })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: path.resolve("../../.artifacts/refinement-monitor-wide.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,10 +108,7 @@ test.describe("Question option text", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting description text never answers, and the label still does", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test("selecting description text keeps the question open", async ({ page, context }) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
@@ -120,16 +117,13 @@ test.describe("Question option text", () => {
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
|
||||
await expectChecked(done, false);
|
||||
|
||||
await description.getByText("Logs are at", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Logs");
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, false);
|
||||
|
||||
await done.getByText("Done", { exact: true }).click();
|
||||
await expectChecked(done, true);
|
||||
await continueToNextQuestion(page);
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(done, true);
|
||||
@@ -138,11 +132,17 @@ test.describe("Question option text", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("double-clicking a single-select label answers once", async ({ page, context }) => {
|
||||
test("double-clicking a single-select label selects once without advancing", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
|
||||
await continueToNextQuestion(page);
|
||||
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
|
||||
@@ -154,14 +154,12 @@ test.describe("Question option text", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("multi-select description links and selection leave the option unchanged", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test("multi-select descriptions support links and text selection", async ({ page, context }) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
|
||||
await continueToNextQuestion(page);
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
|
||||
const lint = card.getByRole("checkbox", { name: "Lint" });
|
||||
@@ -177,18 +175,16 @@ test.describe("Question option text", () => {
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await description.getByText("Rules:", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Rules");
|
||||
await expectChecked(lint, false);
|
||||
await expectChecked(lint, true);
|
||||
|
||||
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
|
||||
await expectChecked(typecheck, true);
|
||||
await lint.getByText("Lint", { exact: true }).click();
|
||||
await expectChecked(lint, true);
|
||||
await expectChecked(lint, false);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
@@ -268,6 +264,7 @@ test.describe("Question prompt pagination", () => {
|
||||
await expectQuestionHidden(page, SUCCESS_QUESTION);
|
||||
|
||||
await chooseQuestionOption(page, "App");
|
||||
await continueToNextQuestion(page);
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 2,
|
||||
total: TOTAL_QUESTIONS,
|
||||
@@ -288,6 +285,7 @@ test.describe("Question prompt pagination", () => {
|
||||
preview: 'flags.enabled("new-surface")',
|
||||
});
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await continueToNextQuestion(page);
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
total: TOTAL_QUESTIONS,
|
||||
@@ -466,7 +464,9 @@ retryTest(
|
||||
retryTest.setTimeout(180_000);
|
||||
const card = page.getByTestId("question-form-card");
|
||||
await chooseQuestionOption(page, "App");
|
||||
await continueToNextQuestion(page);
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await continueToNextQuestion(page);
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Preserve my answers after a failed submission.",
|
||||
@@ -520,7 +520,9 @@ retryTest(
|
||||
const card = page.getByTestId("question-form-card");
|
||||
questionClient.releaseFirstResponse();
|
||||
await chooseQuestionOption(page, "App");
|
||||
await continueToNextQuestion(page);
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await continueToNextQuestion(page);
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Start with the internal team.",
|
||||
|
||||
@@ -60,6 +60,15 @@ async function verifyChronologicalFiltering(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyHistorySurvivesReload(page: Page): Promise<void> {
|
||||
await test.step("reloads History without visiting a workspace to reconnect", async () => {
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
await page.reload();
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
await expect(page.getByTestId("sessions-host-errors")).toBeHidden();
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyRecencyBeforeMatchStrength(page: Page): Promise<void> {
|
||||
await test.step("keeps newer partial matches before older stronger matches", async () => {
|
||||
await search(page, `${NONCE} bill`);
|
||||
@@ -151,6 +160,8 @@ test.describe("History search", () => {
|
||||
|
||||
await verifyChronologicalFiltering(page);
|
||||
|
||||
await verifyHistorySurvivesReload(page);
|
||||
|
||||
await verifyRecencyBeforeMatchStrength(page);
|
||||
|
||||
await verifyExactAndTypoHighlights(page);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
fillNewWorkspaceDraft,
|
||||
expectNewWorkspaceDraft,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { readFile, mkdir, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -257,12 +262,15 @@ test.describe("Settings host page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
|
||||
test("restart recovery defaults off and persists opt-in and custom prompt", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "agents");
|
||||
await expectRestartRecoveryEnabled(page, false);
|
||||
await setRestartRecoveryEnabled(page, true);
|
||||
await page.reload();
|
||||
await expectRestartRecoveryEnabled(page, true);
|
||||
await expectRestartRecoveryPrompt(page, "Resume working.");
|
||||
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||
@@ -273,3 +281,71 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
|
||||
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||
await editRestartRecoveryPrompt(page, "Resume working.");
|
||||
});
|
||||
|
||||
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).toHaveURL(/\/open-project$/);
|
||||
const originalUrl = page.url();
|
||||
await openSidebarAddHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await closeAddHostAndChangeTheme(page);
|
||||
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
});
|
||||
|
||||
async function openSidebarAddHost(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Hosts", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Add host", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
|
||||
await page.keyboard.press("Control+Alt+t");
|
||||
}
|
||||
|
||||
test("sidebar Add host completes without navigating", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).toHaveURL(/\/open-project$/);
|
||||
const originalUrl = page.url();
|
||||
await openSidebarAddHost(page);
|
||||
await connectToTestHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Settings Add host opens the saved host Overview", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
|
||||
});
|
||||
|
||||
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
|
||||
const originalUrl = page.url();
|
||||
await openNewWorkspaceAddHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await connectToTestHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
|
||||
});
|
||||
|
||||
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page.getByRole("button", { name: "Add host", exact: true }).click();
|
||||
}
|
||||
|
||||
async function connectToTestHost(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
|
||||
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ import {
|
||||
fillDirectHostUri,
|
||||
expectDirectHostFormValues,
|
||||
expectDirectHostSslEnabled,
|
||||
expectDirectHostSslDisabled,
|
||||
fillDirectHostAddress,
|
||||
toggleDirectHostSsl,
|
||||
expectDirectHostUriValue,
|
||||
expectDirectHostUriHidden,
|
||||
expectDiagnosticsContent,
|
||||
@@ -92,66 +95,63 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
|
||||
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position when switching between app and host sections", async ({
|
||||
test("keeps sidebar and detail scrolling independent through section changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
await test.step("scrolls the sidebar and detail pane independently", async () => {
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
await test.step("keeps the sidebar position after rapid consecutive selections", async () => {
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
await test.step("keeps the sidebar position when switching between app and host sections", async () => {
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,6 +284,16 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expectAddHostMethodOptions(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
|
||||
await fillDirectHostAddress(page, "example.paseo.test", "7443");
|
||||
await expectDirectHostSslDisabled(page);
|
||||
await toggleDirectHostSsl(page);
|
||||
await expectDirectHostSslEnabled(page);
|
||||
await toggleHostAdvanced(page);
|
||||
await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true");
|
||||
await toggleHostAdvanced(page);
|
||||
await toggleDirectHostSsl(page);
|
||||
await expectDirectHostSslDisabled(page);
|
||||
|
||||
await toggleHostAdvanced(page);
|
||||
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||
await toggleHostAdvanced(page);
|
||||
@@ -313,12 +323,12 @@ test.describe("Settings sidebar navigation", () => {
|
||||
|
||||
await test.step("a dropdown owns Escape", async () => {
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel(/Theme:/).click();
|
||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
|
||||
await page.getByLabel(/Highlight theme:/).click();
|
||||
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "GitHub", exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { addProjectFlow } from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
chooseFooterOverflowAction,
|
||||
expectFooterLine,
|
||||
expectFooterMenusInsideViewport,
|
||||
expectFooterNeverClipped,
|
||||
expectFooterOverflowActions,
|
||||
footerOverflowMenu,
|
||||
openFooterOverflow,
|
||||
seedDockedSidebarWidth,
|
||||
watchFooterClipping,
|
||||
} from "../support/helpers/sidebar-footer-overflow";
|
||||
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
|
||||
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const TOUCH_CONTROL = 44;
|
||||
const MIN_SIDEBAR_WIDTH = 200;
|
||||
const ALL_INLINE = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
];
|
||||
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
|
||||
const NARROW_TOUCH_LINE = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-footer-overflow",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
test.describe("Sidebar footer on a touch screen", () => {
|
||||
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
|
||||
|
||||
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
|
||||
await watchFooterClipping(page);
|
||||
await gotoAppShell(page);
|
||||
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
|
||||
await expectFooterNeverClipped(page);
|
||||
|
||||
await test.step("Help opens as a page of the overflow menu", async () => {
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
|
||||
await chooseFooterOverflowAction(page, "Help and support");
|
||||
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(footerOverflowMenu(page)).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Usage opens from the overflow menu", async () => {
|
||||
await openFooterOverflow(page);
|
||||
await chooseFooterOverflowAction(page, "Usage");
|
||||
await expect(footerOverflowMenu(page)).toBeHidden();
|
||||
await expect(page.getByTestId("usage-modal")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("usage-modal")).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Add project and Settings shortcuts still work", async () => {
|
||||
await page.keyboard.press("Control+o");
|
||||
await expect(addProjectFlow(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await page.keyboard.press("Control+,");
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
});
|
||||
});
|
||||
|
||||
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
|
||||
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
|
||||
|
||||
test("the overflow menu reaches the hidden actions", async ({ page }) => {
|
||||
await watchFooterClipping(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
|
||||
await expectFooterNeverClipped(page);
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Sidebar footer on a short, narrow phone", () => {
|
||||
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
|
||||
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
|
||||
|
||||
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostsAndReload(
|
||||
page,
|
||||
Array.from({ length: 10 }, (_, index) => ({
|
||||
serverId: `overflow-host-${index}`,
|
||||
label: `Overflow host ${index}`,
|
||||
})),
|
||||
);
|
||||
await openCompactSidebar(page);
|
||||
await expectFooterLine(page, {
|
||||
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
|
||||
controlSize: TOUCH_CONTROL,
|
||||
});
|
||||
await openFooterOverflow(page);
|
||||
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
await chooseFooterOverflowAction(page, "Hosts");
|
||||
await expectFooterMenusInsideViewport(page);
|
||||
const addHost = page.getByRole("menuitem", { name: "Add host" });
|
||||
await addHost.scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
|
||||
await expect(addHost).toBeInViewport();
|
||||
await addHost.click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,7 @@ test("fixed footer line keeps its five icons, Help and Settings at the end", asy
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await leaveSettings(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openCompactSidebar(page);
|
||||
await expectCompactSidebarOpen(page);
|
||||
await expectFooterSeparator(page, true);
|
||||
await expectFooterIconRow(page);
|
||||
|
||||
@@ -426,3 +426,81 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
function workspaceRows(page: Page, ids: string[]) {
|
||||
return page.locator(
|
||||
ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
async function openWorkspaceRow(page: Page, workspaceId: string) {
|
||||
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
|
||||
}
|
||||
|
||||
test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { seed, ids } = await seedCompactProject();
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
const { rows, group } = compactProjectRows(page, seed);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
await revealWorkspaces(group, "Show more");
|
||||
await openWorkspaceRow(page, ids[9]!);
|
||||
await expectWorkspaceOrder(rows, ids);
|
||||
await revealWorkspaces(group, "Show less");
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await reconnectSidebar(page);
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await openWorkspaceRow(page, ids[2]!);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { seed, ids } = await seedCompactProject();
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const projectKey = projectEquivalenceViewKey(seed.projectKey);
|
||||
await restorePreBetaOrder(
|
||||
page,
|
||||
projectKey,
|
||||
ids.map((id) => `${getServerId()}:${id}`),
|
||||
);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
const rows = workspaceRows(page, ids);
|
||||
const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
await toggle.click();
|
||||
await openWorkspaceRow(page, ids[9]!);
|
||||
await toggle.click();
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||
await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow);
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
||||
await reconnectSidebar(page);
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
||||
await toggle.click();
|
||||
await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]);
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withSidebarViews } from "../support/helpers/sidebar-views";
|
||||
|
||||
test("sidebar views replace the content, preserve chrome, and recover to the default", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await withSidebarViews(page, async (views) => {
|
||||
await views.expectDefault();
|
||||
await views.chooseWorkspaces();
|
||||
await views.exerciseExamples();
|
||||
await views.exerciseTerminals();
|
||||
await views.restoreDefault();
|
||||
await views.recoverFromFailure();
|
||||
await views.chooseWorkspaces();
|
||||
await views.disableChosenView();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { expectOpenTabCount } from "../support/helpers/sheet";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { expectReconnectingToastGone } from "../support/helpers/workspace-ui";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
@@ -101,11 +102,13 @@ async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: Fin
|
||||
}
|
||||
|
||||
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
@@ -141,13 +144,15 @@ async function leaveMarkedWorkspaceAndReopenOnCompact(
|
||||
}
|
||||
|
||||
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
|
||||
await closeMobileAgentSidebar(page);
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
@@ -194,11 +199,12 @@ async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
|
||||
test("manual unread survives departure and clears on reopening and ordinary completion survives departure", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await leaveMarkedWorkspaceAndRead(page, workspaces);
|
||||
@@ -207,16 +213,19 @@ test("manual unread survives departure and clears on reopening without changing
|
||||
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
|
||||
});
|
||||
|
||||
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
|
||||
test("clicking a multi-agent workspace preserves its selected agent and leaves the other unread", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await expectSelectedAgent(page, workspaces.subject.agentId);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
const newest = await addFinishedAgent(workspaces.subject);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await expectSelectedAgent(page, newest.id);
|
||||
await addFinishedAgent(workspaces.subject);
|
||||
await markAsUnread(page, workspaces.subject.workspaceId);
|
||||
await openWorkspace(page, workspaces.subject.workspaceId);
|
||||
await expectSelectedAgent(page, workspaces.subject.agentId);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
});
|
||||
|
||||
test("manual unread survives leaving the current workspace on compact layout", async ({
|
||||
@@ -230,7 +239,7 @@ test("manual unread survives leaving the current workspace on compact layout", a
|
||||
await completeTurnAndLeaveOnCompact(page, workspaces);
|
||||
});
|
||||
|
||||
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
|
||||
async function reopenWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
|
||||
const route = page.url();
|
||||
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
|
||||
await page.goto("about:blank");
|
||||
@@ -265,9 +274,72 @@ test("reopening a marked unread workspace restores only its agent tab and curren
|
||||
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
|
||||
await openMobileAgentSidebar(page);
|
||||
await markAsUnread(page, workspaces.subject.workspaceId);
|
||||
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
});
|
||||
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
|
||||
await expectRestoredCompactChat(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking the already focused timeline acknowledges a new completion", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await workspaces.subject.client.sendAgentMessage(
|
||||
workspaces.subject.agentId,
|
||||
"Finish another turn.",
|
||||
);
|
||||
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await page.getByText("Finish another turn.", { exact: true }).click();
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
});
|
||||
|
||||
async function reconnectWithoutInteracting(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
agentId: string,
|
||||
) {
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
gate.holdTimelineResponses(agentId);
|
||||
gate.restore();
|
||||
await gate.waitForHeldTimelineResponse();
|
||||
await expectReconnectingToastGone(page);
|
||||
gate.releaseHeldTimelineResponses();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("reconnecting a focused chat preserves its unread completion until another click", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
|
||||
await workspaces.subject.client.sendAgentMessage(
|
||||
workspaces.subject.agentId,
|
||||
"Unread across reconnect.",
|
||||
);
|
||||
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "attention");
|
||||
await page.getByText("Unread across reconnect.", { exact: true }).click();
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
});
|
||||
|
||||
test("a cached read chat acknowledges a completion that arrived while the app was closed", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openCompactWorkspace(page, workspaces.subject);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
|
||||
await expectRestoredCompactChat(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, workspaces.subject.workspaceId, "done");
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectOnlyWorkspaceHoverCardFor,
|
||||
expectWorkspaceHoverCardClosed,
|
||||
expectWorkspaceHoverCardOpen,
|
||||
expectWorkspaceHoverCardStaysOpen,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
movePointerAwayFromWorkspaceHoverCard,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
sweepPointerAcrossWorkspaceRows,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -79,6 +82,22 @@ async function openWorkspaceHoverCard(page: import("@playwright/test").Page, wor
|
||||
return hoverCard;
|
||||
}
|
||||
|
||||
async function sortWorkspacesBySidebarOrder<T extends { workspaceId: string }>(
|
||||
page: Page,
|
||||
workspaces: T[],
|
||||
): Promise<T[]> {
|
||||
const positioned = await Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const bounds = await page
|
||||
.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))
|
||||
.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspace.workspaceId}`);
|
||||
return { workspace, y: bounds.y };
|
||||
}),
|
||||
);
|
||||
return positioned.sort((left, right) => left.y - right.y).map(({ workspace }) => workspace);
|
||||
}
|
||||
|
||||
interface PaseoOwnedWorktree {
|
||||
projectName: string;
|
||||
workspaceId: string;
|
||||
@@ -429,6 +448,38 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("scanning up the workspace rows leaves only the resting row's hover card open", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspaces = await Promise.all(
|
||||
[1, 2, 3, 4, 5].map((index) =>
|
||||
seedWorkspace({
|
||||
repoPrefix: `sidebar-hover-scan-${index}-`,
|
||||
title: `Hover scan ${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
for (const workspace of workspaces) {
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
}
|
||||
const rowsTopToBottom = await sortWorkspacesBySidebarOrder(page, workspaces);
|
||||
const top = rowsTopToBottom[0]!;
|
||||
|
||||
for (let scan = 0; scan < 3; scan += 1) {
|
||||
await sweepPointerAcrossWorkspaceRows(
|
||||
page,
|
||||
rowsTopToBottom.map((workspace) => workspace.workspaceId).toReversed(),
|
||||
);
|
||||
await expectOnlyWorkspaceHoverCardFor(page, top.workspaceName);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(workspaces.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
@@ -582,6 +633,12 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await page.getByTestId("menu-button").click();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
|
||||
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await focusWorkspaceRowWithKeyboard(page, lastWorkspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await scrollWorkspaceList(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -645,6 +702,9 @@ test.describe("Half-screen desktop layout", () => {
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await explorerToggle.click();
|
||||
|
||||
@@ -38,8 +38,12 @@ async function openParentInRightPane(page: Page, parentId: string, workspaceId:
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
// A later split can replace the parent pane's rail with compact chrome.
|
||||
// Keep the pane identity instead of finding it again through that rail.
|
||||
const rightPaneId = await right.getAttribute("data-testid");
|
||||
expect(rightPaneId).not.toBeNull();
|
||||
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
|
||||
return { left, right };
|
||||
return { left, right: page.getByTestId(rightPaneId!) };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
@@ -413,6 +414,8 @@ test("searches retained output without sending Find input to the shell", async (
|
||||
test("keeps terminal Find owned by retained tabs and the focused split pane", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// This journey selects tabs from the full rails in both split panes.
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const first = await harness.createTerminal({
|
||||
name: "First search",
|
||||
command: "bash",
|
||||
@@ -487,3 +490,142 @@ test("keeps the shared Find controls usable in a compact dark browser", async ({
|
||||
await page.getByRole("button", { name: "Close Find" }).click();
|
||||
await expect(input(page)).toBeFocused();
|
||||
});
|
||||
|
||||
// A phone browser installs xterm's document-level touch gesture listener too.
|
||||
// Keep this journey touch-enabled so it catches duplicate scroll handling.
|
||||
test.describe("terminal history with a finger", () => {
|
||||
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("scrolls history once per finger drag", async ({ page }) => {
|
||||
await openPhoneTerminalHistory(page);
|
||||
await expectSingleFingerScroll(page, 24);
|
||||
await expectSingleFingerScroll(page, 1);
|
||||
await expectMouseWheelScrollsHistory(page);
|
||||
});
|
||||
|
||||
for (const mode of ["alternate", "mouse"] as const) {
|
||||
test(`preserves finger navigation in ${mode} input mode`, async ({ page }) => {
|
||||
await openPhoneInputCapture(page, mode);
|
||||
await expectFingerNavigationInput(page, mode);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function expectFingerNavigationInput(page: Page, mode: "alternate" | "mouse") {
|
||||
await dragTerminalWithFinger(page, 24, async () => {
|
||||
const prefix = mode === "alternate" ? "TOUCH_BYTES:1b4f41" : "TOUCH_BYTES:1b5b3c3634";
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain(prefix);
|
||||
});
|
||||
}
|
||||
|
||||
async function openPhoneTerminalHistory(page: Page) {
|
||||
await harness.createTerminal({
|
||||
name: "Finger scroll history",
|
||||
command: "bash",
|
||||
args: [
|
||||
"--noprofile",
|
||||
"--norc",
|
||||
"-c",
|
||||
"for i in $(seq 1 150); do echo HISTORY_$i; done; exec sleep 300",
|
||||
],
|
||||
});
|
||||
await openPhoneTerminal(page);
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150");
|
||||
}
|
||||
|
||||
async function openPhoneInputCapture(page: Page, mode: "alternate" | "mouse") {
|
||||
const escapes = mode === "alternate" ? "\x1b[?1049h\x1b[?1h" : "\x1b[?1000h\x1b[?1006h";
|
||||
await harness.createTerminal({
|
||||
name: "Touch application input",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.once("data", () => {
|
||||
process.stdin.on("data", chunk => {
|
||||
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
|
||||
});
|
||||
process.stdout.write(${JSON.stringify(escapes)} + "TOUCH_INPUT_READY\\r\\n");
|
||||
});
|
||||
process.stdout.write("TOUCH_START_READY\\r\\n");
|
||||
`,
|
||||
],
|
||||
});
|
||||
await openPhoneTerminal(page);
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_START_READY");
|
||||
await input(page).press("x");
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__paseoTerminal!.buffer.active.type))
|
||||
.toBe(mode === "alternate" ? "alternate" : "normal");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
|
||||
.toBe(mode === "mouse" ? "vt200" : "none");
|
||||
}
|
||||
|
||||
async function openPhoneTerminal(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${harness.workspaceId}`).click();
|
||||
await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeVisible();
|
||||
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectMouseWheelScrollsHistory(page: Page) {
|
||||
const beforeWheel = (await viewport(page))!;
|
||||
await page.mouse.move(200, 300);
|
||||
await page.mouse.wheel(0, -120);
|
||||
await expect.poll(() => viewport(page)).toBeLessThan(beforeWheel);
|
||||
}
|
||||
|
||||
async function expectSingleFingerScroll(page: Page, moves: number) {
|
||||
await dragTerminalWithFinger(page, moves, async ({ lineHeight, before }) => {
|
||||
await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0);
|
||||
const moved = before - (await viewport(page))!;
|
||||
await test.info().attach("finger-scroll", {
|
||||
body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual(
|
||||
Math.ceil(120 / lineHeight),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function dragTerminalWithFinger(
|
||||
page: Page,
|
||||
moves: number,
|
||||
inspect: (state: { lineHeight: number; before: number }) => Promise<void>,
|
||||
) {
|
||||
await test.step(`Drag history with ${moves} touch moves`, async () => {
|
||||
const screen = page
|
||||
.getByTestId("terminal-surface")
|
||||
.filter({ visible: true })
|
||||
.locator(".xterm-screen");
|
||||
const box = await screen.boundingBox();
|
||||
if (!box) throw new Error("Terminal screen is not visible");
|
||||
const rows = await page.evaluate(() => window.__paseoTerminal!.rows);
|
||||
const lineHeight = box.height / rows;
|
||||
const before = (await viewport(page))!;
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
const start = { x: box.x + box.width / 2, y: box.y + box.height / 3 };
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ ...start, id: 1 }],
|
||||
});
|
||||
for (let step = 1; step <= moves; step++) {
|
||||
await session.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: start.x, y: start.y + (120 * step) / moves, id: 1 }],
|
||||
});
|
||||
}
|
||||
// Check before lifting the finger to exclude inertial scrolling.
|
||||
await inspect({ lineHeight, before });
|
||||
} finally {
|
||||
await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
await session.detach();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => {
|
||||
for (;;) {
|
||||
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
|
||||
if (!paste) break;
|
||||
const pasted = JSON.stringify(paste[1]).replace(
|
||||
/\s/g,
|
||||
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
|
||||
);
|
||||
process.stdout.write("PASTED:" + pasted + "\r\n");
|
||||
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
|
||||
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
|
||||
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
|
||||
@@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => {
|
||||
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
// Every bracketed paste body the receiver consumed, in order and joined: what a
|
||||
// shell would read as one command line.
|
||||
async function pastedText(terminalId: string): Promise<string> {
|
||||
const output = await terminalOutput(terminalId);
|
||||
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
|
||||
.map(([, json]) => JSON.parse(json) as string)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Exactly one ASCII space after each quoted path and nothing else between them,
|
||||
// so the paths stay separate shell words.
|
||||
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
|
||||
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
|
||||
}
|
||||
|
||||
test("pastes images from two panes into their own terminals as uploaded host files", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => {
|
||||
|
||||
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
|
||||
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
|
||||
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
|
||||
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
|
||||
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
|
||||
try {
|
||||
await dropFiles(page, [first]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
|
||||
await dropFiles(page, [second]);
|
||||
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
|
||||
|
||||
await expectSeparatePastedPaths(terminal.id);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
expectTerminalCursor,
|
||||
getTerminalBufferText,
|
||||
waitForTerminalContent,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
|
||||
interface AttachOverlayProbeWindow extends Window {
|
||||
@@ -59,4 +64,65 @@ test.describe("retained terminal tab streams", () => {
|
||||
|
||||
expect(await terminalAttachOverlayWasSeen(page)).toBe(false);
|
||||
});
|
||||
|
||||
for (const { count, retainedMarker } of [
|
||||
{ count: 3000, retainedMarker: "history-0001界🙂" },
|
||||
{ count: 12050, retainedMarker: "history-3000界🙂" },
|
||||
]) {
|
||||
test(`reattach restores retained styled Unicode history from ${count} lines`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
const key = "@paseo:app-settings";
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, terminalScrollbackLines: 10000 }));
|
||||
});
|
||||
const terminal = await harness.createTerminal({
|
||||
name: `history-${count}`,
|
||||
command: "/bin/sh",
|
||||
args: [
|
||||
"-lc",
|
||||
`i=1; while [ $i -le ${count} ]; do printf '\\033[31mhistory-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 120`,
|
||||
],
|
||||
});
|
||||
const last = `history-${String(count).padStart(4, "0")}界🙂`;
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
|
||||
expect(await getTerminalBufferText(page)).toContain(retainedMarker);
|
||||
await page.reload();
|
||||
await waitForTerminalContent(page, (text) => text.includes(last), 10000);
|
||||
const restored = await getTerminalBufferText(page);
|
||||
expect(restored).toContain(retainedMarker);
|
||||
expect(restored.match(/history-\d{4,5}/g)?.length).toBeGreaterThanOrEqual(
|
||||
Math.min(count, 10000),
|
||||
);
|
||||
if (count > 10000) expect(restored).not.toContain("history-0001界🙂");
|
||||
});
|
||||
}
|
||||
for (const input of [
|
||||
{ name: "client blinking bar", presentation: "", shape: "bar" as const, blinking: true },
|
||||
{
|
||||
name: "program blinking underline",
|
||||
presentation: "\x1b[3 q",
|
||||
shape: "underline" as const,
|
||||
blinking: true,
|
||||
},
|
||||
]) {
|
||||
test(`reattach preserves the ${input.name} cursor`, async ({ page }) => {
|
||||
const terminal = await harness.createTerminal({
|
||||
name: "cursor-presentation",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`,
|
||||
],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
|
||||
await expectTerminalCursor(page, input);
|
||||
await page.reload();
|
||||
await waitForTerminalContent(page, (text) => text.includes("CURSOR-READY"), 10000);
|
||||
await expectTerminalCursor(page, input);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import path from "node:path";
|
||||
import {
|
||||
holdOlderHistoryPages,
|
||||
scrollTimelineRowIntoView,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { getActiveTabTestId } from "../support/helpers/launcher";
|
||||
import type { Page, Locator } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { withDirectoryPlugin } from "../support/helpers/plugin-timeline";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.use({
|
||||
@@ -49,15 +51,36 @@ async function seedAnsweredAgent(title: string) {
|
||||
}
|
||||
|
||||
/** Chat rows between the latest turn's summary line and its reply, in document order. */
|
||||
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
async function rowsBetweenLatestSummaryAndReply(
|
||||
page: Page,
|
||||
replyTestId = "assistant-message",
|
||||
): Promise<number> {
|
||||
return page.evaluate((testId) => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const replyRow = rows.findLastIndex((row) =>
|
||||
row.querySelector("[data-testid=assistant-message]"),
|
||||
);
|
||||
const replyRow = rows.findLastIndex((row) => row.querySelector(`[data-testid="${testId}"]`));
|
||||
return replyRow - summaryRow - 1;
|
||||
});
|
||||
}, replyTestId);
|
||||
}
|
||||
|
||||
/** The latest turn's summary line is a chat row of its own, sitting apart from its prompt. */
|
||||
async function expectSummaryLineSitsApartFromPrompt(page: Page) {
|
||||
const summaryRow = () =>
|
||||
page.evaluate(() => {
|
||||
const latest = (testId: string) =>
|
||||
[...document.querySelectorAll(`[data-testid="${testId}"]`)].at(-1)!;
|
||||
const summary = latest("turn-summary");
|
||||
const row = summary.closest("[data-message-id]")!;
|
||||
return {
|
||||
sharesRowWithPrompt: row.querySelector("[data-testid=user-message]") !== null,
|
||||
gapBelowPrompt: Math.round(
|
||||
summary.getBoundingClientRect().top -
|
||||
latest("user-message").getBoundingClientRect().bottom,
|
||||
),
|
||||
};
|
||||
});
|
||||
await expect.poll(summaryRow).toMatchObject({ sharesRowWithPrompt: false });
|
||||
expect((await summaryRow()).gapBelowPrompt).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
|
||||
@@ -125,6 +148,67 @@ async function expandAndCollapseLongPrompt(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("Turn collapse", () => {
|
||||
test("folds work before a plugin-rendered reply and reopens it", async ({ page }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "plugin-reply-fold-",
|
||||
title: "Plugin reply folding",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Make the fix and run the burst script.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await withDirectoryPlugin({
|
||||
pluginId: "reply-fold-e2e",
|
||||
source: `import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addTimelineTransformer({
|
||||
id: "reply", query: { itemType: "assistant_message" },
|
||||
transform({ item, phase }) {
|
||||
if (phase === "streaming") return undefined;
|
||||
return { items: [{ type: "plugin", kind: "reply", version: 1, data: { text: item.text } }] };
|
||||
},
|
||||
});
|
||||
client.addTimelineRenderer({
|
||||
kind: "reply", version: 1, schema: z.object({ text: z.string() }),
|
||||
Component: ({ item }) => <Text testID="plugin-reply">{item.data.text}</Text>,
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
run: async () => {
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
for (const width of [1280, 390]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
const reply = page.getByTestId("plugin-reply").last();
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(reply).toContainText("(end of synthetic stream)");
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
|
||||
await page.screenshot({ path: info.outputPath(`plugin-reply-folded-${width}.png`) });
|
||||
await summary.click();
|
||||
await expect
|
||||
.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply"))
|
||||
.toBeGreaterThan(0);
|
||||
await expect(reply).toContainText("(end of synthetic stream)");
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
|
||||
}
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("turn-summary").last()).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
@@ -144,6 +228,7 @@ test.describe("Turn collapse", () => {
|
||||
/\(end of synthetic stream\)$/,
|
||||
);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await expectSummaryLineSitsApartFromPrompt(page);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
@@ -343,7 +428,6 @@ test("opening a turn keeps the received message above it clipped on every frame"
|
||||
.getByTestId("user-message")
|
||||
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
|
||||
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
|
||||
await bubble.scrollIntoViewIfNeeded();
|
||||
|
||||
await watchReceivedMessagePreview(page, "Sent by Reviewer");
|
||||
await page
|
||||
@@ -407,34 +491,87 @@ async function seedWholeTurnHistory() {
|
||||
return agent;
|
||||
}
|
||||
|
||||
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||
page,
|
||||
}) => {
|
||||
async function expectInitialFillIsConcealed(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0");
|
||||
}
|
||||
|
||||
async function expectFilledChatAtBottom(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
const chat = page.getByTestId("agent-chat-scroll");
|
||||
await expect(chat).toHaveCSS("opacity", "1");
|
||||
await expect
|
||||
.poll(() =>
|
||||
chat.evaluate((element) => ({
|
||||
hasOverflow: element.scrollHeight > element.clientHeight,
|
||||
atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2,
|
||||
})),
|
||||
)
|
||||
.toEqual({ hasOverflow: true, atBottom: true });
|
||||
}
|
||||
|
||||
test("fills collapsed history before revealing a fresh chat", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await page.setViewportSize({ width: 1280, height: 8000 });
|
||||
await history.expectRequestedPages(3);
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1");
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
history.releasePage(3);
|
||||
await history.expectSettledWithRequestedPages(3);
|
||||
history.expectNoRepeatedEntries();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await expectComposerVisible(page);
|
||||
const latest = page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 120 commands", exact: true });
|
||||
await expect(latest).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 60 commands", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(3);
|
||||
history.releasePage(3);
|
||||
await page.getByTestId("scroll-to-bottom-button").click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
history.expectNoRepeatedEntries();
|
||||
await latest.scrollIntoViewIfNeeded();
|
||||
await scrollTimelineRowIntoView(page, latest);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
@@ -529,6 +666,7 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
|
||||
.last();
|
||||
await expect(group).toContainText(/read 1 file/i);
|
||||
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||
await scrollTimelineRowIntoView(page, group);
|
||||
await group.getByRole("button").first().click();
|
||||
// Expand thinking is off: the group opens its rows, but the thought row stays closed
|
||||
// until the reader opens it.
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
expectFooterItemSetting,
|
||||
expectFooterSeparator,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
@@ -70,7 +71,112 @@ async function footerClip(page: Page) {
|
||||
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
|
||||
}
|
||||
|
||||
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
|
||||
await page.evaluate((state) => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => state === "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}, visibility);
|
||||
}
|
||||
|
||||
test.describe("Usage item", () => {
|
||||
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
|
||||
let usedPct = 31;
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
() => [
|
||||
{
|
||||
...claudeAndCodexReports()[0]!,
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(WIDE);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h"]);
|
||||
const startingUrl = page.url();
|
||||
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
|
||||
const requestsBeforeRefresh = usage.listRequests();
|
||||
|
||||
usedPct = 42;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
|
||||
await expectPinnedUsage(page, ["42% 5h"]);
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(startingUrl);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
...requestsBeforeRefresh,
|
||||
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
|
||||
]);
|
||||
|
||||
await setPageVisibility(page, "hidden");
|
||||
const requestsBeforeBackgrounding = usage.listRequests();
|
||||
usedPct = 47;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
|
||||
await expectPinnedUsage(page, ["42% 5h"]);
|
||||
await setPageVisibility(page, "visible");
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await expectPinnedUsage(page, ["47% 5h"]);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
...requestsBeforeBackgrounding,
|
||||
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
|
||||
]);
|
||||
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(usageItem(page)).toHaveCount(0);
|
||||
const requestsBeforeHiding = usage.listRequests();
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
|
||||
|
||||
await openCompactSidebar(page);
|
||||
await expectPinnedUsage(page, ["47% 5h"]);
|
||||
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
|
||||
usedPct = 53;
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
await expectPinnedUsage(page, ["53% 5h"]);
|
||||
await expect(usageModal(page)).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize(WIDE);
|
||||
await openSidebarNavSettings(page);
|
||||
await setFooterItemVisible(page, "usage", false);
|
||||
await leaveSettings(page);
|
||||
await expectNoUsageItem(page);
|
||||
const requestsBeforeDisabling = usage.listRequests();
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
|
||||
});
|
||||
|
||||
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(COMPACT);
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
await openSettingsHostSection(page, getServerId(), "usage");
|
||||
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
|
||||
await expect(usageItem(page)).toHaveCount(0);
|
||||
const requestsBeforeWaiting = usage.listRequests();
|
||||
|
||||
await page.clock.fastForward(5 * 60_000);
|
||||
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
|
||||
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
|
||||
});
|
||||
|
||||
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
|
||||
@@ -357,6 +357,8 @@ test.describe("Viewed agent timelines", () => {
|
||||
});
|
||||
|
||||
test("two visible split chats both stay current", async ({ page }) => {
|
||||
// Keep both split panes wide enough for the tab-rail interactions below.
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await enableMoveTabShortcut(page);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
archiveFromRowButton,
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
expectHostWorkspaceSettledActive,
|
||||
outlastUndoWindow,
|
||||
pressUndoShortcut,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
workspaceTitleWidth,
|
||||
} from "../support/helpers/workspace-archive-undo";
|
||||
import {
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive undo", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const worktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
tempRepo = await createTempGitRepo("archive-undo-");
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of worktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
|
||||
}
|
||||
worktreeDirectories.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
async function seedWorktree(page: Page, slug: string) {
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `${slug}-${Date.now()}`,
|
||||
});
|
||||
worktreeDirectories.add(worktree.workspaceDirectory);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: worktree.workspaceId,
|
||||
});
|
||||
return worktree;
|
||||
}
|
||||
|
||||
test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-button-overlay");
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0);
|
||||
const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId);
|
||||
|
||||
await workspaceRow(page, worktree.workspaceId).hover();
|
||||
await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible();
|
||||
expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth);
|
||||
|
||||
const [buttonBox, kebabBox] = await Promise.all([
|
||||
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
|
||||
rowKebab(page, worktree.workspaceId).boundingBox(),
|
||||
]);
|
||||
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-undo-shortcut");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await pressUndoShortcut(page);
|
||||
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
||||
// Once the host settled, any remove the canceled archive sent has already arrived.
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, true);
|
||||
});
|
||||
|
||||
test("Undo after the worktree was removed recreates it", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-undo-recreate");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
// Hovering the toast holds it, and with it the undo, open while the archive finishes.
|
||||
await undoButton(page).hover();
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, false);
|
||||
await undoButton(page).click();
|
||||
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
await expectDirectoryPresence(worktree.workspaceDirectory, true);
|
||||
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
||||
});
|
||||
|
||||
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
const worktree = await seedWorktree(page, "archive-undo-hold");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } });
|
||||
await undoButton(page).hover();
|
||||
await outlastUndoWindow(page);
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await outlastUndoWindow(page);
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
|
||||
// The toast took the undo with it.
|
||||
await pressUndoShortcut(page);
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
});
|
||||
|
||||
test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => {
|
||||
const teardown = createGatedTeardown();
|
||||
const repo = await createTempGitRepo("archive-undo-tabs-", {
|
||||
paseoConfig: teardown.paseoConfig,
|
||||
});
|
||||
const seedClient = await connectSeedClient();
|
||||
try {
|
||||
await openProjectViaDaemon(client, repo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: repo.path,
|
||||
slug: `archive-undo-tabs-${Date.now()}`,
|
||||
});
|
||||
worktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId };
|
||||
const agents = [
|
||||
await createMockIdleAgent(seedClient, { ...target, title: "first" }),
|
||||
await createMockIdleAgent(seedClient, { ...target, title: "second" }),
|
||||
] as const;
|
||||
await openWorkspaceWithAgents(page, [agents[0], agents[1]]);
|
||||
const tabsBefore = await getWorkspaceTabTestIds(page);
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
// The archive has taken its agents down and now parks in teardown.
|
||||
await teardown.expectStarted();
|
||||
await undoButton(page).click();
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
|
||||
for (const agent of agents) {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible();
|
||||
}
|
||||
teardown.release();
|
||||
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
||||
await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore);
|
||||
} finally {
|
||||
teardown.release();
|
||||
await seedClient.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => {
|
||||
const worktree = await seedWorktree(page, "archive-undo-none");
|
||||
|
||||
await pressUndoShortcut(page);
|
||||
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -101,3 +101,20 @@ test("focus mode only applies to the active workspace screen", async ({ page, wi
|
||||
await expect(exitFocusMode).toHaveCount(0);
|
||||
await expect(settingsButton).toBeVisible();
|
||||
});
|
||||
|
||||
async function exitFocusModeFromCompactHeader(page: Page) {
|
||||
await page.setViewportSize({ width: 400, height: 844 });
|
||||
await page.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Exit focus mode", exact: true }).click();
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
}
|
||||
|
||||
test("compact workspace menu keeps focus mode exit reachable", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "compact-focus-exit-" });
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await enterFocusMode(page);
|
||||
await exitFocusModeFromCompactHeader(page);
|
||||
await expect(exitFocusModeButton(page)).toHaveCount(0);
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -52,6 +52,8 @@ async function waitForWorkspaceRoute(page: Page, route: string) {
|
||||
test.describe("Workspace pane mounting", () => {
|
||||
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// Keep both split panes wide enough for the tab-rail interactions below.
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const serverId = getServerId();
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" });
|
||||
|
||||
@@ -70,25 +70,31 @@ test("previous and next traverse every visible workspace while digit shortcuts s
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
|
||||
await expectHiddenWorkspaceShortcuts(page, order);
|
||||
await test.step("collapsed groups keep shortcut targets selected without expanding", async () => {
|
||||
await expectCollapsedWorkspaceShortcuts(page, order);
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
|
||||
async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) {
|
||||
await page.getByLabel("Show less", { exact: true }).click();
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(7);
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await expect(rows).toHaveCount(7);
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await expect(rows).toHaveCount(8);
|
||||
await page.keyboard.press("Alt+]");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] });
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Alt+]");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||
await expect(rows).toHaveCount(7);
|
||||
await expect(page.getByLabel("Show more", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ env APP_ID=sh.paseo.debug
|
||||
# Hold past drag arming (180ms), then release.
|
||||
open "${APP_ID}"
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"sidebar-close\"" 10000
|
||||
press "id=\"sidebar-close\""
|
||||
# The close target must clear system controls, including a trailing status column.
|
||||
wait "label=\"Open menu\"" 5000
|
||||
press "label=\"Open menu\""
|
||||
wait "id=\"${WORKSPACE_ROW}\"" 10000
|
||||
longpress "id=\"${WORKSPACE_ROW}\"" 250
|
||||
press "id=\"${WORKSPACE_ROW}\""
|
||||
|
||||
@@ -509,6 +509,8 @@ check_autocomplete() {
|
||||
fi
|
||||
fi
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
# Rows replace the popover's loading row once the host lists them; a cold Claude list is slow.
|
||||
ad wait "text=\"${expected}\"" 30000
|
||||
# The popover remeasures once keyboard motion settles.
|
||||
sleep 1
|
||||
capture_screen "${prefix}.png"
|
||||
|
||||
@@ -69,17 +69,23 @@ async function waitFor(name, predicate, timeoutMs = 10_000) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard motion has settled once two snapshots agree on the composer position.
|
||||
// Keyboard motion has settled once two snapshots agree on the composer position. The
|
||||
// popover shows a loading row until the host lists its commands; a cold Claude list is slow.
|
||||
async function settledPlacement(name, keyboard) {
|
||||
let previousTop;
|
||||
return waitFor(name, (snap) => {
|
||||
const composer = nodeById(snap, "message-input-root");
|
||||
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
|
||||
if (keyboardShown(snap) !== keyboard) return false;
|
||||
const stable = composer.rect.y === previousTop;
|
||||
previousTop = composer.rect.y;
|
||||
return stable;
|
||||
});
|
||||
return waitFor(
|
||||
name,
|
||||
(snap) => {
|
||||
const composer = nodeById(snap, "message-input-root");
|
||||
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
|
||||
if (nodeById(snap, "composer-autocomplete-loading")) return false;
|
||||
if (keyboardShown(snap) !== keyboard) return false;
|
||||
const stable = composer.rect.y === previousTop;
|
||||
previousTop = composer.rect.y;
|
||||
return stable;
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
}
|
||||
|
||||
function assertPlacement(name, snap, expected) {
|
||||
|
||||
@@ -38,12 +38,9 @@ async function expectDarkIcons(expected, context) {
|
||||
assert.equal(windowState(), expected, context);
|
||||
}
|
||||
|
||||
// Settings > Appearance shows every theme as a tile in one radio group.
|
||||
function selectTheme(theme) {
|
||||
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
|
||||
assert(current, "Start on Settings > Appearance");
|
||||
ad("press", `label="Theme: ${current}"`, "--settle");
|
||||
ad("press", `label="${theme}"`, "--settle");
|
||||
ad("wait", "text", `Theme: ${theme}`, "5000");
|
||||
}
|
||||
|
||||
ad("settings", "appearance", "light");
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import xterm from "@xterm/headless";
|
||||
import { PNG } from "pngjs";
|
||||
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
|
||||
|
||||
/** A pixel is ink when one channel differs from the terminal background by more than this. */
|
||||
const INK_THRESHOLD = 70;
|
||||
/** Ink pixels a printed grapheme cluster needs inside its own columns. */
|
||||
const MIN_CLUSTER_INK = 3;
|
||||
/** How far a pixel may sit from the background-to-color blend line and still count as that color. */
|
||||
const BLEND_TOLERANCE = 40;
|
||||
/** Pixels of another cell's color tolerated in a cell, for antialiasing at the cell edge. */
|
||||
const MAX_FOREIGN_INK = 2;
|
||||
|
||||
const graphemes = new Intl.Segmenter("und", { granularity: "grapheme" });
|
||||
|
||||
/**
|
||||
* Checks a screenshot of the fixture against the cells xterm assigns: every printed grapheme
|
||||
* cluster has ink inside its own columns, blank cells stay blank, and the cursor sits right after
|
||||
* the text. A cluster that spans cells, such as a Devanagari conjunct, is shaped as one unit, so
|
||||
* it is judged across all its columns. On rows that use the fixture's accent color, every cell
|
||||
* must be drawn in its own color, including cells that share a shaped cluster.
|
||||
*
|
||||
* These are geometry and color checks: they cannot tell a correct glyph from a wrong one.
|
||||
*
|
||||
* `rows` maps a terminal row index to its rect in screenshot pixels.
|
||||
*/
|
||||
export async function checkTerminalScreenshot({ png, size, rows }) {
|
||||
const terminal = await fixtureTerminal(size);
|
||||
assert.ok(rows.get(0), "The ruler row is not exposed as terminal-row-0");
|
||||
const grid = measureGrid(png, rows.get(0));
|
||||
const { failures, slots } = checkRows({ png, terminal, rows, grid });
|
||||
terminal.dispose();
|
||||
return { grid, failures, slots };
|
||||
}
|
||||
|
||||
/** Where the harness selects: the first cell of the conjunct that starts the `select` line. */
|
||||
export async function selectionTarget({ size }) {
|
||||
const terminal = await fixtureTerminal(size);
|
||||
const row = lineNames(terminal.buffer.active, terminal.buffer.active.cursorY + 1).indexOf(
|
||||
SELECT_LINE,
|
||||
);
|
||||
terminal.dispose();
|
||||
return { row, col: 0, clusterColumns: 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a screenshot taken with only the first cell of the `select` conjunct selected: that cell
|
||||
* has the selection background and the conjunct's second, unselected cell keeps its accent color.
|
||||
*/
|
||||
export function checkPartialSelection({ png, rows, grid, target }) {
|
||||
const rect = rows.get(target.row);
|
||||
if (!rect) return [{ row: target.row, kind: "missing-row" }];
|
||||
const cell = (col) => ({
|
||||
left: grid.left + col * grid.cellWidth + 1,
|
||||
right: grid.left + (col + 1) * grid.cellWidth - 1,
|
||||
top: rect.y,
|
||||
bottom: rect.y + rect.height,
|
||||
});
|
||||
const selected = cell(target.col);
|
||||
const unselected = cell(target.col + 1);
|
||||
const selectionBackground = pixelAt(png, selected.left + 1, selected.top + 1);
|
||||
const unselectedBackground = pixelAt(png, unselected.left + 1, unselected.top + 1);
|
||||
if (
|
||||
!differs(selectionBackground, grid.background) ||
|
||||
differs(unselectedBackground, grid.background)
|
||||
) {
|
||||
return [{ row: target.row, col: target.col, kind: "selection-not-set-up" }];
|
||||
}
|
||||
const failures = [];
|
||||
const ownInk = countColor(png, unselected, grid.background, ACCENT);
|
||||
if (ownInk < MIN_CLUSTER_INK) {
|
||||
failures.push({ row: target.row, col: target.col + 1, kind: "unselected-cell-lost-color" });
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
async function fixtureTerminal(size) {
|
||||
const terminal = new xterm.Terminal({
|
||||
cols: size.columns,
|
||||
rows: size.rows,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
// The app's buffer uses grapheme widths; the expected cells must too.
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
await new Promise((done) =>
|
||||
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
|
||||
);
|
||||
return terminal;
|
||||
}
|
||||
|
||||
export function writeOverlay(png, slots, path) {
|
||||
const out = new PNG({ width: png.width, height: png.height });
|
||||
png.data.copy(out.data);
|
||||
for (const slot of slots) {
|
||||
const color = slot.ok ? [0, 200, 0] : [255, 0, 0];
|
||||
const { left, right, top, bottom } = slot.box;
|
||||
for (let x = Math.ceil(left); x < right; x += 1) {
|
||||
for (const y of [Math.ceil(top), Math.floor(bottom) - 1]) {
|
||||
const offset = (out.width * y + x) * 4;
|
||||
out.data.set(color, offset);
|
||||
}
|
||||
}
|
||||
if (!slot.ok) {
|
||||
for (let y = Math.ceil(top); y < bottom; y += 1) {
|
||||
for (const x of [Math.ceil(left), Math.floor(right) - 1]) {
|
||||
out.data.set(color, (out.width * y + x) * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(path, PNG.sync.write(out));
|
||||
}
|
||||
|
||||
function inkAt(png, x, y, background) {
|
||||
const offset = (png.width * Math.round(y) + Math.round(x)) * 4;
|
||||
return (
|
||||
Math.abs(png.data[offset] - background[0]) > INK_THRESHOLD ||
|
||||
Math.abs(png.data[offset + 1] - background[1]) > INK_THRESHOLD ||
|
||||
Math.abs(png.data[offset + 2] - background[2]) > INK_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
/** Pixels in `box` that differ from `background` in some channel by more than the ink threshold. */
|
||||
export function countInk(png, box, background) {
|
||||
let count = 0;
|
||||
for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) {
|
||||
for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) {
|
||||
if (inkAt(png, x, y, background)) count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function differs(a, b) {
|
||||
return a.some((channel, index) => Math.abs(channel - b[index]) > INK_THRESHOLD);
|
||||
}
|
||||
|
||||
/** Distance from `pixel` to the nearest blend of `background` toward `color`. */
|
||||
function blendDistance(pixel, background, color) {
|
||||
const direction = color.map((channel, index) => channel - background[index]);
|
||||
const length = direction.reduce((sum, value) => sum + value * value, 0);
|
||||
const offset = pixel.map((channel, index) => channel - background[index]);
|
||||
const along = offset.reduce((sum, value, index) => sum + value * direction[index], 0);
|
||||
const alpha = length === 0 ? 0 : Math.min(1, Math.max(0, along / length));
|
||||
return Math.hypot(...offset.map((value, index) => value - alpha * direction[index]));
|
||||
}
|
||||
|
||||
/** Ink pixels in `box` that are a blend of the background toward `color`. */
|
||||
function countColor(png, box, background, color, other) {
|
||||
let count = 0;
|
||||
for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) {
|
||||
for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) {
|
||||
if (!inkAt(png, x, y, background)) continue;
|
||||
const pixel = pixelAt(png, x, y);
|
||||
const distance = blendDistance(pixel, background, color);
|
||||
if (distance > BLEND_TOLERANCE) continue;
|
||||
if (other && blendDistance(pixel, background, other) <= distance) continue;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function pixelAt(png, x, y) {
|
||||
const offset = (png.width * Math.round(y) + Math.round(x)) * 4;
|
||||
return [png.data[offset], png.data[offset + 1], png.data[offset + 2]];
|
||||
}
|
||||
|
||||
/** Reads the grid origin and cell width from a ruler row of `RULER_CELLS` full blocks. */
|
||||
export function measureGrid(png, rulerRect) {
|
||||
const y = rulerRect.y + rulerRect.height / 2;
|
||||
const background = pixelAt(png, rulerRect.x + rulerRect.width - 2, y);
|
||||
let left = -1;
|
||||
let right = -1;
|
||||
for (let x = Math.ceil(rulerRect.x); x < rulerRect.x + rulerRect.width; x += 1) {
|
||||
if (!inkAt(png, x, y, background)) continue;
|
||||
if (left < 0) left = x;
|
||||
right = x + 1;
|
||||
}
|
||||
assert.ok(left >= 0, "The ruler row has no ink");
|
||||
const foreground = pixelAt(png, Math.round((left + right) / 2), y);
|
||||
return { left, cellWidth: (right - left) / RULER_CELLS, background, foreground };
|
||||
}
|
||||
|
||||
function lineNames(buffer, count) {
|
||||
const names = fixtureLines(0).map((line) => line.name);
|
||||
const result = [];
|
||||
let index = -1;
|
||||
for (let row = 0; row < count; row += 1) {
|
||||
if (!buffer.getLine(row).isWrapped) index += 1;
|
||||
result.push(names[index] ?? `line-${index}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The row's printed cells, consecutive cells of one grapheme cluster grouped together. */
|
||||
function rowClusters(line, columns, cell) {
|
||||
const cells = [];
|
||||
let text = "";
|
||||
for (let col = 0; col < columns; col += 1) {
|
||||
line.getCell(col, cell);
|
||||
const width = cell.getWidth();
|
||||
if (width === 0) continue;
|
||||
const chars = cell.getChars() || " ";
|
||||
cells.push({
|
||||
col,
|
||||
width,
|
||||
chars,
|
||||
offset: text.length,
|
||||
painted: cell.isInverse() || !cell.isBgDefault(),
|
||||
accent: cell.isFgRGB() && cell.getFgColor() === rgbValue(ACCENT),
|
||||
});
|
||||
text += chars;
|
||||
}
|
||||
const clusterEnds = [...graphemes.segment(text)].map(
|
||||
(segment) => segment.index + segment.segment.length,
|
||||
);
|
||||
const clusters = [];
|
||||
let end = 0;
|
||||
for (const entry of cells) {
|
||||
while (entry.offset >= clusterEnds[end]) end += 1;
|
||||
const last = clusters.at(-1);
|
||||
if (last && last.end === end) last.cells.push(entry);
|
||||
else clusters.push({ end, cells: [entry] });
|
||||
}
|
||||
return clusters.map((cluster) => cluster.cells);
|
||||
}
|
||||
|
||||
function rgbValue([red, green, blue]) {
|
||||
return (red << 16) | (green << 8) | blue;
|
||||
}
|
||||
|
||||
/** Each cell of a cluster that uses the accent color holds ink of its own color only. */
|
||||
function checkClusterColors({ png, cluster, rect, grid }) {
|
||||
const kinds = [];
|
||||
for (const entry of cluster) {
|
||||
const box = {
|
||||
left: grid.left + entry.col * grid.cellWidth + 1,
|
||||
right: grid.left + (entry.col + entry.width) * grid.cellWidth - 1,
|
||||
top: rect.y,
|
||||
bottom: rect.y + rect.height,
|
||||
};
|
||||
const [color, other] = entry.accent ? [ACCENT, grid.foreground] : [grid.foreground, ACCENT];
|
||||
const own = countColor(png, box, grid.background, color, other);
|
||||
const foreign = countColor(png, box, grid.background, other, color);
|
||||
if (own < MIN_CLUSTER_INK || foreign > MAX_FOREIGN_INK) {
|
||||
kinds.push({ col: entry.col, kind: entry.accent ? "accent-color-lost" : "color-leaked" });
|
||||
}
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
function checkRows({ png, terminal, rows, grid }) {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lastRow = buffer.cursorY;
|
||||
const names = lineNames(buffer, lastRow + 1);
|
||||
const failures = [];
|
||||
const slots = [];
|
||||
const cell = buffer.getNullCell();
|
||||
for (let row = 0; row <= lastRow; row += 1) {
|
||||
const rect = rows.get(row);
|
||||
if (!rect) {
|
||||
failures.push({ row, line: names[row], kind: "missing-row" });
|
||||
continue;
|
||||
}
|
||||
const clusters = rowClusters(buffer.getLine(row), terminal.cols, cell);
|
||||
// Color is judged on whole rows that use the accent, so a color leaking into a neighbouring
|
||||
// default-colored cell fails too.
|
||||
const checksColor = clusters.some((cluster) => cluster.some((entry) => entry.accent));
|
||||
for (const cluster of clusters) {
|
||||
const first = cluster[0];
|
||||
const columns = cluster.reduce((sum, entry) => sum + entry.width, 0);
|
||||
const left = grid.left + first.col * grid.cellWidth;
|
||||
const right = left + columns * grid.cellWidth;
|
||||
const box = { left: left + 1, right: right - 1, top: rect.y, bottom: rect.y + rect.height };
|
||||
const chars = cluster.map((entry) => entry.chars).join("");
|
||||
const isCursor = row === lastRow && first.col === buffer.cursorX;
|
||||
let kind = null;
|
||||
if (isCursor) {
|
||||
const covered = countInk(png, box, grid.background);
|
||||
const area = (box.right - box.left) * (box.bottom - box.top);
|
||||
if (covered < area * 0.25) kind = "cursor-not-drawn";
|
||||
} else if (chars.trim() !== "") {
|
||||
if (countInk(png, box, grid.background) < MIN_CLUSTER_INK) kind = "missing-ink";
|
||||
} else if (!cluster.some((entry) => entry.painted)) {
|
||||
const quarter = (right - left) / 4;
|
||||
const center = { ...box, left: left + quarter, right: right - quarter };
|
||||
if (countInk(png, center, grid.background) > 0) kind = "ink-in-blank";
|
||||
}
|
||||
const colorFailures =
|
||||
kind === null && checksColor && chars.trim() !== ""
|
||||
? checkClusterColors({ png, cluster, rect, grid })
|
||||
: [];
|
||||
slots.push({
|
||||
row,
|
||||
line: names[row],
|
||||
col: first.col,
|
||||
width: columns,
|
||||
chars,
|
||||
isCursor,
|
||||
box,
|
||||
ok: kind === null && colorFailures.length === 0,
|
||||
});
|
||||
if (kind) failures.push({ row, line: names[row], col: first.col, char: chars, kind });
|
||||
for (const failure of colorFailures) {
|
||||
failures.push({ row, line: names[row], char: chars, ...failure });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { failures, slots };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Terminal output covering the scripts and styles the native renderer must place on the grid.
|
||||
// Each entry is one line of output; `ruler` is drawn with full blocks so the harness can read
|
||||
// the grid origin and cell width from pixels, independent of the renderer under test.
|
||||
|
||||
const ESC = "\u001b";
|
||||
const sgr = (codes, text) => `${ESC}[${codes}m${text}${ESC}[0m`;
|
||||
|
||||
export const RULER_CELLS = 20;
|
||||
/** A foreground no theme uses, so its pixels can be told apart from default text. */
|
||||
export const ACCENT = [255, 64, 160];
|
||||
const accent = (text) => sgr(`38;2;${ACCENT.join(";")}`, text);
|
||||
/**
|
||||
* The `select` line starts with a conjunct whose first cell the harness selects on its own. Its
|
||||
* second cell uses the accent, which no theme's selection foreground matches.
|
||||
*/
|
||||
export const SELECT_LINE = "select";
|
||||
|
||||
export function fixtureLines(columns) {
|
||||
const wrapSyllables = Math.ceil(columns / 2) + 6;
|
||||
return [
|
||||
{ name: "ruler", text: "█".repeat(RULER_CELLS) },
|
||||
{ name: "ascii", text: "ASCII abcdefghij 0123456789 end" },
|
||||
{ name: "hangul-mixed", text: "한글 문장 Hello end" },
|
||||
{ name: "hangul-color-boundary", text: `안녕하세요${sgr(31, "NEXT")} end` },
|
||||
{ name: "hangul-bold-boundary", text: `${sgr(1, "안녕하세요")}NEXT end` },
|
||||
{ name: "hangul-decomposed", text: "한글 end" },
|
||||
{ name: "cjk", text: "日本語テキスト 中文字符 end" },
|
||||
{ name: "combining", text: "é ä ñ x̣̂ end" },
|
||||
{ name: "arabic", text: "مرحبا لا 123 abc end" },
|
||||
{ name: "devanagari", text: "नमस्ते क्षत्रिय end" },
|
||||
{ name: "emoji", text: "👍 ❤️ 👋🏽 👩💻 🇰🇷 end" },
|
||||
// One shaped cluster over two cells, colored per cell: the color must follow the cell.
|
||||
{ name: "split-color", text: `क्${accent("ष")} ${accent("क्")}ष ल${accent("ا")} end` },
|
||||
{ name: SELECT_LINE, text: `क्${accent("ष")} end` },
|
||||
{ name: "box", text: "╭──┬──╮ │ab│cd│ ╰──┴──╯ ▀▄▌▐░▒▓ end" },
|
||||
{
|
||||
name: "styles",
|
||||
text: [
|
||||
sgr(1, "bold"),
|
||||
sgr(3, "italic"),
|
||||
sgr(4, "under"),
|
||||
sgr(7, "inverse"),
|
||||
sgr(42, "bg"),
|
||||
sgr(9, "strike"),
|
||||
"end",
|
||||
].join(" "),
|
||||
},
|
||||
{ name: "hangul-wrap", text: "가".repeat(wrapSyllables) },
|
||||
// The last line has no newline: the shell sleeps with the cursor right after it.
|
||||
{ name: "cursor", text: "> 안녕하세요" },
|
||||
];
|
||||
}
|
||||
|
||||
/** The bytes `cat` writes; the tty turns each newline into CR LF. */
|
||||
export function fixtureOutput(columns) {
|
||||
return fixtureLines(columns)
|
||||
.map((line) => line.text)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { PNG } from "pngjs";
|
||||
import { checkTerminalScreenshot } from "./check.mjs";
|
||||
|
||||
// Checks that every printed cluster of a `render.mjs` run is the right glyph. `check.mjs` proves
|
||||
// where ink lands and what color it has; this compares each cluster's ink with pango's (HarfBuzz)
|
||||
// rendering of the same characters in the device's own fonts, so a wrong glyph, tofu, a solid
|
||||
// block, a split conjunct or a cluster from elsewhere fails.
|
||||
//
|
||||
// FONTCONFIG_FILE=<fonts.conf> node e2e/mobile/terminal-rendering/identity.mjs <run dir> [control]
|
||||
//
|
||||
// The fonts.conf lists the device's font files and prefers, for `monospace`, the terminal font
|
||||
// followed by the device's fallbacks; `docs/terminal-rendering.md` says how to build one. Needs
|
||||
// `pango-view` on the PATH.
|
||||
//
|
||||
// control:
|
||||
// none judge the screenshot (default); exits 1 when a cluster fails.
|
||||
// rect first fill every judged cluster's box with white; exits 1 unless every cluster fails.
|
||||
// swap judge each cluster against a different cluster of the corpus; exits 1 unless every
|
||||
// cluster fails.
|
||||
// The two controls prove the oracle refuses ink that is present but wrong.
|
||||
|
||||
/** Coverage grid side used to compare shapes. */
|
||||
const GRID = 16;
|
||||
/** A pixel at or above this ink strength (0..1) is part of a glyph's bounding box. */
|
||||
const INK = 0.25;
|
||||
/** Lowest shape correlation a cluster may have with its own reference. */
|
||||
const THRESHOLD = 0.6;
|
||||
/** How much better another cluster may match before the cluster's own match stops counting. */
|
||||
const MARGIN = 0.02;
|
||||
/** Lines whose cells carry decorations or a custom-drawn ruler; they stay geometry-only. */
|
||||
const SKIPPED_LINES = new Set(["ruler", "styles"]);
|
||||
const ZWJ = "";
|
||||
|
||||
/**
|
||||
* The renderer draws box and block characters to fill the cell, so they are judged against their
|
||||
* Unicode definition. Shades come from the font, whose fallback differs between the device and
|
||||
* pango, so a shade is judged by how evenly and densely it inks its own bounding box. Fonts dither
|
||||
* differently: ░ ▒ ▓ measure 0.16, 0.31, 0.75 in Droid Sans Mono and 0.19, 0.50, 0.81 in Menlo. The
|
||||
* bands split between those, and do not overlap, so one shade never passes as another.
|
||||
*/
|
||||
const CELL_GLYPHS = {
|
||||
"─": { arms: "LR" },
|
||||
"│": { arms: "UD" },
|
||||
"┬": { arms: "LRD" },
|
||||
"┴": { arms: "LRU" },
|
||||
"╭": { arms: "DR" },
|
||||
"╮": { arms: "DL" },
|
||||
"╰": { arms: "UR" },
|
||||
"╯": { arms: "UL" },
|
||||
"▀": { fill: "top" },
|
||||
"▄": { fill: "bottom" },
|
||||
"▌": { fill: "left" },
|
||||
"▐": { fill: "right" },
|
||||
"░": { shade: [0.08, 0.25] },
|
||||
"▒": { shade: [0.25, 0.625] },
|
||||
"▓": { shade: [0.625, 0.9] },
|
||||
};
|
||||
/** A shade must ink each region of this grid over its bounding box within its band. */
|
||||
const SHADE_GRID = 4;
|
||||
/** Regions of the cell, as fractions [left, top, right, bottom], that a line to each edge crosses. */
|
||||
const ARM_REGIONS = {
|
||||
U: [0.3, 0, 0.7, 0.2],
|
||||
D: [0.3, 0.8, 0.7, 1],
|
||||
L: [0, 0.3, 0.2, 0.7],
|
||||
R: [0.8, 0.3, 1, 0.7],
|
||||
};
|
||||
/** [filled, empty] halves of each half block. */
|
||||
const HALVES = {
|
||||
top: [
|
||||
[0, 0, 1, 0.5],
|
||||
[0, 0.5, 1, 1],
|
||||
],
|
||||
bottom: [
|
||||
[0, 0.5, 1, 1],
|
||||
[0, 0, 1, 0.5],
|
||||
],
|
||||
left: [
|
||||
[0, 0, 0.5, 1],
|
||||
[0.5, 0, 1, 1],
|
||||
],
|
||||
right: [
|
||||
[0.5, 0, 1, 1],
|
||||
[0, 0, 0.5, 1],
|
||||
],
|
||||
};
|
||||
|
||||
const [dirArg, control = "none"] = process.argv.slice(2);
|
||||
if (!dirArg || !["none", "rect", "swap"].includes(control)) {
|
||||
throw new Error("Usage: identity.mjs <run dir> [none|rect|swap]");
|
||||
}
|
||||
const dir = resolve(dirArg);
|
||||
const report = JSON.parse(readFileSync(resolve(dir, "report.json"), "utf8"));
|
||||
const png = PNG.sync.read(readFileSync(resolve(dir, "terminal.png")));
|
||||
const rows = new Map(report.rows.map((row) => [row.index, row.rect]));
|
||||
const { grid, slots } = await checkTerminalScreenshot({ png, size: report.size, rows });
|
||||
|
||||
const printed = slots.filter(
|
||||
(slot) => !SKIPPED_LINES.has(slot.line) && !slot.isCursor && slot.chars.trim() !== "",
|
||||
);
|
||||
/** The columns xterm gives each printed cluster. */
|
||||
const columnsOf = new Map(printed.map((slot) => [slot.chars, slot.width]));
|
||||
|
||||
if (control === "rect") {
|
||||
for (const { box } of printed) {
|
||||
for (let y = Math.ceil(box.top); y < Math.floor(box.bottom); y += 1) {
|
||||
for (let x = Math.ceil(box.left); x < Math.floor(box.right); x += 1) {
|
||||
png.data.set([255, 255, 255, 255], (png.width * y + x) * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ink strength 0..1 of each pixel in a box: the largest channel distance from the background. */
|
||||
function screenshotInk(box) {
|
||||
const left = Math.ceil(box.left);
|
||||
const top = Math.ceil(box.top);
|
||||
const width = Math.floor(box.right) - left;
|
||||
const height = Math.floor(box.bottom) - top;
|
||||
const ink = new Float32Array(width * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = (png.width * (top + y) + left + x) * 4;
|
||||
let distance = 0;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
distance = Math.max(
|
||||
distance,
|
||||
Math.abs(png.data[offset + channel] - grid.background[channel]),
|
||||
);
|
||||
}
|
||||
ink[y * width + x] = distance / 255;
|
||||
}
|
||||
}
|
||||
return { ink, width, height };
|
||||
}
|
||||
|
||||
/** Ink of the slot's whole cells, scaled so the foreground color is 1. */
|
||||
function cellInk(slot) {
|
||||
const left = Math.round(grid.left + slot.col * grid.cellWidth);
|
||||
const right = Math.round(grid.left + (slot.col + slot.width) * grid.cellWidth);
|
||||
const cells = screenshotInk({ left, right, top: slot.box.top, bottom: slot.box.bottom });
|
||||
const full =
|
||||
Math.max(...[0, 1, 2].map((c) => Math.abs(grid.foreground[c] - grid.background[c]))) / 255;
|
||||
for (let i = 0; i < cells.ink.length; i += 1) cells.ink[i] = Math.min(1, cells.ink[i] / full);
|
||||
return cells;
|
||||
}
|
||||
|
||||
const scratch = mkdtempSync(resolve(tmpdir(), "terminal-identity-"));
|
||||
/** pango-view sizes its image to the text's advance plus this margin on each side. */
|
||||
const REFERENCE_MARGIN = 48;
|
||||
function pangoInk(text) {
|
||||
const out = resolve(scratch, "reference.png");
|
||||
execFileSync("pango-view", [
|
||||
"--no-display",
|
||||
"--dpi=72",
|
||||
"--font=monospace 96px",
|
||||
`--text=${text}`,
|
||||
"--background=black",
|
||||
"--foreground=white",
|
||||
`--margin=${REFERENCE_MARGIN}`,
|
||||
"-o",
|
||||
out,
|
||||
]);
|
||||
const image = PNG.sync.read(readFileSync(out));
|
||||
const ink = new Float32Array(image.width * image.height);
|
||||
for (let i = 0; i < ink.length; i += 1) {
|
||||
const o = i * 4;
|
||||
ink[i] = Math.max(image.data[o], image.data[o + 1], image.data[o + 2]) / 255;
|
||||
}
|
||||
return {
|
||||
ink,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
advance: image.width - 2 * REFERENCE_MARGIN,
|
||||
};
|
||||
}
|
||||
|
||||
/** The terminal font's cell advance at the reference size. */
|
||||
const referenceCell = pangoInk("M").advance;
|
||||
|
||||
/**
|
||||
* The text as the renderer places it in `columns` cells: centred, scaled down when its advance is
|
||||
* wider, and cut at the cells' edges. Glyphs whose ink overhangs their advance, such as Devanagari
|
||||
* headlines, are cut on the device too.
|
||||
*/
|
||||
function referenceInk(text, columns) {
|
||||
const image = pangoInk(text);
|
||||
const box = Math.max(image.advance, columns * referenceCell);
|
||||
const left = Math.round(REFERENCE_MARGIN + (image.advance - box) / 2);
|
||||
const right = Math.round(REFERENCE_MARGIN + (image.advance + box) / 2);
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
for (let x = 0; x < image.width; x += 1) {
|
||||
if (x < left || x >= right) image.ink[y * image.width + x] = 0;
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
function regionInk({ ink, width, height }, [x0, y0, x1, y1]) {
|
||||
let [sum, max, count] = [0, 0, 0];
|
||||
for (let y = Math.floor(y0 * height); y < Math.ceil(y1 * height); y += 1) {
|
||||
for (let x = Math.floor(x0 * width); x < Math.ceil(x1 * width); x += 1) {
|
||||
const value = ink[y * width + x] ?? 0;
|
||||
sum += value;
|
||||
max = Math.max(max, value);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return { mean: sum / count, max };
|
||||
}
|
||||
|
||||
function inkBox({ ink, width, height }) {
|
||||
let [minX, minY, maxX, maxY] = [width, height, -1, -1];
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (ink[y * width + x] < INK) continue;
|
||||
minX = Math.min(minX, x);
|
||||
maxX = Math.max(maxX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
return maxX < 0 ? null : { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
/** Whether an unbroken line crosses the region to the cell edge: a row for L/R, a column for U/D. */
|
||||
function lineCrosses({ ink, width, height }, arm) {
|
||||
const [x0, y0, x1, y1] = ARM_REGIONS[arm];
|
||||
const [left, top, right, bottom] = [
|
||||
Math.floor(x0 * width),
|
||||
Math.floor(y0 * height),
|
||||
Math.ceil(x1 * width),
|
||||
Math.ceil(y1 * height),
|
||||
];
|
||||
const horizontal = arm === "L" || arm === "R";
|
||||
const [outerFrom, outerTo, innerFrom, innerTo] = horizontal
|
||||
? [top, bottom, left, right]
|
||||
: [left, right, top, bottom];
|
||||
for (let outer = outerFrom; outer < outerTo; outer += 1) {
|
||||
let unbroken = true;
|
||||
for (let inner = innerFrom; inner < innerTo && unbroken; inner += 1) {
|
||||
const [x, y] = horizontal ? [inner, outer] : [outer, inner];
|
||||
unbroken = (ink[y * width + x] ?? 0) >= 0.5;
|
||||
}
|
||||
if (unbroken) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 1 when the cell matches the character's definition, 0 otherwise. */
|
||||
function cellGlyphScore(slot, spec) {
|
||||
const ink = cellInk(slot);
|
||||
if (spec.arms) {
|
||||
const matches = Object.keys(ARM_REGIONS).every((arm) =>
|
||||
spec.arms.includes(arm) ? lineCrosses(ink, arm) : regionInk(ink, ARM_REGIONS[arm]).max < 0.3,
|
||||
);
|
||||
return matches ? 1 : 0;
|
||||
}
|
||||
if (spec.fill) {
|
||||
const [filled, empty] = HALVES[spec.fill].map((region) => regionInk(ink, region).mean);
|
||||
return filled >= 0.8 && empty <= 0.2 ? 1 : 0;
|
||||
}
|
||||
const box = inkBox(ink);
|
||||
if (!box || box.width < 0.7 * ink.width || box.height < 0.5 * ink.height) return 0;
|
||||
const [x0, y0] = [box.x / ink.width, box.y / ink.height];
|
||||
const [x1, y1] = [(box.x + box.width) / ink.width, (box.y + box.height) / ink.height];
|
||||
const [w, h] = [(x1 - x0) / SHADE_GRID, (y1 - y0) / SHADE_GRID];
|
||||
const regions = Array.from({ length: SHADE_GRID * SHADE_GRID }, (_, index) => {
|
||||
const [gx, gy] = [index % SHADE_GRID, Math.floor(index / SHADE_GRID)];
|
||||
return [x0 + gx * w, y0 + gy * h, x0 + (gx + 1) * w, y0 + (gy + 1) * h];
|
||||
});
|
||||
const means = regions.map((region) => regionInk(ink, region).mean);
|
||||
return means.every((mean) => mean >= spec.shade[0] && mean < spec.shade[1]) ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Coverage of the ink's bounding box, centered in a square that keeps its aspect, at GRID². */
|
||||
function coverage(image) {
|
||||
const box = inkBox(image);
|
||||
if (!box) return null;
|
||||
const side = Math.max(box.width, box.height);
|
||||
const originX = box.x - (side - box.width) / 2;
|
||||
const originY = box.y - (side - box.height) / 2;
|
||||
const vector = new Float32Array(GRID * GRID);
|
||||
const step = side / GRID;
|
||||
const samples = 4;
|
||||
for (let gy = 0; gy < GRID; gy += 1) {
|
||||
for (let gx = 0; gx < GRID; gx += 1) {
|
||||
let sum = 0;
|
||||
for (let sample = 0; sample < samples * samples; sample += 1) {
|
||||
const x = Math.floor(originX + (gx + ((sample % samples) + 0.5) / samples) * step);
|
||||
const y = Math.floor(
|
||||
originY + (gy + (Math.floor(sample / samples) + 0.5) / samples) * step,
|
||||
);
|
||||
sum +=
|
||||
x >= 0 && y >= 0 && x < image.width && y < image.height
|
||||
? image.ink[y * image.width + x]
|
||||
: 0;
|
||||
}
|
||||
vector[gy * GRID + gx] = sum / (samples * samples);
|
||||
}
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
|
||||
/** Pearson correlation of two coverage vectors; -1 when either has no ink or no contrast. */
|
||||
function correlation(a, b) {
|
||||
if (!a || !b) return -1;
|
||||
const n = a.length;
|
||||
let [ma, mb] = [0, 0];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
ma += a[i];
|
||||
mb += b[i];
|
||||
}
|
||||
ma /= n;
|
||||
mb /= n;
|
||||
let [num, da, db] = [0, 0, 0];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
num += (a[i] - ma) * (b[i] - mb);
|
||||
da += (a[i] - ma) ** 2;
|
||||
db += (b[i] - mb) ** 2;
|
||||
}
|
||||
return da === 0 || db === 0 ? -1 : num / Math.sqrt(da * db);
|
||||
}
|
||||
|
||||
const references = new Map();
|
||||
/**
|
||||
* The cluster alone and with a zero-width joiner before, after and on both sides, so any Arabic
|
||||
* positional form of the right letter matches.
|
||||
*/
|
||||
function referenceVectors(chars) {
|
||||
if (!references.has(chars)) {
|
||||
const forms = [chars, ZWJ + chars, chars + ZWJ, ZWJ + chars + ZWJ];
|
||||
references.set(
|
||||
chars,
|
||||
forms.map((text) => coverage(referenceInk(text, columnsOf.get(chars)))),
|
||||
);
|
||||
}
|
||||
return references.get(chars);
|
||||
}
|
||||
|
||||
function shapeScore(vector, chars) {
|
||||
return Math.max(...referenceVectors(chars).map((reference) => correlation(vector, reference)));
|
||||
}
|
||||
|
||||
/**
|
||||
* pango draws no ink for Apple Color Emoji, and draws flags on neither device. Those clusters have
|
||||
* no reference and are reported instead of judged.
|
||||
*/
|
||||
const hasReference = (chars) =>
|
||||
CELL_GLYPHS[chars] ||
|
||||
(!/^\p{Regional_Indicator}{2}$/u.test(chars) && referenceVectors(chars).some(Boolean));
|
||||
const judged = printed.filter((slot) => hasReference(slot.chars));
|
||||
const unjudged = printed
|
||||
.filter((slot) => !hasReference(slot.chars))
|
||||
.map((slot) => ({
|
||||
line: slot.line,
|
||||
col: slot.col,
|
||||
chars: slot.chars,
|
||||
reason: "no reference ink",
|
||||
}));
|
||||
|
||||
const distinct = [...new Set(judged.map((slot) => slot.chars))];
|
||||
const shapes = distinct.filter((chars) => !CELL_GLYPHS[chars]);
|
||||
|
||||
/** The cluster a slot is judged against: its own, or under `swap` a different one. */
|
||||
function expectedChars(slot, index) {
|
||||
if (control !== "swap") return slot.chars;
|
||||
const own = distinct.indexOf(slot.chars);
|
||||
const other = distinct[(own + 1 + (index % (distinct.length - 1))) % distinct.length];
|
||||
return other === slot.chars ? distinct[(own + 1) % distinct.length] : other;
|
||||
}
|
||||
|
||||
function judge(slot, index) {
|
||||
const expected = expectedChars(slot, index);
|
||||
const result = { line: slot.line, row: slot.row, col: slot.col, chars: slot.chars, expected };
|
||||
const spec = CELL_GLYPHS[expected];
|
||||
if (spec) {
|
||||
result.score = cellGlyphScore(slot, spec);
|
||||
return result;
|
||||
}
|
||||
// The shape must also resemble its own cluster at least as much as any other cluster in the
|
||||
// corpus, within MARGIN, so a near-lookalike such as g judged as 8 fails.
|
||||
const vector = coverage(screenshotInk(slot.box));
|
||||
const score = shapeScore(vector, expected);
|
||||
let nearest = { chars: expected, score };
|
||||
for (const other of shapes) {
|
||||
if (other === expected) continue;
|
||||
const otherScore = shapeScore(vector, other);
|
||||
if (otherScore > nearest.score) nearest = { chars: other, score: otherScore };
|
||||
}
|
||||
const distinctive = nearest.chars === expected || nearest.score - score <= MARGIN;
|
||||
result.score = distinctive ? score : Math.min(score, 0);
|
||||
result.nearest = nearest;
|
||||
return result;
|
||||
}
|
||||
|
||||
const results = judged.map(judge);
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
|
||||
const passed = results.filter((result) => result.score >= THRESHOLD);
|
||||
const failed = results.filter((result) => result.score < THRESHOLD);
|
||||
const outDir = resolve(dir, "identity");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(outDir, `${control}.json`),
|
||||
`${JSON.stringify({ control, threshold: THRESHOLD, unjudged, results }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const format = (result) =>
|
||||
`${result.line}@${result.col}(${result.chars}${result.expected === result.chars ? "" : `→${result.expected}`})=${result.score.toFixed(2)}${result.nearest && result.nearest.chars !== result.expected ? ` nearest ${result.nearest.chars}` : ""}`;
|
||||
console.log(
|
||||
`${report.label} ${control}: ${results.length} clusters judged, ${passed.length} pass, ${failed.length} fail, ${unjudged.length} unjudged (${outDir})`,
|
||||
);
|
||||
if (control === "none") {
|
||||
for (const result of failed) console.log(` fail ${format(result)}`);
|
||||
process.exitCode = failed.length === 0 ? 0 : 1;
|
||||
} else {
|
||||
for (const result of passed) console.log(` accepted ${format(result)}`);
|
||||
process.exitCode = passed.length === 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { PNG } from "pngjs";
|
||||
import { countInk, measureGrid } from "./check.mjs";
|
||||
import { RULER_CELLS } from "./fixture.mjs";
|
||||
|
||||
// Checks ink that leaves its row: stacked marks above and below a row reach into the rows next to
|
||||
// it. The script prints such rows, then in one update writes into two rows that hold another row's
|
||||
// overhang and erases two rows whose overhang reaches a blank row. The overhang must be drawn
|
||||
// before the update, stay drawn in the written rows, and leave no ink behind the erased rows.
|
||||
//
|
||||
// Preconditions are the same as `render.mjs`: the app runs the build being verified against a
|
||||
// daemon on the same host as this script, and shows an idle, focused terminal with an empty prompt.
|
||||
//
|
||||
// PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/overhang.mjs
|
||||
// PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/overhang.mjs
|
||||
const platform = process.env.PLATFORM ?? "android";
|
||||
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
|
||||
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "overhang";
|
||||
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
|
||||
const artifacts = resolve(
|
||||
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
|
||||
label,
|
||||
);
|
||||
const fixtureDir = process.env.PASEO_TERMINAL_RENDERING_FIXTURE_DIR ?? tmpdir();
|
||||
const screenshotArgs =
|
||||
platform === "ios"
|
||||
? ["--pixel-density", process.env.PASEO_TERMINAL_RENDERING_PIXEL_DENSITY ?? "3"]
|
||||
: [];
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_DEVICE_STATE_DIR:
|
||||
process.env.AGENT_DEVICE_STATE_DIR ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
|
||||
};
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
/** Marks stacked below the row: Arabic dots, rings below `g`, a vowel sign below a consonant. */
|
||||
const BELOW = "پپپ ييي ججج g̥̥̥̥ कृ";
|
||||
/** Marks stacked above the row: accents over `e` and `s`, candrabindu, om. */
|
||||
const ABOVE = "é́́́ ŝ̂̂ कँ ॐ";
|
||||
/** The marks rows stay left of this column; the update writes at it, outside the checked boxes. */
|
||||
const CHECKED_COLUMNS = 26;
|
||||
const WRITE_COLUMN = 30;
|
||||
/** 0-based rows. Each source row's overhang reaches the row next to it. */
|
||||
const SOURCES = [
|
||||
{ row: 2, text: BELOW, overhangRow: 3, update: "write" },
|
||||
{ row: 6, text: ABOVE, overhangRow: 5, update: "erase" },
|
||||
{ row: 9, text: BELOW, overhangRow: 10, update: "erase" },
|
||||
{ row: 13, text: ABOVE, overhangRow: 12, update: "write" },
|
||||
];
|
||||
const PARK_ROW = 16;
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function deviceArgs() {
|
||||
if (platform === "android") {
|
||||
return [
|
||||
"--platform",
|
||||
"android",
|
||||
...(process.env.ANDROID_SERIAL ? ["--serial", process.env.ANDROID_SERIAL] : []),
|
||||
];
|
||||
}
|
||||
return [
|
||||
"--platform",
|
||||
"ios",
|
||||
...(process.env.IOS_DEVICE ? ["--device", process.env.IOS_DEVICE] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function snapshotNodes() {
|
||||
return JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
}
|
||||
|
||||
function pressControl(id) {
|
||||
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
|
||||
(candidate) => candidate.identifier === id,
|
||||
);
|
||||
assert.ok(node?.rect, `No ${id} control on screen`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
|
||||
}
|
||||
|
||||
async function pressEnter() {
|
||||
pressControl("terminal-key-enter");
|
||||
await setTimeout(2_000);
|
||||
}
|
||||
|
||||
async function runCommand(command) {
|
||||
ad("type", command, "--delay-ms", "0");
|
||||
await pressEnter();
|
||||
}
|
||||
|
||||
async function terminalSize() {
|
||||
await runCommand("clear; stty size");
|
||||
for (const node of snapshotNodes()) {
|
||||
const match = /^(\d+) (\d+)$/.exec((node.label ?? "").trim());
|
||||
if (match && (node.identifier ?? "").startsWith("terminal-row-")) {
|
||||
return { rows: Number(match[1]), columns: Number(match[2]) };
|
||||
}
|
||||
}
|
||||
throw new Error("No terminal row showed the `stty size` output");
|
||||
}
|
||||
|
||||
function shellQuote(text) {
|
||||
return `'${text.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
/** Prints the source rows, waits for Enter, applies the update, and waits for Enter again. */
|
||||
function fixtureScript() {
|
||||
const at = (row, col) => `\\033[${row + 1};${col + 1}H`;
|
||||
const print = (row, text) => `printf '${at(row, 0)}%s' ${shellQuote(text)}`;
|
||||
const update = SOURCES.map((source) =>
|
||||
source.update === "write"
|
||||
? `${at(source.overhangRow, WRITE_COLUMN)}x`
|
||||
: `${at(source.row, 0)}\\033[2K`,
|
||||
).join("");
|
||||
return [
|
||||
"stty -echo",
|
||||
"printf '\\033[2J\\033[3J'",
|
||||
print(0, "█".repeat(RULER_CELLS)),
|
||||
...SOURCES.map((source) => print(source.row, source.text)),
|
||||
`printf '${at(PARK_ROW, 0)}'`,
|
||||
"read -r _",
|
||||
`printf '${update}${at(PARK_ROW, 0)}'`,
|
||||
"read -r _",
|
||||
"stty echo",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function screenshot(name) {
|
||||
const path = resolve(artifacts, name);
|
||||
ad("screenshot", path, ...screenshotArgs);
|
||||
return PNG.sync.read(readFileSync(path));
|
||||
}
|
||||
|
||||
/** Pixels in `box` where the two screenshots differ by more than the ink threshold. */
|
||||
function changedPixels(before, after, box) {
|
||||
const difference = new PNG({ width: before.width, height: before.height });
|
||||
for (let index = 0; index < before.data.length; index += 1) {
|
||||
difference.data[index] = Math.abs(before.data[index] - after.data[index]);
|
||||
}
|
||||
return countInk(difference, box, [0, 0, 0]);
|
||||
}
|
||||
|
||||
ad("open", appId, ...deviceArgs());
|
||||
const controls = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes;
|
||||
const hasControl = (id) => controls.some((node) => node.identifier === id);
|
||||
assert.ok(hasControl("terminal-keyboard-toggle"), "Open a terminal first");
|
||||
if (!hasControl("terminal-key-enter")) pressControl("terminal-keyboard-toggle");
|
||||
|
||||
const size = await terminalSize();
|
||||
assert.ok(
|
||||
size.rows > PARK_ROW && size.columns > WRITE_COLUMN,
|
||||
`Terminal too small: ${JSON.stringify(size)}`,
|
||||
);
|
||||
const scriptPath = resolve(fixtureDir, "paseo-terminal-overhang.sh");
|
||||
writeFileSync(scriptPath, fixtureScript());
|
||||
await runCommand(`sh ${scriptPath}`);
|
||||
|
||||
const nodes = snapshotNodes();
|
||||
const before = screenshot("before.png");
|
||||
await pressEnter();
|
||||
const after = screenshot("after.png");
|
||||
await pressEnter();
|
||||
|
||||
// Snapshot rects are in points on iOS and pixels on Android; the checks work in screenshot pixels.
|
||||
const scale = before.width / Math.max(...nodes.map((node) => node.rect?.width ?? 0));
|
||||
const ruler = nodes.find((node) => node.identifier === "terminal-row-0")?.rect;
|
||||
assert.ok(ruler, "The ruler row is not exposed as terminal-row-0");
|
||||
const rulerRect = {
|
||||
x: ruler.x * scale,
|
||||
y: ruler.y * scale,
|
||||
width: ruler.width * scale,
|
||||
height: ruler.height * scale,
|
||||
};
|
||||
const grid = measureGrid(before, rulerRect);
|
||||
const rowBox = (row) => ({
|
||||
left: grid.left,
|
||||
right: grid.left + CHECKED_COLUMNS * grid.cellWidth,
|
||||
top: rulerRect.y + row * rulerRect.height,
|
||||
bottom: rulerRect.y + (row + 1) * rulerRect.height,
|
||||
});
|
||||
|
||||
/** Measures one source row's overhang before and after the update. */
|
||||
function measure(source) {
|
||||
const box = rowBox(source.overhangRow);
|
||||
const overhangBefore = countInk(before, box, grid.background);
|
||||
if (source.update === "write") {
|
||||
const changedAfterWrite = changedPixels(before, after, box);
|
||||
const failures = [
|
||||
...(overhangBefore === 0 ? ["overhang-not-drawn"] : []),
|
||||
...(changedAfterWrite > 0 ? ["overhang-lost-after-write"] : []),
|
||||
];
|
||||
return { ...source, overhangBefore, changedAfterWrite, failures };
|
||||
}
|
||||
const inkAfterErase = countInk(after, box, grid.background);
|
||||
const sourceInkAfterErase = countInk(after, rowBox(source.row), grid.background);
|
||||
const failures = [
|
||||
...(overhangBefore === 0 ? ["overhang-not-drawn"] : []),
|
||||
...(inkAfterErase > 0 ? ["overhang-left-after-erase"] : []),
|
||||
...(sourceInkAfterErase > 0 ? ["source-left-after-erase"] : []),
|
||||
];
|
||||
return { ...source, overhangBefore, inkAfterErase, sourceInkAfterErase, failures };
|
||||
}
|
||||
|
||||
const results = SOURCES.map(measure);
|
||||
|
||||
const report = { platform, label, size, grid, rulerRect, results };
|
||||
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
const failures = results.flatMap((result) =>
|
||||
result.failures.map((failure) => `row ${result.overhangRow}: ${failure}`),
|
||||
);
|
||||
console.log(`${label}: ${failures.length} failures (${artifacts})`);
|
||||
for (const result of results) {
|
||||
console.log(
|
||||
` row ${result.row} -> ${result.overhangRow} (${result.update}): overhang ${result.overhangBefore}` +
|
||||
(result.update === "write"
|
||||
? `, changed after write ${result.changedAfterWrite}`
|
||||
: `, left after erase ${result.inkAfterErase}, source left ${result.sourceInkAfterErase}`),
|
||||
);
|
||||
}
|
||||
for (const failure of failures) console.log(` ${failure}`);
|
||||
process.exitCode = failures.length === 0 ? 0 : 1;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { homedir } from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
// Reattaches to a terminal whose PTY has a different size than the app will measure, and checks
|
||||
// that the client rows equal the daemon's rows. RESIZE=keyboard opens the keyboard, and the
|
||||
// relaunched app starts with it closed. RESIZE=fold folds an Android foldable emulator and
|
||||
// relaunches the app unfolded. Either way the shell redraws its prompt on attach.
|
||||
//
|
||||
// Preconditions: the app shows terminal TERMINAL_ID, idle at an empty shell prompt, against the
|
||||
// daemon at PASEO_HOST.
|
||||
//
|
||||
// PLATFORM=android TERMINAL_ID=511fd9f0 PASEO_HOST=localhost:6870 \
|
||||
// node e2e/mobile/terminal-rendering/reattach.mjs
|
||||
//
|
||||
// The client used to be sized after the replay arrived, so the shell's redraw landed on the
|
||||
// wrong rows in most trials. TRIALS sets how many reattaches to check.
|
||||
const platform = process.env.PLATFORM ?? "android";
|
||||
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
|
||||
const appUrl = process.env.PASEO_TERMINAL_RENDERING_APP_URL;
|
||||
const terminalId = process.env.TERMINAL_ID;
|
||||
const host = process.env.PASEO_HOST;
|
||||
const trials = Number(process.env.TRIALS ?? 5);
|
||||
const resize = process.env.RESIZE ?? "keyboard";
|
||||
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "reattach";
|
||||
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
|
||||
const artifacts = resolve(
|
||||
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
|
||||
label,
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_DEVICE_STATE_DIR:
|
||||
process.env.AGENT_DEVICE_STATE_DIR ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
|
||||
};
|
||||
if (!terminalId || !host) throw new Error("Set TERMINAL_ID and PASEO_HOST");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
function paseo(...args) {
|
||||
return execFileSync("npm", ["run", "-s", "cli", "--", ...args, "--host", host], {
|
||||
cwd: resolve(import.meta.dirname, "../../../../.."),
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
function pressControl(id) {
|
||||
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
|
||||
(candidate) => candidate.identifier === id,
|
||||
);
|
||||
if (!node?.rect) throw new Error(`No ${id} control on screen`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
|
||||
}
|
||||
|
||||
async function resizePty() {
|
||||
if (resize === "keyboard") {
|
||||
pressControl("terminal-keyboard-toggle");
|
||||
await setTimeout(2_000);
|
||||
return;
|
||||
}
|
||||
const adb = (...args) =>
|
||||
execFileSync("adb", [
|
||||
...(process.env.ANDROID_SERIAL ? ["-s", process.env.ANDROID_SERIAL] : []),
|
||||
...args,
|
||||
]);
|
||||
adb("emu", "fold");
|
||||
await setTimeout(5_000);
|
||||
adb("shell", "am", "force-stop", appId);
|
||||
adb("emu", "unfold");
|
||||
await setTimeout(3_000);
|
||||
}
|
||||
|
||||
function clientRows() {
|
||||
const nodes = JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
const byIndex = new Map(nodes.map((node) => [node.index, node]));
|
||||
const rows = [];
|
||||
for (const node of nodes) {
|
||||
const match = /^terminal-row-(\d+)$/.exec(node.identifier ?? "");
|
||||
if (!match) continue;
|
||||
// agent-device leaves the label on the parent when the row inherits it.
|
||||
const text = node.label ?? (node.inheritsLabel ? byIndex.get(node.parentIndex)?.label : "");
|
||||
rows[Number(match[1])] = (text ?? "").trim();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the relaunched app to show terminal rows, then for the shell's redraw to land.
|
||||
* Returns null when no row appears, for example when the app crashed on launch.
|
||||
*/
|
||||
async function attachedClientRows() {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await setTimeout(2_000);
|
||||
if (clientRows().some((text) => text !== "")) {
|
||||
await setTimeout(3_000);
|
||||
return clientRows();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mismatches(client, server) {
|
||||
const rows = [];
|
||||
for (let row = 0; row < Math.max(client.length, server.length); row += 1) {
|
||||
const expected = (server[row] ?? "").trim();
|
||||
const actual = client[row] ?? "";
|
||||
if (expected !== actual) rows.push({ row, server: expected, client: actual });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let trial = 1; trial <= trials; trial += 1) {
|
||||
paseo("terminal", "send-keys", terminalId, "clear", "Enter");
|
||||
await setTimeout(1_500);
|
||||
await resizePty();
|
||||
ad("open", appId, ...(appUrl ? [appUrl] : []), "--relaunch");
|
||||
const client = await attachedClientRows();
|
||||
ad("screenshot", resolve(artifacts, `trial-${trial}.png`));
|
||||
if (!client) {
|
||||
results.push({ trial, attached: false });
|
||||
console.log(`${label} trial ${trial}: no terminal rows appeared`);
|
||||
continue;
|
||||
}
|
||||
const server = paseo("terminal", "capture", terminalId).split("\n");
|
||||
const rows = mismatches(client, server);
|
||||
results.push({ trial, attached: true, mismatchedRows: rows });
|
||||
console.log(`${label} trial ${trial}: ${rows.length} mismatched rows`);
|
||||
}
|
||||
|
||||
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(results, null, 2)}\n`);
|
||||
const attached = results.filter((result) => result.attached);
|
||||
const failed = attached.filter((result) => result.mismatchedRows.length > 0).length;
|
||||
console.log(
|
||||
`${label}: ${failed} of ${attached.length} reattaches differ from the daemon, ${trials - attached.length} never attached (${artifacts})`,
|
||||
);
|
||||
process.exitCode = failed === 0 && attached.length === trials ? 0 : 1;
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { PNG } from "pngjs";
|
||||
import {
|
||||
checkPartialSelection,
|
||||
checkTerminalScreenshot,
|
||||
selectionTarget,
|
||||
writeOverlay,
|
||||
} from "./check.mjs";
|
||||
import { fixtureOutput } from "./fixture.mjs";
|
||||
|
||||
// Renders the fixture in a real Paseo terminal and checks the screenshot with `check.mjs`, then
|
||||
// selects one cell of a conjunct and checks a second screenshot.
|
||||
//
|
||||
// Preconditions: the app runs the build being verified against a daemon on the same host as
|
||||
// this script, and shows an idle, focused terminal with an empty shell prompt.
|
||||
//
|
||||
// PLATFORM=android ANDROID_SERIAL=emulator-5554 node e2e/mobile/terminal-rendering/render.mjs
|
||||
// PLATFORM=ios IOS_DEVICE="iPhone 17" node e2e/mobile/terminal-rendering/render.mjs
|
||||
const platform = process.env.PLATFORM ?? "android";
|
||||
const appId = process.env.PASEO_TERMINAL_RENDERING_APP_ID ?? "sh.paseo.debug";
|
||||
const label = process.env.PASEO_TERMINAL_RENDERING_LABEL ?? "run";
|
||||
const session = process.env.AGENT_DEVICE_SESSION ?? `terminal-rendering-${platform}`;
|
||||
const artifacts = resolve(
|
||||
process.env.PASEO_TERMINAL_RENDERING_ARTIFACTS ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering", platform),
|
||||
label,
|
||||
);
|
||||
const fixtureDir = process.env.PASEO_TERMINAL_RENDERING_FIXTURE_DIR ?? tmpdir();
|
||||
// iOS simulators screenshot at one pixel per point unless asked; glyph identity needs the
|
||||
// device's real density. Android screenshots are always in device pixels.
|
||||
const screenshotArgs =
|
||||
platform === "ios"
|
||||
? ["--pixel-density", process.env.PASEO_TERMINAL_RENDERING_PIXEL_DENSITY ?? "3"]
|
||||
: [];
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_DEVICE_STATE_DIR:
|
||||
process.env.AGENT_DEVICE_STATE_DIR ??
|
||||
resolve(homedir(), ".paseo/artifacts/terminal-rendering/device-state"),
|
||||
};
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function deviceArgs() {
|
||||
if (platform === "android") {
|
||||
return [
|
||||
"--platform",
|
||||
"android",
|
||||
...(process.env.ANDROID_SERIAL ? ["--serial", process.env.ANDROID_SERIAL] : []),
|
||||
];
|
||||
}
|
||||
return [
|
||||
"--platform",
|
||||
"ios",
|
||||
...(process.env.IOS_DEVICE ? ["--device", process.env.IOS_DEVICE] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function snapshotNodes() {
|
||||
return JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
}
|
||||
|
||||
function rowNodes(nodes) {
|
||||
const rows = new Map();
|
||||
for (const node of nodes) {
|
||||
const match = /^terminal-row-(\d+)$/.exec(node.identifier ?? "");
|
||||
if (match && node.rect) rows.set(Number(match[1]), node);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Taps a control at its center. On iOS the keyboard toolbar is a full-screen accessibility node
|
||||
* listed after the key bar, so pressing by selector reports the keys as covered.
|
||||
*/
|
||||
function pressControl(id) {
|
||||
const node = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes.find(
|
||||
(candidate) => candidate.identifier === id,
|
||||
);
|
||||
assert.ok(node?.rect, `No ${id} control on screen`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
ad("press", String(Math.round(x + width / 2)), String(Math.round(y + height / 2)));
|
||||
}
|
||||
|
||||
async function runCommand(command) {
|
||||
ad("type", command, "--delay-ms", "0");
|
||||
pressControl("terminal-key-enter");
|
||||
await setTimeout(2_000);
|
||||
}
|
||||
|
||||
async function terminalSize() {
|
||||
await runCommand("clear; stty size");
|
||||
for (const node of snapshotNodes()) {
|
||||
const match = /^(\d+) (\d+)$/.exec((node.label ?? "").trim());
|
||||
if (match && (node.identifier ?? "").startsWith("terminal-row-")) {
|
||||
return { rows: Number(match[1]), columns: Number(match[2]) };
|
||||
}
|
||||
}
|
||||
throw new Error("No terminal row showed the `stty size` output");
|
||||
}
|
||||
|
||||
ad("open", appId, ...deviceArgs());
|
||||
const controls = JSON.parse(ad("snapshot", "-i", "--json")).data.nodes;
|
||||
const hasControl = (id) => controls.some((node) => node.identifier === id);
|
||||
assert.ok(hasControl("terminal-keyboard-toggle"), "Open a terminal first");
|
||||
if (!hasControl("terminal-key-enter")) pressControl("terminal-keyboard-toggle");
|
||||
|
||||
const size = await terminalSize();
|
||||
const fixturePath = resolve(fixtureDir, "paseo-terminal-rendering.txt");
|
||||
const scriptPath = resolve(fixtureDir, "paseo-terminal-rendering.sh");
|
||||
writeFileSync(fixturePath, fixtureOutput(size.columns));
|
||||
writeFileSync(scriptPath, `printf '\\033[2J\\033[3J\\033[H'; cat '${fixturePath}'; sleep 120\n`);
|
||||
await runCommand(`sh ${scriptPath}`);
|
||||
|
||||
const nodes = snapshotNodes();
|
||||
const screenshotPath = resolve(artifacts, "terminal.png");
|
||||
ad("screenshot", screenshotPath, ...screenshotArgs);
|
||||
const png = PNG.sync.read(readFileSync(screenshotPath));
|
||||
// Snapshot rects are in points on iOS and pixels on Android; the checks work in screenshot pixels.
|
||||
const scale = png.width / Math.max(...nodes.map((node) => node.rect?.width ?? 0));
|
||||
const toPixels = ({ x, y, width, height }) => ({
|
||||
x: x * scale,
|
||||
y: y * scale,
|
||||
width: width * scale,
|
||||
height: height * scale,
|
||||
});
|
||||
const rows = rowNodes(nodes);
|
||||
const rects = new Map([...rows].map(([index, node]) => [index, toPixels(node.rect)]));
|
||||
const { grid, failures, slots } = await checkTerminalScreenshot({ png, size, rows: rects });
|
||||
|
||||
// Long-press selects the conjunct as a word; dragging back from its second cell leaves the first.
|
||||
const target = await selectionTarget({ size });
|
||||
const targetRect = rects.get(target.row);
|
||||
assert.ok(targetRect, "The select line is not on screen");
|
||||
const y = Math.round((targetRect.y + targetRect.height / 2) / scale);
|
||||
const cellX = (offset) => Math.round((grid.left + (target.col + offset) * grid.cellWidth) / scale);
|
||||
ad("longpress", String(cellX(0.5)), String(y), "800");
|
||||
ad("gesture", "pan", String(cellX(1.75)), String(y), String(cellX(0.5) - cellX(1.75)), "0", "600");
|
||||
const selectionPath = resolve(artifacts, "selection.png");
|
||||
ad("screenshot", selectionPath, ...screenshotArgs);
|
||||
const selectionFailures = checkPartialSelection({
|
||||
png: PNG.sync.read(readFileSync(selectionPath)),
|
||||
rows: rects,
|
||||
grid,
|
||||
target,
|
||||
});
|
||||
for (const failure of selectionFailures) failures.push({ line: "select", ...failure });
|
||||
// A tap clears the selection; it would otherwise survive into the next run's screenshot.
|
||||
const lastRect = rects.get(size.rows - 1);
|
||||
if (lastRect) {
|
||||
ad("press", String(cellX(4)), String(Math.round((lastRect.y + lastRect.height / 2) / scale)));
|
||||
}
|
||||
try {
|
||||
pressControl("terminal-key-ctrl");
|
||||
ad("type", "c", "--delay-ms", "0");
|
||||
} catch (error) {
|
||||
console.warn(`Could not interrupt the fixture: ${error.message}`);
|
||||
}
|
||||
|
||||
writeOverlay(png, slots, resolve(artifacts, "overlay.png"));
|
||||
const report = {
|
||||
platform,
|
||||
label,
|
||||
size,
|
||||
grid,
|
||||
checkedClusters: slots.length,
|
||||
failures,
|
||||
rows: [...rows.keys()].map((index) => ({
|
||||
index,
|
||||
label: rows.get(index).label,
|
||||
rect: rects.get(index),
|
||||
})),
|
||||
};
|
||||
writeFileSync(resolve(artifacts, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
const byLine = Map.groupBy(failures, (failure) => failure.line);
|
||||
console.log(
|
||||
`${label}: ${slots.length} clusters checked, ${failures.length} failures (${artifacts})`,
|
||||
);
|
||||
for (const [line, list] of byLine) {
|
||||
console.log(
|
||||
` ${line}: ${list.map((failure) => `${failure.kind}@${failure.col ?? "-"}${failure.char ? `(${failure.char})` : ""}`).join(" ")}`,
|
||||
);
|
||||
}
|
||||
process.exitCode = failures.length === 0 ? 0 : 1;
|
||||
@@ -0,0 +1,54 @@
|
||||
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
|
||||
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
|
||||
// Leave the daemon running. This test observes the public reconnecting state.
|
||||
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
|
||||
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
|
||||
assert(
|
||||
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
|
||||
"Pass all five arguments",
|
||||
);
|
||||
const scale = Number(coordinateScale);
|
||||
assert(scale > 0, "Pass a positive coordinate scale");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
function capture(name) {
|
||||
const snapshot = ad("snapshot", "--raw", "--json");
|
||||
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
|
||||
ad("screenshot", path.join(artifacts, `${name}.png`));
|
||||
const nodes = JSON.parse(snapshot).data.nodes;
|
||||
const header = nodes.find((node) => node.identifier === "floating-header");
|
||||
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
|
||||
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
|
||||
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
|
||||
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
|
||||
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
|
||||
return toast.rect.y / scale;
|
||||
}
|
||||
|
||||
ad("wait", 'id="agent-reconnecting-toast"', "20000");
|
||||
const closedTop = capture("keyboard-closed");
|
||||
ad("press", `label=${JSON.stringify(composerLabel)}`);
|
||||
const opened = ad("snapshot", "--raw", "--json");
|
||||
const nodes = JSON.parse(opened).data.nodes;
|
||||
assert(
|
||||
nodes.some(
|
||||
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
|
||||
),
|
||||
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
|
||||
);
|
||||
const openTop = capture("keyboard-open");
|
||||
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
|
||||
console.log(
|
||||
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
|
||||
);
|
||||
@@ -107,6 +107,8 @@ const daemonTest = metroTest.extend<
|
||||
});
|
||||
|
||||
const test = daemonTest.extend<{
|
||||
/** Specs start past first-run setup; onboarding specs opt out to see a fresh install. */
|
||||
e2eOnboardingCompleted: boolean;
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
@@ -114,8 +116,9 @@ const test = daemonTest.extend<{
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
e2eOnboardingCompleted: [true, { option: true }],
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
async ({ page, e2eOnboardingCompleted }, provide, testInfo) => {
|
||||
const daemonPort = getE2EDaemonPort();
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -157,7 +160,7 @@ const test = daemonTest.extend<{
|
||||
const createAgentPreferences = buildCreateAgentPreferences();
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
|
||||
({ daemon, preferences, seedNonce: nonce, extraHostsKey, onboardingCompleted }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
@@ -185,12 +188,20 @@ const test = daemonTest.extend<{
|
||||
if (!localStorage.getItem("@paseo:app-settings")) {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
|
||||
}
|
||||
// The shared daemon often has no projects, which would open first-run setup over the app.
|
||||
if (onboardingCompleted && !localStorage.getItem("@paseo:onboarding")) {
|
||||
localStorage.setItem(
|
||||
"@paseo:onboarding",
|
||||
JSON.stringify({ completedAt: new Date(0).toISOString() }),
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
daemon: testDaemon,
|
||||
preferences: createAgentPreferences,
|
||||
seedNonce,
|
||||
extraHostsKey: EXTRA_HOSTS_KEY,
|
||||
onboardingCompleted: e2eOnboardingCompleted,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -113,3 +113,29 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
|
||||
}
|
||||
|
||||
export async function navigateAndDismissAddProject(page: Page): Promise<void> {
|
||||
// Keep both keys in one task so Escape closes with a selection update still queued.
|
||||
await page.evaluate(() => {
|
||||
for (const key of ["ArrowDown", "Escape"]) {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, code: key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function watchAddProjectRenderWarnings(page: Page): string[] {
|
||||
const warnings: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (text.includes("Cannot update a component") && text.includes("AddProjectFlow")) {
|
||||
warnings.push(text);
|
||||
}
|
||||
});
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ export async function chooseCustomSkills(page: Page, selected: string[]): Promis
|
||||
await all.click();
|
||||
const keep = new Set(selected);
|
||||
for (const name of await listSkillChoices(page)) {
|
||||
if (!keep.has(name)) await page.getByRole("checkbox", { name, exact: true }).click();
|
||||
if (!keep.has(name)) await skillSwitch(page, name).click();
|
||||
}
|
||||
await expectSkillChoices(page, selected);
|
||||
}
|
||||
@@ -176,7 +176,7 @@ export async function cancelSkillSelection(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function toggleSkill(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole("checkbox", { name, exact: true }).click();
|
||||
await skillSwitch(page, name).click();
|
||||
}
|
||||
|
||||
export function answerNextRemovalWarning(
|
||||
@@ -222,15 +222,21 @@ export async function expectSkillFile(
|
||||
export async function expectSkillChoices(page: Page, selected: string[]): Promise<void> {
|
||||
const expected = new Set(selected);
|
||||
for (const name of await listSkillChoices(page)) {
|
||||
await expect(page.getByRole("checkbox", { name, exact: true })).toBeChecked({
|
||||
await expect(skillSwitch(page, name)).toBeChecked({
|
||||
checked: expected.has(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** One bundled skill's switch in the picker; "All skills" sits outside the list. */
|
||||
function skillSwitch(page: Page, name: string) {
|
||||
return page.getByTestId("skill-selection-list-card").getByRole("switch", { name, exact: true });
|
||||
}
|
||||
|
||||
async function listSkillChoices(page: Page): Promise<string[]> {
|
||||
return page
|
||||
.getByRole("checkbox")
|
||||
.getByTestId("skill-selection-list-card")
|
||||
.getByRole("switch")
|
||||
.evaluateAll((elements) =>
|
||||
elements
|
||||
.map((element) => element.getAttribute("aria-label") ?? "")
|
||||
|
||||
@@ -219,6 +219,11 @@ export async function expectWorkspaceArchiveOutcome(
|
||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||
}
|
||||
|
||||
export async function closeActiveAgentWithShortcut(page: Page, agentId: string): Promise<void> {
|
||||
await page.keyboard.press("Alt+Shift+w");
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
}
|
||||
|
||||
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
||||
visible: true,
|
||||
@@ -237,6 +242,10 @@ export async function expectArchivedAgentFocused(page: Page, agentId: string): P
|
||||
});
|
||||
}
|
||||
|
||||
export async function unarchiveAgentFromConversation(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
/**
|
||||
* Starts a background agent the way a plugin helper does: in the workspace, under a parent,
|
||||
* streaming for about ten seconds. Resolves once the daemon reports it running.
|
||||
*/
|
||||
export async function startBackgroundAgent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentId: string; title: string },
|
||||
): Promise<{ id: string }> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "classify the last turn",
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: input.parentId },
|
||||
background: true,
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running");
|
||||
return { id: agent.id };
|
||||
}
|
||||
|
||||
/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */
|
||||
export async function finishAndArchiveAgent(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const finished = await workspace.client.waitForFinish(agentId, 60_000);
|
||||
expect(finished.status).toBe("idle");
|
||||
await workspace.client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise<void> {
|
||||
const row = page.getByRole("button", {
|
||||
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
|
||||
});
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -99,6 +99,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
const observedElements = new WeakSet<Element>();
|
||||
const resizeObserver = new ResizeObserver(record);
|
||||
const mutationObserver = new MutationObserver(discover);
|
||||
let performanceObserver: PerformanceObserver | undefined;
|
||||
|
||||
function record(): void {
|
||||
if (!observation.active) return;
|
||||
@@ -132,6 +133,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
if (!observation.active) {
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
performanceObserver?.disconnect();
|
||||
return;
|
||||
}
|
||||
record();
|
||||
@@ -141,9 +143,16 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
|
||||
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
try {
|
||||
const performanceObserver = new PerformanceObserver((list) => {
|
||||
performanceObserver = new PerformanceObserver((list) => {
|
||||
const panel = document.querySelector(selectors.panel);
|
||||
for (const entry of list.getEntries()) {
|
||||
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
|
||||
const shift = entry as PerformanceEntry & {
|
||||
value: number;
|
||||
sources: Array<{ node: Node | null }>;
|
||||
};
|
||||
if (shift.sources.some((source) => source.node && panel?.contains(source.node))) {
|
||||
observation.layoutShift += shift.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
performanceObserver.observe({ type: "layout-shift", buffered: false });
|
||||
@@ -174,7 +183,54 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
|
||||
expect(
|
||||
observation.layoutShift,
|
||||
`command center layoutShift: ${JSON.stringify(observation)}`,
|
||||
).toBeLessThanOrEqual(0.001);
|
||||
}
|
||||
|
||||
/** Exercise an unrelated page shift while the real search panel is being observed. */
|
||||
export async function shiftOutsideCommandCenter(page: Page): Promise<void> {
|
||||
await shiftLayoutProbe(page.locator("body"));
|
||||
}
|
||||
|
||||
export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise<void> {
|
||||
await startCommandCenterLayoutObservation(page);
|
||||
await shiftLayoutProbe(page.getByTestId("command-center-panel"));
|
||||
await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift");
|
||||
}
|
||||
|
||||
async function shiftLayoutProbe(parent: Locator): Promise<void> {
|
||||
const recordedShift = await parent.evaluate(async (container) => {
|
||||
const sibling = document.createElement("div");
|
||||
sibling.textContent = "Layout probe";
|
||||
sibling.style.cssText =
|
||||
"position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999";
|
||||
container.append(sibling);
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
const value = await new Promise<number>((resolve) => {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
const shift = entry as PerformanceEntry & {
|
||||
value: number;
|
||||
sources: Array<{ node: Node | null }>;
|
||||
};
|
||||
if (shift.sources.some((source) => source.node === sibling)) {
|
||||
observer.disconnect();
|
||||
resolve(shift.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "layout-shift", buffered: false });
|
||||
sibling.style.left = "400px";
|
||||
});
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
sibling.remove();
|
||||
return value;
|
||||
});
|
||||
expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001);
|
||||
}
|
||||
|
||||
export async function expectOneLineTruncatedFileResult(
|
||||
|
||||
@@ -53,3 +53,192 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise
|
||||
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
|
||||
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
|
||||
}
|
||||
|
||||
/** What the folded model control's labels show, read from the DOM. */
|
||||
export interface ModelTriggerLabelState {
|
||||
thinkingShown: boolean;
|
||||
modelShown: boolean;
|
||||
modelText: string;
|
||||
modelTruncated: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__modelTriggerFrames?: ModelTriggerLabelState[];
|
||||
}
|
||||
}
|
||||
|
||||
function readModelTriggerLabelStateInPage(): ModelTriggerLabelState {
|
||||
const visible = (node: Element) => node.getClientRects().length > 0;
|
||||
const model = Array.from(
|
||||
document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'),
|
||||
).find(visible);
|
||||
const thinking = Array.from(
|
||||
document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'),
|
||||
).find(visible);
|
||||
const shown = (node: Element | null): boolean => {
|
||||
for (let current = node; current; current = current.parentElement) {
|
||||
if (Number(getComputedStyle(current).opacity) === 0) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const modelShown = model !== undefined && shown(model);
|
||||
const modelText = model?.textContent ?? "";
|
||||
return {
|
||||
modelShown,
|
||||
modelText,
|
||||
thinkingShown: modelShown && thinking !== undefined,
|
||||
modelTruncated:
|
||||
model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth),
|
||||
};
|
||||
}
|
||||
|
||||
/** Records the folded model control's labels on every painted frame from page load. */
|
||||
export async function recordModelTriggerFramesFromLoad(page: Page): Promise<void> {
|
||||
await page.addInitScript(`(() => {
|
||||
const read = ${readModelTriggerLabelStateInPage.toString()};
|
||||
const frames = [];
|
||||
window.__modelTriggerFrames = frames;
|
||||
const tick = () => {
|
||||
frames.push(read());
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
})()`);
|
||||
}
|
||||
|
||||
/** The model control's labels once two reads 100ms apart agree. */
|
||||
export async function readSettledModelTriggerLabels(page: Page): Promise<ModelTriggerLabelState> {
|
||||
let previous = await page.evaluate(readModelTriggerLabelStateInPage);
|
||||
for (;;) {
|
||||
await page.waitForTimeout(100);
|
||||
const next = await page.evaluate(readModelTriggerLabelStateInPage);
|
||||
if (JSON.stringify(next) === JSON.stringify(previous)) return next;
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
|
||||
/** The labels at the widths where the folded model control changes what it shows. */
|
||||
export interface ModelTriggerLabelSweep {
|
||||
/** The labels at the widest width. */
|
||||
widest: ModelTriggerLabelState & { width: number };
|
||||
/** The first width at which the thinking label is hidden. */
|
||||
thinkingHidden: ModelTriggerLabelState & { width: number };
|
||||
/** The first width at which the model label is truncated. */
|
||||
modelTruncated: ModelTriggerLabelState & { width: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows the viewport step by step and records where the thinking label disappears and where the
|
||||
* model label first truncates.
|
||||
*/
|
||||
export async function sweepModelTriggerLabels(
|
||||
page: Page,
|
||||
widths: { from: number; to: number; step: number; height: number },
|
||||
): Promise<ModelTriggerLabelSweep> {
|
||||
const steps: (ModelTriggerLabelState & { width: number })[] = [];
|
||||
for (let width = widths.from; width >= widths.to; width -= widths.step) {
|
||||
await page.setViewportSize({ width, height: widths.height });
|
||||
const labels = await readSettledModelTriggerLabels(page);
|
||||
steps.push({ ...labels, width });
|
||||
if (labels.modelTruncated || !labels.modelShown) break;
|
||||
}
|
||||
const thinkingHidden = steps.find((step) => !step.thinkingShown);
|
||||
const modelTruncated = steps.find((step) => step.modelTruncated);
|
||||
if (!thinkingHidden || !modelTruncated) {
|
||||
throw new Error(`the sweep never hid the thinking label and truncated the model label`);
|
||||
}
|
||||
return { widest: steps[0], thinkingHidden, modelTruncated };
|
||||
}
|
||||
|
||||
/** No frame recorded so far painted a truncated model label beside the thinking label. */
|
||||
export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise<void> {
|
||||
const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []);
|
||||
expect(frames.some((frame) => frame.modelShown)).toBe(true);
|
||||
const wrong = frames
|
||||
.map((frame, index) => ({ ...frame, index }))
|
||||
.filter((frame) => frame.thinkingShown && frame.modelTruncated);
|
||||
expect(wrong, `of ${frames.length} frames`).toEqual([]);
|
||||
}
|
||||
|
||||
/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */
|
||||
export interface ComposerToolbarRails {
|
||||
/** Where the text column starts. */
|
||||
textLeft: number;
|
||||
/** Where the toolbar's leftmost ink starts. */
|
||||
inkLeft: number;
|
||||
/** How far the trailing disc sits from the composer's right edge. */
|
||||
discRightInset: number;
|
||||
/** How far the trailing disc sits from the composer's bottom edge. */
|
||||
discBottomInset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads where the toolbar's ink starts against the text column and where the trailing disc sits
|
||||
* in the composer's corner. A control's ink is its filled disc when it has one, otherwise its
|
||||
* glyph's stroked geometry.
|
||||
*/
|
||||
export async function readComposerToolbarRails(page: Page): Promise<ComposerToolbarRails> {
|
||||
const root = page.locator('[data-testid="message-input-root"]:visible').first();
|
||||
return root.evaluate((element) => {
|
||||
const textarea = element.querySelector("textarea");
|
||||
if (!textarea) throw new Error("composer has no text input");
|
||||
const composerRect = element.getBoundingClientRect();
|
||||
const textLeft =
|
||||
textarea.getBoundingClientRect().left +
|
||||
Number.parseFloat(getComputedStyle(textarea).paddingLeft);
|
||||
|
||||
const isFilled = (node: Element) => {
|
||||
const color = getComputedStyle(node).backgroundColor;
|
||||
return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
|
||||
};
|
||||
const glyphInkLeft = (svg: SVGSVGElement) => {
|
||||
let left = Number.POSITIVE_INFINITY;
|
||||
for (const shape of Array.from(
|
||||
svg.querySelectorAll<SVGGraphicsElement>("path, circle, rect, line, polyline"),
|
||||
)) {
|
||||
const matrix = shape.getScreenCTM();
|
||||
if (!matrix) continue;
|
||||
const box = shape.getBBox();
|
||||
const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0");
|
||||
left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e);
|
||||
}
|
||||
return left;
|
||||
};
|
||||
|
||||
const band = composerRect.bottom - 64;
|
||||
let inkLeft = Number.POSITIVE_INFINITY;
|
||||
let trailingDisc: DOMRect | null = null;
|
||||
for (const control of Array.from(element.querySelectorAll('[role="button"]'))) {
|
||||
const rect = control.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.top < band) continue;
|
||||
const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled);
|
||||
const svg = control.querySelector("svg");
|
||||
if (disc) {
|
||||
const discRect = disc.getBoundingClientRect();
|
||||
inkLeft = Math.min(inkLeft, discRect.left);
|
||||
if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect;
|
||||
} else if (svg) {
|
||||
inkLeft = Math.min(inkLeft, glyphInkLeft(svg));
|
||||
}
|
||||
}
|
||||
if (!trailingDisc) throw new Error("composer toolbar has no trailing disc");
|
||||
return {
|
||||
textLeft,
|
||||
inkLeft,
|
||||
discRightInset: composerRect.right - trailingDisc.right,
|
||||
discBottomInset: composerRect.bottom - trailingDisc.bottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the
|
||||
* corner, as far from the right edge as from the bottom edge. Within half a pixel.
|
||||
*/
|
||||
export async function expectComposerToolbarOnTextRails(page: Page): Promise<void> {
|
||||
const rails = await readComposerToolbarRails(page);
|
||||
// A precision of 0 digits passes within half a pixel.
|
||||
expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0);
|
||||
expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ export async function expectComposerVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||
await expect(composerInput(page)).toBeVisible({
|
||||
timeout: options?.timeout ?? 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectComposerDisabled(page: Page): Promise<void> {
|
||||
@@ -156,7 +158,9 @@ export async function attachImageFromMenu(
|
||||
}
|
||||
|
||||
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function attachFileFromMenu(
|
||||
@@ -218,18 +222,24 @@ export async function expectGithubAttachmentPill(
|
||||
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
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. */
|
||||
@@ -283,7 +293,10 @@ export async function startRunningMockAgent(
|
||||
model: opts.model,
|
||||
featureValues: opts.featureValues,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
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);
|
||||
@@ -347,7 +360,11 @@ export async function pasteComposerTestImage(page: Page): Promise<boolean> {
|
||||
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
|
||||
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
input.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}, COMPOSER_TEST_IMAGE_BASE64);
|
||||
@@ -505,7 +522,10 @@ export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
|
||||
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
|
||||
const frames = await page.evaluate(() => {
|
||||
Reflect.get(window, "__stopComposerGeometry")?.();
|
||||
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
|
||||
return Reflect.get(window, "__composerGeometryFrames") as {
|
||||
input: number;
|
||||
root: number;
|
||||
}[];
|
||||
});
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
|
||||
@@ -516,7 +536,10 @@ export async function roundTripComposerTabBeforePaint(
|
||||
page: Page,
|
||||
activeChatTitle: string,
|
||||
): Promise<void> {
|
||||
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
|
||||
const activeTab = page.getByRole("button", {
|
||||
name: activeChatTitle,
|
||||
exact: true,
|
||||
});
|
||||
await expect(activeTab).toHaveAttribute("aria-selected", "true");
|
||||
const result = await activeTab.evaluate((tab) => {
|
||||
const editor = document.activeElement;
|
||||
@@ -556,7 +579,10 @@ export async function roundTripComposerTabBeforePaint(
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
|
||||
observer.observe(tab, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-selected"],
|
||||
});
|
||||
shortcut("BracketLeft");
|
||||
});
|
||||
});
|
||||
@@ -566,3 +592,44 @@ 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,
|
||||
html,
|
||||
text,
|
||||
}: {
|
||||
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");
|
||||
}
|
||||
|
||||
/** Reuse the actual response Copy action and the browser's native paste. */
|
||||
export async function copyLatestTurnIntoComposer(page: Page): Promise<void> {
|
||||
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.getByRole("button", { name: "Copy turn", exact: true }).last().click();
|
||||
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
|
||||
await composerInput(page).fill("");
|
||||
await composerInput(page).press("ControlOrMeta+v");
|
||||
}
|
||||
|
||||
@@ -54,12 +54,15 @@ export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Compact screens open the meter's details in a sheet. */
|
||||
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
/** Clicking the meter opens its details in the active layout. */
|
||||
export async function pressContextWindowMeter(
|
||||
page: Page,
|
||||
name: string | RegExp = METER_NAME,
|
||||
): Promise<Locator> {
|
||||
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowDetails(page);
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
const details = contextWindowDetails(page);
|
||||
await expect(details).toBeVisible();
|
||||
return details;
|
||||
}
|
||||
|
||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { writeFile, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
@@ -154,3 +154,42 @@ export async function plantUnclosableTerminalProgram(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fault injection and liveness checks for the isolated daemon cleanup journey. */
|
||||
export function suspendDaemonProcess(pid: number): void {
|
||||
process.kill(pid, "SIGSTOP");
|
||||
}
|
||||
|
||||
export function resumeDaemonProcess(pid: number): void {
|
||||
try {
|
||||
process.kill(pid, "SIGCONT");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function daemonProcessCanWrite(pid: number): Promise<boolean> {
|
||||
try {
|
||||
const state = await psField(pid, "stat");
|
||||
return state.length > 0 && !state.startsWith("Z");
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown }).code === 1) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise<number[]> {
|
||||
const directory = path.join(daemon.paseoHome, "terminal-worker");
|
||||
const records = await Promise.all(
|
||||
(await readdir(directory))
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map(async (name) => {
|
||||
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
|
||||
pid: number;
|
||||
terminals: Array<{ pid: number }>;
|
||||
};
|
||||
return [record.pid].concat(record.terminals.map((terminal) => terminal.pid));
|
||||
}),
|
||||
);
|
||||
return records.flat();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
|
||||
|
||||
export interface DirectoryBootstrapCounts {
|
||||
agents: number;
|
||||
@@ -294,7 +294,8 @@ function recordClientRequest(
|
||||
directoryStarts.total[directory] += 1;
|
||||
}
|
||||
|
||||
export async function installDaemonWebSocketGate(page: Page) {
|
||||
/** Gates the shared E2E daemon, or the daemon on `port` for a spec that runs its own. */
|
||||
export async function installDaemonWebSocketGate(page: Page, options: { port?: number } = {}) {
|
||||
let acceptingConnections = true;
|
||||
let reconnectWithFreshClient = false;
|
||||
let suppressAgentStream = false;
|
||||
@@ -445,7 +446,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
return true;
|
||||
};
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const routePattern =
|
||||
options.port === undefined
|
||||
? daemonWsRoutePattern()
|
||||
: wsRoutePatternForPort(String(options.port));
|
||||
await page.routeWebSocket(routePattern, (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
blockedConnectionCount += 1;
|
||||
for (const resolve of blockedConnectionWaiters) resolve();
|
||||
|
||||
@@ -148,3 +148,59 @@ export async function expectFullscreenDiagram(
|
||||
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Tall diagrams stay within the screen so their controls remain reachable. */
|
||||
export async function expectInlineDiagramFitsViewport(page: Page): Promise<void> {
|
||||
const viewport = page.getByTestId("mermaid-viewport").last();
|
||||
const box = await viewport.boundingBox();
|
||||
expect(box, "the inline diagram has measurable layout").not.toBeNull();
|
||||
const screenHeight = page.viewportSize()!.height;
|
||||
expect(box!.height, "the inline diagram fits within the screen").toBeLessThan(screenHeight);
|
||||
const contentHeight = await diagramSvg(page, "mermaid-viewport").evaluate(
|
||||
(svg) => (svg as SVGSVGElement).viewBox.baseVal.height,
|
||||
);
|
||||
expect(box!.height, "a tall diagram is bounded rather than expanding the chat").toBeLessThan(
|
||||
contentHeight,
|
||||
);
|
||||
}
|
||||
|
||||
/** Exercise pointer toggles and the controls encountered along that same journey. */
|
||||
export async function toggleDiagramSourceAndVerifyControls(page: Page): Promise<void> {
|
||||
const viewport = page.getByTestId("mermaid-viewport").last();
|
||||
const iframe = await viewport.locator("iframe").elementHandle();
|
||||
for (let iteration = 0; iteration < 3; iteration++) {
|
||||
await viewport.hover();
|
||||
await page.getByRole("button", { name: "View source" }).last().click();
|
||||
const viewDiagram = page.getByRole("button", { name: "View diagram" }).last();
|
||||
await viewDiagram.click({ timeout: 10_000 });
|
||||
await expect(viewDiagram).toHaveCount(0);
|
||||
await expectFullscreenDiagramClosed(page);
|
||||
expect(
|
||||
await viewport.locator("iframe").evaluate((node, original) => node === original, iframe),
|
||||
"switching views preserves the rendered diagram",
|
||||
).toBe(true);
|
||||
}
|
||||
await viewport.hover();
|
||||
await page.getByRole("button", { name: "View source" }).last().click();
|
||||
const hiddenFullscreen = viewport.getByRole("button", {
|
||||
name: "Fullscreen",
|
||||
includeHidden: true,
|
||||
});
|
||||
await hiddenFullscreen.focus();
|
||||
await expect(hiddenFullscreen).not.toBeFocused();
|
||||
await page.getByRole("button", { name: "View diagram" }).last().click();
|
||||
await viewport.hover();
|
||||
const fullscreen = viewport.getByRole("button", { name: "Fullscreen", exact: true });
|
||||
const box = await viewport.boundingBox();
|
||||
const control = await fullscreen.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(control).not.toBeNull();
|
||||
expect(control!.y, "the toolbar starts inside the preview").toBeGreaterThanOrEqual(box!.y);
|
||||
expect(control!.y + control!.height, "the toolbar fits inside the preview").toBeLessThanOrEqual(
|
||||
box!.y + box!.height,
|
||||
);
|
||||
await viewport.getByRole("button", { name: "Zoom in" }).click();
|
||||
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeEnabled();
|
||||
await viewport.getByRole("button", { name: "Reset view" }).click();
|
||||
await expect(viewport.getByRole("button", { name: "Reset view" })).toBeDisabled();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
function listingResponse(message: string | Buffer) {
|
||||
try {
|
||||
const envelope = JSON.parse(message.toString());
|
||||
return envelope.message?.type === "file_explorer_response" ? envelope.message.payload : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delay one real directory response after the daemon has read its contents. */
|
||||
export async function delayDirectoryListingResponse(page: Page, directoryPath: string) {
|
||||
let held: { socket: WebSocketRoute; message: string | Buffer; requestId: string } | null = null;
|
||||
let resolveHeld!: () => void;
|
||||
const heldResponse = new Promise<void>((resolve) => {
|
||||
resolveHeld = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
socket.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const response = listingResponse(message);
|
||||
if (!held && response?.mode === "list" && response.path === directoryPath) {
|
||||
held = { socket, message, requestId: response.requestId };
|
||||
resolveHeld();
|
||||
return;
|
||||
}
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitUntilHeld: () => heldResponse,
|
||||
release: async () => {
|
||||
if (!held) throw new Error("Directory response must be held before release");
|
||||
held.socket.send(held.message);
|
||||
// Let the delivered reply update the UI before the user reopens the folder.
|
||||
await page.evaluate(
|
||||
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -22,15 +22,27 @@ export async function addOfflineHostAndReload(
|
||||
page: Page,
|
||||
input: { serverId: string; label: string; primaryLabel?: string },
|
||||
): Promise<void> {
|
||||
const offlineHost = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
label: input.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
|
||||
}
|
||||
|
||||
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
|
||||
export async function addOfflineHostsAndReload(
|
||||
page: Page,
|
||||
hosts: Array<{ serverId: string; label: string }>,
|
||||
options: { primaryLabel?: string } = {},
|
||||
): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
const offlineHosts = hosts.map((host) =>
|
||||
buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
endpoint: "127.0.0.1:59999",
|
||||
nowIso,
|
||||
}),
|
||||
);
|
||||
|
||||
await page.evaluate(
|
||||
({ host, keys, primaryLabel }) => {
|
||||
({ hosts: added, keys, primaryLabel }) => {
|
||||
const nonce = localStorage.getItem(keys.nonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
|
||||
@@ -40,20 +52,22 @@ export async function addOfflineHostAndReload(
|
||||
if (primaryLabel && registry[0]) {
|
||||
registry[0].label = primaryLabel;
|
||||
}
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
for (const host of added) {
|
||||
if (!registry.some((entry) => entry.serverId === host.serverId)) {
|
||||
registry.push(host);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(keys.registry, JSON.stringify(registry));
|
||||
localStorage.setItem(keys.disableSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
host: offlineHost,
|
||||
hosts: offlineHosts,
|
||||
keys: {
|
||||
registry: REGISTRY_KEY,
|
||||
nonce: SEED_NONCE_KEY,
|
||||
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
primaryLabel: input.primaryLabel,
|
||||
primaryLabel: options.primaryLabel,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -273,3 +287,25 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ registryKey, nonceKey, disableSeedKey }) => {
|
||||
const nonce = localStorage.getItem(nonceKey);
|
||||
if (!nonce) throw new Error("Expected the e2e seed nonce");
|
||||
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
|
||||
for (const host of hosts) {
|
||||
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
|
||||
host.preferredConnectionId = "offline";
|
||||
}
|
||||
localStorage.setItem(registryKey, JSON.stringify(hosts));
|
||||
localStorage.setItem(disableSeedKey, nonce);
|
||||
},
|
||||
{
|
||||
registryKey: REGISTRY_KEY,
|
||||
nonceKey: SEED_NONCE_KEY,
|
||||
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
spawn,
|
||||
execFile,
|
||||
execFileSync,
|
||||
type ChildProcess,
|
||||
type SpawnOptions,
|
||||
} from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -6,6 +13,8 @@ import path from "node:path";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
@@ -16,6 +25,8 @@ export interface IsolatedHostDaemon {
|
||||
}
|
||||
|
||||
export interface IsolatedHostDaemonOptions {
|
||||
/** Written to the daemon's config.json; `mutableRelay` adds `daemon.relay` to it. */
|
||||
config?: { daemon?: Record<string, unknown>; [key: string]: unknown };
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
mutableRelay?: {
|
||||
enabled: boolean;
|
||||
@@ -84,6 +95,28 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function withMutableRelay(
|
||||
config: NonNullable<IsolatedHostDaemonOptions["config"]>,
|
||||
relay: NonNullable<IsolatedHostDaemonOptions["mutableRelay"]>,
|
||||
): NonNullable<IsolatedHostDaemonOptions["config"]> {
|
||||
const endpoint =
|
||||
relay.endpoint ??
|
||||
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
||||
return {
|
||||
...config,
|
||||
daemon: {
|
||||
...config.daemon,
|
||||
relay: {
|
||||
enabled: relay.enabled,
|
||||
endpoint,
|
||||
publicEndpoint: endpoint,
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function startIsolatedHostDaemon(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
@@ -129,29 +162,36 @@ export async function startIsolatedHostDaemon(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (options.mutableRelay) {
|
||||
const endpoint =
|
||||
options.mutableRelay.endpoint ??
|
||||
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
||||
const config = options.mutableRelay
|
||||
? withMutableRelay(options.config ?? {}, options.mutableRelay)
|
||||
: options.config;
|
||||
if (config) {
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
daemon: {
|
||||
relay: {
|
||||
enabled: options.mutableRelay.enabled,
|
||||
endpoint,
|
||||
publicEndpoint: endpoint,
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
`${JSON.stringify({ version: 1, ...config })}\n`,
|
||||
);
|
||||
}
|
||||
const serverDir = publishedPackageRoot
|
||||
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
||||
: path.resolve(__dirname, "../../../../server");
|
||||
let child: ChildProcess | null = null;
|
||||
const stopDaemon = async (): Promise<void> => {
|
||||
// The CLI owns graceful shutdown, forced cleanup and detached terminal
|
||||
// reaping. Keep its ESM graph out of Playwright's transforming loader.
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, "../../../../cli/dist/index.js"),
|
||||
"daemon",
|
||||
"stop",
|
||||
"--home",
|
||||
paseoHome,
|
||||
"--force",
|
||||
],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await killProcessTree(child);
|
||||
};
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const spawnOptions: SpawnOptions = {
|
||||
cwd: serverDir,
|
||||
@@ -169,21 +209,21 @@ export async function startIsolatedHostDaemon(
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
};
|
||||
const child = publishedPackageRoot
|
||||
const spawned = publishedPackageRoot
|
||||
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
||||
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
|
||||
|
||||
child = spawned;
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
spawned.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
return child;
|
||||
await waitForServer(port, spawned);
|
||||
return spawned;
|
||||
} catch (error) {
|
||||
await killProcessTree(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
@@ -191,10 +231,10 @@ export async function startIsolatedHostDaemon(
|
||||
}
|
||||
};
|
||||
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = await spawnDaemon();
|
||||
await spawnDaemon();
|
||||
} catch (error) {
|
||||
await stopDaemon();
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
@@ -209,22 +249,22 @@ export async function startIsolatedHostDaemon(
|
||||
serverId,
|
||||
port,
|
||||
paseoHome,
|
||||
getPid: () => child.pid,
|
||||
getPid: () => child?.pid,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await killProcessTree(child);
|
||||
child = await spawnDaemon();
|
||||
await stopDaemon();
|
||||
await spawnDaemon();
|
||||
},
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await killProcessTree(child);
|
||||
await stopDaemon();
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
if (publishedPackageRoot) {
|
||||
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||
}
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "createTerminal"
|
||||
| "killTerminal"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
@@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "removePlugin"
|
||||
| "reloadPlugin"
|
||||
| "setWorkspaceTitle"
|
||||
| "setWorkspaceLabel"
|
||||
>;
|
||||
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { addProjectFlow, addProjectFlowInput, chooseAddProjectMethod } from "./add-project-flow";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { startIsolatedHostDaemon, type IsolatedHostDaemonOptions } from "./isolated-host-daemon";
|
||||
|
||||
/** Points a fresh browser profile at one host, with no first-run record and no app settings. */
|
||||
export async function openAppAsNewUser(
|
||||
page: Page,
|
||||
host: { serverId: string; port: number },
|
||||
): Promise<void> {
|
||||
const seededHost = buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
endpoint: `127.0.0.1:${host.port}`,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.route(/:6767\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
|
||||
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ registryHost, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([registryHost]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ registryHost: seededHost, preferences: buildCreateAgentPreferences() },
|
||||
);
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
interface FirstLaunchRecord {
|
||||
appBeforeSetup: boolean;
|
||||
setupOpacityOnArrival: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the page from its first paint: whether the app's sidebar or home showed before setup,
|
||||
* and how opaque setup was when it arrived. Call before `openAppAsNewUser`.
|
||||
*/
|
||||
export async function recordFirstLaunch(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const record: FirstLaunchRecord = { appBeforeSetup: false, setupOpacityOnArrival: null };
|
||||
Object.assign(window, { __firstLaunch: record });
|
||||
const observer = new MutationObserver(() => {
|
||||
const setup = document.querySelector('[data-testid="onboarding-modal"] [role="dialog"]');
|
||||
if (setup) {
|
||||
record.setupOpacityOnArrival = Number(getComputedStyle(setup).opacity);
|
||||
observer.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
document.querySelector(
|
||||
'[data-testid="open-project-submit"], [data-testid="sidebar-settings"]',
|
||||
)
|
||||
) {
|
||||
record.appBeforeSetup = true;
|
||||
}
|
||||
});
|
||||
observer.observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function readFirstLaunch(page: Page): Promise<FirstLaunchRecord> {
|
||||
return page.evaluate(
|
||||
() => (window as unknown as { __firstLaunch: FirstLaunchRecord }).__firstLaunch,
|
||||
);
|
||||
}
|
||||
|
||||
/** The loading screen the app shows while it starts. */
|
||||
export function loadingScreen(page: Page): Locator {
|
||||
return page.getByTestId("startup-splash");
|
||||
}
|
||||
|
||||
/** The empty home a host with no projects lands on. */
|
||||
export function emptyHome(page: Page): Locator {
|
||||
return page.getByTestId("open-project-submit");
|
||||
}
|
||||
|
||||
export function setupModal(page: Page): Locator {
|
||||
return page.getByTestId("onboarding-modal").getByRole("dialog");
|
||||
}
|
||||
|
||||
export async function expectSetupStep(page: Page, title: string): Promise<void> {
|
||||
await expect(setupModal(page).getByRole("heading", { name: title, exact: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickTheme(page: Page, name: string): Promise<void> {
|
||||
const tile = setupModal(page).getByRole("radio", { name, exact: true });
|
||||
await tile.click();
|
||||
await expect(tile).toBeChecked();
|
||||
}
|
||||
|
||||
export async function continueSetup(page: Page): Promise<void> {
|
||||
await setupModal(page).getByRole("button", { name: "Continue", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function skipSetup(page: Page): Promise<void> {
|
||||
await setupModal(page).getByRole("button", { name: "Skip setup", exact: true }).click();
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Adds a folder through the regular Add project flow, opened from the setup's Projects step. */
|
||||
export async function addProjectFromSetup(page: Page, projectPath: string): Promise<void> {
|
||||
await setupModal(page).getByRole("button", { name: "Add project", exact: true }).click();
|
||||
await expect(addProjectFlow(page)).toBeVisible({ timeout: 30_000 });
|
||||
await chooseAddProjectMethod(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(addProjectFlow(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function showSetupAgainFromSettings(page: Page): Promise<void> {
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await page.getByRole("button", { name: "Show setup again", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function goToSetupStep(page: Page, label: string): Promise<void> {
|
||||
await setupModal(page).getByRole("tab", { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
export function paseoToolsSwitch(scope: Page | Locator): Locator {
|
||||
return scope.getByRole("switch", { name: "Inject Paseo tools", exact: true });
|
||||
}
|
||||
|
||||
/** Whether the host gives its agents Paseo tools, read from the host itself. */
|
||||
export async function hostGivesAgentsPaseoTools(host: { port: number }): Promise<boolean> {
|
||||
const client = await connectDaemonClient<
|
||||
Pick<InternalDaemonClient, "connect" | "close" | "getDaemonConfig">
|
||||
>({ clientIdPrefix: "app-e2e-onboarding", port: host.port });
|
||||
try {
|
||||
return (await client.getDaemonConfig()).config.mcp.injectIntoAgents !== false;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function finishSetup(page: Page): Promise<void> {
|
||||
await setupModal(page).getByRole("button", { name: "Finish", exact: true }).click();
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Opens the Phone step's Relay row: the pair device modal, over setup. */
|
||||
export async function openRelayPairingFromSetup(page: Page): Promise<Locator> {
|
||||
await setupModal(page).getByRole("button", { name: "Relay", exact: true }).click();
|
||||
const pairing = page.getByTestId("onboarding-pair-device-card");
|
||||
await expect(pairing).toBeVisible();
|
||||
return pairing;
|
||||
}
|
||||
|
||||
export async function closeRelayPairing(pairing: Locator): Promise<void> {
|
||||
await pairing.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(pairing).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Clicks the Phone step's Tailscale row and returns the docs page it opened. */
|
||||
export async function openTailscaleDocsFromSetup(page: Page): Promise<string> {
|
||||
await page
|
||||
.context()
|
||||
.route("https://paseo.sh/**", (route) =>
|
||||
route.fulfill({ contentType: "text/html", body: "<title>Paseo docs</title>" }),
|
||||
);
|
||||
const popup = page.waitForEvent("popup");
|
||||
await setupModal(page).getByRole("link", { name: "Tailscale, Docs", exact: true }).click();
|
||||
const docs = await popup;
|
||||
await docs.waitForLoadState("domcontentloaded");
|
||||
const url = docs.url();
|
||||
await docs.close();
|
||||
return url;
|
||||
}
|
||||
|
||||
export interface SetupHost {
|
||||
serverId: string;
|
||||
port: number;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An isolated host whose only agent history is the Claude sessions given here, so suggestions
|
||||
* never depend on the machine running the test. Its HOME is a scratch folder, so the skills setup
|
||||
* installs never land in the real ~/.agents, ~/.claude or ~/.codex. `withBrokenProvider` adds a
|
||||
* provider that cannot start.
|
||||
*/
|
||||
export async function startSetupHost(
|
||||
label: string,
|
||||
options: {
|
||||
claudeSessionsIn?: readonly string[];
|
||||
withBrokenProvider?: boolean;
|
||||
mutableRelay?: IsolatedHostDaemonOptions["mutableRelay"];
|
||||
} = {},
|
||||
): Promise<SetupHost> {
|
||||
const serverId = `srv_onboarding_${label}_${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
const claudeConfigDir = await seedClaudeHistory(options.claudeSessionsIn ?? []);
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-home-"));
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
config: claudeOnlyConfig({ withBrokenProvider: options.withBrokenProvider ?? false }),
|
||||
environment: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: claudeConfigDir },
|
||||
mutableRelay: options.mutableRelay,
|
||||
});
|
||||
return {
|
||||
serverId,
|
||||
port: daemon.port,
|
||||
close: async () => {
|
||||
await daemon.close();
|
||||
await rm(claudeConfigDir, { recursive: true, force: true });
|
||||
await rm(home, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes one recent Claude session per folder into a fresh CLAUDE_CONFIG_DIR. */
|
||||
async function seedClaudeHistory(cwds: readonly string[]): Promise<string> {
|
||||
const configDir = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-claude-"));
|
||||
const newest = Date.now() - 60_000;
|
||||
for (const [index, cwd] of cwds.entries()) {
|
||||
const sessionId = `onboarding-session-${index + 1}`;
|
||||
const projectDir = path.join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
const sessionPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
const record = {
|
||||
type: "user",
|
||||
uuid: `${sessionId}-user`,
|
||||
message: { role: "user", content: `Work on session ${index + 1}` },
|
||||
cwd,
|
||||
sessionId,
|
||||
};
|
||||
await writeFile(sessionPath, `${JSON.stringify(record)}\n`);
|
||||
const timestamp = new Date(newest - index * 60_000);
|
||||
await utimes(sessionPath, timestamp, timestamp);
|
||||
}
|
||||
return configDir;
|
||||
}
|
||||
|
||||
function claudeOnlyConfig(options: { withBrokenProvider: boolean }) {
|
||||
const brokenProvider = {
|
||||
"broken-acp": {
|
||||
extends: "acp",
|
||||
label: "Broken ACP",
|
||||
command: ["missing-agent-command", "acp"],
|
||||
},
|
||||
};
|
||||
return {
|
||||
agents: {
|
||||
providers: {
|
||||
codex: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
omp: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
...(options.withBrokenProvider ? brokenProvider : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The switch that picks a suggested project for adding. */
|
||||
export function projectSuggestion(page: Page, name: string): Locator {
|
||||
return setupModal(page).getByRole("switch", { name, exact: true });
|
||||
}
|
||||
|
||||
/** The whole suggestion row, with its path, last use and any add error beside the switch. */
|
||||
export function projectSuggestionRow(page: Page, name: string): Locator {
|
||||
return setupModal(page)
|
||||
.getByTestId("project-suggestion")
|
||||
.filter({ has: page.getByRole("switch", { name, exact: true }) });
|
||||
}
|
||||
|
||||
/** The footer's main button, which adds the checked suggestions when there are any. */
|
||||
export function setupPrimaryButton(page: Page, label: string): Locator {
|
||||
return setupModal(page).getByRole("button", { name: label, exact: true });
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor";
|
||||
import { expectTurnCopyButton } from "./agent-stream";
|
||||
import { submitMessage } from "./composer";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import {
|
||||
@@ -88,7 +91,7 @@ export default function contribute(client) {
|
||||
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||
command("finish", "Finish deployment", () => finish());
|
||||
command("finish-review", "Finish review", () => finishReview());
|
||||
@@ -102,10 +105,16 @@ export default function contribute(client) {
|
||||
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
||||
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
||||
const lifetime = new AbortController();
|
||||
let pillsHidden = false;
|
||||
const applyWorkspace = (workspace) => {
|
||||
if (workspace.id !== workspaceId) return;
|
||||
const hidePills = workspace.name === "No composer pills";
|
||||
deploy.update({ visible: workspace.name !== "Compact checks" });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills });
|
||||
if (hidePills === pillsHidden) return;
|
||||
pillsHidden = hidePills;
|
||||
review.update({ visible: !hidePills });
|
||||
usage.update({ visible: !hidePills });
|
||||
};
|
||||
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
|
||||
subscription.subscribe({
|
||||
@@ -244,6 +253,38 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject:
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
}
|
||||
|
||||
const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const;
|
||||
|
||||
function chevronOf(page: Page, title: string): Locator {
|
||||
return page
|
||||
.getByRole("button", { name: title, exact: true })
|
||||
.getByTestId("plugin-button-chevron");
|
||||
}
|
||||
|
||||
/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */
|
||||
async function expectComposerPillChevrons(page: Page) {
|
||||
await expect(chevronOf(page, "Composer checks")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Composer status")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Run review")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function setComposerPillsShown(
|
||||
page: Page,
|
||||
showcase: { setTitle(title: string): Promise<unknown> },
|
||||
shown: boolean,
|
||||
) {
|
||||
await showcase.setTitle(shown ? "Button showcase" : "No composer pills");
|
||||
for (const title of COMPOSER_PILLS) {
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function lastTurnTop(page: Page): Promise<number> {
|
||||
const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
return box!.y;
|
||||
}
|
||||
|
||||
async function expectHeaderTextTruncated(page: Page) {
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const subtitle = page.getByTestId("workspace-header-subtitle");
|
||||
@@ -276,6 +317,7 @@ export async function withButtonShowcase(
|
||||
run: (buttons: {
|
||||
openWideMenusAndPopovers(): Promise<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
keepTrackSpaceWithoutPills(): Promise<void>;
|
||||
openCompactSheets(): Promise<void>;
|
||||
useCompactWorkspaceMenu(): Promise<void>;
|
||||
hideAndUnloadOpenButtons(): Promise<void>;
|
||||
@@ -348,11 +390,7 @@ export async function withButtonShowcase(
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Composer checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expectSurfaceVisible(page);
|
||||
await captureSchemes(
|
||||
page,
|
||||
@@ -476,10 +514,31 @@ export async function withButtonShowcase(
|
||||
"Review ready",
|
||||
);
|
||||
}),
|
||||
keepTrackSpaceWithoutPills: () =>
|
||||
test.step("the composer track keeps its space when every pill leaves and returns", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await submitMessage(page, "Stream a turn under the composer pills");
|
||||
await expectTurnCopyButton(page);
|
||||
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
|
||||
for (const viewport of [WIDE, COMPACT]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await scrollAgentChatToBottom(page);
|
||||
const top = await lastTurnTop(page);
|
||||
await setComposerPillsShown(page, showcase, false);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
await setComposerPillsShown(page, showcase, true);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
}
|
||||
await page.setViewportSize(WIDE);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toContainText("Checks");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
@@ -9,6 +12,54 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
const TABBED_PLUGIN_ID = "settings-tabs";
|
||||
const TABBED_PLUGIN_NAME = "Tabbed settings";
|
||||
/** Two settings screens, so the settings page shows a tab per screen. */
|
||||
const TABBED_SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
|
||||
|
||||
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
|
||||
|
||||
function General({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
|
||||
}
|
||||
|
||||
function Advanced({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
|
||||
}
|
||||
|
||||
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
|
||||
function TranslatedIcon({ color, size }) {
|
||||
const { t } = useTranslation(messages);
|
||||
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
function BrokenIcon() {
|
||||
throw new Error("Settings tab icon exploded");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
|
||||
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
async function writeTabbedPlugin() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: TABBED_PLUGIN_ID,
|
||||
name: TABBED_PLUGIN_NAME,
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
|
||||
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
settingsPlugin: [
|
||||
async ({ e2eWorker, page }, provide, testInfo) => {
|
||||
@@ -16,16 +67,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("settings");
|
||||
const tabbed = await writeTabbedPlugin();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await client.installDirectoryPlugin(tabbed.directory);
|
||||
await provide(client);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
||||
} finally {
|
||||
await client.removePlugin("settings-example");
|
||||
await client.removePlugin(TABBED_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await tabbed.cleanup();
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
@@ -44,9 +99,88 @@ export async function openCompactDisplaySettings(page: Page) {
|
||||
}
|
||||
async function openPluginScreen(page: Page) {
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await openSettingsFromCog(page);
|
||||
}
|
||||
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
|
||||
function settingsButton(page: Page, pluginId: string) {
|
||||
return page
|
||||
.getByTestId(`plugin-row-${pluginId}`)
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Settings", exact: true });
|
||||
}
|
||||
async function openSettingsFromCog(page: Page) {
|
||||
await settingsButton(page, "settings-example").click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
|
||||
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
|
||||
await expectSettingsHeader(page, "settings-example");
|
||||
}
|
||||
export async function openPluginsPage(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
}
|
||||
export async function expectActionsMenuWithoutSettings(page: Page) {
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
export async function expectNoSettingsCogWithoutScreens(page: Page) {
|
||||
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
|
||||
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
|
||||
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
|
||||
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
|
||||
await expect(settingsButton(page, "settings-example")).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsTooltip(page: Page) {
|
||||
await settingsButton(page, "settings-example").hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Settings");
|
||||
await page.mouse.move(0, 0);
|
||||
}
|
||||
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
|
||||
export async function expectNamedPluginRow(page: Page) {
|
||||
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
|
||||
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
|
||||
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
export async function openTabbedSettings(page: Page) {
|
||||
await settingsButton(page, TABBED_PLUGIN_ID).click();
|
||||
await expectSettingsTab(page, "General");
|
||||
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
|
||||
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
|
||||
}
|
||||
function settingsTab(page: Page, title: string) {
|
||||
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
|
||||
}
|
||||
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
await settingsTab(page, title).click();
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
const other = title === "General" ? "Advanced" : "General";
|
||||
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
|
||||
// The header names the plugin by its manifest name; the tab names the screen.
|
||||
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
|
||||
await expectAppRoute(
|
||||
page,
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
|
||||
await gotoAppShell(page);
|
||||
await page.goto(
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||
@@ -101,11 +235,7 @@ export async function discardTitle(page: Page) {
|
||||
}
|
||||
export async function returnAndReopenSettings(page: Page) {
|
||||
await goBackInSettings(page);
|
||||
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
|
||||
await expect(actions).toBeVisible();
|
||||
await actions.click();
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
await openSettingsFromCog(page);
|
||||
}
|
||||
export async function returnToPluginsPage(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
@@ -114,6 +244,10 @@ export async function returnToPluginsPage(page: Page) {
|
||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function expectSettingsHeader(page: Page, title: string) {
|
||||
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
|
||||
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function contribute(client) {
|
||||
}`;
|
||||
}
|
||||
|
||||
async function withDirectoryPlugin(options: {
|
||||
export async function withDirectoryPlugin(options: {
|
||||
pluginId: string;
|
||||
source: string;
|
||||
run: () => Promise<void>;
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface SeedDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
background?: boolean;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
@@ -156,8 +157,11 @@ export interface SeedDaemonClient {
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { sourceAgentId?: string },
|
||||
options?: { sourceAgentId?: string; activeTurnBehavior?: "interrupt" | "steer" | "queue" },
|
||||
): Promise<void>;
|
||||
listAgentBackgroundWork(
|
||||
agentId: string,
|
||||
): Promise<import("@getpaseo/client").PaseoAgentBackgroundWorkItem[]>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
|
||||
@@ -351,9 +351,24 @@ export async function expectDirectHostFormValues(
|
||||
}
|
||||
|
||||
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).toBeChecked();
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function fillDirectHostAddress(page: Page, host: string, port: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Host", exact: true }).fill(host);
|
||||
await page.getByRole("textbox", { name: "Port", exact: true }).fill(port);
|
||||
}
|
||||
|
||||
export async function toggleDirectHostSsl(page: Page): Promise<void> {
|
||||
await page.getByRole("checkbox", { name: "Use SSL", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectDirectHostSslDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).not.toBeChecked();
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** Every footer control that can sit in the icon line, caret included. */
|
||||
const FOOTER_LINE_TEST_IDS = [
|
||||
"sidebar-add-project",
|
||||
"sidebar-usage-icon",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-footer-overflow",
|
||||
"sidebar-settings",
|
||||
];
|
||||
|
||||
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
|
||||
const GEOMETRY_TOLERANCE = 0.01;
|
||||
|
||||
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
|
||||
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
|
||||
await page.addInitScript((sidebarWidth) => {
|
||||
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
|
||||
}, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* From the first frame, record any frame in which a footer button reaches past its row, so a row
|
||||
* laid out before it measured shows up even if it lasted one frame.
|
||||
*/
|
||||
export async function watchFooterClipping(page: Page): Promise<void> {
|
||||
await page.addInitScript((testIDs) => {
|
||||
const clippedFrames: string[] = [];
|
||||
Object.assign(window, { __footerClippedFrames: clippedFrames });
|
||||
const sample = () => {
|
||||
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
if (rowRect.width === 0) continue;
|
||||
for (const testID of testIDs) {
|
||||
const button = row.querySelector(`[data-testid="${testID}"]`);
|
||||
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
|
||||
clippedFrames.push(testID);
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}, FOOTER_LINE_TEST_IDS);
|
||||
}
|
||||
|
||||
export async function expectFooterNeverClipped(page: Page): Promise<void> {
|
||||
const clipped = await page.evaluate(
|
||||
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
|
||||
);
|
||||
expect(clipped).toEqual([]);
|
||||
}
|
||||
|
||||
function footerLine(page: Page): Locator {
|
||||
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
|
||||
}
|
||||
|
||||
export function footerOverflowMenu(page: Page): Locator {
|
||||
return page.getByTestId("sidebar-footer-overflow-menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
|
||||
* line's box, so no button is cut off by the sidebar's edge.
|
||||
*/
|
||||
export async function expectFooterLine(
|
||||
page: Page,
|
||||
input: { testIDs: string[]; controlSize: number },
|
||||
): Promise<void> {
|
||||
const line = footerLine(page);
|
||||
await expect(line).toHaveCount(1);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
line
|
||||
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
|
||||
)
|
||||
.toEqual(input.testIDs);
|
||||
const { row, buttons } = await line.evaluate((element, testIDs) => {
|
||||
const rect = (node: Element) => {
|
||||
const { x, width, height } = node.getBoundingClientRect();
|
||||
return { x, width, height };
|
||||
};
|
||||
return {
|
||||
row: rect(element),
|
||||
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
|
||||
};
|
||||
}, input.testIDs);
|
||||
for (const button of buttons) {
|
||||
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
|
||||
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
|
||||
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
|
||||
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFooterOverflow(page: Page): Promise<void> {
|
||||
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
|
||||
await expect(footerOverflowMenu(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** The overflow menu lists exactly these actions, in row order. */
|
||||
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
|
||||
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
|
||||
}
|
||||
|
||||
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
|
||||
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
|
||||
}
|
||||
|
||||
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
|
||||
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize()!;
|
||||
const rows = page.getByRole("menuitem");
|
||||
await expect(rows.first()).toBeVisible();
|
||||
const boxes = await rows.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const { x, width } = element.getBoundingClientRect();
|
||||
return { x, width };
|
||||
}),
|
||||
);
|
||||
for (const box of boxes) {
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings";
|
||||
import { visibleTestId } from "./plugin-sidebar-items";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function withSidebarViews(
|
||||
page: Page,
|
||||
run: (views: ReturnType<typeof sidebarViews>) => Promise<void>,
|
||||
) {
|
||||
const example = await copyPluginExample("sidebar-views");
|
||||
const entry = path.join(example.directory, "index.client.tsx");
|
||||
const original = await readFile(entry, "utf8");
|
||||
await writeFile(
|
||||
entry,
|
||||
original.replace(
|
||||
"const cleanups = [",
|
||||
'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [',
|
||||
),
|
||||
);
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-views-",
|
||||
title: "Sidebar view workspace",
|
||||
});
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await run(sidebarViews(page, workspace.workspaceId, client));
|
||||
} finally {
|
||||
await client.removePlugin("sidebar-views");
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await example.cleanup();
|
||||
}
|
||||
}
|
||||
function sidebarViews(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
) {
|
||||
const builtinRow = () =>
|
||||
visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
async function choose(title: string) {
|
||||
await openSidebarNavSettings(page);
|
||||
await page.getByRole("button", { name: /^View:/ }).click();
|
||||
await page.getByRole("menuitem", { name: title, exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible();
|
||||
await leaveSettings(page);
|
||||
}
|
||||
async function expectDefault() {
|
||||
await expect(builtinRow()).toBeVisible();
|
||||
}
|
||||
async function expectPlugin() {
|
||||
await expect(
|
||||
visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(builtinRow()).toHaveCount(0);
|
||||
await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
|
||||
}
|
||||
return {
|
||||
expectDefault,
|
||||
async chooseWorkspaces() {
|
||||
await choose("Example workspaces");
|
||||
await expectPlugin();
|
||||
},
|
||||
async exerciseExamples() {
|
||||
await choose("Example agents");
|
||||
await expect(
|
||||
visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }),
|
||||
).toBeVisible();
|
||||
await choose("Example nested agents");
|
||||
await expectPlugin();
|
||||
await choose("Example label folders");
|
||||
const slot = visibleTestId(page, "plugin-sidebar-view");
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toContain("Inbox");
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
await slot.getByRole("button", { name: "Move to Later", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Later"]);
|
||||
await client.setWorkspaceLabel({
|
||||
workspaceId,
|
||||
label: { name: "Unlabelled", color: "red" },
|
||||
assigned: true,
|
||||
});
|
||||
await client.setWorkspaceLabel({
|
||||
workspaceId,
|
||||
label: { name: "Later", color: "red" },
|
||||
assigned: false,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Unlabelled"]);
|
||||
await slot
|
||||
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
|
||||
.click();
|
||||
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
|
||||
?.labels,
|
||||
)
|
||||
.toEqual(["Inbox"]);
|
||||
},
|
||||
async exerciseTerminals() {
|
||||
await choose("Example terminals");
|
||||
const slot = visibleTestId(page, "plugin-sidebar-view");
|
||||
await slot
|
||||
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
|
||||
.click();
|
||||
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
|
||||
const workspace = (await client.fetchWorkspaces()).entries.find(
|
||||
(item) => item.id === workspaceId,
|
||||
)!;
|
||||
const created = await client.createTerminal(
|
||||
workspace.workspaceDirectory,
|
||||
"Sidebar terminal",
|
||||
undefined,
|
||||
{ workspaceId },
|
||||
);
|
||||
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
|
||||
try {
|
||||
await expect(
|
||||
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
|
||||
).toBeVisible();
|
||||
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
|
||||
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
|
||||
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
|
||||
await expect
|
||||
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
|
||||
.not.toContain(created.terminal!.id);
|
||||
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await client.killTerminal(created.terminal.id);
|
||||
}
|
||||
},
|
||||
async restoreDefault() {
|
||||
await choose("Default view");
|
||||
await expectDefault();
|
||||
},
|
||||
async recoverFromFailure() {
|
||||
await choose("Broken example");
|
||||
await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Use default view", exact: true }).click();
|
||||
await expectDefault();
|
||||
},
|
||||
async disableChosenView() {
|
||||
await client.disablePlugin("sidebar-views");
|
||||
await expectDefault();
|
||||
await openSidebarNavSettings(page);
|
||||
await expect(
|
||||
page.getByText("The selected view is unavailable or disabled. Showing Default view.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await leaveSettings(page);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -71,8 +71,23 @@ export async function focusWorkspaceRowWithKeyboard(
|
||||
await expect(row).toBeFocused();
|
||||
}
|
||||
|
||||
export async function scrollWorkspaceList(page: Page): Promise<void> {
|
||||
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const before = await list.evaluate((element) => element.scrollTop);
|
||||
await list.evaluate((element) =>
|
||||
element.scrollBy({ top: element.scrollTop > 0 ? -40 : 40, behavior: "instant" }),
|
||||
);
|
||||
await expect.poll(() => list.evaluate((element) => element.scrollTop)).not.toBe(before);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
const cards = page.getByTestId("workspace-hover-card");
|
||||
// Keyboard navigation can leave the previous card closing while the next opens.
|
||||
// Retry both checks together: another card may mount between count and visibility.
|
||||
await expect(async () => {
|
||||
expect(await cards.count()).toBe(1);
|
||||
expect(await cards.isVisible()).toBe(true);
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
|
||||
@@ -81,6 +96,35 @@ export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<voi
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
}
|
||||
|
||||
/** Scans the rows in the given order at about a user's pace, then rests on the last one. */
|
||||
export async function sweepPointerAcrossWorkspaceRows(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
): Promise<void> {
|
||||
const centers: Array<{ x: number; y: number }> = [];
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
const bounds = await row.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspaceId}`);
|
||||
centers.push({ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 });
|
||||
}
|
||||
const [first, ...rest] = centers;
|
||||
await page.mouse.move(first!.x, first!.y);
|
||||
for (const center of rest) {
|
||||
await page.mouse.move(center.x, center.y, { steps: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectOnlyWorkspaceHoverCardFor(
|
||||
page: Page,
|
||||
workspaceName: string,
|
||||
): Promise<void> {
|
||||
const cards = page.getByTestId("workspace-hover-card");
|
||||
await expect(cards).toHaveCount(1);
|
||||
await expect(cards.getByTestId("hover-card-workspace-name")).toHaveText(workspaceName);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
@@ -252,16 +296,9 @@ export async function expectMobileAgentSidebarHidden(page: Page): Promise<void>
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
|
||||
export async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("viewport size is required");
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await page.mouse.move(8, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(40, y, { steps: 4 });
|
||||
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
/** Opens navigation without moving focus to the header, including over a fullscreen editor. */
|
||||
export async function openMobileAgentSidebarWithKeyboard(page: Page): Promise<void> {
|
||||
await page.keyboard.press("ControlOrMeta+b");
|
||||
}
|
||||
|
||||
/** Drag from the right edge to open the compact Explorer over the agent. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user