common-qa-mcp 0.4.1 → 0.6.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 (58) hide show
  1. package/dist/adapters/remoteMcp/adapter.d.ts +4 -1
  2. package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
  3. package/dist/adapters/remoteMcp/adapter.js +8 -3
  4. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  5. package/dist/adapters/simplifyqa/adapter.d.ts +130 -36
  6. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  7. package/dist/adapters/simplifyqa/adapter.js +833 -265
  8. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  9. package/dist/adapters/simplifyqa/mapper.d.ts +5 -1
  10. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  11. package/dist/adapters/simplifyqa/mapper.js +35 -1
  12. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  13. package/dist/adapters/types.d.ts +47 -10
  14. package/dist/adapters/types.d.ts.map +1 -1
  15. package/dist/adapters/walnut/actionTypes.d.ts +1 -1
  16. package/dist/adapters/walnut/actionTypes.d.ts.map +1 -1
  17. package/dist/adapters/walnut/actionTypes.js +37 -0
  18. package/dist/adapters/walnut/actionTypes.js.map +1 -1
  19. package/dist/adapters/walnut/mapper.d.ts.map +1 -1
  20. package/dist/adapters/walnut/mapper.js +12 -1
  21. package/dist/adapters/walnut/mapper.js.map +1 -1
  22. package/dist/domain/types.d.ts +98 -17
  23. package/dist/domain/types.d.ts.map +1 -1
  24. package/dist/initAgents.d.ts.map +1 -1
  25. package/dist/initAgents.js +31 -11
  26. package/dist/initAgents.js.map +1 -1
  27. package/dist/server.js +1 -1
  28. package/dist/tools/backlog.tools.d.ts.map +1 -1
  29. package/dist/tools/backlog.tools.js +154 -26
  30. package/dist/tools/backlog.tools.js.map +1 -1
  31. package/dist/tools/connection.tools.d.ts.map +1 -1
  32. package/dist/tools/connection.tools.js +15 -3
  33. package/dist/tools/connection.tools.js.map +1 -1
  34. package/dist/tools/defects.tools.d.ts.map +1 -1
  35. package/dist/tools/defects.tools.js +29 -3
  36. package/dist/tools/defects.tools.js.map +1 -1
  37. package/dist/tools/discovery.tools.d.ts.map +1 -1
  38. package/dist/tools/discovery.tools.js +304 -137
  39. package/dist/tools/discovery.tools.js.map +1 -1
  40. package/dist/tools/markdown.d.ts.map +1 -1
  41. package/dist/tools/markdown.js +9 -5
  42. package/dist/tools/markdown.js.map +1 -1
  43. package/dist/tools/objects.tools.d.ts.map +1 -1
  44. package/dist/tools/objects.tools.js +111 -6
  45. package/dist/tools/objects.tools.js.map +1 -1
  46. package/dist/tools/recording.tools.d.ts.map +1 -1
  47. package/dist/tools/recording.tools.js +108 -10
  48. package/dist/tools/recording.tools.js.map +1 -1
  49. package/dist/tools/run.tools.d.ts.map +1 -1
  50. package/dist/tools/run.tools.js +263 -63
  51. package/dist/tools/run.tools.js.map +1 -1
  52. package/dist/tools/tasks.tools.d.ts.map +1 -1
  53. package/dist/tools/tasks.tools.js +29 -3
  54. package/dist/tools/tasks.tools.js.map +1 -1
  55. package/dist/tools/testcase.tools.d.ts.map +1 -1
  56. package/dist/tools/testcase.tools.js +5 -2
  57. package/dist/tools/testcase.tools.js.map +1 -1
  58. package/package.json +1 -1
@@ -1,21 +1,17 @@
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, toModuleSummary, toObject, toReleaseSummary, toSavedRef, toSprintSummary, toStory, toStorySummary, toSuite, toSuiteSummary, toTaskSummary, toTcSummary, toTestCase, toVersionEntries, withTotal, } from './mapper.js';
5
- const PROJECT_URL_SEGMENT = {
6
- testcase: 'testcase',
7
- suite: 'suite',
8
- run: 'reports/execution',
9
- function: 'function',
10
- };
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';
11
5
  /** SQA run-row status → neutral RunStatus.state. */
12
6
  function mapRunState(s) {
13
7
  switch (s.toUpperCase()) {
14
8
  case 'PASSED': return 'passed';
15
9
  case 'FAILED': return 'failed';
16
10
  case 'STOPPED': return 'stopped';
17
- case 'RUNNING': return 'running';
18
- case 'QUEUED': return 'queued';
11
+ case 'RUNNING':
12
+ case 'INPROGRESS': return 'running';
13
+ case 'QUEUED':
14
+ case 'NOTEXECUTED': return 'queued';
19
15
  default: return 'queued';
20
16
  }
21
17
  }
@@ -106,10 +102,14 @@ const STEP_ALLOWED_KEYS = [
106
102
  'captureScreenshot', 'screenshotEnabled', 'fnVersionId', 'description', 'expectedResult',
107
103
  'conditions', 'stepLevelActions', 'skipParamInReport', 'paramLoopduration', 'showGroups',
108
104
  'isConditional', 'if', 'else', 'loop', 'isLoop', 'loopType', 'startFor', 'endFor',
109
- 'loopDuration', 'executionType', 'recordedParam', 'switchToAndroidData', 'switchToIOSData',
105
+ 'loopDuration', 'executionType', 'switchToAndroidData', 'switchToIOSData',
110
106
  'isDebug', 'manualStepsTopreview', 'sharedFnProjectName', 'isMigrated', 'customFields',
111
107
  // ponytail: id / versionId are in the schema but always stripped for SAVENEWVERSION — the
112
108
  // backend re-assigns them to the fresh version row, and keeping the old id would resurrect it.
109
+ // ponytail: recordedParam is intentionally dropped — the FE clears it on every save-as-new
110
+ // (testcase.component.ts:1370 `if (shouldClearRecordedParam) step.recordedParam = {}`) so the
111
+ // backend's prepareDataObjet doesn't burn recorded values into fresh ITR-1 rows. MCP has no
112
+ // recording flow, so nothing needs it kept.
113
113
  ];
114
114
  function pick(obj, keys) {
115
115
  const out = {};
@@ -120,22 +120,6 @@ function pick(obj, keys) {
120
120
  out[k] = obj[k];
121
121
  return out;
122
122
  }
123
- /** Filter a fetched list of source version names by the caller's `requested` subset. Empty request
124
- * or undefined means "all". Unknown names hard-fail with the valid list so the LLM can retry with
125
- * a corrected name instead of silently cloning nothing. Empty request array = user error. */
126
- function filterVersionNames(available, requested, entityId) {
127
- if (requested === undefined)
128
- return available;
129
- if (requested.length === 0) {
130
- throw new Error(`versions: [] is not valid — omit the parameter to clone all versions, or pass at least one name. Source ${entityId} has: ${available.join(', ')}.`);
131
- }
132
- const availSet = new Set(available);
133
- const unknown = requested.filter((n) => !availSet.has(n));
134
- if (unknown.length) {
135
- throw new Error(`Unknown version name(s) on ${entityId}: ${unknown.join(', ')}. Available versions: ${available.join(', ')}.`);
136
- }
137
- return [...new Set(requested)];
138
- }
139
123
  /** Guard against feeding an FN business key into a TC-only endpoint (or vice-versa). `numericId`
140
124
  * strips ANY alpha prefix — so "FN-22" → "22" and would silently target TC-22 if the caller
141
125
  * routed to the wrong tool. Reject any business key whose prefix doesn't match the expected
@@ -231,6 +215,9 @@ export class SimplifyQaAdapter {
231
215
  // (from the Promise.all in saveGeneratedIterations) all await the same fetch instead of each firing
232
216
  // their own /tm/syncFormulas/fetch before the cache is populated.
233
217
  this.formulaCache = null;
218
+ // Same rationale for the backlog default state/priority per parentId (1=Epic, 2=Feature, 3=Story).
219
+ // Fields config barely changes mid-session; cache the in-flight promise so parallel creates share it.
220
+ this.backlogDefaultsCache = new Map();
234
221
  // ---------------------------------------------------------------------- Automation resolvers
235
222
  /** POST /tm/action/search → per (technology, name) → row (id + objectRequired + objectTemplate).
236
223
  * Cached per adapter instance: the catalog is customer-wide and rarely changes during a session.
@@ -244,6 +231,9 @@ export class SimplifyQaAdapter {
244
231
  * formulaCache. Populated from `POST /tm/objectTemplate/search` (confirmed endpoint,
245
232
  * api-endpoints.ts `object.search`'s sibling `objectTemplate.search`). */
246
233
  this.objectTemplateCache = null;
234
+ /** Gather every identifier this process can claim: bare hostname, non-loopback MAC addresses,
235
+ * non-loopback IPs. Cached per-adapter — network interfaces do not change mid-session. */
236
+ this.localIdsCache = null;
247
237
  if (!cfg.baseUrl) {
248
238
  throw new Error('SimplifyQaAdapter requires a baseUrl — set QA_BASE_URL or use a JWT with a `url` claim.');
249
239
  }
@@ -570,9 +560,16 @@ export class SimplifyQaAdapter {
570
560
  }
571
561
  }
572
562
  /** For every step with an objectRef, resolve the object's display name (`disname`) via
573
- * /tm/object/search (batched by distinct objectId). Mutates `objectRef.name` in place. Non-fatal
574
- * on failure — the bare id still renders. Mirrors the FE's `defaultFieldsMap.get('objectId').map`
575
- * behavior used by the Excel export (tcfn.service.js:2876) so the tool surface matches the UI. */
563
+ * /tm/object/search (batched by distinct objectId). Mutates `objectRef.name` in place. Mirrors the
564
+ * FE's `defaultFieldsMap.get('objectId').map` behavior used by the Excel export
565
+ * (tcfn.service.js:2876) so the tool surface matches the UI.
566
+ *
567
+ * An id that does NOT come back from the search (deleted, or never existed) is flagged
568
+ * `objectRef.missing = true` — this must NOT be silently indistinguishable from "the object
569
+ * exists but has no disname set" (see the "show me the objects in this test case" incident:
570
+ * falling back to the bare id with no signal made a genuinely-deleted object's step look
571
+ * identical to a normal one). Only a hard failure of the search call itself (network/auth) skips
572
+ * this — we can't tell missing-from-deleted apart from missing-from-can't-check in that case. */
576
573
  async hydrateObjectNames(tc) {
577
574
  const ids = new Set();
578
575
  for (const s of tc.steps) {
@@ -605,10 +602,13 @@ export class SimplifyQaAdapter {
605
602
  const name = byId.get(s.objectRef.id);
606
603
  if (name)
607
604
  s.objectRef.name = name;
605
+ else
606
+ s.objectRef.missing = true;
608
607
  }
609
608
  }
610
609
  catch {
611
- // Non-fatal: fall back to bare id in the render.
610
+ // Search call itself failed — can't distinguish missing-from-deleted vs. couldn't-check, so
611
+ // don't flag anything; the bare id still renders (existing best-effort behavior).
612
612
  }
613
613
  }
