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.
Files changed (48) hide show
  1. package/dist/adapters/remoteMcp/adapter.js +1 -1
  2. package/dist/adapters/simplifyqa/adapter.d.ts +48 -4
  3. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  4. package/dist/adapters/simplifyqa/adapter.js +585 -63
  5. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  6. package/dist/adapters/simplifyqa/mapper.d.ts +2 -2
  7. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  8. package/dist/adapters/simplifyqa/mapper.js +257 -64
  9. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  10. package/dist/adapters/types.d.ts +10 -1
  11. package/dist/adapters/types.d.ts.map +1 -1
  12. package/dist/domain/types.d.ts +46 -4
  13. package/dist/domain/types.d.ts.map +1 -1
  14. package/dist/domain/validate.d.ts.map +1 -1
  15. package/dist/domain/validate.js +5 -1
  16. package/dist/domain/validate.js.map +1 -1
  17. package/dist/drafts/store.d.ts +9 -0
  18. package/dist/drafts/store.d.ts.map +1 -1
  19. package/dist/drafts/store.js +12 -0
  20. package/dist/drafts/store.js.map +1 -1
  21. package/dist/initAgents.d.ts.map +1 -1
  22. package/dist/initAgents.js +48 -16
  23. package/dist/initAgents.js.map +1 -1
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +6 -1
  26. package/dist/server.js.map +1 -1
  27. package/dist/tools/recording.tools.d.ts.map +1 -1
  28. package/dist/tools/recording.tools.js +234 -0
  29. package/dist/tools/recording.tools.js.map +1 -1
  30. package/dist/tools/run.tools.d.ts +4 -0
  31. package/dist/tools/run.tools.d.ts.map +1 -1
  32. package/dist/tools/run.tools.js +360 -114
  33. package/dist/tools/run.tools.js.map +1 -1
  34. package/dist/tools/schemas.d.ts +41 -49
  35. package/dist/tools/schemas.d.ts.map +1 -1
  36. package/dist/tools/schemas.js +19 -1
  37. package/dist/tools/schemas.js.map +1 -1
  38. package/dist/tools/tcEditPublisher.d.ts +20 -0
  39. package/dist/tools/tcEditPublisher.d.ts.map +1 -0
  40. package/dist/tools/tcEditPublisher.js +46 -0
  41. package/dist/tools/tcEditPublisher.js.map +1 -0
  42. package/dist/tools/testcase.tools.d.ts.map +1 -1
  43. package/dist/tools/testcase.tools.js +303 -10
  44. package/dist/tools/testcase.tools.js.map +1 -1
  45. package/dist/tools/testdata.tools.d.ts.map +1 -1
  46. package/dist/tools/testdata.tools.js +60 -6
  47. package/dist/tools/testdata.tools.js.map +1 -1
  48. package/package.json +1 -1
@@ -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
  }
@@ -227,6 +231,9 @@ export class SimplifyQaAdapter {
227
231
  * Keyed by `${technologyType}::${lowerCaseName}` so the same action name across two techs
228
232
  * (e.g. "click" for WEB + ANDROID) resolves to the right numeric id. */
229
233
  this.actionCatalogCache = null;
234
+ /** Populated by actionCatalog() on each fetch. Surfaced in resolveAutomationSteps error messages
235
+ * so a user hitting "no SQA action named X" can see what the fetch actually returned. */
236
+ this.actionCatalogFetchDiag = 'never fetched';
230
237
  // ---------------------------------------------------------------------- Objects (read + delete)
231
238
  // list/create/delete now go direct REST (same /tm/object base getObject already uses), mirroring
232
239
  // the testcase/suite search + soft-delete convention.
@@ -254,6 +261,18 @@ export class SimplifyQaAdapter {
254
261
  ...(this.cfg.projectId ? { 'x-project-id': this.cfg.projectId } : {}),
255
262
  };
256
263
  }
