527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
import { test, expect } from "../../fixtures/test-base";
|
|
import type { Page } from "@playwright/test";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
import { makeGitEnv } from "../../helpers/git-helper";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { execSync } from "node:child_process";
|
|
import { waitForSessionDone } from "../../helpers/session";
|
|
import { dwell } from "../../helpers/causal-waits";
|
|
|
|
/** Minimal git helper for E2E tests - runs git commands in the test repository. */
|
|
class GitHelper {
|
|
constructor(
|
|
private repoDir: string,
|
|
private env: NodeJS.ProcessEnv,
|
|
) {}
|
|
|
|
exec(cmd: string): string {
|
|
return execSync(cmd, { cwd: this.repoDir, env: this.env, encoding: "utf8" });
|
|
}
|
|
|
|
createFile(name: string, content: string) {
|
|
fs.writeFileSync(path.join(this.repoDir, name), content);
|
|
}
|
|
|
|
stageAll() {
|
|
this.exec("git add -A");
|
|
}
|
|
|
|
commit(message: string) {
|
|
this.exec(`git commit -m "${message}"`);
|
|
}
|
|
}
|
|
|
|
function changesTab(testPage: Page) {
|
|
return testPage.locator(".dv-tab:visible", {
|
|
has: testPage.locator(".dv-default-tab:visible").filter({ hasText: /^Changes/ }),
|
|
});
|
|
}
|
|
|
|
function vscodeTab(testPage: Page) {
|
|
return testPage.locator(".dv-tab:visible", {
|
|
has: testPage.locator(".dv-default-tab:visible").filter({ hasText: /^VS Code/ }),
|
|
});
|
|
}
|
|
|
|
async function changesCountForSession(testPage: Page, sessionId: string): Promise<number | null> {
|
|
return testPage.evaluate((sid) => {
|
|
type StoreWindow = Window & {
|
|
__KANDEV_E2E_STORE__?: {
|
|
getState: () => {
|
|
environmentIdBySessionId: Record<string, string>;
|
|
gitStatus: {
|
|
byEnvironmentRepo: Record<string, Record<string, { files?: Record<string, unknown> }>>;
|
|
};
|
|
sessionCommits: { byEnvironmentId: Record<string, unknown[]> };
|
|
};
|
|
};
|
|
};
|
|
const store = (window as StoreWindow).__KANDEV_E2E_STORE__;
|
|
if (!store) throw new Error("E2E store bridge missing");
|
|
const state = store.getState();
|
|
const envKey = state.environmentIdBySessionId[sid] ?? sid;
|
|
const hasRepoStatuses = envKey in state.gitStatus.byEnvironmentRepo;
|
|
const hasCommits = envKey in state.sessionCommits.byEnvironmentId;
|
|
if (!hasRepoStatuses && !hasCommits) return null;
|
|
const repoStatuses = state.gitStatus.byEnvironmentRepo[envKey] ?? {};
|
|
// Keep this count in sync with selectChangesMarkerByEnvironment.
|
|
let count = state.sessionCommits.byEnvironmentId[envKey]?.length ?? 0;
|
|
for (const status of Object.values(repoStatuses)) {
|
|
count += Object.keys(status.files ?? {}).length;
|
|
}
|
|
return count;
|
|
}, sessionId);
|
|
}
|
|
|
|
async function setGitStatusForSession(testPage: Page, sessionId: string, changedFiles: string[]) {
|
|
await testPage.evaluate(
|
|
({ sid, files }) => {
|
|
type FileInfo = {
|
|
path: string;
|
|
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
|
|
staged: boolean;
|
|
additions?: number;
|
|
deletions?: number;
|
|
};
|
|
type StoreWindow = Window & {
|
|
__KANDEV_E2E_STORE__?: {
|
|
getState: () => {
|
|
setSessionCommits: (
|
|
sessionId: string,
|
|
commits: unknown[],
|
|
options?: { allowEmpty?: boolean },
|
|
) => void;
|
|
setGitStatus: (
|
|
taskEnvironmentId: string,
|
|
status: {
|
|
branch: string;
|
|
remote_branch: string | null;
|
|
modified: string[];
|
|
added: string[];
|
|
deleted: string[];
|
|
untracked: string[];
|
|
renamed: string[];
|
|
ahead: number;
|
|
behind: number;
|
|
files: Record<string, FileInfo>;
|
|
timestamp: string;
|
|
},
|
|
) => boolean;
|
|
};
|
|
};
|
|
};
|
|
const store = (window as StoreWindow).__KANDEV_E2E_STORE__;
|
|
if (!store) throw new Error("E2E store bridge missing");
|
|
const state = store.getState();
|
|
const taskEnvironmentId = state.environmentIdBySessionId[sid] ?? sid;
|
|
state.setSessionCommits(sid, [], { allowEmpty: true });
|
|
const fileMap = Object.fromEntries(
|
|
files.map((path): [string, FileInfo] => [
|
|
path,
|
|
{ path, status: "untracked", staged: false, additions: 1, deletions: 0 },
|
|
]),
|
|
);
|
|
state.setGitStatus(taskEnvironmentId, {
|
|
branch: "main",
|
|
remote_branch: null,
|
|
modified: [],
|
|
added: [],
|
|
deleted: [],
|
|
untracked: files,
|
|
renamed: [],
|
|
ahead: 0,
|
|
behind: 0,
|
|
files: fileMap,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
},
|
|
{ sid: sessionId, files: changedFiles },
|
|
);
|
|
}
|
|
|
|
async function moveChangesToChatGroupAndFocusChat(testPage: Page) {
|
|
await testPage.evaluate(() => {
|
|
type Group = { id: string };
|
|
type PanelApi = {
|
|
moveTo: (opts: { group: Group }) => void;
|
|
setActive: () => void;
|
|
};
|
|
type Panel = { id: string; api: PanelApi; group?: Group };
|
|
type Api = { panels: Panel[]; getPanel: (id: string) => Panel | undefined };
|
|
const dockview = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
|
|
const changes = dockview?.getPanel("changes");
|
|
const chat =
|
|
dockview?.getPanel("chat") ?? dockview?.panels.find((p) => p.id.startsWith("session:"));
|
|
if (!changes || !chat?.group) {
|
|
throw new Error("Dockview changes or chat panel was not available");
|
|
}
|
|
changes.api.moveTo({ group: chat.group });
|
|
chat.api.setActive();
|
|
});
|
|
}
|
|
|
|
test.describe("Changes panel focus behavior", () => {
|
|
test.beforeEach(({ backend, seedData }) => {
|
|
// Requesting seedData guarantees that the repository and its offline
|
|
// origin exist before this hook runs.
|
|
const git = new GitHelper(seedData.repositoryPath, makeGitEnv(backend.tmpDir));
|
|
const seedCommit = git.exec("git rev-list --max-parents=0 HEAD").trim();
|
|
git.exec(`git reset --hard ${seedCommit}`);
|
|
git.exec("git clean -fd");
|
|
});
|
|
|
|
/**
|
|
* Verifies the changes panel does NOT steal focus from the chat tab
|
|
* on page refresh when the task has existing git changes/commits.
|
|
*
|
|
* Root cause of the bug: the changes-tab component auto-activated the
|
|
* changes panel whenever totalCount went from 0 → N. On page refresh,
|
|
* hooks start with totalCount=0 (no data loaded), then async git data
|
|
* arrives making totalCount > 0 — triggering the auto-activate.
|
|
*/
|
|
test("changes panel does not auto-focus on page refresh", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
|
const gitEnv = makeGitEnv(backend.tmpDir);
|
|
const git = new GitHelper(repoDir, gitEnv);
|
|
|
|
// Create a task and wait for the agent to be ready
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Focus test task",
|
|
seedData.agentProfileId,
|
|
{
|
|
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: 30_000 });
|
|
|
|
// Create a file and commit so there are existing changes
|
|
git.createFile("test-file.txt", "hello world");
|
|
git.stageAll();
|
|
git.commit("test commit");
|
|
|
|
// Wait for the changes panel to show the commit
|
|
await session.clickTab("Changes");
|
|
await expect(session.changes).toBeVisible({ timeout: 10_000 });
|
|
await session.expandCommitsSection();
|
|
await expect(session.changes.getByText("test commit")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Switch back to chat tab — this is the tab that should be active after refresh
|
|
await session.clickSessionChatTab();
|
|
await expect(session.chat).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Refresh the page
|
|
await testPage.reload();
|
|
await session.waitForLoad();
|
|
|
|
// Wait for the git data to load (changes tab should show count)
|
|
await expect(testPage.locator(".dv-default-tab:has-text('Changes')")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// The chat/session panel should be the active tab, NOT changes
|
|
const changesTab = testPage.locator(".dv-default-tab:has-text('Changes')");
|
|
await expect(changesTab).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 });
|
|
|
|
// Chat should be visible (active in center group)
|
|
await expect(session.chat).toBeVisible({ timeout: 5_000 });
|
|
});
|
|
|
|
test("discarding one changed file uses local confirmation", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
prCapture,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
|
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Single file discard confirmation",
|
|
seedData.agentProfileId,
|
|
{
|
|
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: 30_000 });
|
|
await session.clickTab("Changes");
|
|
|
|
git.createFile("single-discard.txt", "discard me");
|
|
const file = session.changesFileRow("single-discard.txt");
|
|
await expect(file).toBeVisible({ timeout: 15_000 });
|
|
await file.hover();
|
|
|
|
const discard = file.getByRole("button", { name: "Discard changes" });
|
|
await expect(discard).toBeVisible();
|
|
await discard.click();
|
|
|
|
const confirmation = testPage.getByTestId("discard-local-changes-confirm-popover");
|
|
await expect(confirmation).toBeVisible();
|
|
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
|
|
await expect(file).toBeVisible();
|
|
await prCapture.screenshot("desktop-discard-file-confirmation", {
|
|
caption: "Desktop Changes panel with one-file discard confirmation",
|
|
});
|
|
|
|
await testPage.keyboard.press("Escape");
|
|
await expect(confirmation).not.toBeVisible();
|
|
await expect(discard).toBeFocused();
|
|
|
|
await discard.click();
|
|
await expect(confirmation).toBeVisible();
|
|
await testPage.getByTestId("discard-local-changes-confirm").click();
|
|
await expect(file).not.toBeVisible({ timeout: 15_000 });
|
|
});
|
|
|
|
test("changes panel does not auto-focus when grouped with agent session panels", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
|
const gitEnv = makeGitEnv(backend.tmpDir);
|
|
const git = new GitHelper(repoDir, gitEnv);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Center group focus test",
|
|
seedData.agentProfileId,
|
|
{
|
|
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: 30_000 });
|
|
await session.waitForDockviewReady();
|
|
|
|
await moveChangesToChatGroupAndFocusChat(testPage);
|
|
await expect(session.chat).toBeVisible();
|
|
|
|
await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 });
|
|
|
|
git.createFile("new-file.txt", "new content");
|
|
git.stageAll();
|
|
git.commit("new commit");
|
|
|
|
// Wait for the changes badge to update
|
|
await expect(testPage.locator(".dv-default-tab:has-text('Changes')")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
await dwell(
|
|
testPage,
|
|
2_000,
|
|
"negative-assertion",
|
|
"the assertion below is that the changes tab does not steal activation; an auto-activate that must never happen publishes nothing, so the only way to give a regression room to fire is to let its window elapse",
|
|
);
|
|
|
|
await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 });
|
|
|
|
await expect(session.chat).toBeVisible();
|
|
});
|
|
|
|
test("new git updates do not steal focus from the VS Code group", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
|
const gitEnv = makeGitEnv(backend.tmpDir);
|
|
const git = new GitHelper(repoDir, gitEnv);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"VS Code group focus test",
|
|
seedData.agentProfileId,
|
|
{
|
|
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: 30_000 });
|
|
await session.waitForDockviewReady();
|
|
|
|
git.createFile("first-change.txt", "one");
|
|
await expect(
|
|
testPage.locator(".dv-default-tab").filter({ hasText: /^Changes \(1\)$/ }),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
|
|
const presetTrigger = testPage.getByTestId("layout-preset-trigger");
|
|
await presetTrigger.click();
|
|
await testPage.locator('[data-testid="layout-preset-item"][data-preset-id="vscode"]').click();
|
|
|
|
const activeVscodeTab = vscodeTab(testPage);
|
|
await expect(activeVscodeTab).toBeVisible({ timeout: 15_000 });
|
|
await activeVscodeTab.click();
|
|
await expect(activeVscodeTab).toHaveClass(/dv-active-tab/, { timeout: 5_000 });
|
|
|
|
git.createFile("second-change.txt", "two");
|
|
await expect(
|
|
testPage.locator(".dv-default-tab").filter({ hasText: /^Changes \(2\)$/ }),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
|
|
await dwell(
|
|
testPage,
|
|
2_000,
|
|
"negative-assertion",
|
|
"the VS Code group must remain active; a forbidden Changes auto-focus publishes no completion event, so the bounded negative window gives it room to fire",
|
|
);
|
|
|
|
await expect(activeVscodeTab).toHaveClass(/dv-active-tab/, { timeout: 5_000 });
|
|
await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 });
|
|
});
|
|
|
|
test("new git updates focus changes after switching to an inactive task", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const taskA = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Inactive Changes Focus A",
|
|
seedData.agentProfileId,
|
|
{
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
const taskB = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Inactive Changes Focus B",
|
|
seedData.agentProfileId,
|
|
{
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!taskB.session_id) throw new Error("Task B did not start a session");
|
|
await waitForSessionDone(apiClient, taskB.id, taskB.session_id, "Task B did not settle");
|
|
|
|
await testPage.goto(`/t/${taskA.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 30_000 });
|
|
await session.waitForDockviewReady();
|
|
|
|
await expect(session.taskInSidebar("Inactive Changes Focus B")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await setGitStatusForSession(testPage, taskB.session_id, []);
|
|
await expect
|
|
.poll(() => changesCountForSession(testPage, taskB.session_id!), { timeout: 15_000 })
|
|
.toBe(0);
|
|
|
|
await setGitStatusForSession(testPage, taskB.session_id, ["background-change.txt"]);
|
|
await expect
|
|
.poll(() => changesCountForSession(testPage, taskB.session_id!), { timeout: 15_000 })
|
|
.toBe(1);
|
|
await expect(session.activeChat()).toBeVisible();
|
|
await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/);
|
|
|
|
await session.taskInSidebar("Inactive Changes Focus B").click();
|
|
|
|
await expect(changesTab(testPage)).toHaveClass(/dv-active-tab/, { timeout: 5_000 });
|
|
});
|
|
|
|
test("new git updates focus changes when returning to a task with a saved layout", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
const taskA = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Saved Agent Focus A",
|
|
seedData.agentProfileId,
|
|
{
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
const taskB = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Saved Agent Focus B",
|
|
seedData.agentProfileId,
|
|
{
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
if (!taskA.session_id || !taskB.session_id) {
|
|
throw new Error("Tasks did not start sessions");
|
|
}
|
|
|
|
await testPage.goto(`/t/${taskA.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await session.waitForChatIdle({ timeout: 30_000 });
|
|
await session.waitForDockviewReady();
|
|
await session.clickSessionChatTab();
|
|
await expect(session.activeChat()).toBeVisible();
|
|
|
|
await session.taskInSidebar("Saved Agent Focus B").click();
|
|
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), {
|
|
timeout: 15_000,
|
|
});
|
|
await session.waitForLoad();
|
|
|
|
await setGitStatusForSession(testPage, taskA.session_id, []);
|
|
await expect
|
|
.poll(() => changesCountForSession(testPage, taskA.session_id!), { timeout: 15_000 })
|
|
.toBe(0);
|
|
await setGitStatusForSession(testPage, taskA.session_id, ["background-change.txt"]);
|
|
await expect
|
|
.poll(() => changesCountForSession(testPage, taskA.session_id!), { timeout: 15_000 })
|
|
.toBe(1);
|
|
|
|
await session.taskInSidebar("Saved Agent Focus A").click();
|
|
|
|
await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), {
|
|
timeout: 15_000,
|
|
});
|
|
await expect(changesTab(testPage)).toHaveClass(/dv-active-tab/, { timeout: 5_000 });
|
|
});
|
|
});
|