common-qa-mcp 0.8.0 → 0.9.0
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.
- package/dist/adapters/remoteMcp/adapter.js +1 -1
- package/dist/adapters/simplifyqa/adapter.d.ts +48 -4
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/adapter.js +585 -63
- package/dist/adapters/simplifyqa/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/mapper.d.ts +2 -2
- package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/mapper.js +257 -64
- package/dist/adapters/simplifyqa/mapper.js.map +1 -1
- package/dist/adapters/types.d.ts +10 -1
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/domain/types.d.ts +46 -4
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/domain/validate.d.ts.map +1 -1
- package/dist/domain/validate.js +5 -1
- package/dist/domain/validate.js.map +1 -1
- package/dist/drafts/store.d.ts +9 -0
- package/dist/drafts/store.d.ts.map +1 -1
- package/dist/drafts/store.js +12 -0
- package/dist/drafts/store.js.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +48 -16
- package/dist/initAgents.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +6 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/recording.tools.d.ts.map +1 -1
- package/dist/tools/recording.tools.js +234 -0
- package/dist/tools/recording.tools.js.map +1 -1
- package/dist/tools/run.tools.d.ts +4 -0
- package/dist/tools/run.tools.d.ts.map +1 -1
- package/dist/tools/run.tools.js +360 -114
- package/dist/tools/run.tools.js.map +1 -1
- package/dist/tools/schemas.d.ts +41 -49
- package/dist/tools/schemas.d.ts.map +1 -1
- package/dist/tools/schemas.js +19 -1
- package/dist/tools/schemas.js.map +1 -1
- package/dist/tools/tcEditPublisher.d.ts +20 -0
- package/dist/tools/tcEditPublisher.d.ts.map +1 -0
- package/dist/tools/tcEditPublisher.js +46 -0
- package/dist/tools/tcEditPublisher.js.map +1 -0
- package/dist/tools/testcase.tools.d.ts.map +1 -1
- package/dist/tools/testcase.tools.js +303 -10
- package/dist/tools/testcase.tools.js.map +1 -1
- package/dist/tools/testdata.tools.d.ts.map +1 -1
- package/dist/tools/testdata.tools.js +60 -6
- package/dist/tools/testdata.tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools/run.tools.js
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ok, err } from '../mcpResult.js';
|
|
3
3
|
const NOT_CONNECTED = 'Not connected to a product. Use qa_connect({ url, token, projectId }) first — running a test needs a live backend connection.';
|
|
4
|
+
const lastRun = new Map();
|
|
5
|
+
function summarizeLastRun(c) {
|
|
6
|
+
const parts = [];
|
|
7
|
+
if (c.when)
|
|
8
|
+
parts.push(`when=${c.when}`);
|
|
9
|
+
if (c.mode)
|
|
10
|
+
parts.push(`mode=${c.mode}`);
|
|
11
|
+
if (c.releaseId !== undefined)
|
|
12
|
+
parts.push(`release=${c.releaseId || '(none)'}`);
|
|
13
|
+
if (c.sprintId !== undefined)
|
|
14
|
+
parts.push(`sprint=${c.sprintId || '(none)'}`);
|
|
15
|
+
if (c.environment)
|
|
16
|
+
parts.push(`env=${c.environment}`);
|
|
17
|
+
if (c.browser)
|
|
18
|
+
parts.push(`browser=${c.browser}`);
|
|
19
|
+
if (c.agentId)
|
|
20
|
+
parts.push(`agent=${c.agentId}`);
|
|
21
|
+
if (c.cloudRunnerId)
|
|
22
|
+
parts.push(`runner=${c.cloudRunnerId}`);
|
|
23
|
+
return parts.join(', ');
|
|
24
|
+
}
|
|
4
25
|
function describeError(e) {
|
|
5
26
|
return e instanceof Error ? e.message : String(e);
|
|
6
27
|
}
|
|
@@ -119,14 +140,87 @@ function statusText(s) {
|
|
|
119
140
|
(total > 0 ? '\n\nTo see WHY a specific one passed/failed, call run_status again with the same runId ' +
|
|
120
141
|
'plus `tcId` set to one of the ids above.' : '')
|
|
121
142
|
: '';
|
|
143
|
+
// First 5 non-blank lines of the raw error string — the previous single-line slice usually
|
|
144
|
+
// showed a "[ERROR] session …" log prefix instead of the actual exception. Enough context for
|
|
145
|
+
// the LLM to name the failure ("NoSuchElement on #foo") without dumping a full stack.
|
|
146
|
+
const errBlock = (raw) => {
|
|
147
|
+
if (!raw)
|
|
148
|
+
return '';
|
|
149
|
+
const lines = raw.split('\n').map((l) => l.trim()).filter((l) => l.length > 0).slice(0, 5);
|
|
150
|
+
return lines.map((l) => ` ${l}`).join('\n');
|
|
151
|
+
};
|
|
152
|
+
const stepIcon = (st) => st === 'passed' ? '✅' : st === 'failed' ? '❌' : st === 'skipped' ? '⏭️' : '•';
|
|
122
153
|
const steps = s.stepResults?.length
|
|
123
154
|
? '\n\n' + s.stepResults.map((st) => {
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
return `${
|
|
155
|
+
const desc = st.description?.trim() || '(no description)';
|
|
156
|
+
const err = errBlock(st.error);
|
|
157
|
+
return `${stepIcon(st.status)} step ${st.n}: ${desc} (${st.status})${err ? '\n' + err : ''}`;
|
|
158
|
+
}).join('\n')
|
|
159
|
+
: '';
|
|
160
|
+
// Failed-step summary: hoist FAILED rows to the top so the LLM sees "which step, why" without
|
|
161
|
+
// scanning the full list. Only fires when the run itself is failed AND at least one step row is
|
|
162
|
+
// failed (a run can be marked FAILED before any step logs, in which case the reportUrl is the
|
|
163
|
+
// only lead). Multi-step failures are all listed — cascading failures often clarify the root.
|
|
164
|
+
const failedSteps = (s.stepResults ?? []).filter((st) => st.status === 'failed');
|
|
165
|
+
const failedSummary = s.state === 'failed' && failedSteps.length
|
|
166
|
+
? '\n\n**Failed at:**\n' + failedSteps.map((st) => {
|
|
167
|
+
const desc = st.description?.trim() || '(no description)';
|
|
168
|
+
const err = errBlock(st.error);
|
|
169
|
+
return `- step ${st.n}: ${desc}${err ? '\n' + err : ''}`;
|
|
127
170
|
}).join('\n')
|
|
128
171
|
: '';
|
|
129
|
-
return `Run ${s.runId} · **${s.state}**${prog}${when}${tally}${link}${kids}${steps}`;
|
|
172
|
+
return `Run ${s.runId} · **${s.state}**${prog}${when}${tally}${link}${failedSummary}${kids}${steps}`;
|
|
173
|
+
}
|
|
174
|
+
/** Compact per-run bullet list for `run_history`. Matches the icon vocabulary `statusText` uses so
|
|
175
|
+
* the two outputs feel like one surface. Duration only shows when both timestamps are present
|
|
176
|
+
* (running/queued rows will read as `-> (running)` without a `(53s)` suffix). */
|
|
177
|
+
function renderRunHistory(type, entityId, runs) {
|
|
178
|
+
const stateIcon = (st) => st === 'passed' ? '✅'
|
|
179
|
+
: st === 'failed' ? '❌'
|
|
180
|
+
: st === 'running' ? '⏳'
|
|
181
|
+
: st === 'skipped' ? '⏭️'
|
|
182
|
+
: st === 'notExecuted' ? '➖'
|
|
183
|
+
: st === 'stopped' ? '⏹️'
|
|
184
|
+
: st === 'error' ? '⚠️'
|
|
185
|
+
: '•';
|
|
186
|
+
const durationOf = (r) => {
|
|
187
|
+
if (!r.startedAt || !r.endedAt)
|
|
188
|
+
return '';
|
|
189
|
+
const start = Date.parse(r.startedAt);
|
|
190
|
+
const end = Date.parse(r.endedAt);
|
|
191
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start)
|
|
192
|
+
return '';
|
|
193
|
+
const secs = Math.round((end - start) / 1000);
|
|
194
|
+
if (secs < 60)
|
|
195
|
+
return ` (${secs}s)`;
|
|
196
|
+
const m = Math.floor(secs / 60);
|
|
197
|
+
const s = secs % 60;
|
|
198
|
+
return ` (${m}m ${s.toString().padStart(2, '0')}s)`;
|
|
199
|
+
};
|
|
200
|
+
const when = (r) => {
|
|
201
|
+
if (r.startedAt && r.endedAt)
|
|
202
|
+
return ` · ${r.startedAt} → ${r.endedAt}${durationOf(r)}`;
|
|
203
|
+
if (r.startedAt)
|
|
204
|
+
return ` · started ${r.startedAt}`;
|
|
205
|
+
return '';
|
|
206
|
+
};
|
|
207
|
+
const tally = (r) => {
|
|
208
|
+
if (type !== 'SUITE' || r.totalTestCases === undefined)
|
|
209
|
+
return '';
|
|
210
|
+
const parts = [`${r.passed ?? 0}✅`];
|
|
211
|
+
parts.push(`${r.failed ?? 0}❌`);
|
|
212
|
+
parts.push(`${r.skipped ?? 0}⏭`);
|
|
213
|
+
if ((r.notExecuted ?? 0) > 0)
|
|
214
|
+
parts.push(`${r.notExecuted}➖`);
|
|
215
|
+
return ` · ${parts.join('/')} of ${r.totalTestCases}`;
|
|
216
|
+
};
|
|
217
|
+
const label = type === 'SUITE' ? 'suite' : 'test case';
|
|
218
|
+
const header = `Recent executions of ${label} ${entityId} (${runs.length} shown):\n`;
|
|
219
|
+
const lines = runs.map((r) => `- ${stateIcon(r.state)} ${r.runId} · ${r.state}${tally(r)}${when(r)}`);
|
|
220
|
+
const hint = type === 'SUITE'
|
|
221
|
+
? '\n\nCall run_status({ runId: "<id>" }) for the per-test-case rollup, then again with tcId for a child\'s steps.'
|
|
222
|
+
: '\n\nCall run_status({ runId: "<id>" }) for step-level detail on any one.';
|
|
223
|
+
return header + '\n' + lines.join('\n') + hint;
|
|
130
224
|
}
|
|
131
225
|
export function registerRunTools(server, deps) {
|
|
132
226
|
server.registerTool('cloud_runners', {
|
|
@@ -266,18 +360,25 @@ export function registerRunTools(server, deps) {
|
|
|
266
360
|
});
|
|
267
361
|
server.registerTool('tc_run', {
|
|
268
362
|
title: 'Run a test case (local / remote / cloud; now or scheduled)',
|
|
269
|
-
description: "
|
|
270
|
-
"
|
|
271
|
-
"
|
|
272
|
-
"
|
|
273
|
-
'
|
|
274
|
-
|
|
275
|
-
"
|
|
276
|
-
"
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
'
|
|
363
|
+
description: "★ ORDER OF QUESTIONS (STRICT — applies whether you ask one at a time OR bundle several into a " +
|
|
364
|
+
"single message): when → mode → release → sprint → version → environment → browser/device → " +
|
|
365
|
+
"schedule fields → agent. Ask in THIS order, always — never schema-declaration order, never " +
|
|
366
|
+
"refusal-arrival order, never a 'natural UI' order (release first is WRONG). Skip a step only " +
|
|
367
|
+
"when the flag below says it's skippable (sprint when release='', version when TC has one " +
|
|
368
|
+
"version, browser vs. device by TC type). ★\n\n" +
|
|
369
|
+
"Execute ONE existing test case in the connected product. ASK the user for EVERY field the " +
|
|
370
|
+
"Execute dialog asks — never assume — in this DEPENDENCY ORDER: (0) if a previous run in this " +
|
|
371
|
+
"session left a config for the project, the tool refuses first asking `reuseConfig` (true=merge " +
|
|
372
|
+
"remembered mode/release/sprint/env/browser/agent, false=start fresh); (1) `when` " +
|
|
373
|
+
"immediate/schedule, (2) `mode` local/remote/cloud, (3) `releaseId` (from list_releases; pass " +
|
|
374
|
+
"'' to skip — no release), (4) `sprintId` (from list_sprints; skipped entirely when " +
|
|
375
|
+
"releaseId=''), (5) `versionId` (from tc_get's `versions[]`; pass '' for current), (6) " +
|
|
376
|
+
"`environment` (from tc_list_environments), (7) FOR WEB TCs: `browser`; FOR ANDROID/IOS TCs: " +
|
|
377
|
+
"`deviceType` + `deviceId` (via `mobile_list_devices`), (8) for a schedule the start time + " +
|
|
378
|
+
"recurrence + timezone, (9) THEN offer the eligible agent/runner and pass the pick. IF YOU " +
|
|
379
|
+
"BATCH SEVERAL REFUSED FIELDS INTO ONE AskUserQuestion, ORDER the questions EXACTLY as above " +
|
|
380
|
+
"(when → mode → release → sprint → version → environment → browser/device → schedule fields " +
|
|
381
|
+
"→ agent) — never in schema order or refusal order. `mode` decides WHERE it runs:\n" +
|
|
281
382
|
" • 'local' — THIS machine's desktop agent (its own browser). Call `local_agents` (0 or 1 row per box). " +
|
|
282
383
|
"For when:'immediate' the tool first probes the desktop agent (:4040) and refuses cleanly if it's not up. " +
|
|
283
384
|
"Omit `agentId` to auto-pick this machine's agent (or pass it explicitly — must be this machine's row). " +
|
|
@@ -287,54 +388,91 @@ export function registerRunTools(server, deps) {
|
|
|
287
388
|
"remote agents are eligible; for when:'schedule' ANY remote agent works (it runs when it fires).\n" +
|
|
288
389
|
" • 'cloud' — an online cloud runner VM (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
|
|
289
390
|
'from cloud_runners; for a schedule omit it to let the backend auto-pick a live VM at fire time.\n' +
|
|
290
|
-
'
|
|
291
|
-
'if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
|
|
292
|
-
'`when` decides WHEN it runs (this tool REFUSES when `when` is omitted — ASK the user):\n' +
|
|
293
|
-
" • 'immediate' — dispatch now; returns the run id — poll run_status for the result.\n" +
|
|
294
|
-
" • 'schedule' — create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
|
|
295
|
-
'end date and timezone BEFORE offering an agent. Returns the schedule id.\n' +
|
|
391
|
+
'For cloud, if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
|
|
296
392
|
'NOTE: MANUAL test cases cannot be run or scheduled by an agent — the backend rejects them.',
|
|
297
393
|
inputSchema: {
|
|
298
394
|
tcId: z.string().describe('The test case id (or business key) to run.'),
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
395
|
+
reuseConfig: z.boolean().optional().describe("Set only when the tool refused asking about REUSE. true = merge the previous run's mode/release/sprint/env/browser/agent/runner into any unset field; false = ignore prior config and walk the full cascade. Omit on a first run."),
|
|
396
|
+
when: z.enum(['immediate', 'schedule']).optional().describe("STEP 1 — when to run. REQUIRED, ASK the user if unstated (the tool refuses when omitted). 'immediate' dispatches now; 'schedule' creates a schedule (needs scheduledFor)."),
|
|
397
|
+
mode: z.enum(['local', 'remote', 'cloud']).optional().describe("STEP 2 — where to run. REQUIRED, ASK the user if unstated."),
|
|
398
|
+
releaseId: z.string().optional().describe("STEP 3 — tag the run with a RELEASE (from list_releases). Optional in the UI; pass '' after asking to skip. The tool refuses when undefined so the user is asked."),
|
|
399
|
+
sprintId: z.string().optional().describe("STEP 4 — tag the run with a SPRINT (from list_sprints, scoped to release). Only asked when releaseId is non-empty; skipped entirely when releaseId=''. Pass '' after asking to skip."),
|
|
400
|
+
versionId: z.string().optional().describe("STEP 5 — override the TC's currently-selected VERSION for this run (an id or name from the TC's `versions[]`). Pass '' to accept the TC's current version."),
|
|
401
|
+
environment: z.string().optional().describe('STEP 6 — environment key (from tc_list_environments). ASK the user; the UI marks this required.'),
|
|
402
|
+
browser: z.string().optional().describe("STEP 7 (WEB TCs) — browser, default 'chromium' (bundled + isolated). Others: 'chrome'/'msedge' (installed channel), 'firefox', 'webkit'. NOTE: a schedule accepts only chromium/msedge/firefox/webkit."),
|
|
403
|
+
deviceId: z.string().optional().describe("STEP 7 (ANDROID/IOS TCs) — the target device id. LOCAL: adb serial or emulator name. CLOUD: the marketplace device name. Enumerate with `mobile_list_devices`."),
|
|
404
|
+
deviceType: z.enum(['real_device', 'emulator', 'simulator', 'cloud_device', 'cloud_emulator', 'cloud_simulator']).optional().describe("STEP 7 (ANDROID/IOS TCs) — the KIND of device the picked `deviceId` is (read off the matching row from `mobile_list_devices`)."),
|
|
405
|
+
cloudDeviceType: z.enum(['lambda_test', 'browser_stack', 'sauce_labs', 'simplify_qa_device_form']).optional().describe("REQUIRED when `deviceType` is cloud_* — which cloud farm the device came from (read off the matching row's `cloudDeviceType`)."),
|
|
406
|
+
osVersion: z.string().optional().describe("Optional OS version to stamp on the wire platform object for ANDROID/IOS — read off the matching row from `mobile_list_devices`."),
|
|
407
|
+
agentId: z.string().optional().describe("STEP 9 — for local: a credential_id from `local_agents` (must be THIS machine's row; omit to auto-pick). For remote: REQUIRED — a credential_id from `remote_agents`. when:'immediate' → the picked agent must be ONLINE; when:'schedule' → offline OK and `agentId` is REQUIRED for both local and remote."),
|
|
408
|
+
cloudRunnerId: z.string().optional().describe("STEP 9 (cloud) — a specific ONLINE runner id (from cloud_runners). For when:'schedule' omit to auto-pick a live VM at fire time."),
|
|
306
409
|
headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
|
|
307
|
-
|
|
308
|
-
deviceId: z.string().optional().describe("REQUIRED for ANDROID/IOS test cases — the target device id. LOCAL: adb serial or emulator name. CLOUD: the marketplace device name. Enumerate with `mobile_list_devices`."),
|
|
309
|
-
deviceType: z.enum(['real_device', 'emulator', 'simulator', 'cloud_device', 'cloud_emulator', 'cloud_simulator']).optional().describe("REQUIRED for ANDROID/IOS test cases — the KIND of device the picked `deviceId` is (read off the matching row from `mobile_list_devices`). Drives the wire execution platform's `type` field."),
|
|
310
|
-
cloudDeviceType: z.enum(['lambda_test', 'browser_stack', 'sauce_labs', 'simplify_qa_device_form']).optional().describe("REQUIRED when `deviceType` is cloud_* — which cloud farm the device came from (read off the matching row's `cloudDeviceType` from `mobile_list_devices`). Note SIMPLIFY_QA_DEVICE_FORM's legacy 'FORM' spelling — mirrors the SQA wire schema exactly."),
|
|
311
|
-
osVersion: z.string().optional().describe("Optional OS version to stamp on the wire platform object for ANDROID/IOS — read off the matching row from `mobile_list_devices`. Ignored for non-mobile tcTypes."),
|
|
312
|
-
when: z.enum(['immediate', 'schedule']).optional().describe("When to run — REQUIRED, ASK the user if unstated (the tool refuses when omitted). 'immediate' dispatches now; 'schedule' creates a schedule (needs scheduledFor)."),
|
|
313
|
-
scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule' — ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
|
|
410
|
+
scheduledFor: z.string().optional().describe("STEP 8 — REQUIRED for when:'schedule'; ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
|
|
314
411
|
scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
|
|
315
412
|
endDate: z.string().optional().describe('For a recurring schedule — ISO end date (required for hourly/daily/weekdays).'),
|
|
316
413
|
timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
|
|
317
414
|
},
|
|
318
415
|
annotations: { readOnlyHint: false },
|
|
319
|
-
}, async ({ tcId, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, deviceType, cloudDeviceType, osVersion,
|
|
416
|
+
}, async ({ tcId, reuseConfig, when, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, deviceType, cloudDeviceType, osVersion, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
|
|
320
417
|
const adapter = deps.getAdapter();
|
|
321
418
|
if (!adapter)
|
|
322
419
|
return err(NOT_CONNECTED);
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
420
|
+
// STEP 0 — reuse-last-config prompt. Fires only when the caller wired getProjectId AND there
|
|
421
|
+
// is a remembered config for that project AND the caller has not answered yet (undefined).
|
|
422
|
+
// Merging when `reuseConfig: true` fills only fields left unset by this call — the user can
|
|
423
|
+
// still override any single field explicitly. TC-specific fields (versionId, deviceId,
|
|
424
|
+
// schedule cadence) are never remembered — they belong to the run, not the environment.
|
|
425
|
+
const projectId = deps.getProjectId?.() ?? null;
|
|
426
|
+
const remembered = projectId ? lastRun.get(projectId) : undefined;
|
|
427
|
+
if (remembered && reuseConfig === undefined) {
|
|
428
|
+
return err(`Previous run in this project used: ${summarizeLastRun(remembered)}. ` +
|
|
429
|
+
'ASK the user whether to reuse it — call tc_run again with `reuseConfig: true` to merge ' +
|
|
430
|
+
'those defaults, or `reuseConfig: false` to start fresh and be asked each field.');
|
|
431
|
+
}
|
|
432
|
+
if (reuseConfig === true && remembered) {
|
|
433
|
+
if (when === undefined)
|
|
434
|
+
when = remembered.when;
|
|
435
|
+
if (mode === undefined)
|
|
436
|
+
mode = remembered.mode;
|
|
437
|
+
if (releaseId === undefined)
|
|
438
|
+
releaseId = remembered.releaseId;
|
|
439
|
+
if (sprintId === undefined)
|
|
440
|
+
sprintId = remembered.sprintId;
|
|
441
|
+
if (environment === undefined)
|
|
442
|
+
environment = remembered.environment;
|
|
443
|
+
if (browser === undefined)
|
|
444
|
+
browser = remembered.browser;
|
|
445
|
+
if (agentId === undefined)
|
|
446
|
+
agentId = remembered.agentId;
|
|
447
|
+
if (cloudRunnerId === undefined)
|
|
448
|
+
cloudRunnerId = remembered.cloudRunnerId;
|
|
449
|
+
}
|
|
450
|
+
// STEP 1 — when. Decides schedule vs immediate; gates the schedule fields and the agent
|
|
451
|
+
// online-check downstream, so ask before anything else.
|
|
452
|
+
if (!when) {
|
|
453
|
+
return err("Specify WHEN to run: call tc_run again with when: 'immediate' (run now) or when: 'schedule' " +
|
|
454
|
+
'(create a schedule). If the user has not said, ask them first — never assume.');
|
|
455
|
+
}
|
|
456
|
+
// STEP 2 — mode. Decides the agent/runner pool and constrains the browser set for a schedule.
|
|
457
|
+
if (!mode) {
|
|
458
|
+
return err("Specify where to run: call tc_run again with mode: 'local', 'remote', or 'cloud'. " +
|
|
459
|
+
"If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
|
|
460
|
+
"remote runs on a teammate's registered agent, cloud runs on an online cloud runner VM.");
|
|
461
|
+
}
|
|
462
|
+
// STEP 3 — release. Optional in the UI; the tool asks anyway (pass '' to skip).
|
|
326
463
|
if (releaseId === undefined) {
|
|
327
464
|
return err("ASK the user which RELEASE this run belongs to (from list_releases), then call tc_run " +
|
|
328
465
|
"again with releaseId. If the user wants no release, pass releaseId: '' (empty string).");
|
|
329
466
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
467
|
+
// STEP 4 — sprint. Only asked when a release was picked; when release is skipped, sprint is
|
|
468
|
+
// skipped entirely (sprints scope to a release in the UI).
|
|
469
|
+
if (releaseId !== '' && sprintId === undefined) {
|
|
470
|
+
return err("ASK the user which SPRINT this run belongs to (from list_sprints, filtered by releaseId), " +
|
|
471
|
+
"then call tc_run again with sprintId. If the user wants no sprint, pass sprintId: '' (empty string).");
|
|
333
472
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
// discovery.tools.ts:226; without this the model guesses a version NAME and the backend 500s.
|
|
473
|
+
if (releaseId === '')
|
|
474
|
+
sprintId = '';
|
|
475
|
+
// STEP 5 — version. Needs the TC's tcType and version list, so pre-fetch once here.
|
|
338
476
|
let preview;
|
|
339
477
|
try {
|
|
340
478
|
preview = await adapter.getTestCase(tcId);
|
|
@@ -357,30 +495,19 @@ export function registerRunTools(server, deps) {
|
|
|
357
495
|
}
|
|
358
496
|
versionId = '';
|
|
359
497
|
}
|
|
498
|
+
// STEP 6 — environment.
|
|
360
499
|
if (!environment) {
|
|
361
500
|
return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
|
|
362
501
|
'tc_run again with environment set to the chosen env key. Required — the UI marks this ' +
|
|
363
502
|
'field with a red asterisk.');
|
|
364
503
|
}
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// buildRunPlatform will ignore any browser passed alongside a mobile deviceId.
|
|
504
|
+
// STEP 7 (web only) — browser. Mobile branch handled after mode-dependent cloud/agent
|
|
505
|
+
// resolution, since the device roster is filtered by where it will run.
|
|
368
506
|
if (!isMobile && !browser) {
|
|
369
507
|
return err("ASK the user which BROWSER to run in (chromium / chrome / msedge / firefox / webkit), " +
|
|
370
508
|
'then call tc_run again with browser set. Required — the UI marks this field with a ' +
|
|
371
509
|
'red asterisk.');
|
|
372
510
|
}
|
|
373
|
-
if (!mode) {
|
|
374
|
-
return err("Specify where to run: call tc_run again with mode: 'local', 'remote', or 'cloud'. " +
|
|
375
|
-
"If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
|
|
376
|
-
"remote runs on a teammate's registered agent, cloud runs on an online cloud runner VM.");
|
|
377
|
-
}
|
|
378
|
-
// Never assume now-vs-scheduled — it decides which agents are eligible (online-only for now,
|
|
379
|
-
// online+offline for a local schedule) and, for a schedule, the start time asked next.
|
|
380
|
-
if (!when) {
|
|
381
|
-
return err("Specify WHEN to run: call tc_run again with when: 'immediate' (run now) or when: 'schedule' " +
|
|
382
|
-
'(create a schedule). If the user has not said, ask them first — never assume.');
|
|
383
|
-
}
|
|
384
511
|
let resolvedRunner;
|
|
385
512
|
if (mode === 'cloud') {
|
|
386
513
|
if (!adapter.listCloudRunners)
|
|
@@ -484,6 +611,8 @@ export function registerRunTools(server, deps) {
|
|
|
484
611
|
const where = mode === 'cloud'
|
|
485
612
|
? (pinnedRunner ? `cloud runner **${pinnedRunner.name}**` : 'cloud (auto-pick a live VM at run time)')
|
|
486
613
|
: `agent \`${agentId}\``;
|
|
614
|
+
if (projectId)
|
|
615
|
+
lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId });
|
|
487
616
|
return ok(`Scheduled ${tcId} (${scheduleType ?? 'once'}, ${mode}) for ${scheduledFor}${next} → ${where}. Schedule id \`${sched.id}\`.`, { schedule: sched, mode, when: 'schedule', ...(pinnedRunner ? { runner: pinnedRunner } : {}) });
|
|
488
617
|
}
|
|
489
618
|
catch (e) {
|
|
@@ -512,6 +641,8 @@ export function registerRunTools(server, deps) {
|
|
|
512
641
|
: mode === 'remote'
|
|
513
642
|
? `on remote agent \`${agentId}\``
|
|
514
643
|
: `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
|
|
644
|
+
if (projectId)
|
|
645
|
+
lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId: resolvedRunner?.id ?? cloudRunnerId });
|
|
515
646
|
return ok(`Started ${tcId} ${where}. Run id \`${run.runId}\` — poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
|
|
516
647
|
}
|
|
517
648
|
catch (e) {
|
|
@@ -520,15 +651,24 @@ export function registerRunTools(server, deps) {
|
|
|
520
651
|
});
|
|
521
652
|
server.registerTool('suite_run', {
|
|
522
653
|
title: 'Run a test suite (local / remote / cloud; now or scheduled)',
|
|
523
|
-
description:
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
'
|
|
527
|
-
|
|
528
|
-
'
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
"
|
|
654
|
+
description: "★ ORDER OF QUESTIONS (STRICT — applies whether you ask one at a time OR bundle several into a " +
|
|
655
|
+
"single message): when → mode → release → sprint → environment → schedule fields → agent. Ask " +
|
|
656
|
+
"in THIS order, always — never schema-declaration order, never refusal-arrival order, never " +
|
|
657
|
+
"a 'natural UI' order (release first is WRONG). Skip sprint when release=''. Suites do NOT " +
|
|
658
|
+
"ask version / browser / device — each TC in the suite carries its own. ★\n\n" +
|
|
659
|
+
'Execute an existing test SUITE — all its member test cases, in order — in the connected product. ' +
|
|
660
|
+
"ASK the user for EVERY field the UI's Execute-suite dialog asks — never assume — in this " +
|
|
661
|
+
"DEPENDENCY ORDER: (0) if a previous run in this session left a config for the project, the tool " +
|
|
662
|
+
"refuses first asking `reuseConfig` (true=merge remembered mode/release/sprint/env/agent, " +
|
|
663
|
+
"false=start fresh); (1) `when` immediate/schedule, (2) `mode` local/remote/cloud, (3) " +
|
|
664
|
+
"`releaseId` (from list_releases; pass '' to skip), (4) `sprintId` (from list_sprints; skipped " +
|
|
665
|
+
"entirely when releaseId=''), (5) `environment` (from tc_list_environments), (6) for a schedule " +
|
|
666
|
+
"the start time + recurrence + timezone, (7) THEN offer the eligible agent/runner. IF YOU " +
|
|
667
|
+
"BATCH SEVERAL REFUSED FIELDS INTO ONE AskUserQuestion, ORDER the questions EXACTLY as above " +
|
|
668
|
+
"(when → mode → release → sprint → environment → schedule fields → agent) — never in schema " +
|
|
669
|
+
"order or refusal order. UNLIKE " +
|
|
670
|
+
"`tc_run`, a suite does NOT ask for `versionId` or `browser` — each TC in the suite carries its " +
|
|
671
|
+
"own pinned version + execution platform (that's what the suite editor picks per row). " +
|
|
532
672
|
'`mode` decides WHERE it runs:\n' +
|
|
533
673
|
" • 'local' — THIS machine's desktop agent, always serial. Call `local_agents` (0 or 1 row). For " +
|
|
534
674
|
"when:'immediate' the tool probes :4040 and refuses if the desktop agent is down. Omit `agentId` to " +
|
|
@@ -538,65 +678,89 @@ export function registerRunTools(server, deps) {
|
|
|
538
678
|
" • 'cloud' — online cloud runner VM(s) (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
|
|
539
679
|
'from cloud_runners; for a schedule omit it to auto-pick a live VM at fire time.\n' +
|
|
540
680
|
'For cloud, if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
|
|
541
|
-
'
|
|
542
|
-
" • 'immediate' — dispatch now; returns the suite-run id — poll run_status for progress " +
|
|
543
|
-
'(per-test-case results under `children`) and run_stop to cancel.\n' +
|
|
544
|
-
" • 'schedule' — create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
|
|
545
|
-
'end date and timezone BEFORE offering an agent. Returns the schedule id.\n' +
|
|
546
|
-
'NOTE: a suite of only MANUAL test cases cannot be run or scheduled by an agent — the backend rejects it.',
|
|
681
|
+
'NOTE: manual suites go through the same dispatch — the backend stores them as an execution (no agent picks them up until steps are marked from the UI).',
|
|
547
682
|
inputSchema: {
|
|
548
683
|
suiteId: z.string().describe('The suite id to run (from suite_list / suite_get).'),
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
mode: z.enum(['local', 'remote', 'cloud']).optional().describe('
|
|
552
|
-
|
|
553
|
-
|
|
684
|
+
reuseConfig: z.boolean().optional().describe("Set only when the tool refused asking about REUSE. true = merge the previous run's mode/release/sprint/env/browser/agent/runner into any unset field; false = ignore prior config. Omit on a first run."),
|
|
685
|
+
when: z.enum(['immediate', 'schedule']).optional().describe("STEP 1 — when to run. REQUIRED, ASK the user if unstated."),
|
|
686
|
+
mode: z.enum(['local', 'remote', 'cloud']).optional().describe('STEP 2 — where to run. REQUIRED, ASK the user if unstated.'),
|
|
687
|
+
releaseId: z.string().optional().describe("STEP 3 — release from list_releases. ASK the user; pass '' to skip (no release / no sprint asked)."),
|
|
688
|
+
sprintId: z.string().optional().describe("STEP 4 — sprint from list_sprints (scoped to release). Only asked when releaseId is non-empty; skipped when releaseId=''. Pass '' to skip."),
|
|
689
|
+
environment: z.string().optional().describe('STEP 5 — environment key (from tc_list_environments). ASK the user; the UI marks this required for a suite.'),
|
|
554
690
|
browser: z.string().optional().describe("Optional at the SUITE level — each TC in the suite has its own execution platform (that's the suite-editor pick per row). Provide only to OVERRIDE for every WEB TC. Values: 'chromium' (default), 'chrome'/'msedge', 'firefox', 'webkit'. NOTE: a schedule accepts only chromium/msedge/firefox/webkit."),
|
|
555
691
|
headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule'
|
|
692
|
+
agentId: z.string().optional().describe("STEP 7 — for local: a credential_id from `local_agents` (this machine only; omit to auto-pick). For remote: REQUIRED — a credential_id from `remote_agents`. when:'immediate' → picked agent must be ONLINE; when:'schedule' → offline OK, agentId REQUIRED for both local and remote."),
|
|
693
|
+
cloudRunnerId: z.string().optional().describe("STEP 7 (cloud) — a specific ONLINE runner id (from cloud_runners). For when:'schedule' omit to auto-pick a live VM at fire time."),
|
|
694
|
+
strategy: z.enum(['serial', 'parallel']).optional().describe("Dispatch order across the suite's test cases. Wire only accepts SERIAL / PARALLEL (backend validator). Default 'serial'; LOCAL is always serial regardless."),
|
|
695
|
+
maxConcurrent: z.number().int().positive().optional().describe('Cloud parallel only — maps to globalConfiguration.MAXIMUM_CONTAINER_COUNT on the wire.'),
|
|
696
|
+
terminateOnFailure: z.boolean().optional().describe('Stop dispatching remaining test cases after the first failure. Maps to globalConfiguration.TERMINATE_ON_FAIL on the wire.'),
|
|
697
|
+
scheduledFor: z.string().optional().describe("STEP 6 — REQUIRED for when:'schedule'; ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
|
|
562
698
|
scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
|
|
563
699
|
endDate: z.string().optional().describe('For a recurring schedule — ISO end date (required for hourly/daily/weekdays).'),
|
|
564
700
|
timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
|
|
565
701
|
},
|
|
566
702
|
annotations: { readOnlyHint: false },
|
|
567
|
-
}, async ({ suiteId, releaseId, sprintId, mode, agentId, environment, browser, headless, cloudRunnerId, strategy, maxConcurrent, terminateOnFailure, when, scheduledFor, scheduleType, endDate, timezone }) => {
|
|
703
|
+
}, async ({ suiteId, reuseConfig, releaseId, sprintId, mode, agentId, environment, browser, headless, cloudRunnerId, strategy, maxConcurrent, terminateOnFailure, when, scheduledFor, scheduleType, endDate, timezone }) => {
|
|
568
704
|
const adapter = deps.getAdapter();
|
|
569
705
|
if (!adapter)
|
|
570
706
|
return err(NOT_CONNECTED);
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if (
|
|
577
|
-
return err(
|
|
578
|
-
'
|
|
707
|
+
// STEP 0 — reuse-last-config prompt. Same shape as tc_run: fires only when the caller wired
|
|
708
|
+
// getProjectId AND there is a remembered config AND the user hasn't answered yet. Merges the
|
|
709
|
+
// environment-level fields (mode/release/sprint/env/browser/agent/runner) into any left unset.
|
|
710
|
+
const projectId = deps.getProjectId?.() ?? null;
|
|
711
|
+
const remembered = projectId ? lastRun.get(projectId) : undefined;
|
|
712
|
+
if (remembered && reuseConfig === undefined) {
|
|
713
|
+
return err(`Previous run in this project used: ${summarizeLastRun(remembered)}. ` +
|
|
714
|
+
'ASK the user whether to reuse it — call suite_run again with `reuseConfig: true` to merge ' +
|
|
715
|
+
'those defaults, or `reuseConfig: false` to start fresh.');
|
|
579
716
|
}
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
717
|
+
if (reuseConfig === true && remembered) {
|
|
718
|
+
if (when === undefined)
|
|
719
|
+
when = remembered.when;
|
|
720
|
+
if (mode === undefined)
|
|
721
|
+
mode = remembered.mode;
|
|
722
|
+
if (releaseId === undefined)
|
|
723
|
+
releaseId = remembered.releaseId;
|
|
724
|
+
if (sprintId === undefined)
|
|
725
|
+
sprintId = remembered.sprintId;
|
|
726
|
+
if (environment === undefined)
|
|
727
|
+
environment = remembered.environment;
|
|
728
|
+
if (browser === undefined)
|
|
729
|
+
browser = remembered.browser;
|
|
730
|
+
if (agentId === undefined)
|
|
731
|
+
agentId = remembered.agentId;
|
|
732
|
+
if (cloudRunnerId === undefined)
|
|
733
|
+
cloudRunnerId = remembered.cloudRunnerId;
|
|
584
734
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
'
|
|
735
|
+
// STEP 1 — when.
|
|
736
|
+
if (!when) {
|
|
737
|
+
return err("Specify WHEN to run: call suite_run again with when: 'immediate' (run now) or when: 'schedule' " +
|
|
738
|
+
'(create a schedule). If the user has not said, ask them first — never assume.');
|
|
589
739
|
}
|
|
740
|
+
// STEP 2 — mode.
|
|
590
741
|
if (!mode) {
|
|
591
742
|
return err("Specify where to run: call suite_run again with mode: 'local', 'remote', or 'cloud'. " +
|
|
592
743
|
"If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
|
|
593
744
|
"remote runs on a teammate's registered agent, cloud runs on online cloud runner VM(s).");
|
|
594
745
|
}
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
746
|
+
// STEP 3 — release. Unified with tc_run: pass '' to skip (matches the UI's optional field).
|
|
747
|
+
if (releaseId === undefined) {
|
|
748
|
+
return err("ASK the user which RELEASE this suite run belongs to (from list_releases), then call " +
|
|
749
|
+
"suite_run again with releaseId. If the user wants no release, pass releaseId: '' (empty string).");
|
|
750
|
+
}
|
|
751
|
+
// STEP 4 — sprint. Only asked when release is non-empty.
|
|
752
|
+
if (releaseId !== '' && sprintId === undefined) {
|
|
753
|
+
return err("ASK the user which SPRINT this suite run belongs to (from list_sprints, filtered by " +
|
|
754
|
+
"releaseId), then call suite_run again with sprintId. If the user wants no sprint, pass " +
|
|
755
|
+
"sprintId: '' (empty string).");
|
|
756
|
+
}
|
|
757
|
+
if (releaseId === '')
|
|
758
|
+
sprintId = '';
|
|
759
|
+
// STEP 5 — environment.
|
|
760
|
+
if (!environment) {
|
|
761
|
+
return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
|
|
762
|
+
'suite_run again with environment set to the chosen env key. Required — the UI marks this ' +
|
|
763
|
+
'field with a red asterisk for a suite.');
|
|
600
764
|
}
|
|
601
765
|
let resolvedRunner;
|
|
602
766
|
if (mode === 'cloud') {
|
|
@@ -671,6 +835,8 @@ export function registerRunTools(server, deps) {
|
|
|
671
835
|
const where = mode === 'cloud'
|
|
672
836
|
? (pinnedRunner ? `cloud runner **${pinnedRunner.name}**` : 'cloud (auto-pick a live VM at run time)')
|
|
673
837
|
: `agent \`${agentId}\``;
|
|
838
|
+
if (projectId)
|
|
839
|
+
lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId });
|
|
674
840
|
return ok(`Scheduled suite ${suiteId} (${scheduleType ?? 'once'}, ${mode}) for ${scheduledFor}${next} → ${where}. Schedule id \`${sched.id}\`.`, { schedule: sched, mode, when: 'schedule', ...(pinnedRunner ? { runner: pinnedRunner } : {}) });
|
|
675
841
|
}
|
|
676
842
|
catch (e) {
|
|
@@ -697,6 +863,8 @@ export function registerRunTools(server, deps) {
|
|
|
697
863
|
: mode === 'remote'
|
|
698
864
|
? `on remote agent \`${agentId}\``
|
|
699
865
|
: `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
|
|
866
|
+
if (projectId)
|
|
867
|
+
lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId: resolvedRunner?.id ?? cloudRunnerId });
|
|
700
868
|
return ok(`Started suite ${suiteId} ${where}. Run id \`${run.runId}\` — poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
|
|
701
869
|
}
|
|
702
870
|
catch (e) {
|
|
@@ -768,18 +936,96 @@ export function registerRunTools(server, deps) {
|
|
|
768
936
|
return err(describeError(e));
|
|
769
937
|
}
|
|
770
938
|
});
|
|
939
|
+
server.registerTool('run_history', {
|
|
940
|
+
title: 'Recent execution history for a suite or test case',
|
|
941
|
+
description: 'Return the top-N most recent STANDALONE executions of a suite or test case (newest first), ' +
|
|
942
|
+
'as a scoreboard — runId, state, timing, reportUrl, plus a per-test-case tally for SUITE ' +
|
|
943
|
+
'runs (passed/failed/skipped of total). No step-level detail; use `run_status({ runId })` ' +
|
|
944
|
+
'on any row to drill in. Pass EXACTLY one of `suiteId` or `tcId`. `limit` defaults to 5, ' +
|
|
945
|
+
'capped at 20. Suite-child TC rows are excluded from a `tcId` query, so a test case that ' +
|
|
946
|
+
'only runs inside suites returns empty. Requires a live connection.',
|
|
947
|
+
inputSchema: {
|
|
948
|
+
suiteId: z.string().optional().describe('A suite id (from suite_list) — returns the suite\'s recent executions.'),
|
|
949
|
+
tcId: z.string().optional().describe('A test case id — returns the TC\'s recent STANDALONE executions.'),
|
|
950
|
+
limit: z.number().int().min(1).max(20).optional().describe('How many recent runs to return (default 5, max 20).'),
|
|
951
|
+
},
|
|
952
|
+
annotations: { readOnlyHint: true },
|
|
953
|
+
}, async ({ suiteId, tcId, limit }) => {
|
|
954
|
+
const adapter = deps.getAdapter();
|
|
955
|
+
if (!adapter)
|
|
956
|
+
return err(NOT_CONNECTED);
|
|
957
|
+
if ((suiteId && tcId) || (!suiteId && !tcId)) {
|
|
958
|
+
return err('Pass exactly one of `suiteId` or `tcId`.');
|
|
959
|
+
}
|
|
960
|
+
if (!adapter.listRecentRuns) {
|
|
961
|
+
return err('This product does not support run-history lookup.');
|
|
962
|
+
}
|
|
963
|
+
const type = suiteId ? 'SUITE' : 'TC';
|
|
964
|
+
const entityId = (suiteId ?? tcId);
|
|
965
|
+
const n = Math.min(Math.max(limit ?? 5, 1), 20);
|
|
966
|
+
try {
|
|
967
|
+
const runs = await adapter.listRecentRuns(type, entityId, n);
|
|
968
|
+
if (!runs.length) {
|
|
969
|
+
const label = type === 'SUITE' ? 'suite' : 'test case';
|
|
970
|
+
return ok(`No executions found for ${label} ${entityId}.`, { runs: [] });
|
|
971
|
+
}
|
|
972
|
+
const text = renderRunHistory(type, entityId, runs);
|
|
973
|
+
return ok(text, { runs });
|
|
974
|
+
}
|
|
975
|
+
catch (e) {
|
|
976
|
+
return err(describeError(e));
|
|
977
|
+
}
|
|
978
|
+
});
|
|
771
979
|
server.registerTool('run_stop', {
|
|
772
980
|
title: 'Stop a run',
|
|
773
|
-
description: 'Signal a running execution to stop
|
|
774
|
-
|
|
981
|
+
description: 'Signal a running execution to stop, and poll briefly for the terminal state so the reply ' +
|
|
982
|
+
"reflects reality (not a fire-and-forget). Pass EXACTLY one of: `runId` (from tc_run / " +
|
|
983
|
+
"suite_run), `suiteId` (stops the suite's LATEST execution), or `tcId` (stops the TC's " +
|
|
984
|
+
'LATEST standalone execution — suite-child rows are excluded). Requires a live connection.',
|
|
985
|
+
inputSchema: {
|
|
986
|
+
runId: z.string().optional().describe('The run id (from tc_run / suite_run).'),
|
|
987
|
+
suiteId: z.string().optional().describe("A suite id (from suite_list) — stops the suite's latest execution."),
|
|
988
|
+
tcId: z.string().optional().describe("A test case id — stops the TC's latest STANDALONE execution."),
|
|
989
|
+
},
|
|
775
990
|
annotations: { readOnlyHint: false },
|
|
776
|
-
}, async ({ runId }) => {
|
|
991
|
+
}, async ({ runId, suiteId, tcId }) => {
|
|
777
992
|
const adapter = deps.getAdapter();
|
|
778
993
|
if (!adapter)
|
|
779
994
|
return err(NOT_CONNECTED);
|
|
995
|
+
if ([runId, suiteId, tcId].filter((v) => v !== undefined && v !== '').length !== 1) {
|
|
996
|
+
return err('Pass exactly one of `runId`, `suiteId`, or `tcId`.');
|
|
997
|
+
}
|
|
780
998
|
try {
|
|
781
|
-
|
|
782
|
-
|
|
999
|
+
let effectiveRunId = runId;
|
|
1000
|
+
if (suiteId) {
|
|
1001
|
+
if (!adapter.getLatestSuiteRun)
|
|
1002
|
+
return err('This product does not support latest-suite-run lookup — pass `runId` instead.');
|
|
1003
|
+
const latest = await adapter.getLatestSuiteRun(suiteId);
|
|
1004
|
+
if (!latest)
|
|
1005
|
+
return err(`Suite ${suiteId} has no executions to stop.`);
|
|
1006
|
+
effectiveRunId = latest;
|
|
1007
|
+
}
|
|
1008
|
+
else if (tcId) {
|
|
1009
|
+
if (!adapter.getLatestTestcaseRun)
|
|
1010
|
+
return err('This product does not support latest-testcase-run lookup — pass `runId` instead.');
|
|
1011
|
+
const latest = await adapter.getLatestTestcaseRun(tcId);
|
|
1012
|
+
if (!latest)
|
|
1013
|
+
return err(`Test case ${tcId} has no executions to stop.`);
|
|
1014
|
+
effectiveRunId = latest;
|
|
1015
|
+
}
|
|
1016
|
+
await adapter.stopRun(effectiveRunId);
|
|
1017
|
+
// Confirm — the backend accepts the stop async, so getRun is the source of truth. Poll a few
|
|
1018
|
+
// times so the reply reports the actual state instead of lying "requested" and moving on.
|
|
1019
|
+
// ponytail: fixed 3×1.5s poll, upgrade to configurable/backoff if teams hit slow stop paths.
|
|
1020
|
+
const TERMINAL = new Set(['stopped', 'passed', 'failed', 'error', 'skipped', 'notExecuted']);
|
|
1021
|
+
for (let i = 0; i < 3; i++) {
|
|
1022
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
1023
|
+
const s = await adapter.getRun(effectiveRunId);
|
|
1024
|
+
if (TERMINAL.has(s.state))
|
|
1025
|
+
return ok(`Run ${effectiveRunId} is now **${s.state}**.`, { run: s });
|
|
1026
|
+
}
|
|
1027
|
+
return ok(`Stop request sent for run ${effectiveRunId}. State is still queued/running — poll ` +
|
|
1028
|
+
`run_status({ runId: "${effectiveRunId}" }) to confirm.`);
|
|
783
1029
|
}
|
|
784
1030
|
catch (e) {
|
|
785
1031
|
return err(describeError(e));
|