614
614
  // ---------------------------------------------------------------------- Suites (read)
@@ -738,12 +738,102 @@ export class SimplifyQaAdapter {
738
738
  const rows = pickArray(raw, 'data', 'content', 'tasks');
739
739
  return withTotal(rows.map(toTaskSummary), raw);
740
740
  }
741
+ // ---------------------------------------------------------------------- Backlog defaults
742
+ /** Fetch the project's default `state` and `priority` for a backlog entity.
743
+ * parentId: 1 = Epic, 2 = Feature, 3 = User Story. Two-source resolution, in order:
744
+ *
745
+ * 1. `/pm/fields/:parentId?fieldType=defaultFld` — pick the field value marked `isDefault: true`;
746
+ * fall back to the first non-archived value (seed ships with `isDefault: false` everywhere
747
+ * until a customer configures one).
748
+ * 2. If either state or priority is still missing, hit the entity's `/search` endpoint for one
749
+ * existing row in the project and steal its state / priority. Real data is a more reliable
750
+ * signal than the fields config, which is customer-scoped for Epic/Feature (parentIds 1/2)
751
+ * and can be missing entirely on customers that pre-date the seeding step.
752
+ *
753
+ * Returns the value's `.key` (uppercase enum like `PROPOSED`, `MEDIUM`) — that is what the entity
754
+ * row stores; the field metadata declares `keyname: "key"` / `type: "KEY"` and the SQA UI binding
755
+ * (see epic-form.component.html: `keyname="key" type="KEY" [displayKeys]="{'field':'value'}"`) confirms
756
+ * key is stored, `.value` is only for display. Both sources are wrapped in try/catch: a transient
757
+ * upstream error must not block create; the backend surfaces its own error if invalid. */
758
+ async getBacklogDefaults(parentId) {
759
+ const cached = this.backlogDefaultsCache.get(parentId);
760
+ if (cached)
761
+ return cached;
762
+ const projectId = this.requireProjectId();
763
+ const p = (async () => {
764
+ const out = {};
765
+ // Source 1: /pm/fields config.
766
+ try {
767
+ const raw = await this.http.getJson(`/pm/fields/${parentId}?fieldType=defaultFld&projectId=${projectId}`);
768
+ const fields = Array.isArray(raw) ? raw : pickArray(raw, 'data', 'content', 'fields');
769
+ const pick = (key) => {
770
+ const field = fields.find((f) => f?.key === key);
771
+ const values = Array.isArray(field?.values) ? field.values : [];
772
+ const chosen = values.find((v) => v?.isDefault === true && v?.archive !== true)
773
+ ?? values.find((v) => v?.archive !== true);
774
+ // Entity rows persist the uppercase enum (value.key). value.value is display-only.
775
+ const stored = str(chosen ?? {}, 'key', 'value');
776
+ return stored && stored.length > 0 ? stored : undefined;
777
+ };
778
+ const s = pick('state');
779
+ const pr = pick('priority');
780
+ if (s)
781
+ out.state = s;
782
+ if (pr)
783
+ out.priority = pr;
784
+ }
785
+ catch { /* fall through to Source 2 */ }
786
+ // Source 2: fall back to state/priority from an existing entity of the same kind in this project.
787
+ // Epic has no priority field, so we only need state for parentId=1.
788
+ const needsPriority = parentId !== 1 && !out.priority;
789
+ if (!out.state || needsPriority) {
790
+ try {
791
+ const path = parentId === 1 ? '/pm/epic/search'
792
+ : parentId === 2 ? '/pm/feature/search'
793
+ : '/pm/userstory/search';
794
+ // Story rows carry a scalar `projectId`; Epic/Feature rows carry an array `projectIds`.
795
+ const filters = [
796
+ { column: 'deleted', value: false },
797
+ parentId === 3
798
+ ? { column: 'projectId', value: projectId }
799
+ : { column: 'projectIds', value: projectId },
800
+ ];
801
+ const body = this.searchBody(filters, { startIndex: 0, limit: 1 });
802
+ const raw = await this.http.postJson(path, body);
803
+ const rows = pickArray(raw, 'data', 'content');
804
+ const row = rows[0];
805
+ if (row) {
806
+ if (!out.state) {
807
+ const s = str(row, 'state', 'status');
808
+ if (s)
809
+ out.state = s;
810
+ }
811
+ if (needsPriority) {
812
+ const pr = str(row, 'priority');
813
+ if (pr)
814
+ out.priority = pr;
815
+ }
816
+ }
817
+ }
818
+ catch { /* leave out.state / out.priority absent; caller can override */ }
819
+ }
820
+ return out;
821
+ })();
822
+ this.backlogDefaultsCache.set(parentId, p);
823
+ return p;
824
+ }
741
825
  // ---------------------------------------------------------------------- Stories (write)
