common-qa-mcp 0.8.1 → 0.10.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.
Files changed (49) hide show
  1. package/dist/adapters/remoteMcp/adapter.js +1 -1
  2. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  3. package/dist/adapters/simplifyqa/adapter.d.ts +24 -4
  4. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  5. package/dist/adapters/simplifyqa/adapter.js +273 -47
  6. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  7. package/dist/adapters/simplifyqa/mapper.d.ts +6 -1
  8. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  9. package/dist/adapters/simplifyqa/mapper.js +137 -6
  10. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  11. package/dist/adapters/types.d.ts +10 -1
  12. package/dist/adapters/types.d.ts.map +1 -1
  13. package/dist/domain/types.d.ts +24 -0
  14. package/dist/domain/types.d.ts.map +1 -1
  15. package/dist/domain/validate.d.ts.map +1 -1
  16. package/dist/domain/validate.js +14 -8
  17. package/dist/domain/validate.js.map +1 -1
  18. package/dist/drafts/store.d.ts +9 -0
  19. package/dist/drafts/store.d.ts.map +1 -1
  20. package/dist/drafts/store.js +12 -0
  21. package/dist/drafts/store.js.map +1 -1
  22. package/dist/initAgents.d.ts.map +1 -1
  23. package/dist/initAgents.js +43 -9
  24. package/dist/initAgents.js.map +1 -1
  25. package/dist/server.d.ts.map +1 -1
  26. package/dist/server.js +11 -3
  27. package/dist/server.js.map +1 -1
  28. package/dist/tools/actionCatalog.d.ts +24 -0
  29. package/dist/tools/actionCatalog.d.ts.map +1 -0
  30. package/dist/tools/actionCatalog.js +50 -0
  31. package/dist/tools/actionCatalog.js.map +1 -0
  32. package/dist/tools/recording.tools.d.ts.map +1 -1
  33. package/dist/tools/recording.tools.js +234 -0
  34. package/dist/tools/recording.tools.js.map +1 -1
  35. package/dist/tools/run.tools.d.ts.map +1 -1
  36. package/dist/tools/run.tools.js +160 -9
  37. package/dist/tools/run.tools.js.map +1 -1
  38. package/dist/tools/schemas.d.ts +28 -0
  39. package/dist/tools/schemas.d.ts.map +1 -1
  40. package/dist/tools/schemas.js +4 -0
  41. package/dist/tools/schemas.js.map +1 -1
  42. package/dist/tools/testcase.tools.d.ts +15 -1
  43. package/dist/tools/testcase.tools.d.ts.map +1 -1
  44. package/dist/tools/testcase.tools.js +39 -20
  45. package/dist/tools/testcase.tools.js.map +1 -1
  46. package/dist/tools/testdata.tools.d.ts.map +1 -1
  47. package/dist/tools/testdata.tools.js +60 -6
  48. package/dist/tools/testdata.tools.js.map +1 -1
  49. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import os from 'node:os';
2
2
  import { makeClient } from '../http.js';
3
3
  import { RemoteMcpAdapter } from '../remoteMcp/adapter.js';
4
- import { applyCollectionSteps, buildTestcasePayload, deriveTcType, extractParamNames, fromSqaStep, looksSensitive, normalizeSuiteEntry, numericId, paramDefault, pickArray, str, SUITE_UPDATE_ALLOWED_KEYS, TECH_BY_STEPTYPE, toDefectSummary, toEpic, toEpicSummary, toFeature, toFeatureSummary, toFunction, toFunctionSummary, toModuleSaved, toModuleSummary, toObject, toReleaseSummary, toSavedRef, toSprintSummary, toStory, toStorySummary, toSuite, toSuiteSummary, toTaskSummary, toTcSummary, toTestCase, toVersionEntries, withTotal, } from './mapper.js';
4
+ import { applyCollectionSteps, buildTestcasePayload, deriveTcType, extractApiParamNames, extractParamNames, fromSqaStep, looksSensitive, normalizeSuiteEntry, numericId, paramDefault, pickArray, str, SUITE_UPDATE_ALLOWED_KEYS, TECH_BY_STEPTYPE, toDefectSummary, toEpic, toEpicSummary, toFeature, toFeatureSummary, toFunction, toFunctionSummary, toModuleSaved, toModuleSummary, toObject, toImageUrl, toReleaseSummary, toSavedRef, toSprintSummary, toStory, toStorySummary, toSuite, toSuiteSummary, toTaskSummary, toTcSummary, toTestCase, toVersionEntries, withTotal, } from './mapper.js';
5
5
  /** SQA run-row status → neutral RunStatus.state. */
