mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
* Virtualize long Import Session Web lists while preserving native and short-list scrolling * fix(app): measure import list headers through the existing layout event Keep the virtualized import list usable in the existing component harness without introducing an observer mock. Import React for the unit JSX transform. Co-authored-by: royenheart <royenheart@outlook.com> --------- Co-authored-by: royenheart <royenheart@outlook.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { TestInfo } from "@playwright/test";
|
|
import { expect, test, type Page } from "../support/fixtures";
|
|
import { ImportSessionFlow } from "../support/helpers/import-session";
|
|
import {
|
|
connectNewWorkspaceDaemonClient,
|
|
openProjectViaDaemon,
|
|
type OpenedProject,
|
|
} from "../support/helpers/new-workspace";
|
|
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
|
|
|
const SCREENSHOT_DIRECTORY = path.join(
|
|
process.env.HOME ?? tmpdir(),
|
|
".paseo/plans/import-session-ux",
|
|
);
|
|
const claudeConfigDirectory = mkdtempSync(path.join(tmpdir(), "paseo-import-flow-claude-"));
|
|
const brokenProvider = "broken-acp";
|
|
|
|
test.use({
|
|
e2eDaemonConfig: {
|
|
version: 1,
|
|
agents: {
|
|
providers: {
|
|
codex: { enabled: false },
|
|
copilot: { enabled: false },
|
|
omp: { enabled: false },
|
|
opencode: { enabled: false },
|
|
pi: { enabled: false },
|
|
[brokenProvider]: {
|
|
extends: "acp",
|
|
label: "Broken ACP",
|
|
command: ["missing-agent-command", "acp"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
e2eDaemonEnvironment: { CLAUDE_CONFIG_DIR: claudeConfigDirectory },
|
|
});
|
|
|
|
interface ImportFlowScenario {
|
|
project: OpenedProject;
|
|
reuseTarget: OpenedProject;
|
|
projectName: string;
|
|
projectRoot: string;
|
|
worktreeDirectory: string;
|
|
unrelatedDirectory: string;
|
|
importSessionId: string;
|
|
importSessionTimestamp: number;
|
|
repoCleanup(): Promise<void>;
|
|
unrelatedCleanup(): Promise<void>;
|
|
}
|
|
|
|
let scenario: ImportFlowScenario;
|
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
|
|
|
test.setTimeout(120_000);
|
|
|
|
test.beforeAll(async () => {
|
|
await mkdir(SCREENSHOT_DIRECTORY, { recursive: true });
|
|
const repo = await createTempGitRepo("isf-", {
|
|
originUrl: "https://github.com/paseo-e2e/import-session-fixture.git",
|
|
});
|
|
const unrelated = await createTempDirectory("isf-other-");
|
|
const worktreeDirectory = path.join(repo.path, "worktrees", "review-fix");
|
|
await mkdir(path.dirname(worktreeDirectory), { recursive: true });
|
|
execFileSync("git", ["worktree", "add", "-b", "review-fix", worktreeDirectory], {
|
|
cwd: repo.path,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
|
const project = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
|
if (!project.workspace) {
|
|
throw new Error(project.error ?? "Failed to create the import-session fixture workspace");
|
|
}
|
|
const projects = await client.listProjects();
|
|
const projectDescriptor = projects.projects.find(
|
|
(candidate) => candidate.projectId === project.workspace?.projectId,
|
|
);
|
|
if (!projectDescriptor?.projectKey) {
|
|
throw new Error("Fixture workspace has no project key");
|
|
}
|
|
|
|
const openedProject: OpenedProject = {
|
|
workspaceId: project.workspace.id,
|
|
projectId: project.workspace.projectId,
|
|
projectKey: projectDescriptor.projectKey,
|
|
projectDisplayName: project.workspace.projectDisplayName,
|
|
workspaceName: project.workspace.name,
|
|
workspaceDirectory: project.workspace.workspaceDirectory,
|
|
};
|
|
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
|
|
const importSessionId = "fixture-custom-title";
|
|
const importSessionTimestamp = await seedClaudeSessions({
|
|
projectRoot: repo.path,
|
|
worktreeDirectory,
|
|
unrelatedDirectory: unrelated.path,
|
|
importSessionId,
|
|
});
|
|
scenario = {
|
|
project: openedProject,
|
|
reuseTarget,
|
|
projectName: project.workspace.projectDisplayName,
|
|
projectRoot: repo.path,
|
|
worktreeDirectory,
|
|
unrelatedDirectory: unrelated.path,
|
|
importSessionId,
|
|
importSessionTimestamp,
|
|
repoCleanup: repo.cleanup,
|
|
unrelatedCleanup: unrelated.cleanup,
|
|
};
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await client?.removeProject(scenario?.project.projectId).catch(() => undefined);
|
|
await client?.removeProject(scenario?.reuseTarget.projectId).catch(() => undefined);
|
|
await client?.close().catch(() => undefined);
|
|
await scenario?.repoCleanup().catch(() => undefined);
|
|
await scenario?.unrelatedCleanup().catch(() => undefined);
|
|
await rm(claudeConfigDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
test("an open Import Session row keeps its age current", async ({ page }) => {
|
|
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
|
|
const flow = new ImportSessionFlow(page);
|
|
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
|
await flow.openGlobally();
|
|
|
|
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
|
|
await expect(row).toContainText("1m ago");
|
|
await page.clock.fastForward("03:00");
|
|
await expect(row).toContainText("4m ago");
|
|
});
|
|
|
|
test("captures the compact import-session journey", async ({ page }, testInfo) => {
|
|
const flow = new ImportSessionFlow(page);
|
|
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
|
|
|
await test.step("the new workspace screen offers import at the top", async () => {
|
|
await flow.revealNewWorkspaceEntryPoint();
|
|
await capture(page, testInfo, "01-mobile-new-workspace-import.png");
|
|
});
|
|
|
|
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
|
|
await flow.openFromNewWorkspace();
|
|
await flow.expectScope("Sessions on", false);
|
|
await flow.expectRows({
|
|
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
|
|
folders: [
|
|
[scenario.importSessionId, scenario.projectName],
|
|
["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`],
|
|
["fixture-unrelated", scenario.reuseTarget.projectDisplayName],
|
|
],
|
|
});
|
|
await flow.expectProviderError("Broken ACP");
|
|
await flow.expectProviderFilterFits(390);
|
|
await flow.revealSession("fixture-unrelated");
|
|
await capture(page, testInfo, "02-mobile-sheet-unscoped.png");
|
|
});
|
|
|
|
await test.step("search narrows across the fixture corpus", async () => {
|
|
await flow.search("invoice");
|
|
await capture(page, testInfo, "03-mobile-search-narrowed.png");
|
|
});
|
|
|
|
await test.step("load more grows the result set and then disappears", async () => {
|
|
await flow.resetSearch();
|
|
await capture(page, testInfo, "04-mobile-load-more-visible.png");
|
|
await flow.loadMore();
|
|
await capture(page, testInfo, "05-mobile-load-more-complete.png");
|
|
});
|
|
|
|
await test.step("one provider fails inline and Retry settles", async () => {
|
|
await flow.retryProvider(brokenProvider, "Broken ACP");
|
|
await capture(page, testInfo, "06-mobile-provider-error-retry.png");
|
|
});
|
|
|
|
await test.step("the selected row imports and opens the hydrated transcript", async () => {
|
|
await flow.importSession(scenario.importSessionId);
|
|
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
|
|
await expect(page.getByRole("button", { name: "Open menu", exact: true })).toBeVisible();
|
|
await capture(page, testInfo, "08-mobile-agent-after-import.png");
|
|
});
|
|
|
|
await test.step("workspace actions start scoped and can widen to the host", async () => {
|
|
await flow.openFromWorkspaceHeader();
|
|
await capture(page, testInfo, "09-mobile-workspace-scoped.png");
|
|
await flow.showAll();
|
|
await flow.expectRows({
|
|
folders: [["fixture-unrelated", scenario.reuseTarget.projectDisplayName]],
|
|
});
|
|
await capture(page, testInfo, "10-mobile-workspace-show-all.png");
|
|
|
|
await flow.importSession("fixture-root-03");
|
|
await flow.expectImportedIntoWorkspace(
|
|
scenario.reuseTarget.workspaceId,
|
|
"Review fixture item 3",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("captures the desktop import sheet and command-center entry", async ({ page }, testInfo) => {
|
|
const flow = new ImportSessionFlow(page);
|
|
await flow.openWorkspace(scenario.project.workspaceId, {
|
|
width: 1280,
|
|
height: 800,
|
|
});
|
|
|
|
await test.step("desktop shows the flat host-wide sheet", async () => {
|
|
await flow.openGlobally();
|
|
// The compact test may already have imported the newest fixture row, so this
|
|
// asserts recency as an ordering between two rows nothing imports.
|
|
await flow.expectRows({
|
|
before: ["fixture-worktree", "fixture-unrelated"],
|
|
folders: [["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`]],
|
|
});
|
|
await flow.revealSession("fixture-unrelated");
|
|
await capture(page, testInfo, "11-desktop-sheet-unscoped.png");
|
|
await flow.close();
|
|
});
|
|
|
|
await test.step("import matches the command but not Home", async () => {
|
|
await flow.expectCommandCenterMatch();
|
|
await capture(page, testInfo, "12-desktop-command-center-import.png");
|
|
});
|
|
});
|
|
|
|
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 });
|
|
await copyFile(outputPath, path.join(SCREENSHOT_DIRECTORY, name));
|
|
}
|
|
|
|
async function seedClaudeSessions(input: {
|
|
projectRoot: string;
|
|
worktreeDirectory: string;
|
|
unrelatedDirectory: string;
|
|
importSessionId: string;
|
|
}): Promise<number> {
|
|
const sessions = [
|
|
{
|
|
cwd: input.projectRoot,
|
|
id: input.importSessionId,
|
|
title: "Invoice migration plan",
|
|
prompt: "Review the invoice migration",
|
|
answer: "The fixture transcript is ready.",
|
|
},
|
|
{
|
|
cwd: input.worktreeDirectory,
|
|
id: "fixture-worktree",
|
|
title: "Review worktree fix",
|
|
prompt: "Check the worktree import flow",
|
|
},
|
|
{
|
|
cwd: input.unrelatedDirectory,
|
|
id: "fixture-unrelated",
|
|
title: "Unrelated directory notes",
|
|
prompt: "Review the unrelated directory",
|
|
},
|
|
...Array.from({ length: 20 }, (_, index) => ({
|
|
cwd: [input.projectRoot, input.worktreeDirectory, input.unrelatedDirectory][index % 3]!,
|
|
id: `fixture-root-${String(index + 1).padStart(2, "0")}`,
|
|
title: `Root session ${String(index + 1).padStart(2, "0")}`,
|
|
prompt: index === 7 ? "Investigate invoice rendering" : `Review fixture item ${index + 1}`,
|
|
})),
|
|
];
|
|
const newest = Date.now() - 60_000;
|
|
for (const [index, session] of sessions.entries()) {
|
|
const projectDirectory = path.join(
|
|
claudeConfigDirectory,
|
|
"projects",
|
|
session.cwd.replace(/[^a-zA-Z0-9]/g, "-"),
|
|
);
|
|
await mkdir(projectDirectory, { recursive: true });
|
|
const sessionPath = path.join(projectDirectory, `${session.id}.jsonl`);
|
|
const records = [
|
|
{
|
|
type: "user",
|
|
uuid: `${session.id}-user`,
|
|
message: { role: "user", content: session.prompt },
|
|
cwd: session.cwd,
|
|
sessionId: session.id,
|
|
},
|
|
...(session.answer
|
|
? [
|
|
{
|
|
type: "assistant",
|
|
uuid: `${session.id}-assistant`,
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: session.answer }],
|
|
},
|
|
cwd: session.cwd,
|
|
sessionId: session.id,
|
|
},
|
|
]
|
|
: []),
|
|
{ type: "custom-title", customTitle: session.title, sessionId: session.id },
|
|
];
|
|
await writeFile(sessionPath, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`);
|
|
const timestamp = new Date(newest - index * 60_000);
|
|
await utimes(sessionPath, timestamp, timestamp);
|
|
}
|
|
return newest;
|
|
}
|