refactor: remove unused test helpers and related test files
- Deleted `helpers.ts`, `projects.test.ts`, and `scenarios.test.ts` as they are no longer needed. - Updated `package.json` to remove the test script that referenced the deleted files.
This commit is contained in:
@@ -28,8 +28,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"check": "tsc --noEmit",
|
||||
"watch": "tsc --noEmit --watch",
|
||||
"test": "npx tsx --test test/**/*.test.ts"
|
||||
"watch": "tsc --noEmit --watch"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.0.0"
|
||||
|
||||
379
test/helpers.ts
379
test/helpers.ts
@@ -1,379 +0,0 @@
|
||||
/**
|
||||
* helpers.ts — Shared utilities for DevClaw integration tests.
|
||||
*
|
||||
* Provides: gateway RPC wrapper, GitHub issue helpers, session verification,
|
||||
* mock context factories, and automatic test resource cleanup.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { ToolContext } from "../lib/types.js";
|
||||
import { type ProjectsData, writeProjects } from "../lib/projects.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Prefix for all test session keys — used for cleanup sweeps */
|
||||
export const TEST_SESSION_PREFIX = "agent:devclaw:subagent:test-";
|
||||
|
||||
/** Group ID used for the test project in projects.json.
|
||||
* Uses the real DevClaw Telegram group so notifications are visible in the channel. */
|
||||
export const TEST_GROUP_ID = "-5239235162";
|
||||
|
||||
/** Repo path for test issues (devclaw repo) */
|
||||
export const TEST_REPO = "laurentenhoor/devclaw";
|
||||
|
||||
/** Project name used in test workspaces. Prefixed with "test-" so session keys
|
||||
* (agent:devclaw:subagent:test-devclaw-dev-junior) don't collide with production
|
||||
* and match TEST_SESSION_PREFIX for cleanup sweeps. */
|
||||
export const TEST_PROJECT_NAME = "test-devclaw";
|
||||
|
||||
// ── Gateway RPC ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Call an OpenClaw gateway method. Returns parsed JSON response.
|
||||
* Throws on gateway error or timeout.
|
||||
*/
|
||||
export async function gateway(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"openclaw",
|
||||
[
|
||||
"gateway",
|
||||
"call",
|
||||
method,
|
||||
"--params",
|
||||
JSON.stringify(params),
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
// openclaw may output plugin registration lines before JSON
|
||||
const jsonStart = stdout.indexOf("{");
|
||||
if (jsonStart === -1) {
|
||||
throw new Error(`No JSON in gateway response for ${method}: ${stdout}`);
|
||||
}
|
||||
return JSON.parse(stdout.slice(jsonStart));
|
||||
}
|
||||
|
||||
// ── Session helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Check if a session exists in the gateway */
|
||||
export async function sessionExists(key: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await gateway("sessions.list", {
|
||||
limit: 200,
|
||||
agentId: "devclaw",
|
||||
});
|
||||
const sessions = result.sessions as Array<{ key: string }>;
|
||||
return sessions.some((s) => s.key === key);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get token count for a session (0 = never started) */
|
||||
export async function getSessionTokens(
|
||||
key: string,
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const result = await gateway("sessions.list", {
|
||||
limit: 200,
|
||||
agentId: "devclaw",
|
||||
});
|
||||
const sessions = result.sessions as Array<{
|
||||
key: string;
|
||||
totalTokens?: number;
|
||||
}>;
|
||||
const session = sessions.find((s) => s.key === key);
|
||||
return session ? (session.totalTokens ?? 0) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── GitHub issue helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Get current labels on a GitHub issue */
|
||||
export async function getIssueLabels(
|
||||
repo: string,
|
||||
issueId: number,
|
||||
): Promise<string[]> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
["issue", "view", String(issueId), "--repo", repo, "--json", "labels"],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const data = JSON.parse(stdout) as { labels: Array<{ name: string }> };
|
||||
return data.labels.map((l) => l.name);
|
||||
}
|
||||
|
||||
/** Get current state of a GitHub issue (OPEN/CLOSED) */
|
||||
export async function getIssueState(
|
||||
repo: string,
|
||||
issueId: number,
|
||||
): Promise<string> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
["issue", "view", String(issueId), "--repo", repo, "--json", "state"],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const data = JSON.parse(stdout) as { state: string };
|
||||
return data.state;
|
||||
}
|
||||
|
||||
/** Close a GitHub issue (best-effort) */
|
||||
export async function closeIssue(
|
||||
repo: string,
|
||||
issueId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await execFileAsync(
|
||||
"gh",
|
||||
["issue", "close", String(issueId), "--repo", repo],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cleanup registry ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tracks all test resources (sessions + issues) for guaranteed cleanup.
|
||||
*
|
||||
* Usage:
|
||||
* const cleanup = new TestCleanup();
|
||||
* cleanup.trackSession("agent:devclaw:subagent:test-xxx");
|
||||
* cleanup.trackIssue("laurentenhoor/devclaw", 42);
|
||||
* await cleanup.cleanAll(); // in after() hook
|
||||
*/
|
||||
export class TestCleanup {
|
||||
private sessions = new Set<string>();
|
||||
private issues: Array<{ repo: string; id: number }> = [];
|
||||
|
||||
trackSession(key: string): void {
|
||||
this.sessions.add(key);
|
||||
}
|
||||
|
||||
trackIssue(repo: string, id: number): void {
|
||||
this.issues.push({ repo, id });
|
||||
}
|
||||
|
||||
async cleanAll(): Promise<void> {
|
||||
// Delete tracked sessions
|
||||
for (const key of this.sessions) {
|
||||
try {
|
||||
await gateway("sessions.delete", {
|
||||
key,
|
||||
deleteTranscript: true,
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
this.sessions.clear();
|
||||
|
||||
// Close tracked issues
|
||||
for (const { repo, id } of this.issues) {
|
||||
await closeIssue(repo, id);
|
||||
}
|
||||
this.issues.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety sweep: find and delete any test sessions from previous failed runs.
|
||||
* Scans sessions.list for keys matching TEST_SESSION_PREFIX.
|
||||
*/
|
||||
export async function sweepTestSessions(): Promise<number> {
|
||||
let cleaned = 0;
|
||||
try {
|
||||
const result = await gateway("sessions.list", {
|
||||
limit: 200,
|
||||
agentId: "devclaw",
|
||||
});
|
||||
const sessions = result.sessions as Array<{ key: string }>;
|
||||
for (const session of sessions) {
|
||||
if (session.key.startsWith(TEST_SESSION_PREFIX)) {
|
||||
try {
|
||||
await gateway("sessions.delete", {
|
||||
key: session.key,
|
||||
deleteTranscript: true,
|
||||
});
|
||||
cleaned++;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ── Mock factories ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a mock ToolContext simulating a group chat for the test project.
|
||||
*/
|
||||
export function makeTestContext(
|
||||
groupId: string,
|
||||
workspaceDir: string,
|
||||
): ToolContext {
|
||||
return {
|
||||
config: {},
|
||||
workspaceDir,
|
||||
agentDir: "/tmp/devclaw-test-agent",
|
||||
agentId: "devclaw",
|
||||
sessionKey: `agent:devclaw:telegram:group:${groupId}`,
|
||||
messageChannel: "telegram",
|
||||
sandboxed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal mock OpenClawPluginApi for testing.
|
||||
* Only provides the fields tools actually use: pluginConfig, logger, resolvePath.
|
||||
*/
|
||||
export function makeTestApi(pluginConfig?: Record<string, unknown>): any {
|
||||
return {
|
||||
id: "devclaw",
|
||||
name: "DevClaw",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: pluginConfig ?? {
|
||||
devClawAgentIds: ["devclaw"],
|
||||
models: {
|
||||
junior: "anthropic/claude-haiku-4-5",
|
||||
medior: "anthropic/claude-sonnet-4-5",
|
||||
senior: "anthropic/claude-opus-4-5",
|
||||
qa: "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
projectExecution: "parallel",
|
||||
},
|
||||
logger: {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
runtime: {},
|
||||
registerTool: () => {},
|
||||
registerHook: () => {},
|
||||
registerHttpHandler: () => {},
|
||||
registerHttpRoute: () => {},
|
||||
registerChannel: () => {},
|
||||
registerGatewayMethod: () => {},
|
||||
registerCli: () => {},
|
||||
registerService: () => {},
|
||||
registerProvider: () => {},
|
||||
registerCommand: () => {},
|
||||
resolvePath: (input: string) => input.replace("~", os.homedir()),
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Workspace helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a temp workspace directory with initial projects.json and role files.
|
||||
* Returns the workspace path. Caller must clean up via fs.rm().
|
||||
*/
|
||||
export async function createTestWorkspace(opts?: {
|
||||
groupId?: string;
|
||||
autoChain?: boolean;
|
||||
}): Promise<string> {
|
||||
const groupId = opts?.groupId ?? TEST_GROUP_ID;
|
||||
const autoChain = opts?.autoChain ?? false;
|
||||
|
||||
const tempDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "devclaw-scenario-test-"),
|
||||
);
|
||||
|
||||
// Create required directories
|
||||
await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
|
||||
await fs.mkdir(path.join(tempDir, "roles", "default"), { recursive: true });
|
||||
await fs.mkdir(path.join(tempDir, "roles", TEST_PROJECT_NAME), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Write initial projects.json
|
||||
const initialData: ProjectsData = {
|
||||
projects: {
|
||||
[groupId]: {
|
||||
name: TEST_PROJECT_NAME,
|
||||
repo: `~/.openclaw/extensions/devclaw`,
|
||||
groupName: "DevClaw - Test",
|
||||
deployUrl: "",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
autoChain,
|
||||
channel: "telegram",
|
||||
dev: {
|
||||
active: false,
|
||||
issueId: null,
|
||||
startTime: null,
|
||||
model: null,
|
||||
sessions: { junior: null, medior: null, senior: null },
|
||||
},
|
||||
qa: {
|
||||
active: false,
|
||||
issueId: null,
|
||||
startTime: null,
|
||||
model: null,
|
||||
sessions: { qa: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await writeProjects(tempDir, initialData);
|
||||
|
||||
// Write minimal role files
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "roles", "default", "dev.md"),
|
||||
"# DEV Worker Instructions\n\nThis is a test worker. Just acknowledge the task.\n",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "roles", "default", "qa.md"),
|
||||
"# QA Worker Instructions\n\nThis is a test QA worker. Just acknowledge the task.\n",
|
||||
);
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
// ── Result parser ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the result from a tool's execute() call.
|
||||
* Tools return jsonResult() which wraps the payload in AgentToolResult format.
|
||||
*/
|
||||
export function parseToolResult(result: unknown): Record<string, unknown> {
|
||||
// jsonResult returns [{ type: "text", text: JSON.stringify(payload) }]
|
||||
// or { content: [{ type: "text", text: "..." }] }
|
||||
if (Array.isArray(result)) {
|
||||
const first = result[0];
|
||||
if (first && typeof first === "object" && "text" in first) {
|
||||
return JSON.parse(first.text as string);
|
||||
}
|
||||
}
|
||||
if (
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"content" in result
|
||||
) {
|
||||
const content = (result as any).content;
|
||||
if (Array.isArray(content) && content[0]?.text) {
|
||||
return JSON.parse(content[0].text);
|
||||
}
|
||||
}
|
||||
throw new Error(`Cannot parse tool result: ${JSON.stringify(result)}`);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Tests for projects.ts session persistence
|
||||
* Run with: npm test
|
||||
*/
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import {
|
||||
type ProjectsData,
|
||||
activateWorker,
|
||||
deactivateWorker,
|
||||
readProjects,
|
||||
writeProjects,
|
||||
} from "../lib/projects.js";
|
||||
|
||||
describe("Session persistence", () => {
|
||||
let tempDir: string;
|
||||
let testWorkspaceDir: string;
|
||||
|
||||
before(async () => {
|
||||
// Create temp directory for test workspace
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
|
||||
testWorkspaceDir = tempDir;
|
||||
await fs.mkdir(path.join(testWorkspaceDir, "memory"), { recursive: true });
|
||||
|
||||
// Create initial projects.json
|
||||
const initialData: ProjectsData = {
|
||||
projects: {
|
||||
"-test-group": {
|
||||
name: "test-project",
|
||||
repo: "~/git/test-project",
|
||||
groupName: "Test Project",
|
||||
deployUrl: "https://test.example.com",
|
||||
baseBranch: "main",
|
||||
deployBranch: "main",
|
||||
autoChain: false,
|
||||
channel: "telegram",
|
||||
dev: {
|
||||
active: false,
|
||||
issueId: null,
|
||||
startTime: null,
|
||||
model: null,
|
||||
sessions: {
|
||||
junior: null,
|
||||
medior: null,
|
||||
senior: null,
|
||||
},
|
||||
},
|
||||
qa: {
|
||||
active: false,
|
||||
issueId: null,
|
||||
startTime: null,
|
||||
model: null,
|
||||
sessions: {
|
||||
qa: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await writeProjects(testWorkspaceDir, initialData);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Clean up temp directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should preserve sessions after task completion (single tier)", async () => {
|
||||
// Simulate task pickup: activate worker with senior tier
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "42",
|
||||
model: "senior",
|
||||
sessionKey: "agent:test:subagent:senior-session-123",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Verify session was stored
|
||||
let data = await readProjects(testWorkspaceDir);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.senior,
|
||||
"agent:test:subagent:senior-session-123",
|
||||
"Senior session should be stored after activation",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.active,
|
||||
true,
|
||||
"Worker should be active",
|
||||
);
|
||||
|
||||
// Simulate task completion: deactivate worker
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
// Verify session persists after deactivation
|
||||
data = await readProjects(testWorkspaceDir);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.senior,
|
||||
"agent:test:subagent:senior-session-123",
|
||||
"Senior session should persist after deactivation",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.active,
|
||||
false,
|
||||
"Worker should be inactive",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.issueId,
|
||||
null,
|
||||
"Issue ID should be cleared",
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve all tier sessions after completion (multiple tiers)", async () => {
|
||||
// Setup: create sessions for multiple tiers
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "10",
|
||||
model: "junior",
|
||||
sessionKey: "agent:test:subagent:junior-session-111",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "20",
|
||||
model: "medior",
|
||||
sessionKey: "agent:test:subagent:medior-session-222",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "30",
|
||||
model: "senior",
|
||||
sessionKey: "agent:test:subagent:senior-session-333",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
// Verify all sessions persisted
|
||||
const data = await readProjects(testWorkspaceDir);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.junior,
|
||||
"agent:test:subagent:junior-session-111",
|
||||
"Junior session should persist",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.medior,
|
||||
"agent:test:subagent:medior-session-222",
|
||||
"Medior session should persist",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.senior,
|
||||
"agent:test:subagent:senior-session-333",
|
||||
"Senior session should persist",
|
||||
);
|
||||
});
|
||||
|
||||
it("should reuse existing session when picking up new task", async () => {
|
||||
// Setup: create a session for senior tier
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "100",
|
||||
model: "senior",
|
||||
sessionKey: "agent:test:subagent:senior-reuse-999",
|
||||
startTime: new Date().toISOString(),
|
||||
});
|
||||
await deactivateWorker(testWorkspaceDir, "-test-group", "dev");
|
||||
|
||||
// Pick up new task with same tier (no sessionKey = reuse)
|
||||
await activateWorker(testWorkspaceDir, "-test-group", "dev", {
|
||||
issueId: "200",
|
||||
model: "senior",
|
||||
});
|
||||
|
||||
// Verify session was preserved (not overwritten)
|
||||
const data = await readProjects(testWorkspaceDir);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.sessions.senior,
|
||||
"agent:test:subagent:senior-reuse-999",
|
||||
"Senior session should be reused (not cleared)",
|
||||
);
|
||||
assert.strictEqual(
|
||||
data.projects["-test-group"].dev.issueId,
|
||||
"200",
|
||||
"Issue ID should be updated to new task",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,741 +0,0 @@
|
||||
/**
|
||||
* scenarios.test.ts — Scenario-based integration tests for DevClaw plugin tools.
|
||||
*
|
||||
* Tests the full tool pipeline in realistic sequences against real gateway + GitHub.
|
||||
* Each scenario exercises multiple tools in order, verifying BOTH return values
|
||||
* AND actual side effects (session existence, issue labels, projects.json state).
|
||||
*
|
||||
* Prerequisites:
|
||||
* - OpenClaw gateway running
|
||||
* - `gh` CLI authenticated with access to laurentenhoor/devclaw
|
||||
* - `openclaw` CLI in PATH
|
||||
*
|
||||
* Run with: npm test
|
||||
*/
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import { createTaskCreateTool } from "../lib/tools/task-create.js";
|
||||
import { createTaskPickupTool } from "../lib/tools/task-pickup.js";
|
||||
import { createTaskCompleteTool } from "../lib/tools/task-complete.js";
|
||||
import { createQueueStatusTool } from "../lib/tools/queue-status.js";
|
||||
import { createSessionHealthTool } from "../lib/tools/session-health.js";
|
||||
import { readProjects, writeProjects } from "../lib/projects.js";
|
||||
import { resolveModel } from "../lib/tiers.js";
|
||||
import {
|
||||
TestCleanup,
|
||||
TEST_GROUP_ID,
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_REPO,
|
||||
createTestWorkspace,
|
||||
gateway,
|
||||
getIssueLabels,
|
||||
getIssueState,
|
||||
makeTestApi,
|
||||
makeTestContext,
|
||||
parseToolResult,
|
||||
sessionExists,
|
||||
sweepTestSessions,
|
||||
} from "./helpers.js";
|
||||
|
||||
// ── Suite-level setup ───────────────────────────────────────────────────────
|
||||
|
||||
describe("DevClaw Scenario Tests", { timeout: 240_000 }, () => {
|
||||
before(async () => {
|
||||
// Verify gateway is accessible
|
||||
try {
|
||||
await gateway("sessions.list", { limit: 1 });
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Gateway not accessible — cannot run integration tests: ${(err as Error).message}. ` +
|
||||
`Ensure 'openclaw' is in PATH and gateway is running.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify gh CLI is authenticated
|
||||
try {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
await execFileAsync("gh", ["auth", "status"], { timeout: 10_000 });
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`GitHub CLI not authenticated — cannot run integration tests: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Sweep leftover test sessions from previous failed runs
|
||||
await sweepTestSessions();
|
||||
});
|
||||
|
||||
// ── Scenario 1: Full DEV lifecycle ──────────────────────────────────────
|
||||
|
||||
describe("Scenario 1: Full DEV lifecycle", () => {
|
||||
const cleanup = new TestCleanup();
|
||||
let workspaceDir: string;
|
||||
let api: ReturnType<typeof makeTestApi>;
|
||||
let ctx: ReturnType<typeof makeTestContext>;
|
||||
|
||||
let createdIssueId: number;
|
||||
let spawnedSessionKey: string;
|
||||
|
||||
before(async () => {
|
||||
workspaceDir = await createTestWorkspace({ autoChain: false });
|
||||
api = makeTestApi();
|
||||
ctx = makeTestContext(TEST_GROUP_ID, workspaceDir);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await cleanup.cleanAll();
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("step 1: task_create creates a test issue with To Do label", async () => {
|
||||
const tool = createTaskCreateTool(api)(ctx);
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
title: "[TEST] Scenario 1 — DEV lifecycle test",
|
||||
description:
|
||||
"Automated integration test. This issue will be cleaned up automatically.",
|
||||
label: "To Do",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(result.success, true, "task_create should succeed");
|
||||
assert.ok(result.issue, "Should return issue object");
|
||||
createdIssueId = (result.issue as any).id as number;
|
||||
assert.ok(createdIssueId > 0, "Issue ID should be positive");
|
||||
cleanup.trackIssue(TEST_REPO, createdIssueId);
|
||||
|
||||
// Verify side effect: issue has "To Do" label in GitHub
|
||||
const labels = await getIssueLabels(TEST_REPO, createdIssueId);
|
||||
assert.ok(
|
||||
labels.includes("To Do"),
|
||||
`Issue should have "To Do" label, got: ${labels.join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("step 2: task_pickup spawns a worker session", async () => {
|
||||
const tool = createTaskPickupTool(api)(ctx);
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
issueId: createdIssueId,
|
||||
role: "dev",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
model: "junior",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(result.success, true, `task_pickup should succeed: ${result.error ?? ""}`);
|
||||
assert.strictEqual(result.sessionAction, "spawn", "Should spawn new session");
|
||||
|
||||
// task_pickup stores the session key in projects.json, not in the result
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = data.projects[TEST_GROUP_ID];
|
||||
assert.ok(project.dev.sessions.junior, "Should have session key in projects.json");
|
||||
spawnedSessionKey = project.dev.sessions.junior as string;
|
||||
|
||||
// Track for cleanup
|
||||
cleanup.trackSession(spawnedSessionKey);
|
||||
|
||||
// Verify side effect: session exists in gateway
|
||||
const exists = await sessionExists(spawnedSessionKey);
|
||||
assert.ok(exists, `Session ${spawnedSessionKey} should exist in gateway`);
|
||||
|
||||
// Verify side effect: issue label transitioned to "Doing"
|
||||
const labels = await getIssueLabels(TEST_REPO, createdIssueId);
|
||||
assert.ok(
|
||||
labels.includes("Doing"),
|
||||
`Issue should have "Doing" label after pickup, got: ${labels.join(", ")}`,
|
||||
);
|
||||
|
||||
// Verify side effect: worker is active
|
||||
assert.strictEqual(project.dev.active, true, "Worker should be active");
|
||||
assert.strictEqual(
|
||||
project.dev.issueId,
|
||||
String(createdIssueId),
|
||||
"Worker should have correct issue ID",
|
||||
);
|
||||
});
|
||||
|
||||
it("step 3: task_complete (dev done) transitions to To Test", async () => {
|
||||
const tool = createTaskCompleteTool(api)(ctx);
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
role: "dev",
|
||||
result: "done",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
summary: "Test task completed by integration test",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(result.success, true, `task_complete should succeed: ${result.error ?? ""}`);
|
||||
assert.ok(
|
||||
(result.labelTransition as string)?.includes("To Test"),
|
||||
`Label should transition to "To Test", got: ${result.labelTransition}`,
|
||||
);
|
||||
|
||||
// Verify side effect: issue label is now "To Test"
|
||||
const labels = await getIssueLabels(TEST_REPO, createdIssueId);
|
||||
assert.ok(
|
||||
labels.includes("To Test"),
|
||||
`Issue should have "To Test" label, got: ${labels.join(", ")}`,
|
||||
);
|
||||
|
||||
// Verify side effect: worker deactivated but session preserved
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = data.projects[TEST_GROUP_ID];
|
||||
assert.strictEqual(project.dev.active, false, "Worker should be inactive");
|
||||
assert.strictEqual(project.dev.issueId, null, "Issue ID should be cleared");
|
||||
assert.strictEqual(
|
||||
project.dev.sessions.junior,
|
||||
spawnedSessionKey,
|
||||
"Session should be PRESERVED after completion",
|
||||
);
|
||||
});
|
||||
|
||||
it("step 4: task_pickup (qa) transitions to Testing", async () => {
|
||||
const tool = createTaskPickupTool(api)(ctx);
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
issueId: createdIssueId,
|
||||
role: "qa",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
model: "qa",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(result.success, true, `QA pickup should succeed: ${result.error ?? ""}`);
|
||||
|
||||
// Read QA session key from projects.json
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = data.projects[TEST_GROUP_ID];
|
||||
assert.ok(project.qa.sessions.qa, "Should have QA session key in projects.json");
|
||||
cleanup.trackSession(project.qa.sessions.qa as string);
|
||||
|
||||
// Verify side effect: issue label transitioned to "Testing"
|
||||
const labels = await getIssueLabels(TEST_REPO, createdIssueId);
|
||||
assert.ok(
|
||||
labels.includes("Testing"),
|
||||
`Issue should have "Testing" label after QA pickup, got: ${labels.join(", ")}`,
|
||||
);
|
||||
|
||||
// Verify side effect: QA worker is active
|
||||
assert.strictEqual(project.qa.active, true, "QA worker should be active");
|
||||
assert.strictEqual(
|
||||
project.qa.issueId,
|
||||
String(createdIssueId),
|
||||
"QA worker should have correct issue ID",
|
||||
);
|
||||
});
|
||||
|
||||
it("step 5: task_complete (qa pass) transitions to Done and closes issue", async () => {
|
||||
const tool = createTaskCompleteTool(api)(ctx);
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
role: "qa",
|
||||
result: "pass",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
summary: "QA passed by integration test",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(result.success, true, `QA complete should succeed: ${result.error ?? ""}`);
|
||||
assert.ok(
|
||||
(result.labelTransition as string)?.includes("Done"),
|
||||
`Label should transition to "Done", got: ${result.labelTransition}`,
|
||||
);
|
||||
assert.strictEqual(result.issueClosed, true, "Issue should be closed");
|
||||
|
||||
// Verify side effect: issue label is now "Done"
|
||||
const labels = await getIssueLabels(TEST_REPO, createdIssueId);
|
||||
assert.ok(
|
||||
labels.includes("Done"),
|
||||
`Issue should have "Done" label, got: ${labels.join(", ")}`,
|
||||
);
|
||||
|
||||
// Verify side effect: issue is closed
|
||||
const state = await getIssueState(TEST_REPO, createdIssueId);
|
||||
assert.strictEqual(state, "CLOSED", "Issue should be closed in GitHub");
|
||||
|
||||
// Verify side effect: QA worker deactivated, sessions preserved
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = data.projects[TEST_GROUP_ID];
|
||||
assert.strictEqual(project.qa.active, false, "QA worker should be inactive");
|
||||
assert.strictEqual(project.qa.issueId, null, "QA issue ID should be cleared");
|
||||
|
||||
// DEV session should still be preserved from earlier
|
||||
assert.strictEqual(
|
||||
project.dev.sessions.junior,
|
||||
spawnedSessionKey,
|
||||
"DEV session should still be PRESERVED after full lifecycle",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scenario 2: Queue status accuracy ─────────────────────────────────
|
||||
|
||||
describe("Scenario 2: Queue status accuracy", () => {
|
||||
const cleanup = new TestCleanup();
|
||||
let workspaceDir: string;
|
||||
let api: ReturnType<typeof makeTestApi>;
|
||||
let ctx: ReturnType<typeof makeTestContext>;
|
||||
let issueIds: number[] = [];
|
||||
|
||||
before(async () => {
|
||||
workspaceDir = await createTestWorkspace({ autoChain: false });
|
||||
api = makeTestApi();
|
||||
ctx = makeTestContext(TEST_GROUP_ID, workspaceDir);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await cleanup.cleanAll();
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("step 1: create 3 issues with different labels", async () => {
|
||||
const tool = createTaskCreateTool(api)(ctx);
|
||||
|
||||
const labels = ["To Do", "To Improve", "To Test"];
|
||||
for (const label of labels) {
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
title: `[TEST] Queue test — ${label}`,
|
||||
description: "Automated test issue for queue_status verification.",
|
||||
label,
|
||||
}),
|
||||
);
|
||||
assert.strictEqual(result.success, true);
|
||||
const issueId = (result.issue as any).id as number;
|
||||
issueIds.push(issueId);
|
||||
cleanup.trackIssue(TEST_REPO, issueId);
|
||||
}
|
||||
|
||||
assert.strictEqual(issueIds.length, 3, "Should have created 3 issues");
|
||||
});
|
||||
|
||||
it("step 2: queue_status shows all issues in correct buckets", async () => {
|
||||
// Small delay for GitHub API eventual consistency
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
|
||||
const tool = createQueueStatusTool(api)(ctx);
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
// The result should contain projects with queue data
|
||||
const projects = result.projects as Array<{
|
||||
queue: {
|
||||
toImprove: Array<{ id: number }>;
|
||||
toTest: Array<{ id: number }>;
|
||||
toDo: Array<{ id: number }>;
|
||||
};
|
||||
}>;
|
||||
assert.ok(projects && projects.length > 0, "Should have project data");
|
||||
|
||||
const queue = projects[0].queue;
|
||||
assert.ok(queue, "Should have queue data");
|
||||
|
||||
// Verify each bucket has our test issues
|
||||
const toDoIds = queue.toDo.map((i) => i.id);
|
||||
const toImproveIds = queue.toImprove.map((i) => i.id);
|
||||
const toTestIds = queue.toTest.map((i) => i.id);
|
||||
|
||||
assert.ok(
|
||||
toDoIds.includes(issueIds[0]),
|
||||
`"To Do" bucket should contain issue ${issueIds[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
toImproveIds.includes(issueIds[1]),
|
||||
`"To Improve" bucket should contain issue ${issueIds[1]}`,
|
||||
);
|
||||
assert.ok(
|
||||
toTestIds.includes(issueIds[2]),
|
||||
`"To Test" bucket should contain issue ${issueIds[2]}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scenario 3: Session health detection ──────────────────────────────
|
||||
|
||||
describe("Scenario 3: Session health detection", () => {
|
||||
let workspaceDir: string;
|
||||
let api: ReturnType<typeof makeTestApi>;
|
||||
let ctx: ReturnType<typeof makeTestContext>;
|
||||
|
||||
before(async () => {
|
||||
workspaceDir = await createTestWorkspace({ autoChain: false });
|
||||
api = makeTestApi();
|
||||
ctx = makeTestContext(TEST_GROUP_ID, workspaceDir);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("step 1: inject zombie state into projects.json", async () => {
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = data.projects[TEST_GROUP_ID];
|
||||
|
||||
// Set active=true with a dead session key (session doesn't exist in gateway)
|
||||
project.dev.active = true;
|
||||
project.dev.issueId = "999";
|
||||
project.dev.model = "medior";
|
||||
project.dev.startTime = new Date(
|
||||
Date.now() - 3 * 60 * 60 * 1000,
|
||||
).toISOString(); // 3 hours ago
|
||||
project.dev.sessions.medior =
|
||||
"agent:devclaw:subagent:dead-zombie-session-000";
|
||||
|
||||
await writeProjects(workspaceDir, data);
|
||||
|
||||
// Verify it was written
|
||||
const readBack = await readProjects(workspaceDir);
|
||||
assert.strictEqual(
|
||||
readBack.projects[TEST_GROUP_ID].dev.active,
|
||||
true,
|
||||
"Zombie state should be written",
|
||||
);
|
||||
});
|
||||
|
||||
it("step 2: session_health detects the zombie (no autoFix)", async () => {
|
||||
const tool = createSessionHealthTool(api)(ctx);
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
autoFix: false,
|
||||
activeSessions: [], // empty = zombie detection skipped, but stale_worker (3h) will be caught
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(result.healthy, false, "Should report unhealthy");
|
||||
assert.ok(
|
||||
(result.issuesFound as number) > 0,
|
||||
"Should find at least one issue",
|
||||
);
|
||||
|
||||
const issues = result.issues as Array<{
|
||||
type: string;
|
||||
project: string;
|
||||
role: string;
|
||||
}>;
|
||||
const zombieIssue = issues.find(
|
||||
(i) =>
|
||||
i.project === TEST_PROJECT_NAME &&
|
||||
i.role === "dev",
|
||||
);
|
||||
assert.ok(zombieIssue, "Should detect zombie for dev worker");
|
||||
});
|
||||
|
||||
it("step 3: session_health fixes the zombie (autoFix=true)", async () => {
|
||||
const tool = createSessionHealthTool(api)(ctx);
|
||||
// Provide a non-empty activeSessions list that does NOT include the zombie key.
|
||||
// This enables zombie detection (requires activeSessions.length > 0).
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
autoFix: true,
|
||||
activeSessions: ["agent:devclaw:subagent:some-alive-session"],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
(result.fixesApplied as number) > 0,
|
||||
"Should apply at least one fix",
|
||||
);
|
||||
|
||||
// Verify side effect: worker is deactivated in projects.json
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = data.projects[TEST_GROUP_ID];
|
||||
assert.strictEqual(
|
||||
project.dev.active,
|
||||
false,
|
||||
"Worker should be deactivated after auto-fix",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scenario 4: Auto-chain DEV → QA ──────────────────────────────────
|
||||
|
||||
describe("Scenario 4: Auto-chain DEV → QA", () => {
|
||||
const cleanup = new TestCleanup();
|
||||
let workspaceDir: string;
|
||||
let api: ReturnType<typeof makeTestApi>;
|
||||
let ctx: ReturnType<typeof makeTestContext>;
|
||||
|
||||
let issueId: number;
|
||||
let devSessionKey: string;
|
||||
|
||||
before(async () => {
|
||||
workspaceDir = await createTestWorkspace({ autoChain: true });
|
||||
api = makeTestApi();
|
||||
ctx = makeTestContext(TEST_GROUP_ID, workspaceDir);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await cleanup.cleanAll();
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("step 1: create issue and pick up for DEV", async () => {
|
||||
// Create
|
||||
const createTool = createTaskCreateTool(api)(ctx);
|
||||
const createResult = parseToolResult(
|
||||
await createTool.execute("test", {
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
title: "[TEST] Scenario 4 — Auto-chain DEV→QA",
|
||||
description: "Automated test for auto-chain pipeline.",
|
||||
label: "To Do",
|
||||
}),
|
||||
);
|
||||
issueId = (createResult.issue as any).id as number;
|
||||
cleanup.trackIssue(TEST_REPO, issueId);
|
||||
|
||||
// Pickup DEV
|
||||
const pickupTool = createTaskPickupTool(api)(ctx);
|
||||
const pickupResult = parseToolResult(
|
||||
await pickupTool.execute("test", {
|
||||
issueId,
|
||||
role: "dev",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
model: "junior",
|
||||
}),
|
||||
);
|
||||
assert.strictEqual(pickupResult.success, true, `Pickup should succeed: ${pickupResult.error ?? ""}`);
|
||||
|
||||
// Read session key from projects.json (not in tool result)
|
||||
const pickupData = await readProjects(workspaceDir);
|
||||
devSessionKey = pickupData.projects[TEST_GROUP_ID].dev.sessions.junior as string;
|
||||
assert.ok(devSessionKey, "Should have session key in projects.json");
|
||||
cleanup.trackSession(devSessionKey);
|
||||
});
|
||||
|
||||
it("step 2: task_complete (dev done) auto-chains to QA", async () => {
|
||||
const tool = createTaskCompleteTool(api)(ctx);
|
||||
const result = parseToolResult(
|
||||
await tool.execute("test", {
|
||||
role: "dev",
|
||||
result: "done",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
summary: "DEV done, should auto-chain to QA",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(result.success, true, `Complete should succeed: ${result.error ?? ""}`);
|
||||
|
||||
// Check auto-chain result
|
||||
const autoChain = result.autoChain as Record<string, unknown> | undefined;
|
||||
if (autoChain) {
|
||||
assert.strictEqual(
|
||||
autoChain.dispatched,
|
||||
true,
|
||||
"Auto-chain should dispatch QA",
|
||||
);
|
||||
assert.strictEqual(autoChain.role, "qa", "Should chain to QA role");
|
||||
|
||||
// Track QA session for cleanup
|
||||
if (autoChain.sessionKey) {
|
||||
cleanup.trackSession(autoChain.sessionKey as string);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify issue label moved to Testing (via auto-chain)
|
||||
const labels = await getIssueLabels(TEST_REPO, issueId);
|
||||
assert.ok(
|
||||
labels.includes("Testing") || labels.includes("To Test"),
|
||||
`Issue should have "Testing" or "To Test" label, got: ${labels.join(", ")}`,
|
||||
);
|
||||
|
||||
// Verify QA worker is active in projects.json
|
||||
const data = await readProjects(workspaceDir);
|
||||
const project = data.projects[TEST_GROUP_ID];
|
||||
if (autoChain?.dispatched) {
|
||||
assert.strictEqual(
|
||||
project.qa.active,
|
||||
true,
|
||||
"QA worker should be active after auto-chain",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scenario 5: Blocked result escalation ────────────────────────────
|
||||
|
||||
describe("Scenario 5: Blocked result escalation", () => {
|
||||
const cleanup = new TestCleanup();
|
||||
let workspaceDir: string;
|
||||
let api: ReturnType<typeof makeTestApi>;
|
||||
let ctx: ReturnType<typeof makeTestContext>;
|
||||
|
||||
let issueId: number;
|
||||
|
||||
before(async () => {
|
||||
workspaceDir = await createTestWorkspace({ autoChain: false });
|
||||
api = makeTestApi();
|
||||
ctx = makeTestContext(TEST_GROUP_ID, workspaceDir);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await cleanup.cleanAll();
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("step 1: create issue, pick up as DEV, then block", async () => {
|
||||
// Create issue
|
||||
const createTool = createTaskCreateTool(api)(ctx);
|
||||
const createResult = parseToolResult(
|
||||
await createTool.execute("test", {
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
title: "[TEST] Scenario 5 — DEV blocked escalation",
|
||||
description: "Test that blocked DEV returns issue to queue.",
|
||||
label: "To Do",
|
||||
}),
|
||||
);
|
||||
issueId = (createResult.issue as any).id as number;
|
||||
cleanup.trackIssue(TEST_REPO, issueId);
|
||||
|
||||
// Pickup DEV
|
||||
const pickupTool = createTaskPickupTool(api)(ctx);
|
||||
const pickupResult = parseToolResult(
|
||||
await pickupTool.execute("test", {
|
||||
issueId,
|
||||
role: "dev",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
model: "junior",
|
||||
}),
|
||||
);
|
||||
assert.strictEqual(pickupResult.success, true, `Pickup should succeed: ${pickupResult.error ?? ""}`);
|
||||
|
||||
// Track session for cleanup
|
||||
const data = await readProjects(workspaceDir);
|
||||
const sessionKey = data.projects[TEST_GROUP_ID].dev.sessions.junior as string;
|
||||
if (sessionKey) cleanup.trackSession(sessionKey);
|
||||
|
||||
// Complete with blocked
|
||||
const completeTool = createTaskCompleteTool(api)(ctx);
|
||||
const completeResult = parseToolResult(
|
||||
await completeTool.execute("test", {
|
||||
role: "dev",
|
||||
result: "blocked",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
summary: "Cannot complete — missing dependencies",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(completeResult.success, true, `Blocked should succeed: ${completeResult.error ?? ""}`);
|
||||
assert.strictEqual(completeResult.labelTransition, "Doing → To Do", "Should revert to To Do");
|
||||
|
||||
// Verify side effect: issue label is back to "To Do"
|
||||
const labels = await getIssueLabels(TEST_REPO, issueId);
|
||||
assert.ok(
|
||||
labels.includes("To Do"),
|
||||
`Issue should have "To Do" label after DEV blocked, got: ${labels.join(", ")}`,
|
||||
);
|
||||
|
||||
// Verify side effect: worker deactivated
|
||||
const refreshedData = await readProjects(workspaceDir);
|
||||
const project = refreshedData.projects[TEST_GROUP_ID];
|
||||
assert.strictEqual(project.dev.active, false, "DEV worker should be inactive after blocked");
|
||||
});
|
||||
|
||||
it("step 2: pick up as QA, then block", async () => {
|
||||
// First do a DEV cycle to get to "To Test"
|
||||
const pickupTool = createTaskPickupTool(api)(ctx);
|
||||
await pickupTool.execute("test", {
|
||||
issueId,
|
||||
role: "dev",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
model: "junior",
|
||||
});
|
||||
|
||||
const completeTool = createTaskCompleteTool(api)(ctx);
|
||||
await completeTool.execute("test", {
|
||||
role: "dev",
|
||||
result: "done",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
summary: "DEV done",
|
||||
});
|
||||
|
||||
// Now pick up as QA
|
||||
const qaPickupResult = parseToolResult(
|
||||
await pickupTool.execute("test", {
|
||||
issueId,
|
||||
role: "qa",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
model: "qa",
|
||||
}),
|
||||
);
|
||||
assert.strictEqual(qaPickupResult.success, true, `QA pickup should succeed: ${qaPickupResult.error ?? ""}`);
|
||||
|
||||
// Track QA session for cleanup
|
||||
const data = await readProjects(workspaceDir);
|
||||
const qaSessionKey = data.projects[TEST_GROUP_ID].qa.sessions.qa as string;
|
||||
if (qaSessionKey) cleanup.trackSession(qaSessionKey);
|
||||
|
||||
// Complete QA with blocked
|
||||
const qaCompleteResult = parseToolResult(
|
||||
await completeTool.execute("test", {
|
||||
role: "qa",
|
||||
result: "blocked",
|
||||
projectGroupId: TEST_GROUP_ID,
|
||||
summary: "Cannot test — environment not available",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(qaCompleteResult.success, true, `QA blocked should succeed: ${qaCompleteResult.error ?? ""}`);
|
||||
assert.strictEqual(qaCompleteResult.labelTransition, "Testing → To Test", "Should revert to To Test");
|
||||
|
||||
// Verify side effect: issue label is back to "To Test"
|
||||
const labels = await getIssueLabels(TEST_REPO, issueId);
|
||||
assert.ok(
|
||||
labels.includes("To Test"),
|
||||
`Issue should have "To Test" label after QA blocked, got: ${labels.join(", ")}`,
|
||||
);
|
||||
|
||||
// Verify side effect: QA worker deactivated
|
||||
const refreshedData = await readProjects(workspaceDir);
|
||||
const project = refreshedData.projects[TEST_GROUP_ID];
|
||||
assert.strictEqual(project.qa.active, false, "QA worker should be inactive after blocked");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scenario 6: Model resolution ──────────────────────────────────────
|
||||
|
||||
describe("Scenario 6: Model resolution", () => {
|
||||
it("resolves tier names to correct model IDs", () => {
|
||||
assert.strictEqual(
|
||||
resolveModel("junior"),
|
||||
"anthropic/claude-haiku-4-5",
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveModel("medior"),
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveModel("senior"),
|
||||
"anthropic/claude-opus-4-5",
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveModel("qa"),
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects plugin config overrides", () => {
|
||||
assert.strictEqual(
|
||||
resolveModel("junior", { models: { junior: "custom/fast-model" } }),
|
||||
"custom/fast-model",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes through raw model IDs unchanged", () => {
|
||||
assert.strictEqual(
|
||||
resolveModel("openai/gpt-4o"),
|
||||
"openai/gpt-4o",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user