6
6
  function mapRunState(s) {
7
7
  switch (s.toUpperCase()) {
@@ -65,13 +65,17 @@ function normalizeFormula(doc, projectId) {
65
65
  parameters: params,
66
66
  };
67
67
  }
68
- /** SQA per-step status → neutral step status. */
68
+ /** SQA per-step status → neutral step status. Matches RunState terminals ('passed'/'failed'/
69
+ * 'skipped'/'stopped') so the same icon/formatting logic works for top-level state AND per-step
70
+ * rows (run.tools.ts:180 keys off these values — the earlier 'pass'/'fail' names left every
71
+ * step icon defaulting to '•'). Non-terminal SQA states (INFO, INPROGRESS, NOTEXECUTED) pass
72
+ * through lowercased so the tool can still surface them as diagnostic text. */
69
73
  function mapStepStatus(s) {
70
74
  switch (s.toUpperCase()) {
71
- case 'PASSED': return 'pass';
72
- case 'FAILED': return 'fail';
73
- case 'SKIPPED': return 'skip';
74
- case 'STOPPED': return 'stop';
75
+ case 'PASSED': return 'passed';
76
+ case 'FAILED': return 'failed';
77
+ case 'SKIPPED': return 'skipped';
78
+ case 'STOPPED': return 'stopped';
75
79
  default: return s.toLowerCase();
76
80
  }
77
81
  }
@@ -608,7 +612,7 @@ export class SimplifyQaAdapter {
608
612
  async hydrateObjectNames(tc) {
609
613
  const ids = new Set();
610
614
  for (const s of tc.steps) {
611
- if (s.objectRef?.id && !s.objectRef.name)
615
+ if (s.objectRef?.id && (!s.objectRef.name || !s.objectRef.thumbnailUrl))
612
616
  ids.add(s.objectRef.id);
613
617
  }
614
618
  if (!ids.size)
@@ -623,6 +627,7 @@ export class SimplifyQaAdapter {
623
627
  const raw = await this.http.postJson('/tm/object/search', body);
624
628
  const rows = pickArray(raw, 'data', 'content', 'objects');
625
629
  const byId = new Map();
630
+ const imgById = new Map();
626
631
  for (const r of rows) {
627
632
  const rid = str(r, 'id', '_id');
628
633
  if (!rid)
@@ -630,9 +635,19 @@ export class SimplifyQaAdapter {
630
635
  const name = str(r, 'disname', 'description', 'placeholder');
631
636
  if (name)
632
637
  byId.set(rid, name);
638
+ const thumbnailUrl = toImageUrl(r?.thumbnail_url, this.cfg.baseUrl);
639
+ const pageScreenshotUrl = toImageUrl(r?.pageScreenShot_url, this.cfg.baseUrl);
640
+ if (thumbnailUrl || pageScreenshotUrl) {
641
+ imgById.set(rid, { ...(thumbnailUrl ? { thumbnailUrl } : {}), ...(pageScreenshotUrl ? { pageScreenshotUrl } : {}) });
642
+ }
633
643
  }
634
644
  for (const s of tc.steps) {
635
- if (!s.objectRef?.id || s.objectRef.name)
645
+ if (!s.objectRef?.id)
646
+ continue;
647
+ const img = imgById.get(s.objectRef.id);
648
+ if (img)
649
+ Object.assign(s.objectRef, img);
650
+ if (s.objectRef.name)
636
651
  continue;
637
652
  const name = byId.get(s.objectRef.id);
638
653
  if (name)
@@ -1016,15 +1031,41 @@ export class SimplifyQaAdapter {
1016
1031
  return str(rows[0], 'id', '_id', 'featureId') ?? ref;
1017
1032
  }
1018
1033
  // ---------------------------------------------------------------------- Modules, releases, sprints
1034
+ /** Pages internally until the server returns a short page (or the total is reached) — the tool
1035
+ * has no limit/offset of its own and always wants the WHOLE project tree; `searchBody`'s default
1036
+ * page size (100) previously truncated any project with more modules than that silently. */
1019
1037
  async listModules() {
1020
1038
  const projectId = this.requireProjectId();
1021
- const body = this.searchBody([
1022
- { column: 'projectId', value: projectId },
1023
- { column: 'deleted', value: false },
1024
- ], { selectFields: ['id', 'code', 'name', 'parentId', 'isRoot', 'childIds'] });
1025
- const raw = await this.http.postJson('/pm/module/search', body);
1026
- const rows = pickArray(raw, 'data', 'content', 'modules');
1027
- return withTotal(rows.map(toModuleSummary), raw);
1039
+ const PAGE = 100;
1040
+ const SAFETY_CAP = 5000;
1041
+ const rows = [];
1042
+ let totalCount;
1043
+ let startIndex = 0;
1044
+ for (;;) {
1045
+ const body = this.searchBody([
1046
+ { column: 'projectId', value: projectId },
1047
+ { column: 'deleted', value: false },
1048
+ ], { selectFields: ['id', 'code', 'name', 'parentId', 'isRoot', 'childIds'], startIndex, limit: PAGE });
1049
+ const raw = await this.http.postJson('/pm/module/search', body);
1050
+ const page = pickArray(raw, 'data', 'content', 'modules');
1051
+ if (totalCount === undefined) {
1052
+ const t = raw?.totalCount ?? raw?.total ?? raw?.totalRecords;
1053
+ if (typeof t === 'number')
1054
+ totalCount = t;
1055
+ }
1056
+ rows.push(...page);
1057
+ if (page.length < PAGE)
1058
+ break;
1059
+ if (rows.length >= SAFETY_CAP)
1060
+ break;
1061
+ if (totalCount !== undefined && rows.length >= totalCount)
1062
+ break;
1063
+ startIndex += PAGE;
1064
+ }
1065
+ const mapped = rows.map(toModuleSummary);
1066
+ if (totalCount !== undefined)
1067
+ mapped.totalCount = totalCount;
1068
+ return mapped;
1028
1069
  }
1029
1070
  /** GET /pm/module/:id — server returns an array; empty array → NotFound. */
1030
1071
  async getModule(id) {
@@ -1227,23 +1268,73 @@ export class SimplifyQaAdapter {
1227
1268
  // reaches that branch after emptyIds.push).
1228
1269
  return { dryRun: false, deleted: true, dependencies: [], hasBlockers: false };
1229
1270
  }
1271
+ /** `list_releases` has no limit/offset of its own — it always wants the WHOLE visible set — so
1272
+ * page internally past `searchBody`'s default 100 instead of silently truncating (same fix as
1273
+ * listModules). */
1230
1274
  async listReleases(q) {
1231
1275
  const fields = [{ column: 'deleted', value: false }];
1232
1276
  if (q.scope === 'project')
1233
1277
  fields.push({ column: 'projectIds', value: this.requireProjectId() });
1234
- const body = this.searchBody(fields);
1235
- const raw = await this.http.postJson('/pm/release/search', body);
1236
- const rows = pickArray(raw, 'data', 'content', 'releases');
1237
- return withTotal(rows.map(toReleaseSummary), raw);
1278
+ const PAGE = 100;
1279
+ const SAFETY_CAP = 5000;
1280
+ const rows = [];
1281
+ let totalCount;
1282
+ let startIndex = 0;
1283
+ for (;;) {
1284
+ const body = this.searchBody(fields, { startIndex, limit: PAGE });
1285
+ const raw = await this.http.postJson('/pm/release/search', body);
1286
+ const page = pickArray(raw, 'data', 'content', 'releases');
1287
+ if (totalCount === undefined) {
1288
+ const t = raw?.totalCount ?? raw?.total ?? raw?.totalRecords;
1289
+ if (typeof t === 'number')
1290
+ totalCount = t;
1291
+ }
1292
+ rows.push(...page);
1293
+ if (page.length < PAGE)
1294
+ break;
1295
+ if (rows.length >= SAFETY_CAP)
1296
+ break;
1297
+ if (totalCount !== undefined && rows.length >= totalCount)
1298
+ break;
1299
+ startIndex += PAGE;
1300
+ }
1301
+ const mapped = rows.map(toReleaseSummary);
1302
+ if (totalCount !== undefined)
1303
+ mapped.totalCount = totalCount;
1304
+ return mapped;
1238
1305
  }
1306
+ /** Same unconditional-pagination fix as listReleases — `list_sprints` has no limit/offset either. */
1239
1307
  async listSprints(q) {
1240
1308
  const fields = [{ column: 'deleted', value: false }];
1241
1309
  if (q.releaseId)
1242
1310
  fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
1243
- const body = this.searchBody(fields);
1244
- const raw = await this.http.postJson('/pm/sprint/search', body);
1245
- const rows = pickArray(raw, 'data', 'content', 'sprints');
1246
- return withTotal(rows.map(toSprintSummary), raw);
1311
+ const PAGE = 100;
1312
+ const SAFETY_CAP = 5000;
1313
+ const rows = [];
1314
+ let totalCount;
1315
+ let startIndex = 0;
1316
+ for (;;) {
1317
+ const body = this.searchBody(fields, { startIndex, limit: PAGE });
1318
+ const raw = await this.http.postJson('/pm/sprint/search', body);
1319
+ const page = pickArray(raw, 'data', 'content', 'sprints');
1320
+ if (totalCount === undefined) {
1321
+ const t = raw?.totalCount ?? raw?.total ?? raw?.totalRecords;
1322
+ if (typeof t === 'number')
1323
+ totalCount = t;
1324
+ }
1325
+ rows.push(...page);
1326
+ if (page.length < PAGE)
1327
+ break;
1328
+ if (rows.length >= SAFETY_CAP)
1329
+ break;
1330
+ if (totalCount !== undefined && rows.length >= totalCount)
1331
+ break;
1332
+ startIndex += PAGE;
1333
+ }
1334
+ const mapped = rows.map(toSprintSummary);
1335
+ if (totalCount !== undefined)
1336
+ mapped.totalCount = totalCount;
1337
+ return mapped;
1247
1338
  }
1248
1339
  /** POST /pm/users/search — the customer's users (router mounted at `/pm/users`, plural — see
1249
1340
  * project-management/express-app.js:181). Backend auto-scopes non-admin callers to users in
@@ -1285,7 +1376,7 @@ export class SimplifyQaAdapter {
1285
1376
  let doc = raw?.data ?? raw;
1286
1377
  if (Array.isArray(doc))
1287
1378
  doc = doc.length > 0 ? doc[0] : {};
1288
- const obj = toObject(doc && typeof doc === 'object' ? doc : {});
1379
+ const obj = toObject(doc && typeof doc === 'object' ? doc : {}, this.cfg.baseUrl);
1289
1380
  if (opts?.enrichNames)
1290
1381
  await this.enrichObjectNames(obj);
1291
1382
  return obj;
@@ -2328,6 +2419,22 @@ export class SimplifyQaAdapter {
2328
2419
  paramIdByName.set(name, id);
2329
2420
  }
2330
2421
  }
2422
+ // API steps keep their `{{name}}` tokens INSIDE the request object (url/headers/body/auth),
2423
+ // not in the description — scan those too so the mapper can rewrite them to `{{<paramId>}}`
2424
+ // and register parameterIds. Without this, an API request containing {{var}} saves unbound.
2425
+ if (s.stepType === 'api') {
2426
+ const apiObj = (s.api ?? s.sourceRaw?.['apiData']);
2427
+ for (const name of extractApiParamNames(apiObj)) {
2428
+ // A purely-numeric token is an ALREADY-BOUND `{{<paramId>}}` (round-tripped from the
2429
+ // backend), not a parameter name — never create a parameter for it.
2430
+ if (/^\d+$/.test(name))
2431
+ continue;
2432
+ if (paramIdByName.has(name))
2433
+ continue;
2434
+ const id = await this.getOrCreateParameter(projectId, moduleId, name, paramDefault(name, tc.data, undefined));
2435
+ paramIdByName.set(name, id);
2436
+ }
2437
+ }
2331
2438
  }
2332
2439
  return paramIdByName;
2333
2440
  }
@@ -2433,6 +2540,14 @@ export class SimplifyQaAdapter {
2433
2540
  parameterId: [],
2434
2541
  description: '',
2435
2542
  };
2543
+ // Backend expects raw base64 PNG (no `data:` prefix) and stores it under /download/recording.
2544
+ const rawImage = (v) => (v ?? '').replace(/^data:image\/\w+;base64,/i, '').trim();
2545
+ const thumb = rawImage(objectRef.thumbnail_url);
2546
+ const pageShot = rawImage(objectRef.pageScreenShot_url);
2547
+ if (thumb)
2548
+ body['thumbnail_url'] = thumb;
2549
+ if (pageShot)
2550
+ body['pageScreenShot_url'] = pageShot;
2436
2551
  const raw = await this.http.postJson('/tm/object/createObjectSeq', body);
2437
2552
  const flat = raw?.data ?? raw;
2438
2553
  const id = str(flat, 'id', '_id', 'objectId');
@@ -2457,7 +2572,11 @@ export class SimplifyQaAdapter {
2457
2572
  // ponytail: control-flow / function / switch / function_end steps have no action to resolve.
2458
2573
  // Push a null-actionId slot so index alignment survives; mapper checks and skips action/object
2459
2574
  // emit for these. Without this, IF/FOR/WHILE/FUNCTION steps throw "missing actionType" on save.
2460
- if (s.stepType === 'control_flow' || s.stepType === 'function' || s.stepType === 'function_end' || s.stepType === 'switch' || s.stepType === 'manual') {
2575
+ // API steps are action-less too: Postman import and the SQA UI both persist them with
2576
+ // actionId:null and identify them by stepType:'API' + apiData. Resolving an action name here
2577
+ // would fail for a step that legitimately has none (e.g. the recorder's "API Call" is not a
2578
+ // backend action name — the seeded one is executeRestAPI).
2579
+ if (s.stepType === 'control_flow' || s.stepType === 'function' || s.stepType === 'function_end' || s.stepType === 'switch' || s.stepType === 'manual' || s.stepType === 'api') {
2461
2580
  out.push({ actionId: null, objectId: null });
2462
2581
  continue;
2463
2582
  }
@@ -2767,6 +2886,25 @@ export class SimplifyQaAdapter {
2767
2886
  doc = doc.length > 0 ? doc[0] : {};
2768
2887
  return doc && typeof doc === 'object' ? doc : {};
2769
2888
  }
2889
+ /** Mirrors sqa-ui `buildExecutionPlatform` (suites.component.ts:779) so MCP-added TCs save with
2890
+ * the same per-row platform the UI would set — otherwise the suite trips the "select execution
2891
+ * platform" warning at run time. Special types self-identify; WEB falls back to Chrome so
2892
+ * suite_run doesn't send `platform: []`; mobile with no bound device stays empty (UI does same). */
2893
+ buildExecutionPlatformForEntry(raw, tcType) {
2894
+ const special = new Set(['API', 'MAINFRAME', 'SAP', 'DESKTOP', 'DATABASE']);
2895
+ if (special.has(tcType)) {
2896
+ const p = { id: tcType, name: tcType, technologyType: tcType, type: 'NONE' };
2897
+ return { LOCAL: p, REMOTE: { ...p }, CLOUD: { ...p } };
2898
+ }
2899
+ const own = raw?.['executionPlatform'];
2900
+ if (own && typeof own === 'object' && own.id) {
2901
+ return { LOCAL: own, REMOTE: {}, CLOUD: {} };
2902
+ }
2903
+ if (tcType === 'WEB') {
2904
+ return { LOCAL: { id: 'CHROME', name: 'Chrome', technologyType: 'WEB', type: 'NONE' }, REMOTE: {}, CLOUD: {} };
2905
+ }
2906
+ return { LOCAL: {}, REMOTE: {}, CLOUD: {} };
2907
+ }
2770
2908
  /** Assemble the strict allow-listed suite entry for an ADD op. Required fields per the SQA
2771
2909
  * validator: testcaseId, code, seq (set by the caller), version, versionId, tcType. */
2772
2910
  async buildSuiteEntryFromTestcase(tcNum) {
@@ -2789,7 +2927,7 @@ export class SimplifyQaAdapter {
2789
2927
  ...(raw?.projectId !== undefined ? { projectId: raw.projectId } : {}),
2790
2928
  version: versionName ?? 'v1.0',
2791
2929
  versionId: versionId ?? '',
2792
- executionPlatform: { LOCAL: {}, REMOTE: {}, CLOUD: {} },
2930
+ executionPlatform: this.buildExecutionPlatformForEntry(raw, tcType),
2793
2931
  };
2794
2932
  }
2795
2933
  async resolveObjectTemplateId(technology) {
@@ -2833,7 +2971,7 @@ export class SimplifyQaAdapter {
2833
2971
  const body = this.searchBody(fields, { startIndex: q.offset ?? 0, limit: q.limit ?? 100 });
2834
2972
  const raw = await this.http.postJson('/tm/object/search', body);
2835
2973
  const rows = pickArray(raw, 'data', 'content', 'objects');
2836
- return rows.map(toObject);
2974
+ return rows.map((r) => toObject(r, this.cfg.baseUrl));
2837
2975
  }
2838
2976
  /**
2839
2977
  * Create a new object — `POST /tm/object`, plain JSON (NOT multipart, unlike testcase/suite writes;
@@ -2882,7 +3020,7 @@ export class SimplifyQaAdapter {
2882
3020
  };
2883
3021
  const raw = await this.http.postJson('/tm/object', body);
2884
3022
  const doc = (raw?.data && typeof raw.data === 'object' ? raw.data : raw) ?? {};
2885
- return toObject(doc);
3023
+ return toObject(doc, this.cfg.baseUrl);
2886
3024
  }
2887
3025
  /** The current project's module literally named "Default Module" (case-insensitive) — mirrors the
2888
3026
  * real sqa-ui `object.component.ts` `openForm()` auto-selection. Best-effort: returns undefined
@@ -3019,7 +3157,7 @@ export class SimplifyQaAdapter {
3019
3157
  };
3020
3158
  const res = await this.http.putJson(`/tm/object/${numId}`, body);
3021
3159
  const updated = (res?.data && typeof res.data === 'object' ? res.data : res) ?? doc;
3022
- const obj = toObject(updated);
3160
+ const obj = toObject(updated, this.cfg.baseUrl);
3023
3161
  if (opts?.enrichNames)
3024
3162
  await this.enrichObjectNames(obj);
3025
3163
  return obj;
@@ -3344,7 +3482,24 @@ export class SimplifyQaAdapter {
3344
3482
  executionId, executionRunId, parentId, projectId,
3345
3483
  });
3346
3484
  const rawSteps = Array.isArray(stepsResp?.data) ? stepsResp.data : [];
3347
- return rawSteps.map((s) => {
3485
+ // Backend logs control-flow markers as INFO rows with `description: null` (see
3486
+ // executionStepResult.service.js insertLoopIterationStart/insertFunctionEnd/… — the same
3487
+ // executionRunId carries LOOP_ITERATION_START/END, FUNCTION_START/END, FUNCTION_ITERATION_*,
3488
+ // WHILE_START/END alongside the real action rows). Left in, they fill the text response
3489
+ // with blank `• step N: (info)` lines that hide the actual failing step. Filter here so
3490
+ // stepResults contains only debuggable rows.
3491
+ const NOISE_STEP_TYPES = new Set([
3492
+ 'LOOP_ITERATION_START', 'LOOP_ITERATION_END',
3493
+ 'FUNCTION_START', 'FUNCTION_END',
3494
+ 'FUNCTION_ITERATION_START', 'FUNCTION_ITERATION_END',
3495
+ 'FOR_START', 'FOR_END', 'WHILE_START', 'WHILE_END',
3496
+ 'ITERATOR_START', 'ITERATOR_END', 'DO',
3497
+ ]);
3498
+ const filtered = rawSteps.filter((s) => {
3499
+ const st = String(s?.stepType ?? '').toUpperCase();
3500
+ return !NOISE_STEP_TYPES.has(st);
3501
+ });
3502
+ return filtered.map((s) => {
3348
3503
  const step = {
3349
3504
  n: typeof s?.seq === 'number' ? s.seq : 0,
3350
3505
  description: String(s?.description ?? ''),
@@ -3414,15 +3569,15 @@ export class SimplifyQaAdapter {
3414
3569
  reportUrl: `${base}/user/reports/reports-page?type=TESTCASE&parentId=${parentId}&id=${runIdNum}&projectId=${projectId}`,
3415
3570
  };
3416
3571
  }
3417
- /** Most recent execution runId for a SUITE — same `/exec/execution/search` the /reports UI's
3418
- * current-executions view drives, filtered to `type=SUITE` + this suite's numeric id, sorted by
3419
- * id desc, limit 1. Returns the numeric run id (what `getRun` accepts), or null if the suite has
3420
- * never been executed. */
3421
- async getLatestSuiteRun(suiteId) {
3572
+ /** Most recent execution runId for a SUITE or TC — same `/exec/execution/search` the /reports UI's
3573
+ * current-executions view drives, filtered by `type` + the entity's numeric id, sorted id desc,
3574
+ * limit 1. `childExecution:false` + the `parentExecutionCopy nor` clause together drop suite-child
3575
+ * TC rows, so a TC lookup returns its LATEST STANDALONE run, not the child spawned inside a suite. */
3576
+ async getLatestRunFor(type, id) {
3422
3577
  const projectId = this.requireProjectId();
3423
- const suiteNum = Number.parseInt(numericId(suiteId), 10);
3424
- if (!Number.isFinite(suiteNum))
3425
- throw new Error(`getLatestSuiteRun: invalid suiteId ${suiteId}.`);
3578
+ const num = Number.parseInt(numericId(id), 10);
3579
+ if (!Number.isFinite(num))
3580
+ throw new Error(`getLatestRunFor(${type}): invalid id ${id}.`);
3426
3581
  // Hand-rolled payload — searchBody doesn't emit `sort` or `operator` keys the /reports search wants.
3427
3582
  const body = {
3428
3583
  searchFields: [
@@ -3431,16 +3586,83 @@ export class SimplifyQaAdapter {
3431
3586
  { column: 'projectId', value: projectId, regEx: false },
3432
3587
  { column: 'childExecution', value: false },
3433
3588
  { column: 'parentExecutionCopy', value: true, operator: 'nor' },
3434
- { column: 'type', value: 'SUITE', regEx: false },
3435
- { column: 'executionTypeId', value: suiteNum, regEx: false },
3589
+ { column: 'type', value: type, regEx: false },
3590
+ { column: 'executionTypeId', value: num, regEx: false },
3436
3591
  ],
3437
3592
  startIndex: 0,
3438
3593
  limit: 1,
3439
3594
  };
3440
3595
  const raw = await this.http.postJson('/exec/execution/search', body);
3441
3596
  const rows = pickArray(raw, 'data', 'content', 'executions');
3442
- const runId = str(rows[0], 'id', '_id');
3443
- return runId ?? null;
3597
+ return str(rows[0], 'id', '_id') ?? null;
3598
+ }
3599
+ async getLatestSuiteRun(suiteId) { return this.getLatestRunFor('SUITE', suiteId); }
3600
+ async getLatestTestcaseRun(tcId) { return this.getLatestRunFor('TC', tcId); }
3601
+ /** Top-N recent runs — one HTTP call, same standalone-only filters as `getLatestRunFor`. For each
3602
+ * SUITE row the exec doc's `executionItems[]` already carries per-TC status, so the tally is
3603
+ * computed inline (no extra call per run). TC rows omit the tally counters. Sorted newest-first
3604
+ * by `id` (execution ids are monotonic). Bounded — callers should cap `limit` at the tool layer
3605
+ * so a runaway request can't pull thousands of rows. */
3606
+ async listRecentRuns(type, id, limit) {
3607
+ const projectId = this.requireProjectId();
3608
+ const num = Number.parseInt(numericId(id), 10);
3609
+ if (!Number.isFinite(num))
3610
+ throw new Error(`listRecentRuns(${type}): invalid id ${id}.`);
3611
+ const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
3612
+ const body = {
3613
+ searchFields: [
3614
+ { column: 'id', sort: 'dsc' },
3615
+ { column: 'deleted', value: false },
3616
+ { column: 'projectId', value: projectId, regEx: false },
3617
+ { column: 'childExecution', value: false },
3618
+ { column: 'parentExecutionCopy', value: true, operator: 'nor' },
3619
+ { column: 'type', value: type, regEx: false },
3620
+ { column: 'executionTypeId', value: num, regEx: false },
3621
+ ],
3622
+ startIndex: 0,
3623
+ limit,
3624
+ };
3625
+ const raw = await this.http.postJson('/exec/execution/search', body);
3626
+ const rows = pickArray(raw, 'data', 'content', 'executions');
3627
+ return rows.map((r) => {
3628
+ const runId = str(r, 'id', '_id') ?? '';
3629
+ const runIdNum = Number.parseInt(runId, 10);
3630
+ const summary = {
3631
+ runId,
3632
+ state: mapRunState(str(r, 'status') ?? ''),
3633
+ };
3634
+ const startedAt = str(r, 'startTime', 'createdAt');
3635
+ const endedAt = str(r, 'endTime');
3636
+ if (startedAt)
3637
+ summary.startedAt = startedAt;
3638
+ if (endedAt)
3639
+ summary.endedAt = endedAt;
3640
+ if (Number.isFinite(runIdNum)) {
3641
+ const entityNum = Number(r?.executionTypeId ?? num);
3642
+ summary.reportUrl = `${base}/user/reports/reports-page?type=${type === 'SUITE' ? 'SUITE' : 'TESTCASE'}&parentId=${entityNum}&id=${runIdNum}&projectId=${projectId}`;
3643
+ }
3644
+ if (type === 'SUITE') {
3645
+ const items = Array.isArray(r?.executionItems) ? r.executionItems : [];
3646
+ let passed = 0, failed = 0, skipped = 0, notExecuted = 0;
3647
+ for (const it of items) {
3648
+ const s = String(it?.status ?? '').toUpperCase();
3649
+ if (s === 'PASSED')
3650
+ passed++;
3651
+ else if (s === 'FAILED')
3652
+ failed++;
3653
+ else if (s === 'SKIPPED')
3654
+ skipped++;
3655
+ else if (s === 'NOTEXECUTED')
3656
+ notExecuted++;
3657
+ }
3658
+ summary.totalTestCases = items.length;
3659
+ summary.passed = passed;
3660
+ summary.failed = failed;
3661
+ summary.skipped = skipped;
3662
+ summary.notExecuted = notExecuted;
3663
+ }
3664
+ return summary;
3665
+ });
3444
3666
  }
3445
3667
  /** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
3446
3668
  * is paged (default 100) and API-key scoping can hide the selected project, which left
@@ -3635,11 +3857,15 @@ export class SimplifyQaAdapter {
3635
3857
  const executionId = Number.parseInt(numericId(runId), 10);
3636
3858
  if (!Number.isFinite(executionId))
3637
3859
  throw new Error(`stopRun: invalid runId ${runId}.`);
3638
- await this.http.postJson('/exec/execution/stopExecution', {
3639
- projectId,
3640
- executionId,
3641
- killType: 'STOP_EXECUTION',
3642
- });
3860
+ // Mirror sqa-ui reports.component.ts:2041-2054: cloud-parallel needs a dedicated killType so
3861
+ // the backend fans the Redis kill out to every child agent (executions/…/execution.service.js
3862
+ // :4610-4657). Plain STOP_EXECUTION only pushes to the parent's agentId, leaving cloud-parallel
3863
+ // child VMs running even though their DB rows read FAILED.
3864
+ const doc = await this.fetchExecDoc(executionId, projectId);
3865
+ const mode = String(doc?.mode ?? '').toUpperCase();
3866
+ const style = String(doc?.executionStyle ?? '').toUpperCase();
3867
+ const killType = mode === 'CLOUD' && style === 'PARALLEL' ? 'STOP_EXECUTION_PARALLEL_CLOUD' : 'STOP_EXECUTION';
3868
+ await this.http.postJson('/exec/execution/stopExecution', { projectId, executionId, killType });
3643
3869
  }
3644
3870
  /** SUITE dispatch. Mirrors `startRun` above (which handles TC) — same `/exec/execution` endpoint,
3645
3871
  * same LOCAL-agent fast-fail, same project-name / environment resolution. What differs is on the