Files
kandev/apps/web/e2e/tests/command-panel.spec.ts
T
2026-09-03 08:08:12 +00:00

547 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from "../fixtures/test-base";
import { KanbanPage } from "../pages/kanban-page";
import { SessionPage } from "../pages/session-page";
import { waitForSessionDone } from "../helpers/session";
import { createSubmoduleReviewFixture } from "./review/submodule-review-helpers";
type CommandAliasExpectation = {
query: string;
label: string;
};
const HOME_COMMAND_ALIASES: CommandAliasExpectation[] = [
{ query: "general settings", label: "Go to Settings" },
{ query: "statistics", label: "Go to Stats" },
{ query: "issues", label: "Go to GitHub Dashboard" },
{ query: "pull request", label: "Go to GitHub Dashboard" },
{ query: "agent profiles", label: "Agents" },
{ query: "claude", label: "Agents" },
{ query: "execution environment", label: "Executors" },
{ query: "custom prompts", label: "Prompts" },
{ query: "prompt templates", label: "Prompts" },
{ query: "color theme", label: "Switch to Dark Mode" },
{ query: "new quick chat", label: "Quick Chat" },
{ query: "configure kandev", label: "Configuration Chat" },
{ query: "mcp configuration", label: "Configuration Chat" },
{ query: "recent tasks", label: "Open Recent Task Switcher" },
];
const SESSION_COMMAND_ALIASES: CommandAliasExpectation[] = [
{ query: "pull request", label: "Create PR" },
{ query: "push to remote", label: "Push" },
{ query: "upload changes", label: "Push" },
{ query: "download changes", label: "Pull" },
{ query: "start agent", label: "New Agent" },
{ query: "child task", label: "Create Subtask" },
{ query: "open browser preview", label: "Add Browser Panel" },
{ query: "command line", label: "Add Terminal Panel" },
{ query: "implementation plan", label: "Add Plan Panel" },
{ query: "source control", label: "Add Changes Panel" },
];
/**
* Helper: open the command panel via Cmd/Ctrl+K.
*/
async function openCommandPanel(page: import("@playwright/test").Page) {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+k`);
}
/**
* Helper: open the file search panel via Cmd/Ctrl+Shift+K.
*/
async function openFileSearch(page: import("@playwright/test").Page) {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Shift+k`);
}
/** The command dialog (Radix Dialog with role="dialog"). */
function commandDialog(page: import("@playwright/test").Page) {
return page.getByRole("dialog");
}
function commandOption(page: import("@playwright/test").Page, label: string) {
return commandDialog(page)
.getByRole("option")
.filter({ has: page.getByText(label, { exact: true }) });
}
async function expectCommandAliases(
page: import("@playwright/test").Page,
aliases: CommandAliasExpectation[],
) {
const dialog = commandDialog(page);
const input = dialog.getByRole("combobox");
for (const { query, label } of aliases) {
await input.fill(query);
await expect(commandOption(page, label)).toBeVisible();
}
}
test.describe("Command Panel", () => {
test("Cmd+K opens command panel and shows commands (not files)", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Should show command groups like Navigation, Settings
await expect(dialog.getByText("Navigation")).toBeVisible();
await expect(dialog.getByText("Go to Home")).toBeVisible();
// Type something that matches a navigation command
await dialog.locator("input").fill("Settings");
// Should find settings commands
await expect(dialog.getByText("Go to Settings")).toBeVisible({ timeout: 5_000 });
// Should NOT show a "Files" group — file search is now separate
await expect(dialog.getByText("Files").first()).not.toBeVisible({ timeout: 2_000 });
});
test("common aliases find home commands", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expectCommandAliases(testPage, HOME_COMMAND_ALIASES);
await dialog.getByRole("combobox").fill("Environments Settings");
await expect(commandOption(testPage, "Environments Settings")).toHaveCount(0);
});
test("settings discovery stays quiet until typing and lands on an exact control", async ({
testPage,
}) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(commandOption(testPage, "Terminal Font Size")).toHaveCount(0);
const input = dialog.getByRole("combobox");
await input.fill("terminal font size");
await expect(dialog.locator("[cmdk-group-heading]")).toHaveText(["Settings"]);
const option = commandOption(testPage, "Terminal Font Size");
await expect(option).toBeVisible();
await expect(option.getByText("Settings › Terminal & Editors", { exact: true })).toBeVisible();
await expect(option).toHaveAttribute("data-selected", "true");
await input.press("Enter");
await expect(dialog).not.toBeVisible();
await expect(testPage).toHaveURL(
/\/settings\/preferences\/terminal-editors#setting-terminal-font-size$/,
);
const target = testPage.locator('[data-settings-target="setting-terminal-font-size"]');
await expect(target).toHaveAttribute("data-settings-target-highlight", "true");
await expect(testPage.getByTestId("terminal-font-size-input")).toBeFocused();
});
test("common aliases find worktree session commands", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Command Alias E2E",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
if (!task.session_id) throw new Error("Command alias task did not start a session");
await waitForSessionDone(
apiClient,
task.id,
task.session_id,
"command alias task session did not become ready",
);
await expect
.poll(async () => (await apiClient.getTaskEnvironment(task.id))?.status, {
timeout: 30_000,
message: "command alias task environment did not become ready",
})
.toBe("ready");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.showSessionContext();
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
const input = dialog.getByRole("combobox");
await input.fill("plan");
await expect(dialog.getByRole("option").filter({ hasText: "Plan" })).toHaveCount(1);
await expect(commandOption(testPage, "Add Plan Panel")).toBeVisible();
await input.fill("pull request");
await expect(commandOption(testPage, "Create PR")).toHaveAttribute("data-selected", "true");
await input.fill("pr");
await expect(commandOption(testPage, "Create PR")).toHaveAttribute("data-selected", "true");
await expectCommandAliases(testPage, SESSION_COMMAND_ALIASES);
await input.fill("pr");
await expect(commandOption(testPage, "Create PR")).toHaveAttribute("data-selected", "true");
await input.press("Enter");
await expect(testPage.getByRole("heading", { name: /Create Pull Request/ })).toBeVisible();
});
test("Cmd+Shift+K opens file search inside a task workbench", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"File Search Shortcut E2E",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await testPage.goto(`/t/${task.id}`);
await new SessionPage(testPage).waitForLoad();
await openFileSearch(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByRole("tab", { name: "Files" })).toHaveAttribute(
"aria-selected",
"true",
);
// Should show empty state for file search
await expect(dialog.getByText("Type to search files...")).toBeVisible();
});
test("Cmd+Shift+K finds root and submodule files", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(180_000);
const fixture = await createSubmoduleReviewFixture(
apiClient,
seedData,
backend.tmpDir,
"Submodule file search E2E",
);
try {
await testPage.goto(`/t/${fixture.taskId}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
await fixture.waitForWorktree(apiClient);
await openFileSearch(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.getByRole("combobox").fill("README.md");
const groups = dialog.getByTestId("file-search-repo-group");
await expect(groups).toHaveCount(3, { timeout: 45_000 });
await expect(
dialog.locator('[data-testid="file-search-repo-group"][data-repository=""]'),
).toBeVisible();
await expect(
dialog.locator('[data-testid="file-search-repo-group"][data-repository="vendor/outer"]'),
).toBeVisible();
await expect(
dialog.locator(
'[data-testid="file-search-repo-group"][data-repository="vendor/outer/vendor/inner"]',
),
).toBeVisible();
} finally {
fixture.cleanup();
}
});
test("Cmd+K inline task search shows matching tasks", async ({
testPage,
apiClient,
seedData,
}) => {
// Create a task to search for
await apiClient.createTask(seedData.workspaceId, "Searchable E2E Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Wait for the task to appear on the kanban board first
await expect(kanban.taskCardByTitle("Searchable E2E Task")).toBeVisible({ timeout: 10_000 });
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Type the task name — task search requires ≥2 characters
await dialog.locator("input").fill("Searchable");
// Should show the task in a "Tasks" group (inline search, debounced).
// Scope the heading lookup: "Tasks" is also the label of the Tasks tab.
await expect(dialog.getByTestId("command-panel-task-preview")).toBeVisible({
timeout: 10_000,
});
await expect(dialog.getByText("Searchable E2E Task")).toBeVisible({ timeout: 5_000 });
});
// @covers AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.1, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.2,
// AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.3, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.5,
// AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.6, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.7
test("archived task results show archive cues and follow active results", async ({
testPage,
apiClient,
seedData,
}) => {
const unarchivedCompleted = await apiClient.createTask(
seedData.workspaceId,
"Command panel unarchived completed",
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
},
);
await apiClient.updateTaskState(unarchivedCompleted.id, "COMPLETED");
const archivedInProgress = await apiClient.createTask(
seedData.workspaceId,
"Command panel archived in progress",
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
},
);
await apiClient.archiveTask(archivedInProgress.id);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.getByRole("combobox").fill("Command panel");
const taskResults = dialog.getByTestId("command-panel-task-preview");
await expect(taskResults).toBeVisible({ timeout: 10_000 });
const resultOptions = taskResults.getByRole("option");
await expect(resultOptions).toHaveCount(2);
await expect(resultOptions.nth(0)).toContainText(unarchivedCompleted.title);
await expect(resultOptions.nth(1)).toContainText(archivedInProgress.title);
const archivedOption = resultOptions.nth(1);
const startStep = seedData.steps.find((step) => step.id === seedData.startStepId)!;
await expect(archivedOption.getByTestId("task-state-archived")).toBeVisible();
await expect(archivedOption.getByRole("img", { name: "Archived" })).toBeVisible();
await expect(archivedOption.getByText("Archived", { exact: true })).toBeVisible();
await expect(archivedOption.getByText(startStep.name, { exact: true })).toHaveCount(0);
await expect(resultOptions.nth(0).getByTestId("task-state-archived")).toHaveCount(0);
await expect(resultOptions.nth(0).getByText("Archived", { exact: true })).toHaveCount(0);
await archivedOption.click();
await expect(testPage).toHaveURL(new RegExp(`/t/${archivedInProgress.id}$`));
});
test("task activity icon distinguishes running and idle search results", async ({
testPage,
apiClient,
seedData,
}) => {
const runningTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Running Activity Icon E2E",
seedData.agentProfileId,
{
description: "/e2e:steer-fold-setup",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await apiClient.createTask(seedData.workspaceId, "Idle Activity Icon E2E", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardByTitle(runningTask.title)).toBeVisible({ timeout: 10_000 });
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
const input = dialog.getByRole("combobox");
await input.fill("Running Activity Icon");
const runningOption = dialog.getByRole("option").filter({ hasText: runningTask.title });
await expect(runningOption).toBeVisible({ timeout: 10_000 });
await expect(runningOption.getByTestId("task-state-running")).toBeVisible();
await expect(runningOption.getByRole("img", { name: "In progress" })).toBeVisible();
await input.fill("Idle Activity Icon");
const idleOption = dialog.getByRole("option").filter({ hasText: "Idle Activity Icon E2E" });
await expect(idleOption).toBeVisible({ timeout: 10_000 });
await expect(idleOption.getByTestId("task-state-backlog")).toBeVisible();
await expect(idleOption.getByRole("img", { name: "Created" })).toBeVisible();
});
// @covers AC-UI-COMMAND-PANEL-TASK-ACTIVITY-001.8
test("inline task search shows workflow step badge", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, "Badged Task E2E", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardByTitle("Badged Task E2E")).toBeVisible({ timeout: 10_000 });
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.locator("input").fill("Badged Task");
// The task should appear with a workflow step badge
const taskRow = dialog.getByText("Badged Task E2E");
await expect(taskRow).toBeVisible({ timeout: 10_000 });
// The step badge should be present in the task result row
const startStep = seedData.steps.find((s) => s.id === seedData.startStepId)!;
const taskOption = dialog.getByRole("option", { name: /Badged Task E2E/ });
await expect(taskOption).toBeVisible({ timeout: 5_000 });
await expect(taskOption.getByText(startStep.name)).toBeVisible({ timeout: 5_000 });
const reviewStep = seedData.steps.find((s) => s.name === "Review")!;
await apiClient.moveTask(task.id, seedData.workflowId, reviewStep.id);
await expect(taskOption.getByText(reviewStep.name)).toBeVisible({ timeout: 10_000 });
await expect(taskOption.getByText(startStep.name)).not.toBeVisible({ timeout: 5_000 });
});
test("inline task search selects the first matching task after loading", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.createTask(seedData.workspaceId, "Palette Selection Alpha", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.createTask(seedData.workspaceId, "Palette Selection Beta", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(kanban.taskCardByTitle("Palette Selection Alpha")).toBeVisible({
timeout: 10_000,
});
await expect(kanban.taskCardByTitle("Palette Selection Beta")).toBeVisible({
timeout: 10_000,
});
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
const input = dialog.getByRole("combobox");
await input.fill("Palette Selection");
const taskOptions = dialog.getByRole("option").filter({ hasText: /Palette Selection/ });
await expect(taskOptions.first()).toBeVisible({ timeout: 10_000 });
await expect(taskOptions.first()).toHaveAttribute("data-selected", "true", {
timeout: 5_000,
});
});
test("Escape closes the command panel", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
await testPage.keyboard.press("Escape");
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
});
test("Backspace keeps the empty file-search scope active", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"File Search Backspace E2E",
seedData.agentProfileId,
{
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
await testPage.goto(`/t/${task.id}`);
await new SessionPage(testPage).waitForLoad();
// Open file search mode
await openFileSearch(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByText("Type to search files...")).toBeVisible();
// Files is a peer scope, not a nested command mode, so an empty Backspace
// should remain in place instead of acting as hidden "back" navigation.
const input = dialog.getByRole("combobox");
await input.focus();
await input.press("Backspace");
await expect(dialog.getByRole("tab", { name: "Files" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(dialog.getByText("Type to search files...")).toBeVisible();
await expect(dialog.getByText("Navigation")).toHaveCount(0);
});
test("Cmd+K toggles the panel open and closed", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Open
await openCommandPanel(testPage);
const dialog = commandDialog(testPage);
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Close by pressing Cmd+K again
await openCommandPanel(testPage);
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
});
});