742
826
  async createStory(input) {
827
+ const defaults = await this.getBacklogDefaults(3);
828
+ const state = input.state ?? defaults.state;
829
+ const priority = input.priority ?? defaults.priority;
743
830
  const body = {
744
831
  ...input,
745
832
  // SQA userstory expects a singular numeric projectId; fill from the connected project when omitted.
746
833
  projectId: input.projectId ?? this.requireProjectId(),
834
+ ...(state ? { state } : {}),
835
+ ...(priority ? { priority } : {}),
836
+ customFields: input.customFields ?? {},
747
837
  };
748
838
  const raw = await this.http.postJson('/pm/userstory', body);
749
839
  return toSavedRef(raw, true);
@@ -791,9 +881,13 @@ export class SimplifyQaAdapter {
791
881
  return toEpic(doc && typeof doc === 'object' ? doc : {});
792
882
  }
793
883
  async createEpic(input) {
884
+ const defaults = await this.getBacklogDefaults(1);
885
+ const state = input.state ?? defaults.state;
794
886
  const body = {
795
887
  ...input,
796
888
  projectIds: input.projectIds ?? [this.requireProjectId()],
889
+ ...(state ? { state } : {}),
890
+ customFields: input.customFields ?? {},
797
891
  };
798
892
  const raw = await this.http.postJson('/pm/epic', body);
799
893
  return toSavedRef(raw, true);
@@ -855,9 +949,15 @@ export class SimplifyQaAdapter {
855
949
  return toFeature(doc && typeof doc === 'object' ? doc : {});
856
950
  }
857
951
  async createFeature(input) {
952
+ const defaults = await this.getBacklogDefaults(2);
953
+ const state = input.state ?? defaults.state;
954
+ const priority = input.priority ?? defaults.priority;
858
955
  const body = {
859
956
  ...input,
860
957
  projectIds: input.projectIds ?? [this.requireProjectId()],
958
+ ...(state ? { state } : {}),
959
+ ...(priority ? { priority } : {}),
960
+ customFields: input.customFields ?? {},
861
961
  };
862
962
  const raw = await this.http.postJson('/pm/feature', body);
863
963
  return toSavedRef(raw, true);
@@ -886,7 +986,7 @@ export class SimplifyQaAdapter {
886
986
  const body = this.searchBody([
887
987
  { column: 'projectId', value: projectId },
888
988
  { column: 'deleted', value: false },
889
- ], { selectFields: ['id', 'code', 'name', 'parentId'] });
989
+ ], { selectFields: ['id', 'code', 'name', 'parentId', 'isRoot', 'childIds'] });
890
990
  const raw = await this.http.postJson('/pm/module/search', body);
891
991
  const rows = pickArray(raw, 'data', 'content', 'modules');
892
992
  return withTotal(rows.map(toModuleSummary), raw);
@@ -916,19 +1016,71 @@ export class SimplifyQaAdapter {
916
1016
  };
917
1017
  }
918
1018
  /** POST /pm/module — server validates: name+isRoot+projectId required; parentId ignored when
919
- * isRoot:true. Duplicate name per project is rejected server-side. */
1019
+ * isRoot:true. Duplicate name per project is rejected server-side. When `input.children` is
1020
+ * present, walks the subtree depth-first: creates the root, then each child under the id
1021
+ * returned for its parent. `childIds:[]` matches the working UI payload byte-for-byte.
1022
+ * Backend has no tx endpoint: on mid-walk failure the returned tree carries `failedAt`, and
1023
+ * every node BEFORE that point is already persisted. */
920
1024
  async createModule(input) {
1025
+ const root = await this.postOneModule(input.name, input.isRoot, input.projectId, input.description, input.parentId);
1026
+ if (input.children?.length) {
1027
+ root.children = await this.createSubtree(input.children, root.id, input.projectId, input.name, root);
1028
+ }
1029
+ return root;
1030
+ }
1031
+ /** Depth-first walk of a subtree. Stops at the first failure — everything already POSTed stays
1032
+ * (no rollback endpoint upstream) and the failure is bubbled back on the root via `failedAt`. */
1033
+ async createSubtree(children, parentId, projectId, parentPath, root) {
1034
+ const out = [];
1035
+ for (const child of children) {
1036
+ const path = `${parentPath}/${child.name}`;
1037
+ if (root.failedAt)
1038
+ return out;
1039
+ let saved;
1040
+ try {
1041
+ saved = await this.postOneModule(child.name, false, projectId, child.description, parentId);
1042
+ }
1043
+ catch (e) {
1044
+ root.failedAt = { path, error: e instanceof Error ? e.message : String(e) };
1045
+ return out;
1046
+ }
1047
+ if (child.children?.length) {
1048
+ saved.children = await this.createSubtree(child.children, saved.id, projectId, path, root);
1049
+ }
1050
+ out.push(saved);
1051
+ }
1052
+ return out;
1053
+ }
1054
+ async postOneModule(name, isRoot, projectId, description, parentId) {
921
1055
  const payload = {
922
- name: input.name,
923
- isRoot: input.isRoot,
924
- projectId: Number.parseInt(input.projectId, 10),
1056
+ name,
1057
+ isRoot,
1058
+ projectId: Number.parseInt(projectId, 10),
1059
+ childIds: [],
925
1060
  };
926
- if (input.description)
927
- payload['description'] = input.description;
928
- if (!input.isRoot && input.parentId)
929
- payload['parentId'] = Number.parseInt(input.parentId, 10);
1061
+ if (description)
1062
+ payload['description'] = description;
1063
+ if (!isRoot && parentId)
1064
+ payload['parentId'] = Number.parseInt(parentId, 10);
930
1065
  const raw = await this.http.postJson('/pm/module', payload);
931
- return toSavedRef(raw, true);
1066
+ return toModuleSaved(raw, name);
1067
+ }
1068
+ /** PUT /pm/module/:id — partial edit. Controller reads `projectId` off the body for its
1069
+ * `add_module` privilege check (module.controller.js:64), so it MUST be sent even when unchanged;
1070
+ * we default to the connected project. Only `name` / `description` are exposed — the controller
1071
+ * ignores everything else the validator accepts (isRoot, parentId, childIds). */
1072
+ async updateModule(id, patch) {
1073
+ const projectId = patch.projectId
1074
+ ? Number.parseInt(numericId(patch.projectId), 10)
1075
+ : this.requireProjectId();
1076
+ const body = { projectId };
1077
+ if (patch.name !== undefined)
1078
+ body['name'] = patch.name;
1079
+ if (patch.description !== undefined)
1080
+ body['description'] = patch.description;
1081
+ const resolvedId = numericId(id);
1082
+ const raw = await this.http.putJson(`/pm/module/${resolvedId}`, body);
1083
+ return toSavedRef(raw ?? { id: resolvedId }, false);
932
1084
  }
933
1085
  /** PATCH /pm/module — two-phase per module.controller.js:94. `confirm:false` returns the per-id
934
1086
  * dependency footprint (test cases, functions, params, objects, defects, user stories). `confirm:true`
@@ -1053,7 +1205,13 @@ export class SimplifyQaAdapter {
1053
1205
  // ---------------------------------------------------------------------- Deep links
1054
1206
  productUrlFor(entity) {
1055
1207
  const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
1056
- return `${base}/${PROJECT_URL_SEGMENT[entity.kind]}/${entity.id}`;
1208
+ switch (entity.kind) {
1209
+ case 'testcase': return `${base}/user/testcase/details/${entity.id}`;
1210
+ case 'function': return `${base}/user/function/fnDetails/${entity.id}`;
1211
+ case 'suite': return `${base}/user/suites/suite-details?id=${entity.id}`;
1212
+ // ponytail: run URL is built inline where parentId/projectId are known (see reportUrl above). Fallback stays generic.
1213
+ case 'run': return `${base}/user/reports/reports-page?id=${entity.id}`;
1214
+ }
1057
1215
  }
1058
1216
  // ---------------------------------------------------------------------- Agent hop (recording + not-yet-ported ops)
1059
1217
  // Everything below either delegates to the local agent /mcp (recording surface, and writes/testdata/
@@ -1617,87 +1775,92 @@ export class SimplifyQaAdapter {
1617
1775
  }
1618
1776
  /** Clone a testcase in place (same project + module) with all steps, versions, testdata,
1619
1777
  * parameters, object refs, and nested function calls carried over. Backed by the SQA native
1620
- * `/tm/tcfnCopy/copytc` endpoint (see test-management/src/controllers/tcfnCopy.controller.js).
1621
- *
1622
- * Requires the `copy_testcase_across_project` privilege on the current project — enforced
1623
- * server-side regardless of whether the copy is in-project or across projects.
1624
- *
1625
- * When `newTitle` is omitted the backend auto-names the copy as `{sourceName}_copy1`,
1626
- * `_copy2`, etc. When provided, a follow-up PUT renames the freshly-created TC to that name.
1627
- *
1628
- * IMPORTANT — the copy endpoint's `selversions` and `selectedEnv` fields are FILTERS, not
1629
- * wildcards. Passing empty arrays makes the backend copy the TC shell only (no steps, no
1630
- * testdata, since both live under versions and per-env iterations). So we fetch the source
1631
- * TC's version names AND the project's environment names first and pass them explicitly. */
1632
- async cloneTestCase(id, newTitle, versions) {
1778
+ * the FE in-editor "Save as new TC" dialog — `PUT /tm/testcase` with `operation:SAVEASNEWTC`.
1779
+ * Same-project only (no cross-project copy — use the FE tree menu for that). Clones ONE
1780
+ * source version. `tdCopy:true` clones the source's testdata iterations across every env;
1781
+ * `tdCopy:false` copies only the column structure (one empty ITR-1 per env, values fall back
1782
+ * to project-level parameter defaultValue if any). Mirrors the FE's "COPY TEST DATA" modal. */
1783
+ async cloneTestCase(id, newTitle, opts) {
1633
1784
  assertBusinessKeyKind(id, 'TC');
1634
1785
  const projectId = this.requireProjectId();
1635
- const src = await this.fetchRawTestcase(id);
1636
- if (!src || Object.keys(src).length === 0)
1786
+ const title = newTitle?.trim();
1787
+ if (!title)
1788
+ throw new Error('newTitle is required for save-as-new-testcase.');
1789
+ const rawTc = await this.fetchRawTestcase(id);
1790
+ if (!rawTc || Object.keys(rawTc).length === 0)
1637
1791
  throw new Error(`test case ${id} not found`);
1638
1792
  const sourceId = Number.parseInt(numericId(id), 10);
1639
- const sourceProjId = typeof src.projectId === 'number' ? src.projectId : projectId;
1640
- const sourceModuleId = typeof src.moduleId === 'number' ? src.moduleId : 0;
1641
- // Fetch source's version names — required, empty means no versions get copied.
1642
- const versionsRaw = await this.http.getJson(`/tm/testcase/version/${sourceId}/${sourceProjId}`);
1643
- const versionsArr = Array.isArray(versionsRaw) ? versionsRaw : (versionsRaw?.data ?? []);
1644
- const availableNames = versionsArr
1645
- .map((v) => str(v, 'name'))
1646
- .filter((n) => !!n);
1647
- if (availableNames.length === 0) {
1648
- throw new Error(`Cannot clone ${id}: source has no versions to copy.`);
1649
- }
1650
- const versionNames = filterVersionNames(availableNames, versions, id);
1651
- // Fetch project's environment names — empty means per-env testdata rows are dropped
1652
- // (only the "general" fallback iteration survives).
1653
- const envsRaw = await this.http.getJson(`/pm/fields/8?projectId=${sourceProjId}&fieldType=environment`);
1654
- const envsArr = Array.isArray(envsRaw) ? envsRaw : (envsRaw?.data ?? []);
1655
- const envNames = envsArr
1656
- .map((e) => str(e, 'name'))
1657
- .filter((n) => !!n);
1658
- await this.http.postJson('/tm/tcfnCopy/copytc', {
1659
- type: 'TC',
1660
- tcfnList: [{ id: sourceId, selversions: versionNames }],
1661
- sourceProjId,
1662
- sourceModuleId,
1663
- destinationProjId: sourceProjId,
1664
- destinationModuleId: sourceModuleId,
1665
- selectedEnv: envNames,
1666
- });
1667
- // Find the newly-created copy: highest-id TC whose `projectLevelcopiedFrom.id` matches. Not
1668
- // going through `searchBody` because the SQA search protocol lets a searchField have EITHER a
1669
- // `value` (filter) OR a `sort` (ordering) — the helper only builds value entries.
1670
- const searchRaw = await this.http.postJson('/tm/testcase/search', {
1671
- searchFields: [
1672
- { column: 'deleted', value: false },
1673
- { column: 'projectId', value: sourceProjId },
1674
- { column: 'projectLevelcopiedFrom.id', value: sourceId },
1675
- { column: 'id', sort: 'dsc' },
1676
- ],
1677
- startIndex: 0,
1678
- limit: 1,
1679
- selectFields: ['id', 'code', 'name'],
1680
- });
1681
- const rows = pickArray(searchRaw, 'data', 'content', 'testCases');
1682
- if (rows.length === 0) {
1683
- throw new Error('Clone was requested but the newly-created testcase could not be located afterwards. ' +
1684
- 'The backend may still be processing the copy, or the caller may lack the ' +
1685
- '`copy_testcase_across_project` privilege on this project.');
1686
- }
1687
- const created = rows[0];
1688
- const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
1689
- // Optional rename: PUT /tm/testcase/:id with { name, projectId }.
1690
- if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
1691
- await this.http.putJson(`/tm/testcase/${newIdNum}`, {
1692
- name: newTitle.trim(),
1693
- projectId: sourceProjId,
1694
- });
1695
- created.name = newTitle.trim();
1793
+ // Resolve source version — same fallback chain as saveAsNewVersion.
1794
+ let sourceVersionId = opts.sourceVersionId;
1795
+ if (!sourceVersionId) {
1796
+ sourceVersionId = str(rawTc, 'versionId', 'selectedVersionId', 'selVersion');
1797
+ if (!sourceVersionId && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
1798
+ sourceVersionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId');
1799
+ }
1800
+ if (!sourceVersionId && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
1801
+ sourceVersionId = str(rawTc.versions[0], 'id', '_id');
1802
+ }
1803
+ }
1804
+ // Source version's NAME (SAVEASNEWTC re-uses the same version name on the copy, per FE).
1805
+ const sourceVersionName = (() => {
1806
+ if (rawTc?.versionsDetails?.id === sourceVersionId)
1807
+ return str(rawTc.versionsDetails, 'name');
1808
+ const match = Array.isArray(rawTc?.versions)
1809
+ ? rawTc.versions.find((v) => str(v, 'id', '_id') === sourceVersionId)
1810
+ : undefined;
1811
+ return match ? str(match, 'name') : str(rawTc?.versionsDetails, 'name');
1812
+ })() ?? 'v1.0';
1813
+ const rawSteps = await this.fetchStepsCollection(id, rawTc);
1814
+ const automationSteps = [];
1815
+ const manualSteps = [];
1816
+ for (const s of rawSteps) {
1817
+ const step = pick(s, STEP_ALLOWED_KEYS);
1818
+ const et = typeof step['executionType'] === 'string' ? String(step['executionType']).toUpperCase() : '';
1819
+ if (et === 'MANUAL')
1820
+ manualSteps.push(step);
1821
+ else
1822
+ automationSteps.push(step);
1696
1823
  }
1824
+ const tcfnObj = pick(rawTc, TCFNOBJ_ALLOWED_KEYS);
1825
+ tcfnObj['deleted'] = false;
1826
+ tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
1827
+ if (typeof tcfnObj['obsolete'] !== 'boolean')
1828
+ tcfnObj['obsolete'] = false;
1829
+ tcfnObj['name'] = title;
1830
+ tcfnObj['copiedFromTcId'] = sourceId;
1831
+ const prevSync = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
1832
+ ? str(rawTc.versionsDetails, 'syncONOFF')
1833
+ : undefined;
1834
+ const versionsDetails = {
1835
+ name: sourceVersionName,
1836
+ id: '',
1837
+ syncONOFF: prevSync === 'SYNCOFF' ? '' : (prevSync ?? ''),
1838
+ deleted: false,
1839
+ executionType: str(rawTc, 'executionType') ?? 'AUTOMATION',
1840
+ functionProjectIds: [tcfnObj['projectId']],
1841
+ reviewApproveStatus: '',
1842
+ };
1843
+ const verData = {
1844
+ tdCopy: !!opts.tdCopy,
1845
+ ...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
1846
+ };
1847
+ const payload = {
1848
+ tcfnObj,
1849
+ steps: { automationSteps, manualSteps },
1850
+ versionsDetails,
1851
+ operation: 'SAVEASNEWTC',
1852
+ type: 'TC',
1853
+ verData,
1854
+ };
1855
+ const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
1856
+ const flat = raw?.data ?? raw ?? {};
1857
+ const newId = str(flat, 'id', '_id', 'testcaseId');
1858
+ if (!newId)
1859
+ throw new Error('SAVEASNEWTC succeeded but returned no id.');
1697
1860
  return {
1698
- id: String(newIdNum),
1861
+ id: newId,
1699
1862
  created: true,
1700
- productUrl: this.productUrlFor({ kind: 'testcase', id: String(newIdNum) }),
1863
+ productUrl: this.productUrlFor({ kind: 'testcase', id: newId }),
1701
1864
  };
1702
1865
  }
1703
1866
  // ---------------------------------------------------------------------- Functions (reusable)
@@ -1819,81 +1982,91 @@ export class SimplifyQaAdapter {
1819
1982
  ? `Not deleted — function is still referenced by ${refs.join('; ')}. Remove those references first.`
1820
1983
  : 'Not deleted — the backend reported blocking dependencies.');
1821
1984
  }
1822
- /** Copy a function in place — new tcfnObj row, all versions/steps/testdata carried over. Same
1823
- * shape as `cloneTestCase`: version names + env names come from the source, then POST to
1824
- * `/tm/tcfnCopy/copytc` with `type:'FN'` (backend enforces `copy_function_across_projects`).
1825
- * When `newTitle` is omitted the backend auto-names as `{sourceName}_copy1`, etc.; when
1826
- * provided a follow-up PUT `/tm/function/:id` renames the freshly-created FN. */
1827
- async cloneFunction(id, newTitle, versions) {
1985
+ /** Save-as-new-function — same-project only. Mirrors FE's in-editor "Save as new function"
1986
+ * dialog: `PUT /tm/function` with `operation:SAVEASNEWFN`. Clones ONE source version.
1987
+ * `tdCopy:true` clones testdata; `tdCopy:false` copies structure only (empty ITR-1 per env). */
1988
+ async cloneFunction(id, newTitle, opts) {
1828
1989
  assertBusinessKeyKind(id, 'FN');
1829
1990
  const projectId = this.requireProjectId();
1991
+ const title = newTitle?.trim();
1992
+ if (!title)
1993
+ throw new Error('newTitle is required for save-as-new-function.');
1830
1994
  const fnNum = numericId(id);
1831
1995
  const rawFn = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
1832
- let src = rawFn?.data ?? rawFn;
1833
- if (Array.isArray(src))
1834
- src = src.length > 0 ? src[0] : {};
1835
- if (!src || typeof src !== 'object' || Object.keys(src).length === 0) {
1996
+ let rawTc = rawFn?.data ?? rawFn;
1997
+ if (Array.isArray(rawTc))
1998
+ rawTc = rawTc.length > 0 ? rawTc[0] : {};
1999
+ if (!rawTc || typeof rawTc !== 'object' || Object.keys(rawTc).length === 0) {
1836
2000
  throw new Error(`function ${id} not found`);
1837
2001
  }
1838
2002
  const sourceId = Number.parseInt(fnNum, 10);
1839
- const sourceProjId = typeof src.projectId === 'number' ? src.projectId : projectId;
1840
- const sourceModuleId = typeof src.moduleId === 'number' ? src.moduleId : 0;
1841
- // Fetch source's version names — required, empty means no versions get copied.
1842
- const versionsRaw = await this.http.getJson(`/tm/function/version/${sourceId}/${sourceProjId}`);
1843
- const versionsArr = Array.isArray(versionsRaw) ? versionsRaw : (versionsRaw?.data ?? []);
1844
- const availableNames = versionsArr
1845
- .map((v) => str(v, 'name'))
1846
- .filter((n) => !!n);
1847
- if (availableNames.length === 0) {
1848
- throw new Error(`Cannot clone ${id}: source has no versions to copy.`);
1849
- }
1850
- const versionNames = filterVersionNames(availableNames, versions, id);
1851
- // Same environment fetch as cloneTestCase — empty means per-env testdata rows are dropped.
1852
- const envsRaw = await this.http.getJson(`/pm/fields/8?projectId=${sourceProjId}&fieldType=environment`);
1853
- const envsArr = Array.isArray(envsRaw) ? envsRaw : (envsRaw?.data ?? []);
1854
- const envNames = envsArr
1855
- .map((e) => str(e, 'name'))
1856
- .filter((n) => !!n);
1857
- await this.http.postJson('/tm/tcfnCopy/copytc', {
1858
- type: 'FN',
1859
- tcfnList: [{ id: sourceId, selversions: versionNames }],
1860
- sourceProjId,
1861
- sourceModuleId,
1862
- destinationProjId: sourceProjId,
1863
- destinationModuleId: sourceModuleId,
1864
- selectedEnv: envNames,
1865
- });
1866
- // Find the newly-created copy: highest-id FN whose `projectLevelcopiedFrom.id` matches source.
1867
- const searchRaw = await this.http.postJson('/tm/function/search', {
1868
- searchFields: [
1869
- { column: 'deleted', value: false },
1870
- { column: 'projectId', value: sourceProjId },
1871
- { column: 'projectLevelcopiedFrom.id', value: sourceId },
1872
- { column: 'id', sort: 'dsc' },
1873
- ],
1874
- startIndex: 0,
1875
- limit: 1,
1876
- selectFields: ['id', 'code', 'name'],
1877
- });
1878
- const rows = pickArray(searchRaw, 'data', 'content', 'functions');
1879
- if (rows.length === 0) {
1880
- throw new Error('Clone was requested but the newly-created function could not be located afterwards. ' +
1881
- 'The backend may still be processing the copy, or the caller may lack the ' +
1882
- '`copy_function_across_projects` privilege on this project.');
1883
- }
1884
- const created = rows[0];
1885
- const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
1886
- // Optional rename: PUT /tm/function/:id — inline validator accepts { name } only (no projectId).
1887
- if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
1888
- await this.http.putJson(`/tm/function/${newIdNum}`, {
1889
- name: newTitle.trim(),
1890
- });
1891
- created.name = newTitle.trim();
2003
+ // Resolve source version — FN doc has no inline versionId, use version list.
2004
+ let sourceVersionId = opts.sourceVersionId;
2005
+ const versionRows = await this.fetchFunctionVersionRows(fnNum);
2006
+ if (!sourceVersionId && versionRows.length > 0) {
2007
+ sourceVersionId = str(versionRows[0], 'id', '_id', 'versionId');
2008
+ }
2009
+ const sourceVersionName = (() => {
2010
+ const match = versionRows.find((v) => str(v, 'id', '_id', 'versionId') === sourceVersionId);
2011
+ return match ? str(match, 'name') : (versionRows[0] ? str(versionRows[0], 'name') : undefined);
2012
+ })() ?? 'v1.0';
2013
+ const rawSteps = sourceVersionId
2014
+ ? await this.http.getJson('/tm/function/steps' +
2015
+ `?projectId=${projectId}` +
2016
+ `&tcfnId=${fnNum}` +
2017
+ `&versionId=${encodeURIComponent(sourceVersionId)}` +
2018
+ '&type=FN').then((body) => (Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : []))
2019
+ : [];
2020
+ const automationSteps = [];
2021
+ const manualSteps = [];
2022
+ for (const s of rawSteps) {
2023
+ const step = pick(s, STEP_ALLOWED_KEYS);
2024
+ const et = typeof step['executionType'] === 'string' ? String(step['executionType']).toUpperCase() : '';
2025
+ if (et === 'MANUAL')
2026
+ manualSteps.push(step);
2027
+ else
2028
+ automationSteps.push(step);
1892
2029
  }
2030
+ const tcfnObj = pick(rawTc, TCFNOBJ_FN_ALLOWED_KEYS);
2031
+ tcfnObj['deleted'] = false;
2032
+ tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
2033
+ if (typeof tcfnObj['fnType'] !== 'string' || !tcfnObj['fnType']) {
2034
+ tcfnObj['fnType'] = str(rawTc, 'fnType', 'tcType') ?? 'WEB';
2035
+ }
2036
+ tcfnObj['name'] = title;
2037
+ tcfnObj['copiedFromFnId'] = sourceId;
2038
+ const prevSync = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
2039
+ ? str(rawTc.versionsDetails, 'syncONOFF')
2040
+ : undefined;
2041
+ const versionsDetails = {
2042
+ name: sourceVersionName,
2043
+ id: '',
2044
+ syncONOFF: prevSync === 'SYNCOFF' ? '' : (prevSync ?? ''),
2045
+ deleted: false,
2046
+ executionType: str(rawTc, 'executionType') ?? 'AUTOMATION',
2047
+ reviewApproveStatus: '',
2048
+ };
2049
+ const verData = {
2050
+ tdCopy: !!opts.tdCopy,
2051
+ ...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
2052
+ };
2053
+ const payload = {
2054
+ tcfnObj,
2055
+ steps: { automationSteps, manualSteps },
2056
+ versionsDetails,
2057
+ operation: 'SAVEASNEWFN',
2058
+ type: 'FN',
2059
+ verData,
2060
+ };
2061
+ const raw = await this.http.putMultipart('/tm/function', 'functionObj', payload);
2062
+ const flat = raw?.data ?? raw ?? {};
2063
+ const newId = str(flat, 'id', '_id', 'functionId');
2064
+ if (!newId)
2065
+ throw new Error('SAVEASNEWFN succeeded but returned no id.');
1893
2066
  return {
1894
- id: String(newIdNum),
2067
+ id: newId,
1895
2068
  created: true,
1896
- productUrl: this.productUrlFor({ kind: 'function', id: String(newIdNum) }),
2069
+ productUrl: this.productUrlFor({ kind: 'function', id: newId }),
1897
2070
  };
1898
2071
  }
1899
2072
  /** Snapshot the CURRENT server state of a function under a NEW version name — same FN id, new
@@ -2211,11 +2384,58 @@ export class SimplifyQaAdapter {
2211
2384
  }
2212
2385
  // ---------------------------------------------------------------------- Suites (write) [Step 4]
2213
2386
  async createSuite(s) {
2214
- // Java stubs this out ("create_suite is not supported yet on SimplifyQA"). Mirror that.
2215
- if (this.hop())
2216
- return this.requireHop('create_suite').createSuite(s);
2217
- throw new Error('create_suite is not supported yet on SimplifyQA — create the suite in the SimplifyQA UI, ' +
2218
- 'then add test cases via suite_add_testcases.');
2387
+ const projectId = this.requireProjectId();
2388
+ const name = s.name?.trim();
2389
+ if (!name)
2390
+ throw new Error('name is required.');
2391
+ if (!s.members?.length)
2392
+ throw new Error('At least one test case is required to create a suite.');
2393
+ const entries = [];
2394
+ const categories = new Set();
2395
+ for (const m of s.members) {
2396
+ const tcNum = numericId(m.tcId);
2397
+ const raw = await this.fetchRawTestcase(tcNum);
2398
+ if (Object.keys(raw).length === 0)
2399
+ throw new Error(`test case ${m.tcId} not found (cannot add to suite)`);
2400
+ categories.add(String(str(raw, 'executionType') ?? '').toUpperCase() === 'MANUAL' ? 'MANUAL' : 'AUTOMATION');
2401
+ entries.push(await this.buildSuiteEntryFromTestcase(tcNum));
2402
+ }
2403
+ if (categories.size > 1) {
2404
+ throw new Error('Cannot create suite: the test cases are a mix of MANUAL and AUTOMATION — a suite can only ' +
2405
+ 'hold ONE category. Split them into two suites, or pick only same-category test cases.');
2406
+ }
2407
+ const suiteType = [...categories][0] ?? 'AUTOMATION';
2408
+ entries.forEach((e, i) => { e['seq'] = i + 1; });
2409
+ const payload = {
2410
+ name,
2411
+ description: s.description ?? '',
2412
+ state: 'New',
2413
+ suiteType,
2414
+ projectId,
2415
+ autoDefectLog: false,
2416
+ autoReExecCount: 0,
2417
+ newBrowserSession: false,
2418
+ terminateOnFailure: false,
2419
+ autoReExecTriggerOn: 'AFTER_SUITE',
2420
+ minimumContainerCount: null,
2421
+ maximumContainerCount: null,
2422
+ testCases: entries,
2423
+ mailersList: {},
2424
+ tags: [],
2425
+ customFields: {},
2426
+ lastSelectedExecutionType: 'LOCAL',
2427
+ tcChanged: true,
2428
+ };
2429
+ const raw = await this.http.postMultipart('/tm/suites', 'suiteObj', payload);
2430
+ const flat = raw?.data ?? raw ?? {};
2431
+ const savedId = str(flat, 'id', '_id') ?? '';
2432
+ if (!savedId)
2433
+ throw new Error(`Suite create returned no id: ${JSON.stringify(raw)}`);
2434
+ return {
2435
+ id: savedId,
2436
+ created: true,
2437
+ productUrl: this.productUrlFor({ kind: 'suite', id: savedId }),
2438
+ };
2219
2439
  }
2220
2440
  /** Fetch raw suite, apply add/remove/reorder ops in memory, then PUT the whole doc back with a
2221
2441
  * STRICT allow-listed payload (the SQA update validator rejects unknown keys on both suite-level
@@ -2407,15 +2627,23 @@ export class SimplifyQaAdapter {
2407
2627
  * delete is refused rather than silently breaking those test cases; only a dependency-free object
2408
2628
  * gets the real `isDelete:true` call.
2409
2629
  */
2410
- async deleteObject(id) {
2630
+ /** `PATCH /tm/object` with `isDelete:false` — the SAME dependency-check pre-flight the real
2631
+ * product UI fires before its own delete-confirm modal (object.component.ts onClickTableDelete):
2632
+ * a read-only probe, no restore/undelete side effect. Split out from deleteObject so a caller
2633
+ * (object_delete's preview step) can show this BEFORE asking the user to confirm, not just
2634
+ * discover it as a thrown error after the fact. */
2635
+ async checkObjectDependencies(id) {
2411
2636
  const projectId = this.requireProjectId();
2412
2637
  const numId = Number.parseInt(numericId(id), 10);
2413
2638
  const checkRes = await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: false });
2414
2639
  const row = checkRes?.data?.[0];
2415
2640
  const countRefs = (m) => m ? Object.values(m).reduce((n, arr) => n + (Array.isArray(arr) ? arr.length : 0), 0) : 0;
2416
- const tcCount = countRefs(row?.tcArr);
2417
- const fnCount = countRefs(row?.fnArr);
2418
- const wfCount = countRefs(row?.wfArr);
2641
+ return { tcCount: countRefs(row?.tcArr), fnCount: countRefs(row?.fnArr), wfCount: countRefs(row?.wfArr) };
2642
+ }
2643
+ async deleteObject(id) {
2644
+ const projectId = this.requireProjectId();
2645
+ const numId = Number.parseInt(numericId(id), 10);
2646
+ const { tcCount, fnCount, wfCount } = await this.checkObjectDependencies(id);
2419
2647
  if (tcCount || fnCount || wfCount) {
2420
2648
  const parts = [];
2421
2649
  if (tcCount)
@@ -2704,11 +2932,51 @@ export class SimplifyQaAdapter {
2704
2932
  }
2705
2933
  // ---------------------------------------------------------------------- Run status [Step 6]
2706
2934
  /** Real status of a run — reads step-level results direct from SQA REST because the desktop agent's
2707
- * get_run only returns queue state (often stale). start/stop still hop for v1. */
2935
+ * get_run only returns queue state (often stale). For a SUITE run, returns per-TC rollup
2936
+ * (`children[]`) instead of step details — a suite doc's `type` field flags it. start/stop still
2937
+ * hop for v1. */
2708
2938
  async getRun(runId) {
2709
2939
  const projectId = this.requireProjectId();
2710
2940
  const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
2711
- // GET /exec/report/{runId}/{projectId} — the run rows the /reports page reads.
2941
+ const runIdNum = Number.parseInt(numericId(runId), 10);
2942
+ // Top-level execution doc (carries `type` = 'TC' or 'SUITE', and `executionItems[]` for a suite).
2943
+ // One extra POST per getRun call; a suite short-circuits here so no /exec/report is needed.
2944
+ if (Number.isFinite(runIdNum)) {
2945
+ const execBody = {
2946
+ searchFields: [
2947
+ { column: 'id', value: runIdNum, regEx: false },
2948
+ { column: 'projectId', value: projectId, regEx: false },
2949
+ { column: 'deleted', value: false },
2950
+ ],
2951
+ startIndex: 0,
2952
+ limit: 1,
2953
+ };
2954
+ const execRaw = await this.http.postJson('/exec/execution/search', execBody);
2955
+ const execDoc = pickArray(execRaw, 'data', 'content', 'executions')[0];
2956
+ if (execDoc && String(execDoc.type ?? '').toUpperCase() === 'SUITE') {
2957
+ const items = Array.isArray(execDoc.executionItems) ? execDoc.executionItems : [];
2958
+ const children = items.map((it) => {
2959
+ const child = {
2960
+ id: str(it, 'code') ?? String(it?.id ?? ''),
2961
+ state: mapRunState(str(it, 'status') ?? ''),
2962
+ };
2963
+ const name = str(it, 'name');
2964
+ if (name)
2965
+ child.title = name;
2966
+ return child;
2967
+ });
2968
+ const total = children.length;
2969
+ const done = children.filter((c) => c.state !== 'queued' && c.state !== 'running').length;
2970
+ const suiteNum = Number(execDoc.executionTypeId ?? 0);
2971
+ return {
2972
+ runId,
2973
+ state: mapRunState(str(execDoc, 'status') ?? ''),
2974
+ ...(total > 0 ? { progress: { done, total }, children } : {}),
2975
+ reportUrl: `${base}/user/reports/reports-page?type=SUITE&parentId=${suiteNum}&id=${runIdNum}&projectId=${projectId}`,
2976
+ };
2977
+ }
2978
+ }
2979
+ // TC path — per-step results via /exec/report + /reports/execution/getstepResult.
2712
2980
  const runs = await this.http.getJson(`/exec/report/${runId}/${projectId}`);
2713
2981
  if (!Array.isArray(runs) || runs.length === 0) {
2714
2982
  return { runId, state: 'queued' };
@@ -2764,9 +3032,37 @@ export class SimplifyQaAdapter {
2764
3032
  runId,
2765
3033
  state: mapRunState(str(chosen, 'status') ?? ''),
2766
3034
  stepResults,
2767
- reportUrl: `${base}/reports/execution/${runId}`,
3035
+ reportUrl: `${base}/user/reports/reports-page?type=TESTCASE&parentId=${parentId}&id=${Number.parseInt(numericId(runId), 10)}&projectId=${projectId}`,
2768
3036
  };
2769
3037
  }
3038
+ /** Most recent execution runId for a SUITE — same `/exec/execution/search` the /reports UI's
3039
+ * current-executions view drives, filtered to `type=SUITE` + this suite's numeric id, sorted by
3040
+ * id desc, limit 1. Returns the numeric run id (what `getRun` accepts), or null if the suite has
3041
+ * never been executed. */
3042
+ async getLatestSuiteRun(suiteId) {
3043
+ const projectId = this.requireProjectId();
3044
+ const suiteNum = Number.parseInt(numericId(suiteId), 10);
3045
+ if (!Number.isFinite(suiteNum))
3046
+ throw new Error(`getLatestSuiteRun: invalid suiteId ${suiteId}.`);
3047
+ // Hand-rolled payload — searchBody doesn't emit `sort` or `operator` keys the /reports search wants.
3048
+ const body = {
3049
+ searchFields: [
3050
+ { column: 'id', sort: 'dsc' },
3051
+ { column: 'deleted', value: false },
3052
+ { column: 'projectId', value: projectId, regEx: false },
3053
+ { column: 'childExecution', value: false },
3054
+ { column: 'parentExecutionCopy', value: true, operator: 'nor' },
3055
+ { column: 'type', value: 'SUITE', regEx: false },
3056
+ { column: 'executionTypeId', value: suiteNum, regEx: false },
3057
+ ],
3058
+ startIndex: 0,
3059
+ limit: 1,
3060
+ };
3061
+ const raw = await this.http.postJson('/exec/execution/search', body);
3062
+ const rows = pickArray(raw, 'data', 'content', 'executions');
3063
+ const runId = str(rows[0], 'id', '_id');
3064
+ return runId ?? null;
3065
+ }
2770
3066
  /** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
2771
3067
  * is paged (default 100) and API-key scoping can hide the selected project, which left
2772
3068
  * callers with an empty projectName. */
@@ -2801,36 +3097,60 @@ export class SimplifyQaAdapter {
2801
3097
  if (!Number.isFinite(tcNum))
2802
3098
  throw new Error(`startRun: could not resolve numeric id for ${tcId}.`);
2803
3099
  const mode = (opts.mode ?? 'local').toUpperCase();
3100
+ // LOCAL fast-fail: the desktop agent is what actually executes the TC (it polls the Redis queue
3101
+ // the backend RPUSHes to). If :4040 isn't up, no roster / customer-search / dispatch will help —
3102
+ // fail here with a clear message instead of enqueuing a job nothing will drain.
3103
+ if (mode === 'LOCAL' && !(await this.probeLocalAgent())) {
3104
+ throw new Error(`startRun (LOCAL): SimplifyQA desktop agent is not reachable at ` +
3105
+ `${this.cfg.url ?? '(no agent URL configured — set QA_MCP_URL)'}. Start the desktop ` +
3106
+ `agent, then try again. Use mode:'cloud' to run on a cloud runner instead — it does not ` +
3107
+ `need the desktop agent.`);
3108
+ }
2804
3109
  const rawTc = await this.fetchRawTestcase(tcId);
2805
3110
  if (!rawTc || Object.keys(rawTc).length === 0) {
2806
3111
  throw new Error(`startRun: test case ${tcId} not found.`);
2807
3112
  }
2808
- // Version resolution — same fallback chain buildTestcasePayload/getDefaultIteration use.
2809
- // Caller override (opts.versionId) wins; the name is looked up in the TC's versions[] so the
2810
- // exec payload's versionName matches the picked versionId.
3113
+ // Version resolution. When the caller supplies an override we accept EITHER the version id or
3114
+ // its name (matches the ask-user UX — a human types "v2.0", not a uuid) and hard-fail if it
3115
+ // resolves to nothing. Silent fallback would send a mismatched {versionId, versionName} to
3116
+ // /exec/execution: at best the backend 500s, at worst it silently runs the TC's current
3117
+ // version instead of the one asked for. No override → the existing fallback chain picks the
3118
+ // TC's current version, matching the UI's Version-chip default.
3119
+ const versions = Array.isArray(rawTc?.versions) ? rawTc.versions : [];
2811
3120
  let versionName;
2812
3121
  let versionId;
2813
3122
  if (opts.versionId) {
2814
- versionId = opts.versionId;
2815
- if (Array.isArray(rawTc?.versions)) {
2816
- const picked = rawTc.versions.find((v) => str(v, 'id', '_id') === opts.versionId);
2817
- if (picked)
2818
- versionName = str(picked, 'name');
3123
+ const picked = versions.find((v) => {
3124
+ const vid = str(v, 'id', '_id');
3125
+ const vname = str(v, 'name');
3126
+ return vid === opts.versionId || vname === opts.versionId;
3127
+ });
3128
+ if (!picked) {
3129
+ if (!versions.length) {
3130
+ throw new Error(`startRun: test case ${tcId} has no versions available — cannot pin version "${opts.versionId}".`);
3131
+ }
3132
+ const listing = versions
3133
+ .map((v) => ` • \`${str(v, 'id', '_id')}\` — ${str(v, 'name')}`)
3134
+ .join('\n');
3135
+ throw new Error(`startRun: version "${opts.versionId}" not found on ${tcId}. Available:\n${listing}`);
2819
3136
  }
3137
+ versionId = str(picked, 'id', '_id');
3138
+ versionName = str(picked, 'name');
3139
+ }
3140
+ else {
3141
+ versionName = str(rawTc, 'versionName', 'selectedVersionName');
3142
+ versionId = str(rawTc, 'versionId', 'selectedVersionId');
3143
+ if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
3144
+ versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
3145
+ versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
3146
+ }
3147
+ if ((!versionName || !versionId) && versions.length > 0) {
3148
+ versionName ?? (versionName = str(versions[0], 'name'));
3149
+ versionId ?? (versionId = str(versions[0], 'id', '_id'));
3150
+ }
3151
+ versionName ?? (versionName = 'v1.0');
3152
+ versionId ?? (versionId = '');
2820
3153
  }
2821
- versionName ?? (versionName = str(rawTc, 'versionName', 'selectedVersionName'));
2822
- versionId ?? (versionId = str(rawTc, 'versionId', 'selectedVersionId'));
2823
- if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
2824
- versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
2825
- versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
2826
- }
2827
- if ((!versionName || !versionId) && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
2828
- const v = rawTc.versions[0];
2829
- versionName ?? (versionName = str(v, 'name'));
2830
- versionId ?? (versionId = str(v, 'id', '_id'));
2831
- }
2832
- versionName ?? (versionName = 'v1.0');
2833
- versionId ?? (versionId = '');
2834
3154
  // ponytail: was `this.info()` — 5 round-trips (list_projects + 4 counts) for one field, and
2835
3155
  // returned projectName="" when the current project wasn't in the first 100-row page or was
2836
3156
  // filtered by API-key scope, which the /exec/execution endpoint rejects with HTTP 400.
@@ -2862,8 +3182,16 @@ export class SimplifyQaAdapter {
2862
3182
  }
2863
3183
  catch { /* leave empty */ }
2864
3184
  }
2865
- const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId) : null;
2866
- const platform = this.buildRunPlatform(tcType, mode, opts.browser, opts.deviceId);
3185
+ const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId)
3186
+ : mode === 'REMOTE' ? await this.resolveRemoteAgentId(opts.agentId)
3187
+ : null;
3188
+ const platform = this.buildRunPlatform(tcType, mode, {
3189
+ ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
3190
+ ...(opts.deviceId !== undefined ? { deviceId: opts.deviceId } : {}),
3191
+ ...(opts.deviceType !== undefined ? { deviceType: opts.deviceType } : {}),
3192
+ ...(opts.osVersion !== undefined ? { osVersion: opts.osVersion } : {}),
3193
+ ...(opts.cloudDeviceType !== undefined ? { cloudDeviceType: opts.cloudDeviceType } : {}),
3194
+ });
2867
3195
  // UI uses `Math.random().toString(26).substr(5,6)` — plan doc confirms any string works.
2868
3196
  const stepId = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
2869
3197
  const body = {
@@ -2935,6 +3263,25 @@ export class SimplifyQaAdapter {
2935
3263
  killType: 'STOP_EXECUTION',
2936
3264
  });
2937
3265
  }
3266
+ /** GET the agent /mcp with a 1s timeout. Any HTTP response (incl. 4xx from an MCP server rejecting
3267
+ * a naked GET) proves the process is up; connect/DNS/timeout errors mean it is down. Replaces
3268
+ * the customer-wide `machineRegistration` round trip when all we needed was "is LOCAL possible?". */
3269
+ async probeLocalAgent() {
3270
+ if (!this.cfg.url)
3271
+ return false;
3272
+ const ac = new AbortController();
3273
+ const timer = setTimeout(() => ac.abort(), 1000);
3274
+ try {
3275
+ await fetch(this.cfg.url, { method: 'GET', signal: ac.signal });
3276
+ return true;
3277
+ }
3278
+ catch {
3279
+ return false;
3280
+ }
3281
+ finally {
3282
+ clearTimeout(timer);
3283
+ }
3284
+ }
2938
3285
  async listCloudRunners() {
2939
3286
  const projectId = this.requireProjectId();
2940
3287
  const body = this.searchBody([{ column: 'projectId', value: projectId }], { limit: 1000 });
@@ -2955,6 +3302,46 @@ export class SimplifyQaAdapter {
2955
3302
  };
2956
3303
  });
