291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
import { test, expect } from "../../fixtures/test-base";
|
|
import type { ApiClient } from "../../helpers/api-client";
|
|
import { KanbanPage } from "../../pages/kanban-page";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
import type { Page } from "@playwright/test";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { execSync } from "node:child_process";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers (same patterns as git-changes-panel.spec.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class GitHelper {
|
|
constructor(
|
|
private repoDir: string,
|
|
private env: NodeJS.ProcessEnv,
|
|
) {}
|
|
|
|
exec(cmd: string): string {
|
|
const lockPath = path.join(this.repoDir, ".git", "index.lock");
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
|
|
try {
|
|
return execSync(cmd, { cwd: this.repoDir, env: this.env, encoding: "utf8" });
|
|
} catch (err) {
|
|
const msg = (err as Error).message ?? "";
|
|
if (msg.includes("index.lock") && attempt < 2) {
|
|
execSync("sleep 0.2");
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
throw new Error(`git exec failed after 3 attempts: ${cmd}`);
|
|
}
|
|
|
|
createFile(name: string, content: string) {
|
|
fs.writeFileSync(path.join(this.repoDir, name), content);
|
|
}
|
|
|
|
stageAll() {
|
|
this.exec("git add -A");
|
|
}
|
|
|
|
getLastCommitMessage(): string {
|
|
return this.exec("git log -1 --format=%B").trim();
|
|
}
|
|
|
|
installFailingPreCommitHook(message: string) {
|
|
const hooksDir = path.join(this.repoDir, ".git", "hooks");
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
const hookPath = path.join(hooksDir, "pre-commit");
|
|
fs.writeFileSync(hookPath, `#!/bin/sh\necho "${message}"\nexit 1\n`);
|
|
fs.chmodSync(hookPath, "755");
|
|
}
|
|
|
|
removePreCommitHook() {
|
|
const hookPath = path.join(this.repoDir, ".git", "hooks", "pre-commit");
|
|
if (fs.existsSync(hookPath)) {
|
|
fs.unlinkSync(hookPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function createStandardProfile(apiClient: ApiClient, name: string) {
|
|
const { agents } = await apiClient.listAgents();
|
|
const agentId = agents[0]?.id;
|
|
if (!agentId) {
|
|
throw new Error(`E2E setup failed: no agent available for profile "${name}"`);
|
|
}
|
|
return apiClient.createAgentProfile(agentId, name, {
|
|
model: "mock-fast",
|
|
auto_approve: true,
|
|
cli_passthrough: false,
|
|
});
|
|
}
|
|
|
|
async function openTaskSession(page: Page, title: string): Promise<SessionPage> {
|
|
const kanban = new KanbanPage(page);
|
|
await kanban.goto();
|
|
|
|
const card = kanban.taskCardByTitle(title);
|
|
await expect(card).toBeVisible({ timeout: 15_000 });
|
|
await card.click();
|
|
await expect(page).toHaveURL(/\/t\//, { timeout: 15_000 });
|
|
|
|
const session = new SessionPage(page);
|
|
await session.waitForLoad();
|
|
return session;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe("Git commit body", () => {
|
|
test("commit dialog includes body textarea and commits with title + body", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
const profile = await createStandardProfile(apiClient, "Git Commit Body Profile");
|
|
|
|
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Commit Body Test", profile.id, {
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
|
|
const session = await openTaskSession(testPage, "Git Commit Body Test");
|
|
|
|
// Wait for agent to finish initial turn
|
|
await session.waitForChatIdle({ timeout: 30_000 });
|
|
|
|
// Set up git helper
|
|
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
|
const gitEnv = {
|
|
...process.env,
|
|
HOME: backend.tmpDir,
|
|
GIT_AUTHOR_NAME: "E2E Test",
|
|
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
|
GIT_COMMITTER_NAME: "E2E Test",
|
|
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
|
};
|
|
const git = new GitHelper(repoDir, gitEnv);
|
|
|
|
// Open Changes tab first so WS subscription is active
|
|
await session.clickTab("Changes");
|
|
await expect(session.changes).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Create a file - it should appear as unstaged via git status polling
|
|
git.createFile("commit-body-test.txt", "test content for commit body");
|
|
await expect(session.changes.getByText("commit-body-test.txt")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
// Stage the file via the UI "Stage all" button
|
|
await session.changes.getByRole("button", { name: "Stage all", exact: true }).click();
|
|
|
|
// Wait for the "Commit" button to appear (indicates staged section is ready)
|
|
const commitButton = session.changes.getByRole("button", { name: "Commit", exact: true });
|
|
await expect(commitButton).toBeVisible({ timeout: 15_000 });
|
|
await commitButton.click();
|
|
|
|
// The dialog should open
|
|
const dialog = testPage.getByRole("dialog");
|
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Verify both title input and body textarea are visible
|
|
const titleInput = dialog.getByTestId("commit-title-input");
|
|
const bodyInput = dialog.getByTestId("commit-body-input");
|
|
await expect(titleInput).toBeVisible();
|
|
await expect(bodyInput).toBeVisible();
|
|
|
|
// Fill title and body
|
|
await titleInput.fill("feat: add commit body support");
|
|
await bodyInput.fill("Detailed description of the change");
|
|
|
|
// Click Commit in dialog
|
|
const dialogCommitBtn = dialog.getByRole("button", { name: "Commit", exact: true });
|
|
await expect(dialogCommitBtn).toBeEnabled();
|
|
await dialogCommitBtn.click();
|
|
|
|
// Dialog should close
|
|
await expect(dialog).not.toBeVisible({ timeout: 15_000 });
|
|
|
|
// Verify the commit message in the git repository includes both title and body
|
|
await expect
|
|
.poll(
|
|
() => {
|
|
try {
|
|
return git.getLastCommitMessage();
|
|
} catch {
|
|
return "";
|
|
}
|
|
},
|
|
{ timeout: 15_000, message: "Expected commit with title and body" },
|
|
)
|
|
.toContain("feat: add commit body support");
|
|
|
|
const fullMessage = git.getLastCommitMessage();
|
|
expect(fullMessage).toContain("feat: add commit body support");
|
|
expect(fullMessage).toContain("Detailed description of the change");
|
|
|
|
// Verify title and body are separated by a blank line (git convention)
|
|
const lines = fullMessage.split("\n");
|
|
expect(lines[0]).toBe("feat: add commit body support");
|
|
expect(lines[1]).toBe("");
|
|
expect(lines[2]).toBe("Detailed description of the change");
|
|
});
|
|
});
|
|
|
|
test.describe("Git commit pre-hooks", () => {
|
|
test("failed commit shows error in chat with Fix button", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
const profile = await createStandardProfile(apiClient, "Git Hook Test Profile");
|
|
|
|
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Hook Test", profile.id, {
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
|
|
const session = await openTaskSession(testPage, "Git Hook Test");
|
|
|
|
// Wait for agent to finish initial turn
|
|
await session.waitForChatIdle({ timeout: 30_000 });
|
|
|
|
// Set up git helper
|
|
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
|
const gitEnv = {
|
|
...process.env,
|
|
HOME: backend.tmpDir,
|
|
GIT_AUTHOR_NAME: "E2E Test",
|
|
GIT_AUTHOR_EMAIL: "e2e@test.local",
|
|
GIT_COMMITTER_NAME: "E2E Test",
|
|
GIT_COMMITTER_EMAIL: "e2e@test.local",
|
|
};
|
|
const git = new GitHelper(repoDir, gitEnv);
|
|
|
|
try {
|
|
// Install a pre-commit hook that always fails
|
|
git.installFailingPreCommitHook("lint check failed: missing semicolons");
|
|
|
|
// Open Changes tab first so WS subscription is active
|
|
await session.clickTab("Changes");
|
|
await expect(session.changes).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Create a file - it should appear as unstaged via git status polling
|
|
git.createFile("hook-test.txt", "test content");
|
|
await expect(session.changes.getByText("hook-test.txt")).toBeVisible({ timeout: 30_000 });
|
|
|
|
// Stage the file via the UI "Stage all" button
|
|
await session.changes.getByRole("button", { name: "Stage all", exact: true }).click();
|
|
|
|
// Wait for the "Commit" button to appear (indicates staged section is ready)
|
|
// Use exact match to avoid matching the "Commits" section toggle
|
|
const commitButton = session.changes.getByRole("button", { name: "Commit", exact: true });
|
|
await expect(commitButton).toBeVisible({ timeout: 15_000 });
|
|
await commitButton.click();
|
|
|
|
// Fill commit message and submit
|
|
const dialog = testPage.getByRole("dialog");
|
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
|
await dialog.getByTestId("commit-title-input").fill("test commit message");
|
|
const dialogCommitBtn = dialog.getByRole("button", { name: "Commit", exact: true });
|
|
await expect(dialogCommitBtn).toBeEnabled();
|
|
await dialogCommitBtn.click();
|
|
|
|
// The error message should appear in the chat
|
|
const errorMessage = session.gitOperationErrorMessage();
|
|
await expect(errorMessage).toBeVisible({ timeout: 30_000 });
|
|
await expect(errorMessage.getByText("Git commit failed", { exact: true })).toBeVisible();
|
|
await expect(session.gitFixButton()).toBeVisible();
|
|
|
|
// The concise summary and Fix action remain available while command
|
|
// output stays collapsed until the user asks to inspect it.
|
|
const hookOutput = errorMessage.getByText("lint check failed: missing semicolons");
|
|
await expect(hookOutput).toBeHidden();
|
|
await errorMessage.locator("summary", { hasText: "Technical details" }).click();
|
|
await expect(hookOutput).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Click the Fix button
|
|
await session.gitFixButton().click();
|
|
|
|
// The fix prompt should appear in the chat as a user message
|
|
await expect(
|
|
session.chat.getByText("git commit command failed", { exact: false }).first(),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
|
|
// The agent should process the fix prompt and respond
|
|
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
|
|
|
// Verify the agent responded (mock agent echoes back part of the prompt)
|
|
await expect(session.chat.getByText("completed the analysis", { exact: false })).toBeVisible({
|
|
timeout: 5_000,
|
|
});
|
|
} finally {
|
|
git.removePreCommitHook();
|
|
}
|
|
});
|
|
});
|