test-execution-platform-mcp 0.6.0-rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ import { existsSync, copyFileSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { parse, stringify } from 'smol-toml';
4
+ import { backupPathFor, listBackups, restoreLatestBackup } from './config.js';
5
+ export function readTomlSafe(path) {
6
+ if (!existsSync(path))
7
+ return {};
8
+ try {
9
+ const raw = readFileSync(path, 'utf-8');
10
+ if (raw.trim() === '')
11
+ return {};
12
+ return parse(raw);
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ export function mergeCodexMcpServer(current, serverName, entry) {
19
+ const mcpServers = { ...(current.mcp_servers ?? {}) };
20
+ mcpServers[serverName] = { ...(mcpServers[serverName] ?? {}), ...entry };
21
+ return { ...current, mcp_servers: mcpServers };
22
+ }
23
+ export function writeTomlWithBackup(file, content) {
24
+ const dir = dirname(file);
25
+ if (!existsSync(dir)) {
26
+ mkdirSync(dir, { recursive: true });
27
+ }
28
+ if (existsSync(file)) {
29
+ copyFileSync(file, backupPathFor(file));
30
+ }
31
+ writeFileSync(file, stringify(content), 'utf-8');
32
+ }
33
+ export function removeCodexMcpServer(file, serverName) {
34
+ const data = readTomlSafe(file);
35
+ if (!data.mcp_servers || !data.mcp_servers[serverName])
36
+ return false;
37
+ delete data.mcp_servers[serverName];
38
+ writeTomlWithBackup(file, data);
39
+ return true;
40
+ }
41
+ export function restoreCodexConfigFromBackup(file) {
42
+ return restoreLatestBackup(file);
43
+ }
44
+ export function listCodexBackups(file) {
45
+ return listBackups(file);
46
+ }
@@ -0,0 +1,54 @@
1
+ const ESC = '\x1B';
2
+ const RESET = `${ESC}[0m`;
3
+ const COLORS = {
4
+ green: `${ESC}[32m`,
5
+ red: `${ESC}[31m`,
6
+ yellow: `${ESC}[33m`,
7
+ blue: `${ESC}[34m`,
8
+ cyan: `${ESC}[36m`,
9
+ gray: `${ESC}[90m`,
10
+ };
11
+ export function supportsColor() {
12
+ if (process.env.NO_COLOR)
13
+ return false;
14
+ if (process.env.FORCE_COLOR)
15
+ return true;
16
+ return Boolean(process.stdout.isTTY);
17
+ }
18
+ export function colorize(text, color, enabled = supportsColor()) {
19
+ if (!enabled)
20
+ return text;
21
+ return `${COLORS[color]}${text}${RESET}`;
22
+ }
23
+ export function formatBanner(title, subtitle) {
24
+ const bar = '─'.repeat(Math.max(title.length, subtitle.length) + 4);
25
+ return [
26
+ colorize(`┌${bar}┐`, 'cyan'),
27
+ colorize(`│ ${title.padEnd(bar.length - 2)}│`, 'cyan'),
28
+ colorize(`│ ${subtitle.padEnd(bar.length - 2)}│`, 'gray'),
29
+ colorize(`└${bar}┘`, 'cyan'),
30
+ ].join('\n');
31
+ }
32
+ export function formatStep(current, total, message) {
33
+ return `${colorize(`[${current}/${total}]`, 'blue')} ${message}`;
34
+ }
35
+ export function formatSuccess(message) {
36
+ return `${colorize('✓', 'green')} ${message}`;
37
+ }
38
+ export function formatWarn(message) {
39
+ return `${colorize('!', 'yellow')} ${message}`;
40
+ }
41
+ export function formatError(message) {
42
+ return `${colorize('✗', 'red')} ${message}`;
43
+ }
44
+ export function prompt(question) {
45
+ return new Promise((resolvePrompt) => {
46
+ process.stdout.write(`${colorize('?', 'cyan')} ${question} `);
47
+ const onData = (chunk) => {
48
+ const s = chunk.toString('utf-8').trim();
49
+ process.stdin.removeListener('data', onData);
50
+ resolvePrompt(s);
51
+ };
52
+ process.stdin.once('data', onData);
53
+ });
54
+ }
@@ -0,0 +1,30 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { listBackups, readJsonSafe, restoreLatestBackup } from './config.js';
3
+ import { listCodexBackups, readTomlSafe, removeCodexMcpServer, restoreCodexConfigFromBackup } from './toml-config.js';
4
+ export function restoreOrRemoveMcpServer(file, serverName) {
5
+ const backups = listBackups(file);
6
+ if (backups.length > 0) {
7
+ const ok = restoreLatestBackup(file);
8
+ return { restored: ok, removed: false, path: file };
9
+ }
10
+ const data = readJsonSafe(file);
11
+ if (data.mcpServers?.[serverName]) {
12
+ delete data.mcpServers[serverName];
13
+ writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8');
14
+ return { restored: false, removed: true, path: file };
15
+ }
16
+ return { restored: false, removed: false, path: file };
17
+ }
18
+ export function restoreOrRemoveCodexMcpServer(file, serverName) {
19
+ const backups = listCodexBackups(file);
20
+ if (backups.length > 0) {
21
+ const ok = restoreCodexConfigFromBackup(file);
22
+ return { restored: ok, removed: false, path: file };
23
+ }
24
+ const data = readTomlSafe(file);
25
+ if (data.mcp_servers?.[serverName]) {
26
+ const ok = removeCodexMcpServer(file, serverName);
27
+ return { restored: false, removed: ok, path: file };
28
+ }
29
+ return { restored: false, removed: false, path: file };
30
+ }
@@ -0,0 +1,186 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, existsSync, renameSync, unlinkSync, statSync, createWriteStream, chmodSync } from 'node:fs';
3
+ import { prompt } from './ui.js';
4
+ export function parseChecksums(text) {
5
+ const map = new Map();
6
+ for (const line of text.split('\n')) {
7
+ const trimmed = line.trim();
8
+ if (trimmed.length === 0)
9
+ continue;
10
+ const match = trimmed.match(/^([a-f0-9]+)\s+(.+)$/i);
11
+ if (match) {
12
+ map.set(match[2], match[1].toLowerCase());
13
+ }
14
+ }
15
+ return map;
16
+ }
17
+ export function getInstalledVersion(binaryPath) {
18
+ const versionFile = `${binaryPath}.version`;
19
+ if (!existsSync(versionFile))
20
+ return '0.0.0';
21
+ try {
22
+ const raw = readFileSync(versionFile, 'utf-8').trim();
23
+ const m = raw.match(/^v?(\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.]+)?)$/);
24
+ return m ? m[1] : '0.0.0';
25
+ }
26
+ catch {
27
+ return '0.0.0';
28
+ }
29
+ }
30
+ export function compareVersions(a, b) {
31
+ const strip = (v) => v.replace(/^v/, '');
32
+ const parseMain = (v) => strip(v).split('-')[0].split('.').map(Number);
33
+ const parsePre = (v) => {
34
+ const m = strip(v).match(/-([^+]*)/);
35
+ return m ? m[1] : '';
36
+ };
37
+ const [aMain, bMain] = [parseMain(a), parseMain(b)];
38
+ for (let i = 0; i < 3; i++) {
39
+ const ai = aMain[i] ?? 0, bi = bMain[i] ?? 0;
40
+ if (ai < bi)
41
+ return -1;
42
+ if (ai > bi)
43
+ return 1;
44
+ }
45
+ const aPre = parsePre(a), bPre = parsePre(b);
46
+ if (!aPre && bPre)
47
+ return 1;
48
+ if (aPre && !bPre)
49
+ return -1;
50
+ if (aPre < bPre)
51
+ return -1;
52
+ if (aPre > bPre)
53
+ return 1;
54
+ return 0;
55
+ }
56
+ const ASSET_MAP = {
57
+ linux: { x64: 'qc-mcp-linux-x64', arm64: 'qc-mcp-linux-arm64' },
58
+ darwin: { x64: 'qc-mcp-macos-x64', arm64: 'qc-mcp-macos-arm64' },
59
+ win32: { x64: 'qc-mcp-win-x64.exe', arm64: 'qc-mcp-win-arm64.exe' },
60
+ // unsupported platforms intentionally absent
61
+ };
62
+ export function selectAssetForPlatform(assets, platform, arch) {
63
+ const name = ASSET_MAP[platform]?.[arch];
64
+ if (!name)
65
+ return null;
66
+ return assets.has(name) ? name : null;
67
+ }
68
+ export function verifySha256(file, expected) {
69
+ if (!existsSync(file))
70
+ return false;
71
+ const content = readFileSync(file);
72
+ const hash = createHash('sha256').update(content).digest('hex');
73
+ return hash.toLowerCase() === expected.toLowerCase();
74
+ }
75
+ export function swapBinary(current, next) {
76
+ if (!existsSync(current) || !existsSync(next))
77
+ return false;
78
+ // Refuse to swap a non-file (e.g., directory) in for a binary
79
+ if (!statSync(next).isFile())
80
+ return false;
81
+ const old = `${current}.old`;
82
+ if (existsSync(old))
83
+ unlinkSync(old);
84
+ renameSync(current, old);
85
+ try {
86
+ renameSync(next, current);
87
+ }
88
+ catch (err) {
89
+ // rollback
90
+ try {
91
+ renameSync(old, current);
92
+ }
93
+ catch {
94
+ // ignore
95
+ }
96
+ return false;
97
+ }
98
+ unlinkSync(old);
99
+ // Defense-in-depth: the auto-update flow downloads the raw binary from
100
+ // Generic Package Registry, which strips POSIX mode bits. The new
101
+ // binary would then fail with "Permission denied" on next run. Reapply
102
+ // 0o755 so the binary stays executable after a self-update. No-op on
103
+ // Windows (chmod is a no-op there).
104
+ try {
105
+ chmodSync(current, 0o755);
106
+ }
107
+ catch {
108
+ // ignore — best effort
109
+ }
110
+ return true;
111
+ }
112
+ export async function downloadToFile(url, dest, token, onProgress) {
113
+ const headers = {};
114
+ if (token)
115
+ headers['PRIVATE-TOKEN'] = token;
116
+ const res = await fetch(url, { headers });
117
+ if (!res.ok) {
118
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
119
+ }
120
+ if (!res.body) {
121
+ throw new Error('Response has no body');
122
+ }
123
+ const total = Number(res.headers.get('content-length') ?? 0);
124
+ const file = createWriteStream(dest);
125
+ let downloaded = 0;
126
+ let reader = null;
127
+ try {
128
+ reader = res.body.getReader();
129
+ while (true) {
130
+ const { done, value } = await reader.read();
131
+ if (done)
132
+ break;
133
+ file.write(Buffer.from(value));
134
+ downloaded += value.length;
135
+ onProgress?.(downloaded, total);
136
+ }
137
+ await new Promise((resolve, reject) => {
138
+ file.end((err) => err ? reject(err) : resolve());
139
+ });
140
+ }
141
+ catch (err) {
142
+ file.destroy();
143
+ // Best-effort cleanup of partial file and underlying stream
144
+ try {
145
+ unlinkSync(dest);
146
+ }
147
+ catch { /* ignore */ }
148
+ try {
149
+ await reader?.cancel();
150
+ }
151
+ catch { /* ignore */ }
152
+ throw err;
153
+ }
154
+ }
155
+ export async function fetchLatestRelease(projectPath, baseUrl = 'https://gitlab.vivas.vn', token) {
156
+ const url = `${baseUrl}/api/v4/projects/${encodeURIComponent(projectPath)}/releases/permalink/latest`;
157
+ const headers = { 'User-Agent': 'qc-mcp-setup' };
158
+ if (token)
159
+ headers['PRIVATE-TOKEN'] = token;
160
+ const res = await fetch(url, { headers });
161
+ if (!res.ok)
162
+ return null;
163
+ const body = (await res.json());
164
+ const assets = new Map();
165
+ for (const link of body.assets?.links ?? []) {
166
+ assets.set(link.name, link.url);
167
+ }
168
+ return { tag: body.tag_name, assets };
169
+ }
170
+ export async function fetchChecksums(url, token) {
171
+ const headers = {};
172
+ if (token)
173
+ headers['PRIVATE-TOKEN'] = token;
174
+ const res = await fetch(url, { headers });
175
+ if (!res.ok)
176
+ return '';
177
+ return res.text();
178
+ }
179
+ export async function confirmUpdate(info) {
180
+ const sizeMB = (info.size / 1024 / 1024).toFixed(1);
181
+ console.log(`Update available: v${info.from} → v${info.to}`);
182
+ console.log(` Size: ${sizeMB} MB`);
183
+ console.log(` SHA-256: ${info.sha256.slice(0, 16)}...`);
184
+ const ans = (await prompt('Proceed? [y/N]')).trim().toLowerCase();
185
+ return ans === 'y' || ans === 'yes';
186
+ }
@@ -0,0 +1,137 @@
1
+ import { spawn } from 'node:child_process';
2
+ const INIT_REQUEST = JSON.stringify({
3
+ jsonrpc: '2.0',
4
+ id: 1,
5
+ method: 'initialize',
6
+ params: {
7
+ protocolVersion: '2024-11-05',
8
+ capabilities: {},
9
+ clientInfo: { name: 'qc-mcp-setup', version: '0.0.0' },
10
+ },
11
+ });
12
+ const INIT_NOTIF = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' });
13
+ const HEALTH_REQUEST = JSON.stringify({
14
+ jsonrpc: '2.0',
15
+ id: 2,
16
+ method: 'tools/call',
17
+ params: { name: 'health_check', arguments: {} },
18
+ });
19
+ export function verifyMcpHandshake(input) {
20
+ const timeoutMs = input.timeoutMs ?? 10_000;
21
+ return new Promise((resolve) => {
22
+ const child = spawn(input.binary, [], {
23
+ env: {
24
+ ...process.env,
25
+ TEST_EXECUTION_API_BASE_URL: input.apiBaseUrl,
26
+ TEST_EXECUTION_MCP_TOKEN: input.token,
27
+ },
28
+ stdio: ['pipe', 'pipe', 'pipe'],
29
+ });
30
+ const pending = new Map();
31
+ let buffer = '';
32
+ let stderrBuffer = '';
33
+ let initResponse = null;
34
+ let healthText = null;
35
+ let timedOut = false;
36
+ let resolved = false;
37
+ const finish = (result) => {
38
+ if (resolved)
39
+ return;
40
+ resolved = true;
41
+ clearTimeout(timer);
42
+ try {
43
+ child.stdin?.end();
44
+ }
45
+ catch {
46
+ // ignore
47
+ }
48
+ try {
49
+ child.kill();
50
+ }
51
+ catch {
52
+ // ignore
53
+ }
54
+ resolve(result);
55
+ };
56
+ const tryFinishSuccess = () => {
57
+ if (resolved)
58
+ return;
59
+ if (initResponse && healthText !== null) {
60
+ finish({
61
+ ok: true,
62
+ serverName: initResponse.serverInfo?.name,
63
+ serverVersion: initResponse.serverInfo?.version,
64
+ healthCheckText: healthText,
65
+ });
66
+ }
67
+ };
68
+ const timer = setTimeout(() => {
69
+ timedOut = true;
70
+ finish({ ok: false, error: 'Timeout waiting for MCP handshake' });
71
+ }, timeoutMs);
72
+ child.stdout?.on('data', (chunk) => {
73
+ buffer += chunk.toString('utf-8');
74
+ let idx;
75
+ while ((idx = buffer.indexOf('\n')) >= 0) {
76
+ const line = buffer.slice(0, idx).trim();
77
+ buffer = buffer.slice(idx + 1);
78
+ if (line.length === 0)
79
+ continue;
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(line);
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ if (parsed?.id === 1 && parsed?.result) {
88
+ initResponse = parsed.result;
89
+ if (pending.has(1)) {
90
+ pending.get(1).resolve(parsed.result);
91
+ pending.delete(1);
92
+ }
93
+ }
94
+ else if (parsed?.id === 2 && parsed?.result) {
95
+ const text = parsed.result?.content?.[0]?.text;
96
+ if (typeof text === 'string')
97
+ healthText = text;
98
+ if (pending.has(2)) {
99
+ pending.get(2).resolve(parsed.result);
100
+ pending.delete(2);
101
+ }
102
+ }
103
+ else if (parsed?.id && pending.has(parsed.id)) {
104
+ pending.get(parsed.id).resolve(parsed);
105
+ pending.delete(parsed.id);
106
+ }
107
+ // Resolve as soon as both responses arrive. MCP stdio servers stay
108
+ // alive until stdin closes, so waiting for the child `close` event
109
+ // would block until our own timeout fires.
110
+ tryFinishSuccess();
111
+ }
112
+ });
113
+ child.stderr?.on('data', (chunk) => {
114
+ stderrBuffer += chunk.toString('utf-8');
115
+ });
116
+ child.on('error', (err) => {
117
+ finish({ ok: false, error: `spawn error: ${err.message}` });
118
+ });
119
+ child.on('close', (code) => {
120
+ if (timedOut)
121
+ return;
122
+ if (resolved)
123
+ return;
124
+ // If we never saw both responses, treat unexpected exit as a failure.
125
+ if (initResponse && healthText !== null) {
126
+ tryFinishSuccess();
127
+ }
128
+ else {
129
+ finish({
130
+ ok: false,
131
+ error: `Process exited with code ${code ?? 'unknown'} before handshake completed; stderr=${stderrBuffer.slice(0, 300)}`,
132
+ });
133
+ }
134
+ });
135
+ child.stdin?.write(`${INIT_REQUEST}\n${INIT_NOTIF}\n${HEALTH_REQUEST}\n`);
136
+ });
137
+ }
@@ -0,0 +1,32 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, resolve } from 'node:path';
4
+ // __filename / __dirname: when compiled to CommonJS, these are runtime
5
+ // globals; when compiled to ESM we derive them from import.meta.url.
6
+ const localFilename = typeof __filename !== 'undefined'
7
+ ? __filename
8
+ : fileURLToPath(import.meta.url);
9
+ const localDirname = typeof __dirname !== 'undefined'
10
+ ? __dirname
11
+ : dirname(localFilename);
12
+ const CANDIDATE_PATHS = [
13
+ resolve(localDirname, '..', '..', 'package.json'),
14
+ resolve(localDirname, '..', '..', '..', 'package.json'),
15
+ resolve(process.cwd(), 'package.json'),
16
+ ];
17
+ export function getVersion() {
18
+ for (const candidate of CANDIDATE_PATHS) {
19
+ if (!existsSync(candidate))
20
+ continue;
21
+ try {
22
+ const raw = readFileSync(candidate, 'utf-8');
23
+ const parsed = JSON.parse(raw);
24
+ if (parsed.version)
25
+ return parsed.version;
26
+ }
27
+ catch {
28
+ // ignore malformed json
29
+ }
30
+ }
31
+ return '0.0.0';
32
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from 'zod';
2
+ import { EmptySchema, encodeQuery } from '../types.js';
3
+ import { requireExecuteAfterDryRun } from './dryRun.js';
4
+ export function createCoreTools(api) {
5
+ return {
6
+ list_projects: tool('List projects.', EmptySchema, async () => api.get('/api/projects')),
7
+ list_rounds: tool('List rounds, optionally filtered by project.', z.object({ project_id: z.string().optional() }), async (input) => {
8
+ const qs = input.project_id ? `?project_id=${encodeQuery(input.project_id)}` : '';
9
+ return api.get(`/api/rounds${qs}`);
10
+ }),
11
+ list_sessions: tool('List active sessions.', EmptySchema, async () => api.get('/api/sessions')),
12
+ get_session: tool('Get session metadata and file tree.', z.object({ session_id: z.string().min(1) }), async (input) => api.get(`/api/sessions/${encodeQuery(input.session_id)}`)),
13
+ get_file_tree: tool('Get session file tree.', z.object({ session_id: z.string().min(1) }), async (input) => {
14
+ const result = await api.get(`/api/sessions/${encodeQuery(input.session_id)}`);
15
+ return { file_tree: result.file_tree, session: result.session };
16
+ }),
17
+ get_testcases_by_path: tool('Get testcases from a testcase markdown path.', z.object({ session_id: z.string().min(1), file_path: z.string().min(1) }), async (input) => {
18
+ return api.get(`/api/sessions/${encodeQuery(input.session_id)}/testcases?file_path=${encodeQuery(input.file_path)}`);
19
+ }),
20
+ get_user_story_file: tool('Read the matching [US-*] user story file.', z.object({ session_id: z.string().min(1), tc_file_path: z.string().min(1) }), async (input) => {
21
+ return api.get(`/api/sessions/${encodeQuery(input.session_id)}/us-file?tc_file_path=${encodeQuery(input.tc_file_path)}`);
22
+ }),
23
+ check_user_story_file_exists: tool('Check whether matching user story file exists.', z.object({ session_id: z.string().min(1), tc_file_path: z.string().min(1) }), async (input) => {
24
+ return api.get(`/api/sessions/${encodeQuery(input.session_id)}/us-file-exists?tc_file_path=${encodeQuery(input.tc_file_path)}`);
25
+ }),
26
+ get_notes: tool('Get notes for a testcase.', z.object({ session_id: z.string().min(1), testcase_id: z.string().min(1) }), async (input) => {
27
+ return api.get(`/api/sessions/${encodeQuery(input.session_id)}/testcases/${encodeQuery(input.testcase_id)}/notes`);
28
+ }),
29
+ create_project: tool('Create a project.', z.object({ name: z.string().min(1), git_url: z.string().min(1), branch: z.string().min(1), description: z.string().optional() }), async (input) => api.post('/api/projects', input)),
30
+ update_project: tool('Update a project.', z.object({ id: z.string().min(1), name: z.string().optional(), git_url: z.string().optional(), branch: z.string().optional(), description: z.string().optional() }), async ({ id, ...body }) => api.put(`/api/projects/${encodeQuery(id)}`, body)),
31
+ create_round: tool('Create a round.', z.object({ project_id: z.string().min(1), name: z.string().min(1), round_number: z.number().int().min(1).max(3), status: z.enum(['active', 'completed', 'archived']).optional() }), async (input) => api.post('/api/rounds', input)),
32
+ update_round: tool('Update a round.', z.object({ id: z.string().min(1), name: z.string().optional(), status: z.enum(['active', 'completed', 'archived']).optional() }), async ({ id, ...body }) => api.put(`/api/rounds/${encodeQuery(id)}`, body)),
33
+ open_session: tool('Open a session by round_id or git_url + branch.', z.object({ round_id: z.string().optional(), git_url: z.string().optional(), branch: z.string().optional() }), async (input) => api.post('/api/sessions/open', input)),
34
+ close_session: tool('Close a session.', z.object({ session_id: z.string().min(1) }), async (input) => api.post(`/api/sessions/${encodeQuery(input.session_id)}/close`)),
35
+ list_branches: tool('List remote branches for a Git URL.', z.object({ git_url: z.string().min(1) }), async (input) => api.post('/api/git/branches', input)),
36
+ create_testcase: tool('Create one testcase.', z.object({ session_id: z.string().min(1), file_id: z.string().min(1), tc_id: z.string().optional(), test_case_name: z.string().min(1), status: z.string().optional(), type: z.string().optional(), technique: z.string().optional(), precondition: z.string().optional(), steps: z.string().optional(), expected_result: z.string().optional(), priority: z.string().optional(), ai: z.string().optional(), r1: z.string().optional(), r2: z.string().optional(), r3: z.string().optional(), note: z.string().optional() }), async ({ session_id, ...body }) => api.post(`/api/sessions/${encodeQuery(session_id)}/testcases`, body)),
37
+ update_testcase: tool('Update one testcase.', z.object({ session_id: z.string().min(1), testcase_id: z.string().min(1), test_case_name: z.string().min(1), precondition: z.string().optional(), steps: z.string().optional(), expected_result: z.string().optional(), priority: z.string().optional(), type: z.string().optional(), technique: z.string().optional() }), async ({ session_id, testcase_id, ...body }) => api.put(`/api/sessions/${encodeQuery(session_id)}/testcases/${encodeQuery(testcase_id)}`, body)),
38
+ update_testcase_dev_status: tool('Update testcase dev_status.', z.object({ session_id: z.string().min(1), testcase_id: z.string().min(1), devStatus: z.enum(['', 'open', 'fixed', 'retest']) }), async ({ session_id, testcase_id, devStatus }) => api.patch(`/api/sessions/${encodeQuery(session_id)}/testcases/${encodeQuery(testcase_id)}/dev-status`, { devStatus })),
39
+ create_file: tool('Create a testcase markdown file in a session workspace.', z.object({
40
+ session_id: z.string().min(1),
41
+ name: z.string().min(1),
42
+ parent_path: z.string().optional(),
43
+ }), async ({ session_id, name, parent_path }) => api.post(`/api/sessions/${encodeQuery(session_id)}/files`, { name, parent_path })),
44
+ create_folder: tool('Create a folder in a session workspace.', z.object({
45
+ session_id: z.string().min(1),
46
+ name: z.string().min(1),
47
+ parent_path: z.string().optional(),
48
+ }), async ({ session_id, name, parent_path }) => api.post(`/api/sessions/${encodeQuery(session_id)}/folders`, { name, parent_path })),
49
+ create_note: tool('Create a note for a testcase.', z.object({ session_id: z.string().min(1), testcase_id: z.string().min(1), execution_round: z.enum(['R1', 'R2', 'R3']).nullable().optional(), text: z.string().min(1).max(10000) }), async ({ session_id, testcase_id, ...body }) => api.post(`/api/sessions/${encodeQuery(session_id)}/testcases/${encodeQuery(testcase_id)}/notes`, body)),
50
+ update_note: tool('Update note text.', z.object({ note_id: z.string().min(1), text: z.string().min(1).max(10000) }), async ({ note_id, text }) => api.put(`/api/notes/${encodeQuery(note_id)}`, { text })),
51
+ delete_note: tool('Delete a note.', z.object({ note_id: z.string().min(1) }), async ({ note_id }) => api.delete(`/api/notes/${encodeQuery(note_id)}`)),
52
+ analyze_excel: tool('Analyze an Excel file for import.', z.object({ session_id: z.string().min(1), file_ref: z.string().min(1) }), async (input) => ({ warning: 'File upload transport must be implemented with backend-compatible upload handling.', input })),
53
+ delete_testcase: tool('Delete a testcase with dry-run guardrail.', z.object({ session_id: z.string().min(1), testcase_id: z.string().min(1), dry_run: z.boolean() }), async (input) => {
54
+ const preview = requireExecuteAfterDryRun(input, { action: 'delete_testcase', testcase_id: input.testcase_id });
55
+ if (preview)
56
+ return preview;
57
+ return api.delete(`/api/sessions/${encodeQuery(input.session_id)}/testcases/${encodeQuery(input.testcase_id)}`);
58
+ }),
59
+ delete_file: tool('Delete a file with dry-run guardrail.', z.object({ session_id: z.string().min(1), file_path: z.string().min(1), dry_run: z.boolean() }), async (input) => {
60
+ const preview = requireExecuteAfterDryRun(input, { action: 'delete_file', file_path: input.file_path });
61
+ if (preview)
62
+ return preview;
63
+ const encodedPath = input.file_path.split('/').map(encodeURIComponent).join('/');
64
+ return api.delete(`/api/sessions/${encodeQuery(input.session_id)}/files/${encodedPath}`);
65
+ }),
66
+ delete_project: tool('Delete a project with dry-run guardrail.', z.object({ id: z.string().min(1), dry_run: z.boolean() }), async (input) => {
67
+ const preview = requireExecuteAfterDryRun(input, { action: 'delete_project', project_id: input.id });
68
+ if (preview)
69
+ return preview;
70
+ return api.delete(`/api/projects/${encodeQuery(input.id)}`);
71
+ }),
72
+ delete_round: tool('Delete a round with dry-run guardrail.', z.object({ id: z.string().min(1), dry_run: z.boolean() }), async (input) => {
73
+ const preview = requireExecuteAfterDryRun(input, { action: 'delete_round', round_id: input.id });
74
+ if (preview)
75
+ return preview;
76
+ return api.delete(`/api/rounds/${encodeQuery(input.id)}`);
77
+ }),
78
+ };
79
+ }
80
+ function tool(description, inputSchema, handler) {
81
+ return { description, inputSchema, handler };
82
+ }
@@ -0,0 +1,11 @@
1
+ export function requireExecuteAfterDryRun(input, preview) {
2
+ if (input.dry_run) {
3
+ return {
4
+ dry_run: true,
5
+ execute_allowed: false,
6
+ preview,
7
+ next_steps: ['Review the preview, then call this tool again with dry_run=false to execute.'],
8
+ };
9
+ }
10
+ return null;
11
+ }
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+ import { encodeQuery } from '../types.js';
3
+ import { requireExecuteAfterDryRun } from './dryRun.js';
4
+ export function createWorkflowTools(api) {
5
+ return {
6
+ prepare_review_context: tool('Prepare normalized QA review context for Claude.', z.object({ session_id: z.string().min(1), tc_file_path: z.string().min(1), include_notes: z.boolean().default(true), include_user_story: z.boolean().default(true) }), async (input) => {
7
+ const [sessionResult, testcaseResult, userStoryResult] = await Promise.all([
8
+ api.get(`/api/sessions/${encodeQuery(input.session_id)}`),
9
+ api.get(`/api/sessions/${encodeQuery(input.session_id)}/testcases?file_path=${encodeQuery(input.tc_file_path)}`),
10
+ input.include_user_story ? api.get(`/api/sessions/${encodeQuery(input.session_id)}/us-file?tc_file_path=${encodeQuery(input.tc_file_path)}`).catch((error) => ({ error: String(error) })) : Promise.resolve(null),
11
+ ]);
12
+ const testcases = testcaseResult.testcases ?? [];
13
+ const notesByTestcase = {};
14
+ if (input.include_notes) {
15
+ for (const tc of testcases) {
16
+ notesByTestcase[tc.id] = await api.get(`/api/sessions/${encodeQuery(input.session_id)}/testcases/${encodeQuery(tc.id)}/notes`).catch((error) => ({ error: String(error) }));
17
+ }
18
+ }
19
+ return {
20
+ session: sessionResult.session,
21
+ file_tree: sessionResult.file_tree,
22
+ testcase_file: { path: input.tc_file_path, summary: testcaseResult.summary ?? '' },
23
+ user_story: userStoryResult,
24
+ testcases,
25
+ notes_by_testcase: notesByTestcase,
26
+ review_hints: buildReviewHints(testcases),
27
+ };
28
+ }),
29
+ import_excel_with_preview: tool('Preview or execute Excel import.', z.object({ session_id: z.string().min(1), file_ref: z.string().min(1), dry_run: z.boolean() }), async (input) => {
30
+ const preview = requireExecuteAfterDryRun(input, { action: 'import_excel', session_id: input.session_id, file_ref: input.file_ref, note: 'Use backend analyze endpoint or upload preview before executing import.' });
31
+ if (preview)
32
+ return preview;
33
+ return api.post(`/api/sessions/${encodeQuery(input.session_id)}/excel/import`, { file_ref: input.file_ref });
34
+ }),
35
+ bulk_update_testcases_with_preview: tool('Preview or execute bulk testcase updates.', z.object({ session_id: z.string().min(1), updates: z.array(z.object({ testcase_id: z.string().min(1), fields: z.record(z.string(), z.unknown()) })).min(1), dry_run: z.boolean() }), async (input) => {
36
+ const preview = requireExecuteAfterDryRun(input, { action: 'bulk_update_testcases', count: input.updates.length, testcase_ids: input.updates.map((u) => u.testcase_id) });
37
+ if (preview)
38
+ return preview;
39
+ const results = [];
40
+ for (const update of input.updates) {
41
+ results.push(await api.put(`/api/sessions/${encodeQuery(input.session_id)}/testcases/${encodeQuery(update.testcase_id)}`, update.fields));
42
+ }
43
+ return { updated: results.length, results };
44
+ }),
45
+ prepare_ci_execution_context: tool('Prepare CI-safe context for opening and closing a session.', z.object({ project_id: z.string().optional(), round_id: z.string().optional(), branch_override: z.string().optional() }), async (input) => {
46
+ const [projects, rounds, sessions] = await Promise.all([
47
+ api.get('/api/projects'),
48
+ api.get(input.project_id ? `/api/rounds?project_id=${encodeQuery(input.project_id)}` : '/api/rounds'),
49
+ api.get('/api/sessions'),
50
+ ]);
51
+ return {
52
+ projects: projects.projects ?? [],
53
+ rounds: rounds.rounds ?? [],
54
+ sessions: sessions.sessions ?? [],
55
+ suggested_open_session_payload: input.round_id ? { round_id: input.round_id } : { project_id: input.project_id, branch: input.branch_override },
56
+ checklist: ['Open session', 'Run dry-run for import/bulk operations', 'Execute only after preview is reviewed', 'Get session to inspect dirty/last_commit_at', 'Close session'],
57
+ };
58
+ }),
59
+ };
60
+ }
61
+ function buildReviewHints(testcases) {
62
+ return {
63
+ missing_steps: testcases.filter((tc) => !String(tc.steps ?? '').trim()).map((tc) => tc.id),
64
+ missing_expected_result: testcases.filter((tc) => !String(tc.expected_result ?? '').trim()).map((tc) => tc.id),
65
+ generic_expected_result_candidates: testcases.filter((tc) => /^(ok|pass|success|đúng|thành công)$/i.test(String(tc.expected_result ?? '').trim())).map((tc) => tc.id),
66
+ };
67
+ }
68
+ function tool(description, inputSchema, handler) {
69
+ return { description, inputSchema, handler };
70
+ }
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ import { z } from 'zod';
2
+ export const EmptySchema = z.object({});
3
+ export const SessionIdSchema = z.object({ session_id: z.string().min(1) });
4
+ export function encodeQuery(value) {
5
+ return encodeURIComponent(value);
6
+ }