d35e601d42
Adds a Docker e2e test that exercises the user-visible stop-then-resume flow: launch session, stop it via WS session.stop, launch a fresh session on the same task, and assert the same task environment + container ID are reused and the kandev feature branch is preserved. Also nudge the executor popover padding: when the prepare-status section is rendered above the Sprites/Docker info block, the cumulative top+bottom paddings looked too generous around "Sprites sandbox" / "Local Docker". Tighten both sections vertically without affecting the horizontal rhythm or the bottom action row.
1083 lines
32 KiB
TypeScript
1083 lines
32 KiB
TypeScript
import type {
|
|
Workspace,
|
|
Workflow,
|
|
CreateTaskResponse,
|
|
ListWorkflowsResponse,
|
|
ListWorkflowStepsResponse,
|
|
} from "../../lib/types/http";
|
|
import type { Agent, AgentProfile } from "../../lib/types/http-agents";
|
|
|
|
// --- GitHub Mock Types ---
|
|
|
|
export type MockPR = {
|
|
number: number;
|
|
title: string;
|
|
state: string;
|
|
head_branch: string;
|
|
head_sha?: string;
|
|
base_branch: string;
|
|
author_login: string;
|
|
repo_owner: string;
|
|
repo_name: string;
|
|
html_url?: string;
|
|
url?: string;
|
|
body?: string;
|
|
draft?: boolean;
|
|
mergeable?: boolean;
|
|
mergeable_state?: string;
|
|
additions?: number;
|
|
deletions?: number;
|
|
merged_at?: string;
|
|
requested_reviewers?: Array<{ login: string; type: string }>;
|
|
};
|
|
|
|
export type MockOrg = {
|
|
login: string;
|
|
avatar_url?: string;
|
|
};
|
|
|
|
export type MockRepo = {
|
|
full_name: string;
|
|
owner: string;
|
|
name: string;
|
|
private?: boolean;
|
|
};
|
|
|
|
export type MockReview = {
|
|
id: number;
|
|
author: string;
|
|
author_avatar?: string;
|
|
state: string;
|
|
body?: string;
|
|
created_at?: string;
|
|
};
|
|
|
|
export type MockCheckRun = {
|
|
name: string;
|
|
source?: string;
|
|
status: string;
|
|
conclusion?: string;
|
|
html_url?: string;
|
|
started_at?: string;
|
|
completed_at?: string;
|
|
};
|
|
|
|
function setIf(body: Record<string, unknown>, key: string, value: unknown) {
|
|
if (value !== undefined && value !== null) body[key] = value;
|
|
}
|
|
|
|
type CreateTaskOpts = {
|
|
description?: string;
|
|
workflow_id?: string;
|
|
workflow_step_id?: string;
|
|
agent_profile_id?: string;
|
|
repository_ids?: string[];
|
|
repositories?: Array<{ repository_id: string; base_branch?: string; checkout_branch?: string }>;
|
|
plan_mode?: boolean;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
function buildTaskMetadata(opts: CreateTaskOpts): Record<string, unknown> | undefined {
|
|
const meta: Record<string, unknown> = { ...(opts.metadata ?? {}) };
|
|
if (opts.agent_profile_id && meta.agent_profile_id == null) {
|
|
meta.agent_profile_id = opts.agent_profile_id;
|
|
}
|
|
return Object.keys(meta).length > 0 ? meta : undefined;
|
|
}
|
|
|
|
function buildCreateTaskBody(
|
|
workspaceId: string,
|
|
title: string,
|
|
opts?: CreateTaskOpts,
|
|
): Record<string, unknown> {
|
|
const body: Record<string, unknown> = {
|
|
workspace_id: workspaceId,
|
|
title,
|
|
description: opts?.description ?? "",
|
|
};
|
|
setIf(body, "workflow_id", opts?.workflow_id);
|
|
setIf(body, "workflow_step_id", opts?.workflow_step_id);
|
|
setIf(body, "metadata", opts ? buildTaskMetadata(opts) : undefined);
|
|
setIf(
|
|
body,
|
|
"repositories",
|
|
opts?.repositories ?? opts?.repository_ids?.map((id) => ({ repository_id: id })),
|
|
);
|
|
if (opts?.plan_mode) body.plan_mode = true;
|
|
setIf(body, "parent_id", opts?.parent_id);
|
|
return body;
|
|
}
|
|
|
|
/** Build the optional fields object for createTaskWithAgent requests. */
|
|
function buildOptionalAgentTaskFields(opts?: {
|
|
workflow_id?: string;
|
|
workflow_step_id?: string;
|
|
repository_ids?: string[];
|
|
executor_id?: string;
|
|
executor_profile_id?: string;
|
|
metadata?: Record<string, unknown>;
|
|
parent_id?: string;
|
|
}): Record<string, unknown> {
|
|
const fields: Record<string, unknown> = {};
|
|
if (opts?.workflow_id) fields.workflow_id = opts.workflow_id;
|
|
if (opts?.workflow_step_id) fields.workflow_step_id = opts.workflow_step_id;
|
|
if (opts?.repository_ids)
|
|
fields.repositories = opts.repository_ids.map((id) => ({ repository_id: id }));
|
|
if (opts?.executor_id) fields.executor_id = opts.executor_id;
|
|
if (opts?.executor_profile_id) fields.executor_profile_id = opts.executor_profile_id;
|
|
if (opts?.metadata) fields.metadata = opts.metadata;
|
|
if (opts?.parent_id) fields.parent_id = opts.parent_id;
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* HTTP API client for seeding test data via the backend REST API.
|
|
*/
|
|
export class ApiClient {
|
|
constructor(private baseUrl: string) {}
|
|
|
|
/** Perform an HTTP request and return the raw Response (does not throw on non-2xx). */
|
|
async rawRequest(method: string, path: string, body?: unknown): Promise<Response> {
|
|
return fetch(`${this.baseUrl}${path}`, {
|
|
method,
|
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
method,
|
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`API ${method} ${path} failed (${res.status}): ${text}`);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
async healthCheck(): Promise<void> {
|
|
await this.request("GET", "/health");
|
|
}
|
|
|
|
async createWorkspace(name: string): Promise<Workspace> {
|
|
return this.request("POST", "/api/v1/workspaces", { name });
|
|
}
|
|
|
|
async createWorkflow(workspaceId: string, name: string, templateId?: string): Promise<Workflow> {
|
|
return this.request("POST", "/api/v1/workflows", {
|
|
workspace_id: workspaceId,
|
|
name,
|
|
...(templateId ? { workflow_template_id: templateId } : {}),
|
|
});
|
|
}
|
|
|
|
async reorderWorkflows(
|
|
workspaceId: string,
|
|
workflowIds: string[],
|
|
): Promise<{ success: boolean }> {
|
|
return this.request("PUT", `/api/v1/workspaces/${workspaceId}/workflows/reorder`, {
|
|
workflow_ids: workflowIds,
|
|
});
|
|
}
|
|
|
|
async createTask(
|
|
workspaceId: string,
|
|
title: string,
|
|
opts?: {
|
|
description?: string;
|
|
workflow_id?: string;
|
|
workflow_step_id?: string;
|
|
/** Stored in task.Metadata so auto_start_agent can pick it up on on_enter. */
|
|
agent_profile_id?: string;
|
|
/** Repository IDs to associate with the task (required for agent execution). */
|
|
repository_ids?: string[];
|
|
/** Full repository entries with optional checkout_branch / base_branch. */
|
|
repositories?: Array<{
|
|
repository_id: string;
|
|
base_branch?: string;
|
|
checkout_branch?: string;
|
|
}>;
|
|
/** When true, task is placed at position 0 regardless of is_start_step. */
|
|
plan_mode?: boolean;
|
|
/** Extra metadata to store on the task. */
|
|
metadata?: Record<string, unknown>;
|
|
/** Parent task ID for subtasks. */
|
|
parent_id?: string;
|
|
},
|
|
): Promise<CreateTaskResponse> {
|
|
return this.request("POST", "/api/v1/tasks", buildCreateTaskBody(workspaceId, title, opts));
|
|
}
|
|
|
|
async updateTaskState(
|
|
taskId: string,
|
|
state: "BACKLOG" | "IN_PROGRESS" | "REVIEW" | "COMPLETED",
|
|
): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/tasks/${taskId}`, { state });
|
|
}
|
|
|
|
/** Rename a task. Used in tests that exercise live-title resolution on the
|
|
* cross-task message badge. */
|
|
async updateTaskTitle(taskId: string, title: string): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/tasks/${taskId}`, { title });
|
|
}
|
|
|
|
async listAgents(): Promise<{ agents: Agent[]; total: number }> {
|
|
return this.request("GET", "/api/v1/agents");
|
|
}
|
|
|
|
async deleteAgentProfile(profileId: string, force?: boolean): Promise<void> {
|
|
const qs = force ? "?force=true" : "";
|
|
await this.request("DELETE", `/api/v1/agent-profiles/${profileId}${qs}`);
|
|
}
|
|
|
|
/** Delete all agent profiles except the ones in keepIds. */
|
|
async cleanupTestProfiles(keepIds: string[]): Promise<void> {
|
|
const { agents } = await this.listAgents();
|
|
for (const agent of agents) {
|
|
for (const profile of agent.profiles ?? []) {
|
|
if (!keepIds.includes(profile.id)) {
|
|
await this.deleteAgentProfile(profile.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async createAgentProfile(
|
|
agentId: string,
|
|
name: string,
|
|
opts: {
|
|
model: string;
|
|
mode?: string;
|
|
cli_passthrough?: boolean;
|
|
cli_flags?: Array<{ description: string; flag: string; enabled: boolean }>;
|
|
},
|
|
): Promise<{
|
|
id: string;
|
|
cli_flags: Array<{ description: string; flag: string; enabled: boolean }>;
|
|
}> {
|
|
return this.request("POST", `/api/v1/agents/${agentId}/profiles`, {
|
|
name,
|
|
model: opts.model,
|
|
mode: opts.mode,
|
|
cli_passthrough: opts.cli_passthrough ?? false,
|
|
cli_flags: opts.cli_flags,
|
|
});
|
|
}
|
|
|
|
async getAgentProfile(profileId: string): Promise<AgentProfile> {
|
|
// The profile does not have its own GET endpoint; fetch via listAgents
|
|
// and find the matching row. Keeps the helper surface small.
|
|
const { agents } = await this.listAgents();
|
|
for (const agent of agents) {
|
|
for (const profile of agent.profiles ?? []) {
|
|
if (profile.id === profileId) {
|
|
return profile;
|
|
}
|
|
}
|
|
}
|
|
throw new Error(`profile ${profileId} not found`);
|
|
}
|
|
|
|
async updateAgentProfile(
|
|
profileId: string,
|
|
patch: {
|
|
name?: string;
|
|
model?: string;
|
|
mode?: string;
|
|
cli_passthrough?: boolean;
|
|
cli_flags?: Array<{ description: string; flag: string; enabled: boolean }>;
|
|
},
|
|
): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/agent-profiles/${profileId}`, patch);
|
|
}
|
|
|
|
async createTaskWithAgent(
|
|
workspaceId: string,
|
|
title: string,
|
|
agentProfileId: string,
|
|
opts?: {
|
|
description?: string;
|
|
workflow_id?: string;
|
|
workflow_step_id?: string;
|
|
repository_ids?: string[];
|
|
executor_id?: string;
|
|
executor_profile_id?: string;
|
|
metadata?: Record<string, unknown>;
|
|
/** Parent task ID for subtasks. */
|
|
parent_id?: string;
|
|
},
|
|
): Promise<CreateTaskResponse> {
|
|
return this.request("POST", "/api/v1/tasks", {
|
|
workspace_id: workspaceId,
|
|
title,
|
|
description: opts?.description ?? "",
|
|
start_agent: true,
|
|
agent_profile_id: agentProfileId,
|
|
...buildOptionalAgentTaskFields(opts),
|
|
});
|
|
}
|
|
|
|
/** Start a config chat session via the dedicated config-chat endpoint. */
|
|
async startConfigChat(
|
|
workspaceId: string,
|
|
agentProfileId: string,
|
|
prompt: string,
|
|
): Promise<{ task_id: string; session_id: string }> {
|
|
return this.request("POST", `/api/v1/workspaces/${workspaceId}/config-chat`, {
|
|
agent_profile_id: agentProfileId,
|
|
prompt,
|
|
});
|
|
}
|
|
|
|
async listWorkflows(workspaceId: string): Promise<ListWorkflowsResponse> {
|
|
return this.request("GET", `/api/v1/workspaces/${workspaceId}/workflows`);
|
|
}
|
|
|
|
async listWorkflowSteps(workflowId: string): Promise<ListWorkflowStepsResponse> {
|
|
return this.request("GET", `/api/v1/workflows/${workflowId}/workflow/steps`);
|
|
}
|
|
|
|
async createWorkflowStep(
|
|
workflowId: string,
|
|
name: string,
|
|
position: number,
|
|
opts?: { is_start_step?: boolean },
|
|
): Promise<{ id: string }> {
|
|
return this.request("POST", `/api/v1/workflow/steps`, {
|
|
workflow_id: workflowId,
|
|
name,
|
|
position,
|
|
...(opts?.is_start_step != null ? { is_start_step: opts.is_start_step } : {}),
|
|
});
|
|
}
|
|
|
|
async createRepository(
|
|
workspaceId: string,
|
|
localPath: string,
|
|
defaultBranch = "main",
|
|
opts?: {
|
|
name?: string;
|
|
provider?: string;
|
|
provider_owner?: string;
|
|
provider_name?: string;
|
|
},
|
|
): Promise<{ id: string }> {
|
|
return this.request("POST", `/api/v1/workspaces/${workspaceId}/repositories`, {
|
|
name: opts?.name ?? "E2E Repo",
|
|
source_type: "local",
|
|
local_path: localPath,
|
|
default_branch: defaultBranch,
|
|
...(opts?.provider ? { provider: opts.provider } : {}),
|
|
...(opts?.provider_owner ? { provider_owner: opts.provider_owner } : {}),
|
|
...(opts?.provider_name ? { provider_name: opts.provider_name } : {}),
|
|
});
|
|
}
|
|
|
|
async updateRepository(
|
|
repositoryId: string,
|
|
updates: { dev_script?: string; setup_script?: string; cleanup_script?: string },
|
|
): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/repositories/${repositoryId}`, updates);
|
|
}
|
|
|
|
async createRepositoryScript(
|
|
repositoryId: string,
|
|
name: string,
|
|
command: string,
|
|
position = 0,
|
|
): Promise<{
|
|
id: string;
|
|
repository_id: string;
|
|
name: string;
|
|
command: string;
|
|
position: number;
|
|
}> {
|
|
return this.request("POST", `/api/v1/repositories/${repositoryId}/scripts`, {
|
|
name,
|
|
command,
|
|
position,
|
|
});
|
|
}
|
|
|
|
async createExecutor(
|
|
name: string,
|
|
type: string,
|
|
): Promise<{ id: string; name: string; type: string }> {
|
|
return this.request("POST", "/api/v1/executors", { name, type });
|
|
}
|
|
|
|
async updateWorkspace(
|
|
workspaceId: string,
|
|
updates: {
|
|
default_executor_id?: string;
|
|
default_agent_profile_id?: string;
|
|
default_config_agent_profile_id?: string;
|
|
},
|
|
): Promise<void> {
|
|
await this.request("PATCH", `/api/v1/workspaces/${workspaceId}`, updates);
|
|
}
|
|
|
|
async deleteExecutor(executorId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/executors/${executorId}`);
|
|
}
|
|
|
|
async createExecutorProfile(
|
|
executorId: string,
|
|
nameOrPayload:
|
|
| string
|
|
| {
|
|
name: string;
|
|
mcp_policy?: string;
|
|
prepare_script?: string;
|
|
cleanup_script?: string;
|
|
config?: Record<string, string>;
|
|
env_vars?: Array<{ key: string; value?: string; secret_id?: string }>;
|
|
},
|
|
opts?: { mcp_policy?: string; prepare_script?: string; cleanup_script?: string },
|
|
): Promise<{ id: string; name: string }> {
|
|
if (typeof nameOrPayload === "object") {
|
|
return this.request("POST", `/api/v1/executors/${executorId}/profiles`, nameOrPayload);
|
|
}
|
|
return this.request("POST", `/api/v1/executors/${executorId}/profiles`, {
|
|
name: nameOrPayload,
|
|
...(opts?.mcp_policy ? { mcp_policy: opts.mcp_policy } : {}),
|
|
...(opts?.prepare_script ? { prepare_script: opts.prepare_script } : {}),
|
|
...(opts?.cleanup_script ? { cleanup_script: opts.cleanup_script } : {}),
|
|
});
|
|
}
|
|
|
|
async deleteExecutorProfile(profileId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/executor-profiles/${profileId}`);
|
|
}
|
|
|
|
async getExecutorProfile(
|
|
executorId: string,
|
|
profileId: string,
|
|
): Promise<{
|
|
id: string;
|
|
name: string;
|
|
config?: Record<string, string>;
|
|
prepare_script?: string;
|
|
cleanup_script?: string;
|
|
}> {
|
|
return this.request("GET", `/api/v1/executors/${executorId}/profiles/${profileId}`);
|
|
}
|
|
|
|
async listExecutors(): Promise<{
|
|
executors: Array<{
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
profiles?: Array<{ id: string; name: string }>;
|
|
}>;
|
|
}> {
|
|
return this.request("GET", "/api/v1/executors");
|
|
}
|
|
|
|
async getUserSettings(): Promise<{
|
|
settings: {
|
|
terminal_link_behavior?: string;
|
|
terminal_font_family?: string;
|
|
terminal_font_size?: number;
|
|
[key: string]: unknown;
|
|
};
|
|
}> {
|
|
return this.request("GET", "/api/v1/user/settings");
|
|
}
|
|
|
|
async saveUserSettings(settings: {
|
|
enable_preview_on_click?: boolean;
|
|
workspace_id?: string;
|
|
workflow_filter_id?: string;
|
|
repository_ids?: string[];
|
|
terminal_link_behavior?: "new_tab" | "browser_panel";
|
|
terminal_font_family?: string;
|
|
terminal_font_size?: number;
|
|
keyboard_shortcuts?: Record<string, unknown>;
|
|
default_utility_agent_id?: string;
|
|
default_utility_model?: string;
|
|
sidebar_views?: unknown[];
|
|
}): Promise<void> {
|
|
await this.request("PATCH", "/api/v1/user/settings", settings);
|
|
}
|
|
|
|
async moveTask(taskId: string, workflowId: string, workflowStepId: string): Promise<void> {
|
|
await this.request("POST", `/api/v1/tasks/${taskId}/move`, {
|
|
workflow_id: workflowId,
|
|
workflow_step_id: workflowStepId,
|
|
});
|
|
}
|
|
|
|
async updateWorkflow(
|
|
workflowId: string,
|
|
updates: { name?: string; description?: string; agent_profile_id?: string },
|
|
): Promise<Workflow> {
|
|
return this.request("PATCH", `/api/v1/workflows/${workflowId}`, updates);
|
|
}
|
|
|
|
async updateWorkflowStep(
|
|
stepId: string,
|
|
updates: {
|
|
prompt?: string;
|
|
agent_profile_id?: string;
|
|
events?: {
|
|
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
on_exit?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
};
|
|
},
|
|
): Promise<void> {
|
|
await this.request("PUT", `/api/v1/workflow/steps/${stepId}`, { id: stepId, ...updates });
|
|
}
|
|
|
|
async deleteWorkflow(workflowId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/workflows/${workflowId}`);
|
|
}
|
|
|
|
async deleteWorkflowStep(stepId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/workflow/steps/${stepId}`);
|
|
}
|
|
|
|
async listWorkflowTemplates(): Promise<{
|
|
templates: Array<{ id: string; name: string; default_steps?: Array<{ name: string }> }>;
|
|
}> {
|
|
return this.request("GET", "/api/v1/workflow/templates");
|
|
}
|
|
|
|
// --- Workflow Export/Import ---
|
|
|
|
async exportWorkflow(workflowId: string): Promise<string> {
|
|
const res = await this.rawRequest("GET", `/api/v1/workflows/${workflowId}/export`);
|
|
if (!res.ok) throw new Error(`Export failed (${res.status}): ${await res.text()}`);
|
|
return res.text();
|
|
}
|
|
|
|
async exportAllWorkflows(workspaceId: string): Promise<string> {
|
|
const res = await this.rawRequest("GET", `/api/v1/workspaces/${workspaceId}/workflows/export`);
|
|
if (!res.ok) throw new Error(`Export failed (${res.status}): ${await res.text()}`);
|
|
return res.text();
|
|
}
|
|
|
|
async importWorkflows(
|
|
workspaceId: string,
|
|
yamlContent: string,
|
|
): Promise<{ created: string[]; skipped: string[] }> {
|
|
const res = await fetch(`${this.baseUrl}/api/v1/workspaces/${workspaceId}/workflows/import`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-yaml" },
|
|
body: yamlContent,
|
|
});
|
|
if (!res.ok) throw new Error(`Import failed (${res.status}): ${await res.text()}`);
|
|
return res.json() as Promise<{ created: string[]; skipped: string[] }>;
|
|
}
|
|
|
|
async deleteTask(taskId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/tasks/${taskId}`);
|
|
}
|
|
|
|
async archiveTask(taskId: string): Promise<void> {
|
|
await this.request("POST", `/api/v1/tasks/${taskId}/archive`);
|
|
}
|
|
|
|
async getAgentProfileMcpConfig(
|
|
profileId: string,
|
|
): Promise<{ profile_id: string; enabled: boolean; servers: Record<string, unknown> }> {
|
|
return this.request("GET", `/api/v1/agent-profiles/${profileId}/mcp-config`);
|
|
}
|
|
|
|
// --- E2E Test Reset ---
|
|
|
|
async e2eReset(workspaceId: string, keepWorkflowIds?: string[]): Promise<void> {
|
|
const params = keepWorkflowIds?.length ? `?keep_workflows=${keepWorkflowIds.join(",")}` : "";
|
|
await this.request("DELETE", `/api/v1/e2e/reset/${workspaceId}${params}`);
|
|
}
|
|
|
|
// --- GitHub Mock Control ---
|
|
|
|
async mockGitHubReset(): Promise<void> {
|
|
await this.request("DELETE", "/api/v1/github/mock/reset");
|
|
}
|
|
|
|
async mockGitHubSetUser(username: string): Promise<void> {
|
|
await this.request("PUT", "/api/v1/github/mock/user", { username });
|
|
}
|
|
|
|
async mockGitHubAddPRs(prs: MockPR[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/prs", { prs });
|
|
}
|
|
|
|
async mockGitHubAddOrgs(orgs: MockOrg[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/orgs", { orgs });
|
|
}
|
|
|
|
async mockGitHubAddRepos(org: string, repos: MockRepo[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/repos", { org, repos });
|
|
}
|
|
|
|
async mockGitHubAddReviews(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
reviews: MockReview[],
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/reviews", {
|
|
owner,
|
|
repo,
|
|
number,
|
|
reviews,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAddCheckRuns(
|
|
owner: string,
|
|
repo: string,
|
|
ref: string,
|
|
checks: MockCheckRun[],
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/checks", {
|
|
owner,
|
|
repo,
|
|
ref,
|
|
checks,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAddPRFiles(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
files: Array<{
|
|
filename: string;
|
|
status: string;
|
|
additions: number;
|
|
deletions: number;
|
|
patch?: string;
|
|
}>,
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/files", {
|
|
owner,
|
|
repo,
|
|
number,
|
|
files,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAddPRCommits(
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
commits: Array<{
|
|
sha: string;
|
|
message: string;
|
|
author_login: string;
|
|
author_date: string;
|
|
}>,
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/commits", {
|
|
owner,
|
|
repo,
|
|
number,
|
|
commits,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAddBranches(
|
|
owner: string,
|
|
repo: string,
|
|
branches: Array<{ name: string }>,
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/branches", {
|
|
owner,
|
|
repo,
|
|
branches,
|
|
});
|
|
}
|
|
|
|
async mockGitHubAssociateTaskPR(data: {
|
|
task_id: string;
|
|
owner: string;
|
|
repo: string;
|
|
pr_number: number;
|
|
pr_url: string;
|
|
pr_title: string;
|
|
head_branch: string;
|
|
base_branch: string;
|
|
author_login: string;
|
|
state?: string;
|
|
review_state?: string;
|
|
checks_state?: string;
|
|
mergeable_state?: string;
|
|
additions?: number;
|
|
deletions?: number;
|
|
}): Promise<void> {
|
|
await this.request("POST", "/api/v1/github/mock/task-prs", data);
|
|
}
|
|
|
|
async mockGitHubGetStatus(): Promise<{
|
|
authenticated: boolean;
|
|
username: string;
|
|
auth_method: string;
|
|
}> {
|
|
return this.request("GET", "/api/v1/github/status");
|
|
}
|
|
|
|
// --- Session ---
|
|
|
|
async listSessionMessages(sessionId: string): Promise<{
|
|
messages: Array<{
|
|
id: string;
|
|
content: string;
|
|
author_type: string;
|
|
raw_content?: string;
|
|
metadata?: Record<string, unknown>;
|
|
}>;
|
|
}> {
|
|
return this.request("GET", `/api/v1/task-sessions/${sessionId}/messages`);
|
|
}
|
|
|
|
async listTasks(
|
|
workspaceId: string,
|
|
): Promise<{ tasks: Array<{ id: string; title: string; workflow_step_id?: string }> }> {
|
|
return this.request("GET", `/api/v1/workspaces/${workspaceId}/tasks`);
|
|
}
|
|
|
|
async listTaskSessions(taskId: string): Promise<{
|
|
sessions: Array<{
|
|
id: string;
|
|
task_id: string;
|
|
agent_profile_id?: string;
|
|
state: string;
|
|
started_at: string;
|
|
task_environment_id?: string;
|
|
worktree_path?: string;
|
|
worktree_branch?: string;
|
|
}>;
|
|
total: number;
|
|
}> {
|
|
return this.request("GET", `/api/v1/tasks/${taskId}/sessions`);
|
|
}
|
|
|
|
async setPrimarySession(sessionId: string): Promise<void> {
|
|
await this.request("POST", `/api/v1/task-sessions/${sessionId}/set-primary`);
|
|
}
|
|
|
|
async deleteSession(sessionId: string): Promise<void> {
|
|
await this.request("DELETE", `/api/v1/task-sessions/${sessionId}`);
|
|
}
|
|
|
|
async getTask(taskId: string): Promise<{
|
|
id: string;
|
|
title: string;
|
|
primary_session_id?: string | null;
|
|
state?: string;
|
|
}> {
|
|
return this.request("GET", `/api/v1/tasks/${taskId}`);
|
|
}
|
|
|
|
/**
|
|
* Send a one-shot WebSocket request and await the matching response.
|
|
* Used for actions exposed only over WS (e.g. session.launch). Opens a
|
|
* fresh connection, awaits one response by id, then closes.
|
|
*/
|
|
async wsRequest<T>(action: string, payload: unknown, timeoutMs = 30_000): Promise<T> {
|
|
const wsUrl = this.baseUrl.replace(/^http/, "ws") + "/ws";
|
|
const id = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
ws.close();
|
|
reject(new Error(`wsRequest("${action}") timed out after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
ws.addEventListener("open", () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
id,
|
|
type: "request",
|
|
action,
|
|
payload,
|
|
timestamp: new Date().toISOString(),
|
|
}),
|
|
);
|
|
});
|
|
ws.addEventListener("message", (event) => {
|
|
const msg = JSON.parse(String(event.data));
|
|
if (msg.id !== id) return;
|
|
clearTimeout(timer);
|
|
ws.close();
|
|
if (msg.type === "error") {
|
|
reject(new Error(`wsRequest("${action}") failed: ${msg.payload?.message ?? msg.action}`));
|
|
return;
|
|
}
|
|
resolve(msg.payload as T);
|
|
});
|
|
ws.addEventListener("error", (event) => {
|
|
clearTimeout(timer);
|
|
reject(new Error(`wsRequest("${action}") socket error: ${String(event)}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Launch a new session on a task — wraps the WS `session.launch` action.
|
|
* Used by tests that need a second session on a task with an existing
|
|
* environment (multi-session reuse, recovery scenarios).
|
|
*/
|
|
async launchSession(payload: {
|
|
task_id: string;
|
|
agent_profile_id: string;
|
|
executor_id?: string;
|
|
executor_profile_id?: string;
|
|
prompt: string;
|
|
intent?: string;
|
|
workflow_step_id?: string;
|
|
auto_start?: boolean;
|
|
}): Promise<{ session_id: string; agent_execution_id: string; state: string }> {
|
|
return this.wsRequest("session.launch", payload);
|
|
}
|
|
|
|
/** Stop a running session via WS `session.stop` — same path the UI uses. */
|
|
async stopSession(payload: {
|
|
session_id: string;
|
|
reason?: string;
|
|
force?: boolean;
|
|
}): Promise<{ success: boolean }> {
|
|
return this.wsRequest("session.stop", payload);
|
|
}
|
|
|
|
async getTaskEnvironment(taskId: string): Promise<{
|
|
id: string;
|
|
task_id: string;
|
|
executor_type?: string;
|
|
executor_profile_id?: string;
|
|
container_id?: string;
|
|
sandbox_id?: string;
|
|
worktree_id?: string;
|
|
worktree_path?: string;
|
|
status: string;
|
|
} | null> {
|
|
const res = await this.rawRequest("GET", `/api/v1/tasks/${taskId}/environment`);
|
|
if (res.status === 404) return null;
|
|
if (!res.ok) {
|
|
throw new Error(`getTaskEnvironment failed (${res.status}): ${await res.text()}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// --- GitHub Review Watch ---
|
|
|
|
async createReviewWatch(
|
|
workspaceId: string,
|
|
workflowId: string,
|
|
workflowStepId: string,
|
|
agentProfileId: string,
|
|
opts?: {
|
|
repos?: Array<{ owner: string; name: string }>;
|
|
prompt?: string;
|
|
review_scope?: string;
|
|
poll_interval_seconds?: number;
|
|
},
|
|
): Promise<{ id: string }> {
|
|
return this.request("POST", "/api/v1/github/watches/review", {
|
|
workspace_id: workspaceId,
|
|
workflow_id: workflowId,
|
|
workflow_step_id: workflowStepId,
|
|
agent_profile_id: agentProfileId,
|
|
repos: opts?.repos ?? [],
|
|
prompt: opts?.prompt ?? "",
|
|
review_scope: opts?.review_scope ?? "user_and_teams",
|
|
poll_interval_seconds: opts?.poll_interval_seconds ?? 300,
|
|
});
|
|
}
|
|
|
|
async triggerReviewWatch(watchId: string): Promise<{ new_prs: number; cleaned?: number }> {
|
|
return this.request("POST", `/api/v1/github/watches/review/${watchId}/trigger`, undefined);
|
|
}
|
|
|
|
// --- Integration config seeding (real API, not mock) ---
|
|
|
|
async setJiraConfig(payload: {
|
|
siteUrl: string;
|
|
email: string;
|
|
authMethod?: "api_token" | "session_cookie";
|
|
defaultProjectKey?: string;
|
|
secret: string;
|
|
}): Promise<unknown> {
|
|
return this.request("POST", "/api/v1/jira/config", {
|
|
...payload,
|
|
authMethod: payload.authMethod ?? "api_token",
|
|
});
|
|
}
|
|
|
|
async setLinearConfig(payload: { secret: string; defaultTeamKey?: string }): Promise<unknown> {
|
|
return this.request("POST", "/api/v1/linear/config", {
|
|
defaultTeamKey: "",
|
|
...payload,
|
|
authMethod: "api_key",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Poll until the integration config reports `lastOk: true`. SetConfig kicks
|
|
* off an async auth-health probe in a goroutine, so the row's `lastOk` flips
|
|
* shortly after — but with no synchronous signal. Tests that gate UI on
|
|
* `useJiraAvailable` / `useLinearAvailable` need to await this before
|
|
* navigating, otherwise the import bar can race the first render.
|
|
*/
|
|
async waitForIntegrationAuthHealthy(
|
|
integration: "jira" | "linear",
|
|
timeoutMs = 5_000,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const res = await this.rawRequest("GET", `/api/v1/${integration}/config`);
|
|
if (res.ok && res.status === 200) {
|
|
const cfg = (await res.json()) as { hasSecret?: boolean; lastOk?: boolean };
|
|
if (cfg.hasSecret && cfg.lastOk) return;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
throw new Error(`${integration} config never reported lastOk: true within ${timeoutMs}ms`);
|
|
}
|
|
|
|
// --- Jira Mock Control ---
|
|
|
|
async mockJiraReset(): Promise<void> {
|
|
await this.request("DELETE", "/api/v1/jira/mock/reset");
|
|
}
|
|
|
|
async mockJiraSetAuthResult(result: {
|
|
ok: boolean;
|
|
accountId?: string;
|
|
displayName?: string;
|
|
email?: string;
|
|
error?: string;
|
|
}): Promise<void> {
|
|
await this.request("PUT", "/api/v1/jira/mock/auth-result", result);
|
|
}
|
|
|
|
async mockJiraSetAuthHealth(args: { ok: boolean; error?: string }): Promise<void> {
|
|
await this.request("PUT", "/api/v1/jira/mock/auth-health", args);
|
|
}
|
|
|
|
async mockJiraSetProjects(projects: MockJiraProject[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/jira/mock/projects", { projects });
|
|
}
|
|
|
|
async mockJiraAddTickets(tickets: MockJiraTicket[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/jira/mock/tickets", { tickets });
|
|
}
|
|
|
|
async mockJiraAddTransitions(
|
|
ticketKey: string,
|
|
transitions: MockJiraTransition[],
|
|
): Promise<void> {
|
|
await this.request("POST", "/api/v1/jira/mock/transitions", {
|
|
ticketKey,
|
|
transitions,
|
|
});
|
|
}
|
|
|
|
async mockJiraSetSearchHits(tickets: MockJiraTicket[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/jira/mock/search-hits", { tickets });
|
|
}
|
|
|
|
async mockJiraSetGetTicketError(args: { statusCode: number; message: string }): Promise<void> {
|
|
await this.request("PUT", "/api/v1/jira/mock/get-ticket-error", args);
|
|
}
|
|
|
|
// --- Linear Mock Control ---
|
|
|
|
async mockLinearReset(): Promise<void> {
|
|
await this.request("DELETE", "/api/v1/linear/mock/reset");
|
|
}
|
|
|
|
async mockLinearSetAuthResult(result: {
|
|
ok: boolean;
|
|
userId?: string;
|
|
displayName?: string;
|
|
email?: string;
|
|
orgSlug?: string;
|
|
orgName?: string;
|
|
error?: string;
|
|
}): Promise<void> {
|
|
await this.request("PUT", "/api/v1/linear/mock/auth-result", result);
|
|
}
|
|
|
|
async mockLinearSetAuthHealth(args: {
|
|
ok: boolean;
|
|
error?: string;
|
|
orgSlug?: string;
|
|
}): Promise<void> {
|
|
await this.request("PUT", "/api/v1/linear/mock/auth-health", args);
|
|
}
|
|
|
|
async mockLinearSetTeams(teams: MockLinearTeam[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/linear/mock/teams", { teams });
|
|
}
|
|
|
|
async mockLinearSetStates(teamKey: string, states: MockLinearState[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/linear/mock/states", { teamKey, states });
|
|
}
|
|
|
|
async mockLinearAddIssues(issues: MockLinearIssue[]): Promise<void> {
|
|
await this.request("POST", "/api/v1/linear/mock/issues", { issues });
|
|
}
|
|
|
|
async mockLinearSetGetIssueError(args: { statusCode: number; message: string }): Promise<void> {
|
|
await this.request("PUT", "/api/v1/linear/mock/get-issue-error", args);
|
|
}
|
|
}
|
|
|
|
// --- Jira / Linear mock payload types ---
|
|
|
|
export type MockJiraProject = { id: string; key: string; name: string };
|
|
|
|
export type MockJiraTransition = {
|
|
id: string;
|
|
name: string;
|
|
toStatusId: string;
|
|
toStatusName: string;
|
|
};
|
|
|
|
export type MockJiraTicket = {
|
|
key: string;
|
|
summary?: string;
|
|
description?: string;
|
|
statusId?: string;
|
|
statusName?: string;
|
|
statusCategory?: string;
|
|
projectKey?: string;
|
|
issueType?: string;
|
|
url?: string;
|
|
transitions?: MockJiraTransition[];
|
|
};
|
|
|
|
export type MockLinearTeam = { id: string; key: string; name: string };
|
|
|
|
export type MockLinearState = {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
color?: string;
|
|
position?: number;
|
|
};
|
|
|
|
export type MockLinearIssue = {
|
|
id: string;
|
|
identifier: string;
|
|
title?: string;
|
|
description?: string;
|
|
stateId?: string;
|
|
stateName?: string;
|
|
stateType?: string;
|
|
stateCategory?: string;
|
|
teamId?: string;
|
|
teamKey?: string;
|
|
priority?: number;
|
|
url?: string;
|
|
};
|