mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-06 01:54:11 +08:00
* feat(security): Secure dashboard server by default ## Solution Summary - Solution-ID: SOL-DSC-002-1 - Issue-ID: DSC-002 ## Tasks Completed - [T1] JWT token manager (24h expiry, persisted secret/token) - [T2] API auth middleware + localhost token endpoint - [T3] Default bind 127.0.0.1, add --host with warning - [T4] Localhost-only CORS with credentials + Vary - [T5] SECURITY.md documentation + README link ## Verification - npm run build - npm test -- ccw/tests/token-manager.test.ts ccw/tests/middleware.test.ts ccw/tests/server-auth.integration.test.ts ccw/tests/server.test.ts ccw/tests/cors.test.ts * fix(security): Prevent command injection in Windows spawn() ## Solution Summary - **Solution-ID**: SOL-DSC-001-1 - **Issue-ID**: DSC-001 - **Risk/Impact/Complexity**: high/high/medium ## Tasks Completed - [T1] Create Windows shell escape utility - [T2] Escape cli-executor spawn() args on Windows - [T3] Add command injection regression tests ## Files Modified - ccw/src/utils/shell-escape.ts - ccw/src/tools/cli-executor.ts - ccw/tests/shell-escape.test.ts - ccw/tests/security/command-injection.test.ts ## Verification - npm run build - npm test -- ccw/tests/shell-escape.test.ts ccw/tests/security/command-injection.test.ts * fix(security): Harden path validation (DSC-005) ## Solution Summary - Solution-ID: SOL-DSC-005-1 - Issue-ID: DSC-005 ## Tasks Completed - T1: Refactor path validation to pre-resolution checking - T2: Implement allowlist-based path validation - T3: Add path validation to API routes - T4: Add path security regression tests ## Files Modified - ccw/src/utils/path-resolver.ts - ccw/src/utils/path-validator.ts - ccw/src/core/routes/graph-routes.ts - ccw/src/core/routes/files-routes.ts - ccw/src/core/routes/skills-routes.ts - ccw/tests/path-resolver.test.ts - ccw/tests/graph-routes.test.ts - ccw/tests/files-routes.test.ts - ccw/tests/skills-routes.test.ts - ccw/tests/security/path-traversal.test.ts ## Verification - npm run build - npm test -- path-resolver.test.ts - npm test -- path-validator.test.ts - npm test -- graph-routes.test.ts - npm test -- files-routes.test.ts - npm test -- skills-routes.test.ts - npm test -- ccw/tests/security/path-traversal.test.ts * fix(security): Prevent credential leakage (DSC-004) ## Solution Summary - Solution-ID: SOL-DSC-004-1 - Issue-ID: DSC-004 ## Tasks Completed - T1: Create credential handling security tests - T2: Add log sanitization tests - T3: Add env var leakage prevention tests - T4: Add secure storage tests ## Files Modified - ccw/src/config/litellm-api-config-manager.ts - ccw/src/core/routes/litellm-api-routes.ts - ccw/tests/security/credential-handling.test.ts ## Verification - npm run build - node --experimental-strip-types --test ccw/tests/security/credential-handling.test.ts * test(ranking): expand normalize_weights edge case coverage (ISS-1766920108814-0) ## Solution Summary - Solution-ID: SOL-20251228113607 - Issue-ID: ISS-1766920108814-0 ## Tasks Completed - T1: Fix NaN and invalid total handling in normalize_weights - T2: Add unit tests for NaN edge cases in normalize_weights ## Files Modified - codex-lens/tests/test_rrf_fusion.py ## Verification - python -m pytest codex-lens/tests/test_rrf_fusion.py::TestNormalizeBM25Score -v - python -m pytest codex-lens/tests/test_rrf_fusion.py -v -k normalize - python -m pytest codex-lens/tests/test_rrf_fusion.py::TestReciprocalRankFusion::test_weight_normalization codex-lens/tests/test_cli_hybrid_search.py::TestCLIHybridSearch::test_weights_normalization -v * feat(security): Add CSRF protection and tighten CORS (DSC-006) ## Solution Summary - Solution-ID: SOL-DSC-006-1 - Issue-ID: DSC-006 - Risk/Impact/Complexity: high/high/medium ## Tasks Completed - T1: Create CSRF token generation system - T2: Add CSRF token endpoints - T3: Implement CSRF validation middleware - T4: Restrict CORS to trusted origins - T5: Add CSRF security tests ## Files Modified - ccw/src/core/auth/csrf-manager.ts - ccw/src/core/auth/csrf-middleware.ts - ccw/src/core/routes/auth-routes.ts - ccw/src/core/server.ts - ccw/tests/csrf-manager.test.ts - ccw/tests/auth-routes.test.ts - ccw/tests/csrf-middleware.test.ts - ccw/tests/security/csrf.test.ts ## Verification - npm run build - node --experimental-strip-types --test ccw/tests/csrf-manager.test.ts - node --experimental-strip-types --test ccw/tests/auth-routes.test.ts - node --experimental-strip-types --test ccw/tests/csrf-middleware.test.ts - node --experimental-strip-types --test ccw/tests/cors.test.ts - node --experimental-strip-types --test ccw/tests/security/csrf.test.ts * fix(cli-executor): prevent stale SIGKILL timeouts ## Solution Summary - Solution-ID: SOL-DSC-007-1 - Issue-ID: DSC-007 - Risk/Impact/Complexity: low/low/low ## Tasks Completed - [T1] Store timeout handle in killCurrentCliProcess ## Files Modified - ccw/src/tools/cli-executor.ts - ccw/tests/cli-executor-kill.test.ts ## Verification - node --experimental-strip-types --test ccw/tests/cli-executor-kill.test.ts * fix(cli-executor): enhance merge validation guards ## Solution Summary - Solution-ID: SOL-DSC-008-1 - Issue-ID: DSC-008 - Risk/Impact/Complexity: low/low/low ## Tasks Completed - [T1] Enhance sourceConversations array validation ## Files Modified - ccw/src/tools/cli-executor.ts - ccw/tests/cli-executor-merge-validation.test.ts ## Verification - node --experimental-strip-types --test ccw/tests/cli-executor-merge-validation.test.ts * refactor(core): remove @ts-nocheck from core routes ## Solution Summary - Solution-ID: SOL-DSC-003-1 - Issue-ID: DSC-003 - Queue-ID: QUE-20260106-164500 - Item-ID: S-9 ## Tasks Completed - T1: Create shared RouteContext type definition - T2: Remove @ts-nocheck from small route files - T3: Remove @ts-nocheck from medium route files - T4: Remove @ts-nocheck from large route files - T5: Remove @ts-nocheck from remaining core files ## Files Modified - ccw/src/core/dashboard-generator-patch.ts - ccw/src/core/dashboard-generator.ts - ccw/src/core/routes/ccw-routes.ts - ccw/src/core/routes/claude-routes.ts - ccw/src/core/routes/cli-routes.ts - ccw/src/core/routes/codexlens-routes.ts - ccw/src/core/routes/discovery-routes.ts - ccw/src/core/routes/files-routes.ts - ccw/src/core/routes/graph-routes.ts - ccw/src/core/routes/help-routes.ts - ccw/src/core/routes/hooks-routes.ts - ccw/src/core/routes/issue-routes.ts - ccw/src/core/routes/litellm-api-routes.ts - ccw/src/core/routes/litellm-routes.ts - ccw/src/core/routes/mcp-routes.ts - ccw/src/core/routes/mcp-routes.ts.backup - ccw/src/core/routes/mcp-templates-db.ts - ccw/src/core/routes/nav-status-routes.ts - ccw/src/core/routes/rules-routes.ts - ccw/src/core/routes/session-routes.ts - ccw/src/core/routes/skills-routes.ts - ccw/src/core/routes/status-routes.ts - ccw/src/core/routes/system-routes.ts - ccw/src/core/routes/types.ts - ccw/src/core/server.ts - ccw/src/core/websocket.ts ## Verification - npm run build - npm test * refactor: split cli-executor and codexlens routes into modules ## Solution Summary - Solution-ID: SOL-DSC-012-1 - Issue-ID: DSC-012 - Risk/Impact/Complexity: medium/medium/high ## Tasks Completed - [T1] Extract execution orchestration from cli-executor.ts (Refactor ccw/src/tools) - [T2] Extract route handlers from codexlens-routes.ts (Refactor ccw/src/core/routes) - [T3] Extract prompt concatenation logic from cli-executor (Refactor ccw/src/tools) - [T4] Document refactored module architecture (Docs) ## Files Modified - ccw/src/tools/cli-executor.ts - ccw/src/tools/cli-executor-core.ts - ccw/src/tools/cli-executor-utils.ts - ccw/src/tools/cli-executor-state.ts - ccw/src/tools/cli-prompt-builder.ts - ccw/src/tools/README.md - ccw/src/core/routes/codexlens-routes.ts - ccw/src/core/routes/codexlens/config-handlers.ts - ccw/src/core/routes/codexlens/index-handlers.ts - ccw/src/core/routes/codexlens/semantic-handlers.ts - ccw/src/core/routes/codexlens/watcher-handlers.ts - ccw/src/core/routes/codexlens/utils.ts - ccw/src/core/routes/codexlens/README.md ## Verification - npm run build - npm test * test(issue): Add comprehensive issue command tests ## Solution Summary - **Solution-ID**: SOL-DSC-009-1 - **Issue-ID**: DSC-009 - **Risk/Impact/Complexity**: low/high/medium ## Tasks Completed - [T1] Create issue command test file structure: Create isolated test harness - [T2] Add JSONL read/write operation tests: Verify JSONL correctness and errors - [T3] Add issue lifecycle tests: Verify status transitions and timestamps - [T4] Add solution binding tests: Verify binding flows and error cases - [T5] Add queue formation tests: Verify queue creation, IDs, and DAG behavior - [T6] Add queue execution tests: Verify next/done/retry and status sync ## Files Modified - ccw/src/commands/issue.ts - ccw/tests/issue-command.test.ts ## Verification - node --experimental-strip-types --test ccw/tests/issue-command.test.ts * test(routes): Add integration tests for route modules ## Solution Summary - Solution-ID: SOL-DSC-010-1 - Issue-ID: DSC-010 - Queue-ID: QUE-20260106-164500 ## Tasks Completed - [T1] Add tests for ccw-routes.ts - [T2] Add tests for files-routes.ts - [T3] Add tests for claude-routes.ts (includes Windows path fix for create) - [T4] Add tests for issue-routes.ts - [T5] Add tests for help-routes.ts (avoid hanging watchers) - [T6] Add tests for nav-status-routes.ts - [T7] Add tests for hooks/graph/rules/skills/litellm-api routes ## Files Modified - ccw/src/core/routes/claude-routes.ts - ccw/src/core/routes/help-routes.ts - ccw/tests/integration/ccw-routes.test.ts - ccw/tests/integration/claude-routes.test.ts - ccw/tests/integration/files-routes.test.ts - ccw/tests/integration/issue-routes.test.ts - ccw/tests/integration/help-routes.test.ts - ccw/tests/integration/nav-status-routes.test.ts - ccw/tests/integration/hooks-routes.test.ts - ccw/tests/integration/graph-routes.test.ts - ccw/tests/integration/rules-routes.test.ts - ccw/tests/integration/skills-routes.test.ts - ccw/tests/integration/litellm-api-routes.test.ts ## Verification - node --experimental-strip-types --test ccw/tests/integration/ccw-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/files-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/claude-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/issue-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/help-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/nav-status-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/hooks-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/graph-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/rules-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/skills-routes.test.ts - node --experimental-strip-types --test ccw/tests/integration/litellm-api-routes.test.ts * refactor(core): Switch cache and lite scanning to async fs ## Solution Summary - Solution-ID: SOL-DSC-013-1 - Issue-ID: DSC-013 - Queue-ID: QUE-20260106-164500 ## Tasks Completed - [T1] Convert cache-manager.ts to async file operations - [T2] Convert lite-scanner.ts to async file operations - [T3] Update cache-manager call sites to await async API - [T4] Update lite-scanner call sites to await async API ## Files Modified - ccw/src/core/cache-manager.ts - ccw/src/core/lite-scanner.ts - ccw/src/core/data-aggregator.ts ## Verification - npm run build - npm test * fix(exec): Add timeout protection for execSync ## Solution Summary - Solution-ID: SOL-DSC-014-1 - Issue-ID: DSC-014 - Queue-ID: QUE-20260106-164500 ## Tasks Completed - [T1] Add timeout to execSync calls in python-utils.ts - [T2] Add timeout to execSync calls in detect-changed-modules.ts - [T3] Add timeout to execSync calls in claude-freshness.ts - [T4] Add timeout to execSync calls in issue.ts - [T5] Consolidate execSync timeout constants and audit coverage ## Files Modified - ccw/src/utils/exec-constants.ts - ccw/src/utils/python-utils.ts - ccw/src/tools/detect-changed-modules.ts - ccw/src/core/claude-freshness.ts - ccw/src/commands/issue.ts - ccw/src/tools/smart-search.ts - ccw/src/tools/codex-lens.ts - ccw/src/core/routes/codexlens/config-handlers.ts ## Verification - npm run build - npm test - node --experimental-strip-types --test ccw/tests/issue-command.test.ts * feat(cli): Add progress spinner with elapsed time for long-running operations ## Solution Summary - Solution-ID: SOL-DSC-015-1 - Issue-ID: DSC-015 - Queue-Item: S-15 - Risk/Impact/Complexity: low/medium/low ## Tasks Completed - [T1] Add progress spinner to CLI execution: Update ccw/src/commands/cli.ts ## Files Modified - ccw/src/commands/cli.ts - ccw/tests/cli-command.test.ts ## Verification - node --experimental-strip-types --test ccw/tests/cli-command.test.ts - node --experimental-strip-types --test ccw/tests/cli-executor-kill.test.ts - node --experimental-strip-types --test ccw/tests/cli-executor-merge-validation.test.ts * fix(cli): Move full output hint immediately after truncation notice ## Solution Summary - Solution-ID: SOL-DSC-016-1 - Issue-ID: DSC-016 - Queue-Item: S-16 - Risk/Impact/Complexity: low/high/low ## Tasks Completed - [T1] Relocate output hint after truncation: Update ccw/src/commands/cli.ts ## Files Modified - ccw/src/commands/cli.ts - ccw/tests/cli-command.test.ts ## Verification - npm run build - node --experimental-strip-types --test ccw/tests/cli-command.test.ts * feat(cli): Add confirmation prompts for destructive operations ## Solution Summary - Solution-ID: SOL-DSC-017-1 - Issue-ID: DSC-017 - Queue-Item: S-17 - Risk/Impact/Complexity: low/high/low ## Tasks Completed - [T1] Add confirmation to storage clean operations: Update ccw/src/commands/cli.ts - [T2] Add confirmation to issue queue delete: Update ccw/src/commands/issue.ts ## Files Modified - ccw/src/commands/cli.ts - ccw/src/commands/issue.ts - ccw/tests/cli-command.test.ts - ccw/tests/issue-command.test.ts ## Verification - npm run build - node --experimental-strip-types --test ccw/tests/cli-command.test.ts - node --experimental-strip-types --test ccw/tests/issue-command.test.ts * feat(cli): Improve multi-line prompt guidance ## Solution Summary - Solution-ID: SOL-DSC-018-1 - Issue-ID: DSC-018 - Queue-Item: S-18 - Risk/Impact/Complexity: low/medium/low ## Tasks Completed - [T1] Update CLI help to emphasize --file option: Update ccw/src/commands/cli.ts - [T2] Add inline hint for multi-line detection: Update ccw/src/commands/cli.ts ## Files Modified - ccw/src/commands/cli.ts - ccw/tests/cli-command.test.ts ## Verification - npm run build - node --experimental-strip-types --test ccw/tests/cli-command.test.ts --------- Co-authored-by: catlog22 <catlog22@github.com>
1358 lines
48 KiB
TypeScript
1358 lines
48 KiB
TypeScript
/**
|
|
* Unit tests for issue command module (ccw issue)
|
|
*
|
|
* Notes:
|
|
* - Targets the runtime implementation shipped in `ccw/dist`.
|
|
* - Uses isolated temp directories to avoid touching the real `.workflow/` tree.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import inquirer from 'inquirer';
|
|
|
|
const issueCommandUrl = new URL('../dist/commands/issue.js', import.meta.url).href;
|
|
|
|
interface TestIssuesEnv {
|
|
projectDir: string;
|
|
workflowDir: string;
|
|
issuesDir: string;
|
|
solutionsDir: string;
|
|
queuesDir: string;
|
|
}
|
|
|
|
const ORIGINAL_CWD = process.cwd();
|
|
|
|
function setupTestIssuesDir(): TestIssuesEnv {
|
|
const projectDir = mkdtempSync(join(tmpdir(), 'ccw-issue-cmd-'));
|
|
const workflowDir = join(projectDir, '.workflow');
|
|
const issuesDir = join(workflowDir, 'issues');
|
|
const solutionsDir = join(issuesDir, 'solutions');
|
|
const queuesDir = join(issuesDir, 'queues');
|
|
|
|
mkdirSync(solutionsDir, { recursive: true });
|
|
mkdirSync(queuesDir, { recursive: true });
|
|
|
|
process.chdir(projectDir);
|
|
|
|
return { projectDir, workflowDir, issuesDir, solutionsDir, queuesDir };
|
|
}
|
|
|
|
function cleanupTestIssuesDir(env: TestIssuesEnv): void {
|
|
process.chdir(ORIGINAL_CWD);
|
|
rmSync(env.projectDir, { recursive: true, force: true });
|
|
}
|
|
|
|
type MockIssue = {
|
|
id: string;
|
|
title: string;
|
|
status: string;
|
|
priority: number;
|
|
context: string;
|
|
bound_solution_id: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
type MockSolution = {
|
|
id: string;
|
|
tasks: unknown[];
|
|
is_bound: boolean;
|
|
created_at: string;
|
|
bound_at?: string;
|
|
description?: string;
|
|
approach?: string;
|
|
exploration_context?: Record<string, unknown>;
|
|
analysis?: { risk?: string; impact?: string; complexity?: string };
|
|
score?: number;
|
|
};
|
|
|
|
function createMockIssue(overrides: Partial<MockIssue> = {}): MockIssue {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
id: overrides.id ?? 'ISS-TEST-001',
|
|
title: overrides.title ?? 'Test issue',
|
|
status: overrides.status ?? 'registered',
|
|
priority: overrides.priority ?? 3,
|
|
context: overrides.context ?? 'Test context',
|
|
bound_solution_id: overrides.bound_solution_id ?? null,
|
|
created_at: overrides.created_at ?? now,
|
|
updated_at: overrides.updated_at ?? now,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createMockSolution(overrides: Partial<MockSolution> = {}): MockSolution {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
id: overrides.id ?? 'SOL-ISS-TEST-001-1',
|
|
tasks: overrides.tasks ?? [],
|
|
is_bound: overrides.is_bound ?? false,
|
|
created_at: overrides.created_at ?? now,
|
|
bound_at: overrides.bound_at,
|
|
description: overrides.description,
|
|
approach: overrides.approach,
|
|
exploration_context: overrides.exploration_context,
|
|
analysis: overrides.analysis,
|
|
score: overrides.score,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function readJsonl(path: string): any[] {
|
|
if (!existsSync(path)) return [];
|
|
return readFileSync(path, 'utf8')
|
|
.split('\n')
|
|
.filter((line) => line.trim().length > 0)
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
|
|
class ExitError extends Error {
|
|
code?: number;
|
|
|
|
constructor(code?: number) {
|
|
super(`process.exit(${code ?? 'undefined'})`);
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
async function expectProcessExit(fn: () => Promise<unknown>, code = 1): Promise<void> {
|
|
mock.method(process as any, 'exit', (exitCode?: number) => {
|
|
throw new ExitError(exitCode);
|
|
});
|
|
|
|
await assert.rejects(
|
|
fn(),
|
|
(err: any) => err instanceof ExitError && err.code === code,
|
|
);
|
|
}
|
|
|
|
describe('issue command module', async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let issueModule: any;
|
|
let env: TestIssuesEnv | null = null;
|
|
|
|
beforeEach(() => {
|
|
mock.restoreAll();
|
|
env = setupTestIssuesDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (env) cleanupTestIssuesDir(env);
|
|
env = null;
|
|
mock.restoreAll();
|
|
});
|
|
|
|
it('setup/teardown creates isolated temp directories', () => {
|
|
assert.ok(env);
|
|
assert.ok(existsSync(env.workflowDir));
|
|
assert.ok(existsSync(env.issuesDir));
|
|
assert.ok(existsSync(env.solutionsDir));
|
|
assert.ok(existsSync(env.queuesDir));
|
|
assert.ok(resolve(process.cwd()).startsWith(resolve(env.projectDir)));
|
|
});
|
|
|
|
it('mock generators produce schema-shaped objects', () => {
|
|
const issue = createMockIssue();
|
|
assert.equal(typeof issue.id, 'string');
|
|
assert.equal(typeof issue.title, 'string');
|
|
assert.equal(typeof issue.status, 'string');
|
|
assert.equal(typeof issue.priority, 'number');
|
|
assert.equal(typeof issue.context, 'string');
|
|
assert.ok(issue.created_at);
|
|
assert.ok(issue.updated_at);
|
|
assert.equal(issue.bound_solution_id, null);
|
|
|
|
const solution = createMockSolution();
|
|
assert.equal(typeof solution.id, 'string');
|
|
assert.ok(Array.isArray(solution.tasks));
|
|
assert.equal(typeof solution.is_bound, 'boolean');
|
|
assert.ok(solution.created_at);
|
|
});
|
|
|
|
it('writes issue data under the temp .workflow directory', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
|
|
assert.ok(env);
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: 'ISS-TEST-WRITE' })]);
|
|
const issuesJsonlPath = join(env.issuesDir, 'issues.jsonl');
|
|
assert.ok(existsSync(issuesJsonlPath));
|
|
assert.match(readFileSync(issuesJsonlPath, 'utf8'), /ISS-TEST-WRITE/);
|
|
});
|
|
|
|
describe('JSONL Operations', () => {
|
|
it('readIssues returns [] when issues.jsonl is missing', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.deepEqual(issueModule.readIssues(), []);
|
|
});
|
|
|
|
it('writeIssues writes newline-delimited JSON with trailing newline', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
issueModule.writeIssues([
|
|
createMockIssue({ id: 'ISS-JSONL-1' }),
|
|
createMockIssue({ id: 'ISS-JSONL-2' }),
|
|
]);
|
|
|
|
const issuesJsonlPath = join(env.issuesDir, 'issues.jsonl');
|
|
const content = readFileSync(issuesJsonlPath, 'utf8');
|
|
assert.ok(content.endsWith('\n'));
|
|
|
|
const lines = content.split('\n').filter((line) => line.trim().length > 0);
|
|
assert.equal(lines.length, 2);
|
|
assert.deepEqual(lines.map((l) => JSON.parse(l).id), ['ISS-JSONL-1', 'ISS-JSONL-2']);
|
|
assert.deepEqual(issueModule.readIssues().map((i: any) => i.id), ['ISS-JSONL-1', 'ISS-JSONL-2']);
|
|
});
|
|
|
|
it('readIssues returns [] for corrupted JSONL', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
writeFileSync(join(env.issuesDir, 'issues.jsonl'), '{bad json}\n', 'utf8');
|
|
assert.deepEqual(issueModule.readIssues(), []);
|
|
});
|
|
|
|
it('readIssues returns [] when issues.jsonl is a directory', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mkdirSync(join(env.issuesDir, 'issues.jsonl'), { recursive: true });
|
|
assert.deepEqual(issueModule.readIssues(), []);
|
|
});
|
|
|
|
it('writeIssues throws when issues.jsonl is a directory', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mkdirSync(join(env.issuesDir, 'issues.jsonl'), { recursive: true });
|
|
assert.throws(() => issueModule.writeIssues([createMockIssue({ id: 'ISS-WRITE-ERR' })]));
|
|
});
|
|
|
|
it('readSolutions returns [] when solution JSONL is missing', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.deepEqual(issueModule.readSolutions('ISS-NO-SOL'), []);
|
|
});
|
|
|
|
it('writeSolutions writes newline-delimited JSON with trailing newline', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
issueModule.writeSolutions('ISS-SOL-1', [
|
|
createMockSolution({ id: 'SOL-ISS-SOL-1-1' }),
|
|
createMockSolution({ id: 'SOL-ISS-SOL-1-2' }),
|
|
]);
|
|
|
|
const solutionsPath = join(env.solutionsDir, 'ISS-SOL-1.jsonl');
|
|
const content = readFileSync(solutionsPath, 'utf8');
|
|
assert.ok(content.endsWith('\n'));
|
|
|
|
const lines = content.split('\n').filter((line) => line.trim().length > 0);
|
|
assert.equal(lines.length, 2);
|
|
assert.deepEqual(lines.map((l) => JSON.parse(l).id), ['SOL-ISS-SOL-1-1', 'SOL-ISS-SOL-1-2']);
|
|
assert.deepEqual(issueModule.readSolutions('ISS-SOL-1').map((s: any) => s.id), ['SOL-ISS-SOL-1-1', 'SOL-ISS-SOL-1-2']);
|
|
});
|
|
|
|
it('writeSolutions overwrites with full list (append via read->push->write)', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
issueModule.writeSolutions('ISS-SOL-APPEND', [createMockSolution({ id: 'SOL-ISS-SOL-APPEND-1' })]);
|
|
issueModule.writeSolutions('ISS-SOL-APPEND', [
|
|
createMockSolution({ id: 'SOL-ISS-SOL-APPEND-1' }),
|
|
createMockSolution({ id: 'SOL-ISS-SOL-APPEND-2' }),
|
|
]);
|
|
|
|
const ids = issueModule.readSolutions('ISS-SOL-APPEND').map((s: any) => s.id);
|
|
assert.deepEqual(ids, ['SOL-ISS-SOL-APPEND-1', 'SOL-ISS-SOL-APPEND-2']);
|
|
});
|
|
|
|
it('readSolutions returns [] for corrupted JSONL', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
writeFileSync(join(env.solutionsDir, 'ISS-SOL-BAD.jsonl'), '{bad json}\n', 'utf8');
|
|
assert.deepEqual(issueModule.readSolutions('ISS-SOL-BAD'), []);
|
|
});
|
|
|
|
it('writeSolutions throws when target path is a directory', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mkdirSync(join(env.solutionsDir, 'ISS-SOL-DIR.jsonl'), { recursive: true });
|
|
assert.throws(() => issueModule.writeSolutions('ISS-SOL-DIR', [createMockSolution({ id: 'SOL-X' })]));
|
|
});
|
|
});
|
|
|
|
describe('Issue Lifecycle', () => {
|
|
it('transitions registered → planning → planned → queued → executing → completed', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
mock.method(console, 'warn', () => {});
|
|
|
|
const issueId = 'ISS-LC-1';
|
|
const solutionId = 'SOL-ISS-LC-1-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'registered' })]);
|
|
issueModule.writeSolutions(issueId, [createMockSolution({ id: solutionId, is_bound: false })]);
|
|
|
|
await issueModule.issueCommand('update', [issueId], { status: 'planning' });
|
|
assert.equal(issueModule.readIssues().find((i: any) => i.id === issueId)?.status, 'planning');
|
|
|
|
await issueModule.issueCommand('bind', [issueId, solutionId], {});
|
|
const planned = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(planned?.status, 'planned');
|
|
assert.equal(planned?.bound_solution_id, solutionId);
|
|
assert.ok(planned?.planned_at);
|
|
|
|
await issueModule.issueCommand('queue', ['add', issueId], {});
|
|
const queued = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(queued?.status, 'queued');
|
|
assert.ok(queued?.queued_at);
|
|
|
|
await issueModule.issueCommand('next', [], {});
|
|
const executing = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(executing?.status, 'executing');
|
|
|
|
const queue = issueModule.readQueue();
|
|
assert.ok(queue);
|
|
const itemId = (queue.solutions || queue.tasks || [])[0]?.item_id;
|
|
assert.equal(itemId, 'S-1');
|
|
|
|
await issueModule.issueCommand('done', [itemId], {});
|
|
|
|
// Completed issues are auto-moved to history.
|
|
assert.equal(issueModule.readIssues().some((i: any) => i.id === issueId), false);
|
|
const history = readJsonl(join(env.issuesDir, 'issue-history.jsonl'));
|
|
const completed = history.find((i: any) => i.id === issueId);
|
|
assert.equal(completed?.status, 'completed');
|
|
assert.ok(completed?.completed_at);
|
|
});
|
|
|
|
it('transitions executing → failed when done is called with --fail', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
mock.method(console, 'warn', () => {});
|
|
|
|
const issueId = 'ISS-LC-FAIL';
|
|
const solutionId = 'SOL-ISS-LC-FAIL-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'registered' })]);
|
|
issueModule.writeSolutions(issueId, [createMockSolution({ id: solutionId, is_bound: true })]);
|
|
|
|
// Directly queue (already bound)
|
|
await issueModule.issueCommand('queue', ['add', issueId], {});
|
|
await issueModule.issueCommand('next', [], {});
|
|
|
|
const queue = issueModule.readQueue();
|
|
assert.ok(queue);
|
|
const itemId = (queue.solutions || queue.tasks || [])[0]?.item_id;
|
|
|
|
await issueModule.issueCommand('done', [itemId], { fail: true, reason: 'boom' });
|
|
|
|
const failed = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(failed?.status, 'failed');
|
|
|
|
const updatedQueue = issueModule.readQueue(queue.id);
|
|
const updatedItem = (updatedQueue?.solutions || updatedQueue?.tasks || []).find((i: any) => i.item_id === itemId);
|
|
assert.equal(updatedItem?.status, 'failed');
|
|
assert.ok(updatedItem?.completed_at);
|
|
assert.equal(updatedItem?.failure_reason, 'boom');
|
|
});
|
|
|
|
it('update sets planned_at when status is set to planned', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-UPD-PLANNED';
|
|
const oldUpdatedAt = '2000-01-01T00:00:00.000Z';
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planning', updated_at: oldUpdatedAt })]);
|
|
|
|
await issueModule.issueCommand('update', [issueId], { status: 'planned' });
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.status, 'planned');
|
|
assert.ok(issue?.planned_at);
|
|
assert.notEqual(issue?.updated_at, oldUpdatedAt);
|
|
});
|
|
|
|
it('update sets queued_at when status is set to queued', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-UPD-QUEUED';
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planned' })]);
|
|
|
|
await issueModule.issueCommand('update', [issueId], { status: 'queued' });
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.status, 'queued');
|
|
assert.ok(issue?.queued_at);
|
|
});
|
|
|
|
it('update rejects invalid status values', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-UPD-BAD';
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'registered' })]);
|
|
|
|
await expectProcessExit(() => issueModule.issueCommand('update', [issueId], { status: 'not-a-status' }), 1);
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.status, 'registered');
|
|
});
|
|
|
|
it('update to completed moves issue to history', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-UPD-COMPLETE';
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'executing' })]);
|
|
|
|
await issueModule.issueCommand('update', [issueId], { status: 'completed' });
|
|
|
|
assert.equal(issueModule.readIssues().some((i: any) => i.id === issueId), false);
|
|
const history = readJsonl(join(env.issuesDir, 'issue-history.jsonl'));
|
|
const completed = history.find((i: any) => i.id === issueId);
|
|
assert.equal(completed?.status, 'completed');
|
|
assert.ok(completed?.completed_at);
|
|
});
|
|
|
|
it('queue add fails when issue has no bound solution', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-QUEUE-NO-SOL';
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planned' })]);
|
|
|
|
await expectProcessExit(() => issueModule.issueCommand('queue', ['add', issueId], {}), 1);
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.status, 'planned');
|
|
});
|
|
|
|
it('next returns empty when no active queues exist', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const logs: string[] = [];
|
|
mock.method(console, 'log', (...args: any[]) => {
|
|
logs.push(args.map(String).join(' '));
|
|
});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
await issueModule.issueCommand('next', [], {});
|
|
|
|
const payload = JSON.parse(logs.at(-1) || '{}');
|
|
assert.equal(payload.status, 'empty');
|
|
assert.match(payload.message, /No active queues/);
|
|
});
|
|
});
|
|
|
|
describe('Solution Binding', () => {
|
|
it('binds a solution and marks the issue as planned', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-BIND-1';
|
|
const solutionId = 'SOL-ISS-BIND-1-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planning' })]);
|
|
issueModule.writeSolutions(issueId, [createMockSolution({ id: solutionId, is_bound: false })]);
|
|
|
|
await issueModule.issueCommand('bind', [issueId, solutionId], {});
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.status, 'planned');
|
|
assert.equal(issue?.bound_solution_id, solutionId);
|
|
assert.ok(issue?.planned_at);
|
|
|
|
const solutions = issueModule.readSolutions(issueId);
|
|
assert.equal(solutions.length, 1);
|
|
assert.equal(solutions[0].id, solutionId);
|
|
assert.equal(solutions[0].is_bound, true);
|
|
assert.ok(solutions[0].bound_at);
|
|
});
|
|
|
|
it('binding a second solution unbinds the previous one', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-BIND-2';
|
|
const sol1 = 'SOL-ISS-BIND-2-1';
|
|
const sol2 = 'SOL-ISS-BIND-2-2';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planning' })]);
|
|
issueModule.writeSolutions(issueId, [
|
|
createMockSolution({ id: sol1, is_bound: false }),
|
|
createMockSolution({ id: sol2, is_bound: false }),
|
|
]);
|
|
|
|
await issueModule.issueCommand('bind', [issueId, sol1], {});
|
|
await issueModule.issueCommand('bind', [issueId, sol2], {});
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.bound_solution_id, sol2);
|
|
assert.equal(issue?.status, 'planned');
|
|
|
|
const solutions = issueModule.readSolutions(issueId);
|
|
const bound = solutions.filter((s: any) => s.is_bound);
|
|
assert.equal(bound.length, 1);
|
|
assert.equal(bound[0].id, sol2);
|
|
assert.equal(solutions.find((s: any) => s.id === sol1)?.is_bound, false);
|
|
});
|
|
|
|
it('bind fails when the requested solution does not exist', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-BIND-ERR';
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planning' })]);
|
|
issueModule.writeSolutions(issueId, [createMockSolution({ id: 'SOL-ISS-BIND-ERR-1', is_bound: false })]);
|
|
|
|
await expectProcessExit(() => issueModule.issueCommand('bind', [issueId, 'SOL-NOT-FOUND'], {}), 1);
|
|
});
|
|
|
|
it('bind fails when issue does not exist', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
await expectProcessExit(() => issueModule.issueCommand('bind', ['ISS-NOT-FOUND', 'SOL-ISS-NOT-FOUND-1'], {}), 1);
|
|
});
|
|
|
|
it('bind lists available solutions when solution id is omitted', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const logs: string[] = [];
|
|
mock.method(console, 'log', (...args: any[]) => {
|
|
logs.push(args.map(String).join(' '));
|
|
});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-BIND-LIST';
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planning' })]);
|
|
issueModule.writeSolutions(issueId, [
|
|
createMockSolution({ id: 'SOL-ISS-BIND-LIST-1', is_bound: false }),
|
|
createMockSolution({ id: 'SOL-ISS-BIND-LIST-2', is_bound: false }),
|
|
]);
|
|
|
|
await issueModule.issueCommand('bind', [issueId], {});
|
|
|
|
const output = logs.join('\n');
|
|
assert.match(output, new RegExp(`Solutions for ${issueId}`));
|
|
assert.match(output, /SOL-ISS-BIND-LIST-1/);
|
|
assert.match(output, /SOL-ISS-BIND-LIST-2/);
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.bound_solution_id, null);
|
|
assert.equal(issue?.status, 'planning');
|
|
});
|
|
|
|
it('bind --solution registers and binds a solution file', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-BIND-FILE';
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planning' })]);
|
|
|
|
const solutionPath = join(env.projectDir, 'solution.json');
|
|
writeFileSync(solutionPath, JSON.stringify({ description: 'From file', tasks: [{ id: 'T1' }] }), 'utf8');
|
|
|
|
await issueModule.issueCommand('bind', [issueId], { solution: solutionPath });
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.status, 'planned');
|
|
assert.ok(issue?.bound_solution_id);
|
|
assert.match(issue.bound_solution_id, new RegExp(`^SOL-${issueId}-\\d+$`));
|
|
|
|
const solutions = issueModule.readSolutions(issueId);
|
|
assert.equal(solutions.length, 1);
|
|
assert.equal(solutions[0].id, issue.bound_solution_id);
|
|
assert.equal(solutions[0].is_bound, true);
|
|
assert.ok(solutions[0].bound_at);
|
|
assert.equal(Array.isArray(solutions[0].tasks), true);
|
|
assert.equal(solutions[0].tasks.length, 1);
|
|
});
|
|
});
|
|
|
|
describe('Queue Formation', () => {
|
|
function makeSolutionWithFiles(id: string, files: string[], isBound = true): MockSolution {
|
|
return createMockSolution({
|
|
id,
|
|
is_bound: isBound,
|
|
tasks: [
|
|
{
|
|
id: 'T1',
|
|
modification_points: files.map((file) => ({ file, target: 'x', change: 'y' })),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
it('creates an active queue with a solution-level item', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-QUEUE-1';
|
|
const solutionId = 'SOL-ISS-QUEUE-1-1';
|
|
const files = ['src/a.ts', 'src/b.ts'];
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planned', bound_solution_id: solutionId })]);
|
|
issueModule.writeSolutions(issueId, [makeSolutionWithFiles(solutionId, files, true)]);
|
|
|
|
await issueModule.issueCommand('queue', ['add', issueId], {});
|
|
|
|
const queue = issueModule.readQueue();
|
|
assert.ok(queue);
|
|
assert.ok(typeof queue.id === 'string' && queue.id.startsWith('QUE-'));
|
|
assert.equal(queue.status, 'active');
|
|
assert.ok(queue.issue_ids.includes(issueId));
|
|
|
|
const items = queue.solutions || [];
|
|
assert.equal(items.length, 1);
|
|
assert.equal(items[0].item_id, 'S-1');
|
|
assert.equal(items[0].issue_id, issueId);
|
|
assert.equal(items[0].solution_id, solutionId);
|
|
assert.equal(items[0].status, 'pending');
|
|
assert.equal(items[0].execution_order, 1);
|
|
assert.equal(items[0].execution_group, 'P1');
|
|
assert.deepEqual(items[0].files_touched?.sort(), files.slice().sort());
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.status, 'queued');
|
|
});
|
|
|
|
it('generates queue IDs in QUE-YYYYMMDDHHMMSS format', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-QUEUE-ID';
|
|
const solutionId = 'SOL-ISS-QUEUE-ID-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planned', bound_solution_id: solutionId })]);
|
|
issueModule.writeSolutions(issueId, [makeSolutionWithFiles(solutionId, ['src/a.ts'], true)]);
|
|
|
|
await issueModule.issueCommand('queue', ['add', issueId], {});
|
|
|
|
const queue = issueModule.readQueue();
|
|
assert.ok(queue);
|
|
assert.match(queue.id, /^QUE-\d{14}$/);
|
|
});
|
|
|
|
it('does not add duplicate solution items to the queue', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-QUEUE-DUPE';
|
|
const solutionId = 'SOL-ISS-QUEUE-DUPE-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planned', bound_solution_id: solutionId })]);
|
|
issueModule.writeSolutions(issueId, [makeSolutionWithFiles(solutionId, ['src/a.ts'], true)]);
|
|
|
|
await issueModule.issueCommand('queue', ['add', issueId], {});
|
|
await issueModule.issueCommand('queue', ['add', issueId], {});
|
|
|
|
const queue = issueModule.readQueue();
|
|
assert.ok(queue);
|
|
const items = queue.solutions || [];
|
|
assert.equal(items.length, 1);
|
|
assert.equal(items[0].issue_id, issueId);
|
|
assert.equal(items[0].solution_id, solutionId);
|
|
});
|
|
|
|
it('deduplicates files_touched extracted from modification_points', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issueId = 'ISS-QUEUE-FILES';
|
|
const solutionId = 'SOL-ISS-QUEUE-FILES-1';
|
|
const files = ['src/dup.ts', 'src/dup.ts', 'src/other.ts', 'src/dup.ts'];
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planned', bound_solution_id: solutionId })]);
|
|
issueModule.writeSolutions(issueId, [makeSolutionWithFiles(solutionId, files, true)]);
|
|
|
|
await issueModule.issueCommand('queue', ['add', issueId], {});
|
|
|
|
const queue = issueModule.readQueue();
|
|
assert.ok(queue);
|
|
const items = queue.solutions || [];
|
|
assert.equal(items.length, 1);
|
|
assert.deepEqual(items[0].files_touched?.sort(), ['src/dup.ts', 'src/other.ts']);
|
|
});
|
|
|
|
it('adds multiple issues to the same active queue with incrementing item IDs', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issue1 = 'ISS-QUEUE-M-1';
|
|
const issue2 = 'ISS-QUEUE-M-2';
|
|
|
|
issueModule.writeIssues([
|
|
createMockIssue({ id: issue1, status: 'planned' }),
|
|
createMockIssue({ id: issue2, status: 'planned' }),
|
|
]);
|
|
issueModule.writeSolutions(issue1, [makeSolutionWithFiles('SOL-ISS-QUEUE-M-1-1', ['src/one.ts'], true)]);
|
|
issueModule.writeSolutions(issue2, [makeSolutionWithFiles('SOL-ISS-QUEUE-M-2-1', ['src/two.ts'], true)]);
|
|
|
|
await issueModule.issueCommand('queue', ['add', issue1], {});
|
|
await issueModule.issueCommand('queue', ['add', issue2], {});
|
|
|
|
const queue = issueModule.readQueue();
|
|
assert.ok(queue);
|
|
const items = queue.solutions || [];
|
|
assert.equal(items.length, 2);
|
|
assert.deepEqual(items.map((i: any) => i.item_id), ['S-1', 'S-2']);
|
|
assert.deepEqual(items.map((i: any) => i.execution_order), [1, 2]);
|
|
assert.ok(queue.issue_ids.includes(issue1));
|
|
assert.ok(queue.issue_ids.includes(issue2));
|
|
});
|
|
|
|
it('queue dag batches non-conflicting items together', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const logs: string[] = [];
|
|
mock.method(console, 'log', (...args: any[]) => {
|
|
logs.push(args.map(String).join(' '));
|
|
});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const issue1 = 'ISS-DAG-1';
|
|
const issue2 = 'ISS-DAG-2';
|
|
issueModule.writeIssues([createMockIssue({ id: issue1 }), createMockIssue({ id: issue2 })]);
|
|
issueModule.writeSolutions(issue1, [makeSolutionWithFiles('SOL-ISS-DAG-1-1', ['src/a.ts'], true)]);
|
|
issueModule.writeSolutions(issue2, [makeSolutionWithFiles('SOL-ISS-DAG-2-1', ['src/b.ts'], true)]);
|
|
|
|
await issueModule.issueCommand('queue', ['add', issue1], {});
|
|
await issueModule.issueCommand('queue', ['add', issue2], {});
|
|
|
|
logs.length = 0;
|
|
await issueModule.issueCommand('queue', ['dag'], {});
|
|
|
|
const payload = JSON.parse(logs.at(-1) || '{}');
|
|
assert.deepEqual(payload.parallel_batches, [['S-1', 'S-2']]);
|
|
assert.equal(payload._summary.batches_needed, 1);
|
|
});
|
|
|
|
it('queue dag separates conflicting items into multiple batches', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const logs: string[] = [];
|
|
mock.method(console, 'log', (...args: any[]) => {
|
|
logs.push(args.map(String).join(' '));
|
|
});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const shared = 'src/shared.ts';
|
|
const issue1 = 'ISS-DAG-C-1';
|
|
const issue2 = 'ISS-DAG-C-2';
|
|
issueModule.writeIssues([createMockIssue({ id: issue1 }), createMockIssue({ id: issue2 })]);
|
|
issueModule.writeSolutions(issue1, [makeSolutionWithFiles('SOL-ISS-DAG-C-1-1', [shared], true)]);
|
|
issueModule.writeSolutions(issue2, [makeSolutionWithFiles('SOL-ISS-DAG-C-2-1', [shared], true)]);
|
|
|
|
await issueModule.issueCommand('queue', ['add', issue1], {});
|
|
await issueModule.issueCommand('queue', ['add', issue2], {});
|
|
|
|
logs.length = 0;
|
|
await issueModule.issueCommand('queue', ['dag'], {});
|
|
|
|
const payload = JSON.parse(logs.at(-1) || '{}');
|
|
assert.equal(payload.parallel_batches.length, 2);
|
|
assert.deepEqual(payload.parallel_batches[0], ['S-1']);
|
|
assert.deepEqual(payload.parallel_batches[1], ['S-2']);
|
|
});
|
|
|
|
it('queue dag builds edges for depends_on and marks blocked items', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const logs: string[] = [];
|
|
mock.method(console, 'log', (...args: any[]) => {
|
|
logs.push(args.map(String).join(' '));
|
|
});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const queueId = 'QUE-20260107000000';
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'active',
|
|
issue_ids: ['ISS-DEP'],
|
|
tasks: [],
|
|
solutions: [
|
|
{
|
|
item_id: 'S-1',
|
|
issue_id: 'ISS-DEP',
|
|
solution_id: 'SOL-ISS-DEP-1',
|
|
status: 'pending',
|
|
execution_order: 1,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
files_touched: ['src/a.ts'],
|
|
task_count: 1,
|
|
},
|
|
{
|
|
item_id: 'S-2',
|
|
issue_id: 'ISS-DEP',
|
|
solution_id: 'SOL-ISS-DEP-2',
|
|
status: 'pending',
|
|
execution_order: 2,
|
|
execution_group: 'P1',
|
|
depends_on: ['S-1'],
|
|
semantic_priority: 0.5,
|
|
files_touched: ['src/b.ts'],
|
|
task_count: 1,
|
|
},
|
|
],
|
|
conflicts: [],
|
|
});
|
|
|
|
await issueModule.issueCommand('queue', ['dag', queueId], {});
|
|
const payload = JSON.parse(logs.at(-1) || '{}');
|
|
|
|
assert.deepEqual(payload.edges, [{ from: 'S-1', to: 'S-2' }]);
|
|
const node1 = payload.nodes.find((n: any) => n.id === 'S-1');
|
|
const node2 = payload.nodes.find((n: any) => n.id === 'S-2');
|
|
assert.equal(node1.ready, true);
|
|
assert.equal(node2.ready, false);
|
|
assert.deepEqual(node2.blocked_by, ['S-1']);
|
|
assert.deepEqual(payload.parallel_batches, [['S-1']]);
|
|
});
|
|
|
|
it('prompts for confirmation before deleting a queue (and cancels safely)', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const logs: string[] = [];
|
|
mock.method(console, 'log', (...args: any[]) => {
|
|
logs.push(args.map(String).join(' '));
|
|
});
|
|
mock.method(console, 'error', (...args: any[]) => {
|
|
logs.push(args.map(String).join(' '));
|
|
});
|
|
|
|
const queueId = 'QUE-DELETE-CANCEL';
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'completed',
|
|
issue_ids: [],
|
|
tasks: [],
|
|
solutions: [],
|
|
conflicts: [],
|
|
});
|
|
|
|
const promptCalls: any[] = [];
|
|
mock.method(inquirer, 'prompt', async (questions: any) => {
|
|
promptCalls.push(questions);
|
|
return { proceed: false };
|
|
});
|
|
|
|
await issueModule.issueCommand('queue', ['delete', queueId], {});
|
|
|
|
assert.equal(promptCalls.length, 1);
|
|
assert.equal(promptCalls[0][0].type, 'confirm');
|
|
assert.equal(promptCalls[0][0].default, false);
|
|
assert.ok(promptCalls[0][0].message.includes(queueId));
|
|
assert.ok(logs.some((l) => l.includes('Queue deletion cancelled')));
|
|
assert.ok(existsSync(join(env.queuesDir, `${queueId}.json`)));
|
|
});
|
|
|
|
it('deletes a queue after interactive confirmation', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const queueId = 'QUE-DELETE-CONFIRM';
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'completed',
|
|
issue_ids: [],
|
|
tasks: [],
|
|
solutions: [],
|
|
conflicts: [],
|
|
});
|
|
|
|
mock.method(inquirer, 'prompt', async () => ({ proceed: true }));
|
|
|
|
await issueModule.issueCommand('queue', ['delete', queueId], {});
|
|
|
|
assert.equal(existsSync(join(env.queuesDir, `${queueId}.json`)), false);
|
|
});
|
|
|
|
it('bypasses confirmation prompt when --force is set for queue delete', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const queueId = 'QUE-DELETE-FORCE';
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'completed',
|
|
issue_ids: [],
|
|
tasks: [],
|
|
solutions: [],
|
|
conflicts: [],
|
|
});
|
|
|
|
mock.method(inquirer, 'prompt', async () => {
|
|
throw new Error('inquirer.prompt should not be called when --force is set');
|
|
});
|
|
|
|
await issueModule.issueCommand('queue', ['delete', queueId], { force: true });
|
|
|
|
assert.equal(existsSync(join(env.queuesDir, `${queueId}.json`)), false);
|
|
});
|
|
});
|
|
|
|
describe('Queue Execution', () => {
|
|
async function runNext(queueId: string): Promise<any> {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const logs: string[] = [];
|
|
mock.method(console, 'log', (...args: any[]) => {
|
|
logs.push(args.map(String).join(' '));
|
|
});
|
|
mock.method(console, 'error', () => {});
|
|
mock.method(console, 'warn', () => {});
|
|
|
|
await issueModule.issueCommand('next', [], { queue: queueId });
|
|
return JSON.parse(logs.at(-1) || '{}');
|
|
}
|
|
|
|
it('next respects dependencies and advances after done()', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
mock.method(console, 'warn', () => {});
|
|
|
|
const queueId = 'QUE-20260107010101';
|
|
const issue1 = 'ISS-NEXT-1';
|
|
const issue2 = 'ISS-NEXT-2';
|
|
const sol1 = 'SOL-ISS-NEXT-1-1';
|
|
const sol2 = 'SOL-ISS-NEXT-2-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issue1, status: 'queued' }), createMockIssue({ id: issue2, status: 'queued' })]);
|
|
issueModule.writeSolutions(issue1, [createMockSolution({ id: sol1, is_bound: false })]);
|
|
issueModule.writeSolutions(issue2, [createMockSolution({ id: sol2, is_bound: false })]);
|
|
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'active',
|
|
issue_ids: [issue1, issue2],
|
|
tasks: [],
|
|
solutions: [
|
|
{
|
|
item_id: 'S-1',
|
|
issue_id: issue1,
|
|
solution_id: sol1,
|
|
status: 'pending',
|
|
execution_order: 1,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
task_count: 1,
|
|
},
|
|
{
|
|
item_id: 'S-2',
|
|
issue_id: issue2,
|
|
solution_id: sol2,
|
|
status: 'pending',
|
|
execution_order: 2,
|
|
execution_group: 'P1',
|
|
depends_on: ['S-1'],
|
|
semantic_priority: 0.5,
|
|
task_count: 1,
|
|
},
|
|
],
|
|
conflicts: [],
|
|
});
|
|
|
|
mock.restoreAll();
|
|
const first = await runNext(queueId);
|
|
assert.equal(first.item_id, 'S-1');
|
|
|
|
// Mark S-1 complete so S-2 becomes ready.
|
|
mock.restoreAll();
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
await issueModule.issueCommand('done', ['S-1'], { queue: queueId });
|
|
|
|
mock.restoreAll();
|
|
const second = await runNext(queueId);
|
|
assert.equal(second.item_id, 'S-2');
|
|
});
|
|
|
|
it('next selects lowest execution_order among ready items', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const queueId = 'QUE-20260107011111';
|
|
const issue1 = 'ISS-ORDER-1';
|
|
const issue2 = 'ISS-ORDER-2';
|
|
const sol1 = 'SOL-ISS-ORDER-1-1';
|
|
const sol2 = 'SOL-ISS-ORDER-2-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issue1, status: 'queued' }), createMockIssue({ id: issue2, status: 'queued' })]);
|
|
issueModule.writeSolutions(issue1, [createMockSolution({ id: sol1, is_bound: false })]);
|
|
issueModule.writeSolutions(issue2, [createMockSolution({ id: sol2, is_bound: false })]);
|
|
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'active',
|
|
issue_ids: [issue1, issue2],
|
|
tasks: [],
|
|
solutions: [
|
|
{
|
|
item_id: 'S-1',
|
|
issue_id: issue1,
|
|
solution_id: sol1,
|
|
status: 'pending',
|
|
execution_order: 2,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
task_count: 1,
|
|
},
|
|
{
|
|
item_id: 'S-2',
|
|
issue_id: issue2,
|
|
solution_id: sol2,
|
|
status: 'pending',
|
|
execution_order: 1,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
task_count: 1,
|
|
},
|
|
],
|
|
conflicts: [],
|
|
});
|
|
|
|
const next = await runNext(queueId);
|
|
assert.equal(next.item_id, 'S-2');
|
|
});
|
|
|
|
it('next skips failed items when auto-selecting', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const queueId = 'QUE-20260107020202';
|
|
const issue1 = 'ISS-SKIP-1';
|
|
const issue2 = 'ISS-SKIP-2';
|
|
const sol1 = 'SOL-ISS-SKIP-1-1';
|
|
const sol2 = 'SOL-ISS-SKIP-2-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issue1, status: 'queued' }), createMockIssue({ id: issue2, status: 'queued' })]);
|
|
issueModule.writeSolutions(issue1, [createMockSolution({ id: sol1, is_bound: false })]);
|
|
issueModule.writeSolutions(issue2, [createMockSolution({ id: sol2, is_bound: false })]);
|
|
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'active',
|
|
issue_ids: [issue1, issue2],
|
|
tasks: [],
|
|
solutions: [
|
|
{
|
|
item_id: 'S-1',
|
|
issue_id: issue1,
|
|
solution_id: sol1,
|
|
status: 'failed',
|
|
execution_order: 1,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
failure_reason: 'nope',
|
|
task_count: 1,
|
|
},
|
|
{
|
|
item_id: 'S-2',
|
|
issue_id: issue2,
|
|
solution_id: sol2,
|
|
status: 'pending',
|
|
execution_order: 2,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
task_count: 1,
|
|
},
|
|
],
|
|
conflicts: [],
|
|
});
|
|
|
|
const next = await runNext(queueId);
|
|
assert.equal(next.item_id, 'S-2');
|
|
});
|
|
|
|
it('done stores parsed result JSON on the queue item', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
mock.method(console, 'warn', () => {});
|
|
|
|
const queueId = 'QUE-20260107022222';
|
|
const issueId = 'ISS-DONE-RESULT';
|
|
const solutionId = 'SOL-ISS-DONE-RESULT-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'executing' })]);
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'active',
|
|
issue_ids: [issueId],
|
|
tasks: [],
|
|
solutions: [
|
|
{
|
|
item_id: 'S-1',
|
|
issue_id: issueId,
|
|
solution_id: solutionId,
|
|
status: 'executing',
|
|
execution_order: 1,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
started_at: new Date().toISOString(),
|
|
task_count: 1,
|
|
},
|
|
],
|
|
conflicts: [],
|
|
});
|
|
|
|
await issueModule.issueCommand('done', ['S-1'], { queue: queueId, result: '{"ok":true,"n":1}' });
|
|
|
|
const updatedQueue = issueModule.readQueue(queueId);
|
|
assert.equal(updatedQueue?.status, 'completed');
|
|
const item = (updatedQueue?.solutions || []).find((i: any) => i.item_id === 'S-1');
|
|
assert.equal(item?.status, 'completed');
|
|
assert.ok(item?.completed_at);
|
|
assert.deepEqual(item?.result, { ok: true, n: 1 });
|
|
});
|
|
|
|
it('retry resets failed items to pending and clears failure fields', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
mock.method(console, 'warn', () => {});
|
|
|
|
const queueId = 'QUE-20260107030303';
|
|
const issueId = 'ISS-RETRY-1';
|
|
const solutionId = 'SOL-ISS-RETRY-1-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'failed' })]);
|
|
issueModule.writeSolutions(issueId, [createMockSolution({ id: solutionId, is_bound: false })]);
|
|
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'failed',
|
|
issue_ids: [issueId],
|
|
tasks: [],
|
|
solutions: [
|
|
{
|
|
item_id: 'S-1',
|
|
issue_id: issueId,
|
|
solution_id: solutionId,
|
|
status: 'failed',
|
|
execution_order: 1,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
failure_reason: 'boom',
|
|
failure_details: { error_type: 'test_failure', message: 'boom', timestamp: new Date().toISOString() },
|
|
task_count: 1,
|
|
},
|
|
],
|
|
conflicts: [],
|
|
});
|
|
|
|
await issueModule.issueCommand('retry', [issueId], { queue: queueId });
|
|
|
|
const updatedQueue = issueModule.readQueue(queueId);
|
|
const item = (updatedQueue?.solutions || []).find((i: any) => i.item_id === 'S-1');
|
|
assert.equal(updatedQueue?.status, 'active');
|
|
assert.equal(item?.status, 'pending');
|
|
assert.equal(item?.failure_reason, undefined);
|
|
assert.equal(item?.failure_details, undefined);
|
|
assert.equal(Array.isArray(item?.failure_history), true);
|
|
assert.equal(item.failure_history.length, 1);
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.status, 'queued');
|
|
});
|
|
|
|
it('update --from-queue syncs planned issues to queued', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
const logs: string[] = [];
|
|
mock.method(console, 'log', (...args: any[]) => {
|
|
logs.push(args.map(String).join(' '));
|
|
});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const queueId = 'QUE-20260107040404';
|
|
const issueId = 'ISS-SYNC-1';
|
|
const solutionId = 'SOL-ISS-SYNC-1-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issueId, status: 'planned', bound_solution_id: solutionId })]);
|
|
issueModule.writeSolutions(issueId, [createMockSolution({ id: solutionId, is_bound: true })]);
|
|
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'active',
|
|
issue_ids: [issueId],
|
|
tasks: [],
|
|
solutions: [
|
|
{
|
|
item_id: 'S-1',
|
|
issue_id: issueId,
|
|
solution_id: solutionId,
|
|
status: 'pending',
|
|
execution_order: 1,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
task_count: 1,
|
|
},
|
|
],
|
|
conflicts: [],
|
|
});
|
|
|
|
await issueModule.issueCommand('update', [], { fromQueue: true, json: true });
|
|
const payload = JSON.parse(logs.at(-1) || '{}');
|
|
assert.equal(payload.success, true);
|
|
assert.deepEqual(payload.queued, [issueId]);
|
|
|
|
const issue = issueModule.readIssues().find((i: any) => i.id === issueId);
|
|
assert.equal(issue?.status, 'queued');
|
|
assert.ok(issue?.queued_at);
|
|
});
|
|
|
|
it('marks queue as completed when all items are completed', async () => {
|
|
issueModule ??= await import(issueCommandUrl);
|
|
assert.ok(env);
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
const queueId = 'QUE-20260107050505';
|
|
const issue1 = 'ISS-QDONE-1';
|
|
const issue2 = 'ISS-QDONE-2';
|
|
const sol1 = 'SOL-ISS-QDONE-1-1';
|
|
const sol2 = 'SOL-ISS-QDONE-2-1';
|
|
|
|
issueModule.writeIssues([createMockIssue({ id: issue1, status: 'queued' }), createMockIssue({ id: issue2, status: 'queued' })]);
|
|
issueModule.writeSolutions(issue1, [createMockSolution({ id: sol1, is_bound: false })]);
|
|
issueModule.writeSolutions(issue2, [createMockSolution({ id: sol2, is_bound: false })]);
|
|
|
|
issueModule.writeQueue({
|
|
id: queueId,
|
|
status: 'active',
|
|
issue_ids: [issue1, issue2],
|
|
tasks: [],
|
|
solutions: [
|
|
{
|
|
item_id: 'S-1',
|
|
issue_id: issue1,
|
|
solution_id: sol1,
|
|
status: 'pending',
|
|
execution_order: 1,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
task_count: 1,
|
|
},
|
|
{
|
|
item_id: 'S-2',
|
|
issue_id: issue2,
|
|
solution_id: sol2,
|
|
status: 'pending',
|
|
execution_order: 2,
|
|
execution_group: 'P1',
|
|
depends_on: [],
|
|
semantic_priority: 0.5,
|
|
task_count: 1,
|
|
},
|
|
],
|
|
conflicts: [],
|
|
});
|
|
|
|
// Complete both items.
|
|
await issueModule.issueCommand('next', [], { queue: queueId });
|
|
await issueModule.issueCommand('done', ['S-1'], { queue: queueId });
|
|
assert.equal(issueModule.readQueue(queueId)?.status, 'active');
|
|
|
|
await issueModule.issueCommand('next', [], { queue: queueId });
|
|
await issueModule.issueCommand('done', ['S-2'], { queue: queueId });
|
|
assert.equal(issueModule.readQueue(queueId)?.status, 'completed');
|
|
});
|
|
});
|
|
});
|