2957
3304
  }
3305
+ getLocalIdentifiers() {
3306
+ if (this.localIdsCache)
3307
+ return this.localIdsCache;
3308
+ const hostFull = os.hostname().toLowerCase();
3309
+ const hostBare = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
3310
+ const macs = new Set();
3311
+ const ips = new Set();
3312
+ const ifaces = os.networkInterfaces();
3313
+ for (const list of Object.values(ifaces)) {
3314
+ if (!list)
3315
+ continue;
3316
+ for (const iface of list) {
3317
+ if (iface.internal)
3318
+ continue;
3319
+ if (iface.mac && iface.mac !== '00:00:00:00:00:00')
3320
+ macs.add(iface.mac.toLowerCase());
3321
+ if (iface.address)
3322
+ ips.add(iface.address);
3323
+ }
3324
+ }
3325
+ this.localIdsCache = { hostBare, macs, ips };
3326
+ return this.localIdsCache;
3327
+ }
3328
+ /** Does a machineRegistration row belong to the machine this MCP is running on?
3329
+ * A single positive match on hostname, MAC, or IP is enough — the local agent registers with
3330
+ * ALL of these, but the surviving match on the SQA row depends on OS quirks (Windows writes a
3331
+ * padded hostname, macOS writes `foo.local`, IPs shift on Wi-Fi handoff). Any one match is
3332
+ * proof enough; we don't try to reconcile the others. */
3333
+ isThisMachine(row, ids = this.getLocalIdentifiers()) {
3334
+ const name = (str(row, 'machineName', 'name', 'host') ?? '').toLowerCase();
3335
+ if (ids.hostBare.length > 0 && name.length > 0 && name.includes(ids.hostBare))
3336
+ return true;
3337
+ const mac = (str(row, 'macAddress') ?? '').toLowerCase();
3338
+ if (mac.length > 0 && ids.macs.has(mac))
3339
+ return true;
3340
+ const ip = str(row, 'ipAddress', 'hostAddress') ?? '';
3341
+ if (ip.length > 0 && ids.ips.has(ip))
3342
+ return true;
3343
+ return false;
3344
+ }
2958
3345
  async listLocalAgents() {
2959
3346
  // ponytail: no projectId filter — machine_registration.projectId is never written by
2960
3347
  // test-management/services/machine_registration.service.js (create only stamps
@@ -2963,77 +3350,252 @@ export class SimplifyQaAdapter {
2963
3350
  const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
2964
3351
  const raw = await this.http.postJson('/tm/machineRegistration/search', body);
2965
3352
  const rows = pickArray(raw, 'data', 'content', 'agents', 'machines');
2966
- // ponytail: hostname override, same trick sqa-ui uses at
2967
- // sqa-ui/.../machine-registration.component.ts:169. The DB `active` flag is only
2968
- // written to `true` on first registration (fire-and-forget in executions/services/
2969
- // executionQueue.service.js:49) and back to `false` by a 7s-stale cron. Every other poll
2970
- // is a no-op, so a still-polling agent frequently shows `active:false`. When the row's
2971
- // machineName matches THIS box's hostname, MCP is running on the same machine as the
2972
- // agent — trust that over the stale DB flag. Upgrade path: if the backend adds a live
2973
- // Redis-presence endpoint (ZSCORE SQAAGENTTRACKER), consult that here instead.
2974
- const hostFull = os.hostname().toLowerCase();
2975
- // Bare hostname: strip trailing .local/.lan/.localdomain AND take the first label. Handles
2976
- // both directions of "short vs FQDN" mismatch — the agent may register as `Pratiks-MBP`
2977
- // while os.hostname() returns `pratiks-mbp.local`, or vice-versa.
2978
- const hostBare = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
3353
+ // ponytail: mirror sqa-ui/machine-registration.component.ts:169. The DB `active` flag is only
3354
+ // written to `true` on FIRST registration (fire-and-forget in executions/services/
3355
+ // executionQueue.service.js:49) and back to `false` by a 7s-stale cron. Every subsequent poll
3356
+ // is a no-op for the DB — a still-polling agent frequently shows `active:false`. Executions
3357
+ // service is off-limits for the fix, so when the row belongs to THIS machine (any of hostname
3358
+ // / MAC / IP matches), trust that over the stale DB flag.
3359
+ const ids = this.getLocalIdentifiers();
2979
3360
  return rows.map((n) => {
2980
3361
  const id = str(n, 'id', '_id', 'agentId') ?? '';
2981
3362
  const agentName = str(n, 'machineName', 'name', 'host');
2982
3363
  const owner = str(n, 'owner', 'userName', 'createdBy');
2983
3364
  const hostAddr = str(n, 'macAddress', 'hostAddress');
2984
3365
  const dbActive = typeof n?.active === 'boolean' ? n.active : false;
2985
- const nameLc = agentName?.toLowerCase() ?? '';
2986
- const isMyBox = nameLc.length > 0 && hostBare.length > 0 && nameLc.includes(hostBare);
3366
+ const isMyBox = this.isThisMachine(n, ids);
2987
3367
  return {
2988
3368
  id,
2989
3369
  ...(agentName ? { name: agentName } : {}),
2990
3370
  online: dbActive || isMyBox,
3371
+ ...(isMyBox ? { isThisMachine: true } : {}),
2991
3372
  ...(owner ? { owner } : {}),
2992
3373
  ...(hostAddr ? { host: hostAddr } : {}),
2993
3374
  };
2994
3375
  });
2995
3376
  }
3377
+ /** Mobile devices for the project — the union of:
3378
+ * (a) the LOCAL roster from `/tm/mobileDevices/search` (adb-visible reals, emulators,
3379
+ * simulators registered via the desktop agent) — same source the UI's "Real Devices /
3380
+ * Emulators / Simulators" tabs read; `active` is a last-known connection flag stale by
3381
+ * design (the desktop agent writes it, a 7s-stale cron flips it back).
3382
+ * (b) the CLOUD roster from whichever farm the project has integrated — probed in the same
3383
+ * order as the UI (`execution-popup-modal-v2.component.ts:198-204`): LambdaTest,
3384
+ * BrowserStack, SauceLabs, SimplifyQA Device Farm. First provider that answers with a
3385
+ * non-empty list wins (matches the UI's `mobileIntergation.type` single-provider model —
3386
+ * one cloud provider per project). Cloud rows carry `deviceType:'CLOUD_DEVICE'` and a
3387
+ * `cloudDeviceType` tag so `tc_run` knows which farm to submit to.
3388
+ * A failure fetching one source doesn't kill the other — the tool logs and returns whatever
3389
+ * it could resolve. */
3390
+ async listMobileDevices(platform) {
3391
+ const local = await this.fetchLocalMobileDevices(platform).catch(() => []);
3392
+ const cloud = await this.fetchCloudMobileDevices(platform).catch(() => []);
3393
+ return [...local, ...cloud];
3394
+ }
3395
+ async fetchLocalMobileDevices(platform) {
3396
+ const body = this.searchBody([
3397
+ { column: 'deleted', value: false },
3398
+ { column: 'technologyType', value: platform },
3399
+ ], { limit: 1000 });
3400
+ const raw = await this.http.postJson('/tm/mobileDevices/search', body);
3401
+ const rows = pickArray(raw, 'data', 'content', 'devices');
3402
+ return rows.map((n) => {
3403
+ const id = str(n, 'deviceId', 'udid', 'id', '_id') ?? '';
3404
+ const name = str(n, 'deviceName', 'name', 'model') ?? id;
3405
+ const rawType = String(str(n, 'deviceType') ?? 'REAL_DEVICE').toUpperCase();
3406
+ // SQA stores "REAL" | "EMULATOR" | "SIMULATOR" — normalise "REAL" → "REAL_DEVICE" so the
3407
+ // adapter's downstream wire mapping stays consistent with the executor's enum.
3408
+ const deviceType = rawType === 'REAL' ? 'REAL_DEVICE'
3409
+ : rawType;
3410
+ const osVersion = str(n, 'osVersion');
3411
+ const status = str(n, 'status');
3412
+ const agentId = str(n, 'agentId');
3413
+ return {
3414
+ id, name, platform, deviceType,
3415
+ ...(osVersion ? { osVersion } : {}),
3416
+ ...(typeof n?.active === 'boolean' ? { active: n.active } : {}),
3417
+ ...(status ? { status } : {}),
3418
+ ...(agentId ? { agentId } : {}),
3419
+ };
3420
+ });
3421
+ }
3422
+ /** Probe cloud farms in the UI's order and return the first non-empty match. One provider per
3423
+ * project (the UI's `mobileIntergation.type` model), so no need to merge across farms. Every
3424
+ * fetch is wrapped so a 404/401 from one provider doesn't kill the whole list. */
3425
+ async fetchCloudMobileDevices(platform) {
3426
+ // Order matches execution-popup-modal-v2:198-204 (SauceLabs → BrowserStack → LambdaTest).
3427
+ // SQA Device Farm is the fallback the UI only reaches when LambdaTest is inactive (line 203).
3428
+ const attempts = [
3429
+ ['SAUCE_LABS', () => this.fetchSauceLabsMobileDevices(platform)],
3430
+ ['BROWSER_STACK', () => this.fetchBrowserStackMobileDevices(platform)],
3431
+ ['LAMBDA_TEST', () => this.fetchLambdaTestMobileDevices(platform)],
3432
+ ['SIMPLIFY_QA_DEVICE_FORM', () => this.fetchDeviceFarmMobileDevices(platform)],
3433
+ ];
3434
+ for (const [, fetcher] of attempts) {
3435
+ const rows = await fetcher().catch(() => []);
3436
+ if (rows.length)
3437
+ return rows;
3438
+ }
3439
+ return [];
3440
+ }
3441
+ async fetchSauceLabsMobileDevices(platform) {
3442
+ const projectId = this.requireProjectId();
3443
+ const raw = await this.http.getJson(`/tm/marketPlace/sauceLabsDevices/SAUCE_LABS?projectId=${encodeURIComponent(projectId)}`);
3444
+ const rows = pickArray(raw, 'data', 'content', 'devices');
3445
+ // SauceLabs returns OS as UPPERCASE ("IOS"/"ANDROID") — mirror execution-popup-modal-v2:447-448.
3446
+ const wanted = platform.toUpperCase();
3447
+ return rows
3448
+ .filter((n) => String(n?.os ?? '').toUpperCase() === wanted)
3449
+ .map((n) => {
3450
+ const id = str(n, 'id', 'udid', 'name') ?? '';
3451
+ const name = str(n, 'name', 'deviceName', 'model') ?? id;
3452
+ const osVersion = str(n, 'osVersion', 'os_version');
3453
+ return {
3454
+ id, name, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'SAUCE_LABS',
3455
+ ...(osVersion ? { osVersion } : {}),
3456
+ };
3457
+ });
3458
+ }
3459
+ async fetchBrowserStackMobileDevices(platform) {
3460
+ const projectId = this.requireProjectId();
3461
+ const raw = await this.http.getJson(`/tm/testdata/browserStackDevices/BROWSER_STACK?projectId=${encodeURIComponent(projectId)}`);
3462
+ const rows = pickArray(raw, 'data', 'content', 'devices');
3463
+ // BrowserStack returns OS as lowercase ("ios"/"android") — mirror execution-popup-modal-v2:434-435.
3464
+ const wanted = platform.toLowerCase();
3465
+ return rows
3466
+ .filter((n) => String(n?.os ?? '').toLowerCase() === wanted)
3467
+ .map((n) => {
3468
+ const id = str(n, 'id', 'device') ?? '';
3469
+ const name = str(n, 'device', 'name') ?? id;
3470
+ const osVersion = str(n, 'os_version', 'osVersion');
3471
+ return {
3472
+ id, name, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'BROWSER_STACK',
3473
+ ...(osVersion ? { osVersion } : {}),
3474
+ };
3475
+ });
3476
+ }
3477
+ async fetchLambdaTestMobileDevices(platform) {
3478
+ const projectId = this.requireProjectId();
3479
+ const raw = await this.http.getJson(`/tm/marketPlace/lambdaTestDevices/LAMBDA_TEST?projectId=${encodeURIComponent(projectId)}`);
3480
+ const rows = pickArray(raw, 'data', 'content', 'devices');
3481
+ // LambdaTest returns OS as lowercase in `platformName`/`os` — mirror the mapping in
3482
+ // execution-popup-modal-v2:472-493 (which composes a friendly display name + slug id).
3483
+ const wantedLower = platform.toLowerCase();
3484
+ const seen = new Set();
3485
+ const mapped = [];
3486
+ for (const d of rows) {
3487
+ const os = String(d?.platformName ?? d?.os ?? '').toLowerCase();
3488
+ if (os !== wantedLower)
3489
+ continue;
3490
+ const baseName = str(d, 'deviceName', 'name') ?? '';
3491
+ const version = str(d, 'version', 'platformVersion', 'osVersion') ?? '';
3492
+ const displayName = version
3493
+ ? `${baseName} (${os === 'ios' ? 'iOS' : 'Android'} ${version})`
3494
+ : baseName;
3495
+ const udid = str(d, 'udid');
3496
+ const slug = `${baseName}_${version}_${os}`.toLowerCase()
3497
+ .replace(/[^a-z0-9_]+/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
3498
+ const id = udid ? `cloud_${udid}` : `cloud_${slug}`;
3499
+ if (seen.has(id))
3500
+ continue;
3501
+ seen.add(id);
3502
+ mapped.push({
3503
+ id, name: displayName, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'LAMBDA_TEST',
3504
+ ...(version ? { osVersion: version } : {}),
3505
+ });
3506
+ }
3507
+ return mapped;
3508
+ }
3509
+ async fetchDeviceFarmMobileDevices(platform) {
3510
+ const projectId = this.requireProjectId();
3511
+ // SimplifyQA Device Farm is Appium-backed — endpoint mirrors marketPlace.route.js:25. The UI's
3512
+ // `getCloudFormDevicesForExec()` reads the same source when LambdaTest is inactive.
3513
+ const raw = await this.http.getJson(`/tm/marketPlace/deviceFarmDevices/SIMPLIFY_QA_DEVICE_FORM?projectId=${encodeURIComponent(projectId)}`);
3514
+ const rows = pickArray(raw, 'data', 'content', 'devices');
3515
+ // Farm rows include `platform` (ANDROID/IOS) and `sdk` (osVersion). Mirror device-dropdown.ts:413-419.
3516
+ const wanted = platform.toUpperCase();
3517
+ return rows
3518
+ .filter((n) => String(n?.platform ?? '').toUpperCase() === wanted)
3519
+ .map((n) => {
3520
+ const id = str(n, 'udid', 'id') ?? '';
3521
+ const name = str(n, 'name', 'deviceName') ?? id;
3522
+ const osVersion = str(n, 'sdk', 'osVersion');
3523
+ return {
3524
+ id, name, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'SIMPLIFY_QA_DEVICE_FORM',
3525
+ ...(osVersion ? { osVersion } : {}),
3526
+ };
3527
+ });
3528
+ }
2996
3529
  /**
2997
- * Pick THE ACTING USER'S OWN active agent — never row [0] of a shared-tenant roster (which
2998
- * would silently dispatch a local run to a teammate's box, and the run would sit queued until
2999
- * TTL because it lands in a Redis key that no local agent on the caller's machine polls).
3530
+ * Pick THE ACTING USER'S OWN agent — never row [0] of a shared-tenant roster (which would
3531
+ * silently dispatch a local run to a teammate's box, and the run would sit queued until TTL
3532
+ * because it lands in a Redis key that no local agent on the caller's machine polls).
3000
3533
  *
3001
3534
  * SQA's `machineRegistration` row carries no owner_user_id column (unlike walnut, which matches
3002
- * the JWT `id` claim to `owner_user_id`), so we identify "your" agent by hostname: common-mcp
3003
- * runs on the caller's own box in the typical MCP setup, and SQA `machineName` is either the
3004
- * bare OS hostname or a bracketed display form (`USER.NAME [ Hostname.local ]`) — both contain
3005
- * `os.hostname()`. Filter → active only → hostname substring match → return the single hit.
3006
- * Zero/many matches refuse honestly; a silent guess would just repeat the exact bug we're fixing.
3535
+ * the JWT `id` claim to `owner_user_id`), so we identify "your" agent by physical identity:
3536
+ * hostname, MAC address, or IP — any one match against `os.networkInterfaces()` on the box this
3537
+ * MCP is running on. When exactly one row is "this machine", pick it regardless of the DB
3538
+ * `active` flag (that flag is stale by design — see listLocalAgents for the executionQueue bug).
3007
3539
  *
3008
- * ponytail: hostname heuristic. Upgrade to owner-user-id match if the backend adds that column
3009
- * to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
3540
+ * ponytail: multi-identifier heuristic. Upgrade to owner-user-id match if the backend adds that
3541
+ * column to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
3010
3542
  */
3011
3543
  async resolveLocalAgentId(preferred) {
3012
- if (preferred && preferred.length > 0)
3013
- return preferred;
3014
3544
  const agents = await this.listLocalAgents();
3015
3545
  if (agents.length === 0) {
3016
3546
  throw new Error('startRun (LOCAL): no local agent registered. Register a machine in the SimplifyQA UI ' +
3017
3547
  'first, or pass mode: "cloud" if a cloud runner is provisioned.');
3018
3548
  }
3019
- const active = agents.filter((a) => a.online);
3020
- if (active.length === 0) {
3021
- throw new Error('startRun (LOCAL): no local agent is currently active (polling). Launch your SimplifyQA ' +
3022
- 'desktop agent and retry — or pass `agentId` explicitly if you know the credential id.');
3549
+ // Priority 1: rows that physically match this machine. Ignore the DB `active` flag — a running
3550
+ // agent frequently shows `active:false` between the 5s stale-cron and the next heartbeat, and
3551
+ // the executions service is off-limits for the real fix.
3552
+ const mine = agents.filter((a) => a.isThisMachine);
3553
+ // Explicit agentId: only accept it when it belongs to THIS machine. LOCAL dispatches to the
3554
+ // desktop agent on this box (there is exactly one per machine); a teammate's credentialId under
3555
+ // mode:'local' was the silent-misroute bug isThisMachine exists to prevent.
3556
+ if (preferred && preferred.length > 0) {
3557
+ if (mine.some((a) => a.id === preferred))
3558
+ return preferred;
3559
+ const listing = agents.map((a) => ` - ${a.name ?? a.id} \`${a.id}\`${a.isThisMachine ? ' (this machine)' : ''}`).join('\n');
3560
+ throw new Error(`startRun (LOCAL): agentId "${preferred}" is not registered from THIS machine. LOCAL mode ` +
3561
+ `dispatches to this box's own desktop agent — teammate agents are for a REMOTE run. ` +
3562
+ `Omit agentId to auto-pick this machine's agent, or use mode:'cloud' for a cloud runner.\n` +
3563
+ `Registered agents:\n${listing}`);
3023
3564
  }
3024
- const hostFull = os.hostname().toLowerCase();
3025
- // Same short-vs-FQDN handling as listLocalAgents — see the ponytail note there.
3026
- const host = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
3027
- const mine = active.filter((a) => host.length > 0 && (a.name ?? '').toLowerCase().includes(host));
3028
3565
  if (mine.length === 1)
3029
3566
  return mine[0].id;
3030
- const listing = (arr) => arr.map((a) => ` - ${a.name ?? a.id} \`${a.id}\``).join('\n');
3031
3567
  if (mine.length > 1) {
3032
- throw new Error(`startRun (LOCAL): multiple active agents match this machine (${host}). Pass \`agentId\` ` +
3033
- `to pick one:\n${listing(mine)}`);
3568
+ const listing = mine.map((a) => ` - ${a.name ?? a.id} \`${a.id}\``).join('\n');
3569
+ throw new Error(`startRun (LOCAL): multiple agents registered from this machine. Pass \`agentId\` to ` +
3570
+ `pick one:\n${listing}`);
3571
+ }
3572
+ // No physical match for this machine and no preferred — LOCAL cannot proceed.
3573
+ const roster = agents.map((a) => ` - ${a.name ?? a.id} \`${a.id}\`${a.online ? '' : ' (offline)'}`).join('\n');
3574
+ throw new Error(`startRun (LOCAL): no agent registered from this machine (hostname / MAC / IP did not match ` +
3575
+ `any row). Launch the SimplifyQA desktop agent on this box, or use mode:'remote' to run on a ` +
3576
+ `teammate's agent.\nRegistered agents:\n${roster}`);
3577
+ }
3578
+ /** REMOTE dispatches to ANOTHER machine's desktop agent — must pin a specific credentialId (auto-pick
3579
+ * across teammate boxes is the exact silent-misroute this exists to prevent). Enforces:
3580
+ * (1) preferred is present, (2) it exists in the roster, (3) it is NOT this machine (use LOCAL for
3581
+ * that). Reachability of the remote agent is not probed — the backend queues the job and the
3582
+ * remote agent picks it up when it polls. */
3583
+ async resolveRemoteAgentId(preferred) {
3584
+ if (!preferred || preferred.length === 0) {
3585
+ throw new Error(`startRun (REMOTE): agentId is required. REMOTE dispatches to a specific teammate machine — ` +
3586
+ `there is no auto-pick. Call remote_agents to see the roster and pass one as \`agentId\`.`);
3587
+ }
3588
+ const agents = await this.listLocalAgents();
3589
+ const picked = agents.find((a) => a.id === preferred);
3590
+ if (!picked) {
3591
+ throw new Error(`startRun (REMOTE): agentId "${preferred}" is not a registered agent. Call remote_agents ` +
3592
+ `for the current roster.`);
3593
+ }
3594
+ if (picked.isThisMachine) {
3595
+ throw new Error(`startRun (REMOTE): agentId "${preferred}" is THIS machine's own agent — use mode:'local' ` +
3596
+ `for a run on this box.`);
3034
3597
  }
3035
- throw new Error(`startRun (LOCAL): could not auto-pick your local agent — no active agent's name matches ` +
3036
- `this hostname (${host}). Pass \`agentId\` explicitly. Active agents:\n${listing(active)}`);
3598
+ return preferred;
3037
3599
  }
3038
3600
  detectExecutionOS() {
3039
3601
  if (process.platform === 'win32')
@@ -3042,30 +3604,36 @@ export class SimplifyQaAdapter {
3042
3604
  return 'MAC';
3043
3605
  return 'LINUX';
3044
3606
  }
3045
- buildRunPlatform(tcType, mode, browser, deviceId) {
3607
+ buildRunPlatform(tcType, mode, opts) {
3046
3608
  const t = tcType.toUpperCase();
3047
3609
  if (t === 'WEB') {
3048
3610
  if (mode === 'CLOUD') {
3049
- const key = `${(browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
3611
+ const key = `${(opts.browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
3050
3612
  return [{ name: key, id: key, technologyType: 'WEB', type: 'NONE' }];
3051
3613
  }
3052
- const b = (browser ?? 'CHROME').toUpperCase();
3614
+ const b = (opts.browser ?? 'CHROME').toUpperCase();
3053
3615
  return [{ name: b, id: b, technologyType: 'WEB', type: 'NONE' }];
3054
3616
  }
3055
3617
  if (t === 'ANDROID' || t === 'IOS') {
3056
3618
  // Executor's AndroidSessionFactory.buildLocal calls requireDeviceId — no default. Fail
3057
3619
  // loud so the caller sees the fix (list devices, pick one) instead of a queued/error hang.
3058
- if (!deviceId) {
3059
- throw new Error(`${t} test cases need \`deviceId\`. Call android_list_devices to see the available ` +
3620
+ if (!opts.deviceId) {
3621
+ throw new Error(`${t} test cases need \`deviceId\`. Call mobile_list_devices to see the available ` +
3060
3622
  `devices, then re-run with \`deviceId\` set to the adb serial (LOCAL) or marketplace ` +
3061
3623
  `device name (CLOUD).`);
3062
3624
  }
3063
- // ponytail: CLOUD path still ships `cloudDeviceType: undefined` — the executor needs it
3064
- // (BROWSER_STACK / SAUCE_LABS / LAMBDA_INTEGRATION / SQA_DEVICE_FARM) to know which farm
3065
- // to route to. Deferred with the marketplace port; LOCAL runs unaffected.
3625
+ // Default LOCAL to REAL_DEVICE when caller doesn't say — mirrors the pre-deviceType shape.
3626
+ // CLOUD gets CLOUD_DEVICE for the same reason. Explicit deviceType wins over both.
3627
+ const type = opts.deviceType ?? (mode === 'CLOUD' ? 'CLOUD_DEVICE' : 'REAL_DEVICE');
3628
+ const isCloud = type.startsWith('CLOUD_');
3629
+ // ponytail: LambdaTest cloud runs work without app-URL preload for most flows — the
3630
+ // executor derives creds from the project's LT integration server-side. If a run fails
3631
+ // with "app not found" we'll port the UI's preloadLambdaAppMap flow (execution-popup-
3632
+ // modal-v2:613-641) and stamp appUrl/userName/accessKey here.
3066
3633
  return [{
3067
- name: deviceId, id: deviceId, technologyType: t,
3068
- type: mode === 'CLOUD' ? 'CLOUD_DEVICE' : 'NONE',
3634
+ name: opts.deviceId, id: opts.deviceId, technologyType: t, type,
3635
+ ...(opts.osVersion ? { osVersion: opts.osVersion } : {}),
3636
+ ...(isCloud && opts.cloudDeviceType ? { cloudDeviceType: opts.cloudDeviceType } : {}),
3069
3637
  }];
3070
3638
  }
3071
3639
  return [{ name: t, id: t, technologyType: t, type: 'NONE' }];