Files
paseo/packages/app/e2e/support/helpers/plugin-timeline.ts
T
99e4b53aa0 fix(app): restore outline jumps to prompts rendered by plugins (#6690)
* fix(app): jump the chat outline to prompts a timeline plugin draws

A timeline transformer replaces a stream item with plugin rows whose ids
are "<pluginId>/<itemId>". The chat outline, chat find and history reveal
look a message up by the source item's id, so once a plugin drew the
prompts the outline found no row: the rail showed the prompts and
clicking one did nothing, for mounted, virtualized and unloaded prompts
alike.

Projected plugin rows now carry the id of the item they replaced, and
getStreamItemMessageId answers with it. Row wrappers, the virtualized
index lookup, the visible-message set and history reveal all read that
function, so they now find the plugin row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): verify outline jumps to virtualized plugin prompts

Replacing every prompt keeps all loaded rows mounted, so the original plugin regression never exercised a virtualized target. Retain newer native prompts and verify that a loaded unmounted plugin prompt lands without fetching.

Co-authored-by: luopeihuan <luopeihuan@roundreddot.com>

* test(app): retain outline navigation screenshots

---------

Co-authored-by: luopeihuan <luopeihuan@roundreddot.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 12:10:54 +02:00

234 lines
8.3 KiB
TypeScript

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
const PLUGIN_ID = "timeline-transform-e2e";
export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
const CLIENT_SOURCE = `import React, { useState } from "react";
import { Text, View, Pressable } from "react-native";
import { z } from "zod";
function Card({ item }) {
const [count, setCount] = useState(0);
return <View>
<Text accessibilityRole="header">{item.data.label + " " + item.data.phase}</Text>
<Text>{item.data.text}</Text>
<Pressable accessibilityRole="button" onPress={() => setCount(count + 1)}>
<Text>{"Card clicks " + count}</Text>
</Pressable>
</View>;
}
export default function contribute(client) {
for (const itemType of ["assistant_message", "tool_call"]) {
client.addTimelineTransformer({
id: itemType.replaceAll("_", "-"),
query: { itemType },
transform({ item, phase }) {
return { items: [{ type: "plugin", kind: "card", version: 1, data: {
label: item.type === "tool_call" ? "Tool " + item.name : "Assistant",
text: item.type === "tool_call" ? item.callId : item.text,
phase,
} }] };
},
});
}
client.addTimelineRenderer({
kind: "card", version: 1,
schema: z.object({ label: z.string(), text: z.string(), phase: z.enum(["streaming", "complete"]) }),
Component: Card,
});
return () => {};
}`;
const PROMPT_PLUGIN_ID = "timeline-prompt-e2e";
function promptClientSource(prompts?: readonly string[]): string {
return `import React from "react";
import { Text, View } from "react-native";
import { z } from "zod";
const selectedPrompts = ${JSON.stringify(prompts ?? null)};
function PromptCard({ item }) {
return <View testID="plugin-prompt"><Text>{item.data.text}</Text></View>;
}
export default function contribute(client) {
client.addTimelineTransformer({
id: "prompt",
query: { itemType: "user_message" },
transform({ item }) {
if (selectedPrompts && !selectedPrompts.includes(item.text)) return undefined;
return { items: [{ type: "plugin", kind: "prompt", version: 1, data: { text: item.text } }] };
},
});
client.addTimelineRenderer({
kind: "prompt", version: 1,
schema: z.object({ text: z.string() }),
Component: PromptCard,
});
return () => {};
}`;
}
async function withDirectoryPlugin(options: {
pluginId: string;
source: string;
run: () => Promise<void>;
}): Promise<void> {
const { pluginId, source, run } = options;
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: pluginId, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), source);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await run();
} finally {
await pluginClient.removePlugin(pluginId);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await rm(directory, { recursive: true, force: true });
}
}
export async function withTimelinePlugin(
page: Page,
info: TestInfo,
mode: "assistant" | "tools",
run: (agent: MockAgentWorkspace) => Promise<void>,
): Promise<void> {
info.setTimeout(120_000);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-plugin-",
title: "Timeline plugin regression",
model: "ten-second-stream",
featureValues:
mode === "assistant"
? {
mockStreamingAssistantResponse: ASSISTANT_TEXT,
mockStreamingAssistantIntervalMs: 300,
}
: {},
});
try {
await withDirectoryPlugin({
pluginId: PLUGIN_ID,
source: CLIENT_SOURCE,
run: async () => {
try {
await openAgentRoute(page, agent);
await expect(
page.getByRole("textbox", { name: "Message agent..." }).first(),
).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
}
},
});
} finally {
await agent.cleanup();
}
}
/** Draws all prompts, or only the selected prompts, as plugin cards. */
export async function withPromptTimelinePlugin(
run: () => Promise<void>,
prompts?: readonly string[],
): Promise<void> {
await withDirectoryPlugin({
pluginId: PROMPT_PLUGIN_ID,
source: promptClientSource(prompts),
run,
});
}
export async function expectPromptsDrawnByPlugin(page: Page): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByTestId("plugin-prompt").first()).toBeVisible({ timeout: 30_000 });
await expect(timeline.getByTestId("user-message")).toHaveCount(0);
}
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
}
export async function interactWithStreamingCard(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount(
1,
);
await page.getByRole("button", { name: "Card clicks 0", exact: true }).click();
await expect(page.getByText(/Skill header\s+# Instructions/)).toBeVisible();
await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount(
1,
);
await expect(page.getByRole("button", { name: "Card clicks 1", exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toHaveCount(0);
}
export async function expectWholeCompletedCard(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Assistant complete", exact: true })).toHaveCount(
1,
);
await expect(page.getByText(ASSISTANT_TEXT, { exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toHaveCount(0);
}
export async function expectBothConsecutiveTools(page: Page): Promise<void> {
// read and grep are consecutive source calls in the fixture. Overview used to
// send only the later call to the plugin, so the read card never existed.
const read = page.getByRole("heading", { name: "Tool read complete", exact: true }).first();
await read.scrollIntoViewIfNeeded();
await expect(read).toBeVisible();
const grep = page.getByRole("heading", { name: "Tool grep complete", exact: true }).first();
await grep.scrollIntoViewIfNeeded();
await expect(grep).toBeVisible();
}
/** The row belongs to the virtualized history, not the always-mounted recent window. */
export async function expectVirtualizedPluginPromptVisible(
page: Page,
prompt: string,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(
timeline.locator("[data-index]").getByTestId("plugin-prompt").filter({ hasText: prompt }),
).toBeVisible();
}
export async function attachPromptTimelineEvidence(page: Page, info: TestInfo): Promise<void> {
await info.attach("plugin-outline-jump", {
body: await page.screenshot({ path: info.outputPath("plugin-outline-jump.png") }),
contentType: "image/png",
});
}