2c06cf5b6f
Co-authored-by: Kandev Agent <agent@kandev.dev>
651 lines
23 KiB
TypeScript
651 lines
23 KiB
TypeScript
// Routing: /t/{taskId} (task-keyed). File starts with "mobile-" so it runs on
|
|
// the mobile-chrome Playwright project (Pixel 5 emulation).
|
|
//
|
|
// Regression guard: tapping a file in the Files → All files tab must replace
|
|
// the Files panel with the file viewer, NOT navigate to the Changes panel.
|
|
import { type Page } from "@playwright/test";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { test, expect } from "../../fixtures/test-base";
|
|
import type { SeedData } from "../../fixtures/test-base";
|
|
import type { ApiClient } from "../../helpers/api-client";
|
|
import type { BackendContext } from "../../fixtures/backend";
|
|
import { GitHelper, makeGitEnv, createStandardProfile } from "../../helpers/git-helper";
|
|
import { selectMarkdownPreviewText } from "../../helpers/markdown-preview";
|
|
import { waitForWorkspacePath } from "../../helpers/session";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
|
|
function createLongFileContent(lines = 500): string {
|
|
return Array.from(
|
|
{ length: lines },
|
|
(_, index) => `export const line_${index} = "line ${index}";`,
|
|
).join("\n");
|
|
}
|
|
|
|
async function setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle,
|
|
options,
|
|
}: {
|
|
testPage: Page;
|
|
apiClient: ApiClient;
|
|
seedData: SeedData;
|
|
backend: BackendContext;
|
|
taskTitle: string;
|
|
options?: {
|
|
extension?: string;
|
|
content?: string | Buffer;
|
|
directory?: string;
|
|
};
|
|
}): Promise<{ session: SessionPage; filePath: string }> {
|
|
const fileExtension = options?.extension ?? "ts";
|
|
const fileContent = options?.content ?? 'export const greeting = "hello";';
|
|
const fileName = `viewer-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${fileExtension}`;
|
|
const filePath = options?.directory ? `${options.directory}/${fileName}` : fileName;
|
|
const git = new GitHelper(
|
|
path.join(backend.tmpDir, "repos", "e2e-repo"),
|
|
makeGitEnv(backend.tmpDir),
|
|
);
|
|
git.createFile(filePath, fileContent);
|
|
git.stageAll();
|
|
git.commit(`add ${filePath}`);
|
|
|
|
const profile = await createStandardProfile(apiClient, `mobile-fv-${Date.now()}`);
|
|
const task = await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 45_000 });
|
|
return { session, filePath };
|
|
}
|
|
|
|
test.describe("Mobile file tree keyboard shortcuts", () => {
|
|
test("select-all stays in the new-file name input", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
// @covers AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.1
|
|
// @covers AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.2
|
|
const { session, filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile File Create Select All",
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
await expect(session.fileTreeNode(filePath)).toBeVisible({ timeout: 15_000 });
|
|
|
|
const directNewFile = testPage.getByRole("button", { name: "New file" });
|
|
if (await directNewFile.isVisible().catch(() => false)) {
|
|
await directNewFile.tap();
|
|
} else {
|
|
await testPage.getByTestId("files-create-menu").tap();
|
|
const menuItem = testPage.getByRole("menuitem", { name: "New file" });
|
|
await menuItem.tap();
|
|
await expect(menuItem).toHaveCount(0);
|
|
}
|
|
const input = testPage.getByPlaceholder("filename...");
|
|
await expect(input).toBeFocused({ timeout: 5_000 });
|
|
|
|
const draftName = "mobile-draft.ts";
|
|
await input.fill(draftName);
|
|
await input.press("ControlOrMeta+a");
|
|
|
|
await expect
|
|
.poll(async () => ({
|
|
selection: await input.evaluate((element) => ({
|
|
start: element.selectionStart,
|
|
end: element.selectionEnd,
|
|
})),
|
|
selectedRows: await session.fileTreeSelectedNodes().count(),
|
|
}))
|
|
.toEqual({
|
|
selection: { start: 0, end: draftName.length },
|
|
selectedRows: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe("Mobile file viewer panel", () => {
|
|
test.describe.configure({ retries: 1 });
|
|
|
|
test("tapping file in All-files tab opens viewer panel instead of Changes panel", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const { filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV Open",
|
|
});
|
|
|
|
// Navigate to the Files panel via the bottom nav
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
|
|
// Wait for the file to appear in the browser tree
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Tap the file — must open the inline viewer panel, NOT switch to the Changes panel
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
await expect(viewer.getByText(filePath)).toBeVisible();
|
|
});
|
|
|
|
test("closing the viewer panel returns to the files browser", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const { filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV Close",
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
|
|
await viewer.getByRole("button", { name: "Close" }).tap();
|
|
await expect(viewer).not.toBeVisible({ timeout: 5_000 });
|
|
await expect(fileNode).toBeVisible();
|
|
});
|
|
|
|
test("binary files show preview-unavailable message in viewer panel", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const { filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV Binary",
|
|
options: { extension: "bin", content: Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0x10]) },
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
await expect(viewer.getByText("Cannot preview this file")).toBeVisible();
|
|
await expect(viewer.getByText("Binary file")).toBeVisible();
|
|
});
|
|
|
|
test("binary viewer exposes a touch-sized download control", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
const binary = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]);
|
|
const { session, filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV Binary Download",
|
|
options: { extension: "bin", content: binary },
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
await session.fileTreeNode(filePath).tap();
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
|
|
const downloadControl = viewer.getByRole("button", { name: "Download file" });
|
|
await expect(downloadControl).toBeVisible();
|
|
const box = await downloadControl.boundingBox();
|
|
expect(box?.height).toBeGreaterThanOrEqual(44);
|
|
expect(box?.width).toBeGreaterThanOrEqual(44);
|
|
|
|
const download = testPage.waitForEvent("download");
|
|
await downloadControl.tap();
|
|
const saved = await download;
|
|
const target = path.join(backend.tmpDir, "mobile-file-download.bin");
|
|
await saved.saveAs(target);
|
|
expect(fs.readFileSync(target)).toEqual(binary);
|
|
});
|
|
|
|
test("Files upload menu keeps its actions touch-sized", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV Upload Menu",
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
const createMenu = testPage.getByTestId("files-create-menu");
|
|
const triggerBox = await createMenu.boundingBox();
|
|
expect(triggerBox?.height).toBeGreaterThanOrEqual(44);
|
|
expect(triggerBox?.width).toBeGreaterThanOrEqual(44);
|
|
await createMenu.tap();
|
|
|
|
const uploadFiles = testPage.getByRole("menuitem", { name: "Upload files" });
|
|
const uploadFolder = testPage.getByRole("menuitem", { name: "Upload folder" });
|
|
await expect(uploadFiles).toBeVisible();
|
|
await expect(uploadFolder).toBeVisible();
|
|
await expect
|
|
.poll(async () => (await uploadFiles.boundingBox())?.height ?? 0, { timeout: 5_000 })
|
|
.toBeGreaterThanOrEqual(44);
|
|
await expect
|
|
.poll(async () => (await uploadFolder.boundingBox())?.height ?? 0, { timeout: 5_000 })
|
|
.toBeGreaterThanOrEqual(44);
|
|
});
|
|
|
|
test("viewer panel keeps a visible close action and supports scrolling large files", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const { filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV Scroll",
|
|
options: { extension: "ts", content: createLongFileContent(1_000) },
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
|
|
const closeButton = viewer.getByRole("button", { name: "Close" });
|
|
await expect(closeButton).toBeVisible();
|
|
await expect(closeButton).toBeInViewport();
|
|
|
|
// CodeMirror's `.cm-scroller` owns the scroll for text files (matches the
|
|
// desktop editor tab). Verify it's the element that actually scrolls.
|
|
const cmScrollerLocator = viewer.locator(".cm-scroller");
|
|
await expect(cmScrollerLocator).toHaveCount(1);
|
|
const cmScroller = cmScrollerLocator.first();
|
|
await expect(cmScroller).toBeVisible();
|
|
|
|
const metrics = await cmScroller.evaluate((element) => ({
|
|
clientHeight: element.clientHeight,
|
|
scrollHeight: element.scrollHeight,
|
|
touchAction: getComputedStyle(element).touchAction,
|
|
overflowY: getComputedStyle(element).overflowY,
|
|
}));
|
|
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
|
|
// touch-action must allow vertical pan or mobile touch scroll won't work.
|
|
expect(metrics.touchAction).toMatch(/pan-y|auto/);
|
|
expect(metrics.overflowY).toMatch(/auto|scroll/);
|
|
|
|
const scrollTopAfterMove = await cmScroller.evaluate((element) => {
|
|
element.scrollTop = 0;
|
|
element.scrollBy(0, 1_400);
|
|
return element.scrollTop;
|
|
});
|
|
expect(scrollTopAfterMove).toBeGreaterThan(0);
|
|
|
|
// Real touch swipe: dispatch CDP touch events to exercise the gesture path
|
|
// that the programmatic scrollBy above bypasses. This is the path the user
|
|
// actually triggers when finger-scrolling a long file.
|
|
await cmScroller.evaluate((element) => {
|
|
element.scrollTop = 0;
|
|
});
|
|
const box = await cmScroller.boundingBox();
|
|
if (!box) throw new Error("cm-scroller has no bounding box");
|
|
const cdp = await testPage.context().newCDPSession(testPage);
|
|
const centerX = box.x + box.width / 2;
|
|
const startY = box.y + box.height - 20;
|
|
const endY = box.y + 20;
|
|
await cdp.send("Input.dispatchTouchEvent", {
|
|
type: "touchStart",
|
|
touchPoints: [{ x: centerX, y: startY }],
|
|
});
|
|
for (let i = 1; i <= 8; i++) {
|
|
const y = startY + ((endY - startY) * i) / 8;
|
|
await cdp.send("Input.dispatchTouchEvent", {
|
|
type: "touchMove",
|
|
touchPoints: [{ x: centerX, y }],
|
|
});
|
|
}
|
|
await cdp.send("Input.dispatchTouchEvent", {
|
|
type: "touchEnd",
|
|
touchPoints: [],
|
|
});
|
|
|
|
await expect
|
|
.poll(async () => cmScroller.evaluate((element) => element.scrollTop), {
|
|
timeout: 5_000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
});
|
|
|
|
test("markdown files can toggle preview in the viewer panel", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const { session, filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV Markdown",
|
|
options: {
|
|
extension: "md",
|
|
content: '# Heading\n\n<div align="center"><p>Embedded HTML body</p></div>',
|
|
},
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
|
|
await viewer.getByTestId("markdown-preview-toggle").tap();
|
|
await expect(viewer.getByTestId("markdown-preview")).toBeVisible();
|
|
await expect(viewer.locator('div[align="center"]')).toContainText("Embedded HTML body");
|
|
|
|
await selectMarkdownPreviewText(viewer.getByTestId("markdown-preview").locator("p").first());
|
|
const commentButton = testPage.getByTestId("markdown-preview-comment-button");
|
|
await expect(commentButton).toBeVisible({ timeout: 5_000 });
|
|
await commentButton.tap();
|
|
|
|
await testPage
|
|
.locator('textarea[placeholder="Add your comment or instruction..."]')
|
|
.fill("Mobile preview comment.");
|
|
await testPage.getByRole("button", { name: "Add", exact: true }).tap();
|
|
await expect(viewer.getByTestId("markdown-preview-commented-range").first()).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
|
|
const badge = viewer.getByTestId("markdown-preview-comment-badge");
|
|
await expect(badge).toHaveCount(1);
|
|
await badge.tap();
|
|
await testPage.getByRole("button", { name: "Edit comment" }).tap();
|
|
await testPage
|
|
.locator('textarea[placeholder="Add a comment..."]')
|
|
.fill("Updated mobile comment.");
|
|
await testPage.getByRole("button", { name: "Update", exact: true }).tap();
|
|
await expect(testPage.getByText("Updated mobile comment.")).toBeVisible({ timeout: 5_000 });
|
|
|
|
await testPage.getByRole("button", { name: "Chat", exact: true }).tap();
|
|
const fileName = filePath.split("/").pop() ?? filePath;
|
|
await expect(session.activeChat().getByText(`${fileName} (1)`)).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
});
|
|
|
|
test("wide Markdown preview tables scroll locally without resize controls", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
// Covers AC-UI-RESIZABLE-MARKDOWN-TABLES-001.12.
|
|
test.setTimeout(90_000);
|
|
const marker = "Mobile file preview table marker";
|
|
const { filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV Markdown Table",
|
|
options: {
|
|
extension: "md",
|
|
content: [
|
|
"| Status | Owner | Project | Branch | Review | Build | Deploy | Notes |",
|
|
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
`| Ready | Team Alpha | Kandev | main | Pending | Passing | Staged | ${marker} |`,
|
|
].join("\n"),
|
|
},
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
await viewer.getByTestId("markdown-preview-toggle").tap();
|
|
|
|
const preview = viewer.getByTestId("markdown-preview");
|
|
const table = preview.locator("table", { hasText: marker });
|
|
const wrapper = table.locator("xpath=..");
|
|
await expect(table).toBeVisible();
|
|
await expect(preview.getByTestId(/^markdown-table-resizer-/)).toHaveCount(0);
|
|
expect(await wrapper.evaluate((element) => element.scrollWidth > element.clientWidth + 1)).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
await preview.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
|
|
).toBe(true);
|
|
expect(
|
|
await testPage.evaluate(
|
|
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("viewer header shows full path for files inside subdirectories", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const directory = `nested-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
const { filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV Subdir",
|
|
options: { directory },
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
|
|
const dirNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${directory}"]`);
|
|
await expect(dirNode).toBeVisible({ timeout: 15_000 });
|
|
await dirNode.tap();
|
|
|
|
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
|
|
await expect(fileNode).toBeVisible({ timeout: 15_000 });
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Header must show full relative path, not just the basename.
|
|
await expect(viewer.getByText(filePath)).toBeVisible();
|
|
});
|
|
|
|
// @covers AC-UI-FILE-TREE-PATH-SCOPE-001.1
|
|
// @covers AC-UI-FILE-TREE-PATH-SCOPE-001.6
|
|
// An absolute workspace path from a read tool must use the same relative
|
|
// identity for the native viewer and its pending cursor position.
|
|
test("tapping an absolute workspace read link scrolls to the target line", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const fileName = `chat-read-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.ts`;
|
|
const filePath = fileName;
|
|
const git = new GitHelper(
|
|
path.join(backend.tmpDir, "repos", "e2e-repo"),
|
|
makeGitEnv(backend.tmpDir),
|
|
);
|
|
git.createFile(filePath, createLongFileContent(500));
|
|
git.stageAll();
|
|
git.commit(`add ${filePath}`);
|
|
|
|
const profile = await createStandardProfile(apiClient, `mobile-fv-scroll-${Date.now()}`);
|
|
// `e2e:message(...)` emits a single non-activity text message — no thinking
|
|
// and no tool calls — so the seeded read card below is the only activity
|
|
// message and renders as a standalone, directly tappable card (a turn group
|
|
// only forms with ≥2 activity messages in the same turn).
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Mobile FV Scroll To Line",
|
|
profile.id,
|
|
{
|
|
description: 'e2e:message("ready")',
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
|
const workspacePath = await waitForWorkspacePath(apiClient, task.id, task.session_id);
|
|
const absoluteFilePath = path.join(workspacePath, filePath);
|
|
|
|
// Seed the absolute path emitted by an agent tool. The opener must normalize
|
|
// it before keying the pending cursor and native viewer with the same path.
|
|
const targetLine = 300;
|
|
await apiClient.seedSessionMessage(task.session_id, {
|
|
type: "tool_read",
|
|
content: "Read file",
|
|
metadata: {
|
|
status: "complete",
|
|
tool_call_id: "tc-chat-read-scroll",
|
|
normalized: {
|
|
read_file: {
|
|
file_path: absoluteFilePath,
|
|
offset: targetLine,
|
|
limit: 20,
|
|
output: { content: 'export const line_299 = "line 299";', line_count: 20 },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 45_000 });
|
|
|
|
// FilePathButton preserves the recorded path in its title while displaying
|
|
// the relative suffix.
|
|
const chat = session.activeChat();
|
|
const fileLink = chat.locator(`button[title="${absoluteFilePath}"]`);
|
|
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
|
await fileLink.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 10_000 });
|
|
await expect(viewer.getByText(filePath)).toBeVisible();
|
|
|
|
const cmScroller = viewer.locator(".cm-scroller").first();
|
|
await expect(cmScroller).toBeVisible();
|
|
|
|
// FileViewerContent consumed the pending position on mount and scrolled the
|
|
// target line to centre — the scroller is no longer pinned at the top.
|
|
await expect
|
|
.poll(async () => cmScroller.evaluate((element) => element.scrollTop), { timeout: 10_000 })
|
|
.toBeGreaterThan(0);
|
|
|
|
// The target line's gutter number is rendered and on-screen, proving the
|
|
// scroll landed on the requested line rather than an arbitrary offset.
|
|
await expect(
|
|
viewer.locator(".cm-lineNumbers .cm-gutterElement", { hasText: "300" }),
|
|
).toBeInViewport();
|
|
});
|
|
|
|
// Opening a file with NO pending cursor position (normal file browsing) must
|
|
// not scroll — the CodeMirror viewer stays pinned at the top.
|
|
test("opening a file without a pending cursor position stays at the top", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const { session, filePath } = await setupMobileFileViewerTest({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
taskTitle: "Mobile FV No Scroll",
|
|
options: { extension: "ts", content: createLongFileContent(500) },
|
|
});
|
|
|
|
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
|
|
const fileNode = await session.fileTree.waitForFileTreeNode(filePath, 15_000);
|
|
await fileNode.tap();
|
|
|
|
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
|
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
|
|
|
const cmScroller = viewer.locator(".cm-scroller").first();
|
|
await expect(cmScroller).toBeVisible();
|
|
// No pending entry → no scroll-to-line dispatch → scroller stays at the top.
|
|
expect(await cmScroller.evaluate((element) => element.scrollTop)).toBe(0);
|
|
});
|
|
});
|