264
+ /** Public session context for MCP viewer/recorder windows that need to authenticate the sqa-ui
265
+ * they load. cfg itself stays private; this getter is the one seam through which tc_edit_open
266
+ * and record tools can forward token + baseUrl + projectId to Electron without asking the LLM. */
267
+ getSessionContext() {
268
+ // Constructor already rejected missing baseUrl (~line 265), so `!` is safe here.
269
+ return {
270
+ token: this.cfg.token,
271
+ baseUrl: this.cfg.baseUrl,
272
+ ...(this.cfg.projectId ? { projectId: this.cfg.projectId } : {}),
273
+ ...(this.cfg.userId ? { userId: this.cfg.userId } : {}),
274
+ };
275
+ }
257
276
  requireProjectId() {
258
277
  if (!this.cfg.projectId)
259
278
  throw new Error(NEEDS_PROJECT);
@@ -470,7 +489,11 @@ export class SimplifyQaAdapter {
470
489
  // Overlay currentVersionId to the resolved one and flip isCurrent flags on versions[]. Apply
471
490
  // even when rows are empty — a caller who pinned versionId still expects the header + versions[]
472
491
  // to reflect that choice (a legitimately-empty version shouldn't fall back to the TC's default).
473
- const resolvedVer = versionId ?? tc.currentVersionId;
492
+ // ponytail: mirror fetchStepsCollection's resolution ladder — the raw doc from /tm/testcase/getById
493
+ // does NOT include a top-level versionId, only a `versions[]` array. Without the versions[0] fallback,
494
+ // tc.currentVersionId stays undefined → tc_save emits versionsDetails.id:"" → backend mints a
495
+ // phantom version and orphans the saved steps.
496
+ const resolvedVer = versionId ?? tc.currentVersionId ?? (Array.isArray(tc.versions) ? tc.versions[0]?.id : undefined);
474
497
  const patched = { ...withSteps, ...(resolvedVer ? { currentVersionId: resolvedVer } : {}) };
475
498
  if (resolvedVer && patched.versions?.length) {
476
499
  patched.versions = patched.versions.map((v) => ({ ...v, isCurrent: v.id === resolvedVer }));
@@ -997,15 +1020,41 @@ export class SimplifyQaAdapter {
997
1020
  return str(rows[0], 'id', '_id', 'featureId') ?? ref;
998
1021
  }
999
1022
  // ---------------------------------------------------------------------- Modules, releases, sprints
1023
+ /** Pages internally until the server returns a short page (or the total is reached) — the tool
1024
+ * has no limit/offset of its own and always wants the WHOLE project tree; `searchBody`'s default
1025
+ * page size (100) previously truncated any project with more modules than that silently. */
1000
1026
  async listModules() {
1001
1027
  const projectId = this.requireProjectId();
1002
- const body = this.searchBody([
1003
- { column: 'projectId', value: projectId },
1004
- { column: 'deleted', value: false },
1005
- ], { selectFields: ['id', 'code', 'name', 'parentId', 'isRoot', 'childIds'] });
1006
- const raw = await this.http.postJson('/pm/module/search', body);
1007
- const rows = pickArray(raw, 'data', 'content', 'modules');
1008
- return withTotal(rows.map(toModuleSummary), raw);
1028
+ const PAGE = 100;
1029
+ const SAFETY_CAP = 5000;
1030
+ const rows = [];
1031
+ let totalCount;
1032
+ let startIndex = 0;
1033
+ for (;;) {
1034
+ const body = this.searchBody([
1035
+ { column: 'projectId', value: projectId },
1036
+ { column: 'deleted', value: false },
1037
+ ], { selectFields: ['id', 'code', 'name', 'parentId', 'isRoot', 'childIds'], startIndex, limit: PAGE });
1038
+ const raw = await this.http.postJson('/pm/module/search', body);
1039
+ const page = pickArray(raw, 'data', 'content', 'modules');
1040
+ if (totalCount === undefined) {
1041
+ const t = raw?.totalCount ?? raw?.total ?? raw?.totalRecords;
1042
+ if (typeof t === 'number')
1043
+ totalCount = t;
1044
+ }
1045
+ rows.push(...page);
1046
+ if (page.length < PAGE)
1047
+ break;
1048
+ if (rows.length >= SAFETY_CAP)
1049
+ break;
1050
+ if (totalCount !== undefined && rows.length >= totalCount)
1051
+ break;
1052
+ startIndex += PAGE;
1053
+ }
1054
+ const mapped = rows.map(toModuleSummary);
1055
+ if (totalCount !== undefined)
1056
+ mapped.totalCount = totalCount;
1057
+ return mapped;
1009
1058
  }
1010
1059
  /** GET /pm/module/:id — server returns an array; empty array → NotFound. */
1011
1060
  async getModule(id) {
@@ -1208,23 +1257,73 @@ export class SimplifyQaAdapter {
1208
1257
  // reaches that branch after emptyIds.push).
1209
1258
  return { dryRun: false, deleted: true, dependencies: [], hasBlockers: false };
1210
1259
  }
1260
+ /** `list_releases` has no limit/offset of its own — it always wants the WHOLE visible set — so
1261
+ * page internally past `searchBody`'s default 100 instead of silently truncating (same fix as
1262
+ * listModules). */
1211
1263
  async listReleases(q) {
1212
1264
  const fields = [{ column: 'deleted', value: false }];
1213
1265
  if (q.scope === 'project')
1214
1266
  fields.push({ column: 'projectIds', value: this.requireProjectId() });
1215
- const body = this.searchBody(fields);
1216
- const raw = await this.http.postJson('/pm/release/search', body);
1217
- const rows = pickArray(raw, 'data', 'content', 'releases');
1218
- return withTotal(rows.map(toReleaseSummary), raw);
1267
+ const PAGE = 100;
1268
+ const SAFETY_CAP = 5000;
1269
+ const rows = [];
1270
+ let totalCount;
1271
+ let startIndex = 0;
1272
+ for (;;) {
1273
+ const body = this.searchBody(fields, { startIndex, limit: PAGE });
1274
+ const raw = await this.http.postJson('/pm/release/search', body);
1275
+ const page = pickArray(raw, 'data', 'content', 'releases');
1276
+ if (totalCount === undefined) {
1277
+ const t = raw?.totalCount ?? raw?.total ?? raw?.totalRecords;
1278
+ if (typeof t === 'number')
1279
+ totalCount = t;
1280
+ }
1281
+ rows.push(...page);
1282
+ if (page.length < PAGE)
1283
+ break;
1284
+ if (rows.length >= SAFETY_CAP)
1285
+ break;
1286
+ if (totalCount !== undefined && rows.length >= totalCount)
1287
+ break;
1288
+ startIndex += PAGE;
1289
+ }
1290
+ const mapped = rows.map(toReleaseSummary);
1291
+ if (totalCount !== undefined)
1292
+ mapped.totalCount = totalCount;
1293
+ return mapped;
1219
1294
  }
1295
+ /** Same unconditional-pagination fix as listReleases — `list_sprints` has no limit/offset either. */
1220
1296
  async listSprints(q) {
1221
1297
  const fields = [{ column: 'deleted', value: false }];
1222
1298
  if (q.releaseId)
1223
1299
  fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
1224
- const body = this.searchBody(fields);
1225
- const raw = await this.http.postJson('/pm/sprint/search', body);
1226
- const rows = pickArray(raw, 'data', 'content', 'sprints');
1227
- return withTotal(rows.map(toSprintSummary), raw);
1300
+ const PAGE = 100;
1301
+ const SAFETY_CAP = 5000;
1302
+ const rows = [];
1303
+ let totalCount;
1304
+ let startIndex = 0;
1305
+ for (;;) {
1306
+ const body = this.searchBody(fields, { startIndex, limit: PAGE });
1307
+ const raw = await this.http.postJson('/pm/sprint/search', body);
1308
+ const page = pickArray(raw, 'data', 'content', 'sprints');
1309
+ if (totalCount === undefined) {
1310
+ const t = raw?.totalCount ?? raw?.total ?? raw?.totalRecords;
1311
+ if (typeof t === 'number')
1312
+ totalCount = t;
1313
+ }
1314
+ rows.push(...page);
1315
+ if (page.length < PAGE)
1316
+ break;
1317
+ if (rows.length >= SAFETY_CAP)
1318
+ break;
1319
+ if (totalCount !== undefined && rows.length >= totalCount)
1320
+ break;
1321
+ startIndex += PAGE;
1322
+ }
1323
+ const mapped = rows.map(toSprintSummary);
1324
+ if (totalCount !== undefined)
1325
+ mapped.totalCount = totalCount;
1326
+ return mapped;
1228
1327
  }
1229
1328
  /** POST /pm/users/search — the customer's users (router mounted at `/pm/users`, plural — see
1230
1329
  * project-management/express-app.js:181). Backend auto-scopes non-admin callers to users in
@@ -2317,8 +2416,10 @@ export class SimplifyQaAdapter {
2317
2416
  return this.actionCatalogCache;
2318
2417
  const projectId = this.requireProjectId();
2319
2418
  this.actionCatalogCache = (async () => {
2320
- // `projectId in [0, projectId]` — 0 is the customer-wide default catalog, projectId is the
2321
- // per-project overrides layered on top. Same filter the SQA UI uses (steps.model.ts:59).
2419
+ // Use the SAME filter the SQA UI uses (mobile-app/angular/.../steps.model.ts:59):
2420
+ // {projectId: {$in:[0, projectId]}}. Removing it caused /tm/action/search to return 0 rows in
2421
+ // practice — the backend endpoint apparently requires it as-is despite server-side resolvers
2422
+ // using only {customerId, deleted:false}. Keep the UI's filter to stay compatible.
2322
2423
  const body = {
2323
2424
  searchFields: [
2324
2425
  { column: 'deleted', value: false },
@@ -2330,12 +2431,25 @@ export class SimplifyQaAdapter {
2330
2431
  };
2331
2432
  const raw = await this.http.postJson('/tm/action/search', body);
2332
2433
  const rows = pickArray(raw, 'data', 'content', 'actions');
2434
+ // Capture diagnostic snapshot so downstream errors can surface WHY the fetch returned 0 rows.
2435
+ try {
2436
+ const topKeys = raw && typeof raw === 'object' ? Object.keys(raw).slice(0, 10).join(',') : typeof raw;
2437
+ const totalCount = raw?.totalCount ?? raw?.total ?? 'n/a';
2438
+ const firstRowKeys = rows[0] && typeof rows[0] === 'object' ? Object.keys(rows[0]).slice(0, 12).join(',') : 'no-rows';
2439
+ this.actionCatalogFetchDiag = `rows.length=${rows.length} totalCount=${totalCount} raw.keys=[${topKeys}] firstRowKeys=[${firstRowKeys}] projectId=${projectId}`;
2440
+ }
2441
+ catch { /* diagnostic must not throw */ }
2333
2442
  const map = new Map();
2334
2443
  for (const row of rows) {
2335
2444
  const idStr = str(row, 'id', '_id', 'actionId');
2336
2445
  const name = str(row, 'name');
2337
- const tech = str(row, 'technology');
2338
- if (!idStr || !name || !tech)
2446
+ // Diagnostic proved action rows have NO top-level `technology` field
2447
+ // (firstRowKeys=[id,name,objectRequired,objectTemplate]). SQA UI derives tech by looking up
2448
+ // objectTemplate → object_templates.name — we don't (would need a second fetch). Just require
2449
+ // id; drop the tech-in-key and let names collide across techs. Round-trip lookups (by numeric
2450
+ // id) are unaffected. Model-authored by-name lookups get whichever action registered last for
2451
+ // that name — good enough until cross-tech name collisions actually bite.
2452
+ if (!idStr)
2339
2453
  continue;
2340
2454
  const id = Number.parseInt(idStr, 10);
2341
2455
  if (!Number.isFinite(id))
@@ -2345,14 +2459,17 @@ export class SimplifyQaAdapter {
2345
2459
  const objectTemplate = typeof otRaw === 'number' ? otRaw
2346
2460
  : typeof otRaw === 'string' && /^\d+$/.test(otRaw) ? Number.parseInt(otRaw, 10)
2347
2461
  : undefined;
2348
- // Prefer the higher-priority project override when the same (tech, name) has both a
2349
- // default and a project row: last write wins if we sort defaults first. Backend returns
2350
- // whichever ordering; we resolve determinism by preferring a non-zero projectId row.
2351
- const key = `${tech.toUpperCase()}::${name.toLowerCase()}`;
2352
- const prev = map.get(key);
2353
- const isProjectOverride = typeof row?.projectId === 'number' && row.projectId !== 0;
2354
- if (!prev || isProjectOverride)
2355
- map.set(key, { id, objectRequired, objectTemplate });
2462
+ const entry = { id, objectRequired, ...(objectTemplate !== undefined ? { objectTemplate } : {}) };
2463
+ // Name-only key for by-name lookups (may collide across techs — see comment above).
2464
+ if (name) {
2465
+ const key = name.toLowerCase();
2466
+ const prev = map.get(key);
2467
+ const isProjectOverride = typeof row?.projectId === 'number' && row.projectId !== 0;
2468
+ if (!prev || isProjectOverride)
2469
+ map.set(key, entry);
2470
+ }
2471
+ // Id-keyed entry so numeric-actionType round-trip lookups are O(1) instead of a scan.
2472
+ map.set(`id::${id}`, entry);
2356
2473
  }
2357
2474
  return map;
2358
2475
  })().catch((e) => { this.actionCatalogCache = null; throw e; });
@@ -2409,27 +2526,106 @@ export class SimplifyQaAdapter {
2409
2526
  async resolveAutomationSteps(projectId, moduleId, tc) {
2410
2527
  const catalog = await this.actionCatalog();
2411
2528
  const steps = Array.isArray(tc.steps) ? tc.steps : [];
2529
+ // ponytail: objectId can be `undefined` here to mean "don't emit — preserve the backend value".
2530
+ // Used when an existing step (sourceId set) has no round-trip objectRef.id, e.g. because it was
2531
+ // recorded before objectRequired was flipped on the action. Emitting null would $set-clobber the DB.
2532
+ // actionId also nullable for control-flow / function / switch / function_end — those steps don't
2533
+ // have an action; buildAutomationPayload omits action/object emit when actionId is null.
2412
2534
  const out = [];
2413
2535
  for (let i = 0; i < steps.length; i++) {
2414
2536
  const s = steps[i];
2537
+ // ponytail: control-flow / function / switch / function_end steps have no action to resolve.
2538
+ // Push a null-actionId slot so index alignment survives; mapper checks and skips action/object
2539
+ // emit for these. Without this, IF/FOR/WHILE/FUNCTION steps throw "missing actionType" on save.
2540
+ if (s.stepType === 'control_flow' || s.stepType === 'function' || s.stepType === 'function_end' || s.stepType === 'switch' || s.stepType === 'manual') {
2541
+ out.push({ actionId: null, objectId: null });
2542
+ continue;
2543
+ }
2415
2544
  const tech = (TECH_BY_STEPTYPE[s.stepType] ?? deriveTcType(tc)).toUpperCase();
2416
2545
  const name = (s.actionType ?? '').trim();
2417
2546
  if (!name) {
2418
2547
  throw new Error(`Automation step ${i + 1} ("${s.description}"): missing actionType — the neutral Step.actionType must name an SQA action (e.g. "Click", "Type Text", "Launch Application").`);
2419
2548
  }
2420
- const meta = catalog.get(`${tech}::${name.toLowerCase()}`);
2549
+ // Round-trip support: a TC hydrated via getTestCase carries actionType = String(actionId), so
2550
+ // on save we may see a numeric string here. Catalog is dual-keyed: `id::N` for round-trip,
2551
+ // `<lowercase name>` for model-authored by-name lookups. Tech is not in the key because
2552
+ // action rows have no top-level `technology` field (SQA UI resolves via objectTemplate lookup).
2553
+ const isNumericId = /^\d+$/.test(name);
2554
+ let meta = isNumericId
2555
+ ? catalog.get(`id::${Number.parseInt(name, 10)}`)
2556
+ : catalog.get(name.toLowerCase());
2557
+ // Fallback: if the by-id scan misses (action created in a different project and thus not in
2558
+ // the UI's projectId-filtered catalog), fetch it directly via GET /tm/action/:id. Only tries
2559
+ // for numeric-id lookups — model-authored name lookups have no id to fetch.
2560
+ let fallbackDiag = '';
2561
+ if (!meta && isNumericId) {
2562
+ try {
2563
+ const raw = await this.http.getJson(`/tm/action/${Number.parseInt(name, 10)}`);
2564
+ const row = raw?.data && typeof raw.data === 'object' ? raw.data
2565
+ : Array.isArray(raw?.data) ? raw.data[0]
2566
+ : Array.isArray(raw) ? raw[0]
2567
+ : raw;
2568
+ const idStr = str(row, 'id', '_id', 'actionId');
2569
+ // ponytail: no `technology` gate. Catalog builder proved action rows have no top-level
2570
+ // technology field (see comment at ~line 2357); requiring it here made the fallback
2571
+ // never succeed — every cross-project action id threw "no SQA action named X".
2572
+ if (idStr) {
2573
+ const otRaw = row?.objectTemplate;
2574
+ const objectTemplate = typeof otRaw === 'number' ? otRaw
2575
+ : typeof otRaw === 'string' && /^\d+$/.test(otRaw) ? Number.parseInt(otRaw, 10)
2576
+ : undefined;
2577
+ meta = {
2578
+ id: Number.parseInt(idStr, 10),
2579
+ objectRequired: row?.objectRequired === true,
2580
+ ...(objectTemplate !== undefined ? { objectTemplate } : {}),
2581
+ };
2582
+ }
2583
+ else {
2584
+ fallbackDiag = ` [GET /tm/action/${name} returned no usable row (idStr=null). Raw keys: ${row && typeof row === 'object' ? Object.keys(row).join(',') : typeof row}]`;
2585
+ }
2586
+ }
2587
+ catch (e) {
2588
+ fallbackDiag = ` [GET /tm/action/${name} failed: ${e.message}]`;
2589
+ }
2590
+ }
2421
2591
  if (!meta) {
2422
- throw new Error(`Automation step ${i + 1}: no SQA action named "${name}" for technology ${tech}. Check the action catalog in the SQA UI.`);
2592
+ throw new Error(`Automation step ${i + 1}: no SQA action named "${name}" for technology ${tech}. Catalog size=${catalog.size}, numericId=${isNumericId}. FetchDiag: ${this.actionCatalogFetchDiag}.${fallbackDiag}`);
2423
2593
  }
2424
2594
  let objectId = null;
2425
2595
  if (meta.objectRequired) {
2426
- if (!s.objectRef || !s.objectRef.name || !(s.objectRef.locators?.length)) {
2427
- throw new Error(`Automation step ${i + 1} ("${name}"): action requires an object but Step.objectRef is missing name/locators. Provide objectRef:{name, locators:[{name, value}]}.`);
2596
+ // Resolution ladder (first match wins):
2597
+ // 1. objectRef.id set → use directly (round-trip OR LLM referencing a known object)
2598
+ // 2. sourceId set but no objectRef.id → preserve DB value (existing step, backend may
2599
+ // have null historically; emitting anything would $set-clobber)
2600
+ // 3. objectRef.name only (no locators) → project-scoped name lookup (LLM added a step
2601
+ // referencing an existing Object Repository entry by name — mirrors UI dropdown)
2602
+ // 4. objectRef.name + locators → get-or-create via createObjectSeq (recorder path)
2603
+ // 5. nothing usable → throw with the three-option error so the caller can pick
2604
+ const roundTripId = s.objectRef?.id ? Number.parseInt(String(s.objectRef.id), 10) : NaN;
2605
+ if (Number.isFinite(roundTripId)) {
2606
+ objectId = roundTripId;
2607
+ }
2608
+ else if (s.sourceId) {
2609
+ objectId = undefined;
2610
+ }
2611
+ else if (s.objectRef?.name && !(s.objectRef.locators?.length)) {
2612
+ const found = await this.findObjectIdByName(projectId, s.objectRef.name);
2613
+ if (found != null) {
2614
+ objectId = found;
2615
+ }
2616
+ else {
2617
+ throw new Error(`Automation step ${i + 1} ("${name}"): no object named "${s.objectRef.name}" found in this project. Either pass objectRef.id to reuse an existing one, or add objectRef.locators:[{name, value}, ...] to create a new object with that name.`);
2618
+ }
2428
2619
  }
2429
- if (meta.objectTemplate == null) {
2430
- throw new Error(`Automation step ${i + 1} ("${name}"): action ${meta.id} has no objectTemplate — cannot create/reuse an object without knowing which template it belongs to.`);
2620
+ else if (s.objectRef?.name && s.objectRef.locators?.length) {
2621
+ if (meta.objectTemplate == null) {
2622
+ throw new Error(`Automation step ${i + 1} ("${name}"): action ${meta.id} has no objectTemplate — cannot create/reuse an object without knowing which template it belongs to.`);
2623
+ }
2624
+ objectId = await this.resolveObjectId(s.objectRef, projectId, moduleId, tech, meta.objectTemplate);
2625
+ }
2626
+ else {
2627
+ throw new Error(`Automation step ${i + 1} ("${name}"): action requires an object but Step.objectRef is missing. Provide objectRef:{id} to reuse a known one, objectRef:{name} to look one up by name in the project, or objectRef:{name, locators:[{name, value}]} to create a new one.`);
2431
2628
  }
2432
- objectId = await this.resolveObjectId(s.objectRef, projectId, moduleId, tech, meta.objectTemplate);
2433
2629
  }
2434
2630
  out.push({ actionId: meta.id, objectId });
2435
2631
  }
@@ -2458,6 +2654,28 @@ export class SimplifyQaAdapter {
2458
2654
  }
2459
2655
  return null;
2460
2656
  }
2657
+ /** Case-insensitive project-scoped lookup of an existing object by `disname` (the UI's Name).
2658
+ * Lets tc_draft_update pass `objectRef:{name}` for an already-defined object without also
2659
+ * supplying locators — mirrors what a user picking from the Object Repository dropdown does. */
2660
+ async findObjectIdByName(projectId, name) {
2661
+ const body = this.searchBody([
2662
+ { column: 'disname', value: name },
2663
+ { column: 'projectId', value: projectId },
2664
+ { column: 'deleted', value: false },
2665
+ ], { startIndex: 0, limit: 10 });
2666
+ body['searchFields'][0]['ignorecase'] = true;
2667
+ const raw = await this.http.postJson('/tm/object/search', body);
2668
+ const rows = pickArray(raw, 'data', 'content', 'objects');
2669
+ for (const row of rows) {
2670
+ const rowName = str(row, 'disname');
2671
+ if (rowName?.toLowerCase() === name.toLowerCase()) {
2672
+ const id = str(row, 'id', '_id');
2673
+ if (id)
2674
+ return Number.parseInt(id, 10);
2675
+ }
2676
+ }
2677
+ return null;
2678
+ }
2461
2679
  async getOrCreateParameter(projectId, moduleId, name, defaultValue) {
2462
2680
  const existing = await this.findParameterId(projectId, name);
2463
2681
  if (existing != null)
@@ -2629,6 +2847,25 @@ export class SimplifyQaAdapter {
2629
2847
  doc = doc.length > 0 ? doc[0] : {};
2630
2848
  return doc && typeof doc === 'object' ? doc : {};
2631
2849
  }
2850
+ /** Mirrors sqa-ui `buildExecutionPlatform` (suites.component.ts:779) so MCP-added TCs save with
2851
+ * the same per-row platform the UI would set — otherwise the suite trips the "select execution
2852
+ * platform" warning at run time. Special types self-identify; WEB falls back to Chrome so
2853
+ * suite_run doesn't send `platform: []`; mobile with no bound device stays empty (UI does same). */
2854
+ buildExecutionPlatformForEntry(raw, tcType) {
2855
+ const special = new Set(['API', 'MAINFRAME', 'SAP', 'DESKTOP', 'DATABASE']);
2856
+ if (special.has(tcType)) {
2857
+ const p = { id: tcType, name: tcType, technologyType: tcType, type: 'NONE' };
2858
+ return { LOCAL: p, REMOTE: { ...p }, CLOUD: { ...p } };
2859
+ }
2860
+ const own = raw?.['executionPlatform'];
2861
+ if (own && typeof own === 'object' && own.id) {
2862
+ return { LOCAL: own, REMOTE: {}, CLOUD: {} };
2863
+ }
2864
+ if (tcType === 'WEB') {
2865
+ return { LOCAL: { id: 'CHROME', name: 'Chrome', technologyType: 'WEB', type: 'NONE' }, REMOTE: {}, CLOUD: {} };
2866
+ }
2867
+ return { LOCAL: {}, REMOTE: {}, CLOUD: {} };
2868
+ }
2632
2869
  /** Assemble the strict allow-listed suite entry for an ADD op. Required fields per the SQA
2633
2870
  * validator: testcaseId, code, seq (set by the caller), version, versionId, tcType. */
2634
2871
  async buildSuiteEntryFromTestcase(tcNum) {
@@ -2651,7 +2888,7 @@ export class SimplifyQaAdapter {
2651
2888
  ...(raw?.projectId !== undefined ? { projectId: raw.projectId } : {}),
2652
2889
  version: versionName ?? 'v1.0',
2653
2890
  versionId: versionId ?? '',
2654
- executionPlatform: { LOCAL: {}, REMOTE: {}, CLOUD: {} },
2891
+ executionPlatform: this.buildExecutionPlatformForEntry(raw, tcType),
2655
2892
  };
2656
2893
  }
2657
2894
  async resolveObjectTemplateId(technology) {
@@ -2888,7 +3125,19 @@ export class SimplifyQaAdapter {
2888
3125
  }
2889
3126
  // ---------------------------------------------------------------------- Actions catalog
2890
3127
  async actionsCatalog(platform) {
2891
- return this.requireHop('actions_catalog').actionsCatalog(platform);
3128
+ // Build from the live backend catalog (already cached per-connection). Each entry gets its
3129
+ // numeric id + objectRequired flag so callers can round-trip actionType ↔ actionId. Ignores
3130
+ // `platform` — action rows carry no top-level technology; tech disambiguation is a follow-up
3131
+ // (would need an object_templates lookup). Skips the `id::N` reverse-lookup keys.
3132
+ void platform;
3133
+ const catalog = await this.actionCatalog();
3134
+ const defs = [];
3135
+ for (const [key, meta] of catalog) {
3136
+ if (key.startsWith('id::'))
3137
+ continue; // reverse-lookup entry — already covered by name key
3138
+ defs.push({ actionType: key, platform: 'unknown', id: meta.id, objectRequired: meta.objectRequired });
3139
+ }
3140
+ return defs;
2892
3141
  }
2893
3142
  // ---------------------------------------------------------------------- Testdata generation [Step 3]
2894
3143
  /** Fetch the default (template) iteration row for a TC. Fills gaps from the raw TC:
@@ -3194,7 +3443,24 @@ export class SimplifyQaAdapter {
3194
3443
  executionId, executionRunId, parentId, projectId,
3195
3444
  });
3196
3445
  const rawSteps = Array.isArray(stepsResp?.data) ? stepsResp.data : [];
3197
- return rawSteps.map((s) => {
3446
+ // Backend logs control-flow markers as INFO rows with `description: null` (see
3447
+ // executionStepResult.service.js insertLoopIterationStart/insertFunctionEnd/… — the same
3448
+ // executionRunId carries LOOP_ITERATION_START/END, FUNCTION_START/END, FUNCTION_ITERATION_*,
3449
+ // WHILE_START/END alongside the real action rows). Left in, they fill the text response
3450
+ // with blank `• step N: (info)` lines that hide the actual failing step. Filter here so
3451
+ // stepResults contains only debuggable rows.
3452
+ const NOISE_STEP_TYPES = new Set([
3453
+ 'LOOP_ITERATION_START', 'LOOP_ITERATION_END',
3454
+ 'FUNCTION_START', 'FUNCTION_END',
3455
+ 'FUNCTION_ITERATION_START', 'FUNCTION_ITERATION_END',
3456
+ 'FOR_START', 'FOR_END', 'WHILE_START', 'WHILE_END',
3457
+ 'ITERATOR_START', 'ITERATOR_END', 'DO',
3458
+ ]);
3459
+ const filtered = rawSteps.filter((s) => {
3460
+ const st = String(s?.stepType ?? '').toUpperCase();
3461
+ return !NOISE_STEP_TYPES.has(st);
3462
+ });
3463
+ return filtered.map((s) => {
3198
3464
  const step = {
3199
3465
  n: typeof s?.seq === 'number' ? s.seq : 0,
3200
3466
  description: String(s?.description ?? ''),
@@ -3264,15 +3530,15 @@ export class SimplifyQaAdapter {
3264
3530
  reportUrl: `${base}/user/reports/reports-page?type=TESTCASE&parentId=${parentId}&id=${runIdNum}&projectId=${projectId}`,
3265
3531
  };
3266
3532
  }
3267
- /** Most recent execution runId for a SUITE — same `/exec/execution/search` the /reports UI's
3268
- * current-executions view drives, filtered to `type=SUITE` + this suite's numeric id, sorted by
3269
- * id desc, limit 1. Returns the numeric run id (what `getRun` accepts), or null if the suite has
3270
- * never been executed. */
3271
- async getLatestSuiteRun(suiteId) {
3533
+ /** Most recent execution runId for a SUITE or TC — same `/exec/execution/search` the /reports UI's
3534
+ * current-executions view drives, filtered by `type` + the entity's numeric id, sorted id desc,
3535
+ * limit 1. `childExecution:false` + the `parentExecutionCopy nor` clause together drop suite-child
3536
+ * TC rows, so a TC lookup returns its LATEST STANDALONE run, not the child spawned inside a suite. */
3537
+ async getLatestRunFor(type, id) {
3272
3538
  const projectId = this.requireProjectId();
3273
- const suiteNum = Number.parseInt(numericId(suiteId), 10);
3274
- if (!Number.isFinite(suiteNum))
3275
- throw new Error(`getLatestSuiteRun: invalid suiteId ${suiteId}.`);
3539
+ const num = Number.parseInt(numericId(id), 10);
3540
+ if (!Number.isFinite(num))
3541
+ throw new Error(`getLatestRunFor(${type}): invalid id ${id}.`);
3276
3542
  // Hand-rolled payload — searchBody doesn't emit `sort` or `operator` keys the /reports search wants.
3277
3543
  const body = {
3278
3544
  searchFields: [
@@ -3281,16 +3547,83 @@ export class SimplifyQaAdapter {
3281
3547
  { column: 'projectId', value: projectId, regEx: false },
3282
3548
  { column: 'childExecution', value: false },
3283
3549
  { column: 'parentExecutionCopy', value: true, operator: 'nor' },
3284
- { column: 'type', value: 'SUITE', regEx: false },
3285
- { column: 'executionTypeId', value: suiteNum, regEx: false },
3550
+ { column: 'type', value: type, regEx: false },
3551
+ { column: 'executionTypeId', value: num, regEx: false },
3286
3552
  ],
3287
3553
  startIndex: 0,
3288
3554
  limit: 1,
3289
3555
  };
3290
3556
  const raw = await this.http.postJson('/exec/execution/search', body);
3291
3557
  const rows = pickArray(raw, 'data', 'content', 'executions');
3292
- const runId = str(rows[0], 'id', '_id');
3293
- return runId ?? null;
3558
+ return str(rows[0], 'id', '_id') ?? null;
3559
+ }
3560
+ async getLatestSuiteRun(suiteId) { return this.getLatestRunFor('SUITE', suiteId); }
3561
+ async getLatestTestcaseRun(tcId) { return this.getLatestRunFor('TC', tcId); }
3562
+ /** Top-N recent runs — one HTTP call, same standalone-only filters as `getLatestRunFor`. For each
3563
+ * SUITE row the exec doc's `executionItems[]` already carries per-TC status, so the tally is
3564
+ * computed inline (no extra call per run). TC rows omit the tally counters. Sorted newest-first
3565
+ * by `id` (execution ids are monotonic). Bounded — callers should cap `limit` at the tool layer
3566
+ * so a runaway request can't pull thousands of rows. */
3567
+ async listRecentRuns(type, id, limit) {
3568
+ const projectId = this.requireProjectId();
3569
+ const num = Number.parseInt(numericId(id), 10);
3570
+ if (!Number.isFinite(num))
3571
+ throw new Error(`listRecentRuns(${type}): invalid id ${id}.`);
3572
+ const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
3573
+ const body = {
3574
+ searchFields: [
3575
+ { column: 'id', sort: 'dsc' },
3576
+ { column: 'deleted', value: false },
3577
+ { column: 'projectId', value: projectId, regEx: false },
3578
+ { column: 'childExecution', value: false },
3579
+ { column: 'parentExecutionCopy', value: true, operator: 'nor' },
3580
+ { column: 'type', value: type, regEx: false },
3581
+ { column: 'executionTypeId', value: num, regEx: false },
3582
+ ],
3583
+ startIndex: 0,
3584
+ limit,
3585
+ };
3586
+ const raw = await this.http.postJson('/exec/execution/search', body);
3587
+ const rows = pickArray(raw, 'data', 'content', 'executions');
3588
+ return rows.map((r) => {
3589
+ const runId = str(r, 'id', '_id') ?? '';
3590
+ const runIdNum = Number.parseInt(runId, 10);
3591
+ const summary = {
3592
+ runId,
3593
+ state: mapRunState(str(r, 'status') ?? ''),
3594
+ };
3595
+ const startedAt = str(r, 'startTime', 'createdAt');
3596
+ const endedAt = str(r, 'endTime');
3597
+ if (startedAt)
3598
+ summary.startedAt = startedAt;
3599
+ if (endedAt)
3600
+ summary.endedAt = endedAt;
3601
+ if (Number.isFinite(runIdNum)) {
3602
+ const entityNum = Number(r?.executionTypeId ?? num);
3603
+ summary.reportUrl = `${base}/user/reports/reports-page?type=${type === 'SUITE' ? 'SUITE' : 'TESTCASE'}&parentId=${entityNum}&id=${runIdNum}&projectId=${projectId}`;
3604
+ }
3605
+ if (type === 'SUITE') {
3606
+ const items = Array.isArray(r?.executionItems) ? r.executionItems : [];
3607
+ let passed = 0, failed = 0, skipped = 0, notExecuted = 0;
3608
+ for (const it of items) {
3609
+ const s = String(it?.status ?? '').toUpperCase();
3610
+ if (s === 'PASSED')
3611
+ passed++;
3612
+ else if (s === 'FAILED')
3613
+ failed++;
3614
+ else if (s === 'SKIPPED')
3615
+ skipped++;
3616
+ else if (s === 'NOTEXECUTED')
3617
+ notExecuted++;
3618
+ }
3619
+ summary.totalTestCases = items.length;
3620
+ summary.passed = passed;
3621
+ summary.failed = failed;
3622
+ summary.skipped = skipped;
3623
+ summary.notExecuted = notExecuted;
3624
+ }
3625
+ return summary;
3626
+ });
3294
3627
  }
3295
3628
  /** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
3296
3629
  * is paged (default 100) and API-key scoping can hide the selected project, which left
@@ -3314,11 +3647,10 @@ export class SimplifyQaAdapter {
3314
3647
  // SUITE runs are not yet ported and still hop (or error clearly).
3315
3648
  async startRun(target, opts) {
3316
3649
  const projectId = this.requireProjectId();
3650
+ if (target.suiteId && !target.tcId) {
3651
+ return this.startSuiteRun(target.suiteId, opts);
3652
+ }
3317
3653
  if (!target.tcId) {
3318
- if (target.suiteId) {
3319
- throw new Error('startRun: SUITE runs are not yet ported to direct REST — set QA_MCP_URL so common-mcp ' +
3320
- 'can hop to the SimplifyQA agent for suite dispatch, or run a single TC.');
3321
- }
3322
3654
  throw new Error('startRun: target.tcId (or target.suiteId) is required.');
3323
3655
  }
3324
3656
  const tcId = target.tcId;
@@ -3486,11 +3818,201 @@ export class SimplifyQaAdapter {
3486
3818
  const executionId = Number.parseInt(numericId(runId), 10);
3487
3819
  if (!Number.isFinite(executionId))
3488
3820
  throw new Error(`stopRun: invalid runId ${runId}.`);
3489
- await this.http.postJson('/exec/execution/stopExecution', {
3821
+ // Mirror sqa-ui reports.component.ts:2041-2054: cloud-parallel needs a dedicated killType so
3822
+ // the backend fans the Redis kill out to every child agent (executions/…/execution.service.js
3823
+ // :4610-4657). Plain STOP_EXECUTION only pushes to the parent's agentId, leaving cloud-parallel
3824
+ // child VMs running even though their DB rows read FAILED.
3825
+ const doc = await this.fetchExecDoc(executionId, projectId);
3826
+ const mode = String(doc?.mode ?? '').toUpperCase();
3827
+ const style = String(doc?.executionStyle ?? '').toUpperCase();
3828
+ const killType = mode === 'CLOUD' && style === 'PARALLEL' ? 'STOP_EXECUTION_PARALLEL_CLOUD' : 'STOP_EXECUTION';
3829
+ await this.http.postJson('/exec/execution/stopExecution', { projectId, executionId, killType });
3830
+ }
3831
+ /** SUITE dispatch. Mirrors `startRun` above (which handles TC) — same `/exec/execution` endpoint,
3832
+ * same LOCAL-agent fast-fail, same project-name / environment resolution. What differs is on the
3833
+ * wire: `type: 'SUITE'`, `executionType` sourced from `suite.suiteType`, `mode` defaulting to
3834
+ * `suite.executionType`, a populated `globalConfiguration` block, and one `executionItems` entry
3835
+ * PER member — each carrying the suite row's persisted `versionId` / `stepId` / `platform` / `seq`
3836
+ * (NOT a re-derived random step id). See sqa-ui execution-popup-modal.component.ts:621-1105 SU
3837
+ * branch for the authoritative UI builder; every field below traces back to a line there. */
3838
+ async startSuiteRun(suiteId, opts) {
3839
+ const projectId = this.requireProjectId();
3840
+ const suiteNum = Number.parseInt(numericId(suiteId), 10);
3841
+ if (!Number.isFinite(suiteNum))
3842
+ throw new Error(`startRun: could not resolve numeric id for ${suiteId}.`);
3843
+ const rawSuite = await this.fetchRawSuite(suiteId);
3844
+ if (!rawSuite || Object.keys(rawSuite).length === 0) {
3845
+ throw new Error(`startRun: suite ${suiteId} not found.`);
3846
+ }
3847
+ // Effective mode: caller pick wins (matches our tool's cascade UX); fall back to the suite doc's
3848
+ // stored `executionType`, which the UI reads unconditionally (execution-popup-modal.ts:668).
3849
+ const mode = (opts.mode ?? String(rawSuite.executionType ?? 'LOCAL')).toUpperCase();
3850
+ if (mode === 'LOCAL' && !(await this.probeLocalAgent())) {
3851
+ throw new Error(`startRun (LOCAL): SimplifyQA desktop agent is not reachable at ` +
3852
+ `${this.cfg.url ?? '(no agent URL configured — set QA_MCP_URL)'}. Start the desktop ` +
3853
+ `agent, then try again. Use mode:'cloud' to run on a cloud runner instead — it does not ` +
3854
+ `need the desktop agent.`);
3855
+ }
3856
+ const projectName = await this.lookupProjectName(projectId);
3857
+ if (!projectName) {
3858
+ throw new Error(`startRun: could not resolve project name for projectId=${projectId}. ` +
3859
+ `The token may not have read access to this project.`);
3860
+ }
3861
+ const env = await this.resolveEnvironment(projectId, opts.environment);
3862
+ const suiteType = String(rawSuite.suiteType ?? 'AUTOMATION').toUpperCase();
3863
+ // Build one executionItems entry per suite row, in suite order. Silently skip rows whose TC no
3864
+ // longer exists (matches UI :880 `if (!data) return;`). Every item uses the suite row's stored
3865
+ // versionId + stepId + per-mode platform — regenerating any of those splits step correlation
3866
+ // and breaks the agent's iteration join. Missing platform for a row is not an error here (UI
3867
+ // sends `[]`) — the agent tolerates it for MANUAL suites; AUTOMATION suites without a saved
3868
+ // platform were misconfigured upstream and would fail regardless.
3869
+ const members = Array.isArray(rawSuite.testCases) ? rawSuite.testCases : [];
3870
+ const executionItems = [];
3871
+ let seqCounter = 0;
3872
+ for (const row of members) {
3873
+ seqCounter++;
3874
+ const memberTcId = row?.testcaseId;
3875
+ if (memberTcId === undefined || memberTcId === null)
3876
+ continue;
3877
+ let rawTc;
3878
+ try {
3879
+ rawTc = await this.fetchRawTestcase(String(memberTcId));
3880
+ }
3881
+ catch {
3882
+ continue;
3883
+ }
3884
+ if (!rawTc || Object.keys(rawTc).length === 0)
3885
+ continue;
3886
+ const tcType = String(str(rawTc, 'tcType') ?? 'WEB').toUpperCase();
3887
+ const versions = Array.isArray(rawTc?.versions) ? rawTc.versions : [];
3888
+ const rowVersionId = row?.versionId ?? row?.version;
3889
+ const versionMatch = rowVersionId
3890
+ ? versions.find((v) => str(v, 'id', '_id') === String(rowVersionId) || str(v, 'name') === String(rowVersionId))
3891
+ : undefined;
3892
+ const versionId = str(versionMatch, 'id', '_id') ?? (rowVersionId !== undefined ? String(rowVersionId) : '');
3893
+ const versionName = str(versionMatch, 'name') ?? str(rawTc, 'versionName', 'selectedVersionName') ?? 'v1.0';
3894
+ // Per-mode platform from the suite row. UI reads `data1.executionPlatform[mode]` (:882-965);
3895
+ // when saved, it's a single object → wrap in `[obj]`; if the row didn't save one for this
3896
+ // mode, send `[]` and let the backend reject/accept per its own rules.
3897
+ const savedPlatform = row?.executionPlatform?.[mode];
3898
+ let platform = [];
3899
+ if (savedPlatform && typeof savedPlatform === 'object') {
3900
+ const p = {
3901
+ name: savedPlatform.name,
3902
+ id: savedPlatform.id ?? savedPlatform.name,
3903
+ technologyType: String(savedPlatform.technologyType ?? tcType).toUpperCase(),
3904
+ type: savedPlatform.type ?? 'NONE',
3905
+ };
3906
+ if (savedPlatform.cloudDeviceType)
3907
+ p['cloudDeviceType'] = savedPlatform.cloudDeviceType;
3908
+ if (savedPlatform.osVersion)
3909
+ p['osVersion'] = savedPlatform.osVersion;
3910
+ platform = [p];
3911
+ }
3912
+ else if (['API', 'MAINFRAME', 'SAP', 'DESKTOP', 'DATABASE'].includes(tcType)) {
3913
+ platform = [{ name: tcType, id: tcType, technologyType: tcType, type: 'NONE' }];
3914
+ }
3915
+ const moduleIdNum = typeof rawTc?.moduleId === 'number' ? rawTc.moduleId : 0;
3916
+ let moduleName = '';
3917
+ if (moduleIdNum > 0) {
3918
+ try {
3919
+ const body = this.searchBody([{ column: 'id', value: moduleIdNum }, { column: 'deleted', value: false }], { selectFields: ['id', 'name'], limit: 1 });
3920
+ const raw = await this.http.postJson('/pm/module/search', body);
3921
+ const rows = pickArray(raw, 'data', 'content', 'modules');
3922
+ moduleName = str(rows[0], 'name') ?? '';
3923
+ }
3924
+ catch { /* leave empty */ }
3925
+ }
3926
+ executionItems.push({
3927
+ id: typeof rawTc?.id === 'number' ? rawTc.id : Number.parseInt(numericId(String(memberTcId)), 10),
3928
+ name: str(rawTc, 'name', 'title') ?? '',
3929
+ description: str(rawTc, 'description') ?? '',
3930
+ versionId,
3931
+ versionName,
3932
+ seq: typeof row?.seq === 'number' ? row.seq : seqCounter,
3933
+ stepId: row?.stepId ?? '', // MUST come from the suite row, never regenerated
3934
+ stepCount: -1, // suite items send -1 (TC sends 0)
3935
+ technologyType: tcType,
3936
+ type: 'TC', // every item is 'TC'; suite-ness lives at top-level type
3937
+ platform,
3938
+ projectId,
3939
+ projectName,
3940
+ configuration: {},
3941
+ iterationsSelected: [],
3942
+ moduleId: moduleIdNum,
3943
+ userstoryId: typeof rawTc?.userstoryId === 'number' ? rawTc.userstoryId : 0,
3944
+ tags: Array.isArray(rawTc?.tags) ? rawTc.tags : [],
3945
+ code: str(rawTc, 'code') ?? '',
3946
+ prerequisite: str(rawTc, 'prerequisite', 'preconditions') ?? '',
3947
+ moduleName,
3948
+ userstoryName: '',
3949
+ });
3950
+ }
3951
+ if (executionItems.length === 0) {
3952
+ throw new Error(`startRun: suite ${suiteId} has no runnable test cases (all members were deleted, ` +
3953
+ `unreadable, or filtered out).`);
3954
+ }
3955
+ // agentId derivation (mirrors UI :677). LOCAL always uses this machine's agent; REMOTE/CLOUD
3956
+ // pin the suite doc's saved `agentId` (or null when the suite wasn't pinned). CLOUD_AGENT
3957
+ // fallback (:771-774) promotes `cloudType` to 'SQA'.
3958
+ let agentId;
3959
+ if (mode === 'LOCAL') {
3960
+ agentId = await this.resolveLocalAgentId(opts.agentId);
3961
+ }
3962
+ else if (mode === 'REMOTE') {
3963
+ const preferred = opts.agentId ?? (typeof rawSuite.agentId === 'string' ? rawSuite.agentId : undefined);
3964
+ agentId = preferred ? await this.resolveRemoteAgentId(preferred) : null;
3965
+ }
3966
+ else {
3967
+ agentId = typeof rawSuite.agentId === 'string' && rawSuite.agentId.length > 0 ? rawSuite.agentId : null;
3968
+ }
3969
+ let cloudType = mode === 'CLOUD' ? 'VM' : 'NONE';
3970
+ if (!agentId && typeof rawSuite.cloudAgentId === 'string' && rawSuite.cloudAgentId.length > 0) {
3971
+ agentId = rawSuite.cloudAgentId;
3972
+ cloudType = 'SQA';
3973
+ }
3974
+ const gc = rawSuite.globalConfiguration ?? {};
3975
+ const body = {
3490
3976
  projectId,
3491
- executionId,
3492
- killType: 'STOP_EXECUTION',
3493
- });
3977
+ environmentType: env,
3978
+ executionStyle: (opts.strategy ?? 'serial').toUpperCase(),
3979
+ executionOS: this.detectExecutionOS(),
3980
+ executionTypeId: suiteNum,
3981
+ executionTypeName: str(rawSuite, 'name') ?? '',
3982
+ executionTypeCode: str(rawSuite, 'code') ?? '',
3983
+ mailersList: rawSuite.mailersList ?? {},
3984
+ cloudType,
3985
+ mode,
3986
+ executionCategory: 'EXECUTION',
3987
+ releaseId: opts.releaseId ? Number.parseInt(opts.releaseId, 10) || 0 : 0,
3988
+ iterationId: opts.sprintId ? Number.parseInt(opts.sprintId, 10) || 0 : 0,
3989
+ executionTime: '',
3990
+ status: 'INPROGRESS',
3991
+ iterationsSelected: [],
3992
+ result: '',
3993
+ globalConfiguration: {
3994
+ TERMINATE_ON_FAIL: opts.terminateOnFailure ?? gc.TERMINATE_ON_FAIL ?? false,
3995
+ NEW_BROWSER_SESSION: gc.NEW_BROWSER_SESSION ?? false,
3996
+ RE_EXECUTION_COUNT: gc.RE_EXECUTION_COUNT ?? 0,
3997
+ RE_EXECUTION_TYPE: gc.RE_EXECUTION_TYPE ?? 'AFTER_TESTCASE',
3998
+ MINIMUM_CONTAINER_COUNT: gc.MINIMUM_CONTAINER_COUNT ?? 1,
3999
+ MAXIMUM_CONTAINER_COUNT: opts.maxConcurrent ?? gc.MAXIMUM_CONTAINER_COUNT ?? 5,
4000
+ },
4001
+ isRecordingEnabled: false,
4002
+ offset: -(new Date().getTimezoneOffset()),
4003
+ type: 'SUITE',
4004
+ executionType: suiteType,
4005
+ agentId,
4006
+ executionItems,
4007
+ };
4008
+ const resp = await this.http.postJson('/exec/execution', body);
4009
+ const d = resp?.data ?? resp;
4010
+ const runId = str(d, 'id', 'executionId', '_id') ??
4011
+ (typeof d?.id === 'number' ? String(d.id) : undefined) ??
4012
+ (typeof d?.executionId === 'number' ? String(d.executionId) : undefined);
4013
+ if (!runId)
4014
+ throw new Error('startRun: backend accepted suite trigger but returned no run id.');
4015
+ return { runId };
3494
4016
  }
3495
4017
  /** GET the agent /mcp with a 1s timeout. Any HTTP response (incl. 4xx from an MCP server rejecting
3496
4018
  * a naked GET) proves the process is up; connect/DNS/timeout errors mean it is down. Replaces