chore(markdown): update the table fix to current main

Keep the contributor's table regression tests and source adaptation while
bringing in current browser tooling for supported CI runners.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: thsrite <thsrite@gmail.com>
Co-authored-by: Woosuk Nam <me@suk.kr>
This commit is contained in:
paseo-bot[bot]
2026-11-01 17:59:55 +00:00
co-authored by Okan YILDIRIM thsrite Woosuk Nam
1142 changed files with 83456 additions and 21243 deletions
@@ -42,6 +42,20 @@ jobs:
build_id: ${{ needs.build_android.outputs.build_id }}
profile: production
upload_android_screenshots:
name: Upload Google Play screenshots
needs: [build_android, submit_android]
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
bundle exec fastlane android upload_screenshots
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, distribute_ios]
@@ -0,0 +1,27 @@
name: Upload Google Play screenshots
# Standalone re-trigger for the release workflow's Google Play screenshot step.
# Replaces the en-US phone and tablet screenshots with the committed renders,
# without rebuilding or resubmitting the app.
on:
workflow_dispatch:
inputs:
android_version_code:
type: string
required: false
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
jobs:
upload_android_screenshots:
name: Upload Google Play screenshots
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
bundle exec fastlane android upload_screenshots
+1 -1
View File
@@ -102,7 +102,7 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
orientation: "portrait",
orientation: "default",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
+35
View File
@@ -59,3 +59,38 @@ describe("Android app config", () => {
30_000,
);
});
it("follows system rotation on Android", async () => {
const manifest = await resolveAndroidManifest();
const activity = manifest.application[0].activity.find(
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
);
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
}, 30_000);
it("allows all orientations on iPhone and iPad", async () => {
const { mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
try {
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
await compileModsAsync(exp, {
projectRoot: root,
introspect: true,
platforms: ["ios"],
assertMissingModProviders: false,
});
const plist = exp._internal.modResults.ios.infoPlist;
const orientations = [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
];
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -18,7 +18,9 @@ import {
expectComposerVisible,
cancelAgent,
composerLocator,
expectQueuedMessageButton,
fillComposerDraft,
holdSendButton,
sendDraftToQueue,
startRunningMockAgent,
submitMessage,
@@ -51,6 +53,7 @@ import {
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
userScrollsTimelineToHistoryStart,
scrollTimelineUntilOlderHistoryIsReachable,
} from "../support/helpers/timeline-pagination";
import {
@@ -984,7 +987,7 @@ test.describe("Agent message submission", () => {
}
});
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -1001,8 +1004,10 @@ test.describe("Agent message submission", () => {
await expectVisibleAgentSurfacesIdle(page);
await page.reload();
await expect(submittedPrompt).toHaveCount(0);
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
// The complete turn arrives on reload; scrolling mounts its oldest rows
// without requiring another page or an increase in total content height.
await userScrollsTimelineToHistoryStart(page);
await expect(submittedPrompt).toBeVisible();
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
} finally {
@@ -1290,6 +1295,49 @@ test.describe("Agent message submission", () => {
}
});
test("queues a draft by holding Send while a tap still steers", async ({ page }, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `hold-send-queue-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user holds Send.",
});
try {
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const queuedPrompt = "Queue this follow-up by holding Send.";
await test.step("holding Send queues the draft", async () => {
await fillComposerDraft(page, queuedPrompt);
await holdSendButton(page, "Send and steer");
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
await expectAgentReadyToInterrupt(page);
await expect(
page.getByTestId("user-message").filter({ hasText: queuedPrompt }),
).toHaveCount(0);
});
await test.step("tapping Send still steers", async () => {
const steeredPrompt = "Steer this turn with a tap.";
await fillComposerDraft(page, steeredPrompt);
await page.getByRole("button", { name: "Send and steer", exact: true }).click();
const request = await gate.waitForRequest();
expect(request.text).toBe(steeredPrompt);
expect(request.activeTurnBehavior).toBe("steer");
gate.accept();
await expect(
page.getByTestId("user-message").filter({ hasText: steeredPrompt }),
).toHaveCount(1);
await expectQueuedMessageButton(page);
});
} finally {
await agent.cleanup();
}
});
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
@@ -27,6 +27,10 @@ import {
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
expectToolCallGroupPositionPreserved,
rememberToolCallGroupPosition,
seedItemPagedToolGroupTimeline,
useChatDisplay,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
@@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => {
}
});
// Hosts paged by items can end a page inside a tool run. The older page extends the group
// the reader is on, which changes the row hosting it; the reader must stay with that group
// rather than land at history start and drain every older page.
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseToolCalls");
const agent = await seedItemPagedToolGroupTimeline();
try {
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
history.releasePage(1);
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({
page,
}) => {
@@ -0,0 +1,121 @@
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";
async function readFileLinkTooltip(page: Page): Promise<void> {
const link = page.getByRole("link", { name: "the guide", exact: true });
await link.hover();
await expect(page.getByRole("tooltip")).toContainText("guide.md");
}
async function leaveFileLink(page: Page): Promise<void> {
await page.mouse.move(5, 5);
await expect(page.getByRole("tooltip")).toHaveCount(0);
}
async function openLinkedFile(page: Page): Promise<void> {
await page.getByRole("link", { name: "the guide", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
async function returnToAgent(page: Page): Promise<void> {
await page.getByRole("button", { name: "Tooltip regression", exact: true }).click();
await expect(page.getByRole("link", { name: "the guide", exact: true })).toBeVisible();
await leaveFileLink(page);
}
async function switchToFileTab(page: Page): Promise<void> {
await page.getByRole("button", { name: "guide.md", exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
await leaveFileLink(page);
}
test("opening an assistant file link dismisses its tooltip over the file tab", async ({
page,
}, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "tooltip-5811-",
title: "Tooltip regression",
featureValues: {
mockStreamingAssistantResponse: "Read [the guide](./guide.md).",
mockStreamingAssistantIntervalMs: 5,
},
});
try {
await writeFile(
path.join(agent.cwd, "guide.md"),
"# Example guide\n\n" + "Plain paragraph.\n\n".repeat(150),
);
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Show the file link.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await readFileLinkTooltip(page);
await leaveFileLink(page);
await readFileLinkTooltip(page);
await openLinkedFile(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await switchToFileTab(page);
await returnToAgent(page);
await readFileLinkTooltip(page);
await leaveFileLink(page);
} finally {
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();
}
});
@@ -1415,12 +1415,14 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
if (localStorage.getItem(settingsKey)) {
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
if (settings.codeFontSize !== undefined) {
return;
}
localStorage.setItem(
settingsKey,
JSON.stringify({
...settings,
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
@@ -5,12 +5,16 @@ import { expectComposerVisible } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentFromCompactTrack,
seedSubagent,
} from "../support/helpers/subagents";
import {
backToList,
closeSheet,
expectNoSheetOpen,
expectOneSheetOpen,
expectOpenTabCount,
openSheetAsTab,
expectDeclinedDiscardPrompt,
@@ -52,6 +56,10 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Sheet child" },
);
const grandchild = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: child.id, title: "Sheet grandchild" },
);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expectOpenTabCount(page, 1);
@@ -79,12 +87,14 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await tapSheetBackdrop(page);
});
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await test.step("a subagent opens in place of the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await expect(childSheet).toBeVisible();
await expectSheetAtSnap(page, child.title);
await expectSubagentRowGone(page, child.id);
await expectOneSheetOpen(page);
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
// seeded agent has no context usage yet.
@@ -94,13 +104,51 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expectOpenTabCount(page, 1);
});
await test.step("closing the subagent's sheet uncovers the list", async () => {
await closeSheet(page, child.title);
await test.step("Back returns the sheet to the list", async () => {
await backToList(page, child.title);
await expectSubagentRowVisible(page, child.id);
await expectOneSheetOpen(page);
});
await test.step("Close closes the list with the subagent", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await closeSheet(page, child.title);
await expectSubagentRowGone(page, child.id);
await expectNoSheetOpen(page);
});
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 1);
});
await test.step("a nested subagent's parent box lands on its parent's sheet", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await openSubagentFromCompactTrack(page, grandchild.id, childSheet);
const grandchildSheet = sheet(page, grandchild.title);
await expectSheetAtSnap(page, grandchild.title);
await expect(grandchildSheet.getByTestId("parent-agent-callout")).toContainText(child.title);
await grandchildSheet.getByTestId("parent-agent-callout-open").click();
await expect(grandchildSheet).toHaveCount(0);
await expect(childSheet).toBeVisible();
await expectOneSheetOpen(page);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 1);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await openSheetAsTab(page, child.title);
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
@@ -113,7 +161,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
});
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
await test.step("once its tab closes, the subagent reopens in place of the list", async () => {
await page
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
@@ -131,7 +179,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await closeSheet(page, child.title);
await backToList(page, child.title);
await expectSubagentRowVisible(page, child.id);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
@@ -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,
@@ -365,6 +369,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,
}) => {
@@ -18,7 +18,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) {
@@ -498,6 +498,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 {
@@ -0,0 +1,87 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
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 fillComposerDraft(page, text);
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
async function pressInComposer(page: Page, key: string) {
await composerLocator(page).press(key);
}
test.describe("Composer recall", () => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
await session.cleanup();
}
});
test("a recalled slash message does not open the command picker", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-slash-",
title: "Composer recall slash",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "/tdd", 1);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
} finally {
await session.cleanup();
}
});
});
@@ -42,6 +42,14 @@ const test = base.extend<{ agent: MockAgentSession }>({
},
});
// The outer meter owns the accessible usage label; its SVG is decorative.
async function expectDecorativeContextRing(meter: Locator): Promise<void> {
const ring = meter.locator("svg");
await expect(ring).toHaveAttribute("aria-hidden", "true");
await expect(ring).not.toHaveAttribute("accessibilityElementsHidden");
await expect(ring).not.toHaveAttribute("importantForAccessibility");
}
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
@@ -100,6 +108,7 @@ test.describe("context window meter", () => {
const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
await expectDecorativeContextRing(meter);
const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1);
@@ -1,14 +1,23 @@
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,
restartDaemonInSettings,
expectDaemonRestartComplete,
expectDaemonRestartFailed,
plantUnclosableTerminalProgram,
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 ({
@@ -29,6 +38,57 @@ test("settings restart waits for a replacement worker while retaining the superv
}
});
test("settings restart keeps a terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
const terminalId = await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toContain(terminalId);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals closes the terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toEqual([]);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals shows the processes it could not close", async ({
page,
}, testInfo) => {
test.skip(process.platform === "win32", "The unclosable program is a POSIX zombie");
const daemon = await startIsolatedHostDaemon(randomUUID());
let program: Awaited<ReturnType<typeof plantUnclosableTerminalProgram>> | null = null;
try {
program = await plantUnclosableTerminalProgram(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartFailed(page, `pid ${program.pid}`);
await page.screenshot({
path: testInfo.outputPath("restart-cleanup-failed.png"),
fullPage: true,
});
} finally {
program?.release();
await daemon.close();
}
});
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
page,
}) => {
@@ -64,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,6 +1,7 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
import { pullRequestCorpus } from "../../src/git/diff-document/benchmark-corpus";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
@@ -296,8 +297,187 @@ diffPerfDescribe("Diff canvas performance", () => {
await repo.cleanup().catch(() => undefined);
}
});
test("toggling line wrap on a pull-request-sized diff", async ({ page }, testInfo) => {
test.setTimeout(300_000);
const corpus = await createPullRequestCorpusRepository();
const client = await connectSeedClient();
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: corpus.repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create wrap performance workspace");
}
await configureUnwrappedUnifiedDiff(page);
await instrumentDiffLatency(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
const openStartedAt = performance.now();
await openChangesPanel(page, 90_000);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
const open = {
readyMs: Math.round(performance.now() - openStartedAt),
...(await readDiffDocumentRenders(page)),
};
const toggles: WrapToggleMeasurement[] = [];
for (const name of [
"wrap on (cold)",
"wrap off (cold)",
"wrap on (warm)",
"wrap off (warm)",
]) {
toggles.push(await measureWrapToggle(page, name));
}
const unwrappedScroll = await continuouslyScrollCanvas(page);
toggles.push(await measureWrapToggle(page, "wrap on (warm, scrolled)"));
const wrappedScroll = await continuouslyScrollCanvas(page);
const report = {
scenario: "wrap-toggle",
sourceFiles: corpus.fileCount,
sourceLines: corpus.lineCount,
open,
toggles,
scroll: {
unwrapped: summarizeScroll(unwrappedScroll),
wrapped: summarizeScroll(wrappedScroll),
},
};
await attachPerformanceReport(testInfo, report);
console.log(`[perf] Wrap toggle: ${JSON.stringify(report)}`);
const [wrapOn, wrapOff] = toggles;
expect(wrapOn!.scrollHeightAfter).toBeGreaterThan(wrapOn!.scrollHeightBefore);
expect(wrapOff!.scrollHeightAfter).toBe(wrapOn!.scrollHeightBefore);
} finally {
await client.close().catch(() => undefined);
await corpus.repo.cleanup().catch(() => undefined);
}
});
});
interface WrapToggleMeasurement {
name: string;
/** Pointer down on the toolbar button to two painted frames after the document resized. */
latencyMs: number;
scrollHeightBefore: number;
scrollHeightAfter: number;
longestTaskMs: number;
longTaskTotalMs: number;
diffDocumentCommits: number;
diffDocumentRenderMs: number;
}
interface LongTask {
startTime: number;
duration: number;
}
interface DiffLatencyScope {
__PASEO_RENDER_PROFILE_ENABLED__?: boolean;
__PASEO_RENDER_PROFILE__?: Array<{ id: string; actualDuration: number }>;
__PASEO_RESET_RENDER_PROFILE__?: () => void;
__PASEO_LONG_TASKS__?: LongTask[];
__PASEO_WRAP_TOGGLE_AT__?: number;
}
async function createPullRequestCorpusRepository() {
const files = pullRequestCorpus();
const repo = await createTempGitRepo("diff-wrap-perf-", {
files: files.map((file) => ({ path: file.path, content: "baseline\n" })),
});
for (const file of files) {
await writeFile(path.join(repo.path, file.path), `${file.lines.join("\n")}\n`);
}
return {
repo,
fileCount: files.length,
lineCount: files.reduce((count, file) => count + file.lines.length, 0),
};
}
async function instrumentDiffLatency(page: Page): Promise<void> {
await page.addInitScript(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RENDER_PROFILE_ENABLED__ = true;
scope.__PASEO_LONG_TASKS__ = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
scope.__PASEO_LONG_TASKS__?.push({ startTime: entry.startTime, duration: entry.duration });
}
}).observe({ type: "longtask", buffered: true });
});
}
async function readDiffDocumentRenders(
page: Page,
): Promise<{ diffDocumentCommits: number; diffDocumentRenderMs: number }> {
return page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const samples = (scope.__PASEO_RENDER_PROFILE__ ?? []).filter(
(sample) => sample.id === "DiffDocument",
);
return {
diffDocumentCommits: samples.length,
diffDocumentRenderMs: Math.round(
samples.reduce((total, sample) => total + sample.actualDuration, 0),
),
};
});
}
async function measureWrapToggle(page: Page, name: string): Promise<WrapToggleMeasurement> {
const scroll = page.getByTestId("git-diff-scroll");
const scrollHeightBefore = await scroll.evaluate((element) => element.scrollHeight);
await page.evaluate(() => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
scope.__PASEO_RESET_RENDER_PROFILE__?.();
scope.__PASEO_LONG_TASKS__ = [];
scope.__PASEO_WRAP_TOGGLE_AT__ = undefined;
document.addEventListener(
"pointerdown",
() => {
scope.__PASEO_WRAP_TOGGLE_AT__ = performance.now();
},
{ capture: true, once: true },
);
});
await page.getByTestId("changes-toggle-wrap-lines").click();
const measured = await scroll.evaluate(async (element, before) => {
const scope = globalThis as typeof globalThis & DiffLatencyScope;
const deadline = performance.now() + 240_000;
while (element.scrollHeight === before && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const doneAt = performance.now();
// Long task entries are delivered after the task ends; give the observer a turn.
await new Promise((resolve) => setTimeout(resolve, 50));
const startedAt = scope.__PASEO_WRAP_TOGGLE_AT__ ?? doneAt;
const longTasks = (scope.__PASEO_LONG_TASKS__ ?? []).filter(
(task) => task.startTime + task.duration >= startedAt && task.startTime <= doneAt,
);
return {
latencyMs: Math.round(doneAt - startedAt),
scrollHeightAfter: element.scrollHeight,
longestTaskMs: Math.round(Math.max(0, ...longTasks.map((task) => task.duration))),
longTaskTotalMs: Math.round(longTasks.reduce((total, task) => total + task.duration, 0)),
};
}, scrollHeightBefore);
return { name, scrollHeightBefore, ...measured, ...(await readDiffDocumentRenders(page)) };
}
function summarizeScroll(report: Awaited<ReturnType<typeof continuouslyScrollCanvas>>) {
const { frameSignatures: _frameSignatures, ...summary } = report;
return summary;
}
async function measureAdditionalPerformanceScenario(
page: Page,
cdp: CDPSession,
@@ -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(
@@ -229,6 +229,143 @@ test("captures the desktop import sheet and command-center entry", async ({ page
});
});
test("large import lists keep mounted rows bounded and remain scrollable", async ({
page,
}, testInfo) => {
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
await mkdir(fixtureDirectory, { recursive: true });
try {
const newest = Date.now();
for (let index = 0; index < 200; index += 1) {
const id = `virtualized-${String(index).padStart(3, "0")}`;
const file = path.join(fixtureDirectory, `${id}.jsonl`);
await writeFile(
file,
`${JSON.stringify({
type: "user",
sessionId: id,
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
message: {
role: "user",
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
},
})}\n`,
);
const timestamp = new Date(newest - index * 10);
await utimes(file, timestamp, timestamp);
}
for (const width of [1280, 390]) {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
await flow.openGlobally();
const rows = page.locator('[data-testid^="import-session-session-"]');
const scrollToEnd = async (end = true) => {
// Scroll the body's real overflow element. Virtual rows need not exist yet.
await rows.first().evaluate((row, atEnd) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
element = element.parentElement;
}
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = atEnd ? element.scrollHeight : 0;
element.dispatchEvent(new Event("scroll"));
}, end);
};
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
const loadMore = page.getByTestId("import-session-load-more");
await expect(async () => {
await scrollToEnd();
await expect(loadMore).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await loadMore.click();
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
}
await expect(async () => {
await scrollToEnd();
await expect(
page.getByTestId("import-session-session-claude-virtualized-199"),
).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await expect.poll(() => rows.count()).toBeLessThan(100);
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
if (width === 1280) {
const row = (index: number) =>
page.getByTestId(
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
);
await expect(async () => {
await scrollToEnd(false);
await expect(row(0)).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await row(0).focus();
for (let index = 1; index <= 30; index += 1) {
await page.keyboard.press("Tab");
await expect(row(index)).toBeFocused();
}
for (let index = 29; index >= 15; index -= 1) {
await page.keyboard.press("Shift+Tab");
await expect(row(index)).toBeFocused();
}
const list = page.getByTestId("import-session-list");
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
const listener = await list.evaluateHandle((node, eventName) => {
const promise = new Promise<void>((resolve) => {
node.addEventListener(eventName, () => resolve(), { once: true });
});
return { promise };
}, event);
try {
await page.keyboard.press(key);
await listener.evaluate((value) => value.promise);
} finally {
await listener.dispose();
}
};
const beforePageDown = await list.evaluate((node) => node.scrollTop);
await scrollWithKey("scroll", "PageDown");
await expect
.poll(() => list.evaluate((node) => node.scrollTop))
.toBeGreaterThan(beforePageDown);
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(15)).toBeFocused();
await expect(row(199)).toBeInViewport();
// Home must remain at the first row after the native page scroll ends.
await scrollWithKey("scrollend", "Home");
await expect(row(0)).toBeInViewport();
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(199)).toBeInViewport();
await row(199).focus();
await scrollWithKey("scroll", "PageUp");
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
await scrollWithKey("scrollend", "End");
await expect(row(199)).toBeInViewport();
await expect(row(199)).toBeFocused();
await testInfo.attach("keyboard-scroll-owner", {
body: JSON.stringify(
await list.evaluate((node) => ({
focusedRow: document.activeElement?.getAttribute("data-testid"),
scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
})),
),
contentType: "application/json",
});
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
}
await flow.close();
}
} finally {
await rm(fixtureDirectory, { recursive: true, force: true });
}
});
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const outputPath = testInfo.outputPath(name);
await page.screenshot({ path: outputPath, fullPage: true });
@@ -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 });
@@ -0,0 +1,100 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// The creation response carries the descriptor captured when the workspace was provisioned. With an
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
// late response must not put the old, unpinned row back.
function pinnedRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
let createdId: string | undefined;
await expect
.poll(
async () => {
const { entries } = await seeded.client.fetchWorkspaces({
filter: { projectId: seeded.projectId },
});
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
return createdId;
},
{ timeout: 30_000 },
)
.toBeTruthy();
return createdId!;
}
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
await page.evaluate((rowTestId) => {
const state = { missingFrames: 0, stopped: false };
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
const sample = () => {
if (state.stopped) return;
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, testId);
return () =>
page.evaluate(() => {
const state = (
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
).__pinnedRowWatch;
state.stopped = true;
return state.missingFrames;
});
}
test.describe("New workspace live pin", () => {
test("keeps a pin made before the creation response arrives", async ({ page }) => {
test.setTimeout(120_000);
const creation = await delayBrowserAgentCreatedStatus(page);
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, seeded);
await submitNewWorkspacePrompt(page, "Pin me while I start");
await creation.waitForCreateRequest();
// The response is held here, carrying the descriptor from before the pin.
await creation.waitForDelayedCreatedStatus();
const workspaceId = await findCreatedWorkspaceId(seeded);
const rowTestId = pinnedRowTestId(workspaceId);
await seeded.client.setWorkspacePinned(workspaceId, true);
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
const stopWatching = await watchPinnedRow(page, rowTestId);
creation.release();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
// Leave time for the released response to be merged and rendered.
await page.waitForTimeout(1_000);
expect(await stopWatching()).toBe(0);
await expect(pinnedRow).toBeVisible();
} finally {
creation.release();
await seeded.cleanup();
}
});
});
@@ -1,29 +1,90 @@
import { randomUUID } from "node:crypto";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import {
focusTerminalSurface,
typeInTerminal,
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect } from "../support/fixtures";
import { metroTest as test, expect, type Page } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectTimelinePromptVisible,
expectFoldedTurnAtReadingPosition,
expectOldestLoadedTurnPartial,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
expectTimelinePromptVisible,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
interface PublishedDaemonAgent {
serverId: string;
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
agent: MockAgentWorkspace;
/** Register the host in the browser and apply app settings before the first load. */
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
close(): Promise<void>;
}
async function startPublishedDaemonAgent(options: {
publishedVersion: string;
serverIdPrefix: string;
repoPrefix: string;
title: string;
}): Promise<PublishedDaemonAgent> {
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: options.publishedVersion,
});
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
return {
serverId,
daemon,
agent,
async seedBrowser(page, appSettings) {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences, settings }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
},
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
);
},
async close() {
await agent.cleanup();
await daemon.close();
},
};
}
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
@@ -37,104 +98,84 @@ test("does not repeat an assistant block when the current app paginates a publis
observationSockets.add(socket);
});
});
const workspace = await seedWorkspace({
const published = await startPublishedDaemonAgent({
publishedVersion: "0.2.5",
serverIdPrefix: "srv_old_pagination_",
repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
});
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Published daemon pagination regression",
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
const { agent, serverId } = published;
try {
for (let index = 0; index < 40; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
await agent.client.waitForFinish(agent.agentId, 15_000);
await published.seedBrowser(page);
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
expect(observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => {
await daemon.restart();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectComposerEditable(page);
await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
await test.step("reconnect the browser and reload the old daemon history", async () => {
observationSockets.clear();
await page.reload();
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
await expect.poll(() => observationSockets.size).toBe(1);
});
await test.step("open a terminal and receive its output", async () => {
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId);
});
} finally {
await agent.cleanup();
await daemon.close();
await published.close();
}
});
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
// starts mid-turn and only folds once the next page completes it; the reader must stay
// with that turn instead of landing at history start and draining every older page.
test("keeps the reader on the turn that folds when an older item page completes it", async ({
page,
}) => {
test.setTimeout(240_000);
const published = await startPublishedDaemonAgent({
publishedVersion: "0.13.0-beta.2",
serverIdPrefix: "srv_old_fold_",
repoPrefix: "timeline-old-daemon-fold-",
title: "Published daemon folded turn pagination",
});
const { agent, serverId } = published;
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
try {
for (const prompt of prompts) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 30_000);
}
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
const history = await holdOlderHistoryPages(page, agent, {
daemonPort: published.daemon.port,
});
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, prompts.at(-1)!);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
history.releasePage(1);
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
await history.expectSettledWithRequestedPages(1);
} finally {
await published.close();
}
});
@@ -1,3 +1,13 @@
import {
withRetainedPlugin,
editRetainedDraft,
expectRetainedDraft,
expectStoredPluginTitle,
hideRetainedPreferences,
showRetainedPreferences,
expectChangedPlugin,
expectRemovedPlugin,
} from "../support/helpers/plugin-reconnect";
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -188,3 +198,51 @@ test("plugin content and host labels follow app language without losing an open
test.setTimeout(180_000);
await verifyPluginTranslations(page, info);
});
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
page,
}) => {
test.setTimeout(180_000);
await withRetainedPlugin(page, async (probe) => {
await probe.open();
await editRetainedDraft(page, "Connected draft", 0);
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
await probe.disconnect();
await expectRetainedDraft(page, "Connected draft", 1);
await editRetainedDraft(page, "Offline draft", 1);
await probe.changeStoredTitle("Saved by another client");
await expectStoredPluginTitle(page, "Agent monitor");
await probe.reconnect();
await expectStoredPluginTitle(page, "Saved by another client");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("inactive settings refresh without replacing the installation", async () => {
await hideRetainedPreferences(page);
await probe.disconnect();
await probe.changeStoredTitle("Saved while preferences hidden");
await probe.reconnect();
await showRetainedPreferences(page);
await expectStoredPluginTitle(page, "Saved while preferences hidden");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("changed code replaces the retained installation after reconnect", async () => {
await probe.disconnect();
await probe.changeCode();
await expectRetainedDraft(page, "Offline draft", 2);
await probe.reconnect();
await expectChangedPlugin(page);
await editRetainedDraft(page, "New code draft", 0);
});
await test.step("removal is reconciled after reconnect", async () => {
await probe.disconnect();
await probe.remove();
await expectRetainedDraft(page, "New code draft", 1);
await probe.reconnect();
await expectRemovedPlugin(page);
});
});
});
@@ -1,4 +1,5 @@
import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
withTimelinePlugin,
viewInlineThinkingExample,
@@ -6,6 +7,8 @@ import {
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
@@ -13,13 +16,24 @@ for (const width of [1100, 390]) {
page,
}, info) => {
await page.setViewportSize({ width, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent);
await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
const recovery = trackPluginChatRecovery(page);
gate.holdTimelineResponses(agent.agentId);
await page.reload({ waitUntil: "domcontentloaded" });
await gate.waitForHeldTimelineResponse();
await recovery.expectCatalogWithoutRetransmission();
gate.releaseHeldTimelineResponses();
await expectWholeCompletedCard(page);
await recovery.expectRecoveredWithoutRetransmission();
recovery.reset();
await reconnectPluginChat(page, gate);
await recovery.expectRecoveredWithoutRetransmission();
await expectWholeCompletedCard(page);
});
});
@@ -204,19 +204,26 @@ test.describe("Projects settings", () => {
}) => {
const repo = await createTempGitRepo("projects-settings-empty-");
const client = await connectSeedClient();
let projectId: string | null = null;
let projectViewKey: string | null = null;
try {
await gotoAppShell(page);
projectId = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectId);
projectViewKey = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectViewKey);
await expectProjectSettingsFormVisible(page);
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
} finally {
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
if (projectViewKey) {
const project = (await client.listProjects()).projects.find(
(entry) => entry.projectKey === projectViewKey,
);
expect(project).toBeDefined();
await client.removeProject(project!.projectId);
expect(
(await client.listProjects()).projects.map((entry) => entry.projectKey),
).not.toContain(projectViewKey);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
@@ -1,5 +1,7 @@
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -214,3 +216,66 @@ test.describe("provider settings overlay stack", () => {
}
});
});
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await page.setViewportSize({ width, height: 900 });
await page.getByRole("button", { name: /Select model/ }).click();
if (width < 600) {
await page.getByTestId("agent-controls-model").click();
} else {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
await page.getByTestId("model-provider-picker-timeout").click();
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
}
async function expectErrorHasSideGutters(page: Page) {
const error = page.getByText(/Timed out refreshing Pi timeout/);
const bounds = await error.evaluate((element) => {
const text = element.getBoundingClientRect();
const state = element.parentElement!.getBoundingClientRect();
return { left: text.left - state.left, right: state.right - text.right };
});
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
}
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
catalogRefreshTimeoutMs: 2000,
providers: {
"picker-timeout": {
extends: "pi",
label: "Pi timeout",
enabled: true,
command: [
process.execPath,
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
"--stall-model-discovery",
],
},
},
},
},
});
test.describe("model discovery timeout", () => {
for (const width of [1200, 390]) {
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
page,
withWorkspace,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 900 });
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
await openTimedOutProvider(page, workspace.workspaceId, width);
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
await expectErrorHasSideGutters(page);
});
}
});
@@ -136,7 +136,8 @@ test.describe("real provider subagent timelines", () => {
panel.getByText("Start chatting with this agent...", { exact: true }),
).toHaveCount(0);
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
// A provider subagent has no composer; its callout is the way back to the parent.
await page.getByTestId("parent-agent-callout-open").click();
await expect(
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
).toBeVisible({ timeout: 60_000 });
@@ -1,6 +1,6 @@
import { fork } from "node:child_process";
import path from "node:path";
import { expect } from "@playwright/test";
import { expect, type BrowserContext, type Locator, type Page } from "@playwright/test";
import { z } from "zod";
import { test } from "../support/fixtures";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
@@ -34,8 +34,218 @@ const ROLLOUT_QUESTION = "Which rollout should we use?";
const SUCCESS_QUESTION = "What success criteria should we use?";
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?";
const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?";
const LINKED_QUESTION = "Sign in at https://example.com/login, then follow the setup guide. Done?";
function selectedText(page: Page) {
return page.evaluate(() => window.getSelection()?.toString().trim());
}
const CHECKS_QUESTION = "Which checks should run?";
const DONE_DESCRIPTION = "Logs are at https://example.com/logs if sign-in fails.";
const LINT_DESCRIPTION = "Rules: https://example.com/lint";
async function openLinkedQuestions(page: Page, context: BrowserContext) {
await context.route("https://example.com/**", (route) =>
route.fulfill({ status: 200, body: "ok" }),
);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-option-links-",
title: "Question option links e2e",
initialPrompt: "Emit synthetic questions with links.",
});
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
return { session, card: page.getByTestId("question-form-card").first() };
}
async function dragAcross(page: Page, target: Locator) {
const bounds = await target.boundingBox();
if (!bounds) throw new Error("Text is not visible");
const y = bounds.y + bounds.height / 2;
await page.mouse.move(bounds.x + 1, y);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
await page.mouse.up();
}
async function clickLinkAndExpectPage(context: BrowserContext, link: Locator, url: string) {
const popup = context.waitForEvent("page");
await link.click();
await expect.poll(async () => (await popup).url()).toBe(url);
}
async function expectChecked(option: Locator, checked: boolean) {
await expect(option).toHaveAttribute("aria-checked", String(checked));
}
test.describe("Question option text", () => {
test("links in an option label or description open without answering", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
const done = card.getByRole("radio", { name: "Done" });
const notYet = card.getByRole("radio", { name: "Not yet" });
await clickLinkAndExpectPage(
context,
notYet.getByRole("link", { name: "status" }),
"https://example.com/status",
);
await clickLinkAndExpectPage(
context,
done.getByRole("link", { name: "https://example.com/logs" }),
"https://example.com/logs",
);
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(done, false);
await expectChecked(notYet, false);
} finally {
await session.cleanup();
}
});
test("selecting description text never answers, and the label still does", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
const done = card.getByRole("radio", { name: "Done" });
const description = done.getByTestId("question-form-option-description");
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 expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await openQuestion(page, { index: 1, total: 2 });
await expectChecked(done, true);
} finally {
await session.cleanup();
}
});
test("double-clicking a single-select label answers once", 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: 2, total: 2, question: CHECKS_QUESTION });
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
await expectChecked(card.getByRole("checkbox", { name: "Typecheck" }), false);
await openQuestion(page, { index: 1, total: 2 });
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
} finally {
await session.cleanup();
}
});
test("multi-select description links and selection leave the option unchanged", 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 expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
const lint = card.getByRole("checkbox", { name: "Lint" });
const typecheck = card.getByRole("checkbox", { name: "Typecheck" });
const description = lint.getByTestId("question-form-option-description");
await clickLinkAndExpectPage(
context,
description.getByRole("link", { name: "https://example.com/lint" }),
"https://example.com/lint",
);
await expectChecked(lint, false);
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 typecheck.getByText("Typecheck", { exact: true }).dblclick();
await expectChecked(typecheck, true);
await lint.getByText("Lint", { exact: true }).click();
await expectChecked(lint, true);
} finally {
await session.cleanup();
}
});
});
test.describe("Question prompt pagination", () => {
test("question links open without answering, and the question text is selectable", async ({
page,
context,
}) => {
test.setTimeout(180_000);
await context.route("https://example.com/**", (route) =>
route.fulfill({ status: 200, body: "ok" }),
);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-links-",
title: "Question links e2e",
initialPrompt: "Emit synthetic questions with links.",
});
try {
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
const card = page.getByTestId("question-form-card").first();
const question = card.getByTestId("question-form-current-question");
await expect(question).toHaveText(LINKED_QUESTION);
await expect(question.getByRole("link", { name: "https://example.com/login" })).toBeVisible();
await expect(question.getByRole("link", { name: "the setup guide" })).toBeVisible();
const bounds = await question.boundingBox();
if (!bounds) throw new Error("Question text is not visible");
const y = bounds.y + bounds.height / 2;
await page.mouse.move(bounds.x + 1, y);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
await page.mouse.up();
await expect.poll(() => selectedText(page)).toBe(LINKED_QUESTION);
const popup = context.waitForEvent("page");
await question.getByRole("link", { name: "the setup guide" }).click();
await expect.poll(async () => (await popup).url()).toBe("https://example.com/setup");
await expect(page.getByTestId("question-form-card")).toHaveCount(1);
for (const option of ["Done", "Not yet"]) {
await expect(card.getByRole("radio", { name: option })).toHaveAttribute(
"aria-checked",
"false",
);
}
} finally {
await session.cleanup();
}
});
test("shows one question at a time with numbered navigation", async ({ page }) => {
test.setTimeout(180_000);
@@ -1,3 +1,7 @@
import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
@@ -25,6 +29,12 @@ import {
removeHostFromHostPage,
addDirectHostFromSettings,
expectHostOnlineWithoutError,
expectIdleAgentRelease,
editRestartRecoveryPrompt,
expectRestartRecoveryEnabled,
expectRestartRecoveryPrompt,
setRestartRecoveryEnabled,
setIdleAgentRelease,
} from "../support/helpers/settings";
test.describe("Settings host page", () => {
@@ -137,6 +147,100 @@ test.describe("Settings host page", () => {
}
});
test("idle agent release changes live on one host and stays saved without restarting", async ({
page,
}) => {
test.setTimeout(120_000);
const primaryServerId = getServerId();
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_agent_release");
const client = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "idle-release-settings",
port: daemon.port,
});
const originalPid = (await client.getDaemonStatus()).pid;
const expectLiveSavedSetting = async (enabled: boolean) => {
const saved = JSON.parse(await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"));
expect(saved.agents.idleRuntimeCleanup).toBe(enabled);
expect((await client.getDaemonConfig()).config.idleRuntimeCleanup).toBe(enabled);
expect((await client.reloadDaemonConfig()).restartRequiredPaths).toEqual([]);
expect((await client.getDaemonStatus()).pid).toBe(originalPid);
};
const hosts = [
{
serverId: primaryServerId,
label: TEST_HOST_LABEL,
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: daemon.serverId,
label: "Idle release host",
endpoint: `127.0.0.1:${daemon.port}`,
},
];
const openHostAgentsInFreshApp = async (serverId: string) => {
await seedSavedSettingsHosts(page, hosts);
await page.reload();
await openSettings(page);
await selectSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
};
try {
await test.step("idle agent release starts on and turns off", async () => {
await openHostAgentsInFreshApp(daemon.serverId);
await expectIdleAgentRelease(page, true);
await setIdleAgentRelease(page, false);
await expectLiveSavedSetting(false);
});
await test.step("a fresh app reads the saved setting from the same running host", async () => {
await openHostAgentsInFreshApp(daemon.serverId);
await expectIdleAgentRelease(page, false);
await expectLiveSavedSetting(false);
await setIdleAgentRelease(page, true);
await expectLiveSavedSetting(true);
await setIdleAgentRelease(page, false);
});
await test.step("other hosts keep their own setting", async () => {
await selectSettingsHost(page, primaryServerId);
await openHostSection(page, primaryServerId, "agents");
await expectIdleAgentRelease(page, true);
await expectLiveSavedSetting(false);
});
} finally {
await client.close();
await daemon.close();
}
});
test("a failed idle release save stays visible and retry applies it live", async ({ page }) => {
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_release_failure");
const configPath = path.join(daemon.paseoHome, "config.json");
try {
await seedSavedSettingsHosts(page, [
{ serverId: daemon.serverId, label: "Failure host", endpoint: `127.0.0.1:${daemon.port}` },
]);
await page.reload();
await openSettings(page);
await openHostSection(page, daemon.serverId, "agents");
await expectIdleAgentRelease(page, true);
// A real filesystem failure, with the daemon and RPC path intact.
await rename(configPath, `${configPath}.saved`);
await mkdir(configPath);
await page.getByRole("switch", { name: "Release idle agents" }).click();
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toContainText(
"Failed to read",
);
await expectIdleAgentRelease(page, true);
await rm(configPath, { recursive: true });
await rename(`${configPath}.saved`, configPath);
await setIdleAgentRelease(page, false);
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toHaveCount(0);
} finally {
await daemon.close();
}
});
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
@@ -152,3 +256,20 @@ test.describe("Settings host page", () => {
await expectHostActionCards(page, serverId);
});
});
test("restart recovery host controls persist the opt-out 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, true);
await expectRestartRecoveryPrompt(page, "Resume working.");
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
await setRestartRecoveryEnabled(page, false);
await page.reload();
await expectRestartRecoveryEnabled(page, false);
await setRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
@@ -6,7 +6,10 @@ import {
addDirectHostFromSettings,
clickSettingsBackToWorkspace,
openSettingsHostSection,
openSettingsSection,
selectSettingsHost,
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
@@ -44,6 +47,14 @@ test.describe("Settings host selection", () => {
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
await selectSettingsHost(page, getServerId());
await openSettingsSection(page, "appearance");
await openSettingsHostSection(page, getServerId(), "connections");
await clickSettingsBackToWorkspace(page);
await openSettings(page);
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
} finally {
await remoteWorkspace.cleanup().catch(() => undefined);
await remoteDaemon.close().catch(() => undefined);
@@ -10,6 +10,15 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
closeCompactSettings,
scrollSettingsSidebarToEnd,
revealSettingsSidebarSection,
expectSettingsSidebarScrollSettled,
scrollSettingsSidebarUp,
changeSettingsInterfaceFontSize,
selectSettingsHostSectionsRapidly,
expectSettingsSidebarScrollOffset,
scrollSettingsDetailDown,
expectSettingsDetailScrollOffset,
openSettingsSection,
expectSettingsHeader,
openAddHostFlow,
@@ -29,6 +38,9 @@ import {
fillDirectHostUri,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostSslDisabled,
fillDirectHostAddress,
toggleDirectHostSsl,
expectDirectHostUriValue,
expectDirectHostUriHidden,
expectDiagnosticsContent,
@@ -43,6 +55,8 @@ import {
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -52,7 +66,167 @@ async function openWorkspace(
await expect(page.getByTestId("menu-button")).toBeVisible();
}
test.describe("Settings sidebar scroll position", () => {
test.use({ viewport: { width: 1000, height: 500 } });
test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await scrollSettingsSidebarToEnd(page);
await page.setViewportSize({ width: 1000, height: 1100 });
await expectSettingsSidebarScrollOffset(page, 0);
await page.setViewportSize({ width: 1000, height: 500 });
await openSettingsSection(page, "appearance");
await expectSettingsSidebarScrollOffset(page, 0);
await scrollSettingsSidebarToEnd(page);
await clickSettingsBackToWorkspace(page);
await openSettings(page);
await expectSettingsSidebarScrollOffset(page, 0);
});
test("keeps the sidebar position through interface font changes", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
const offset = await scrollSettingsSidebarToEnd(page);
await changeSettingsInterfaceFontSize(page, 20);
await expectSettingsSidebarScrollOffset(page, offset);
await openSettingsHostSection(page, getServerId(), "providers");
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 ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
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);
});
});
test.describe("Settings sidebar navigation", () => {
test("opening Settings closes the composer model selector until it is opened again", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
});
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
page,
}, testInfo) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-existing-model-",
title: "Settings model selector",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
});
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -77,6 +251,28 @@ test.describe("Settings sidebar navigation", () => {
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("host choice survives app sections and host screen replacements", async ({ page }) => {
const primaryServerId = getServerId();
const secondaryServerId = "srv_e2e_settings_secondary";
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
await seedSavedSettingsHosts(page, [
{ serverId: primaryServerId, label: "First horse", endpoint },
{ serverId: secondaryServerId, label: "Stable horse", endpoint },
]);
await gotoAppShell(page);
await openSettings(page);
await selectSettingsHost(page, secondaryServerId);
await openSettingsHostSection(page, secondaryServerId, "connections");
await openSettingsSection(page, "appearance");
await expectSettingsHostPickerLabel(page, "Stable horse");
await openSettingsSection(page, "general");
await expectSettingsHostPickerLabel(page, "Stable horse");
await openSettingsHostSection(page, secondaryServerId, "connections");
await selectSettingsHost(page, primaryServerId);
await openSettingsSection(page, "about");
await expectSettingsHostPickerLabel(page, "First horse");
});
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
@@ -91,6 +287,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);
@@ -147,6 +353,42 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-tab-switcher-",
title: "Compact tab switcher",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
await switcher.click();
await expect(newAgent).toBeInViewport();
// Navigate without dismissing the sheet through its backdrop first.
await page
.getByTestId("menu-button")
.filter({ visible: true })
.first()
.dispatchEvent("click");
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
await expectAppRoute(page, buildSettingsRoute());
await expect(newAgent).toHaveCount(0);
await closeCompactSettings(page);
await expect(switcher).toBeVisible();
await expect(newAgent).toHaveCount(0);
await switcher.click();
await expect(newAgent).toBeInViewport();
} finally {
await workspace.cleanup();
}
});
test("opens compact app and host details and returns through the settings list", async ({
page,
}) => {
@@ -223,6 +465,15 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsSection(page, "appearance");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
await openSettingsHostSection(page, secondaryServerId, "connections");
await goBackInSettings(page);
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
});
test("removing the last active host returns to welcome after settings closes", async ({
@@ -241,3 +492,76 @@ test.describe("Settings — compact master-detail", () => {
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
});
});
async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true });
const settings = page
.getByRole("button", { name: "Settings", exact: true })
.filter({ visible: true });
await expect(history).toBeVisible();
await expect(settings).toBeVisible();
return {
width,
history: await history.evaluate((el) => el.getBoundingClientRect().height),
settings: await settings.evaluate((el) => el.getBoundingClientRect().height),
};
}
async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const sidebar = page.getByTestId("settings-sidebar");
const detail = page.getByTestId("settings-detail-pane");
await expect(sidebar).toBeVisible();
await expect(detail).toBeVisible();
return {
width,
sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width),
detail: await detail.evaluate((el) => el.getBoundingClientRect().width),
};
}
async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) {
const wide = await measureDockedNavigation(page, 951);
for (const width of [600, 669, 719, 720]) {
const narrow = await measureDockedNavigation(page, width);
expect
.soft(narrow.history, `History keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.history, 0);
expect
.soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.settings, 0);
}
}
async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) {
const reference = await measureSettingsSplit(page, 720);
for (const width of [600, 669, 719]) {
const narrow = await measureSettingsSplit(page, width);
expect
.soft(narrow.detail, `Settings preserves its detail budget at ${width}`)
.toBeGreaterThanOrEqual(reference.detail);
expect
.soft(narrow.sidebar, `Settings navigation yields width at ${width}`)
.toBeLessThan(reference.sidebar);
}
const wide = await measureSettingsSplit(page, 951);
expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0);
expect(wide.detail).toBeGreaterThan(reference.detail);
}
test("docked navigation keeps its density and Settings preserves reading width", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectConsistentDockedNavigation(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await expectAppearanceContent(page);
await expectSettingsDetailKeepsReadingWidth(page);
await page.setViewportSize({ width: 599, height: 951 });
await expectSettingsSidebarHidden(page);
await expectAppearanceContent(page);
});
@@ -1,3 +1,4 @@
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
@@ -10,6 +11,16 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
await page.keyboard.press(`${modifier}+Comma`);
}
async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
await page
.getByTestId(`workspace-tab-agent_${agentId}`)
.filter({ visible: true })
.first()
.click();
await expectAgentTabActive(page, agentId);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
interface ComposerFrame {
at: number;
visible: boolean;
@@ -27,7 +38,9 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
const sample = () => {
if (capture.stopped) return;
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
const root = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
).find((candidate) => candidate.offsetParent !== null);
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
if (root && input) {
const style = getComputedStyle(input);
@@ -178,6 +191,8 @@ test.describe("Settings toggle tab regression", () => {
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
await openComposerTab(page, firstAgent.id);
await openComposerTab(page, secondAgent.id);
const input = page.getByRole("textbox", { name: "Message agent..." });
await input.fill("Keep this short draft in the composer");
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
@@ -194,6 +209,7 @@ test.describe("Settings toggle tab regression", () => {
const frames = await stopComposerFrameCapture(page);
const visibleFrames = frames.filter((frame) => frame.visible);
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
const tallestIndex = visibleFrames.reduce(
(index, frame, candidateIndex) =>
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
@@ -259,3 +275,123 @@ test.describe("Settings toggle tab regression", () => {
}
});
});
async function selectSendingOption(
page: import("@playwright/test").Page,
setting: string,
option: string,
) {
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
await page.getByRole("menuitem", { name: option, exact: true }).click();
await expect(
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
).toBeVisible();
}
async function sendDraftWithShortcut(
page: import("@playwright/test").Page,
draft: string,
shortcut: string,
) {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill(draft);
await composer.press(shortcut);
await expect(composer).toHaveValue("");
await expect(page.getByText(draft, { exact: true })).toBeVisible();
}
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Send shortcut",
});
await openAgentRouteAndExpectFocused({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
agentId: agent.id,
});
await openComposerTab(page, agent.id);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await test.step("Enter sends by default", async () => {
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
});
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
await pressSettingsToggleShortcut(page);
await expect(
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
// Let the menu opening animation settle for visual QA.
await page.waitForTimeout(300);
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
await page.reload();
await expect(
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
).toBeVisible();
await pressSettingsToggleShortcut(page);
await expect(composer).toBeVisible();
});
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
await composer.fill("First line");
await composer.press("Enter");
await expect(composer).toHaveValue("First line\n");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("First line\n\n");
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
});
await test.step("switch back to Enter without changing the default send behavior", async () => {
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", "Enter");
await pressSettingsToggleShortcut(page);
await composer.fill("Shift still edits");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("Shift still edits\n");
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
});
} finally {
await workspace.cleanup();
}
});
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "running-send-shortcut-",
title: "Running send shortcut",
model: "one-minute-stream",
});
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
await selectSendingOption(page, "Default send", behavior);
await pressSettingsToggleShortcut(page);
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Follow-up");
await composer.press("Enter");
await expect(composer).toHaveValue("Follow-up\n");
await composer.press("Control+Enter");
await expect(composer).toHaveValue("");
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
behavior === "Queue" ? 1 : 0,
);
} finally {
await agent.cleanup();
}
});
}
@@ -53,11 +53,18 @@ async function quickDragFirstRowAfterSecond(
await page.mouse.move(source.x, source.y + 7);
await expect(trailingScrim).toHaveCount(0);
await page.mouse.move(target.x, target.y, { steps: 4 });
await page.mouse.up();
await finishSidebarDrag(page);
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
async function finishSidebarDrag(page: Page): Promise<void> {
await page.mouse.up();
// dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop.
// Row order updates before that blocker is removed; wait before the next user action.
await page.waitForTimeout(60);
}
async function dragWithoutReordering(rows: Locator): Promise<void> {
const before = await rowTestIds(rows);
const source = await visibleBoundingBox(rows.nth(0));
@@ -66,7 +73,7 @@ async function dragWithoutReordering(rows: Locator): Promise<void> {
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down();
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
await page.mouse.up();
await finishSidebarDrag(page);
await expect.poll(() => rowTestIds(rows)).toEqual(before);
}
@@ -123,7 +130,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
}
});
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await page.getByRole("menuitem", { name, exact: true }).click();
@@ -362,3 +369,138 @@ test("compact groups retain active overflow and reveal the current ordered rows"
await fixture.seed.cleanup();
}
});
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
await page.evaluate(
({ projectKey: scope, workspaceKeys: order }) => {
localStorage.setItem(
"sidebar-project-workspace-order",
JSON.stringify({
version: 1,
state: {
projectOrder: [scope],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { [scope]: order },
},
}),
);
},
{ projectKey, workspaceKeys },
);
await reconnectSidebar(page);
}
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
page,
}) => {
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
try {
const second = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: "New manual workspace",
});
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await selectWorkspaceSort(page, "Created");
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Last prompt");
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await reconnectSidebar(page);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await selectWorkspaceSort(page, "Created");
await reconnectSidebar(page);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
} finally {
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();
}
});
@@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) {
).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
await page.keyboard.press("Escape");
}
@@ -0,0 +1,134 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { createCreationScenario } from "../support/helpers/creation";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
const TOLERANCE_PX = 2;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX, y, { steps: 8 });
await page.mouse.up();
}
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const input = await page.context().newCDPSession(page);
try {
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 8; step++) {
await input.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
});
}
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
} finally {
await input.detach();
}
}
async function splitIntoFourPanes(page: Page): Promise<void> {
for (const expectedDividers of [1, 2, 3]) {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
}
async function expectDividersTrackPointer(
page: Page,
distance = DRAG_DISTANCE,
drag = dragDivider,
): Promise<void> {
for (const index of [0, 1, 2]) {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await drag(divider, distance);
await expect
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
message: `divider ${index + 1} should stay under the pointer`,
})
.toBeLessThanOrEqual(TOLERANCE_PX);
}
}
for (const hasTouch of [false, true]) {
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
test.use({ hasTouch });
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(
page,
DRAG_DISTANCE,
hasTouch ? dragDividerWithTouch : dragDivider,
);
} finally {
await workspace.cleanup();
}
});
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
page,
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
const creation = await createCreationScenario(page);
try {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Reply briefly.", "Create");
await creation.expectAssistantReply();
await waitForWorkspaceTabsVisible(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(page);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
await page.reload();
await expect(splitDividers(page)).toHaveCount(3);
await expectDividersTrackPointer(page);
} finally {
await creation.cleanup();
}
});
});
}
@@ -125,6 +125,29 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
}
});
test("a subagent's parent pill returns to its parent", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Refactor auth flow",
childTitle: "Write session tests",
});
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
const pill = page.getByTestId("composer-parent-agent-pill");
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
await pill.click();
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
"aria-selected",
"true",
);
} finally {
await workspace.cleanup();
}
});
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
const target = "linked.ts:3";
const workspace = await seedMockAgentWorkspace({
@@ -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);
});
}
});
@@ -1,8 +1,15 @@
import type { Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
readReactRenders,
recordReactRenders,
resetReactRenders,
} from "../support/helpers/react-renders";
type WebSocketMessage = string | Buffer;
@@ -115,23 +122,27 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
});
});
function appendCompletedCalls(extraCompletedCalls: number) {
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
throw new Error("Expected the first completed tool call before release");
}
for (let index = 0; index < extraCompletedCalls; index += 1) {
sendToPage?.(
cloneToolCallWithId(
firstCompletedMessage as WebSocketMessage,
`${firstCallId}:${sequenceOffset + index}`,
sequenceOffset + index + 1,
),
);
}
sequenceOffset += extraCompletedCalls;
}
return {
waitForFirstCompleted: () => firstCompleted,
appendCompletedCalls,
release(extraCompletedCalls = 0) {
isHolding = false;
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
throw new Error("Expected the first completed tool call before release");
}
for (let index = 0; index < extraCompletedCalls; index += 1) {
sendToPage?.(
cloneToolCallWithId(
firstCompletedMessage as WebSocketMessage,
`${firstCallId}:${index}`,
index + 1,
),
);
}
sequenceOffset = extraCompletedCalls;
appendCompletedCalls(extraCompletedCalls);
for (const message of heldMessages.splice(0)) {
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
}
@@ -139,17 +150,24 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
};
}
async function configureOverviewToolCalls(page: Page): Promise<void> {
await page.addInitScript(() => {
type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns";
// Expand thinking keeps reasoning out of tool call runs, so the first tool call starts alone.
async function configureOverviewToolCalls(page: Page, chatDisplay: OverviewChatDisplay) {
await page.addInitScript((display) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }),
);
});
}, chatDisplay);
}
async function createOverviewAgent(page: Page, title: string) {
await configureOverviewToolCalls(page);
async function createOverviewAgent(
page: Page,
title: string,
chatDisplay: OverviewChatDisplay = "collapseToolCalls",
) {
await configureOverviewToolCalls(page, chatDisplay);
return seedMockAgentWorkspace({
repoPrefix: "tool-call-overview-sheet-",
title,
@@ -204,6 +222,36 @@ function readScrollMetrics(root: HTMLElement) {
};
}
const FIRST_CALL_DETAILS = "export function ConversationList()";
function firstToolCall(page: Page): Locator {
return page.getByTestId("tool-call-badge").filter({ hasText: "conversation-list.tsx" }).first();
}
async function openToolDetails(badge: Locator): Promise<void> {
await badge.getByRole("button").first().click();
}
async function timelineRowOf(locator: Locator) {
const row = await locator.locator("xpath=ancestor::*[@data-history-row-id][1]").elementHandle();
if (!row) throw new Error("Expected the locator inside a timeline row");
return row;
}
/** Whether the locator renders inside this exact row node, i.e. the row was not remounted. */
async function isInsideRow(
locator: Locator,
row: Awaited<ReturnType<typeof timelineRowOf>>,
): Promise<boolean> {
return locator.evaluate((node, previousRow) => previousRow.contains(node), row);
}
async function topOf(row: Locator): Promise<number> {
const box = await row.boundingBox();
if (!box) throw new Error("Expected a visible timeline row");
return box.y;
}
test.describe("compact overview tool calls", () => {
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
@@ -219,8 +267,16 @@ test.describe("compact overview tool calls", () => {
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
await gate.waitForFirstCompleted();
const single = firstToolCall(page);
await expect(single).toBeVisible();
await expect(page.getByTestId("tool-call-group")).toHaveCount(0);
await openToolDetails(single);
await expect(page.getByTestId("tool-call-sheet-close")).toBeVisible();
await page.getByTestId("tool-call-sheet-close").click();
gate.appendCompletedCalls(1);
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toBeHidden();
await group.click();
const sheet = page.getByTestId("tool-call-group-sheet");
@@ -261,27 +317,86 @@ test.describe("compact overview tool calls", () => {
});
});
test("keeps overview tool calls inline on desktop", async ({ page }) => {
for (const display of ["collapseToolCalls", "collapseTurns"] as const) {
test(`keeps an opened lone tool call open when it becomes a group (${display})`, async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Desktop overview tool calls", display);
await recordReactRenders(page);
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await gate.waitForFirstCompleted();
const lone = firstToolCall(page);
const group = page.getByTestId("tool-call-group").first();
const details = page.getByText(FIRST_CALL_DETAILS);
await test.step("a lone tool call shows as itself", async () => {
await expect(lone).toBeVisible();
await expect(group).toHaveCount(0);
});
await gate.waitForSecondRunning();
await test.step("opening it re-renders that tool call and nothing else", async () => {
await resetReactRenders(page);
await openToolDetails(lone);
await expect(details).toBeVisible();
const renders = await readReactRenders(page);
expect(renders.AgentStreamView ?? 0).toBe(0);
expect(renders.StreamRowContent ?? 0).toBe(0);
expect(renders.ToolCall).toBe(1);
});
await test.step("the second call forms an open group around the opened call", async () => {
// Reading above the tail, the row holds still; at the tail the new rows push it up.
await scrollChatAwayFromBottom(page, { deltaY: -160, minDistanceFromBottom: 72 });
const loneTop = await topOf(lone);
const loneRow = await timelineRowOf(lone);
await resetReactRenders(page);
gate.releaseSecondRunning();
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
expect(await isInsideRow(group, loneRow)).toBe(true);
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
expect((await readReactRenders(page)).AgentStreamView).toBeGreaterThan(0);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
});
await test.step("closing and reopening the group keeps the call open", async () => {
await openToolDetails(group);
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
await expect(details).toHaveCount(0);
await openToolDetails(group);
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
});
} finally {
await agent.cleanup();
}
});
}
test("forms a closed group when the lone tool call was left closed", async ({ page }) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
const agent = await createOverviewAgent(page, "Closed desktop overview tool calls");
try {
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
const gate = await gateSecondToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await agent.client.sendAgentMessage(agent.agentId, "Leave the first tool call closed.");
await gate.waitForFirstCompleted();
const lone = firstToolCall(page);
const group = page.getByTestId("tool-call-group").first();
await expect(lone).toBeVisible();
await expect(group).toHaveCount(0);
await gate.waitForSecondRunning();
const loneTop = await topOf(lone);
gate.releaseSecondRunning();
await expect(group).toBeVisible();
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
const header = group.getByRole("button").first();
await header.click();
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
gate.release(1);
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
} finally {
await agent.cleanup();
}
@@ -1,11 +1,9 @@
import type { Locator, Page } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
type WebSocketMessage = string | Buffer;
interface ShimmerEvidence {
animationDuration: string;
endPx: number;
@@ -14,127 +12,6 @@ interface ShimmerEvidence {
startPx: number;
}
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let pauseAfterSecondRunning = false;
let releaseSecondRequested = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (secondRunningMessage) {
ws.send(secondRunningMessage);
secondRunningMessage = null;
}
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseAfterSecondRunning) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
ws.send(message);
if (toolCall.status === "completed") {
resolveFirstCompleted();
}
return;
}
secondCallId ??= toolCall.callId;
if (toolCall.callId === secondCallId) {
const transformedMessage =
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
secondRunningMessage = transformedMessage;
pauseAfterSecondRunning = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
forwardSecondRunning?.();
},
};
}
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
return locator.evaluate((root) => {
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
@@ -161,7 +38,7 @@ test("measures an overview heading that becomes loading after its idle mount", a
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
});
const agent = await seedMockAgentWorkspace({
@@ -0,0 +1,613 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import {
holdOlderHistoryPages,
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 { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
},
},
},
});
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
}, chatDisplay);
}
async function seedAnsweredAgent(title: string) {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-collapse-",
title,
model: "ten-second-stream",
initialPrompt: "Why does the chat stop following new output?",
});
await agent.client.waitForFinish(agent.agentId, 30_000);
await agent.client.sendAgentMessage(
agent.agentId,
"Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40),
);
await agent.client.waitForFinish(agent.agentId, 30_000);
return agent;
}
/** 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(() => {
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]"),
);
return replyRow - summaryRow - 1;
});
}
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]"));
return rows
.slice(start + 1, end)
.flatMap((row) =>
[...row.querySelectorAll("[data-testid=tool-call-group]")].map(
(group) => group.textContent ?? "",
),
);
});
}
async function expectCompactMessageToggle(prompt: Locator, label: string) {
const button = prompt.getByRole("button", { name: label, exact: true });
const text = prompt.getByText(label, { exact: true });
await expect
.poll(
async () => {
const control = (await button.boundingBox())!;
const glyphs = (await text.boundingBox())!;
return control.height - glyphs.height;
},
{ message: "The inline toggle adds no vertical space beyond its text" },
)
.toBe(0);
}
async function expandAndCollapseLongPrompt(page: Page) {
const prompt = page.getByTestId("user-message").last();
const before = await prompt.boundingBox();
const text = prompt.locator('[data-message-text="true"]');
const messageLeft = (await text.boundingBox())!.x;
await expect
.poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x)
.toBe(messageLeft);
await expectCompactMessageToggle(prompt, "Show more");
await prompt.getByRole("button", { name: "Show more", exact: true }).click();
await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
await expect
.poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x)
.toBe(messageLeft);
await expect
.poll(async () => (await prompt.boundingBox())?.height ?? 0)
.toBeGreaterThan(before!.height);
await expectCompactMessageToggle(prompt, "Show less");
await prompt.getByRole("button", { name: "Show less", exact: true }).click();
await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height);
await prompt.scrollIntoViewIfNeeded();
const bounds = (await prompt.boundingBox())!;
const body = (await text.boundingBox())!;
await page.screenshot({
path: "/tmp/message-toggle-aligned.png",
clip: {
x: body.x - 16,
y: bounds.y,
width: bounds.x + bounds.width - body.x + 16,
height: bounds.height,
},
});
}
test.describe("Turn collapse", () => {
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 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedAnsweredAgent("Turn collapse");
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expandAndCollapseLongPrompt(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toHaveText(/^Edited 1 file/);
// Text chunks that stream back to back merge into one message, so the reply holds
// at least the stream's last chunk.
await expect(page.getByTestId("assistant-message").last()).toHaveText(
/\(end of synthetic stream\)$/,
);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
await expect(summary).toBeInViewport();
await summary.evaluate((element) => element.scrollIntoView({ block: "start" }));
await page.screenshot({ path: "/tmp/turn-rail-aligned.png" });
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
await agent.cleanup();
}
});
});
test("received messages show their sender and open that agent", async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
);
const recipient = await seedMockAgentWorkspace({
repoPrefix: "sender-attribution-",
title: "Implementation",
});
try {
const sender = await recipient.client.createAgent({
provider: "codex",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title: "Reviewer",
model: "gpt-6.1-sol",
modeId: "full-access",
});
await recipient.client.sendAgentMessage(
recipient.agentId,
"I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.",
{ sourceAgentId: sender.id },
);
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true });
await expect(attribution).toBeVisible();
await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText(
"I reviewed the turn-grouping changes.",
);
const labelColor = await attribution
.getByText("Sent by Reviewer", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
await expectSenderAlignedWithBubble(attribution);
await attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: "/tmp/sender-attribution.png" });
await attribution.click();
await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id);
} finally {
await recipient.cleanup();
}
});
test("long sender attribution stays on one line in compact chat", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
);
const recipient = await seedMockAgentWorkspace({
repoPrefix: "compact-attribution-",
title: "Implementation",
});
try {
const title = "Reviewer checking the compact chat timeline and sender attribution";
const sender = await recipient.client.createAgent({
provider: "codex",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title,
model: "gpt-6.1-sol",
modeId: "full-access",
});
await recipient.client.sendAgentMessage(
recipient.agentId,
"The sender label stays on one line, even when the agent has a long name.",
{ sourceAgentId: sender.id },
);
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
const attribution = page.getByRole("button", { name: `Sent by ${title}`, exact: true });
await expect(attribution).toBeVisible();
await expectSenderTruncatedWithinMessage(attribution, title);
await expectSenderAlignedWithBubble(attribution);
await attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("sender-compact-390.png") });
await page.setViewportSize({ width: 320, height: 740 });
await expectSenderTruncatedWithinMessage(attribution, title);
await expectSenderAlignedWithBubble(attribution);
await page.screenshot({ path: testInfo.outputPath("sender-compact-320.png") });
} finally {
await recipient.cleanup();
}
});
interface MessagePreviewWatch {
/** Frames after the watch started on which the fade or "Show more" control was missing. */
framesWithoutClip: number[];
/** Frames on which the fade was a different DOM node than when the watch started. */
framesRemounted: number[];
}
declare global {
interface Window {
__paseoMessagePreviewWatch?: { stop(): MessagePreviewWatch };
}
}
async function seedReceivedReviewWithToolTurn() {
const recipient = await seedMockAgentWorkspace({
repoPrefix: "preview-clip-",
title: "Implementation",
});
const sender = await recipient.client.createAgent({
provider: "mock",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title: "Reviewer",
modeId: "load-test",
model: "e2e-fast-stream",
});
// Long enough to clip behind the preview fade; the instruction makes the recipient run tools.
await recipient.client.sendAgentMessage(
recipient.agentId,
"Review complete: the regression is red on the baseline and green with the fix.\n" +
"Keep the previous response visible while the reader scrolls.\n".repeat(30) +
"Now emit 7 tool agent stream updates",
{ sourceAgentId: sender.id },
);
return recipient;
}
/** Samples the received message's bubble on every animation frame until stopped. */
async function watchReceivedMessagePreview(page: Page, senderLabel: string): Promise<void> {
await page.evaluate((label) => {
const findBubble = () =>
[...document.querySelectorAll("[data-testid=user-message]")].find((row) =>
row.textContent?.includes(label),
) ?? null;
const initialFade = findBubble()?.querySelector("svg") ?? null;
if (!initialFade) throw new Error(`No faded bubble for ${label}`);
const framesWithoutClip: number[] = [];
const framesRemounted: number[] = [];
let frame = 0;
let running = true;
const sample = () => {
if (!running) return;
const bubble = findBubble();
const fade = bubble?.querySelector("svg") ?? null;
if (!fade || !bubble?.textContent?.includes("Show more")) framesWithoutClip.push(frame);
else if (fade !== initialFade) framesRemounted.push(frame);
frame += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
window.__paseoMessagePreviewWatch = {
stop() {
running = false;
return { framesWithoutClip, framesRemounted };
},
};
}, senderLabel);
}
async function expectReceivedMessagePreviewUndisturbed(page: Page): Promise<void> {
// Give the opened turn's layout a few frames to settle before judging.
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
);
const watch = await page.evaluate(() => window.__paseoMessagePreviewWatch!.stop());
expect(
watch.framesWithoutClip,
"the preview fade and Show more control stay on every frame",
).toEqual([]);
expect(watch.framesRemounted, "the preview keeps its DOM node (no remount)").toEqual([]);
}
// Opening a turn must not rebuild its host row: the preview above the summary loses its
// measured height when remounted, which drops the fade and "Show more" for a frame.
test("opening a turn keeps the received message above it clipped on every frame", async ({
page,
}) => {
await useChatDisplay(page, "collapseTurns");
const recipient = await seedReceivedReviewWithToolTurn();
try {
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
await expect(page.getByText("Completed 7 commands", { exact: true })).toBeVisible({
timeout: 30_000,
});
const bubble = page
.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
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 7 commands", exact: true })
.click();
await expect(page.getByText("echo stress-6", { exact: true })).toBeVisible();
await expectReceivedMessagePreviewUndisturbed(page);
} finally {
await recipient.cleanup();
}
});
async function expectSenderTruncatedWithinMessage(attribution: Locator, title: string) {
const label = attribution.getByText(`Sent by ${title}`, { exact: true });
await expect(label).toHaveCSS("white-space", "nowrap");
await expect(label).toHaveCSS("text-overflow", "ellipsis");
const bounds = await label.evaluate((element) => ({
textWidth: element.scrollWidth,
visibleWidth: element.clientWidth,
right: element.getBoundingClientRect().right,
viewportWidth: document.documentElement.clientWidth,
}));
expect(bounds.textWidth).toBeGreaterThan(bounds.visibleWidth);
expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth);
}
async function expectSenderAlignedWithBubble(attribution: Locator) {
const edges = await attribution.evaluate((button) => {
const bubble = button.nextElementSibling;
const label = button.querySelector('[dir="auto"]');
if (!bubble || !label) throw new Error("Sender label or message bubble missing");
return {
labelRight: label.getBoundingClientRect().right,
bubbleRight: bubble.getBoundingClientRect().right,
};
});
expect(
Math.abs(edges.labelRight - edges.bubbleRight),
"sender and bubble share the right edge",
).toBeLessThanOrEqual(1);
}
async function seedWholeTurnHistory() {
const agent = await seedMockAgentWorkspace({
repoPrefix: "whole-turn-history-",
title: "Whole turns",
model: "e2e-fast-stream",
});
for (const prompt of [
...Array.from(
{ length: 25 },
(_, index) => `Short ${index}: emit 1 coalesced agent stream updates`,
),
"Older long turn: emit 60 tool agent stream updates",
"Latest long turn: emit 120 tool agent stream updates",
]) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
return agent;
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
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(120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
await agent.cleanup();
}
});
async function useThinkingDisplay(
page: Page,
autoExpandReasoning: boolean,
chatDisplay: ChatDisplay,
) {
await page.addInitScript(
(settings) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({
...settings,
chatDisplay: sessionStorage.getItem("e2e-chat-display") ?? settings.chatDisplay,
}),
);
},
{ autoExpandReasoning, chatDisplay },
);
}
for (const autoExpandReasoning of [false, true]) {
test(`reasoning follows Expand thinking (${autoExpandReasoning}) when opening its turn`, async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await useThinkingDisplay(page, autoExpandReasoning, "collapseToolCalls");
const agent = await seedMockAgentWorkspace({
repoPrefix: "thinking-group-",
title: "Thinking with tools",
model: "ten-second-stream",
initialPrompt: "Inspect the scroll container",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const reasoning = page
.getByText(/Need to find the scroll container, the layout effect/)
.filter({ visible: true })
.first();
await expectThinkingPresentation(page, autoExpandReasoning);
await switchToCollapsedTurns(page);
await expectComposerVisible(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toBeVisible();
await expect(summary).not.toContainText(/thought/i);
await expect(reasoning).toHaveCount(0);
await summary.click();
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await openOneToolOutput(page);
await summary.click();
await expect(reasoning).toHaveCount(0);
} finally {
await agent.cleanup();
}
});
}
async function openOneToolOutput(page: Page) {
const output = page.getByText(/\[burst\] tick 1 userIsAtBottom=true/).filter({ visible: true });
await expect(output).toHaveCount(0);
const command = page
.getByTestId("tool-call-badge")
.filter({ hasText: "node scripts/simulate-stream-burst.mjs" })
.first();
await command.getByRole("button").first().click();
await expect(output.first()).toBeVisible();
await command.getByRole("button").first().click();
await expect(output).toHaveCount(0);
}
async function expectThinkingPresentation(page: Page, autoExpandReasoning: boolean) {
const reasoning = page
.getByText(/Need to find the scroll container, the layout effect/)
.filter({ visible: true })
.first();
if (autoExpandReasoning) {
await expect(reasoning).toBeVisible();
await expect(page.getByTestId("tool-call-group").filter({ hasText: /thought/ })).toHaveCount(0);
} else {
await expect(reasoning).toHaveCount(0);
const group = page
.getByTestId("tool-call-group")
.filter({ hasText: /read 1 file/i, visible: true })
.last();
await expect(group).toContainText(/read 1 file/i);
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
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.
const thought = group
.getByTestId("tool-call-badge")
.filter({ hasText: /^Thinking/ })
.first();
await expect(thought).toBeVisible();
await expect(reasoning).toHaveCount(0);
// A press right after the group opens occasionally does not register; retry until the
// thought row opens.
await expect(async () => {
await thought.getByRole("button").first().click();
await expect(reasoning).toBeVisible({ timeout: 2_000 });
}).toPass();
}
}
async function switchToCollapsedTurns(page: Page) {
await page.evaluate(() => {
sessionStorage.setItem("e2e-chat-display", "collapseTurns");
});
await page.reload();
}
test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({
page,
}) => {
test.setTimeout(90_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedMockAgentWorkspace({
repoPrefix: "heartbeat-turn-",
title: "Heartbeat turns",
model: "ten-second-stream",
initialPrompt: "Inspect the scroll container",
});
const schedules = agent.client as unknown as Pick<
DaemonClient,
"scheduleCreate" | "scheduleRunOnce" | "scheduleDelete"
>;
let scheduleId: string | undefined;
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const summaries = page.getByTestId("turn-summary");
await expect(summaries).toHaveCount(1);
const previousSummary = await summaries.first().innerText();
const previousReplyId = await page
.getByTestId("assistant-message")
.last()
.evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id"));
const created = await schedules.scheduleCreate({
name: "Check the completed work",
prompt: "Inspect the scroll container again",
cadence: { type: "cron", expression: "0 0 1 1 *" },
target: { type: "self", agentId: agent.agentId },
runOnCreate: false,
});
expect(created.error).toBeNull();
scheduleId = created.schedule!.id;
const fired = await schedules.scheduleRunOnce({ id: scheduleId });
expect(fired.error).toBeNull();
await expect(
page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }),
).toBeVisible();
await expect(summaries.first()).toHaveText(previousSummary);
const previousReply = page
.locator(`[data-message-id="${previousReplyId}"]`)
.getByTestId("assistant-message");
await expect(previousReply).toBeVisible();
await expect(previousReply).toContainText("(end of synthetic stream)");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(summaries).toHaveCount(2);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2);
await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" });
} finally {
if (scheduleId) await schedules.scheduleDelete({ id: scheduleId });
await agent.cleanup();
}
});
+4 -4
View File
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets (28|29)m$/,
/^resets in (28|29)m\s·\s/,
);
} finally {
await login.cleanup();
@@ -0,0 +1,69 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
openUsageFromIcon,
openUsageOptions,
closeUsageOptions,
closeUsage,
expectNoUsageItem,
expectPinnedUsage,
} from "../support/helpers/usage-sidebar-item";
test("Check usage applies to the host and Turn on restores cards", async ({
page,
e2eWorkerClient,
}) => {
test.setTimeout(180_000);
const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true };
try {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
await openUsageOptions(page);
await page.getByRole("switch", { name: "Check usage", exact: true }).click();
await closeUsageOptions(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0);
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false });
await expectNoUsageItem(page);
const before = usage.listRequests().length;
await closeUsage(page);
await page.reload();
await openUsageFromIcon(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
expect(usage.listRequests()).toHaveLength(before);
await page.getByRole("button", { name: "Turn on", exact: true }).click();
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true });
// A separate client changes the policy, as another device would.
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } });
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } });
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
} finally {
await e2eWorkerClient.patchDaemonConfig({ usage: previous });
}
});
test("older hosts keep usage without a Check usage switch", async ({ page }) => {
test.setTimeout(180_000);
await installUsageReportsFixture(page, {
usageSettingSupported: false,
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
await openUsageOptions(page);
await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0);
});
@@ -441,3 +441,31 @@ test("released hosts supply source logos through the client conversion", async (
).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-modal");
});
test("hidden usage sends no requests until opened", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectNoUsageItem(page);
await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({
timeout: 90_000,
});
await openSidebarNavSettings(page);
await expectFooterItemSetting(page, "usage", false);
await leaveSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
expect(usage.listRequests()).toHaveLength(1);
});
test("visible usage summary requests reports on load", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
});
@@ -0,0 +1,93 @@
import { expect, test } from "../support/fixtures";
import {
apiKeyInput,
baseUrlInput,
expectSheetClosed,
expectSpeechSiteSummary,
openSpeechSite,
openVoiceSettings,
providerOption,
readSpeechSite,
resetSpeechSite,
saveSpeechSite,
withUnwritableDaemonConfig,
} from "../support/helpers/voice-settings";
// E2E daemons pin every speech provider to OpenAI through env vars so no test starts a model
// download. That makes the provider picker read-only here; the env key covers the key fallback.
const ENV_API_KEY = "sk-e2e-env-fallback";
const SAVED_API_KEY = "sk-e2e-saved-key";
const BASE_URL = "http://127.0.0.1:9/v1";
test.use({ e2eDaemonEnvironment: { OPENAI_API_KEY: ENV_API_KEY } });
test("configures an env-pinned OpenAI site and keeps the saved key on later saves", async ({
page,
}) => {
try {
await openVoiceSettings(page);
await expectSpeechSiteSummary(page, "Listening", "OpenAI · whisper-1");
const sheet = await test.step("the env var decides the provider", async () => {
const opened = await openSpeechSite(page, "Listening");
await expect(opened.getByText("Set by PASEO_VOICE_STT_PROVIDER")).toBeVisible();
await expect(providerOption(opened, "Local")).toBeDisabled();
await expect(providerOption(opened, "OpenAI")).toHaveAttribute("aria-selected", "true");
await expect(opened.getByText("Set by OPENAI_API_KEY")).toBeVisible();
return opened;
});
await test.step("a saved key and base URL win over the env key", async () => {
await apiKeyInput(sheet).fill(SAVED_API_KEY);
await baseUrlInput(sheet).fill(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.stt"))
.toMatchObject({ provider: "openai", hasApiKey: true, baseUrl: BASE_URL });
});
await test.step("reopening shows the key as saved without revealing it", async () => {
const reopened = await openSpeechSite(page, "Listening");
await expect(apiKeyInput(reopened)).toHaveValue("");
await expect(apiKeyInput(reopened)).toHaveAttribute("placeholder", "Saved");
await expect(reopened.getByText("Set by OPENAI_API_KEY")).toHaveCount(0);
await expect(baseUrlInput(reopened)).toHaveValue(BASE_URL);
await expect(reopened.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
});
await test.step("clearing the base URL keeps the saved key", async () => {
const sheetAgain = page.getByRole("dialog");
await baseUrlInput(sheetAgain).fill("");
await saveSpeechSite(sheetAgain);
await expectSheetClosed(page);
const site = await readSpeechSite("voiceMode.stt");
expect(site).toMatchObject({ provider: "openai", hasApiKey: true });
expect(site).not.toHaveProperty("baseUrl");
});
} finally {
await resetSpeechSite("voiceMode.stt");
}
});
test("a failed save keeps the sheet open with the user's input", async ({ page }) => {
try {
await openVoiceSettings(page);
const sheet = await openSpeechSite(page, "Speaking");
await baseUrlInput(sheet).fill(BASE_URL);
await withUnwritableDaemonConfig(async () => {
await saveSpeechSite(sheet);
await expect(page.getByText(/^Unable to update voice settings: /)).toBeVisible();
});
await expect(baseUrlInput(sheet)).toHaveValue(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.tts"))
.toMatchObject({ provider: "openai", baseUrl: BASE_URL });
} finally {
await resetSpeechSite("voiceMode.tts");
}
});
@@ -11,7 +11,10 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import {
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
page,
}, testInfo) => {
const serverId = getServerId();
const firstWorkspace = await seedWorkspace({
repoPrefix: "workspace-owner-",
title: "Original workspace",
});
const secondWorkspace = await seedWorkspace({
repoPrefix: "workspace-restored-",
title: "Restored workspace",
});
try {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: "Original workspace agent",
});
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: "Restored workspace agent",
});
await gotoAppShell(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await seedForeignPinnedAgentTab(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await testInfo.attach("hydrated-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
contentType: "image/png",
});
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await testInfo.attach("restored-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
contentType: "image/png",
});
await switchWorkspaceViaSidebar({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
const agents = await firstWorkspace.client.fetchAgents();
expect(
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
).toBe(firstWorkspace.workspaceId);
expect(
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
).toBe(secondWorkspace.workspaceId);
await testInfo.attach("original-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
contentType: "image/png",
});
} finally {
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
@@ -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,7 @@ for await (const line of lines) {
const command = JSON.parse(line);
let data = {};
if (command.type === "get_available_models") {
if (process.argv.includes("--stall-model-discovery")) continue;
data = {
models: [
{
@@ -0,0 +1,14 @@
# Native user message preview
Open a chat with a user message containing 16 newline-separated lines of
`Preview line N`, with N from 1 to 16. Scroll the message into view on a
phone-sized Android emulator or iOS simulator. Its text must exceed the 240-point preview cap,
while its expanded bubble and toggle fit in the chat viewport. Close any other
expanded messages so the toggle labels identify this message.
Replay `native-user-message-preview.android.ad` or
`native-user-message-preview.ios.ad` through the existing mobile runner or
agent-device. It checks that a clipped message offers Show more and
that two expand/collapse round trips keep both controls reachable. Before the
fix, the first Show more assertion fails because native layout reports the
capped height as the full content height.
@@ -0,0 +1,14 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
close
@@ -0,0 +1,14 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
close
@@ -28,7 +28,10 @@ function contains(outer, inner) {
* while suggestions are open, so its callers read the composer from `reference`: a
* snapshot in the same keyboard state with an empty input.
*/
export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) {
export function resolveAutocompletePlacement(
snapshot,
{ expected, keyboardTop, reference, anchorGap },
) {
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
const nodes = snapshot.data.nodes;
const popover = findById(nodes, "composer-autocomplete-popover").rect;
@@ -40,6 +43,10 @@ export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop,
const keyboardY =
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
const popoverBottom = bottomOf(popover);
const gap = composer.y - popoverBottom;
if (anchorGap !== undefined && Math.abs(gap - anchorGap) > 1) {
throw new Error(`Autocomplete lost its anchor gap: gap=${gap}, expected=${anchorGap}`);
}
if (popoverBottom > composer.y + 1) {
throw new Error(
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
+136 -25
View File
@@ -14,7 +14,8 @@ const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?
const serverId = env("SERVER_ID");
const agentId = env("AGENT_ID");
const workspaceId = env("WORKSPACE_ID");
if (!serverId || !agentId || !workspaceId) {
const scenario = env("SCENARIO", "all");
if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
throw new Error(
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
);
@@ -22,7 +23,12 @@ if (!serverId || !agentId || !workspaceId) {
const draftId = env("DRAFT_ID", "composer-keyboard");
const session = env("SESSION", "composer-keyboard-ios");
const device = env("DEVICE", "iPhone 16 Pro");
const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios");
const udid = env("UDID");
const deviceArgs = udid ? ["--udid", udid] : ["--device", device];
const artifactsDir = env(
"ARTIFACTS_DIR",
path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"),
);
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
// Partial queries with known best matches. `inserted` is the composer text after
// tapping the best match.
@@ -35,7 +41,7 @@ const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
fs.mkdirSync(artifactsDir, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
@@ -77,7 +83,7 @@ async function settledPlacement(name, keyboard) {
}
function assertPlacement(name, snap, expected) {
const placement = resolveAutocompletePlacement(snap, { expected });
const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 });
process.stdout.write(
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
placement.popover.y + placement.popover.height,
@@ -123,21 +129,34 @@ async function clearInput(name) {
)?.rect;
if (!deleteKey) throw new Error("Missing keyboard delete key");
const length = composerText(keyboard).length;
ad(
"press",
String(Math.round(deleteKey.x + deleteKey.width / 2)),
String(Math.round(deleteKey.y + deleteKey.height / 2)),
"--count",
String(length + 2),
"--interval-ms",
"40",
);
for (let remaining = length + 2; remaining > 0; remaining -= 100) {
ad(
"press",
String(Math.round(deleteKey.x + deleteKey.width / 2)),
String(Math.round(deleteKey.y + deleteKey.height / 2)),
"--count",
String(Math.min(remaining, 100)),
"--interval-ms",
"40",
);
}
}
// The accessibility value drops a trailing space.
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
async function runCase(host, kind, { text, expected, inserted }) {
async function selectRow(name, row, inserted) {
ad("press", String(row.x), String(row.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${row.label} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${row.label}, composer now ${JSON.stringify(inserted)}\n`,
);
}
async function runCase(host, kind, { text, expected, inserted, selectionKeyboard = "reopened" }) {
const name = `${host}-${kind}`;
await clearInput(name);
await dismissKeyboard(snapshot(`${name}-empty`));
@@ -151,31 +170,32 @@ async function runCase(host, kind, { text, expected, inserted }) {
await clearInput(`${name}-settled`);
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
ad("type", text);
assertPlacement(
const opened = assertPlacement(
`${name} opened after keyboard settled`,
await settledPlacement(`${name}-open`, true),
expected,
);
if (selectionKeyboard === "shown") {
await selectRow(name, opened, inserted);
return;
}
await dismissKeyboard(snapshot(`${name}-before-close`));
assertPlacement(
const closed = assertPlacement(
`${name} keyboard closed`,
await settledPlacement(`${name}-closed`, false),
expected,
);
if (selectionKeyboard === "hidden") {
await selectRow(name, closed, inserted);
return;
}
focusAtEnd(snapshot(`${name}-before-reopen`));
const reopened = assertPlacement(
`${name} keyboard opened under open suggestions`,
await settledPlacement(`${name}-reopened`, true),
expected,
);
ad("press", String(reopened.x), String(reopened.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
);
await selectRow(name, reopened, inserted);
}
// Drafts list commands for their selected provider, and a fresh install selects none.
@@ -216,13 +236,104 @@ async function runHost(host) {
await dismissKeyboard(snapshot(`${host}-done`));
}
// These cases use the selected draft provider's real skills. Run on a 402pt-wide
// iPhone for growth and a small phone (SE) for previews, with Codex selected.
async function runLiveGrowth() {
await clearInput("live-growth");
ad("type", "Please do the following now /paseo");
const before = await settledPlacement("live-growth-before", true);
assertPlacement("before growth", before, "/paseo");
const inputHeight = (snap) =>
snap.data.nodes.find((node) => node.type === "TextView").rect.height;
ad("type", "--", "-committee");
const grown = await settledPlacement("live-growth-after", true);
if (inputHeight(grown) <= inputHeight(before))
throw new Error("Draft did not wrap onto a new line");
assertPlacement("live growth without reopening", grown, "/paseo-committee");
const deleteKey = grown.data.nodes.find(
(node) => node.type === "Key" && node.label === "delete",
).rect;
ad(
"press",
String(deleteKey.x + deleteKey.width / 2),
String(deleteKey.y + deleteKey.height / 2),
"--count",
"10",
"--interval-ms",
"40",
);
const shrunk = await settledPlacement("live-growth-shrunk", true);
if (inputHeight(shrunk) !== inputHeight(before))
throw new Error("Draft did not shrink back to one line");
assertPlacement("live shrink without reopening", shrunk, "/paseo");
ad("type", "--", "-committ");
const partial = await settledPlacement("live-growth-partial", true);
if (!sameText(composerText(partial), "Please do the following now /paseo-committ")) {
throw new Error("Could not prepare a partial command for selection");
}
const row = assertPlacement("partial command after growth", partial, "/paseo-committee");
ad("press", String(row.x), String(row.y));
await waitFor("live-growth-selected", (snap) =>
sameText(composerText(snap), "Please do the following now /paseo-committee "),
);
await clearInput("live-growth-done");
}
async function runBoundedPreview() {
const prefix =
"Please do the following / testing inline selection.\n" +
"Line two with details.\nLine three with details.\nLine four with details.\n" +
"Line five with details.\nLine six with details.\nLine seven with details.\nPlease use ";
await runCase("preview", "long", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "shown",
});
await runCase("preview", "long-hidden", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "hidden",
});
await runCase("preview", "longest", {
text: `${prefix}/paseo-plug`,
expected: "/paseo-plugin",
inserted: `${prefix}/paseo-plugin `,
selectionKeyboard: "shown",
});
await runCase("preview", "short", {
text: `${prefix}/com`,
expected: "/commit",
inserted: `${prefix}/commit `,
selectionKeyboard: "shown",
});
await runCase("preview", "files", fileCase);
}
if (scenario !== "all") {
ad(
"open",
`paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`,
"--platform",
"ios",
...deviceArgs,
);
await waitFor("draft", (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
if (scenario === "growth") await runLiveGrowth();
else if (scenario === "previews") await runBoundedPreview();
else throw new Error(`Unknown scenario: ${scenario}`);
process.stdout.write(`Composer ${scenario} regression passed. Artifacts: ${artifactsDir}\n`);
process.exit(0);
}
const hosts = [
["chat", `paseo://h/${serverId}/agent/${agentId}`],
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
["new-workspace", `paseo://new?serverId=${serverId}`],
];
for (const [host, url] of hosts) {
ad("open", url, "--platform", "ios", "--device", device);
ad("open", url, "--platform", "ios", ...deviceArgs);
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
await runHost(host);
}
+104
View File
@@ -0,0 +1,104 @@
// Run a release build with a connected workspace open and the keyboard hidden.
// ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const serial = process.env.ANDROID_SERIAL;
assert(serial, "Set ANDROID_SERIAL to a booted Android foldable emulator");
const appId = process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug";
const session = "android-fold";
const artifacts = resolve(".dev/agent-device-artifacts/android-fold");
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-android-fold"),
};
mkdirSync(artifacts, { recursive: true });
function run(binary, args) {
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 60_000 });
}
function adb(...args) {
return run("adb", ["-s", serial, ...args]);
}
function ad(...args) {
return run("agent-device", [...args, "--session", session]);
}
function pid() {
return adb("shell", "pidof", appId).trim();
}
function open() {
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
}
function stateId(name) {
const states = adb("shell", "cmd", "device_state", "print-states");
const match = states.match(new RegExp(`identifier=(\\d+), name='${name}'`));
assert(match, `Emulator must expose the ${name} posture: ${states}`);
return match[1];
}
async function expectLayout(posture) {
const compact = posture === "CLOSED";
const expectedId = compact ? "workspace-tab-switcher-trigger" : "workspace-tabs-scroll";
const unexpectedId = compact ? "workspace-tabs-scroll" : "workspace-tab-switcher-trigger";
const deadline = Date.now() + 15_000;
let nodes = [];
do {
let data;
try {
({ data } = JSON.parse(ad("snapshot", "-i", "--json")));
} catch (error) {
// During display handoff Android briefly has no capturable app window.
if (!error.stdout?.includes("insufficient foreground app content")) throw error;
await delay(200);
continue;
}
assert.equal(data.appBundleId, appId, "Snapshot must belong to Paseo");
nodes = data.nodes;
if (
nodes.some((node) => node.identifier === expectedId) &&
!nodes.some((node) => node.identifier === unexpectedId)
) {
writeFileSync(resolve(artifacts, `${posture}.json`), JSON.stringify(data, null, 2));
console.log(`PASS: ${posture} uses ${compact ? "compact" : "wide"} workspace controls`);
return;
}
await delay(200);
} while (Date.now() < deadline);
writeFileSync(resolve(artifacts, `${posture}-failure.json`), JSON.stringify(nodes, null, 2));
writeFileSync(
resolve(artifacts, "native-failure.txt"),
adb("shell", "dumpsys", "activity", "top"),
);
assert.fail(`${posture}: expected ${expectedId}, without ${unexpectedId}`);
}
const closed = stateId("CLOSED");
const opened = stateId("OPENED");
const initialState = adb("shell", "cmd", "device_state", "state").match(
/Committed state: DeviceState\{identifier=(\d+)/,
)?.[1];
assert(initialState, "Could not read the initial fold state");
try {
adb("shell", "cmd", "device_state", "state", opened);
open();
await expectLayout("OPENED");
const initialPid = pid();
for (const [posture, id] of [
["CLOSED", closed],
["OPENED", opened],
]) {
adb("shell", "input", "keyevent", "KEYCODE_HOME");
adb("shell", "cmd", "device_state", "state", id);
// Resume immediately: waiting for configuration delivery hides the regression.
open();
await expectLayout(posture);
assert.equal(pid(), initialPid, "Fold/resume must preserve the app process");
}
} finally {
adb("shell", "cmd", "device_state", "state", initialState);
ad("close");
}
@@ -0,0 +1,41 @@
# Inline code formatting
This fixture renders the production `MarkdownRenderer` with plain, bold, italic,
combined emphasis, strikethrough and linked code, plus bold code in headings,
lists and quotes. Toggle regular/compact text. Inline code uses the theme’s
code-to-body size ratio at every heading level; fenced code keeps the code-size
token. Bold and links preserve the resulting size. Check the glyphs, not the background height: native backgrounds use
line metrics.
Use a development client rebuilt from this checkout. Follow the temporary entry
point and Metro workflow in [status-ring](../status-ring/README.md), importing
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
With this fixture open in an Android Agent Device session, run:
```sh
node packages/app/e2e/mobile/markdown-formatting/android.mjs <session>
```
The runner switches to paragraphs with long inline code at a fixed 300 dp width.
It compares the native Markdown host’s bottom with its last TextView’s bottom,
then repeats in compact mode and after restoring regular text. More than one
pixel of surplus fails. Keep the whole fixture visible: clipped accessibility
bounds cannot establish measurement parity.
`scripts/postinstall-patches.test.mjs` regenerates both platforms’ props and
compares them with the shipped headers. This catches missing C++ fields in the
measurement path, which the Kotlin tests cannot exercise.
The package patch contains `InlineCodeFormattingTest`, which exercises actual
Android spans in Robolectric native graphics mode, covering draw and measurement,
span order, code inside paragraph/heading sizes, bold/italic composition, and
links preserving code and emphasized prose fonts.
It runs in the existing `test:android` CI job. To run only this regression locally,
from `packages/app/android` after Expo prebuild:
```sh
./gradlew :react-native-enriched-markdown:testDebugUnitTest \
--tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2
```
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const session = process.argv[2];
assert(session, "Pass an Android agent-device session serving native.tsx");
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function assertMeasurementParity(mode) {
const snapshot = JSON.parse(ad("snapshot", "--raw", "--json"));
assert.equal(snapshot.success, true);
const nodes = snapshot.data.nodes;
let last = nodes.find((node) => node.label?.endsWith("End of measurement fixture."));
assert(last?.rect, "The last paragraph must be visible, including its bottom");
// EnrichedMarkdownText exposes paragraph accessibility nodes inside its TextView;
// EnrichedMarkdown exposes the TextViews themselves. Measure the native child.
let host = nodes.find((node) => node.index === last.parentIndex);
if (host?.type === "android.widget.TextView") {
last = host;
host = nodes.find((node) => node.index === last.parentIndex);
}
assert.equal(host?.type, "android.widget.FrameLayout");
const viewport = nodes.find((node) => node.type === "android.widget.ScrollView");
const footer = nodes.find((node) => node.label === "End of Markdown host");
assert(viewport?.rect && footer?.rect, "The fixture and footer must be visible");
assert(
host.rect.y >= viewport.rect.y &&
footer.rect.y + footer.rect.height <= viewport.rect.y + viewport.rect.height,
"The whole fixture must fit in the viewport; clipped bounds cannot prove parity",
);
const hostBottom = host.rect.y + host.rect.height;
const childBottom = last.rect.y + last.rect.height;
console.log(
JSON.stringify({
mode,
host: host.rect,
lastChild: last.rect,
surplus: hostBottom - childBottom,
}),
);
assert(
Math.abs(hostBottom - childBottom) <= 1,
`Markdown reserves blank space in ${mode}: host bottom ${hostBottom}, last child bottom ${childBottom}`,
);
}
ad("press", 'label="Show wrapping fixture"');
ad("wait", "text", "End of Markdown host", "10000");
assertMeasurementParity("regular");
ad("press", 'label="Use compact text"');
ad("wait", "text", "Use regular text", "10000");
assertMeasurementParity("compact");
ad("press", 'label="Use regular text"');
ad("wait", "text", "Use compact text", "10000");
assertMeasurementParity("regular restored");
console.log("PASS Android Markdown host measurement matches its last child");
@@ -0,0 +1,68 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { Button, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { MarkdownRenderer } from "@/components/markdown/renderer";
const styles = StyleSheet.create((theme) => ({
root: { flex: 1 },
scroll: { backgroundColor: theme.colors.surface0 },
content: { padding: 32, paddingTop: 60 },
title: { color: theme.colors.foreground },
wrapping: { width: 300 },
}));
const queryClient = new QueryClient();
const source = [
"Plain: `MMMMiiii`",
"Bold: **`MMMMiiii`**",
"Italic: *`MMMMiiii`*",
"Both: ***`MMMMiiii`***",
"Strike: ~~`MMMMiiii`~~",
"Link: [`MMMMiiii`](https://example.com)",
"# Heading **`MMMMiiii`**",
"## Heading *`MMMMiiii`*",
"### Heading [`MMMMiiii`](https://example.com)",
"#### Heading ~~`MMMMiiii`~~",
"##### Heading ***`MMMMiiii`***",
"###### Heading `MMMMiiii`",
"- List **`MMMMiiii`**",
"> Quote **`MMMMiiii`**",
"Normal **bold** and *italic* prose.",
].join("\n\n");
const wrappingSource = [
"Measurement parity. Take a repo with a gitignored `.env` in the main checkout and a `.worktreeinclude` containing `.env`.",
"Every creation path goes through `createPaseoWorktreeWorkflow` in `worktree-session.ts:341`, before `createAgent` in `creation/index.ts:160-174` starts.",
"The built-in plugin lives in `plugins/worktree-include/index.server.ts` and copies `paseo.json` beside `.worktreeinclude`. End of measurement fixture.",
].join("\n\n");
export default function MarkdownFormattingRegression() {
const [wrapping, setWrapping] = useState(false);
const toggleWrapping = useCallback(() => setWrapping((value) => !value), []);
const [compact, setCompact] = useState(false);
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
return (
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={styles.root}>
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
<Text style={styles.title}>Inline code formatting regression</Text>
<Button
title={compact ? "Use regular text" : "Use compact text"}
onPress={toggleCompact}
/>
<Button
title={wrapping ? "Show formatting fixture" : "Show wrapping fixture"}
onPress={toggleWrapping}
/>
<View style={wrapping ? styles.wrapping : undefined}>
<MarkdownRenderer text={wrapping ? wrappingSource : source} compact={compact} />
</View>
<Text style={styles.title}>End of Markdown host</Text>
</ScrollView>
</GestureHandlerRootView>
</QueryClientProvider>
);
}
@@ -0,0 +1,40 @@
// Open a connected host with the wide sidebar visible, keyboard closed, and
// three-button navigation enabled on an Android tablet. Run against a fresh build:
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session, serial] = process.argv;
assert(session && serial, "Pass the agent-device session and adb serial");
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
assert(navBar, "A visible bottom navigation bar is required");
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
const navigationTop = Number(navBar[1]);
const { data } = JSON.parse(
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
encoding: "utf8",
timeout: 60_000,
}),
);
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
assert.equal(
focusedApp,
data.appBundleId,
"Snapshot must belong to the focused app on this device",
);
for (const id of [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
]) {
const button = data.nodes.find((node) => node.identifier === id);
assert(button?.rect, `${id} must be visible above the system navigation bar`);
const bottom = button.rect.y + button.rect.height;
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
}
@@ -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;
+5
View File
@@ -180,6 +180,11 @@ const test = daemonTest.extend<{
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
// New installs collapse finished turns; specs assert on the rows inside them, so they
// start from the full chat unless they seed their own settings.
if (!localStorage.getItem("@paseo:app-settings")) {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
}
},
{
daemon: testDaemon,
@@ -338,11 +338,32 @@ export async function delayAgentOlderTimelineResponse(
return delayAgentTimelineResponse(page, agentId, "before");
}
export interface OlderTimelinePagesOptions {
daemonPort?: number;
/**
* Hide whole-turn paging from the app so it reads this host with item pages, as it reads
* daemons released before whole-turn pages. An item page can end inside a turn or a tool run.
*/
itemPagedHost?: boolean;
}
function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message;
const envelope = parseWebSocketJson(message) as {
message: { payload: { features?: Record<string, unknown> } };
};
delete envelope.message.payload.features?.wholeTurnTimeline;
return JSON.stringify(envelope);
}
export async function holdAgentOlderTimelinePages(
page: Page,
agentId: string,
daemonPort?: number,
options: OlderTimelinePagesOptions = {},
): Promise<OlderTimelinePagesGate> {
const { daemonPort } = options;
let requestCount = 0;
let responseCount = 0;
let repeatedEntryCount = 0;
@@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages(
}
server.send(message);
});
server.onMessage((message) => {
server.onMessage((received) => {
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
@@ -87,6 +87,14 @@ export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
/** Long-press the Send button: the composer takes the draft while the pointer is still down. */
export async function holdSendButton(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).hover();
await page.mouse.down();
await expect(composerInput(page)).toHaveValue("", { timeout: 5_000 });
await page.mouse.up();
}
export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000,
@@ -19,6 +19,9 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
// Config updates before provider refresh finishes and dismisses the add sheet.
// Resizing or interacting with the model list must wait for that save to finish.
await expect(page.getByTestId("add-custom-model-sheet")).toBeHidden();
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
@@ -1,3 +1,7 @@
import { execFile, spawn } from "node:child_process";
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";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "./daemon-client-loader";
@@ -15,14 +19,27 @@ export async function openDaemonOverview(page: Page, daemon: IsolatedHostDaemon)
await openHostSection(page, daemon.serverId, "host");
}
export async function restartDaemonInSettings(page: Page) {
const button = page.getByRole("button", { name: "Restart", exact: true });
export async function restartDaemonInSettings(
page: Page,
options: { closeTerminals?: boolean } = {},
) {
const button = page.getByRole("button", {
name: options.closeTerminals ? "Restart and close terminals" : "Restart",
exact: true,
});
await expect(button).toBeEnabled();
page.once("dialog", (dialog) => dialog.accept());
await button.click();
await expect(page.getByRole("button", { name: "Restarting...", exact: true })).toBeDisabled();
}
export async function expectDaemonRestartFailed(page: Page, text: string) {
const error = page.getByTestId("host-page-restart-error");
await expect(error).toBeVisible({ timeout: 30_000 });
await expect(error).toContainText(text);
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled();
}
export async function expectDaemonRestartComplete(page: Page) {
await expect(page.getByRole("button", { name: "Restart", exact: true })).toBeEnabled({
timeout: 30_000,
@@ -40,3 +57,139 @@ export async function readWorkerPid(daemon: IsolatedHostDaemon): Promise<number>
await client.close();
}
}
/** Opens a terminal on the isolated daemon that runs a long-lived program. */
export async function openLongLivedTerminal(daemon: IsolatedHostDaemon): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminal",
});
try {
const workspace = await client.createWorkspace({
source: { kind: "directory", path: daemon.paseoHome },
title: "lifecycle",
});
if (!workspace.workspace) throw new Error(workspace.error ?? "Workspace creation failed");
const created = await client.createTerminal(daemon.paseoHome, "lifecycle", undefined, {
workspaceId: workspace.workspace.id,
command: process.execPath,
args: ["-e", "setInterval(() => {}, 1e9)"],
});
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
return created.terminal.id;
} finally {
await client.close();
}
}
export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<string[]> {
const client = await connectDaemonClient<DaemonClient>({
port: daemon.port,
clientIdPrefix: "lifecycle-terminals",
});
try {
return (await client.listTerminals()).terminals.map((terminal) => terminal.id);
} finally {
await client.close();
}
}
const execFileAsync = promisify(execFile);
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
env: { ...process.env, LC_ALL: "C" },
});
return stdout.trim();
}
export interface UnclosableTerminalProgram {
pid: number;
/** Ends the program's parent, which lets the system reap the program. */
release(): void;
}
/**
* Leaves in the daemon's home the record a dead terminal worker leaves, naming one terminal
* program that SIGKILL cannot remove: a zombie whose parent (an exec'd `sleep`) never waits for
* it. A kill cleanup can only report it as still running. POSIX only.
*/
export async function plantUnclosableTerminalProgram(
daemon: IsolatedHostDaemon,
): Promise<UnclosableTerminalProgram> {
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
stdio: ["ignore", "pipe", "ignore"],
});
parent.unref();
const release = () => {
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
};
try {
const pid = await new Promise<number>((resolve) => {
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
});
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
await new Promise((resolve) => gone.once("exit", resolve));
const record = {
recordId: "planted-record",
pid: gone.pid,
bootId: null,
createdAt: new Date().toISOString(),
identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" },
protocolVersion: 1,
paseoVersion: "0.0.0-test",
socketPath: path.join(daemon.paseoHome, "planted-worker.sock"),
secret: "planted-secret",
terminals: [{ id: "planted-terminal", pid, startedAt: await psField(pid, "lstart") }],
};
await writeFile(
path.join(daemon.paseoHome, "terminal-worker", `${gone.pid}.json`),
`${JSON.stringify(record, null, 2)}\n`,
{ mode: 0o600 },
);
return { pid, release };
} catch (error) {
release();
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();
}
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
await expectMobileAgentSidebarVisible(this.page);
}
await openGlobalNewWorkspaceComposer(this.page);
const button = this.page.getByTestId("new-workspace-import-session");
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
await expect(button).toHaveAccessibleName("Import session");
await expect(button).toBeInViewport();
}
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
await this.openFromNewWorkspace();
}
async openFromNewWorkspace() {
await this.page.getByTestId("new-workspace-import-session").click();
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
await this.expectSheetReady();
}
async openFromWorkspaceHeader() {
@@ -86,7 +86,29 @@ export class ImportSessionFlow {
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
}
async revealSession(id: string) {
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
const target = this.page.getByTestId(rowTestId(id));
if ((await target.count()) === 0) {
await this.scrollList(false);
await expect(async () => {
if ((await target.count()) === 0) await this.scrollList(true);
await expect(target).toHaveCount(1, { timeout: 100 });
}).toPass({ timeout: 10_000 });
}
await target.scrollIntoViewIfNeeded();
await expect(target).toBeInViewport();
}
private async scrollList(forward: boolean) {
await this.page
.locator('[data-testid^="import-session-session-"]')
.first()
.evaluate((row, advance) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY))
element = element.parentElement;
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
element.dispatchEvent(new Event("scroll"));
}, forward);
}
async search(query: string) {
await this.page.getByTestId("import-session-search").fill(query);
@@ -100,7 +122,8 @@ export class ImportSessionFlow {
}
async loadMore() {
await this.page.getByTestId("import-session-load-more").click();
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
await this.revealSession("fixture-root-20");
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
}
async retryProvider(provider: string, label: string) {
@@ -111,6 +134,7 @@ export class ImportSessionFlow {
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
}
async importSession(id: string) {
await this.revealSession(id);
await this.page.getByTestId(rowTestId(id)).click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
timeout: 30_000,
@@ -138,7 +162,7 @@ export class ImportSessionFlow {
);
}
async close() {
await this.page.keyboard.press("Escape");
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
}
async expectCommandCenterMatch() {
@@ -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;
@@ -152,6 +161,24 @@ export async function startIsolatedHostDaemon(
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 +196,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 +218,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 +236,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;
},
};
}
@@ -0,0 +1,159 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { settingsRpc } from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { z } from "zod";
import { expect, type Page } from "../fixtures";
import { gotoAppShell } from "./app";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { connectDaemonClient } from "./daemon-client-loader";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
const PLUGIN_ID = "retained-plugin-e2e";
export function retainedPluginSource(version: string) {
return `import { useState } from "react";
import { Text, View, TextInput, Pressable } from "react-native";
import { useSettings } from "@getpaseo/plugin/client";
import { preferences } from "./shared/preferences";
function Preferences({ color }) {
const settings = useSettings(preferences);
return <Text style={{ color }}>{settings.status === "ready" ? "Stored title: " + settings.values.title : "Preferences " + settings.status}</Text>;
}
function Surface({ theme }) {
const [draft, setDraft] = useState("");
const [clicks, setClicks] = useState(0);
const [showPreferences, setShowPreferences] = useState(true);
const color = theme.colors.foreground;
return <View>
<Text style={{ color }}>Plugin version ${version}</Text>
<TextInput accessibilityLabel="Plugin draft" value={draft} onChangeText={setDraft} style={{ color }} />
<Pressable accessibilityRole="button" onPress={() => setClicks(value => value + 1)}><Text style={{ color }}>{"Local clicks " + clicks}</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => setShowPreferences(value => !value)}><Text style={{ color }}>{showPreferences ? "Hide preferences" : "Show preferences"}</Text></Pressable>
{showPreferences ? <Preferences color={color} /> : null}
</View>;
}
export default function contribute(plugin) {
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: "Reconnect probe", icon: "PanelsTopLeft", surface: "main" });
return () => {};
}`;
}
export async function withRetainedPlugin(
page: Page,
run: (probe: Awaited<ReturnType<typeof createProbe>>) => Promise<void>,
) {
const probe = await createProbe(page);
try {
await run(probe);
} finally {
await probe.close();
}
}
async function createProbe(page: Page) {
const example = await copyPluginExample("settings");
const peer = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "plugin-reconnect" });
const previous = await peer.getDaemonConfig();
const gate = await installDaemonWebSocketGate(page);
const clientEntry = path.join(example.directory, "index.client.tsx");
let catalogCount = 0;
try {
await writeFile(
path.join(example.directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(clientEntry, retainedPluginSource("one"));
await peer.patchDaemonConfig({ pluginsEnabled: true });
await peer.installDirectoryPlugin(example.directory);
} catch (error) {
await peer.close();
await example.cleanup();
throw error;
}
return {
async open() {
await page.setViewportSize({ width: 1100, height: 800 });
await gotoAppShell(page);
await page.getByRole("button", { name: "Reconnect probe", exact: true }).click();
await expect(page.getByText("Stored title: Agent monitor", { exact: true })).toBeVisible();
},
async disconnect() {
catalogCount = gate.getClientRequestCount("plugin.catalog.get.request");
await gate.drop();
await gate.waitForBlockedConnection();
},
async reconnect() {
gate.restore();
await gate.waitForServerMessage("plugin.catalog.get.response", catalogCount + 1);
},
async changeStoredTitle(title: string) {
const rpc = settingsRpc("display");
const current = rpc.read.output.parse(
await peer.invokePluginRpc(PLUGIN_ID, rpc.read.name, {}),
);
if (current.status !== "ready") throw new Error("Expected ready settings");
const values = z
.object({ groupBy: z.string(), showMetadata: z.boolean(), title: z.string() })
.parse(current.values);
const result = await peer.invokePluginRpc(PLUGIN_ID, rpc.write.name, {
revision: current.revision,
values: { ...values, title },
});
expect(result).toMatchObject({ status: "saved", values: { title } });
},
async changeCode() {
await writeFile(clientEntry, retainedPluginSource("two"));
await peer.reloadPlugin(PLUGIN_ID);
},
async remove() {
await peer.removePlugin(PLUGIN_ID);
},
async close() {
gate.restore();
await peer.removePlugin(PLUGIN_ID).catch(() => undefined);
await peer.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await peer.close();
await example.cleanup();
},
};
}
export async function editRetainedDraft(page: Page, draft: string, clicks: number) {
await page.getByRole("textbox", { name: "Plugin draft", exact: true }).fill(draft);
await page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }).click();
await expectRetainedDraft(page, draft, clicks + 1);
}
export async function expectRetainedDraft(page: Page, draft: string, clicks: number) {
await expect(page.getByRole("textbox", { name: "Plugin draft", exact: true })).toHaveValue(draft);
await expect(
page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }),
).toBeVisible();
}
export async function expectStoredPluginTitle(page: Page, title: string) {
await expect(page.getByText(`Stored title: ${title}`, { exact: true })).toBeVisible();
}
export async function hideRetainedPreferences(page: Page) {
await page.getByRole("button", { name: "Hide preferences", exact: true }).click();
await expect(page.getByText(/^Stored title:/)).toHaveCount(0);
}
export async function showRetainedPreferences(page: Page) {
await page.getByRole("button", { name: "Show preferences", exact: true }).click();
}
export async function expectChangedPlugin(page: Page) {
await expect(page.getByText("Plugin version two", { exact: true })).toBeVisible();
await expectRetainedDraft(page, "", 0);
}
export async function expectRemovedPlugin(page: Page) {
await expect(
page.getByText("This plugin surface is unavailable.", { exact: true }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Reconnect probe", exact: true })).toHaveCount(0);
}
@@ -5,8 +5,64 @@ import { expect, type Page, type TestInfo } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
import { expectReconnectingToastVisible } from "./workspace-ui";
const PLUGIN_ID = "timeline-transform-e2e";
/** Observe the app's real socket, including fresh sockets after a reload. */
export function trackPluginChatRecovery(page: Page) {
const messages: {
type: string;
payload?: { plugins?: { id: string; clientBundle?: string }[] };
}[] = [];
page.on("websocket", (socket) => {
const collect = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.type === "session") messages.push(frame.message);
};
socket.on("framesent", collect);
socket.on("framereceived", collect);
});
return {
reset() {
messages.length = 0;
},
async expectCatalogWithoutRetransmission() {
await expect
.poll(() =>
messages.some(
(message) =>
message.type === "plugin.catalog.get.response" &&
message.payload?.plugins?.some((plugin) => plugin.id === PLUGIN_ID),
),
)
.toBe(true);
const entry = messages
.find((message) => message.type === "plugin.catalog.get.response")
?.payload?.plugins?.find((plugin) => plugin.id === PLUGIN_ID);
expect(entry).toBeDefined();
expect(entry).not.toHaveProperty("clientBundle");
},
async expectRecoveredWithoutRetransmission() {
await this.expectCatalogWithoutRetransmission();
await expect
.poll(() => messages.some((message) => message.type === "fetch_agent_timeline_response"))
.toBe(true);
},
};
}
export async function reconnectPluginChat(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {
await gate.drop();
await gate.waitForBlockedConnection();
await expectReconnectingToastVisible(page);
gate.restore();
}
export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
@@ -116,7 +172,7 @@ export async function withTimelinePlugin(
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
});
const agent = await seedMockAgentWorkspace({
@@ -0,0 +1,78 @@
import type { Page } from "@playwright/test";
declare global {
var __PASEO_E2E_REACT_RENDERS__: Record<string, number> | undefined;
}
/**
* Counts which components render in each React commit, by name, the way React DevTools
* highlights updates: a fiber rendered when React performed work on it in that commit.
* Install before the app loads; React registers with the DevTools hook at startup.
*/
export async function recordReactRenders(page: Page): Promise<void> {
await page.addInitScript(() => {
const PERFORMED_WORK = 1;
interface Fiber {
type: unknown;
flags: number;
child: Fiber | null;
sibling: Fiber | null;
alternate: Fiber | null;
}
const nameOf = (type: unknown): string | null => {
if (typeof type === "function") {
return (type as { displayName?: string }).displayName || type.name || null;
}
if (!type || typeof type !== "object") return null;
const component = type as { displayName?: string; render?: unknown; type?: unknown };
return component.displayName || nameOf(component.render) || nameOf(component.type);
};
const record = (root: Fiber) => {
const renders = (globalThis.__PASEO_E2E_REACT_RENDERS__ ??= {});
const pending: Fiber[] = [root];
while (pending.length > 0) {
const fiber = pending.pop()!;
const name = typeof fiber.type === "string" ? null : nameOf(fiber.type);
if (name && (fiber.flags & PERFORMED_WORK) !== 0) renders[name] = (renders[name] ?? 0) + 1;
// A subtree React bailed out of is shared with the previous commit; skip it.
if (fiber.alternate && fiber.child === fiber.alternate.child) continue;
for (let child = fiber.child; child; child = child.sibling) pending.push(child);
}
};
let rendererCount = 0;
Object.defineProperty(globalThis, "__REACT_DEVTOOLS_GLOBAL_HOOK__", {
configurable: true,
value: {
supportsFiber: true,
isDisabled: false,
renderers: new Map(),
inject(renderer: unknown) {
rendererCount += 1;
this.renderers.set(rendererCount, renderer);
return rendererCount;
},
checkDCE() {},
onScheduleFiberRoot() {},
onCommitFiberRoot(_rendererId: number, root: { current: Fiber }) {
record(root.current);
},
onPostCommitFiberRoot() {},
onCommitFiberUnmount() {},
},
});
});
}
export async function resetReactRenders(page: Page): Promise<void> {
await page.evaluate(() => {
globalThis.__PASEO_E2E_REACT_RENDERS__ = {};
});
}
/** Render counts per component name since the last reset, read after the next painted frame. */
export async function readReactRenders(page: Page): Promise<Record<string, number>> {
return page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return { ...globalThis.__PASEO_E2E_REACT_RENDERS__ };
});
}
@@ -28,6 +28,10 @@ interface SeedProjectDescriptor {
* prefer those wrappers over reaching for this client directly.
*/
export interface SeedDaemonClient {
patchDaemonConfig(
config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch,
): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
connect(): Promise<void>;
close(): Promise<void>;
addProject(cwd: string): Promise<{
@@ -149,7 +153,11 @@ export interface SeedDaemonClient {
timeout?: number,
): Promise<{ status: string }>;
cancelAgent(agentId: string): Promise<void>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string },
): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
@@ -42,6 +42,7 @@ type HostSection =
| "pair-device"
| "agents"
| "metadata"
| "voice"
| "workspaces"
| "providers"
| "usage"
@@ -57,6 +58,92 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
await expectAppRoute(page, buildSettingsSectionRoute(section));
}
export async function revealSettingsSidebarSection(
page: Page,
section: SettingsSection,
): Promise<number> {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: SECTION_LABELS[section], exact: true })
.scrollIntoViewIfNeeded();
const offset = await page
.getByTestId("settings-sidebar-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollSettled(
page: Page,
offset: number,
): Promise<void> {
// RN Web emits its final scroll callback 100 ms after scrolling stops.
await page.waitForTimeout(200);
await expectSettingsSidebarScrollOffset(page, offset);
}
export async function selectSettingsHostSectionsRapidly(page: Page): Promise<void> {
for (let selection = 0; selection < 5; selection += 1) {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Providers", exact: true })
.click();
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Terminals", exact: true })
.click();
await expectSettingsHeader(page, "Terminals");
}
}
export async function scrollSettingsSidebarUp(page: Page, pixels: number): Promise<void> {
await page.getByTestId("settings-sidebar-scroll-body").hover();
await page.mouse.wheel(0, -pixels);
}
export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise<void> {
const input = page.getByRole("textbox", { name: "Interface font size" });
await input.fill(String(size));
await input.press("Tab");
await expect(input).toHaveValue(String(size));
}
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await scrollBody.hover();
await page.mouse.wheel(0, 2000);
await expect
.poll(() =>
scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop),
)
.toBe(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function scrollSettingsDetailDown(page: Page): Promise<number> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect
.poll(() => scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight))
.toBeGreaterThan(0);
await scrollBody.hover();
await page.mouse.wheel(0, 1000);
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
return offset;
}
export async function expectSettingsDetailScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
// Host sections are now flat top-level rows under the Host group. Navigate by
// clicking the Connections section row; the picker only matters when >1 host.
@@ -264,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);
}
@@ -326,6 +428,25 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
function idleAgentReleaseSwitch(page: Page) {
return page
.getByTestId("host-page-idle-runtime-cleanup-card")
.getByRole("switch", { name: "Release idle agents" });
}
export async function expectIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
const toggle = idleAgentReleaseSwitch(page);
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-checked", String(enabled));
}
export async function setIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
const toggle = idleAgentReleaseSwitch(page);
await expect(toggle).toHaveAttribute("aria-checked", String(!enabled));
await toggle.click();
await expectIdleAgentRelease(page, enabled);
}
export async function openHostSection(
page: Page,
serverId: string,
@@ -454,3 +575,41 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
}
function restartRecoverySwitch(page: Page) {
return page.getByRole("switch", { name: "Resume agents after restart", exact: true });
}
function restartRecoveryEditButton(page: Page) {
return page.getByRole("button", { name: "Edit prompt", exact: true });
}
export async function setRestartRecoveryEnabled(page: Page, enabled: boolean) {
const toggle = restartRecoverySwitch(page);
await toggle.click();
await expectRestartRecoveryEnabled(page, enabled);
}
// The prompt is only editable while restart recovery is on.
export async function expectRestartRecoveryEnabled(page: Page, enabled: boolean) {
await expect(restartRecoverySwitch(page)).toBeChecked({ checked: enabled });
await expect(restartRecoveryEditButton(page)).toBeEnabled({ enabled });
}
export async function editRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await sheet.getByRole("textbox", { name: "Resume prompt", exact: true }).fill(prompt);
await sheet.getByRole("button", { name: "Save", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await expect(sheet.getByRole("textbox", { name: "Resume prompt", exact: true })).toHaveValue(
prompt,
);
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
+16 -1
View File
@@ -5,7 +5,7 @@ export function sheet(page: Page, label: string): Locator {
return page.getByRole("dialog", { name: label });
}
/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
/** A sheet tab's header, which carries its title, Back, Close, and the Open as tab glyph. */
export function sheetHeader(page: Page, label: string): Locator {
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
}
@@ -48,6 +48,12 @@ export async function closeSheet(page: Page, label: string): Promise<void> {
await expect(sheet(page, label)).toHaveCount(0);
}
/** Steps a sheet opened from a list back to that list, in the same sheet. */
export async function backToList(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Back" }).click();
await expect(sheet(page, label)).toHaveCount(0);
}
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
await expect(sheet(page, label)).toHaveCount(0);
@@ -76,6 +82,15 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
/** Each open sheet renders its own backdrop, so counting them counts the sheets. */
export async function expectOneSheetOpen(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
}
export async function expectNoSheetOpen(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
export async function tapSheetBackdrop(page: Page): Promise<void> {
await page
@@ -63,8 +63,9 @@ export async function seedSidebarFooterPreferences(
): Promise<void> {
await page.addInitScript(
({ key, sidebarFooterItems }) => {
if (localStorage.getItem(key)) return;
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
if (settings.sidebarFooterItems) return;
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
},
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
);
+19 -8
View File
@@ -3,7 +3,7 @@ import { startIsolatedHostDaemon } from "./isolated-host-daemon";
import { seedWorkspace } from "./seed-client";
import { getE2EDaemonPort } from "./daemon-port";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import type { SeededWorkspace } from "./seed-client";
@@ -151,10 +151,21 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
await expect(panel).toBeVisible({ timeout: 30_000 });
}
/** On compact the track opens as a sheet, which closes once a row is chosen. */
export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise<void> {
await page.getByTestId("subagents-track-header").click();
await page.getByTestId(`subagents-track-row-${childId}`).click();
/**
* On compact the track opens as a sheet, and the chosen row opens in its place. `track` is the
* pane whose track to open, for when a subagent's sheet shows a track of its own.
*/
export async function openSubagentFromCompactTrack(
page: Page,
childId: string,
track: Page | Locator = page,
): Promise<void> {
await track.getByTestId("subagents-track-header").click();
const row = page.getByTestId(`subagents-track-row-${childId}`);
// Tap the row once the sheet has brought it on screen, as a finger would. Clicking it while it is
// still below the fold makes Playwright scroll the sheet host, and every sheet then docks short.
await expect(row).toBeInViewport();
await row.click();
}
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
@@ -188,7 +199,7 @@ export async function expectManagedSubagentArchived(
}
export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("button", { name: "Archive finished subagents" }).click();
await page.getByRole("menuitem", { name: "Archive finished subagents" }).click();
}
export async function expectArchiveFinishedInProgress(
@@ -196,7 +207,7 @@ export async function expectArchiveFinishedInProgress(
completed: number,
total: number,
): Promise<void> {
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
}
@@ -206,7 +217,7 @@ export async function expectArchiveFinishedRetry(
total: number,
): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeEnabled();
}
export async function holdManagedSubagentArchiveRequest(
@@ -1,4 +1,5 @@
import { expect, type Page } from "@playwright/test";
import type { Terminal } from "@xterm/xterm";
import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes";
import { getServerId } from "./server-id";
@@ -206,3 +207,100 @@ export function computePercentile(samples: number[], p: number): number {
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/** Observe actual cursor pixels and blink phases through xterm's public render event. */
export async function expectTerminalCursor(
page: Page,
expected: { shape: "bar" | "underline"; blinking: boolean },
): Promise<void> {
const observed = await page.evaluate(async () => {
const terminal = (window as Window & { __paseoTerminal?: Terminal }).__paseoTerminal;
if (!terminal) throw new Error("Terminal did not mount");
const source = Array.from(document.querySelectorAll(".xterm-screen canvas")).find(
(canvas): canvas is HTMLCanvasElement =>
canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2") !== null,
);
if (!source) throw new Error("Cursor verification requires the actual WebGL renderer");
const copy = document.createElement("canvas");
const context = copy.getContext("2d");
if (!context) throw new Error("Cursor pixel capture is unavailable");
terminal.focus();
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
if (!terminal.element?.contains(document.activeElement))
throw new Error("Terminal did not focus");
const started = performance.now();
const frames: Array<{ time: number; width: number; height: number }> = [];
const rendered = terminal.onRender(() => {
copy.width = source.width;
copy.height = source.height;
// WebGL's default drawing buffer must be copied before this render returns.
context.drawImage(source, 0, 0);
const cellWidth = source.width / terminal.cols;
const cellHeight = source.height / terminal.rows;
const x = Math.round(terminal.buffer.active.cursorX * cellWidth);
const y = Math.round(terminal.buffer.active.cursorY * cellHeight);
const width = Math.ceil(cellWidth);
const height = Math.ceil(cellHeight);
const background = context.getImageData(x + width + 2, y, 1, 1).data;
const pixels = context.getImageData(x, y, width, height).data;
let minX = width;
let maxX = -1;
let minY = height;
let maxY = -1;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
const offset = (row * width + col) * 4;
if (
![0, 1, 2, 3].some(
(channel) => Math.abs(pixels[offset + channel]! - background[channel]!) > 20,
)
)
continue;
minX = Math.min(minX, col);
maxX = Math.max(maxX, col);
minY = Math.min(minY, row);
maxY = Math.max(maxY, row);
}
}
frames.push({
time: performance.now() - started,
width: maxX < 0 ? 0 : (maxX - minX + 1) / cellWidth,
height: maxY < 0 ? 0 : (maxY - minY + 1) / cellHeight,
});
});
try {
const refresh = setInterval(() => terminal.refresh(0, terminal.rows - 1), 40);
try {
await new Promise((resolve) => setTimeout(resolve, 1800));
} finally {
clearInterval(refresh);
}
} finally {
rendered.dispose();
}
return {
frames,
focused: terminal.element?.contains(document.activeElement),
samples: frames.length,
width: Math.max(...frames.map((frame) => frame.width)),
height: Math.max(...frames.map((frame) => frame.height)),
blinking:
frames.filter(
(frame, index) => index > 0 && (frame.width === 0) !== (frames[index - 1]!.width === 0),
).length >= 2,
};
});
expect(observed.samples).toBeGreaterThan(5);
expect(observed.blinking, JSON.stringify(observed)).toBe(expected.blinking);
if (expected.shape === "bar") {
expect(observed.width).toBeGreaterThan(0);
expect(observed.width).toBeLessThan(0.4);
expect(observed.height).toBeGreaterThan(0.6);
} else {
expect(observed.width).toBeGreaterThan(0.6);
expect(observed.height).toBeGreaterThan(0);
expect(observed.height).toBeLessThan(0.4);
}
}
@@ -1,4 +1,4 @@
import { expect, type TestInfo, type Page } from "@playwright/test";
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import {
@@ -9,6 +9,7 @@ import {
type AgentTimelineResponseGate,
type BootstrapTimelineGate,
type OlderTimelinePagesGate,
type OlderTimelinePagesOptions,
} from "./agent-timeline-gate";
export { holdDaemonHydration };
@@ -35,6 +36,19 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
newestPrompt: string;
}
export interface ItemPagedToolGroupAgent extends MockAgentWorkspace {
newestPrompt: string;
/** Commands in one run, more than an item page holds. */
totalCommands: number;
/** Commands of that run the initial tail loads, so the size of the group as first shown. */
loadedCommands: number;
}
interface ToolCallGroupPositionSnapshot {
top: number;
}
const ITEM_PAGE_SIZE = 40;
const PROMPT_PREFIX = "timeline-pagination-turn";
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
const HISTORY_START_THRESHOLD_PX = 96;
@@ -126,6 +140,93 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
};
}
/**
* A run of commands longer than an item page, followed by enough short turns that the
* initial tail ends inside the run. Read with item pages, the tail loads the newest
* commands as one group; the older page carries the rest of that run and the turns before
* it, with a further page behind it.
*/
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-item-page-tool-group-",
title: "Item page tool group regression",
});
const olderTurns = 10;
const newerTurns = 12;
const totalCommands = 50;
// A short turn is its prompt and one reply; the run's turn ends with one reply.
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
const shortTurnPrompt = (label: string, index: number) =>
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
const sendTurn = async (prompt: string) => {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 15_000);
};
for (let index = 0; index < olderTurns; index += 1) {
await sendTurn(shortTurnPrompt("older", index));
}
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
for (let index = 0; index < newerTurns; index += 1) {
await sendTurn(shortTurnPrompt("newer", index));
}
return {
...agent,
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
totalCommands,
loadedCommands,
};
}
export async function useChatDisplay(
page: Page,
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
): Promise<void> {
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
}, chatDisplay);
}
function toolCallGroup(page: Page, commandCount: number): Locator {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
return timeline
.getByTestId("tool-call-group")
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
}
export async function rememberToolCallGroupPosition(
page: Page,
commandCount: number,
): Promise<ToolCallGroupPositionSnapshot> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
const box = await group.boundingBox();
if (!box) {
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
}
return { top: box.y };
}
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
export async function expectToolCallGroupPositionPreserved(
page: Page,
commandCount: number,
before: ToolCallGroupPositionSnapshot,
): Promise<void> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
await expect
.poll(
async () => {
const box = await group.boundingBox();
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
},
{ message: `the group of ${commandCount} commands keeps its viewport position` },
)
.toBeLessThanOrEqual(2);
}
export async function scrollThroughOlderHistoryPages(
page: Page,
pageCount: number,
@@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage(
export async function holdOlderHistoryPages(
page: Page,
agent: MockAgentWorkspace,
daemonPort?: number,
options?: OlderTimelinePagesOptions,
): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
page,
agent.agentId,
daemonPort,
options,
);
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
@@ -330,7 +432,17 @@ export async function holdOlderHistoryPages(
},
async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page);
expect(gate.getRequestCount()).toBe(count);
// The loader stays until the prepend settles and the viewport decides whether to
// keep loading. Once it hides, no further page loads without new user intent.
await expect
.poll(
async () => ({
requestedPages: gate.getRequestCount(),
loading: await loader.isVisible(),
}),
{ message: `older history settles after ${count} requested page(s)` },
)
.toEqual({ requestedPages: count, loading: false });
},
expectNoRepeatedEntries() {
expect(gate.getRepeatedEntryCount()).toBe(0);
@@ -423,6 +535,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
}
/**
* Item-based pages from older hosts split turns, so the oldest loaded turn starts
* mid-turn: its prompt is above loaded history. Returns that prompt.
*/
export async function expectOldestLoadedTurnPartial(
page: Page,
prompts: readonly string[],
): Promise<string> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const oldestLoadedPrompt = await timeline
.getByTestId("user-message")
.first()
.evaluate((element) => element.textContent ?? "");
const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt));
expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0);
const oldestRow = timeline.locator("[data-history-row-id]").first();
await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount(
0,
);
const partialPrompt = prompts[loadedIndex - 1]!;
await expectTimelinePromptNotMounted(page, partialPrompt);
return partialPrompt;
}
/** The reader stays with the turn they were reading once it folds behind its summary. */
export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
await expect(promptRow).toBeVisible({ timeout: 15_000 });
// The host row carries the folded turn's summary line.
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
await expect
.poll(
async () => {
const [timelineBox, promptBox] = await Promise.all([
timeline.boundingBox(),
promptRow.boundingBox(),
]);
if (!timelineBox || !promptBox) return false;
const offset = promptBox.y - timelineBox.y;
return offset >= 0 && offset <= timelineBox.height / 2;
},
{ message: "folded turn sits in the upper half of the viewport" },
)
.toBe(true);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
@@ -0,0 +1,125 @@
import type { Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
export async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let pauseAfterSecondRunning = false;
let releaseSecondRequested = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (secondRunningMessage) {
ws.send(secondRunningMessage);
secondRunningMessage = null;
}
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseAfterSecondRunning) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
ws.send(message);
if (toolCall.status === "completed") {
resolveFirstCompleted();
}
return;
}
secondCallId ??= toolCall.callId;
if (toolCall.callId === secondCallId) {
const transformedMessage =
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
secondRunningMessage = transformedMessage;
pauseAfterSecondRunning = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
forwardSecondRunning?.();
},
};
}
@@ -25,6 +25,7 @@ interface UsageReportsFixtureOptions {
* app connects, so a reload can switch it.
*/
usageSupported?: boolean | (() => boolean);
usageSettingSupported?: boolean;
/** Released hosts expose provider.usage.list with no source icons. */
providerUsageListOnly?: boolean;
/** The host daemon's port; defaults to the E2E daemon. */
@@ -63,6 +64,7 @@ function withUsageSupportFeature(
message: WebSocketMessage,
enabled: boolean,
providerUsageListOnly: boolean,
usageSettingSupported: boolean,
): string | null {
const envelope = parseJson(message) as {
type?: unknown;
@@ -86,6 +88,7 @@ function withUsageSupportFeature(
...payload,
features: {
...features,
usageSetting: usageSettingSupported ? true : undefined,
usageSources: enabled && !providerUsageListOnly,
providerUsageList: enabled,
},
@@ -229,7 +232,12 @@ export async function installUsageReportsFixture(
server.onMessage((message) => {
const serverInfo =
typeof message === "string"
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
? withUsageSupportFeature(
message,
isUsageSupported(),
providerUsageListOnly,
options.usageSettingSupported ?? true,
)
: null;
ws.send(serverInfo ?? message);
});
@@ -0,0 +1,99 @@
import { rename, mkdir, rmdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectSettingsHeader, openHostSection, openSettingsHost } from "./settings";
export type SpeechSiteLabel = "Speech-to-text" | "Listening" | "Speaking";
type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts";
export async function openVoiceSettings(page: Page): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "voice");
await expectSettingsHeader(page, "Voice");
}
function speechSiteRow(page: Page, label: SpeechSiteLabel): Locator {
return page.getByRole("button", { name: new RegExp(`^${label}, `) });
}
export async function expectSpeechSiteSummary(
page: Page,
label: SpeechSiteLabel,
summary: string,
): Promise<void> {
await expect(speechSiteRow(page, label)).toHaveAccessibleName(`${label}, ${summary}`);
}
/** Opens the provider sheet for one use site and returns it. */
export async function openSpeechSite(page: Page, label: SpeechSiteLabel): Promise<Locator> {
await speechSiteRow(page, label).click();
const sheet = page.getByRole("dialog");
await expect(sheet.getByText(label, { exact: true })).toBeVisible();
return sheet;
}
export function providerOption(sheet: Locator, provider: "Local" | "OpenAI"): Locator {
return sheet.getByRole("button", { name: provider, exact: true });
}
export function apiKeyInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "API key" });
}
export function baseUrlInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "Base URL" });
}
export async function saveSpeechSite(sheet: Locator): Promise<void> {
await sheet.getByRole("button", { name: "Save", exact: true }).click();
}
export async function expectSheetClosed(page: Page): Promise<void> {
await expect(page.getByRole("dialog")).toHaveCount(0);
}
/** The speech site as the daemon reports it; the API key itself never leaves the daemon. */
export async function readSpeechSite(siteId: SpeechSiteId): Promise<Record<string, unknown>> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const { config } = await client.getDaemonConfig();
const features = config.features as Record<string, Record<string, unknown>>;
const [feature, site] = siteId.split(".");
return features[feature][site] as Record<string, unknown>;
} finally {
await client.close().catch(() => undefined);
}
}
/** Puts a speech site back to the default provider so later tests start from a known state. */
export async function resetSpeechSite(siteId: SpeechSiteId): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const [feature, site] = siteId.split(".");
await client.patchDaemonConfig({ features: { [feature]: { [site]: { provider: "local" } } } });
} finally {
await client.close().catch(() => undefined);
}
}
/** Makes the daemon's config file unreadable for the duration of `run`. */
export async function withUnwritableDaemonConfig(run: () => Promise<void>): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is required to break the daemon config");
const configPath = path.join(paseoHome, "config.json");
const asidePath = `${configPath}.aside`;
await rename(configPath, asidePath);
await mkdir(configPath);
try {
await run();
} finally {
await rmdir(configPath);
await rename(asidePath, configPath);
}
}
@@ -1,12 +1,47 @@
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
} from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id";
export async function seedForeignPinnedAgentTab(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
.parse(JSON.parse(raw));
const key = buildWorkspaceTabPersistenceKey(input);
const layout = key ? stored.state.layoutByWorkspace[key] : null;
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
const opened = openTabInLayoutFocused({
layout,
target: { kind: "agent", agentId: input.agentId },
now: 1,
placement: { mode: "focused" },
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
});
if (!opened) throw new Error("Failed to seed foreign agent tab");
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
pinnedAgentIdsByWorkspace: {
...stored.state.pinnedAgentIdsByWorkspace,
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
+3
View File
@@ -15,6 +15,9 @@
}
},
"production": {
"ios": {
"image": "macos-tahoe-26.6-xcode-27.1"
},
"env": {
"APP_VARIANT": "production"
},
+64 -1
View File
@@ -1,7 +1,47 @@
require "fileutils"
require "tmpdir"
default_platform(:ios)
APP_IDENTIFIER = "sh.paseo"
# Store screenshots are rendered into assets/store-screenshots/output; see the
# README there. Google Play and F-Droid both read fastlane/metadata/android at
# the repo root, whose screenshot files link to those renders.
REPO_ROOT = File.expand_path("../../..", __dir__)
STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output")
ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android")
# deliver reads screenshots from one folder per language and picks the device
# slot from each image's size, so both Apple sets go into en-US together.
def stage_app_store_screenshots
root = Dir.mktmpdir("app-store-screenshots")
locale = File.join(root, "en-US")
FileUtils.mkdir_p(locale)
{ "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device|
Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file|
FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}"))
end
end
root
end
# Google Play only has en-US screenshots, and some F-Droid locales in
# ANDROID_METADATA are not Play languages (Play rejects "ja"). supply uploads
# every locale folder in one edit, so give it only the en-US images.
def stage_google_play_screenshots
root = Dir.mktmpdir("google-play-screenshots")
images = File.join(ANDROID_METADATA, "en-US/images")
%w[phoneScreenshots tenInchScreenshots].each do |kind|
target = File.join(root, "en-US/images", kind)
FileUtils.mkdir_p(target)
Dir.glob(File.join(images, kind, "*.png")).sort.each do |file|
FileUtils.cp(File.realpath(file), File.join(target, File.basename(file)))
end
end
root
end
platform :ios do
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
@@ -87,7 +127,8 @@ platform :ios do
},
force: true,
skip_binary_upload: true,
skip_screenshots: true,
screenshots_path: stage_app_store_screenshots,
overwrite_screenshots: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false,
@@ -96,3 +137,25 @@ platform :ios do
)
end
end
platform :android do
desc "Replace the Google Play store screenshots with the ones in the repo"
lane :upload_screenshots do
# supply attaches listing changes to a release on the production track.
# The release workflow passes the version code it just submitted; without
# it, supply uses the track's only release.
version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip
supply(
package_name: APP_IDENTIFIER,
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
metadata_path: stage_google_play_screenshots,
version_code: version_code.empty? ? nil : version_code.to_i,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
sync_image_upload: true,
)
end
end
@@ -0,0 +1,25 @@
apply plugin: 'com.android.library'
group = 'sh.paseo.terminal'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.terminal"
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
dependencies {
implementation "androidx.customview:customview:1.1.0"
testImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test:runner:1.6.2"
androidTestImplementation "androidx.test.ext:junit:1.2.1"
}
@@ -0,0 +1,38 @@
[
{
"output": "स्ते",
"row": "4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "क्ष",
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "নক্ষত্র",
"row": "1,1,3,1,3,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "क्अ",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्अ ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "ক্অ",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;ক্অ ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
},
{
"output": "अ्क",
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
}
]
@@ -0,0 +1,132 @@
package sh.paseo.terminal
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
private const val COLUMNS = 8
/** Rasters that draw the same clusters match exactly; a wrong clustering differs by 0.12 or more. */
private const val MATCH_TOLERANCE = 0.01f
@RunWith(AndroidJUnit4::class)
class TerminalRowLayoutTest {
private val font = TerminalFont("monospace", 40f, 1f)
private val paints = TerminalPaints()
/**
* Draws each row of `grapheme-rows.json` and compares it with its grapheme clusters drawn one at a
* time, each centred in its own columns. `terminal-frame.test.ts` checks that the rows are what
* xterm produces and that the clusters are UAX #29 grapheme clusters.
*/
@Test
fun drawsEachGraphemeClusterInItsOwnColumns() {
val assets = InstrumentationRegistry.getInstrumentation().context.assets
val rows = JSONArray(assets.open("grapheme-rows.json").bufferedReader().readText())
for (index in 0 until rows.length()) {
val row = rows.getJSONObject(index)
val source = TerminalRowSource.decode(row.getString("row"))
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
// Selection covers row 0 only, so drawing as row 1 draws nothing selected.
val drawn = raster { layout.drawSelected(it, 1, TerminalSelection(TerminalSelectionRecord()), paints) }
val expected = raster { drawClusters(it, source, starts(row, "clusterStarts")) }
val contrast = raster { drawClusters(it, source, starts(row, "contrastStarts")) }
val output = row.getString("output")
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
}
}
@Test
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
val red = 0xffff0000.toInt()
val green = 0xff00aa00.toInt()
val overlay = 0x472684ff
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
paints.selectionBackground = overlay
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas ->
canvas.drawColor(terminalBackground)
canvas.drawColor(green)
canvas.drawColor(overlay)
}
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
})
}
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
}
}
private fun starts(row: JSONObject, key: String): List<Int> =
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
private fun raster(draw: (Canvas) -> Unit): Bitmap {
val bitmap = Bitmap.createBitmap(COLUMNS * font.cellWidthPx, font.cellHeightPx, Bitmap.Config.ARGB_8888)
draw(Canvas(bitmap))
return bitmap
}
/** Draws each cluster as its own text, centred in its columns and scaled down when wider. */
private fun drawClusters(canvas: Canvas, source: TerminalRowSource, starts: List<Int>) {
val cellCount = source.cells.size / 2
val paint = Paint(font.paint(0)).apply { color = source.styles[1].toLong().toInt() }
val offsets = IntArray(cellCount + 1)
val cols = IntArray(cellCount + 1)
for (cell in 0 until cellCount) {
offsets[cell + 1] = offsets[cell] + source.cells[cell * 2]
cols[cell + 1] = cols[cell] + source.cells[cell * 2 + 1]
}
for ((index, first) in starts.withIndex()) {
val end = starts.getOrElse(index + 1) { cellCount }
val text = source.text.substring(offsets[first], offsets[end])
if (text.isBlank()) continue
val slot = (cols[end] - cols[first]) * font.cellWidthPx.toFloat()
val advance = paint.measureText(text)
val x = cols[first] * font.cellWidthPx + if (advance > slot + 1f) 0f else (slot - advance) / 2f
canvas.save()
if (advance > slot + 1f) canvas.scale(slot / advance, slot / advance, x, font.cellHeightPx / 2f)
canvas.drawText(text, x, font.baselinePx.toFloat(), paint)
canvas.restore()
}
}
/** Share of inked pixels, in either image, whose coverage differs by more than a quarter. */
private fun difference(a: Bitmap, b: Bitmap): Float {
var inked = 0
var differing = 0
for (y in 0 until a.height) {
for (x in 0 until a.width) {
val (left, right) = (a.getPixel(x, y) ushr 24) to (b.getPixel(x, y) ushr 24)
if (left < 64 && right < 64) continue
inked += 1
if (kotlin.math.abs(left - right) > 64) differing += 1
}
}
return if (inked == 0) 0f else differing.toFloat() / inked
}
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,24 @@
package sh.paseo.terminal
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class PaseoTerminalModule : Module() {
override fun definition() = ModuleDefinition {
Name("PaseoTerminal")
View(PaseoTerminalView::class) {
Events("onCellMetrics")
Prop("frame") { view: PaseoTerminalView, frame: TerminalFrameRecord -> view.setFrame(frame) }
Prop("theme") { view: PaseoTerminalView, theme: TerminalThemeRecord -> view.setTheme(theme) }
Prop("fontFamily") { view: PaseoTerminalView, family: String -> view.setFontFamily(family) }
Prop("fontSize") { view: PaseoTerminalView, size: Double -> view.setFontSize(size) }
Prop("customGlyphs") { view: PaseoTerminalView, glyphs: List<TerminalCustomGlyphRecord> ->
view.setCustomGlyphs(glyphs)
}
OnViewDidUpdateProps { view: PaseoTerminalView -> view.commit() }
}
}
}
@@ -0,0 +1,195 @@
package sh.paseo.terminal
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Rect
import android.os.Bundle
import android.view.MotionEvent
import android.widget.TextView
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.customview.widget.ExploreByTouchHelper
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
private const val MIN_CACHED_ROWS = 256
private const val CURSOR_ALPHA = 0.45f
/**
* Paints terminal frames on the cell grid. Row shaping is cached by row content, so cursor and
* selection changes repaint without reshaping, and unchanged rows replay their display lists.
*/
@SuppressLint("ViewConstructor")
class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
private val onCellMetrics by EventDispatcher<Map<String, Float>>()
private var fontFamily = "monospace"
private var fontSize = 12.0
private var font: TerminalFont? = null
private var customGlyphs = TerminalCustomGlyphs.EMPTY
private var frame: TerminalFrameRecord? = null
private var frameChanged = false
private var rows: List<TerminalRowLayout> = emptyList()
private var columns = 0
private var cursor: TerminalCursorRecord? = null
private var selection: TerminalSelection? = null
private var backgroundColor = Color.BLACK
private var cursorColor = Color.WHITE
private val paints = TerminalPaints()
private val rowCache = object : LinkedHashMap<String, TerminalRowLayout>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, TerminalRowLayout>): Boolean {
if (size <= maxOf(MIN_CACHED_ROWS, rows.size * 2)) return false
eldest.value.release()
return true
}
}
private val accessibility = RowAccessibility()
init {
setWillNotDraw(false)
ViewCompat.setAccessibilityDelegate(this, accessibility)
}
fun setFrame(value: TerminalFrameRecord) {
frame = value
frameChanged = true
}
fun setTheme(value: TerminalThemeRecord) {
backgroundColor = argb(value.background)
cursorColor = argb(value.cursor)
paints.selectionBackground = argb(value.selectionBackground)
invalidate()
}
fun setFontFamily(value: String) {
fontFamily = value
}
fun setFontSize(value: Double) {
fontSize = value
}
fun setCustomGlyphs(value: List<TerminalCustomGlyphRecord>) {
customGlyphs = TerminalCustomGlyphs(value)
clearRowCache()
frameChanged = true
}
/** Applies a batch of prop updates: font first, then the frame laid out with it. */
fun commit() {
val density = resources.displayMetrics.density
val sizePx = (fontSize * density).toFloat()
val current = font
if (current == null || !current.sameAs(fontFamily, sizePx, density)) {
val next = TerminalFont(fontFamily, sizePx, density)
font = next
clearRowCache()
frameChanged = true
onCellMetrics(
mapOf(
"cellWidth" to next.cellWidthPx / density,
"cellHeight" to next.cellHeightPx / density,
),
)
}
if (frameChanged) {
frameChanged = false
layoutFrame()
}
invalidate()
}
private fun layoutFrame() {
val currentFont = font ?: return
val record = frame ?: return
columns = record.columns
cursor = record.cursor
selection = record.selection?.let(::TerminalSelection)
rows = record.rows.map { encoded ->
rowCache.getOrPut(encoded) {
TerminalRowLayout(TerminalRowSource.decode(encoded), currentFont, customGlyphs)
}
}
accessibility.invalidateRoot()
}
private fun clearRowCache() {
for (layout in rowCache.values) layout.release()
rowCache.clear()
rows = emptyList()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(backgroundColor)
val currentFont = font ?: return
val cellWidth = currentFont.cellWidthPx
val cellHeight = currentFont.cellHeightPx
val gridWidth = columns * cellWidth
canvas.save()
canvas.clipRect(0, 0, minOf(width, gridWidth), height)
val activeSelection = selection
for ((index, row) in rows.withIndex()) {
canvas.save()
canvas.translate(0f, (index * cellHeight).toFloat())
if (activeSelection != null && activeSelection.intersectsRow(index)) {
row.drawSelected(canvas, index, activeSelection, paints)
} else {
row.draw(canvas, gridWidth, paints)
}
canvas.restore()
}
cursor?.let { position ->
paints.fill.color = withAlpha(cursorColor, CURSOR_ALPHA)
val left = (position.col * cellWidth).toFloat()
val top = (position.row * cellHeight).toFloat()
canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paints.fill)
}
canvas.restore()
}
override fun dispatchHoverEvent(event: MotionEvent): Boolean =
accessibility.dispatchHoverEvent(event) || super.dispatchHoverEvent(event)
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
for (layout in rowCache.values) layout.release()
}
private fun withAlpha(color: Int, alpha: Float): Int =
Color.argb((Color.alpha(color) * alpha).toInt(), Color.red(color), Color.green(color), Color.blue(color))
/** Exposes each non-blank row as a virtual text node, `terminal-row-<index>`. */
private inner class RowAccessibility : ExploreByTouchHelper(this@PaseoTerminalView) {
override fun getVirtualViewAt(x: Float, y: Float): Int {
val cellHeight = font?.cellHeightPx ?: return INVALID_ID
val row = (y / cellHeight).toInt()
return if (row in rows.indices && rows[row].describe().isNotEmpty()) row else INVALID_ID
}
override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {
for ((index, row) in rows.withIndex()) {
if (row.describe().isNotEmpty()) virtualViewIds.add(index)
}
}
@Suppress("DEPRECATION")
override fun onPopulateNodeForVirtualView(virtualViewId: Int, node: AccessibilityNodeInfoCompat) {
val cellHeight = font?.cellHeightPx ?: 1
val label = rows.getOrNull(virtualViewId)?.describe().orEmpty()
node.className = TextView::class.java.name
node.text = label
node.contentDescription = label
node.viewIdResourceName = "terminal-row-$virtualViewId"
val top = virtualViewId * cellHeight
node.setBoundsInParent(Rect(0, top, minOf(width, columns * (font?.cellWidthPx ?: 1)), top + cellHeight))
}
override fun onPerformActionForVirtualView(virtualViewId: Int, action: Int, arguments: Bundle?): Boolean = false
}
}
@@ -0,0 +1,76 @@
package sh.paseo.terminal
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
/**
* Box drawing and block elements painted as cell geometry instead of font glyphs, so frames and
* blocks join across cells regardless of the font's own metrics.
*/
internal class TerminalCustomGlyph(record: TerminalCustomGlyphRecord) {
private val rects: FloatArray = FloatArray(record.rects.size) { record.rects[it].toFloat() }
private val commands: DoubleArray = record.path
private val strokeWidth: Float = record.strokeWidth.toFloat()
private var scaledPath: Path? = null
private var scaledWidth = 0f
private var scaledHeight = 0f
fun draw(canvas: Canvas, x: Float, width: Float, height: Float, density: Float, paint: Paint) {
paint.style = Paint.Style.FILL
var index = 0
while (index + 3 < rects.size) {
canvas.drawRect(
x + rects[index] * width,
rects[index + 1] * height,
x + (rects[index] + rects[index + 2]) * width,
(rects[index + 1] + rects[index + 3]) * height,
paint,
)
index += 4
}
if (commands.isEmpty()) return
val path = pathFor(width, height)
paint.style = Paint.Style.STROKE
paint.strokeWidth = strokeWidth * density
paint.strokeCap = Paint.Cap.BUTT
paint.strokeJoin = Paint.Join.MITER
canvas.save()
canvas.translate(x, 0f)
canvas.drawPath(path, paint)
canvas.restore()
paint.style = Paint.Style.FILL
}
private fun pathFor(width: Float, height: Float): Path {
val cached = scaledPath
if (cached != null && scaledWidth == width && scaledHeight == height) return cached
val path = Path()
var index = 0
while (index < commands.size) {
val x = { offset: Int -> commands[index + offset].toFloat() * width }
val y = { offset: Int -> commands[index + offset].toFloat() * height }
when (commands[index].toInt()) {
0 -> { path.moveTo(x(1), y(2)); index += 3 }
1 -> { path.lineTo(x(1), y(2)); index += 3 }
2 -> { path.quadTo(x(1), y(2), x(3), y(4)); index += 5 }
else -> index = commands.size
}
}
scaledPath = path
scaledWidth = width
scaledHeight = height
return path
}
}
internal class TerminalCustomGlyphs(records: List<TerminalCustomGlyphRecord>) {
private val glyphs: Map<String, TerminalCustomGlyph> =
records.associate { it.char to TerminalCustomGlyph(it) }
operator fun get(text: String): TerminalCustomGlyph? = glyphs[text]
companion object {
val EMPTY = TerminalCustomGlyphs(emptyList())
}
}
@@ -0,0 +1,61 @@
package sh.paseo.terminal
import android.graphics.Paint
import android.graphics.Typeface
import kotlin.math.max
import kotlin.math.roundToInt
private const val MEASURE_TEXT = "mmmmmmmmmm"
/**
* The terminal font at one pixel size: a paint per face (regular, bold, italic, bold italic) and
* the cell those faces are laid out on. Cells are whole device pixels so every column origin is
* exact. Platform fallback fonts cover characters the family lacks.
*/
internal class TerminalFont(val family: String, val sizePx: Float, val density: Float) {
private val faces: Array<Paint> = Array(4) { face ->
Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
typeface = Typeface.create(family, typefaceStyle(face))
textSize = sizePx
}
}
val cellWidthPx: Int
val cellHeightPx: Int
val baselinePx: Int
val underlineOffsetPx: Float
val underlineThicknessPx: Float
val strikeOffsetPx: Float
val strikeThicknessPx: Float
init {
val regular = faces[0]
cellWidthPx = max(1, (regular.measureText(MEASURE_TEXT) / MEASURE_TEXT.length).roundToInt())
val metrics = regular.fontMetricsInt
cellHeightPx = max(1, metrics.descent - metrics.ascent)
baselinePx = -metrics.ascent
underlineOffsetPx = regular.underlinePosition
underlineThicknessPx = max(1f, regular.underlineThickness)
strikeOffsetPx = regular.strikeThruPosition
strikeThicknessPx = max(1f, regular.strikeThruThickness)
}
/** Paint for a style's face; callers only change its color. */
fun paint(flags: Int): Paint = faces[face(flags)]
fun sameAs(family: String, sizePx: Float, density: Float): Boolean =
this.family == family && this.sizePx == sizePx && this.density == density
companion object {
fun face(flags: Int): Int =
(if (flags and TerminalStyleFlags.BOLD != 0) 1 else 0) +
(if (flags and TerminalStyleFlags.ITALIC != 0) 2 else 0)
private fun typefaceStyle(face: Int): Int = when (face) {
1 -> Typeface.BOLD
2 -> Typeface.ITALIC
3 -> Typeface.BOLD_ITALIC
else -> Typeface.NORMAL
}
}
}
@@ -0,0 +1,99 @@
package sh.paseo.terminal
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
class TerminalCursorRecord : Record {
@Field val row: Int = 0
@Field val col: Int = 0
}
class TerminalSelectionRecord : Record {
@Field val startRow: Int = 0
@Field val startCol: Int = 0
@Field val endRow: Int = 0
@Field val endCol: Int = 0
}
class TerminalFrameRecord : Record {
@Field val columns: Int = 0
/** Encoded rows, `cells;styles;text`; see `frame-encoding.ts`. */
@Field val rows: List<String> = emptyList()
@Field val cursor: TerminalCursorRecord? = null
@Field val selection: TerminalSelectionRecord? = null
}
class TerminalThemeRecord : Record {
@Field val background: Double = 0.0
@Field val cursor: Double = 0.0
@Field val selectionBackground: Double = 0.0
}
class TerminalCustomGlyphRecord : Record {
@Field val char: String = ""
@Field val rects: DoubleArray = DoubleArray(0)
@Field val path: DoubleArray = DoubleArray(0)
@Field val strokeWidth: Double = 0.0
}
internal object TerminalStyleFlags {
const val BOLD = 1
const val ITALIC = 2
const val UNDERLINE = 4
const val STRIKETHROUGH = 8
}
/** JS sends colors as unsigned ARGB numbers; Android paints signed ARGB ints. */
internal fun argb(value: Double): Int = value.toLong().toInt()
/** One row as the caller described it: cell text, column spans and style spans. */
internal class TerminalRowSource private constructor(
val text: String,
val cells: IntArray,
val styles: DoubleArray,
) {
companion object {
/** Decodes `cells;styles;text`, where both number lists are comma separated. */
fun decode(encoded: String): TerminalRowSource {
val cellsEnd = encoded.indexOf(';')
val stylesEnd = encoded.indexOf(';', cellsEnd + 1)
val cells = parseNumbers(encoded, 0, cellsEnd)
val styles = parseNumbers(encoded, cellsEnd + 1, stylesEnd)
return TerminalRowSource(
encoded.substring(stylesEnd + 1),
IntArray(cells.size) { cells[it].toInt() },
DoubleArray(styles.size) { styles[it].toDouble() },
)
}
private fun parseNumbers(encoded: String, start: Int, end: Int): LongArray {
if (end <= start) return LongArray(0)
var count = 1
for (index in start until end) if (encoded[index] == ',') count += 1
val numbers = LongArray(count)
var slot = 0
for (index in start until end) {
val char = encoded[index]
if (char == ',') slot += 1 else numbers[slot] = numbers[slot] * 10 + (char - '0')
}
return numbers
}
}
}
internal class TerminalSelection(record: TerminalSelectionRecord) {
private val startRow = record.startRow
private val startCol = record.startCol
private val endRow = record.endRow
private val endCol = record.endCol
fun intersectsRow(row: Int): Boolean = row in startRow..endRow
/** A cell is selected when any column it covers is inside the inclusive range. */
fun contains(row: Int, col: Int, span: Int): Boolean {
if (!intersectsRow(row)) return false
val firstCol = if (row == startRow) startCol else 0
val lastCol = if (row == endRow) endCol else Int.MAX_VALUE
return col + span - 1 >= firstCol && col <= lastCol
}
}
@@ -0,0 +1,302 @@
package sh.paseo.terminal
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RenderNode
import android.graphics.text.MeasuredText
import android.icu.text.BreakIterator
/** Shaped glyphs may exceed their slot by this many pixels before they are scaled down. */
private const val OVERFLOW_TOLERANCE_PX = 1f
/** Stands in for an open edge when a group is clipped on one side only. */
private const val CLIP_UNBOUNDED_PX = 100_000f
/**
* One row shaped once and placed on the terminal grid.
*
* Each run of cells with the same font face is shaped as a whole, so contextual forms, ligatures,
* marks and fallback fonts see their neighbours. The shaper reports a cluster's advance on its
* first UTF-16 unit and zero on the units it absorbed. A cell whose first unit has zero advance
* therefore belongs to the cluster of the cell before it: a ligature, conjunct, emoji sequence or
* reordered mark that spans columns. A cell that does not start a grapheme cluster also stays with
* the cell before it: a half-form conjunct such as स्त keeps both consonants' advances, and drawing
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
*/
internal class TerminalRowLayout(
val source: TerminalRowSource,
private val font: TerminalFont,
private val customGlyphs: TerminalCustomGlyphs,
) {
private val chars: CharArray = source.text.toCharArray()
private val cellCount = source.cells.size / 2
private val cellCol = IntArray(cellCount)
private val cellSpan = IntArray(cellCount)
private val cellStart = IntArray(cellCount)
private val cellEnd = IntArray(cellCount)
private val cellForeground = IntArray(cellCount)
private val cellBackground = IntArray(cellCount)
private val cellFlags = IntArray(cellCount)
private val cellGlyph = arrayOfNulls<TerminalCustomGlyph>(cellCount)
private val groups = ArrayList<Group>()
private val measured: MeasuredText?
val text: String get() = source.text
private var node: RenderNode? = null
private class Group(
val firstCell: Int,
val lastCell: Int,
val col: Int,
val start: Int,
val end: Int,
val contextStart: Int,
val contextEnd: Int,
val offsetPx: Float,
val scale: Float,
val blank: Boolean,
)
init {
readCells()
measured = measure()
if (measured != null) buildGroups(measured)
}
private fun readCells() {
var offset = 0
var col = 0
var styleIndex = 0
var styleEndCol = 0
for (cell in 0 until cellCount) {
val length = source.cells[cell * 2]
val span = maxOf(1, source.cells[cell * 2 + 1])
while (col >= styleEndCol && styleIndex + 3 < source.styles.size) {
styleEndCol += source.styles[styleIndex].toInt()
styleIndex += 4
}
val style = styleIndex - 4
if (style >= 0) {
cellForeground[cell] = argb(source.styles[style + 1])
cellBackground[cell] = argb(source.styles[style + 2])
cellFlags[cell] = source.styles[style + 3].toInt()
}
cellCol[cell] = col
cellSpan[cell] = span
cellStart[cell] = offset
cellEnd[cell] = minOf(chars.size, offset + length)
cellGlyph[cell] = customGlyphs[source.text.substring(cellStart[cell], cellEnd[cell])]
offset = cellEnd[cell]
col += span
}
}
/** Same face, not custom glyph: cells shaped together. */
private fun shapesWith(cell: Int, other: Int): Boolean =
cellGlyph[cell] == null && cellGlyph[other] == null &&
TerminalFont.face(cellFlags[cell]) == TerminalFont.face(cellFlags[other])
private fun measure(): MeasuredText? {
if (chars.isEmpty()) return null
val builder = MeasuredText.Builder(chars)
var cell = 0
while (cell < cellCount) {
var last = cell
while (last + 1 < cellCount && shapesWith(cell, last + 1)) last += 1
val length = cellEnd[last] - cellStart[cell]
if (length > 0) {
if (cellGlyph[cell] != null) {
builder.appendReplacementRun(font.paint(cellFlags[cell]), length, 0f)
} else {
builder.appendStyleRun(font.paint(cellFlags[cell]), length, false)
}
}
cell = last + 1
}
val trailing = chars.size - (if (cellCount > 0) cellEnd[cellCount - 1] else 0)
if (trailing > 0) builder.appendReplacementRun(font.paint(0), trailing, 0f)
return builder.build()
}
private fun buildGroups(measured: MeasuredText) {
// Keep platform graphemes together even when Minikin reports separate advances/cursors.
// Rules follow the OS ICU version: Android 10 splits Indic conjuncts such as स्ते.
val graphemes = BreakIterator.getCharacterInstance()
graphemes.setText(source.text)
var cell = 0
while (cell < cellCount) {
if (cellGlyph[cell] != null) {
cell += 1
continue
}
var last = cell
while (last + 1 < cellCount && shapesWith(cell, last + 1)) last += 1
val contextStart = cellStart[cell]
val contextEnd = cellEnd[last]
var first = cell
for (next in cell + 1..last) {
val startsCluster =
cellEnd[next] > cellStart[next] &&
measured.getCharWidthAt(cellStart[next]) != 0f &&
graphemes.isBoundary(cellStart[next])
if (startsCluster) {
addGroup(measured, first, next - 1, contextStart, contextEnd)
first = next
}
}
addGroup(measured, first, last, contextStart, contextEnd)
cell = last + 1
}
}
private fun addGroup(measured: MeasuredText, first: Int, last: Int, contextStart: Int, contextEnd: Int) {
val start = cellStart[first]
val end = cellEnd[last]
if (end <= start) return
var columns = 0
for (cell in first..last) columns += cellSpan[cell]
val slot = (columns * font.cellWidthPx).toFloat()
val advance = measured.getWidth(start, end)
val scale = if (advance > slot + OVERFLOW_TOLERANCE_PX) slot / advance else 1f
val offset = if (scale < 1f) 0f else (slot - advance) / 2f
var blank = true
for (index in start until end) {
if (chars[index] != ' ') {
blank = false
break
}
}
groups.add(Group(first, last, cellCol[first], start, end, contextStart, contextEnd, offset, scale, blank))
}
/** Draws the row with its own colors, replaying a recorded display list when possible. */
fun draw(canvas: Canvas, width: Int, paints: TerminalPaints) {
if (!canvas.isHardwareAccelerated) {
drawContent(canvas, -1, null, paints)
return
}
// Marks stacked above or below a letter ink outside the row. The node must not clip them: the
// view redraws every row on each frame, so ink outside the row is never left stale.
val recorded = node ?: RenderNode("terminal-row").also {
it.setClipToBounds(false)
node = it
}
if (!recorded.hasDisplayList() || recorded.width != width) {
recorded.setPosition(0, 0, width, font.cellHeightPx)
val recording = recorded.beginRecording()
try {
drawContent(recording, -1, null, paints)
} finally {
recorded.endRecording()
}
}
canvas.drawRenderNode(recorded)
}
/** Draws backgrounds, the selection overlay, then text with its original colors. */
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
drawContent(canvas, row, selection, paints)
}
fun release() {
node?.discardDisplayList()
node = null
}
private fun selected(row: Int, selection: TerminalSelection?, cell: Int): Boolean =
selection?.contains(row, cellCol[cell], cellSpan[cell]) == true
private fun drawContent(canvas: Canvas, row: Int, selection: TerminalSelection?, paints: TerminalPaints) {
val cellWidth = font.cellWidthPx.toFloat()
val cellHeight = font.cellHeightPx.toFloat()
val fill = paints.fill
for (cell in 0 until cellCount) {
val left = cellCol[cell] * cellWidth
val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
val background = cellBackground[cell]
if (background ushr 24 != 0) {
fill.color = background
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
if (selected(row, selection, cell)) {
fill.color = paints.selectionBackground
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
}
val baseline = font.baselinePx.toFloat()
val text = measured
if (text != null) {
for (group in groups) {
if (group.blank) continue
val paint = font.paint(cellFlags[group.firstCell])
var first = group.firstCell
while (first <= group.lastCell) {
val color = cellForeground[first]
var last = first
while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
// Only edges between differently colored cells clip; the group's outer edges may overhang.
val clipped = first > group.firstCell || last < group.lastCell
canvas.save()
if (clipped) {
val left = if (first > group.firstCell) cellCol[first] * cellWidth else -CLIP_UNBOUNDED_PX
val right =
if (last < group.lastCell) (cellCol[last] + cellSpan[last]) * cellWidth else CLIP_UNBOUNDED_PX
canvas.clipRect(left, -CLIP_UNBOUNDED_PX, right, CLIP_UNBOUNDED_PX)
}
paint.color = color
drawGroupText(canvas, text, group, paint, cellWidth, cellHeight, baseline)
canvas.restore()
first = last + 1
}
}
}
for (cell in 0 until cellCount) {
val glyph = cellGlyph[cell]
val flags = cellFlags[cell]
val foreground = cellForeground[cell]
if (glyph != null) {
fill.color = foreground
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
}
if (flags and (TerminalStyleFlags.UNDERLINE or TerminalStyleFlags.STRIKETHROUGH) == 0) continue
fill.color = foreground
val left = cellCol[cell] * cellWidth
val right = left + cellSpan[cell] * cellWidth
if (flags and TerminalStyleFlags.UNDERLINE != 0) {
val top = baseline + font.underlineOffsetPx
canvas.drawRect(left, top, right, top + font.underlineThicknessPx, fill)
}
if (flags and TerminalStyleFlags.STRIKETHROUGH != 0) {
val top = baseline + font.strikeOffsetPx
canvas.drawRect(left, top, right, top + font.strikeThicknessPx, fill)
}
}
}
private fun drawGroupText(
canvas: Canvas,
text: MeasuredText,
group: Group,
paint: Paint,
cellWidth: Float,
cellHeight: Float,
baseline: Float,
) {
val x = group.col * cellWidth + group.offsetPx
if (group.scale < 1f) canvas.scale(group.scale, group.scale, x, cellHeight / 2f)
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
}
/** The row's text as accessibility reads it. */
fun describe(): String = source.text.trimEnd()
}
/** Paint state shared by every row of one view. */
internal class TerminalPaints {
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
var selectionBackground: Int = 0x472684FF
}
@@ -0,0 +1,59 @@
/**
* Native terminal renderer. The caller owns terminal state (xterm), input and gestures; this view
* shapes and paints one frame of terminal cells on the authoritative cell grid and reports the
* cell size its font produces.
*
* Colors are unsigned 32-bit ARGB numbers (`0xAARRGGBB`). Alpha 0 means "not painted".
*/
export interface PaseoTerminalFrame {
/** Columns the frame paints. Cells beyond it are clipped. */
columns: number;
rows: PaseoTerminalRow[];
cursor: PaseoTerminalCursor | null;
selection: PaseoTerminalSelection | null;
}
/**
* One terminal row. Cells start at column 0 and are consecutive. `text` is every cell's text in
* order, so cell `i` owns the next `cells[2i]` UTF-16 code units of `text` and covers the next
* `cells[2i + 1]` columns. Style spans are consecutive from column 0 and cover all columns.
*/
export interface PaseoTerminalRow {
text: string;
/** Flattened `[utf16Length, columnSpan]` per cell. */
cells: number[];
/** Flattened `[columnCount, foreground, background, flags]` per style span. */
styles: number[];
}
export interface PaseoTerminalCursor {
row: number;
col: number;
}
/** Inclusive cell range in frame rows. A cell is selected when any of its columns is. */
export interface PaseoTerminalSelection {
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}
export interface PaseoTerminalTheme {
background: number;
cursor: number;
selectionBackground: number;
}
export interface PaseoTerminalCellMetrics {
cellWidth: number;
cellHeight: number;
}
export const PASEO_TERMINAL_STYLE_FLAGS = {
bold: 1,
italic: 2,
underline: 4,
strikethrough: 8,
dim: 16,
} as const;
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveTerminalCustomGlyph } from "./terminal-custom-glyph";
import { encodeTerminalCustomGlyphs, resolveTerminalCustomGlyph } from "./custom-glyphs";
describe("native terminal custom glyphs", () => {
it("fills the complete cell for the full block used by the Claude mascot", () => {
@@ -85,4 +85,22 @@ describe("native terminal custom glyphs", () => {
expect(resolveTerminalCustomGlyph(char), `missing custom glyph ${char}`).not.toBeNull();
}
});
it("encodes every glyph for the native renderer in cell fractions", () => {
const encoded = new Map(encodeTerminalCustomGlyphs().map((glyph) => [glyph.char, glyph]));
expect(encoded.get("▛")).toEqual({
char: "▛",
rects: [0, 0, 0.5, 0.5, 0.5, 0, 0.5, 0.5, 0, 0.5, 0.5, 0.5],
path: [],
strokeWidth: 0,
});
expect(encoded.get("╭")).toEqual({
char: "╭",
rects: [],
path: [0, 0.5, 1, 1, 0.5, 0.75, 2, 0.5, 0.5, 0.75, 0.5, 1, 1, 0.5],
strokeWidth: 1,
});
expect(encoded.get("┣")?.strokeWidth).toBe(3);
});
});
@@ -82,12 +82,60 @@ const BLOCK_GLYPHS: Readonly<Record<string, TerminalRectGlyph>> = {
"▟": rects(upperRight(), lowerLeft(), lowerRight()),
};
/**
* One custom glyph as the native view consumes it. Coordinates are fractions of one cell.
* `rects` is flattened `[x, y, width, height]`. `path` is flattened commands:
* `0 x y` moves, `1 x y` draws a line, `2 cx cy x y` draws a quadratic curve. Paths are
* stroked with `strokeWidth` points regardless of cell size.
*/
export interface PaseoTerminalCustomGlyph {
char: string;
rects: number[];
path: number[];
strokeWidth: number;
}
const PATH_COMMAND_CODES: Readonly<Record<string, { code: number; coordinates: number }>> = {
M: { code: 0, coordinates: 2 },
L: { code: 1, coordinates: 2 },
Q: { code: 2, coordinates: 4 },
};
export function resolveTerminalCustomGlyph(char: string): TerminalCustomGlyph | null {
return LIGHT_BOX_GLYPHS[char] ?? HEAVY_BOX_GLYPHS[char] ?? BLOCK_GLYPHS[char] ?? null;
}
export function isTerminalCustomGlyph(char: string): boolean {
return resolveTerminalCustomGlyph(char) !== null;
export function encodeTerminalCustomGlyphs(): PaseoTerminalCustomGlyph[] {
const table = { ...LIGHT_BOX_GLYPHS, ...HEAVY_BOX_GLYPHS, ...BLOCK_GLYPHS };
return Object.entries(table).map(([char, glyph]) =>
glyph.kind === "rects"
? {
char,
rects: glyph.rects.flatMap((value) => [value.x, value.y, value.width, value.height]),
path: [],
strokeWidth: 0,
}
: { char, rects: [], path: encodePath(glyph.path), strokeWidth: glyph.strokeWidth },
);
}
function encodePath(pathData: string): number[] {
const tokens = pathData.match(/[MLQ]|-?\d*\.?\d+/g) ?? [];
const encoded: number[] = [];
let index = 0;
while (index < tokens.length) {
const command = PATH_COMMAND_CODES[tokens[index]];
if (!command) {
throw new Error(`Unsupported terminal glyph path command in ${pathData}`);
}
const coordinates = tokens.slice(index + 1, index + 1 + command.coordinates).map(Number);
if (coordinates.length !== command.coordinates || coordinates.some(Number.isNaN)) {
throw new Error(`Malformed terminal glyph path ${pathData}`);
}
encoded.push(command.code, ...coordinates);
index += 1 + command.coordinates;
}
return encoded;
}
function path(pathData: string, strokeWidth = 1): TerminalPathGlyph {
@@ -0,0 +1,9 @@
{
"platforms": ["android", "ios"],
"android": {
"modules": ["sh.paseo.terminal.PaseoTerminalModule"]
},
"ios": {
"modules": ["PaseoTerminalModule"]
}
}
@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { encodeTerminalFrame } from "./frame-encoding";
describe("encodeTerminalFrame", () => {
test("puts cells and styles before text, so text may contain separators", () => {
const native = encodeTerminalFrame({
columns: 10,
rows: [
{
text: "안a;b,",
cells: [1, 2, 1, 1, 1, 1, 1, 1, 1, 1],
styles: [3, 0xffe5e5e5, 0, 0, 3, 0xffff0000, 0, 1],
},
{ text: "", cells: [], styles: [] },
],
cursor: { row: 1, col: 0 },
selection: { startRow: 0, startCol: 0, endRow: 0, endCol: 1 },
});
expect(native).toEqual({
columns: 10,
rows: ["1,2,1,1,1,1,1,1,1,1;3,4293256677,0,0,3,4294901760,0,1;안a;b,", ";;"],
cursor: { row: 1, col: 0 },
selection: { startRow: 0, startCol: 0, endRow: 0, endCol: 1 },
});
});
test("leaves out a missing cursor and selection instead of sending null", () => {
const native = encodeTerminalFrame({ columns: 1, rows: [], cursor: null, selection: null });
expect(Object.keys(native)).toEqual(["columns", "rows"]);
});
});
@@ -0,0 +1,27 @@
import type { PaseoTerminalCursor, PaseoTerminalFrame, PaseoTerminalSelection } from "./contract";
/** A frame as it crosses to native. */
export interface NativePaseoTerminalFrame {
columns: number;
rows: string[];
cursor?: PaseoTerminalCursor;
selection?: PaseoTerminalSelection;
}
/**
* Rows cross as `cells;styles;text`, with the numbers comma separated. One string per row keeps
* prop conversion to a single copy instead of one call per number, and the string is also the
* native shaping cache key, so an unchanged row costs one lookup.
*
* A missing cursor or selection is left out rather than sent as `null`: Expo on iOS fails to
* convert `null` into an optional record field and then drops the whole frame.
*/
export function encodeTerminalFrame(frame: PaseoTerminalFrame): NativePaseoTerminalFrame {
const native: NativePaseoTerminalFrame = {
columns: frame.columns,
rows: frame.rows.map((row) => `${row.cells.join(",")};${row.styles.join(",")};${row.text}`),
};
if (frame.cursor) native.cursor = frame.cursor;
if (frame.selection) native.selection = frame.selection;
return native;
}

Some files were not shown because too many files have changed in this diff Show More