common-qa-mcp 0.2.0 → 0.4.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 (110) hide show
  1. package/README.md +6 -0
  2. package/dist/adapters/factory.d.ts +17 -7
  3. package/dist/adapters/factory.d.ts.map +1 -1
  4. package/dist/adapters/factory.js +44 -7
  5. package/dist/adapters/factory.js.map +1 -1
  6. package/dist/adapters/http.d.ts +50 -0
  7. package/dist/adapters/http.d.ts.map +1 -0
  8. package/dist/adapters/http.js +108 -0
  9. package/dist/adapters/http.js.map +1 -0
  10. package/dist/adapters/remoteMcp/adapter.d.ts +16 -2
  11. package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
  12. package/dist/adapters/remoteMcp/adapter.js +34 -5
  13. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  14. package/dist/adapters/simplifyqa/adapter.d.ts +437 -0
  15. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -0
  16. package/dist/adapters/simplifyqa/adapter.js +3097 -0
  17. package/dist/adapters/simplifyqa/adapter.js.map +1 -0
  18. package/dist/adapters/simplifyqa/mapper.d.ts +95 -0
  19. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -0
  20. package/dist/adapters/simplifyqa/mapper.js +858 -0
  21. package/dist/adapters/simplifyqa/mapper.js.map +1 -0
  22. package/dist/adapters/types.d.ts +168 -5
  23. package/dist/adapters/types.d.ts.map +1 -1
  24. package/dist/adapters/walnut/adapter.d.ts +235 -0
  25. package/dist/adapters/walnut/adapter.d.ts.map +1 -0
  26. package/dist/adapters/walnut/adapter.js +1114 -0
  27. package/dist/adapters/walnut/adapter.js.map +1 -0
  28. package/dist/adapters/walnut/mapper.d.ts +30 -9
  29. package/dist/adapters/walnut/mapper.d.ts.map +1 -1
  30. package/dist/adapters/walnut/mapper.js +149 -24
  31. package/dist/adapters/walnut/mapper.js.map +1 -1
  32. package/dist/config.d.ts +38 -14
  33. package/dist/config.d.ts.map +1 -1
  34. package/dist/config.js +73 -19
  35. package/dist/config.js.map +1 -1
  36. package/dist/connection.d.ts +3 -2
  37. package/dist/connection.d.ts.map +1 -1
  38. package/dist/connection.js +44 -15
  39. package/dist/connection.js.map +1 -1
  40. package/dist/domain/types.d.ts +680 -5
  41. package/dist/domain/types.d.ts.map +1 -1
  42. package/dist/drafts/store.d.ts +4 -1
  43. package/dist/drafts/store.d.ts.map +1 -1
  44. package/dist/drafts/store.js +7 -2
  45. package/dist/drafts/store.js.map +1 -1
  46. package/dist/initAgents.d.ts.map +1 -1
  47. package/dist/initAgents.js +48 -18
  48. package/dist/initAgents.js.map +1 -1
  49. package/dist/requestConnection.d.ts +5 -3
  50. package/dist/requestConnection.d.ts.map +1 -1
  51. package/dist/requestConnection.js +11 -5
  52. package/dist/requestConnection.js.map +1 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +47 -1
  55. package/dist/server.js.map +1 -1
  56. package/dist/tools/backlog.tools.d.ts +13 -0
  57. package/dist/tools/backlog.tools.d.ts.map +1 -0
  58. package/dist/tools/backlog.tools.js +399 -0
  59. package/dist/tools/backlog.tools.js.map +1 -0
  60. package/dist/tools/connection.tools.js +5 -5
  61. package/dist/tools/connection.tools.js.map +1 -1
  62. package/dist/tools/defects.tools.d.ts +7 -0
  63. package/dist/tools/defects.tools.d.ts.map +1 -0
  64. package/dist/tools/defects.tools.js +82 -0
  65. package/dist/tools/defects.tools.js.map +1 -0
  66. package/dist/tools/discovery.tools.d.ts.map +1 -1
  67. package/dist/tools/discovery.tools.js +951 -43
  68. package/dist/tools/discovery.tools.js.map +1 -1
  69. package/dist/tools/formulas.tools.d.ts +7 -0
  70. package/dist/tools/formulas.tools.d.ts.map +1 -0
  71. package/dist/tools/formulas.tools.js +56 -0
  72. package/dist/tools/formulas.tools.js.map +1 -0
  73. package/dist/tools/heal.tools.d.ts +17 -0
  74. package/dist/tools/heal.tools.d.ts.map +1 -0
  75. package/dist/tools/heal.tools.js +76 -0
  76. package/dist/tools/heal.tools.js.map +1 -0
  77. package/dist/tools/markdown.d.ts +3 -3
  78. package/dist/tools/markdown.d.ts.map +1 -1
  79. package/dist/tools/markdown.js +89 -18
  80. package/dist/tools/markdown.js.map +1 -1
  81. package/dist/tools/objects.tools.d.ts +7 -0
  82. package/dist/tools/objects.tools.d.ts.map +1 -0
  83. package/dist/tools/objects.tools.js +201 -0
  84. package/dist/tools/objects.tools.js.map +1 -0
  85. package/dist/tools/recording.tools.d.ts.map +1 -1
  86. package/dist/tools/recording.tools.js +92 -12
  87. package/dist/tools/recording.tools.js.map +1 -1
  88. package/dist/tools/run.tools.d.ts.map +1 -1
  89. package/dist/tools/run.tools.js +464 -39
  90. package/dist/tools/run.tools.js.map +1 -1
  91. package/dist/tools/schemas.d.ts +21 -4
  92. package/dist/tools/schemas.d.ts.map +1 -1
  93. package/dist/tools/schemas.js +14 -1
  94. package/dist/tools/schemas.js.map +1 -1
  95. package/dist/tools/storySync.tools.d.ts +66 -0
  96. package/dist/tools/storySync.tools.d.ts.map +1 -0
  97. package/dist/tools/storySync.tools.js +213 -0
  98. package/dist/tools/storySync.tools.js.map +1 -0
  99. package/dist/tools/tasks.tools.d.ts +7 -0
  100. package/dist/tools/tasks.tools.d.ts.map +1 -0
  101. package/dist/tools/tasks.tools.js +75 -0
  102. package/dist/tools/tasks.tools.js.map +1 -0
  103. package/dist/tools/testcase.tools.d.ts.map +1 -1
  104. package/dist/tools/testcase.tools.js +162 -101
  105. package/dist/tools/testcase.tools.js.map +1 -1
  106. package/dist/tools/testdata.tools.d.ts +7 -0
  107. package/dist/tools/testdata.tools.d.ts.map +1 -0
  108. package/dist/tools/testdata.tools.js +249 -0
  109. package/dist/tools/testdata.tools.js.map +1 -0
  110. package/package.json +3 -3
@@ -0,0 +1,3097 @@
1
+ import os from 'node:os';
2
+ import { makeClient } from '../http.js';
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
+ };
11
+ /** SQA run-row status → neutral RunStatus.state. */
12
+ function mapRunState(s) {
13
+ switch (s.toUpperCase()) {
14
+ case 'PASSED': return 'passed';
15
+ case 'FAILED': return 'failed';
16
+ case 'STOPPED': return 'stopped';
17
+ case 'RUNNING': return 'running';
18
+ case 'QUEUED': return 'queued';
19
+ default: return 'queued';
20
+ }
21
+ }
22
+ /** Formula names round-trip with parens on ("FIRST_NAME()") from the backend and sometimes with a full
23
+ * signature ("FIRST_NAME(country,sex)") from a caller. Reduce both to the bare identifier. */
24
+ function bareFormulaName(s) {
25
+ return s.replace(/\s*\(.*\)\s*$/, '').trim();
26
+ }
27
+ /** Query keys /tm/customFormula expects per formula, positionally — they are NOT the formula's declared
28
+ * keyNames. Mirrors the SQA UI (wfTestdataView.ts). Anything absent takes at most one arg as `value`.
29
+ * ponytail: table, not per-formula handlers — extend a row when a new multi-arg formula shows up. */
30
+ const FORMULA_ARG_KEYS = {
31
+ FIRST_NAME: ['country', 'sex', 'min_len', 'max_len'],
32
+ LAST_NAME: ['country', 'sex', 'min_len', 'max_len'],
33
+ FULL_NAME: ['country', 'sex', 'min_len', 'max_len'],
34
+ MIDDLE_NAME: ['country', 'sex', 'min_len', 'max_len'],
35
+ PREFIX: ['sex'],
36
+ DATE_OF_BIRTH: ['pattern', 'min_age', 'max_age'],
37
+ };
38
+ /** Normalise a formula document from `/tm/syncFormulas/fetch` into the neutral Formula shape. Returns
39
+ * null when the doc is missing a name — a nameless formula cannot be called. `scope` is inferred:
40
+ * DEFAULT → 'default'; CUSTOM with a matching projectId → 'project'; CUSTOM otherwise → 'customer'. */
41
+ function normalizeFormula(doc, projectId) {
42
+ if (!doc || typeof doc !== 'object')
43
+ return null;
44
+ const name = str(doc, 'name');
45
+ if (!name)
46
+ return null;
47
+ const type = str(doc, 'type') === 'CUSTOM' ? 'CUSTOM' : 'DEFAULT';
48
+ const docPid = Number(doc.projectId ?? 0);
49
+ const scope = type === 'DEFAULT' ? 'default' : docPid && docPid === projectId ? 'project' : 'customer';
50
+ const desc = (doc.description && typeof doc.description === 'object') ? doc.description : {};
51
+ const params = Array.isArray(desc.parameters)
52
+ ? desc.parameters
53
+ .map((p) => (p && typeof p === 'object' && typeof p.keyName === 'string')
54
+ ? { keyName: p.keyName, description: str(p, 'value', 'description') ?? undefined, dataType: str(p, 'dataType') ?? undefined }
55
+ : null)
56
+ .filter((p) => p !== null)
57
+ : [];
58
+ return {
59
+ name,
60
+ uniqueId: str(doc, 'uniqueId') ?? name,
61
+ type,
62
+ scope,
63
+ signature: str(desc, 'name') ?? `${name}()`,
64
+ details: str(desc, 'details') ?? undefined,
65
+ example: str(desc, 'example') ?? undefined,
66
+ parameters: params,
67
+ };
68
+ }
69
+ /** SQA per-step status → neutral step status. */
70
+ function mapStepStatus(s) {
71
+ switch (s.toUpperCase()) {
72
+ case 'PASSED': return 'pass';
73
+ case 'FAILED': return 'fail';
74
+ case 'SKIPPED': return 'skip';
75
+ case 'STOPPED': return 'stop';
76
+ default: return s.toLowerCase();
77
+ }
78
+ }
79
+ /** Allowed keys for save-as-new-version. The update joi validator
80
+ * (testcaseValidator.js updateTcBodyValidation, ~line 187) is a STRICT allow-list — a raw doc's
81
+ * extras (`code`, `createdBy`, `updatedBy`, `stepCount`, `versions`, ...) crash the save with
82
+ * "is not allowed". Kept as string arrays so a schema drift is a one-line fix. */
83
+ const TCFNOBJ_ALLOWED_KEYS = [
84
+ 'id', 'name', 'projectId', 'deleted', 'moduleId', 'userstoryId', 'obsolete', 'tcType', 'mbType',
85
+ 'executionType', 'description', 'prerequisite', 'updateVersion', 'linkedIssues', 'attachments',
86
+ 'tags', 'suiteId', 'defectId', 'customFields', 'reviewApproveStatus', 'copiedFromTcId',
87
+ 'projectLevelCopy', 'originalExecutinType', 'projectLevelcopiedFrom', 'mailersList',
88
+ 'configuration', 'executionPlatform',
89
+ ];
90
+ // FN update validator (functionValidator.js `updateFnBodyValidation.tcfnObj`) diverges from the TC
91
+ // one in three ways that MATTER for SAVENEWVERSION: `fnType` REQUIRED (not `tcType`);
92
+ // `obsolete`/`suiteId`/`defectId`/`configuration`/`executionPlatform`/`tcType`/`mbType` NOT
93
+ // allowed; `copiedFromFnId` in place of `copiedFromTcId`. Sending TC's list to `/tm/function`
94
+ // trips joi with "not allowed" on obsolete and "required" on fnType.
95
+ const TCFNOBJ_FN_ALLOWED_KEYS = [
96
+ 'id', 'name', 'projectId', 'deleted', 'moduleId', 'userstoryId', 'fnType',
97
+ 'executionType', 'description', 'prerequisite', 'updateVersion', 'linkedIssues', 'attachments',
98
+ 'tags', 'customFields', 'reviewApproveStatus', 'copiedFromFnId', 'projectLevelCopy',
99
+ 'projectLevelcopiedFrom', 'mailersList', 'originalExecutinType', 'isMigrated', 'oldCode',
100
+ ];
101
+ const STEP_ALLOWED_KEYS = [
102
+ 'projectId', 'sharedFnProjectId', 'moduleId', 'apiData', 'lastActiveTab', 'userstoryId',
103
+ 'updateVersion', 'type', 'parentId', 'objectId', 'stepType', 'functionId', 'actionId',
104
+ 'parameterIds', 'objectParameterIds', 'parameterizedParamIds', 'attachments', 'compareFiles',
105
+ 'compareFileType', 'deviceId', 'deleted', 'ignoreObject', 'browserType', 'technologyType', 'seq',
106
+ 'captureScreenshot', 'screenshotEnabled', 'fnVersionId', 'description', 'expectedResult',
107
+ 'conditions', 'stepLevelActions', 'skipParamInReport', 'paramLoopduration', 'showGroups',
108
+ 'isConditional', 'if', 'else', 'loop', 'isLoop', 'loopType', 'startFor', 'endFor',
109
+ 'loopDuration', 'executionType', 'recordedParam', 'switchToAndroidData', 'switchToIOSData',
110
+ 'isDebug', 'manualStepsTopreview', 'sharedFnProjectName', 'isMigrated', 'customFields',
111
+ // ponytail: id / versionId are in the schema but always stripped for SAVENEWVERSION — the
112
+ // backend re-assigns them to the fresh version row, and keeping the old id would resurrect it.
113
+ ];
114
+ function pick(obj, keys) {
115
+ const out = {};
116
+ if (!obj || typeof obj !== 'object')
117
+ return out;
118
+ for (const k of keys)
119
+ if (obj[k] !== undefined)
120
+ out[k] = obj[k];
121
+ return out;
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
+ /** Guard against feeding an FN business key into a TC-only endpoint (or vice-versa). `numericId`
140
+ * strips ANY alpha prefix — so "FN-22" → "22" and would silently target TC-22 if the caller
141
+ * routed to the wrong tool. Reject any business key whose prefix doesn't match the expected
142
+ * entity kind. Numeric ids and unrecognized-prefix strings pass through (leave that to backend). */
143
+ function assertBusinessKeyKind(id, expected) {
144
+ const m = id?.match(/^([A-Za-z]+)[_-]\d+$/);
145
+ if (!m)
146
+ return;
147
+ const prefix = m[1].toUpperCase();
148
+ if ((expected === 'TC' && prefix === 'FN') || (expected === 'FN' && prefix === 'TC')) {
149
+ const otherTool = expected === 'TC'
150
+ ? 'this is a function key — use the fn_* tool (fn_clone, fn_save_as_new_version, get_function, delete_function)'
151
+ : 'this is a test-case key — use the tc_* tool (tc_clone, tc_save_as_new_version, tc_get, tc_delete)';
152
+ throw new Error(`Refusing to run against ${id}: ${otherTool}.`);
153
+ }
154
+ }
155
+ /** Coerce a raw {[projectId]: [ids]} map from a dep row into a plain Record<string, number[]>.
156
+ * Absent/malformed inputs collapse to `{}`. */
157
+ function toDepMap(v) {
158
+ if (!v || typeof v !== 'object' || Array.isArray(v))
159
+ return {};
160
+ const out = {};
161
+ for (const [k, arr] of Object.entries(v)) {
162
+ if (Array.isArray(arr))
163
+ out[k] = arr.filter((n) => Number.isFinite(n));
164
+ }
165
+ return out;
166
+ }
167
+ /** Normalise a raw dep row from PUT /tm/tcfnversion/delete into the neutral shape. Key set is
168
+ * narrower for FN — the service emits `{tcArr, fnArr, wfArr}` there vs the TC keyset. */
169
+ function normalizeVersionDep(row, parentType) {
170
+ const id = Number(row?.id ?? 0);
171
+ if (parentType === 'FN') {
172
+ return {
173
+ id,
174
+ tcArr: toDepMap(row?.tcArr),
175
+ fnArr: toDepMap(row?.fnArr),
176
+ wfArr: toDepMap(row?.wfArr),
177
+ };
178
+ }
179
+ const name = typeof row?.versionName === 'string' ? row.versionName : undefined;
180
+ return {
181
+ id,
182
+ ...(name ? { versionName: name } : {}),
183
+ defectArr: toDepMap(row?.defectArr),
184
+ suiteArr: toDepMap(row?.suiteArr),
185
+ epArr: toDepMap(row?.epArr),
186
+ scenarioArr: toDepMap(row?.scenarioArr),
187
+ schedulerArr: toDepMap(row?.schedulerArr),
188
+ pipelineArr: toDepMap(row?.pipelineArr),
189
+ workflowArr: toDepMap(row?.workflowArr),
190
+ };
191
+ }
192
+ /** True when every `*Arr` on the row is an empty map — i.e. no references block the delete.
193
+ * Mirrors the UI's own check (testcase.component.ts:2142 / function.component.ts:1617). */
194
+ function versionDepIsEmpty(dep) {
195
+ const maps = 'tcArr' in dep && 'wfArr' in dep && !('defectArr' in dep)
196
+ ? [dep.tcArr, dep.fnArr, dep.wfArr]
197
+ : [
198
+ dep.defectArr, dep.suiteArr,
199
+ dep.epArr, dep.scenarioArr,
200
+ dep.schedulerArr, dep.pipelineArr,
201
+ dep.workflowArr,
202
+ ];
203
+ return maps.every((m) => Object.keys(m).length === 0);
204
+ }
205
+ const NEEDS_PROJECT = 'No project selected. Call list_projects to see the available projects, then ' +
206
+ 'qa_switch_project({ projectId }) to choose one — test cases, runs and objects ' +
207
+ 'are all scoped to a project.';
208
+ const HOP_NEEDED_WRITE = 'This SimplifyQA operation is not yet ported to direct REST — set QA_MCP_URL to the SimplifyQA ' +
209
+ 'agent /mcp so common-mcp can hop for it, or open the SimplifyQA UI to perform it there.';
210
+ /**
211
+ * SimplifyQaAdapter — DIRECT-to-backend REST driver for SimplifyQA, mirroring WalnutAdapter's
212
+ * design. Reads (list/get for projects/testcases/suites/stories/modules/releases/sprints/objects)
213
+ * hit the SimplifyQA backend REST directly via a `QaHttp` bound to `cfg.baseUrl`. Anything not yet
214
+ * ported (writes, testdata, run dispatch, marketplace, and the recording surface) delegates to
215
+ * the LOCAL AGENT's `/mcp` (`cfg.url`) via a lazily-built RemoteMcpAdapter — same "graceful hop"
216
+ * pattern walnut uses for recording. When the hop is not configured, unported ops surface a clear
217
+ * error instead of failing silently.
218
+ *
219
+ * This class deliberately does not `implements ProviderAdapter` (walnut trick): the factory casts
220
+ * it, and tools that call ported methods get direct REST; tools that call unported methods get
221
+ * the hop (or a clear error). Each porting step replaces one more delegation with a direct call.
222
+ */
223
+ export class SimplifyQaAdapter {
224
+ constructor(cfg, transport) {
225
+ this.cfg = cfg;
226
+ this.agentHop = null;
227
+ // Formula list rarely changes during a session; cache the fetch response so the model can call
228
+ // tc_list_formulas without paying a round trip each time. Instance-scoped: switchProject rebuilds
229
+ // the adapter (see connection.ts), which drops this cache with it — no manual invalidation needed.
230
+ // We store the in-flight PROMISE, not the resolved array: several concurrent evaluateFormula calls
231
+ // (from the Promise.all in saveGeneratedIterations) all await the same fetch instead of each firing
232
+ // their own /tm/syncFormulas/fetch before the cache is populated.
233
+ this.formulaCache = null;
234
+ // ---------------------------------------------------------------------- Automation resolvers
235
+ /** POST /tm/action/search → per (technology, name) → row (id + objectRequired + objectTemplate).
236
+ * Cached per adapter instance: the catalog is customer-wide and rarely changes during a session.
237
+ * Keyed by `${technologyType}::${lowerCaseName}` so the same action name across two techs
238
+ * (e.g. "click" for WEB + ANDROID) resolves to the right numeric id. */
239
+ this.actionCatalogCache = null;
240
+ // ---------------------------------------------------------------------- Objects (read + delete)
241
+ // list/create/delete now go direct REST (same /tm/object base getObject already uses), mirroring
242
+ // the testcase/suite search + soft-delete convention.
243
+ /** name (UPPERCASE) → objecttemplateId. Rarely changes; cached per adapter instance like
244
+ * formulaCache. Populated from `POST /tm/objectTemplate/search` (confirmed endpoint,
245
+ * api-endpoints.ts `object.search`'s sibling `objectTemplate.search`). */
246
+ this.objectTemplateCache = null;
247
+ if (!cfg.baseUrl) {
248
+ throw new Error('SimplifyQaAdapter requires a baseUrl — set QA_BASE_URL or use a JWT with a `url` claim.');
249
+ }
250
+ this.http = makeClient({
251
+ baseUrl: cfg.baseUrl,
252
+ headers: () => this.headers(),
253
+ ...transport,
254
+ });
255
+ }
256
+ // Auth headers mirror the Java RequestContext: bearer + x-project-id. `x-project-id` is only
257
+ // spread in when a project is selected — a session can be connected before one is chosen.
258
+ headers() {
259
+ return {
260
+ Authorization: `Bearer ${this.cfg.token}`,
261
+ ...(this.cfg.projectId ? { 'x-project-id': this.cfg.projectId } : {}),
262
+ };
263
+ }
264
+ requireProjectId() {
265
+ if (!this.cfg.projectId)
266
+ throw new Error(NEEDS_PROJECT);
267
+ const n = Number.parseInt(String(this.cfg.projectId).trim(), 10);
268
+ if (!Number.isFinite(n))
269
+ throw new Error(`Invalid projectId ${this.cfg.projectId} — expected a numeric id.`);
270
+ return n;
271
+ }
272
+ // Standard SQA `searchFields` payload builder — the SAME shape every /search endpoint expects.
273
+ // Every list op below uses this instead of hand-writing the same 6-line boilerplate.
274
+ searchBody(fields, opts = {}) {
275
+ const body = {
276
+ searchFields: fields.map((f) => ({
277
+ column: f.column,
278
+ value: f.value,
279
+ ...(f.regEx ? { regEx: true } : {}),
280
+ })),
281
+ startIndex: opts.startIndex ?? 0,
282
+ limit: opts.limit ?? 100,
283
+ };
284
+ if (opts.selectFields)
285
+ body['selectFields'] = opts.selectFields;
286
+ return body;
287
+ }
288
+ // ---------------------------------------------------------------------- Info + projects
289
+ /** Validates baseUrl + token + (optional) project by running the same list_projects search the
290
+ * Java `infoResult` uses to resolve the project name — no separate /info endpoint on SQA.
291
+ * `counts` is a best-effort row count per entity type (test cases/suites/stories/objects), read
292
+ * from each `/search` endpoint's own `totalCount` (see searchTotalCount) — mirrors what Walnut's
293
+ * info() already returns via its own `counts`. */
294
+ async info() {
295
+ const projects = await this.listProjects();
296
+ const projectId = this.cfg.projectId ?? '';
297
+ const found = projects.find((p) => p.id === projectId);
298
+ const [testCases, suites, stories, objects] = await Promise.all([
299
+ this.searchTotalCount('/tm/testcase/search'),
300
+ this.searchTotalCount('/tm/suites/search'),
301
+ this.searchTotalCount('/pm/userstory/search'),
302
+ this.searchTotalCount('/tm/object/search'),
303
+ ]);
304
+ return {
305
+ provider: 'simplifyqa',
306
+ projectId,
307
+ projectName: found?.name ?? '',
308
+ counts: { testCases, suites, stories, objects },
309
+ };
310
+ }
311
+ /** Projects the caller can access. Three code paths in priority order:
312
+ *
313
+ * 1. Config-supplied `QA_MCP_USER_ID` (needed for API-key auth — SQA keys are encrypted JWTs
314
+ * with no client-decryptable userId, and no whoami endpoint exists yet) → user-scoped
315
+ * two-step via `/pm/projectuserrole`.
316
+ * 2. Session JWT with `userId` claim → same two-step.
317
+ * 3. Neither → direct `/pm/project/search`; the backend scopes by the token owner where it
318
+ * can (may return tenant-wide for API keys). `totalCount` uses `rows.length` (backend's
319
+ * top-level `totalCount` is unreliable — counts pre-filter, includes deleted).
320
+ *
321
+ * The two-step avoids both problems: correct user scoping AND an accurate count. */
322
+ async listProjects() {
323
+ const userId = this.cfg.userId?.trim() || this.jwtUserId();
324
+ return userId ? this.listProjectsForUser(userId) : this.listProjectsDirect();
325
+ }
326
+ async listProjectsForUser(userId) {
327
+ const rolesRaw = await this.http.getJson(`/pm/projectuserrole?userId=${encodeURIComponent(userId)}`);
328
+ const roleRows = pickArray(rolesRaw, 'data', 'content');
329
+ const projectIds = Array.from(new Set(roleRows.map((r) => str(r, 'projectId')).filter((v) => !!v))).map((s) => Number.parseInt(s, 10)).filter((n) => Number.isFinite(n));
330
+ if (projectIds.length === 0)
331
+ return withTotal([], { totalCount: 0 });
332
+ const body = this.searchBody([
333
+ { column: 'id', value: projectIds },
334
+ { column: 'deleted', value: false },
335
+ ], { selectFields: ['id', 'code', 'name'], limit: projectIds.length });
336
+ const raw = await this.http.postJson('/pm/project/search', body);
337
+ const rows = pickArray(raw, 'data', 'content', 'projects');
338
+ const projects = rows.map((n) => {
339
+ const code = str(n, 'code');
340
+ return {
341
+ id: str(n, 'id', '_id', 'projectId') ?? '',
342
+ ...(code ? { code } : {}),
343
+ name: str(n, 'name', 'projectName') ?? '',
344
+ };
345
+ });
346
+ return withTotal(projects, { totalCount: projects.length });
347
+ }
348
+ /** Best-effort row count for an entity `/search` endpoint — posts a minimal (limit 1) project-scoped
349
+ * search and reads the `totalCount` the backend returns alongside `data` (the same envelope shape
350
+ * every /search endpoint in this codebase uses, confirmed via the real sqa-ui
351
+ * `{ totalCount: number, data: [...] }` response typing). Never throws — counts are a nice-to-have
352
+ * for project_info, not worth failing the whole call over; any failure (or a missing totalCount)
353
+ * falls back to 0 / the returned row count. */
354
+ async searchTotalCount(path) {
355
+ try {
356
+ const projectId = this.requireProjectId();
357
+ const body = this.searchBody([{ column: 'deleted', value: false }, { column: 'projectId', value: projectId }], { limit: 1 });
358
+ const raw = await this.http.postJson(path, body);
359
+ if (typeof raw?.totalCount === 'number')
360
+ return raw.totalCount;
361
+ return pickArray(raw, 'data', 'content').length;
362
+ }
363
+ catch {
364
+ return 0;
365
+ }
366
+ }
367
+ async listProjectsDirect() {
368
+ const body = this.searchBody([{ column: 'deleted', value: false }], { selectFields: ['id', 'code', 'name'] });
369
+ const raw = await this.http.postJson('/pm/project/search', body);
370
+ const rows = pickArray(raw, 'data', 'content', 'projects');
371
+ const projects = rows.map((n) => {
372
+ const code = str(n, 'code');
373
+ return {
374
+ id: str(n, 'id', '_id', 'projectId') ?? '',
375
+ ...(code ? { code } : {}),
376
+ name: str(n, 'name', 'projectName') ?? '',
377
+ };
378
+ });
379
+ // ponytail: backend `totalCount` on /pm/project/search is unreliable (counts pre-filter). Trust
380
+ // rows.length instead — accurate for the page we actually returned.
381
+ return withTotal(projects, { totalCount: projects.length });
382
+ }
383
+ /** Decode the `userId` claim from the SQA JWT payload WITHOUT verifying the signature. Backend
384
+ * re-verifies on every call; this only needs the id for `/pm/projectuserrole`. Returns undefined
385
+ * on any parse failure or when the token isn't a JWT. */
386
+ jwtUserId() {
387
+ const token = this.cfg.token;
388
+ if (!token)
389
+ return undefined;
390
+ try {
391
+ const t = token.startsWith('Bearer ') ? token.slice(7).trim() : token.trim();
392
+ const seg = t.split('.')[1];
393
+ if (!seg)
394
+ return undefined;
395
+ let b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
396
+ b64 += '='.repeat((4 - (b64.length % 4)) % 4);
397
+ const claims = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
398
+ const v = claims['userId'] ?? claims['id'] ?? claims['sub'];
399
+ if (typeof v === 'string' && v.length)
400
+ return v;
401
+ if (typeof v === 'number')
402
+ return String(v);
403
+ return undefined;
404
+ }
405
+ catch {
406
+ return undefined;
407
+ }
408
+ }
409
+ // ---------------------------------------------------------------------- Test cases (read)
410
+ async listTestCases(q) {
411
+ const projectId = this.requireProjectId();
412
+ const fields = [
413
+ { column: 'deleted', value: false },
414
+ { column: 'projectId', value: projectId },
415
+ ];
416
+ if (q.search)
417
+ fields.push({ column: 'name', value: q.search, regEx: true });
418
+ if (q.status)
419
+ fields.push({ column: 'status', value: q.status });
420
+ const createdBy = this.resolveUserFilter(q.createdBy);
421
+ if (createdBy != null)
422
+ fields.push({ column: 'createdBy', value: createdBy });
423
+ const updatedBy = this.resolveUserFilter(q.updatedBy);
424
+ if (updatedBy != null)
425
+ fields.push({ column: 'updatedBy', value: updatedBy });
426
+ if (q.storyId)
427
+ fields.push({ column: 'userstoryId', value: Number.parseInt(numericId(q.storyId), 10) });
428
+ if (q.moduleId)
429
+ fields.push({ column: 'moduleId', value: Number.parseInt(numericId(q.moduleId), 10) });
430
+ if (typeof q.obsolete === 'boolean')
431
+ fields.push({ column: 'obsolete', value: q.obsolete });
432
+ if (q.filters?.length)
433
+ fields.push(...q.filters);
434
+ const body = this.searchBody(fields, {
435
+ startIndex: q.offset ?? 0,
436
+ limit: q.limit ?? 100,
437
+ selectFields: ['id', 'code', 'name', 'priority', 'status', 'stepCount', 'tcType', 'executionType', 'obsolete'],
438
+ });
439
+ const raw = await this.http.postJson('/tm/testcase/search', body);
440
+ const rows = pickArray(raw, 'data', 'content', 'testCases');
441
+ return withTotal(rows.map(toTcSummary), raw);
442
+ }
443
+ /** "me" → cfg.userId (numeric); numeric string → number; anything else → undefined. Callers
444
+ * should have resolved a name to a numeric id via `list_users` first. */
445
+ resolveUserFilter(v) {
446
+ if (!v)
447
+ return undefined;
448
+ const raw = v === 'me' ? (this.cfg.userId?.trim() ?? '') : v.trim();
449
+ if (!raw)
450
+ return undefined;
451
+ const n = Number.parseInt(raw, 10);
452
+ return Number.isFinite(n) ? n : undefined;
453
+ }
454
+ /** Neutral test case, steps included. Accepts numeric id or business code (`TC_123`).
455
+ * `versionId` overrides which version's steps to load — omit to accept the TC's currently-
456
+ * selected version. When the resolved version differs from the doc's current selection, we
457
+ * also patch `currentVersionId` on the returned tc so callers can render "showing version X". */
458
+ async getTestCase(id, versionId) {
459
+ const raw = await this.fetchRawTestcase(id);
460
+ if (!raw || Object.keys(raw).length === 0) {
461
+ throw new Error(`test case ${id} not found`);
462
+ }
463
+ const tc = toTestCase(raw);
464
+ // If the caller pinned a version, validate it exists on the TC — otherwise the /steps call
465
+ // just returns an empty array for an unknown id and the user sees "no steps" for a typo.
466
+ if (versionId) {
467
+ const known = (tc.versions ?? []).some((v) => v.id === versionId);
468
+ if (!known)
469
+ throw new Error(`version ${versionId} not found on test case ${id}`);
470
+ }
471
+ let rows = [];
472
+ try {
473
+ rows = await this.fetchStepsCollection(id, raw, versionId);
474
+ }
475
+ catch { /* fall through — inline steps stay */ }
476
+ const withSteps = rows.length > 0 ? applyCollectionSteps(tc, rows) : tc;
477
+ // Overlay currentVersionId to the resolved one and flip isCurrent flags on versions[]. Apply
478
+ // even when rows are empty — a caller who pinned versionId still expects the header + versions[]
479
+ // to reflect that choice (a legitimately-empty version shouldn't fall back to the TC's default).
480
+ const resolvedVer = versionId ?? tc.currentVersionId;
481
+ const patched = { ...withSteps, ...(resolvedVer ? { currentVersionId: resolvedVer } : {}) };
482
+ if (resolvedVer && patched.versions?.length) {
483
+ patched.versions = patched.versions.map((v) => ({ ...v, isCurrent: v.id === resolvedVer }));
484
+ }
485
+ if (rows.length > 0) {
486
+ await Promise.all([
487
+ this.hydrateFunctionNames(patched),
488
+ this.hydrateObjectNames(patched),
489
+ ]);
490
+ }
491
+ return patched;
492
+ }
493
+ async fetchRawTestcase(id) {
494
+ const raw = await this.http.getJson(`/tm/testcase/getById/${numericId(id)}`);
495
+ let doc = raw?.data ?? raw;
496
+ if (Array.isArray(doc))
497
+ doc = doc.length > 0 ? doc[0] : {};
498
+ return doc && typeof doc === 'object' ? doc : {};
499
+ }
500
+ async fetchStepsCollection(tcId, rawTc, versionOverride) {
501
+ const projectId = this.requireProjectId();
502
+ const tcNum = numericId(tcId);
503
+ // Resolution order: explicit override → TC's currently-selected version (any of 4 field
504
+ // names, depending on the read model — Java tries all four) → first entry in versions[].
505
+ let versionId = versionOverride;
506
+ if (!versionId) {
507
+ versionId =
508
+ str(rawTc, 'versionId', 'selectedVersionId') ??
509
+ (rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
510
+ ? str(rawTc.versionsDetails, 'id', '_id', 'versionId')
511
+ : undefined);
512
+ if (!versionId && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
513
+ versionId = str(rawTc.versions[0], 'id', '_id');
514
+ }
515
+ }
516
+ if (!versionId)
517
+ return [];
518
+ const path = '/tm/testcase/steps' +
519
+ `?projectId=${projectId}` +
520
+ `&tcfnId=${tcNum}` +
521
+ `&versionId=${encodeURIComponent(versionId)}` +
522
+ '&type=TC';
523
+ const body = await this.http.getJson(path);
524
+ const rows = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
525
+ return rows;
526
+ }
527
+ /** For every FUNCTION step, resolve the referenced function's code + name via /tm/function search
528
+ * (batched by distinct functionId). Mutates `tc.steps[i].functionCall` in place with
529
+ * `testCaseCode`/`testCaseName`. Non-fatal on failure — the raw id still renders. Mirrors the
530
+ * Excel export builder in tcfn.service.js:2696-2703. */
531
+ async hydrateFunctionNames(tc) {
532
+ const ids = new Set();
533
+ for (const s of tc.steps) {
534
+ if (s.stepType === 'function' && s.functionCall?.testCaseId) {
535
+ ids.add(s.functionCall.testCaseId);
536
+ }
537
+ }
538
+ if (!ids.size)
539
+ return;
540
+ try {
541
+ const projectId = this.requireProjectId();
542
+ const body = this.searchBody([
543
+ { column: 'deleted', value: false },
544
+ { column: 'projectId', value: projectId },
545
+ { column: 'id', value: [...ids].map((x) => Number.parseInt(x, 10)).filter(Number.isFinite), regEx: false },
546
+ ]);
547
+ const raw = await this.http.postJson('/tm/function/search', body);
548
+ const rows = pickArray(raw, 'data', 'content', 'functions');
549
+ const byId = new Map();
550
+ for (const r of rows) {
551
+ const rid = str(r, 'id', '_id', 'functionId');
552
+ if (!rid)
553
+ continue;
554
+ byId.set(rid, { code: str(r, 'code'), name: str(r, 'name', 'title') ?? '' });
555
+ }
556
+ for (const s of tc.steps) {
557
+ if (s.stepType !== 'function' || !s.functionCall)
558
+ continue;
559
+ const hit = byId.get(s.functionCall.testCaseId);
560
+ if (!hit)
561
+ continue;
562
+ if (hit.code)
563
+ s.functionCall.testCaseCode = hit.code;
564
+ if (hit.name)
565
+ s.functionCall.testCaseName = hit.name;
566
+ }
567
+ }
568
+ catch {
569
+ // Non-fatal: fall back to bare id in the render.
570
+ }
571
+ }
572
+ /** 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. */
576
+ async hydrateObjectNames(tc) {
577
+ const ids = new Set();
578
+ for (const s of tc.steps) {
579
+ if (s.objectRef?.id && !s.objectRef.name)
580
+ ids.add(s.objectRef.id);
581
+ }
582
+ if (!ids.size)
583
+ return;
584
+ try {
585
+ const projectId = this.requireProjectId();
586
+ const body = this.searchBody([
587
+ { column: 'deleted', value: false },
588
+ { column: 'projectId', value: projectId },
589
+ { column: 'id', value: [...ids].map((x) => Number.parseInt(x, 10)).filter(Number.isFinite), regEx: false },
590
+ ]);
591
+ const raw = await this.http.postJson('/tm/object/search', body);
592
+ const rows = pickArray(raw, 'data', 'content', 'objects');
593
+ const byId = new Map();
594
+ for (const r of rows) {
595
+ const rid = str(r, 'id', '_id');
596
+ if (!rid)
597
+ continue;
598
+ const name = str(r, 'disname', 'description', 'placeholder');
599
+ if (name)
600
+ byId.set(rid, name);
601
+ }
602
+ for (const s of tc.steps) {
603
+ if (!s.objectRef?.id || s.objectRef.name)
604
+ continue;
605
+ const name = byId.get(s.objectRef.id);
606
+ if (name)
607
+ s.objectRef.name = name;
608
+ }
609
+ }
610
+ catch {
611
+ // Non-fatal: fall back to bare id in the render.
612
+ }
613
+ }
614
+ // ---------------------------------------------------------------------- Suites (read)
615
+ async listSuites() {
616
+ const projectId = this.requireProjectId();
617
+ const body = this.searchBody([
618
+ { column: 'deleted', value: false },
619
+ { column: 'projectId', value: projectId },
620
+ ]);
621
+ const raw = await this.http.postJson('/tm/suites/search', body);
622
+ const rows = pickArray(raw, 'data', 'content', 'suites');
623
+ return withTotal(rows.map(toSuiteSummary), raw);
624
+ }
625
+ async getSuite(id) {
626
+ const raw = await this.http.getJson(`/tm/suites/${numericId(id)}`);
627
+ let doc = raw?.data ?? raw;
628
+ if (Array.isArray(doc))
629
+ doc = doc.length > 0 ? doc[0] : {};
630
+ return toSuite(doc && typeof doc === 'object' ? doc : {});
631
+ }
632
+ // ---------------------------------------------------------------------- Stories (read)
633
+ async listStories(q) {
634
+ const projectId = this.requireProjectId();
635
+ const fields = [
636
+ { column: 'deleted', value: false },
637
+ { column: 'projectId', value: projectId },
638
+ ];
639
+ if (q.search)
640
+ fields.push({ column: 'name', value: q.search, regEx: true });
641
+ // SQA's userstory FK for sprint is `iterationId` — spec uses the neutral name `sprintId`.
642
+ if (q.sprintId)
643
+ fields.push({ column: 'iterationId', value: Number.parseInt(q.sprintId, 10) });
644
+ if (q.releaseId)
645
+ fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
646
+ const createdBy = this.resolveUserFilter(q.createdBy);
647
+ if (createdBy != null)
648
+ fields.push({ column: 'createdBy', value: createdBy });
649
+ const updatedBy = this.resolveUserFilter(q.updatedBy);
650
+ if (updatedBy != null)
651
+ fields.push({ column: 'updatedBy', value: updatedBy });
652
+ const ownerId = this.resolveUserFilter(q.ownerId);
653
+ if (ownerId != null)
654
+ fields.push({ column: 'ownerId', value: ownerId });
655
+ if (q.filters?.length)
656
+ fields.push(...q.filters);
657
+ const body = this.searchBody(fields, { limit: q.limit ?? 50 });
658
+ const raw = await this.http.postJson('/pm/userstory/search', body);
659
+ const rows = pickArray(raw, 'data', 'content', 'userStories', 'stories');
660
+ return withTotal(rows.map(toStorySummary), raw);
661
+ }
662
+ async getStory(id) {
663
+ const resolvedId = await this.resolveStoryNumericId(id);
664
+ const raw = await this.http.getJson(`/pm/userstory/${resolvedId}`);
665
+ const doc = Array.isArray(raw)
666
+ ? (raw.length > 0 ? raw[0] : {})
667
+ : (raw?.data && typeof raw.data === 'object' ? raw.data : raw);
668
+ return toStory(doc && typeof doc === 'object' ? doc : {});
669
+ }
670
+ /** A US_XXX business key → the numeric userstoryId via `/pm/userstory/search`. Numeric passes
671
+ * through. SQA's GET-by-id parseInts the path, so a raw key would 404. */
672
+ async resolveStoryNumericId(ref) {
673
+ if (!ref)
674
+ return ref;
675
+ if (/^\d+$/.test(ref))
676
+ return ref;
677
+ const body = this.searchBody([{ column: 'code', value: ref }], { startIndex: 0, limit: 1 });
678
+ const raw = await this.http.postJson('/pm/userstory/search', body);
679
+ const rows = pickArray(raw, 'data', 'content', 'userStories', 'stories');
680
+ if (rows.length === 0)
681
+ throw new Error(`Story not found for business key: ${ref}`);
682
+ return str(rows[0], 'id', '_id', 'userstoryId') ?? ref;
683
+ }
684
+ // ---------------------------------------------------------------------- Defects (read)
685
+ /** `POST /pm/defect/search` — same searchFields protocol as userstory/testcase search. */
686
+ async listDefects(q) {
687
+ const projectId = this.requireProjectId();
688
+ const fields = [
689
+ { column: 'deleted', value: false },
690
+ { column: 'projectId', value: projectId },
691
+ ];
692
+ if (q.search)
693
+ fields.push({ column: 'name', value: q.search, regEx: true });
694
+ if (q.tcId)
695
+ fields.push({ column: 'selectedTestCase.testcaseId', value: [Number.parseInt(numericId(q.tcId), 10)] });
696
+ if (q.storyId)
697
+ fields.push({ column: 'userstoryId', value: Number.parseInt(numericId(q.storyId), 10) });
698
+ if (q.state)
699
+ fields.push({ column: 'state', value: q.state });
700
+ // ownerId is an array field — match with Mongo $in, not bare equality.
701
+ const assignedTo = this.resolveUserFilter(q.assignedTo);
702
+ if (assignedTo != null)
703
+ fields.push({ column: 'ownerId', value: { $in: [assignedTo] } });
704
+ const createdBy = this.resolveUserFilter(q.createdBy);
705
+ if (createdBy != null)
706
+ fields.push({ column: 'createdBy', value: createdBy });
707
+ if (q.filters?.length)
708
+ fields.push(...q.filters);
709
+ const body = this.searchBody(fields, { limit: q.limit ?? 50, startIndex: q.offset ?? 0 });
710
+ const raw = await this.http.postJson('/pm/defect/search', body);
711
+ const rows = pickArray(raw, 'data', 'content', 'defects');
712
+ return withTotal(rows.map(toDefectSummary), raw);
713
+ }
714
+ // ---------------------------------------------------------------------- Tasks (read)
715
+ /** `POST /pm/task/search` — same searchFields protocol as defect/userstory search. */
716
+ async listTasks(q) {
717
+ const projectId = this.requireProjectId();
718
+ const fields = [
719
+ { column: 'deleted', value: false },
720
+ { column: 'projectId', value: projectId },
721
+ ];
722
+ if (q.search)
723
+ fields.push({ column: 'name', value: q.search, regEx: true });
724
+ if (q.storyId)
725
+ fields.push({ column: 'userstoryId', value: Number.parseInt(numericId(q.storyId), 10) });
726
+ if (q.state)
727
+ fields.push({ column: 'state', value: q.state });
728
+ const assignedTo = this.resolveUserFilter(q.assignedTo);
729
+ if (assignedTo != null)
730
+ fields.push({ column: 'ownerId', value: { $in: [assignedTo] } });
731
+ const createdBy = this.resolveUserFilter(q.createdBy);
732
+ if (createdBy != null)
733
+ fields.push({ column: 'createdBy', value: createdBy });
734
+ if (q.filters?.length)
735
+ fields.push(...q.filters);
736
+ const body = this.searchBody(fields, { limit: q.limit ?? 50, startIndex: q.offset ?? 0 });
737
+ const raw = await this.http.postJson('/pm/task/search', body);
738
+ const rows = pickArray(raw, 'data', 'content', 'tasks');
739
+ return withTotal(rows.map(toTaskSummary), raw);
740
+ }
741
+ // ---------------------------------------------------------------------- Stories (write)
742
+ async createStory(input) {
743
+ const body = {
744
+ ...input,
745
+ // SQA userstory expects a singular numeric projectId; fill from the connected project when omitted.
746
+ projectId: input.projectId ?? this.requireProjectId(),
747
+ };
748
+ const raw = await this.http.postJson('/pm/userstory', body);
749
+ return toSavedRef(raw, true);
750
+ }
751
+ async updateStory(id, patch) {
752
+ const resolvedId = await this.resolveStoryNumericId(id);
753
+ const raw = await this.http.putJson(`/pm/userstory/${resolvedId}`, patch);
754
+ return toSavedRef(raw ?? { id: resolvedId }, false);
755
+ }
756
+ // ---------------------------------------------------------------------- Epics
757
+ async listEpics(q) {
758
+ const projectId = this.requireProjectId();
759
+ const fields = [
760
+ { column: 'deleted', value: false },
761
+ // Epic is multi-project in SQA — filter for members of the current project via $in.
762
+ { column: 'projectIds', value: projectId },
763
+ ];
764
+ if (q.search)
765
+ fields.push({ column: 'name', value: q.search, regEx: true });
766
+ if (q.state)
767
+ fields.push({ column: 'state', value: q.state });
768
+ const createdBy = this.resolveUserFilter(q.createdBy);
769
+ if (createdBy != null)
770
+ fields.push({ column: 'createdBy', value: createdBy });
771
+ const updatedBy = this.resolveUserFilter(q.updatedBy);
772
+ if (updatedBy != null)
773
+ fields.push({ column: 'updatedBy', value: updatedBy });
774
+ const ownerId = this.resolveUserFilter(q.ownerId);
775
+ if (ownerId != null)
776
+ fields.push({ column: 'ownerId', value: ownerId });
777
+ const body = this.searchBody(fields, {
778
+ startIndex: q.offset ?? 0,
779
+ limit: q.limit ?? 50,
780
+ });
781
+ const raw = await this.http.postJson('/pm/epic/search', body);
782
+ const rows = pickArray(raw, 'data', 'content', 'epics');
783
+ return withTotal(rows.map(toEpicSummary), raw);
784
+ }
785
+ async getEpic(id) {
786
+ const resolvedId = await this.resolveEpicNumericId(id);
787
+ const raw = await this.http.getJson(`/pm/epic/${resolvedId}`);
788
+ const doc = Array.isArray(raw)
789
+ ? (raw.length > 0 ? raw[0] : {})
790
+ : (raw?.data && typeof raw.data === 'object' ? raw.data : raw);
791
+ return toEpic(doc && typeof doc === 'object' ? doc : {});
792
+ }
793
+ async createEpic(input) {
794
+ const body = {
795
+ ...input,
796
+ projectIds: input.projectIds ?? [this.requireProjectId()],
797
+ };
798
+ const raw = await this.http.postJson('/pm/epic', body);
799
+ return toSavedRef(raw, true);
800
+ }
801
+ async updateEpic(id, patch) {
802
+ const resolvedId = await this.resolveEpicNumericId(id);
803
+ const raw = await this.http.putJson(`/pm/epic/${resolvedId}`, patch);
804
+ return toSavedRef(raw ?? { id: resolvedId }, false);
805
+ }
806
+ /** EP_XXX business key → numeric epic id via `/pm/epic/search`. Numeric passes through. */
807
+ async resolveEpicNumericId(ref) {
808
+ if (!ref)
809
+ return ref;
810
+ if (/^\d+$/.test(ref))
811
+ return ref;
812
+ const body = this.searchBody([{ column: 'code', value: ref }], { startIndex: 0, limit: 1 });
813
+ const raw = await this.http.postJson('/pm/epic/search', body);
814
+ const rows = pickArray(raw, 'data', 'content', 'epics');
815
+ if (rows.length === 0)
816
+ throw new Error(`Epic not found for business key: ${ref}`);
817
+ return str(rows[0], 'id', '_id', 'epicId') ?? ref;
818
+ }
819
+ // ---------------------------------------------------------------------- Features
820
+ async listFeatures(q) {
821
+ const projectId = this.requireProjectId();
822
+ const fields = [
823
+ { column: 'deleted', value: false },
824
+ { column: 'projectIds', value: projectId },
825
+ ];
826
+ if (q.search)
827
+ fields.push({ column: 'name', value: q.search, regEx: true });
828
+ if (q.state)
829
+ fields.push({ column: 'state', value: q.state });
830
+ if (q.epicId)
831
+ fields.push({ column: 'epicId', value: Number.parseInt(q.epicId, 10) });
832
+ const createdBy = this.resolveUserFilter(q.createdBy);
833
+ if (createdBy != null)
834
+ fields.push({ column: 'createdBy', value: createdBy });
835
+ const updatedBy = this.resolveUserFilter(q.updatedBy);
836
+ if (updatedBy != null)
837
+ fields.push({ column: 'updatedBy', value: updatedBy });
838
+ const ownerId = this.resolveUserFilter(q.ownerId);
839
+ if (ownerId != null)
840
+ fields.push({ column: 'ownerId', value: ownerId });
841
+ const body = this.searchBody(fields, {
842
+ startIndex: q.offset ?? 0,
843
+ limit: q.limit ?? 50,
844
+ });
845
+ const raw = await this.http.postJson('/pm/feature/search', body);
846
+ const rows = pickArray(raw, 'data', 'content', 'features');
847
+ return withTotal(rows.map(toFeatureSummary), raw);
848
+ }
849
+ async getFeature(id) {
850
+ const resolvedId = await this.resolveFeatureNumericId(id);
851
+ const raw = await this.http.getJson(`/pm/feature/${resolvedId}`);
852
+ const doc = Array.isArray(raw)
853
+ ? (raw.length > 0 ? raw[0] : {})
854
+ : (raw?.data && typeof raw.data === 'object' ? raw.data : raw);
855
+ return toFeature(doc && typeof doc === 'object' ? doc : {});
856
+ }
857
+ async createFeature(input) {
858
+ const body = {
859
+ ...input,
860
+ projectIds: input.projectIds ?? [this.requireProjectId()],
861
+ };
862
+ const raw = await this.http.postJson('/pm/feature', body);
863
+ return toSavedRef(raw, true);
864
+ }
865
+ async updateFeature(id, patch) {
866
+ const resolvedId = await this.resolveFeatureNumericId(id);
867
+ const raw = await this.http.putJson(`/pm/feature/${resolvedId}`, patch);
868
+ return toSavedRef(raw ?? { id: resolvedId }, false);
869
+ }
870
+ /** FT_XXX business key → numeric feature id via `/pm/feature/search`. Numeric passes through. */
871
+ async resolveFeatureNumericId(ref) {
872
+ if (!ref)
873
+ return ref;
874
+ if (/^\d+$/.test(ref))
875
+ return ref;
876
+ const body = this.searchBody([{ column: 'code', value: ref }], { startIndex: 0, limit: 1 });
877
+ const raw = await this.http.postJson('/pm/feature/search', body);
878
+ const rows = pickArray(raw, 'data', 'content', 'features');
879
+ if (rows.length === 0)
880
+ throw new Error(`Feature not found for business key: ${ref}`);
881
+ return str(rows[0], 'id', '_id', 'featureId') ?? ref;
882
+ }
883
+ // ---------------------------------------------------------------------- Modules, releases, sprints
884
+ async listModules() {
885
+ const projectId = this.requireProjectId();
886
+ const body = this.searchBody([
887
+ { column: 'projectId', value: projectId },
888
+ { column: 'deleted', value: false },
889
+ ], { selectFields: ['id', 'code', 'name', 'parentId'] });
890
+ const raw = await this.http.postJson('/pm/module/search', body);
891
+ const rows = pickArray(raw, 'data', 'content', 'modules');
892
+ return withTotal(rows.map(toModuleSummary), raw);
893
+ }
894
+ /** GET /pm/module/:id — server returns an array; empty array → NotFound. */
895
+ async getModule(id) {
896
+ const raw = await this.http.getJson(`/pm/module/${numericId(id)}`);
897
+ const rows = Array.isArray(raw) ? raw : pickArray(raw, 'data', 'content');
898
+ const doc = rows[0];
899
+ if (!doc)
900
+ throw new Error(`Module ${id} not found.`);
901
+ const summary = toModuleSummary(doc);
902
+ const code = str(doc, 'code');
903
+ const description = str(doc, 'description');
904
+ const projectId = doc?.projectId != null ? String(doc.projectId) : undefined;
905
+ const childIds = Array.isArray(doc?.childIds)
906
+ ? doc.childIds.filter((n) => Number.isFinite(n))
907
+ : undefined;
908
+ const isRoot = typeof doc?.isRoot === 'boolean' ? doc.isRoot : undefined;
909
+ return {
910
+ ...summary,
911
+ ...(code ? { code } : {}),
912
+ ...(description ? { description } : {}),
913
+ ...(childIds ? { childIds } : {}),
914
+ ...(projectId ? { projectId } : {}),
915
+ ...(isRoot !== undefined ? { isRoot } : {}),
916
+ };
917
+ }
918
+ /** POST /pm/module — server validates: name+isRoot+projectId required; parentId ignored when
919
+ * isRoot:true. Duplicate name per project is rejected server-side. */
920
+ async createModule(input) {
921
+ const payload = {
922
+ name: input.name,
923
+ isRoot: input.isRoot,
924
+ projectId: Number.parseInt(input.projectId, 10),
925
+ };
926
+ if (input.description)
927
+ payload['description'] = input.description;
928
+ if (!input.isRoot && input.parentId)
929
+ payload['parentId'] = Number.parseInt(input.parentId, 10);
930
+ const raw = await this.http.postJson('/pm/module', payload);
931
+ return toSavedRef(raw, true);
932
+ }
933
+ /** PATCH /pm/module — two-phase per module.controller.js:94. `confirm:false` returns the per-id
934
+ * dependency footprint (test cases, functions, params, objects, defects, user stories). `confirm:true`
935
+ * triggers the actual delete: server drops only ids with EMPTY dependencies; ids with references are
936
+ * silently skipped — we still return that list so the caller can act on it.
937
+ * ponytail: one method, `confirm` flag; mirrors the backend's two-call contract. */
938
+ async deleteModule(input) {
939
+ const numIds = input.ids.map((id) => Number.parseInt(numericId(id), 10));
940
+ const projectId = Number.parseInt(input.projectId, 10);
941
+ const isDelete = input.confirm === true;
942
+ const raw = await this.http.patchJson('/pm/module', { ids: numIds, projectId, isDelete });
943
+ const arr = Array.isArray(raw) ? raw : pickArray(raw, 'data', 'content');
944
+ const dependencies = arr
945
+ .filter((r) => r && typeof r === 'object')
946
+ .map((r) => ({
947
+ id: Number(r.id),
948
+ tcArr: Array.isArray(r.tcArr) ? r.tcArr : [],
949
+ fnArr: Array.isArray(r.fnArr) ? r.fnArr : [],
950
+ parameterArr: Array.isArray(r.parameterArr) ? r.parameterArr : [],
951
+ objArr: Array.isArray(r.objArr) ? r.objArr : [],
952
+ defectArr: Array.isArray(r.defectArr) ? r.defectArr : [],
953
+ usArr: Array.isArray(r.usArr) ? r.usArr : [],
954
+ }));
955
+ return { dryRun: !isDelete, dependencies };
956
+ }
957
+ /** PUT /tm/tcfnversion/delete — soft-delete ONE version of a TC or FN. Two-phase per
958
+ * tcfnversion.service.js:30 — `isDelete:false` (or `isDelete:true` with non-empty deps) →
959
+ * returns the dependency array unchanged; `isDelete:true` with EMPTY deps → soft-deletes the
960
+ * tcfn_version row + its steps + testdata + testdata iterations.
961
+ *
962
+ * ponytail: one method, `confirm` flag. Response shape decides `deleted`: if every dep row is
963
+ * fully empty (all `*Arr` are `{}` maps), the server actually deleted; otherwise it refused
964
+ * and just handed back the deps. We do not assume — we inspect. */
965
+ async deleteVersion(input) {
966
+ const projectId = this.requireProjectId();
967
+ const parentType = input.kind === 'tc' ? 'TC' : 'FN';
968
+ const isDelete = input.confirm === true;
969
+ const body = {
970
+ id: input.versionId,
971
+ parentId: Number.parseInt(numericId(input.parentId), 10),
972
+ parentType,
973
+ projectId,
974
+ name: input.versionName,
975
+ isDelete,
976
+ };
977
+ const raw = await this.http.putJson('/tm/tcfnversion/delete', body);
978
+ const data = raw?.data ?? raw;
979
+ // Distinguish "dep array" from "delete result": the service returns an ARRAY of dep rows for
980
+ // the dry-run + refused-delete branches, and a single mongo doc (findOneAndUpdate result) for
981
+ // the successful delete branch. Array → parse deps; single doc → deleted.
982
+ if (Array.isArray(data)) {
983
+ const dependencies = data
984
+ .filter((r) => r && typeof r === 'object')
985
+ .map((r) => normalizeVersionDep(r, parentType));
986
+ const hasBlockers = dependencies.some((d) => !versionDepIsEmpty(d));
987
+ return { dryRun: !isDelete, deleted: false, dependencies, hasBlockers };
988
+ }
989
+ // Single doc → successful delete (or upsert-echo when nothing matched, but the service only
990
+ // reaches that branch after emptyIds.push).
991
+ return { dryRun: false, deleted: true, dependencies: [], hasBlockers: false };
992
+ }
993
+ async listReleases(q) {
994
+ const fields = [{ column: 'deleted', value: false }];
995
+ if (q.scope === 'project')
996
+ fields.push({ column: 'projectIds', value: this.requireProjectId() });
997
+ const body = this.searchBody(fields);
998
+ const raw = await this.http.postJson('/pm/release/search', body);
999
+ const rows = pickArray(raw, 'data', 'content', 'releases');
1000
+ return withTotal(rows.map(toReleaseSummary), raw);
1001
+ }
1002
+ async listSprints(q) {
1003
+ const fields = [{ column: 'deleted', value: false }];
1004
+ if (q.releaseId)
1005
+ fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
1006
+ const body = this.searchBody(fields);
1007
+ const raw = await this.http.postJson('/pm/sprint/search', body);
1008
+ const rows = pickArray(raw, 'data', 'content', 'sprints');
1009
+ return withTotal(rows.map(toSprintSummary), raw);
1010
+ }
1011
+ /** POST /pm/users/search — the customer's users (router mounted at `/pm/users`, plural — see
1012
+ * project-management/express-app.js:181). Backend auto-scopes non-admin callers to users in
1013
+ * their assigned projects. Optional `projectId` narrows to that project's members; optional
1014
+ * `search` is a case-insensitive regex over `name`. */
1015
+ async listUsers(q) {
1016
+ const fields = [
1017
+ { column: 'deleted', value: false },
1018
+ ];
1019
+ if (q.search)
1020
+ fields.push({ column: 'name', value: q.search, regEx: true });
1021
+ if (q.projectId)
1022
+ fields.push({ column: 'projectIds', value: Number.parseInt(q.projectId, 10) });
1023
+ const body = this.searchBody(fields, {
1024
+ startIndex: q.offset ?? 0,
1025
+ limit: q.limit ?? 100,
1026
+ selectFields: ['id', 'name', 'email', 'username', 'status'],
1027
+ });
1028
+ const raw = await this.http.postJson('/pm/users/search', body);
1029
+ const rows = pickArray(raw, 'data', 'content', 'users');
1030
+ const users = rows.map((n) => {
1031
+ const name = str(n, 'name');
1032
+ const email = str(n, 'email');
1033
+ const username = str(n, 'username');
1034
+ const status = str(n, 'status');
1035
+ return {
1036
+ id: str(n, 'id', '_id', 'userId') ?? '',
1037
+ ...(name ? { name } : {}),
1038
+ ...(email ? { email } : {}),
1039
+ ...(username ? { username } : {}),
1040
+ ...(status ? { status } : {}),
1041
+ };
1042
+ });
1043
+ return withTotal(users, raw);
1044
+ }
1045
+ // ---------------------------------------------------------------------- Objects (read)
1046
+ async getObject(id) {
1047
+ const raw = await this.http.getJson(`/tm/object/${numericId(id)}`);
1048
+ let doc = raw?.data ?? raw;
1049
+ if (Array.isArray(doc))
1050
+ doc = doc.length > 0 ? doc[0] : {};
1051
+ return toObject(doc && typeof doc === 'object' ? doc : {});
1052
+ }
1053
+ // ---------------------------------------------------------------------- Deep links
1054
+ productUrlFor(entity) {
1055
+ const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
1056
+ return `${base}/${PROJECT_URL_SEGMENT[entity.kind]}/${entity.id}`;
1057
+ }
1058
+ // ---------------------------------------------------------------------- Agent hop (recording + not-yet-ported ops)
1059
+ // Everything below either delegates to the local agent /mcp (recording surface, and writes/testdata/
1060
+ // run dispatch we haven't ported to REST yet) or fails with a clear message when the hop is unset.
1061
+ // As each step of the port lands, the corresponding delegate is replaced with a direct REST impl.
1062
+ hop() {
1063
+ if (!this.cfg.url)
1064
+ return null;
1065
+ if (!this.agentHop)
1066
+ this.agentHop = new RemoteMcpAdapter(this.cfg);
1067
+ return this.agentHop;
1068
+ }
1069
+ requireHop(op) {
1070
+ const h = this.hop();
1071
+ if (!h)
1072
+ throw new Error(`${op}: ${HOP_NEEDED_WRITE}`);
1073
+ return h;
1074
+ }
1075
+ /** Recording tool schemas from the SQA agent — mirrored so the recording passthrough can register
1076
+ * them. No agent configured → `[]`, so no recording tools get registered. */
1077
+ async listAgentTools() {
1078
+ const h = this.hop();
1079
+ return h ? h.listAgentTools() : [];
1080
+ }
1081
+ /** Forward a recording-tool call to the agent /mcp. Same behavior as walnut. */
1082
+ async callRaw(name, args) {
1083
+ const h = this.hop();
1084
+ if (!h) {
1085
+ throw new Error("Recording needs a local SimplifyQA agent — set QA_MCP_URL to the agent's /mcp endpoint.");
1086
+ }
1087
+ return h.callRaw(name, args);
1088
+ }
1089
+ // ---------------------------------------------------------------------- Test cases (write) [Step 4]
1090
+ /** Two-step create: (1) resolve every `${name}` token in the steps to a project-scoped parameter id
1091
+ * (get-or-create) plus, for AUTOMATION, each step's actionId + objectId, (2) POST /tm/testcase
1092
+ * multipart with the assembled `testcaseObj`. */
1093
+ async createTestCase(tc) {
1094
+ const projectId = this.requireProjectId();
1095
+ const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
1096
+ const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
1097
+ const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
1098
+ const stepResolutions = tc.category === 'automated'
1099
+ ? await this.resolveAutomationSteps(projectId, moduleId, tc)
1100
+ : undefined;
1101
+ const payload = buildTestcasePayload({
1102
+ projectId, moduleId,
1103
+ ...(userstoryId != null ? { userstoryId } : {}),
1104
+ tc,
1105
+ paramIdByName,
1106
+ ...(stepResolutions ? { stepResolutions } : {}),
1107
+ });
1108
+ const raw = await this.http.postMultipart('/tm/testcase', 'testcaseObj', payload);
1109
+ return toSavedRef(raw, true);
1110
+ }
1111
+ /** Same shape as create, but with `tcfnObj.id` set. */
1112
+ async updateTestCase(id, tc) {
1113
+ const projectId = this.requireProjectId();
1114
+ const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
1115
+ const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
1116
+ const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
1117
+ const stepResolutions = tc.category === 'automated'
1118
+ ? await this.resolveAutomationSteps(projectId, moduleId, tc)
1119
+ : undefined;
1120
+ const payload = buildTestcasePayload({
1121
+ projectId, moduleId,
1122
+ ...(userstoryId != null ? { userstoryId } : {}),
1123
+ existingId: numericId(id),
1124
+ tc,
1125
+ paramIdByName,
1126
+ ...(stepResolutions ? { stepResolutions } : {}),
1127
+ });
1128
+ // `!` — putMultipart is optional on QaHttp (walnut's wrapper opts out); makeClient always provides it.
1129
+ const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
1130
+ return toSavedRef(raw, false);
1131
+ }
1132
+ /** Snapshot the CURRENT server state of a testcase under a NEW version name — same TC id,
1133
+ * new `tcfn_version` row with cloned steps. Backed by `PUT /tm/testcase` with
1134
+ * `operation:"SAVENEWVERSION"` (see tcfn.service.js:115/242/355 for the branches). The steps
1135
+ * we fetch from the collection are re-POSTed with their `id` stripped so the backend inserts
1136
+ * fresh rows attached to the new version. When `tdCopy` is true, the source version's
1137
+ * testdata iterations are cloned across environments via `verData.selVersionId`.
1138
+ *
1139
+ * Not a mapper roundtrip: we resend the raw tcfnObj/steps we just fetched, so any fields the
1140
+ * neutral `TestCase` type doesn't carry (custom fields, mailers, exec platform, ...) are
1141
+ * preserved verbatim — the point of a version is "freeze what's there now". */
1142
+ async saveAsNewVersion(id, opts) {
1143
+ assertBusinessKeyKind(id, 'TC');
1144
+ const projectId = this.requireProjectId();
1145
+ const versionName = opts.versionName?.trim();
1146
+ if (!versionName)
1147
+ throw new Error('versionName is required.');
1148
+ const rawTc = await this.fetchRawTestcase(id);
1149
+ if (!rawTc || Object.keys(rawTc).length === 0) {
1150
+ throw new Error(`test case ${id} not found`);
1151
+ }
1152
+ // Resolve source version: caller override → current versionId on the TC → versionsDetails.id
1153
+ // → first entry in versions[]. Falls through to '' (empty) if none — backend then skips tdCopy.
1154
+ let sourceVersionId = opts.sourceVersionId;
1155
+ if (!sourceVersionId) {
1156
+ sourceVersionId = str(rawTc, 'versionId', 'selectedVersionId', 'selVersion');
1157
+ if (!sourceVersionId && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
1158
+ sourceVersionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId');
1159
+ }
1160
+ if (!sourceVersionId && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
1161
+ sourceVersionId = str(rawTc.versions[0], 'id', '_id');
1162
+ }
1163
+ }
1164
+ const rawSteps = await this.fetchStepsCollection(id, rawTc);
1165
+ const automationSteps = [];
1166
+ const manualSteps = [];
1167
+ for (const s of rawSteps) {
1168
+ // Project down to the step joi allow-list (testcaseValidator.js:244-306 + :307-367). Backend
1169
+ // re-assigns id/versionId/parentId under the new version row, so drop those regardless.
1170
+ const step = pick(s, STEP_ALLOWED_KEYS);
1171
+ const et = typeof step['executionType'] === 'string' ? String(step['executionType']).toUpperCase() : '';
1172
+ if (et === 'MANUAL')
1173
+ manualSteps.push(step);
1174
+ else
1175
+ automationSteps.push(step);
1176
+ }
1177
+ // Project down to the tcfnObj joi allow-list (testcaseValidator.js:189-227). Anything else on
1178
+ // the raw doc (code, createdBy, updatedBy, stepCount, etc.) trips "is not allowed".
1179
+ const tcfnObj = pick(rawTc, TCFNOBJ_ALLOWED_KEYS);
1180
+ tcfnObj['deleted'] = false;
1181
+ tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
1182
+ // Required-but-boolean fields on the update schema. Coerce so the presence is guaranteed.
1183
+ if (typeof tcfnObj['obsolete'] !== 'boolean')
1184
+ tcfnObj['obsolete'] = false;
1185
+ // Version stub: empty `id` tells the backend to create a fresh row. SYNCOFF normalised to
1186
+ // '' matches the FE (testcase.component.ts:1399) — it's a client-side "don't inherit"
1187
+ // marker, not a server value.
1188
+ const prevSync = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
1189
+ ? str(rawTc.versionsDetails, 'syncONOFF')
1190
+ : undefined;
1191
+ const versionsDetails = {
1192
+ name: versionName,
1193
+ id: '',
1194
+ syncONOFF: prevSync === 'SYNCOFF' ? '' : (prevSync ?? ''),
1195
+ deleted: false,
1196
+ executionType: str(rawTc, 'executionType') ?? 'AUTOMATION',
1197
+ functionProjectIds: [projectId],
1198
+ reviewApproveStatus: '',
1199
+ };
1200
+ const verData = {
1201
+ tdCopy: !!opts.tdCopy,
1202
+ ...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
1203
+ };
1204
+ const payload = {
1205
+ tcfnObj,
1206
+ steps: { automationSteps, manualSteps },
1207
+ versionsDetails,
1208
+ operation: 'SAVENEWVERSION',
1209
+ type: 'TC',
1210
+ verData,
1211
+ };
1212
+ const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
1213
+ const flat = raw?.data ?? raw ?? {};
1214
+ const savedId = str(flat, 'id', '_id', 'testcaseId') ?? numericId(id);
1215
+ const newVersionId = str(flat, 'versionId') ??
1216
+ (flat?.versionsDetails && typeof flat.versionsDetails === 'object'
1217
+ ? str(flat.versionsDetails, 'id')
1218
+ : undefined);
1219
+ return {
1220
+ id: savedId,
1221
+ created: false,
1222
+ productUrl: this.productUrlFor({ kind: 'testcase', id: savedId }),
1223
+ ...(newVersionId ? { artifacts: [`versionId=${newVersionId}`] } : {}),
1224
+ };
1225
+ }
1226
+ /** STEPSTOFN — mirrors the FE "Save as function" flow (create-new-fn-modal.component.ts:88-197):
1227
+ * pick a subset of the TC's steps, retype to FN, POST /tm/function multipart with the
1228
+ * `functionObj` payload. Backend re-assigns step ids/versionId under the newly created FN version.
1229
+ *
1230
+ * ponytail: file attachments (STEP_ATTACMENTS / STEP_COMAPAREFILES multipart entries) are NOT
1231
+ * forwarded — MCP callers don't ship blobs. Add if a real caller needs them.
1232
+ * ponytail: sub-step flattening (FE lines 124-127) is skipped — `/tm/testcase/steps` returns
1233
+ * flat top-level rows; nested FN expansions aren't in that read. Handle if that changes. */
1234
+ async saveStepsAsFunction(tcId, opts) {
1235
+ const projectId = this.requireProjectId();
1236
+ const name = opts.name?.trim();
1237
+ if (!name)
1238
+ throw new Error('name is required.');
1239
+ if (!opts.moduleId)
1240
+ throw new Error('moduleId is required.');
1241
+ const seqSet = new Set(opts.stepSeqs);
1242
+ if (seqSet.size === 0)
1243
+ throw new Error('stepSeqs is required.');
1244
+ const rawTc = await this.fetchRawTestcase(tcId);
1245
+ if (!rawTc || Object.keys(rawTc).length === 0) {
1246
+ throw new Error(`test case ${tcId} not found`);
1247
+ }
1248
+ const sourceVersionId = str(rawTc, 'versionId', 'selectedVersionId') ??
1249
+ (rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
1250
+ ? str(rawTc.versionsDetails, 'id', '_id', 'versionId')
1251
+ : undefined) ??
1252
+ (Array.isArray(rawTc?.versions) && rawTc.versions.length > 0
1253
+ ? str(rawTc.versions[0], 'id', '_id')
1254
+ : undefined);
1255
+ const rawSteps = await this.fetchStepsCollection(tcId, rawTc);
1256
+ const picked = rawSteps
1257
+ .filter((s) => seqSet.has(Number(s.seq)))
1258
+ .sort((a, b) => Number(a.seq) - Number(b.seq));
1259
+ if (picked.length === 0) {
1260
+ throw new Error(`no steps on ${tcId} match stepSeqs [${opts.stepSeqs.join(',')}]`);
1261
+ }
1262
+ const automationSteps = [];
1263
+ const manualSteps = [];
1264
+ let newSeq = 0;
1265
+ for (const s of picked) {
1266
+ const step = { ...s };
1267
+ // Server-assigned refs stripped so backend re-creates them under the new FN version.
1268
+ delete step.id;
1269
+ delete step._id;
1270
+ delete step.versionId;
1271
+ delete step.parentId;
1272
+ delete step.subSteps;
1273
+ delete step.deleted;
1274
+ delete step.isFunctionEdit;
1275
+ delete step.sharedFNsteps;
1276
+ delete step.sharedFnProjectId;
1277
+ step.type = 'FN';
1278
+ step.moduleId = opts.moduleId;
1279
+ step.seq = ++newSeq;
1280
+ // FE mirror (line 129-134): TM only accepts numeric parameterIds.
1281
+ if (Array.isArray(step.parameterIds)) {
1282
+ step.parameterIds = step.parameterIds.filter((p) => typeof p === 'number');
1283
+ }
1284
+ // FE mirror (line 136-150): shed conditional-step UI expansion.
1285
+ if (step.conditions?.children?.length) {
1286
+ for (const c of step.conditions.children) {
1287
+ delete c.groupedActions;
1288
+ delete c.stepActions;
1289
+ delete c.objectOfparameters;
1290
+ if (Array.isArray(c.parameterIds)) {
1291
+ c.parameterIds = c.parameterIds.filter((p) => typeof p === 'number');
1292
+ }
1293
+ }
1294
+ }
1295
+ const forced = opts.executionType;
1296
+ const bucket = forced
1297
+ ?? (String(step.executionType ?? '').toUpperCase() === 'MANUAL' ? 'MANUAL' : 'AUTOMATION');
1298
+ if (bucket === 'MANUAL')
1299
+ manualSteps.push(step);
1300
+ else
1301
+ automationSteps.push(step);
1302
+ }
1303
+ // FE mirror (line 192): DUO iff both buckets populated.
1304
+ const execType = automationSteps.length && manualSteps.length
1305
+ ? 'DUO'
1306
+ : manualSteps.length ? 'MANUAL' : 'AUTOMATION';
1307
+ // FE mirror (line 271-276): all steps share technologyType → use that, else fall back to TC's.
1308
+ const techs = new Set(picked.map((s) => str(s, 'technologyType')).filter(Boolean));
1309
+ const fnType = techs.size === 1
1310
+ ? [...techs][0]
1311
+ : (str(rawTc, 'tcType') ?? 'WEB');
1312
+ const tcfnObj = {
1313
+ name,
1314
+ projectId,
1315
+ moduleId: opts.moduleId,
1316
+ executionType: execType,
1317
+ fnType,
1318
+ reviewApproveStatus: '',
1319
+ };
1320
+ // FE default (function.model.ts:68) — backend requires versionsDetails.name.
1321
+ const versionsDetails = { name: 'v1.0', id: '', syncONOFF: '' };
1322
+ // testcaseId MUST be numeric here — tcfn.service.js:1124 uses it in a Mongo query
1323
+ // against `testdata_iteration.testcaseId` (stored as a Number). A string "42" won't
1324
+ // match `42`, so tdCopy silently returns zero source iterations. FE sends a number
1325
+ // (create-new-fn-modal.component.ts:31 `@Input('tcfnId') _tcfnId: number`).
1326
+ const verData = {
1327
+ tdCopy: !!opts.copyTestData,
1328
+ testcaseId: Number.parseInt(numericId(tcId), 10),
1329
+ fromParent: 'TC',
1330
+ ...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
1331
+ };
1332
+ const payload = {
1333
+ tcfnObj,
1334
+ steps: { automationSteps, manualSteps },
1335
+ versionsDetails,
1336
+ operation: 'STEPSTOFN',
1337
+ type: 'FN',
1338
+ verData,
1339
+ };
1340
+ const raw = await this.http.postMultipart('/tm/function', 'functionObj', payload);
1341
+ return toSavedRef(raw, true);
1342
+ }
1343
+ /** Look up the freshly-created FN's version id — STEPSTOFN's response inlines the FN but not its
1344
+ * version, and the replace-in-TC step needs `fnVersionId`. Mirrors the FE
1345
+ * (`create-new-fn-modal.component.ts:getFnVersion`): search `tcfnversion` by parentType=FN
1346
+ * ordered by createdAt asc, take the first. */
1347
+ async fetchFnVersionId(fnId) {
1348
+ const projectId = this.requireProjectId();
1349
+ const body = {
1350
+ searchFields: [
1351
+ { column: 'createdAt', sort: 'asc' },
1352
+ { column: 'deleted', value: false, regEx: false },
1353
+ { column: 'projectId', value: projectId, regEx: false },
1354
+ { column: 'parentType', value: 'FN', regEx: false },
1355
+ { column: 'parentId', value: Number.parseInt(numericId(fnId), 10), regEx: false },
1356
+ ],
1357
+ startIndex: 0,
1358
+ limit: 10000,
1359
+ };
1360
+ const raw = await this.http.postJson('/tm/tcfnversion/search', body);
1361
+ const rows = pickArray(raw, 'data', 'content');
1362
+ const versionId = rows.length > 0 ? str(rows[0], 'id', '_id') : undefined;
1363
+ if (!versionId)
1364
+ throw new Error(`could not resolve fnVersionId for function ${fnId}`);
1365
+ return versionId;
1366
+ }
1367
+ /** Second half of extract-to-function-and-replace-in-source (FE
1368
+ * `testcase.component.ts:createNewFns → onSave`): after the FN exists, PUT the source TC with the
1369
+ * extracted step rows marked `deleted:true` and a synthetic FUNCTION + FUNCTION_END pair inserted
1370
+ * at the first extracted position. The backend automatically:
1371
+ * - drops testdata columns for params only the deleted steps referenced (allParameters skips
1372
+ * `step.deleted:true` at tcfn.service.js:411)
1373
+ * - injects a per-env FN cell into every TC iteration row using the FN's default iteration
1374
+ * (tcfn.service.js:1032-1085) — so testdata rewiring is free.
1375
+ *
1376
+ * ponytail: refuses DUO / SYNCON test cases and picks that span control-flow or nested FN
1377
+ * brackets. Those cases (v2) need parallel mirroring into the other execution-type array and
1378
+ * bracket-aware validation — out of scope for the first pass. */
1379
+ async replaceStepsWithFunctionCall(tcId, opts) {
1380
+ const projectId = this.requireProjectId();
1381
+ const seqSet = new Set(opts.extractedSeqs);
1382
+ if (seqSet.size === 0)
1383
+ throw new Error('extractedSeqs is required.');
1384
+ const sortedSeqs = [...seqSet].sort((a, b) => a - b);
1385
+ // FE `checkStepsContinueOrNot`: picks must be a contiguous seq range or the resulting FN would
1386
+ // be non-executable and the TC layout would fracture.
1387
+ for (let i = 1; i < sortedSeqs.length; i++) {
1388
+ if (sortedSeqs[i] !== sortedSeqs[i - 1] + 1) {
1389
+ throw new Error(`extractedSeqs must be contiguous — got [${sortedSeqs.join(',')}]`);
1390
+ }
1391
+ }
1392
+ const rawTc = await this.fetchRawTestcase(tcId);
1393
+ if (!rawTc || Object.keys(rawTc).length === 0)
1394
+ throw new Error(`test case ${tcId} not found`);
1395
+ const execType = String(str(rawTc, 'executionType') ?? '').toUpperCase();
1396
+ if (execType === 'DUO') {
1397
+ throw new Error('DUO test cases are not yet supported for replaceInSource — split the extraction into AUTOMATION-only or MANUAL-only first.');
1398
+ }
1399
+ const versionsDetailsRaw = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object' ? rawTc.versionsDetails : {};
1400
+ const syncOnOff = str(versionsDetailsRaw, 'syncONOFF');
1401
+ if (syncOnOff === 'SYNCON') {
1402
+ throw new Error('SYNCON (mirrored automation/manual) test cases are not yet supported for replaceInSource.');
1403
+ }
1404
+ const sourceVersionId = str(rawTc, 'versionId', 'selectedVersionId') ??
1405
+ str(versionsDetailsRaw, 'id', '_id', 'versionId') ??
1406
+ (Array.isArray(rawTc?.versions) && rawTc.versions.length > 0
1407
+ ? str(rawTc.versions[0], 'id', '_id')
1408
+ : undefined);
1409
+ if (!sourceVersionId)
1410
+ throw new Error(`test case ${tcId} has no versionId — cannot save`);
1411
+ const rawSteps = await this.fetchStepsCollection(tcId, rawTc);
1412
+ // Refuse picks that would break brackets. FUNCTION/FUNCTION_END pairs must not be half-picked;
1413
+ // control-flow starts (IF/WHILE_START/FOR_START/…) must not span the range partially.
1414
+ const CONTROL_TYPES = new Set([
1415
+ 'FUNCTION', 'FUNCTION_END', 'IF', 'ELSE_IF', 'ELSE', 'IF_END',
1416
+ 'FOR_START', 'FOR_END', 'WHILE_START', 'WHILE_END', 'DO', 'DO_WHILE_END',
1417
+ 'ITERATOR_START', 'ITERATOR_END', 'LOOP_ITERATION_START', 'LOOP_ITERATION_END',
1418
+ ]);
1419
+ for (const s of rawSteps) {
1420
+ if (!seqSet.has(Number(s.seq)))
1421
+ continue;
1422
+ const t = String(s.stepType ?? '').toUpperCase();
1423
+ if (CONTROL_TYPES.has(t)) {
1424
+ throw new Error(`step seq ${s.seq} is a control-flow/FN bracket step (${t}) — extract a plain step range`);
1425
+ }
1426
+ }
1427
+ const insertAt = sortedSeqs[0];
1428
+ // Build the FUNCTION + FUNCTION_END pair. FE parent (testcase.component.ts:1953+1988-1994)
1429
+ // overrides `type` from 'FN' to 'TC' before splicing — the FN-call lives inside a TC, so it's
1430
+ // typed accordingly. `id`/`versionId` omitted so backend creates fresh rows.
1431
+ const stepLevelActions = {
1432
+ skip: false, captureScreenshot: false, continueOnFailure: false, skippedReport: false,
1433
+ };
1434
+ const fnCallStep = {
1435
+ seq: insertAt,
1436
+ stepType: 'FUNCTION',
1437
+ type: 'TC',
1438
+ functionId: Number.parseInt(numericId(opts.functionId), 10),
1439
+ fnVersionId: opts.fnVersionId,
1440
+ description: '',
1441
+ projectId,
1442
+ moduleId: opts.fnModuleId,
1443
+ executionType: execType || 'AUTOMATION',
1444
+ technologyType: opts.technologyType,
1445
+ stepLevelActions,
1446
+ };
1447
+ const fnEndStep = {
1448
+ seq: insertAt + 1,
1449
+ stepType: 'FUNCTION_END',
1450
+ type: 'TC',
1451
+ projectId,
1452
+ moduleId: opts.fnModuleId,
1453
+ executionType: execType || 'AUTOMATION',
1454
+ technologyType: opts.technologyType,
1455
+ stepLevelActions,
1456
+ };
1457
+ // Kept steps: preserve id/versionId/parentId (this is an update, not a fresh save) and pass
1458
+ // through as-is. Extracted steps: same, plus `deleted:true` — the backend's param collector
1459
+ // (tcfn.service.js:411) skips deleted rows, so their paramIds automatically drop from the TC's
1460
+ // rebuilt testdata columns. Do NOT strip parameterIds / objectParameterIds / parameterizedParamIds
1461
+ // from the deleted rows — those are what `allParameters` compares against to know what to prune.
1462
+ const kept = [];
1463
+ const deleted = [];
1464
+ for (const s of rawSteps) {
1465
+ const step = { ...s };
1466
+ delete step.subSteps; // transient — flat-only per the STEPSTOFN comment
1467
+ if (seqSet.has(Number(step.seq))) {
1468
+ step.deleted = true;
1469
+ delete step.objectOfparameters; // FE mirror (testcase.component.ts:1272)
1470
+ deleted.push(step);
1471
+ }
1472
+ else {
1473
+ kept.push(step);
1474
+ }
1475
+ }
1476
+ kept.sort((a, b) => Number(a.seq) - Number(b.seq));
1477
+ // Splice fn-call pair at the insert position. seqs 1..N are re-assigned at the end so nothing
1478
+ // downstream can misalign.
1479
+ const afterInsert = kept.filter((k) => Number(k.seq) > sortedSeqs[sortedSeqs.length - 1]);
1480
+ const beforeInsert = kept.filter((k) => Number(k.seq) < insertAt);
1481
+ const orderedLive = [...beforeInsert, fnCallStep, fnEndStep, ...afterInsert];
1482
+ let seq = 1;
1483
+ for (const s of orderedLive) {
1484
+ s.seq = seq++;
1485
+ }
1486
+ // Backend expects both automationSteps and manualSteps buckets. Live steps route by their
1487
+ // executionType; deleted steps come along in their own bucket (FE mirror lines 1268-1284).
1488
+ const automationSteps = [];
1489
+ const manualSteps = [];
1490
+ const bucketFor = (s) => {
1491
+ const et = String(s?.executionType ?? '').toUpperCase();
1492
+ return et === 'MANUAL' ? 'MANUAL' : 'AUTOMATION';
1493
+ };
1494
+ for (const s of orderedLive)
1495
+ (bucketFor(s) === 'MANUAL' ? manualSteps : automationSteps).push(s);
1496
+ for (const s of deleted)
1497
+ (bucketFor(s) === 'MANUAL' ? manualSteps : automationSteps).push(s);
1498
+ // tcfnObj: strict allow-list — extras trip the joi validator. Backend mirrors the FE's normal
1499
+ // update PUT (testcase.component.ts:1376+1495): `operation:"TC"`, `type:"TC"`, same versionsDetails.
1500
+ const tcfnObj = pick(rawTc, TCFNOBJ_ALLOWED_KEYS);
1501
+ tcfnObj['deleted'] = false;
1502
+ tcfnObj['id'] = Number.parseInt(numericId(tcId), 10);
1503
+ tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
1504
+ if (typeof tcfnObj['obsolete'] !== 'boolean')
1505
+ tcfnObj['obsolete'] = false;
1506
+ // Backend joi validator refuses an empty versionsDetails.name (BADREQUEST from /tm/testcase). The
1507
+ // name isn't always inlined on the raw TC's `versionsDetails` — for TCs opened via search or
1508
+ // certain list endpoints, only `versionsDetails.id` is present. Walk the version list to find
1509
+ // the row whose id matches sourceVersionId and use ITS name; fall back to 'v1.0' (the SQA
1510
+ // default set by project.service.js when a version is first created).
1511
+ let versionName = str(versionsDetailsRaw, 'name');
1512
+ if (!versionName && Array.isArray(rawTc?.versions)) {
1513
+ const match = rawTc.versions.find((v) => str(v, 'id', '_id') === sourceVersionId);
1514
+ versionName = match ? str(match, 'name') : undefined;
1515
+ }
1516
+ if (!versionName)
1517
+ versionName = 'v1.0';
1518
+ const versionsDetails = {
1519
+ name: versionName,
1520
+ id: sourceVersionId,
1521
+ deleted: false,
1522
+ syncONOFF: syncOnOff ?? '',
1523
+ executionType: execType || 'AUTOMATION',
1524
+ functionProjectIds: [projectId],
1525
+ reviewApproveStatus: str(versionsDetailsRaw, 'reviewApproveStatus') ?? '',
1526
+ };
1527
+ const payload = {
1528
+ tcfnObj,
1529
+ steps: { automationSteps, manualSteps },
1530
+ versionsDetails,
1531
+ operation: 'TC',
1532
+ type: 'TC',
1533
+ };
1534
+ const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
1535
+ const flat = raw?.data ?? raw ?? {};
1536
+ const savedId = str(flat, 'id', '_id', 'testcaseId') ?? numericId(tcId);
1537
+ return {
1538
+ id: savedId,
1539
+ created: false,
1540
+ productUrl: this.productUrlFor({ kind: 'testcase', id: savedId }),
1541
+ };
1542
+ }
1543
+ /** One-shot extract-to-function-and-replace. Chains STEPSTOFN → fetch fn version id → replace in
1544
+ * source TC. If step 2 or 3 fails after step 1 succeeded, the caller sees the fn id in the error
1545
+ * so they can retry or clean up the phantom function. */
1546
+ async extractStepsAsFunctionAndReplace(tcId, opts) {
1547
+ const fnRef = await this.saveStepsAsFunction(tcId, opts);
1548
+ let fnVersionId;
1549
+ try {
1550
+ fnVersionId = await this.fetchFnVersionId(fnRef.id);
1551
+ }
1552
+ catch (e) {
1553
+ const msg = e instanceof Error ? e.message : String(e);
1554
+ throw new Error(`Function ${fnRef.id} was created but its version lookup failed — the source TC was NOT modified. ${msg}`);
1555
+ }
1556
+ // Reuse tech detection from saveStepsAsFunction (same rule: single tech across picks → that
1557
+ // tech, else fall back to the source TC's tcType).
1558
+ const rawTc = await this.fetchRawTestcase(tcId);
1559
+ const rawSteps = await this.fetchStepsCollection(tcId, rawTc);
1560
+ const picked = rawSteps.filter((s) => opts.stepSeqs.includes(Number(s.seq)));
1561
+ const techs = new Set(picked.map((s) => str(s, 'technologyType')).filter(Boolean));
1562
+ const technologyType = techs.size === 1 ? [...techs][0] : (str(rawTc, 'tcType') ?? 'WEB');
1563
+ try {
1564
+ const tcRef = await this.replaceStepsWithFunctionCall(tcId, {
1565
+ extractedSeqs: opts.stepSeqs,
1566
+ functionId: fnRef.id,
1567
+ fnVersionId,
1568
+ fnModuleId: opts.moduleId,
1569
+ technologyType,
1570
+ });
1571
+ return { ...tcRef, fnId: fnRef.id, fnVersionId };
1572
+ }
1573
+ catch (e) {
1574
+ const msg = e instanceof Error ? e.message : String(e);
1575
+ throw new Error(`Function ${fnRef.id} (version ${fnVersionId}) was created but replacing steps in ${tcId} failed — clean up the function or retry with tc_replace_steps_with_function. ${msg}`);
1576
+ }
1577
+ }
1578
+ /** Soft-delete. `/tm/testcase/delete` returns HTTP 200 either way — its `data` is `true` on real
1579
+ * delete, or an array of dependency records when the TC is still referenced by a defect, suite,
1580
+ * execution plan, scenario, workflow, pipeline, or scheduler (nothing was deleted). Inspect the
1581
+ * shape and throw so the tool surfaces it as a failure instead of a fake success. */
1582
+ async deleteTestCase(id) {
1583
+ const projectId = this.requireProjectId();
1584
+ const raw = await this.http.postJson('/tm/testcase/delete', {
1585
+ ids: [Number.parseInt(numericId(id), 10)],
1586
+ projectId,
1587
+ isDelete: true,
1588
+ });
1589
+ const data = raw?.data;
1590
+ if (data === true)
1591
+ return;
1592
+ const deps = Array.isArray(data) ? data[0] : undefined;
1593
+ const list = (m) => m && typeof m === 'object' ? Object.values(m).flat().map(String) : [];
1594
+ const refs = [];
1595
+ const groups = [
1596
+ ['defectArr', 'defects'], ['suiteArr', 'suites'], ['epArr', 'execution plans'],
1597
+ ['scenarioArr', 'scenarios'], ['wfArr', 'workflows'], ['pipelineArr', 'pipelines'],
1598
+ ['schedulerArr', 'schedulers'],
1599
+ ];
1600
+ for (const [key, label] of groups) {
1601
+ const ids = list(deps?.[key]);
1602
+ if (ids.length)
1603
+ refs.push(`${label} (${ids.join(', ')})`);
1604
+ }
1605
+ throw new Error(refs.length
1606
+ ? `Not deleted — test case is still referenced by ${refs.join('; ')}. Remove those references first.`
1607
+ : 'Not deleted — the backend reported blocking dependencies.');
1608
+ }
1609
+ /** Clone a testcase in place (same project + module) with all steps, versions, testdata,
1610
+ * parameters, object refs, and nested function calls carried over. Backed by the SQA native
1611
+ * `/tm/tcfnCopy/copytc` endpoint (see test-management/src/controllers/tcfnCopy.controller.js).
1612
+ *
1613
+ * Requires the `copy_testcase_across_project` privilege on the current project — enforced
1614
+ * server-side regardless of whether the copy is in-project or across projects.
1615
+ *
1616
+ * When `newTitle` is omitted the backend auto-names the copy as `{sourceName}_copy1`,
1617
+ * `_copy2`, etc. When provided, a follow-up PUT renames the freshly-created TC to that name.
1618
+ *
1619
+ * IMPORTANT — the copy endpoint's `selversions` and `selectedEnv` fields are FILTERS, not
1620
+ * wildcards. Passing empty arrays makes the backend copy the TC shell only (no steps, no
1621
+ * testdata, since both live under versions and per-env iterations). So we fetch the source
1622
+ * TC's version names AND the project's environment names first and pass them explicitly. */
1623
+ async cloneTestCase(id, newTitle, versions) {
1624
+ assertBusinessKeyKind(id, 'TC');
1625
+ const projectId = this.requireProjectId();
1626
+ const src = await this.fetchRawTestcase(id);
1627
+ if (!src || Object.keys(src).length === 0)
1628
+ throw new Error(`test case ${id} not found`);
1629
+ const sourceId = Number.parseInt(numericId(id), 10);
1630
+ const sourceProjId = typeof src.projectId === 'number' ? src.projectId : projectId;
1631
+ const sourceModuleId = typeof src.moduleId === 'number' ? src.moduleId : 0;
1632
+ // Fetch source's version names — required, empty means no versions get copied.
1633
+ const versionsRaw = await this.http.getJson(`/tm/testcase/version/${sourceId}/${sourceProjId}`);
1634
+ const versionsArr = Array.isArray(versionsRaw) ? versionsRaw : (versionsRaw?.data ?? []);
1635
+ const availableNames = versionsArr
1636
+ .map((v) => str(v, 'name'))
1637
+ .filter((n) => !!n);
1638
+ if (availableNames.length === 0) {
1639
+ throw new Error(`Cannot clone ${id}: source has no versions to copy.`);
1640
+ }
1641
+ const versionNames = filterVersionNames(availableNames, versions, id);
1642
+ // Fetch project's environment names — empty means per-env testdata rows are dropped
1643
+ // (only the "general" fallback iteration survives).
1644
+ const envsRaw = await this.http.getJson(`/pm/fields/8?projectId=${sourceProjId}&fieldType=environment`);
1645
+ const envsArr = Array.isArray(envsRaw) ? envsRaw : (envsRaw?.data ?? []);
1646
+ const envNames = envsArr
1647
+ .map((e) => str(e, 'name'))
1648
+ .filter((n) => !!n);
1649
+ await this.http.postJson('/tm/tcfnCopy/copytc', {
1650
+ type: 'TC',
1651
+ tcfnList: [{ id: sourceId, selversions: versionNames }],
1652
+ sourceProjId,
1653
+ sourceModuleId,
1654
+ destinationProjId: sourceProjId,
1655
+ destinationModuleId: sourceModuleId,
1656
+ selectedEnv: envNames,
1657
+ });
1658
+ // Find the newly-created copy: highest-id TC whose `projectLevelcopiedFrom.id` matches. Not
1659
+ // going through `searchBody` because the SQA search protocol lets a searchField have EITHER a
1660
+ // `value` (filter) OR a `sort` (ordering) — the helper only builds value entries.
1661
+ const searchRaw = await this.http.postJson('/tm/testcase/search', {
1662
+ searchFields: [
1663
+ { column: 'deleted', value: false },
1664
+ { column: 'projectId', value: sourceProjId },
1665
+ { column: 'projectLevelcopiedFrom.id', value: sourceId },
1666
+ { column: 'id', sort: 'dsc' },
1667
+ ],
1668
+ startIndex: 0,
1669
+ limit: 1,
1670
+ selectFields: ['id', 'code', 'name'],
1671
+ });
1672
+ const rows = pickArray(searchRaw, 'data', 'content', 'testCases');
1673
+ if (rows.length === 0) {
1674
+ throw new Error('Clone was requested but the newly-created testcase could not be located afterwards. ' +
1675
+ 'The backend may still be processing the copy, or the caller may lack the ' +
1676
+ '`copy_testcase_across_project` privilege on this project.');
1677
+ }
1678
+ const created = rows[0];
1679
+ const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
1680
+ // Optional rename: PUT /tm/testcase/:id with { name, projectId }.
1681
+ if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
1682
+ await this.http.putJson(`/tm/testcase/${newIdNum}`, {
1683
+ name: newTitle.trim(),
1684
+ projectId: sourceProjId,
1685
+ });
1686
+ created.name = newTitle.trim();
1687
+ }
1688
+ return {
1689
+ id: String(newIdNum),
1690
+ created: true,
1691
+ productUrl: this.productUrlFor({ kind: 'testcase', id: String(newIdNum) }),
1692
+ };
1693
+ }
1694
+ // ---------------------------------------------------------------------- Functions (reusable)
1695
+ // Same tcfnObj collection as testcases — type='FN' instead of 'TC'. Search / getById / delete /
1696
+ // steps all mirror the testcase endpoints one-to-one.
1697
+ async listFunctions(q) {
1698
+ const projectId = this.requireProjectId();
1699
+ const fields = [
1700
+ { column: 'deleted', value: false },
1701
+ { column: 'projectId', value: projectId },
1702
+ ];
1703
+ if (q.search)
1704
+ fields.push({ column: 'name', value: q.search, regEx: true });
1705
+ if (q.fnType)
1706
+ fields.push({ column: 'fnType', value: q.fnType });
1707
+ if (q.executionType)
1708
+ fields.push({ column: 'executionType', value: q.executionType });
1709
+ if (q.moduleId)
1710
+ fields.push({ column: 'moduleId', value: Number.parseInt(numericId(q.moduleId), 10) });
1711
+ const createdBy = this.resolveUserFilter(q.createdBy);
1712
+ if (createdBy != null)
1713
+ fields.push({ column: 'createdBy', value: createdBy });
1714
+ const updatedBy = this.resolveUserFilter(q.updatedBy);
1715
+ if (updatedBy != null)
1716
+ fields.push({ column: 'updatedBy', value: updatedBy });
1717
+ const body = this.searchBody(fields, {
1718
+ startIndex: q.offset ?? 0,
1719
+ limit: q.limit ?? 100,
1720
+ selectFields: ['id', 'code', 'name', 'description', 'fnType', 'executionType',
1721
+ 'reviewApproveStatus', 'stepCount'],
1722
+ });
1723
+ const raw = await this.http.postJson('/tm/function/search', body);
1724
+ const rows = pickArray(raw, 'data', 'content', 'functions');
1725
+ return withTotal(rows.map(toFunctionSummary), raw);
1726
+ }
1727
+ async getFunction(id) {
1728
+ const projectId = this.requireProjectId();
1729
+ const fnNum = numericId(id);
1730
+ const raw = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
1731
+ let doc = raw?.data ?? raw;
1732
+ if (Array.isArray(doc))
1733
+ doc = doc.length > 0 ? doc[0] : {};
1734
+ if (!doc || typeof doc !== 'object' || Object.keys(doc).length === 0) {
1735
+ throw new Error(`function ${id} not found`);
1736
+ }
1737
+ const fn = toFunction(doc);
1738
+ // Fetch version rows ONCE — reused to (a) pick the newest for the steps call and (b) hydrate
1739
+ // `fn.versions` for the caller. The FN doc from `/tm/function/:id/:pid` has NO inline versions[]
1740
+ // (unlike TC's getById), so this sibling endpoint is the only source. Non-fatal on failure.
1741
+ let versionRows = [];
1742
+ try {
1743
+ versionRows = await this.fetchFunctionVersionRows(fnNum);
1744
+ }
1745
+ catch { /* leave empty */ }
1746
+ const currentVersionId = versionRows.length
1747
+ ? str(versionRows[0], 'id', '_id', 'versionId')
1748
+ : undefined;
1749
+ if (versionRows.length)
1750
+ fn.versions = toVersionEntries(versionRows, currentVersionId);
1751
+ if (currentVersionId) {
1752
+ try {
1753
+ const rows = await this.fetchFunctionStepsForVersion(fnNum, currentVersionId);
1754
+ if (rows.length > 0)
1755
+ fn.steps = rows.map((s, i) => fromSqaStep(s, i + 1));
1756
+ }
1757
+ catch { /* leave steps empty */ }
1758
+ }
1759
+ return fn;
1760
+ }
1761
+ /** Steps for one FN version. Unlike TC docs, the FN doc from `/tm/function/:id/:projectId`
1762
+ * doesn't carry a versionId inline — callers resolve the target versionId (usually the newest,
1763
+ * from `fetchFunctionVersionRows`) and pass it here. */
1764
+ async fetchFunctionStepsForVersion(fnNum, versionId) {
1765
+ const projectId = this.requireProjectId();
1766
+ const path = '/tm/function/steps' +
1767
+ `?projectId=${projectId}` +
1768
+ `&tcfnId=${fnNum}` +
1769
+ `&versionId=${encodeURIComponent(versionId)}` +
1770
+ '&type=FN';
1771
+ const body = await this.http.getJson(path);
1772
+ return Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
1773
+ }
1774
+ /** The function's version rows, newest-first. */
1775
+ async fetchFunctionVersionRows(fnNum) {
1776
+ const projectId = this.requireProjectId();
1777
+ const raw = await this.http.getJson(`/tm/function/version/${fnNum}/${projectId}`);
1778
+ const rows = pickArray(raw, 'data', 'content', 'versions');
1779
+ if (!rows.length)
1780
+ return [];
1781
+ return [...rows].sort((a, b) => Number(b?.versionNumber ?? 0) - Number(a?.versionNumber ?? 0));
1782
+ }
1783
+ /** Soft-delete a function. `/tm/function/delete` returns HTTP 200 either way — its `data` is
1784
+ * `true` on real delete, or an array of dependency records when the FN is still referenced by a
1785
+ * TC / FN / workflow (nothing was deleted). Inspect the shape and throw so the tool surfaces it
1786
+ * as a failure instead of a fake success. */
1787
+ async deleteFunction(id) {
1788
+ const projectId = this.requireProjectId();
1789
+ const raw = await this.http.postJson('/tm/function/delete', {
1790
+ ids: [Number.parseInt(numericId(id), 10)],
1791
+ projectId,
1792
+ isDelete: true,
1793
+ });
1794
+ const data = raw?.data;
1795
+ if (data === true)
1796
+ return;
1797
+ const deps = Array.isArray(data) ? data[0] : undefined;
1798
+ const list = (m) => m && typeof m === 'object' ? Object.values(m).flat().map(String) : [];
1799
+ const tcs = list(deps?.tcArr);
1800
+ const fns = list(deps?.fnArr);
1801
+ const wfs = list(deps?.wfArr);
1802
+ const refs = [];
1803
+ if (tcs.length)
1804
+ refs.push(`test cases (${tcs.join(', ')})`);
1805
+ if (fns.length)
1806
+ refs.push(`functions (${fns.join(', ')})`);
1807
+ if (wfs.length)
1808
+ refs.push(`workflows (${wfs.join(', ')})`);
1809
+ throw new Error(refs.length
1810
+ ? `Not deleted — function is still referenced by ${refs.join('; ')}. Remove those references first.`
1811
+ : 'Not deleted — the backend reported blocking dependencies.');
1812
+ }
1813
+ /** Copy a function in place — new tcfnObj row, all versions/steps/testdata carried over. Same
1814
+ * shape as `cloneTestCase`: version names + env names come from the source, then POST to
1815
+ * `/tm/tcfnCopy/copytc` with `type:'FN'` (backend enforces `copy_function_across_projects`).
1816
+ * When `newTitle` is omitted the backend auto-names as `{sourceName}_copy1`, etc.; when
1817
+ * provided a follow-up PUT `/tm/function/:id` renames the freshly-created FN. */
1818
+ async cloneFunction(id, newTitle, versions) {
1819
+ assertBusinessKeyKind(id, 'FN');
1820
+ const projectId = this.requireProjectId();
1821
+ const fnNum = numericId(id);
1822
+ const rawFn = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
1823
+ let src = rawFn?.data ?? rawFn;
1824
+ if (Array.isArray(src))
1825
+ src = src.length > 0 ? src[0] : {};
1826
+ if (!src || typeof src !== 'object' || Object.keys(src).length === 0) {
1827
+ throw new Error(`function ${id} not found`);
1828
+ }
1829
+ const sourceId = Number.parseInt(fnNum, 10);
1830
+ const sourceProjId = typeof src.projectId === 'number' ? src.projectId : projectId;
1831
+ const sourceModuleId = typeof src.moduleId === 'number' ? src.moduleId : 0;
1832
+ // Fetch source's version names — required, empty means no versions get copied.
1833
+ const versionsRaw = await this.http.getJson(`/tm/function/version/${sourceId}/${sourceProjId}`);
1834
+ const versionsArr = Array.isArray(versionsRaw) ? versionsRaw : (versionsRaw?.data ?? []);
1835
+ const availableNames = versionsArr
1836
+ .map((v) => str(v, 'name'))
1837
+ .filter((n) => !!n);
1838
+ if (availableNames.length === 0) {
1839
+ throw new Error(`Cannot clone ${id}: source has no versions to copy.`);
1840
+ }
1841
+ const versionNames = filterVersionNames(availableNames, versions, id);
1842
+ // Same environment fetch as cloneTestCase — empty means per-env testdata rows are dropped.
1843
+ const envsRaw = await this.http.getJson(`/pm/fields/8?projectId=${sourceProjId}&fieldType=environment`);
1844
+ const envsArr = Array.isArray(envsRaw) ? envsRaw : (envsRaw?.data ?? []);
1845
+ const envNames = envsArr
1846
+ .map((e) => str(e, 'name'))
1847
+ .filter((n) => !!n);
1848
+ await this.http.postJson('/tm/tcfnCopy/copytc', {
1849
+ type: 'FN',
1850
+ tcfnList: [{ id: sourceId, selversions: versionNames }],
1851
+ sourceProjId,
1852
+ sourceModuleId,
1853
+ destinationProjId: sourceProjId,
1854
+ destinationModuleId: sourceModuleId,
1855
+ selectedEnv: envNames,
1856
+ });
1857
+ // Find the newly-created copy: highest-id FN whose `projectLevelcopiedFrom.id` matches source.
1858
+ const searchRaw = await this.http.postJson('/tm/function/search', {
1859
+ searchFields: [
1860
+ { column: 'deleted', value: false },
1861
+ { column: 'projectId', value: sourceProjId },
1862
+ { column: 'projectLevelcopiedFrom.id', value: sourceId },
1863
+ { column: 'id', sort: 'dsc' },
1864
+ ],
1865
+ startIndex: 0,
1866
+ limit: 1,
1867
+ selectFields: ['id', 'code', 'name'],
1868
+ });
1869
+ const rows = pickArray(searchRaw, 'data', 'content', 'functions');
1870
+ if (rows.length === 0) {
1871
+ throw new Error('Clone was requested but the newly-created function could not be located afterwards. ' +
1872
+ 'The backend may still be processing the copy, or the caller may lack the ' +
1873
+ '`copy_function_across_projects` privilege on this project.');
1874
+ }
1875
+ const created = rows[0];
1876
+ const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
1877
+ // Optional rename: PUT /tm/function/:id — inline validator accepts { name } only (no projectId).
1878
+ if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
1879
+ await this.http.putJson(`/tm/function/${newIdNum}`, {
1880
+ name: newTitle.trim(),
1881
+ });
1882
+ created.name = newTitle.trim();
1883
+ }
1884
+ return {
1885
+ id: String(newIdNum),
1886
+ created: true,
1887
+ productUrl: this.productUrlFor({ kind: 'function', id: String(newIdNum) }),
1888
+ };
1889
+ }
1890
+ /** Snapshot the CURRENT server state of a function under a NEW version name — same FN id, new
1891
+ * `tcfn_version` row with cloned steps. Mirrors `saveAsNewVersion` (see there for the shape
1892
+ * rationale) with `type:'FN'` and the multipart route `PUT /tm/function` / key `functionObj`.
1893
+ * Source-version resolution goes through the FN's `/tm/function/version/:id/:pid` list — the
1894
+ * FN doc has no inline versionId. */
1895
+ async saveAsNewFunctionVersion(id, opts) {
1896
+ assertBusinessKeyKind(id, 'FN');
1897
+ const projectId = this.requireProjectId();
1898
+ const versionName = opts.versionName?.trim();
1899
+ if (!versionName)
1900
+ throw new Error('versionName is required.');
1901
+ const fnNum = numericId(id);
1902
+ const rawFn = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
1903
+ let rawTc = rawFn?.data ?? rawFn;
1904
+ if (Array.isArray(rawTc))
1905
+ rawTc = rawTc.length > 0 ? rawTc[0] : {};
1906
+ if (!rawTc || typeof rawTc !== 'object' || Object.keys(rawTc).length === 0) {
1907
+ throw new Error(`function ${id} not found`);
1908
+ }
1909
+ // FN doc has no inline versionId — resolve from the version list. Caller override wins.
1910
+ let sourceVersionId = opts.sourceVersionId;
1911
+ if (!sourceVersionId) {
1912
+ const versionRows = await this.fetchFunctionVersionRows(fnNum);
1913
+ if (versionRows.length > 0)
1914
+ sourceVersionId = str(versionRows[0], 'id', '_id', 'versionId');
1915
+ }
1916
+ // Fetch steps under the resolved (or newest) version.
1917
+ const rawSteps = sourceVersionId
1918
+ ? await this.http.getJson('/tm/function/steps' +
1919
+ `?projectId=${projectId}` +
1920
+ `&tcfnId=${fnNum}` +
1921
+ `&versionId=${encodeURIComponent(sourceVersionId)}` +
1922
+ '&type=FN').then((body) => (Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : []))
1923
+ : [];
1924
+ const automationSteps = [];
1925
+ const manualSteps = [];
1926
+ for (const s of rawSteps) {
1927
+ const step = pick(s, STEP_ALLOWED_KEYS);
1928
+ const et = typeof step['executionType'] === 'string' ? String(step['executionType']).toUpperCase() : '';
1929
+ if (et === 'MANUAL')
1930
+ manualSteps.push(step);
1931
+ else
1932
+ automationSteps.push(step);
1933
+ }
1934
+ const tcfnObj = pick(rawTc, TCFNOBJ_FN_ALLOWED_KEYS);
1935
+ tcfnObj['deleted'] = false;
1936
+ tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
1937
+ // FN update joi REQUIRES `fnType` and REJECTS `obsolete`. Guarantee fnType from the raw doc
1938
+ // (falls through to legacy `tcType` if a pre-migration FN row is missing fnType); never set
1939
+ // obsolete.
1940
+ if (typeof tcfnObj['fnType'] !== 'string' || !tcfnObj['fnType']) {
1941
+ const fallback = str(rawTc, 'fnType', 'tcType') ?? 'WEB';
1942
+ tcfnObj['fnType'] = fallback;
1943
+ }
1944
+ // Version stub mirrors function.component.ts:900-907 — empty id tells the backend to create a
1945
+ // fresh row; SYNCOFF is normalised to '' the same way as the TC path. FN update joi REJECTS
1946
+ // `functionProjectIds` (allowed on TC's versionsDetails but not FN's — see functionValidator.js
1947
+ // updateFnBodyValidation.versionsDetails), so don't include it.
1948
+ const prevSync = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
1949
+ ? str(rawTc.versionsDetails, 'syncONOFF')
1950
+ : undefined;
1951
+ const versionsDetails = {
1952
+ name: versionName,
1953
+ id: '',
1954
+ syncONOFF: prevSync === 'SYNCOFF' ? '' : (prevSync ?? ''),
1955
+ deleted: false,
1956
+ executionType: str(rawTc, 'executionType') ?? 'AUTOMATION',
1957
+ reviewApproveStatus: '',
1958
+ };
1959
+ const verData = {
1960
+ tdCopy: !!opts.tdCopy,
1961
+ ...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
1962
+ };
1963
+ const payload = {
1964
+ tcfnObj,
1965
+ steps: { automationSteps, manualSteps },
1966
+ versionsDetails,
1967
+ operation: 'SAVENEWVERSION',
1968
+ type: 'FN',
1969
+ verData,
1970
+ };
1971
+ const raw = await this.http.putMultipart('/tm/function', 'functionObj', payload);
1972
+ const flat = raw?.data ?? raw ?? {};
1973
+ const savedId = str(flat, 'id', '_id', 'functionId', 'testcaseId') ?? fnNum;
1974
+ const newVersionId = str(flat, 'versionId') ??
1975
+ (flat?.versionsDetails && typeof flat.versionsDetails === 'object'
1976
+ ? str(flat.versionsDetails, 'id')
1977
+ : undefined);
1978
+ return {
1979
+ id: savedId,
1980
+ created: false,
1981
+ productUrl: this.productUrlFor({ kind: 'function', id: savedId }),
1982
+ ...(newVersionId ? { artifacts: [`versionId=${newVersionId}`] } : {}),
1983
+ };
1984
+ }
1985
+ /** Never throw on unresolvable storyRef — the Java code logs and moves on so the save still lands. */
1986
+ async resolveStoryNumericIdOrNull(storyRef) {
1987
+ try {
1988
+ const s = await this.resolveStoryNumericId(storyRef);
1989
+ const n = Number.parseInt(s, 10);
1990
+ return Number.isFinite(n) ? n : undefined;
1991
+ }
1992
+ catch {
1993
+ return undefined;
1994
+ }
1995
+ }
1996
+ /** Resolve every param name referenced by the TC to a SQA parameter id via get-or-create.
1997
+ * Sources: (a) `${name}` tokens in every step's description (manual + automation), and
1998
+ * (b) keys of every automation step's `recordedParam` (action-argument names like
1999
+ * "packageName", "activity", "keys"). Name-uniqueness is project-wide on the backend, so
2000
+ * get-or-create means every TC using `${appUrl}` (or the same "packageName" recorded param)
2001
+ * links to the same parameter row. */
2002
+ async resolveParamIds(projectId, moduleId, tc) {
2003
+ const paramIdByName = new Map();
2004
+ const steps = Array.isArray(tc.steps) ? tc.steps : [];
2005
+ for (const s of steps) {
2006
+ for (const name of extractParamNames(s.description)) {
2007
+ if (paramIdByName.has(name))
2008
+ continue;
2009
+ const defaultValue = paramDefault(name, tc.data, s.value);
2010
+ const id = await this.getOrCreateParameter(projectId, moduleId, name, defaultValue);
2011
+ paramIdByName.set(name, id);
2012
+ }
2013
+ if (s.recordedParam) {
2014
+ for (const [name, value] of Object.entries(s.recordedParam)) {
2015
+ if (paramIdByName.has(name))
2016
+ continue;
2017
+ const id = await this.getOrCreateParameter(projectId, moduleId, name, String(value ?? ''));
2018
+ paramIdByName.set(name, id);
2019
+ }
2020
+ }
2021
+ }
2022
+ return paramIdByName;
2023
+ }
2024
+ actionCatalog() {
2025
+ if (this.actionCatalogCache)
2026
+ return this.actionCatalogCache;
2027
+ const projectId = this.requireProjectId();
2028
+ this.actionCatalogCache = (async () => {
2029
+ // `projectId in [0, projectId]` — 0 is the customer-wide default catalog, projectId is the
2030
+ // per-project overrides layered on top. Same filter the SQA UI uses (steps.model.ts:59).
2031
+ const body = {
2032
+ searchFields: [
2033
+ { column: 'deleted', value: false },
2034
+ { column: 'projectId', value: { $in: [0, projectId] } },
2035
+ ],
2036
+ startIndex: 0,
2037
+ limit: 100000,
2038
+ selectFields: ['id', 'name', 'technology', 'objectTemplate', 'objectRequired'],
2039
+ };
2040
+ const raw = await this.http.postJson('/tm/action/search', body);
2041
+ const rows = pickArray(raw, 'data', 'content', 'actions');
2042
+ const map = new Map();
2043
+ for (const row of rows) {
2044
+ const idStr = str(row, 'id', '_id', 'actionId');
2045
+ const name = str(row, 'name');
2046
+ const tech = str(row, 'technology');
2047
+ if (!idStr || !name || !tech)
2048
+ continue;
2049
+ const id = Number.parseInt(idStr, 10);
2050
+ if (!Number.isFinite(id))
2051
+ continue;
2052
+ const objectRequired = row?.objectRequired === true;
2053
+ const otRaw = row?.objectTemplate;
2054
+ const objectTemplate = typeof otRaw === 'number' ? otRaw
2055
+ : typeof otRaw === 'string' && /^\d+$/.test(otRaw) ? Number.parseInt(otRaw, 10)
2056
+ : undefined;
2057
+ // Prefer the higher-priority project override when the same (tech, name) has both a
2058
+ // default and a project row: last write wins if we sort defaults first. Backend returns
2059
+ // whichever ordering; we resolve determinism by preferring a non-zero projectId row.
2060
+ const key = `${tech.toUpperCase()}::${name.toLowerCase()}`;
2061
+ const prev = map.get(key);
2062
+ const isProjectOverride = typeof row?.projectId === 'number' && row.projectId !== 0;
2063
+ if (!prev || isProjectOverride)
2064
+ map.set(key, { id, objectRequired, objectTemplate });
2065
+ }
2066
+ return map;
2067
+ })().catch((e) => { this.actionCatalogCache = null; throw e; });
2068
+ return this.actionCatalogCache;
2069
+ }
2070
+ /** POST /tm/object/createObjectSeq — get-or-create the shared object row by locator match. The
2071
+ * backend does the dedup (`object.controller.js:268-370`) — same-locators reuses, same-disname-
2072
+ * different-locators creates a suffixed one. Returns the numeric object id.
2073
+ *
2074
+ * ponytail: minimum body — no thumbnail_url / pageScreenShot_url / parameterId. The recording
2075
+ * path enriches these; the tc_save path doesn't have screenshots to attach, and the object
2076
+ * works for execution without them. */
2077
+ async resolveObjectId(objectRef, projectId, moduleId, technology, objectTemplateId) {
2078
+ const attributes = (objectRef.locators ?? []).map((l) => ({
2079
+ name: l.name,
2080
+ value: l.value,
2081
+ ...(l.unique === true ? { unique: true } : {}),
2082
+ }));
2083
+ const body = {
2084
+ projectId,
2085
+ moduleId,
2086
+ objecttemplateId: objectTemplateId,
2087
+ technology,
2088
+ disname: objectRef.name ?? '',
2089
+ attributes,
2090
+ parameterId: [],
2091
+ description: '',
2092
+ };
2093
+ const raw = await this.http.postJson('/tm/object/createObjectSeq', body);
2094
+ const flat = raw?.data ?? raw;
2095
+ const id = str(flat, 'id', '_id', 'objectId');
2096
+ if (!id)
2097
+ throw new Error(`createObjectSeq returned no id for object "${objectRef.name}": ${JSON.stringify(raw)}`);
2098
+ return Number.parseInt(id, 10);
2099
+ }
2100
+ /** For every automation step, resolve `actionId` (from action name + technology) and
2101
+ * `objectId` (via createObjectSeq get-or-create) so the mapper can emit a valid step. Runs
2102
+ * once per save, aligned by step index. */
2103
+ async resolveAutomationSteps(projectId, moduleId, tc) {
2104
+ const catalog = await this.actionCatalog();
2105
+ const steps = Array.isArray(tc.steps) ? tc.steps : [];
2106
+ const out = [];
2107
+ for (let i = 0; i < steps.length; i++) {
2108
+ const s = steps[i];
2109
+ const tech = (TECH_BY_STEPTYPE[s.stepType] ?? deriveTcType(tc)).toUpperCase();
2110
+ const name = (s.actionType ?? '').trim();
2111
+ if (!name) {
2112
+ 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").`);
2113
+ }
2114
+ const meta = catalog.get(`${tech}::${name.toLowerCase()}`);
2115
+ if (!meta) {
2116
+ throw new Error(`Automation step ${i + 1}: no SQA action named "${name}" for technology ${tech}. Check the action catalog in the SQA UI.`);
2117
+ }
2118
+ let objectId = null;
2119
+ if (meta.objectRequired) {
2120
+ if (!s.objectRef || !s.objectRef.name || !(s.objectRef.locators?.length)) {
2121
+ 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}]}.`);
2122
+ }
2123
+ if (meta.objectTemplate == null) {
2124
+ 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.`);
2125
+ }
2126
+ objectId = await this.resolveObjectId(s.objectRef, projectId, moduleId, tech, meta.objectTemplate);
2127
+ }
2128
+ out.push({ actionId: meta.id, objectId });
2129
+ }
2130
+ return out;
2131
+ }
2132
+ /** Case-insensitive project-scoped exact-name lookup. Backend duplicate-name check uses Mongo
2133
+ * collation strength:2, so a case-sensitive search here misses params stored under different
2134
+ * casing — the next POST then trips "Duplicate Name Error". Returns null when not found. */
2135
+ async findParameterId(projectId, name) {
2136
+ const body = this.searchBody([
2137
+ { column: 'name', value: name },
2138
+ { column: 'projectId', value: projectId },
2139
+ ], { startIndex: 0, limit: 10 });
2140
+ // The `searchFields` shape supports `ignorecase:true` per-field; searchBody doesn't emit it,
2141
+ // so add it inline for the name column.
2142
+ body['searchFields'][0]['ignorecase'] = true;
2143
+ const raw = await this.http.postJson('/tm/parameter/search', body);
2144
+ const rows = pickArray(raw, 'data', 'content');
2145
+ for (const row of rows) {
2146
+ const rowName = str(row, 'name');
2147
+ if (rowName === name) {
2148
+ const id = str(row, 'id', '_id', 'parameterId');
2149
+ if (id)
2150
+ return Number.parseInt(id, 10);
2151
+ }
2152
+ }
2153
+ return null;
2154
+ }
2155
+ async getOrCreateParameter(projectId, moduleId, name, defaultValue) {
2156
+ const existing = await this.findParameterId(projectId, name);
2157
+ if (existing != null)
2158
+ return existing;
2159
+ const body = {
2160
+ name,
2161
+ projectId,
2162
+ protected: looksSensitive(name),
2163
+ ...(moduleId > 0 ? { moduleId } : {}),
2164
+ defaultValue,
2165
+ // Match the SQA UI's Add-Parameter defaults so API-created params render identically. Without
2166
+ // these the UI shows "Parameter Type*" empty and refuses to save edits.
2167
+ type: 'Local',
2168
+ parameterType: 'Alphanumeric',
2169
+ description: '',
2170
+ unique: false,
2171
+ concat: '',
2172
+ paramAttributes: { minLength: 0, maxLength: 0, prefix: '', suffix: '' },
2173
+ };
2174
+ let raw;
2175
+ try {
2176
+ raw = await this.http.postJson('/tm/parameter', body);
2177
+ }
2178
+ catch (e) {
2179
+ // Backend rejects a race-lost duplicate with 500 "Duplicate Name" when the concurrent search
2180
+ // missed the just-created row. Recover by re-querying — same name, project-scoped.
2181
+ const msg = e instanceof Error ? e.message : String(e);
2182
+ if (/Duplicate Name|BADREQUEST/i.test(msg)) {
2183
+ const after = await this.findParameterId(projectId, name);
2184
+ if (after != null)
2185
+ return after;
2186
+ }
2187
+ throw e;
2188
+ }
2189
+ // The id lives at various nesting levels depending on which path the backend took (bare doc,
2190
+ // `data`, or `message[0]` for a race-lost duplicate). Try all three.
2191
+ let id = str(raw, 'id', '_id', 'parameterId') ?? str(raw?.data, 'id', '_id', 'parameterId');
2192
+ if (!id) {
2193
+ const m = raw?.message;
2194
+ if (Array.isArray(m) && m.length > 0)
2195
+ id = str(m[0], 'id', '_id', 'parameterId');
2196
+ else if (m && typeof m === 'object')
2197
+ id = str(m, 'id', '_id', 'parameterId');
2198
+ }
2199
+ if (!id)
2200
+ throw new Error(`getOrCreateParameter: no id in response: ${JSON.stringify(raw)}`);
2201
+ return Number.parseInt(id, 10);
2202
+ }
2203
+ // ---------------------------------------------------------------------- Suites (write) [Step 4]
2204
+ async createSuite(s) {
2205
+ // Java stubs this out ("create_suite is not supported yet on SimplifyQA"). Mirror that.
2206
+ if (this.hop())
2207
+ return this.requireHop('create_suite').createSuite(s);
2208
+ throw new Error('create_suite is not supported yet on SimplifyQA — create the suite in the SimplifyQA UI, ' +
2209
+ 'then add test cases via suite_add_testcases.');
2210
+ }
2211
+ /** Fetch raw suite, apply add/remove/reorder ops in memory, then PUT the whole doc back with a
2212
+ * STRICT allow-listed payload (the SQA update validator rejects unknown keys on both suite-level
2213
+ * and testCases[] entries). */
2214
+ async updateSuiteMembers(id, ops) {
2215
+ if (!Array.isArray(ops))
2216
+ throw new Error('ops must be an array');
2217
+ const raw = await this.fetchRawSuite(id);
2218
+ if (Object.keys(raw).length === 0)
2219
+ throw new Error(`suite ${id} not found`);
2220
+ const currentRows = Array.isArray(raw['testCases']) ? raw['testCases'] : [];
2221
+ const members = currentRows.map(normalizeSuiteEntry);
2222
+ for (const op of ops) {
2223
+ const tcNum = numericId(String(op.tcId));
2224
+ switch (op.op) {
2225
+ case 'add': {
2226
+ const already = members.some((m) => String(m['testcaseId']) === tcNum);
2227
+ if (!already)
2228
+ members.push(await this.buildSuiteEntryFromTestcase(tcNum));
2229
+ break;
2230
+ }
2231
+ case 'remove': {
2232
+ for (let i = members.length - 1; i >= 0; i--) {
2233
+ if (String(members[i]['testcaseId']) === tcNum)
2234
+ members.splice(i, 1);
2235
+ }
2236
+ break;
2237
+ }
2238
+ case 'reorder': {
2239
+ const newOrder = op.order ?? -1;
2240
+ const idx = members.findIndex((m) => String(m['testcaseId']) === tcNum);
2241
+ if (idx < 0 || newOrder < 1 || newOrder > members.length)
2242
+ break;
2243
+ const [moved] = members.splice(idx, 1);
2244
+ if (moved)
2245
+ members.splice(newOrder - 1, 0, moved);
2246
+ break;
2247
+ }
2248
+ default: throw new Error(`suite op '${op.op}' not supported (add|remove|reorder)`);
2249
+ }
2250
+ }
2251
+ // Renumber `seq` contiguously from 1 after any mutation.
2252
+ members.forEach((m, i) => { m['seq'] = i + 1; });
2253
+ const payload = {};
2254
+ for (const k of SUITE_UPDATE_ALLOWED_KEYS) {
2255
+ if (raw[k] !== undefined)
2256
+ payload[k] = raw[k];
2257
+ }
2258
+ payload['tcChanged'] = true;
2259
+ payload['testCases'] = members;
2260
+ await this.http.putMultipart(`/tm/suites/${numericId(id)}`, 'suiteObj', payload);
2261
+ return this.getSuite(id);
2262
+ }
2263
+ /** Soft-delete via `isDelete:true`. `isDelete:false` restores. */
2264
+ async deleteSuite(id) {
2265
+ const projectId = this.requireProjectId();
2266
+ await this.http.patchJson('/tm/suites', {
2267
+ ids: [Number.parseInt(numericId(id), 10)],
2268
+ projectId,
2269
+ isDelete: true,
2270
+ });
2271
+ }
2272
+ async fetchRawSuite(id) {
2273
+ const raw = await this.http.getJson(`/tm/suites/${numericId(id)}`);
2274
+ let doc = raw?.data ?? raw;
2275
+ if (Array.isArray(doc))
2276
+ doc = doc.length > 0 ? doc[0] : {};
2277
+ return doc && typeof doc === 'object' ? doc : {};
2278
+ }
2279
+ /** Assemble the strict allow-listed suite entry for an ADD op. Required fields per the SQA
2280
+ * validator: testcaseId, code, seq (set by the caller), version, versionId, tcType. */
2281
+ async buildSuiteEntryFromTestcase(tcNum) {
2282
+ const raw = await this.fetchRawTestcase(tcNum);
2283
+ if (Object.keys(raw).length === 0)
2284
+ throw new Error(`test case ${tcNum} not found (cannot add to suite)`);
2285
+ const code = str(raw, 'code', 'tcCode') ?? `TC_${tcNum}`;
2286
+ const tcType = str(raw, 'tcType') ?? 'WEB';
2287
+ let versionName = str(raw, 'versionName', 'selectedVersionName');
2288
+ let versionId = str(raw, 'versionId', 'selectedVersionId');
2289
+ if ((!versionName || !versionId) && Array.isArray(raw?.versions) && raw.versions.length > 0) {
2290
+ const v = raw.versions[0];
2291
+ versionName ?? (versionName = str(v, 'name'));
2292
+ versionId ?? (versionId = str(v, 'id', '_id'));
2293
+ }
2294
+ return {
2295
+ testcaseId: Number.parseInt(tcNum, 10),
2296
+ code,
2297
+ tcType,
2298
+ ...(raw?.projectId !== undefined ? { projectId: raw.projectId } : {}),
2299
+ version: versionName ?? 'v1.0',
2300
+ versionId: versionId ?? '',
2301
+ executionPlatform: { LOCAL: {}, REMOTE: {}, CLOUD: {} },
2302
+ };
2303
+ }
2304
+ async resolveObjectTemplateId(technology) {
2305
+ if (!this.objectTemplateCache) {
2306
+ this.objectTemplateCache = (async () => {
2307
+ const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
2308
+ const raw = await this.http.postJson('/tm/objectTemplate/search', body);
2309
+ const rows = pickArray(raw, 'data', 'content', 'objectTemplates');
2310
+ const map = new Map();
2311
+ for (const r of rows) {
2312
+ const name = str(r, 'name');
2313
+ const id = str(r, 'id', '_id');
2314
+ if (name && id)
2315
+ map.set(name.toUpperCase(), Number.parseInt(id, 10));
2316
+ }
2317
+ return map;
2318
+ })().catch((e) => {
2319
+ this.objectTemplateCache = null; // failure isn't memoised — a retry actually retries
2320
+ throw e;
2321
+ });
2322
+ }
2323
+ const map = await this.objectTemplateCache;
2324
+ const id = map.get(technology.toUpperCase());
2325
+ if (id == null) {
2326
+ throw new Error(`Unknown object type/technology "${technology}"` +
2327
+ (map.size ? ` — known: ${[...map.keys()].join(', ')}.` : ' — no object templates found in this project.'));
2328
+ }
2329
+ return id;
2330
+ }
2331
+ /** `POST /tm/object/search` — objects visible in the current project. `q.query` free-text searches
2332
+ * `disname` — the UI's "Name" column (confirmed against the real sqa-ui Object Repository grid;
2333
+ * `description` is a separate, optional field and is NOT what users mean by an object's name). */
2334
+ async searchObjects(q) {
2335
+ const projectId = this.requireProjectId();
2336
+ const fields = [
2337
+ { column: 'deleted', value: false },
2338
+ { column: 'projectId', value: projectId },
2339
+ ];
2340
+ if (q.query)
2341
+ fields.push({ column: 'disname', value: q.query, regEx: true });
2342
+ const body = this.searchBody(fields, { startIndex: q.offset ?? 0, limit: q.limit ?? 100 });
2343
+ const raw = await this.http.postJson('/tm/object/search', body);
2344
+ const rows = pickArray(raw, 'data', 'content', 'objects');
2345
+ return rows.map(toObject);
2346
+ }
2347
+ /**
2348
+ * Create a new object — `POST /tm/object`, plain JSON (NOT multipart, unlike testcase/suite writes;
2349
+ * confirmed against the real sqa-ui ObjectService.saveNewObject). `o.objectType` is REQUIRED: it
2350
+ * resolves to the object's `objecttemplateId` — structural, and (per updateObject's contract) not
2351
+ * editable after creation. `moduleId` auto-resolves to the project's "Default Module" (matching the
2352
+ * real UI's `object.component.ts` `openForm()`, which pre-selects it for every new object) — best
2353
+ * effort, never blocks creation if no such module exists or the lookup fails.
2354
+ */
2355
+ async createObject(o) {
2356
+ if (!o.objectType) {
2357
+ throw new Error('createObject: objectType is required (e.g. "WEB", "ANDROID") — it selects the object template ' +
2358
+ 'and cannot be changed after the object is created.');
2359
+ }
2360
+ const projectId = this.requireProjectId();
2361
+ const objecttemplateId = await this.resolveObjectTemplateId(o.objectType);
2362
+ const moduleId = await this.resolveDefaultModuleId();
2363
+ const body = {
2364
+ disname: o.name,
2365
+ objecttemplateId,
2366
+ parameterId: [],
2367
+ projectId,
2368
+ technology: o.objectType,
2369
+ ...(moduleId != null ? { moduleId } : {}),
2370
+ attributes: o.locators.map((l) => ({ name: l.name, value: l.value, ...(l.unique ? { unique: true } : {}) })),
2371
+ };
2372
+ const raw = await this.http.postJson('/tm/object', body);
2373
+ const doc = (raw?.data && typeof raw.data === 'object' ? raw.data : raw) ?? {};
2374
+ return toObject(doc);
2375
+ }
2376
+ /** The current project's module literally named "Default Module" (case-insensitive) — mirrors the
2377
+ * real sqa-ui `object.component.ts` `openForm()` auto-selection. Best-effort: returns undefined
2378
+ * (never throws) when no such module exists or the lookup fails, so object creation still proceeds
2379
+ * without a module rather than being blocked by this convenience. */
2380
+ async resolveDefaultModuleId() {
2381
+ try {
2382
+ const modules = await this.listModules();
2383
+ const found = modules.find((m) => m.name?.toLowerCase() === 'default module');
2384
+ return found ? Number.parseInt(found.id, 10) : undefined;
2385
+ }
2386
+ catch {
2387
+ return undefined;
2388
+ }
2389
+ }
2390
+ /**
2391
+ * Soft-delete — `PATCH /tm/object` (NOT POST .../delete like testcase/suite; confirmed against the
2392
+ * real sqa-ui ObjectService.deleteObject), body `{ids, projectId, isDelete:true}`.
2393
+ *
2394
+ * DEPENDENCY-CHECKED first: the SAME route with `isDelete:false` is a DRY RUN that returns, per
2395
+ * object, which test cases / functions / workflows still reference it (`tcArr`/`fnArr`/`wfArr`, each
2396
+ * keyed by projectId → an array of referencing ids — mirrors the real UI's
2397
+ * deleteDependencyForObject flow, object.component.html/.ts). If anything still references it, the
2398
+ * delete is refused rather than silently breaking those test cases; only a dependency-free object
2399
+ * gets the real `isDelete:true` call.
2400
+ */
2401
+ async deleteObject(id) {
2402
+ const projectId = this.requireProjectId();
2403
+ const numId = Number.parseInt(numericId(id), 10);
2404
+ const checkRes = await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: false });
2405
+ const row = checkRes?.data?.[0];
2406
+ const countRefs = (m) => m ? Object.values(m).reduce((n, arr) => n + (Array.isArray(arr) ? arr.length : 0), 0) : 0;
2407
+ const tcCount = countRefs(row?.tcArr);
2408
+ const fnCount = countRefs(row?.fnArr);
2409
+ const wfCount = countRefs(row?.wfArr);
2410
+ if (tcCount || fnCount || wfCount) {
2411
+ const parts = [];
2412
+ if (tcCount)
2413
+ parts.push(`${tcCount} test case${tcCount === 1 ? '' : 's'}`);
2414
+ if (fnCount)
2415
+ parts.push(`${fnCount} function${fnCount === 1 ? '' : 's'}`);
2416
+ if (wfCount)
2417
+ parts.push(`${wfCount} workflow${wfCount === 1 ? '' : 's'}`);
2418
+ throw new Error(`Cannot delete object ${id} — still used by ${parts.join(', ')}. Remove those references first ` +
2419
+ '(or review/delete them in the SimplifyQA UI, which shows exactly which ones).');
2420
+ }
2421
+ await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: true });
2422
+ }
2423
+ /**
2424
+ * Update an object — `PUT /tm/object/:id`, plain JSON (NOT multipart, unlike testcase/suite writes;
2425
+ * confirmed against the real sqa-ui ObjectService.update). The backend expects the FULL document
2426
+ * back, so this reads the current one first and echoes every field it doesn't touch
2427
+ * (disname/description/objecttemplateId/parameterId/moduleId/technology/updateVersion — the
2428
+ * optimistic-concurrency token the real form also round-trips).
2429
+ *
2430
+ * `changes.locators` UPSERTS by locator name into the existing attribute list — an entry with a
2431
+ * matching name gets its value/unique updated in place, everything else on the object is left alone,
2432
+ * and a name with no existing match is appended as a new locator.
2433
+ *
2434
+ * `technology` (object type) and `objecttemplateId` are DELIBERATELY never taken from `changes` —
2435
+ * they are always echoed back from the existing doc. Both are structural (they determine which
2436
+ * attribute set is even valid for this object), so editing them here — outside the create flow — is
2437
+ * out of scope; changing them means creating a new object, not "updating" this one.
2438
+ */
2439
+ async updateObject(id, changes) {
2440
+ const numId = numericId(id);
2441
+ const raw = await this.http.getJson(`/tm/object/${numId}`);
2442
+ const doc = (raw?.data && typeof raw.data === 'object' ? raw.data : raw) ?? {};
2443
+ if (!doc || typeof doc !== 'object' || Object.keys(doc).length === 0) {
2444
+ throw new Error(`object ${id} not found`);
2445
+ }
2446
+ const attributes = Array.isArray(doc.attributes)
2447
+ ? doc.attributes.map((a) => ({
2448
+ name: String(a?.name ?? ''),
2449
+ value: String(a?.value ?? ''),
2450
+ ...(a?.unique === true ? { unique: true } : {}),
2451
+ }))
2452
+ : [];
2453
+ for (const loc of changes.locators ?? []) {
2454
+ const idx = attributes.findIndex((a) => a.name === loc.name);
2455
+ const merged = { name: loc.name, value: loc.value, ...(loc.unique !== undefined ? { unique: loc.unique } : {}) };
2456
+ if (idx >= 0)
2457
+ attributes[idx] = { ...attributes[idx], ...merged };
2458
+ else
2459
+ attributes.push(merged);
2460
+ }
2461
+ const body = {
2462
+ disname: changes.name ?? doc.disname,
2463
+ description: doc.description,
2464
+ objecttemplateId: doc.objecttemplateId,
2465
+ parameterId: doc.parameterId ?? [],
2466
+ moduleId: doc.moduleId,
2467
+ technology: doc.technology,
2468
+ attributes,
2469
+ projectId: this.requireProjectId(),
2470
+ updateVersion: doc.updateVersion ?? 0,
2471
+ };
2472
+ const res = await this.http.putJson(`/tm/object/${numId}`, body);
2473
+ const updated = (res?.data && typeof res.data === 'object' ? res.data : res) ?? doc;
2474
+ return toObject(updated);
2475
+ }
2476
+ // ---------------------------------------------------------------------- Actions catalog
2477
+ async actionsCatalog(platform) {
2478
+ return this.requireHop('actions_catalog').actionsCatalog(platform);
2479
+ }
2480
+ // ---------------------------------------------------------------------- Testdata generation [Step 3]
2481
+ /** Fetch the default (template) iteration row for a TC. Fills gaps from the raw TC:
2482
+ * - versionId: falls back through versionId → selectedVersionId → versionsDetails.id → versions[0].id
2483
+ * - executionType (required by the backend Mongo filter): read from raw TC, default AUTOMATION
2484
+ * - environmentType: caller value wins; else /pm/fields/8 first configured env; else 'general' */
2485
+ async getDefaultIteration(q) {
2486
+ const projectId = this.requireProjectId();
2487
+ const tcNum = numericId(q.tcId);
2488
+ let versionId = q.versionId;
2489
+ let execType;
2490
+ if (!versionId || !execType) {
2491
+ const raw = await this.fetchRawTestcase(q.tcId);
2492
+ if (!versionId) {
2493
+ versionId = str(raw, 'versionId', 'selectedVersionId', 'selVersion');
2494
+ if (!versionId && raw?.versionsDetails && typeof raw.versionsDetails === 'object') {
2495
+ versionId = str(raw.versionsDetails, 'id', '_id', 'versionId');
2496
+ }
2497
+ if (!versionId && Array.isArray(raw?.versions) && raw.versions.length > 0) {
2498
+ versionId = str(raw.versions[0], 'id', '_id');
2499
+ }
2500
+ }
2501
+ execType = str(raw, 'executionType');
2502
+ if (!versionId)
2503
+ throw new Error(`test case ${q.tcId} has no versionId — pass one explicitly`);
2504
+ }
2505
+ if (!execType)
2506
+ execType = 'AUTOMATION';
2507
+ const env = await this.resolveEnvironment(projectId, q.environmentType);
2508
+ const path = '/tm/testdata/getDefaultItr' +
2509
+ `?projectId=${projectId}` +
2510
+ `&testcaseId=${tcNum}` +
2511
+ `&versionId=${encodeURIComponent(versionId)}` +
2512
+ `&environmentType=${encodeURIComponent(env)}` +
2513
+ '&collection=TC' +
2514
+ `&executionType=${encodeURIComponent(execType)}`;
2515
+ const raw = await this.http.getJson(path);
2516
+ const paramsMap = raw?.data?.parametersData;
2517
+ const parameters = [];
2518
+ if (paramsMap && typeof paramsMap === 'object' && !Array.isArray(paramsMap)) {
2519
+ for (const [key, value] of Object.entries(paramsMap)) {
2520
+ const pid = Number.parseInt(key, 10);
2521
+ if (!Number.isFinite(pid))
2522
+ continue;
2523
+ // Protected params get masked below — never assign this raw value to `seed` unmasked.
2524
+ const rawValue = String(value?.value ?? '');
2525
+ const meta = await this.fetchParameterMeta(pid);
2526
+ if (!meta)
2527
+ continue; // parameter deleted — skip
2528
+ const seed = meta.protected ? (rawValue ? '*****' : '(empty)') : rawValue;
2529
+ parameters.push({ paramId: pid, name: meta.name, seed, ...(meta.protected ? { protected: true } : {}) });
2530
+ }
2531
+ }
2532
+ return { tcId: q.tcId, versionId: versionId, environmentType: env, parameters };
2533
+ }
2534
+ /** Persist N generated iteration rows. Rebuilds the name → paramId map from a fresh template
2535
+ * fetch — no cache, because a stale id would silently write to the wrong parameter. */
2536
+ async saveGeneratedIterations(payload) {
2537
+ if (!payload.rows || payload.rows.length === 0)
2538
+ throw new Error('rows[] required and non-empty');
2539
+ if (!payload.versionId)
2540
+ throw new Error('versionId required — call get_default_iteration first');
2541
+ const projectId = this.requireProjectId();
2542
+ const tcNum = numericId(payload.tcId);
2543
+ const env = payload.environmentType || 'general';
2544
+ // Reuse the caller's already-fetched template when supplied — the tc_generate_testdata tool
2545
+ // fetches it just before calling us. Skipping the re-fetch avoids a duplicate /getDefaultItr +
2546
+ // N /tm/parameter/{id} round trips per save.
2547
+ const tplParams = payload.parameters ?? (await this.getDefaultIteration({
2548
+ tcId: payload.tcId,
2549
+ versionId: payload.versionId,
2550
+ environmentType: env,
2551
+ })).parameters;
2552
+ const nameToId = new Map();
2553
+ for (const p of tplParams)
2554
+ nameToId.set(p.name, p.paramId);
2555
+ const formulas = payload.formulas ?? {};
2556
+ const formulaNames = Object.keys(formulas);
2557
+ // Per-row: evaluate the row's formula columns concurrently. The compute endpoint mints RANDOM
2558
+ // values so each call must still happen (no dedup across rows), but within a row the calls are
2559
+ // independent — Promise.all cuts wall time roughly by the formula count.
2560
+ // ponytail: per-row concurrency only. Firing all rows × formulas at once would work but might
2561
+ // stress the external DATA_GENERATION_URL proxy; scale wider only if the row-level speedup is not enough.
2562
+ const iterations = [];
2563
+ for (const row of payload.rows) {
2564
+ const data = [];
2565
+ for (const [k, v] of Object.entries(row)) {
2566
+ if (formulaNames.includes(k))
2567
+ continue; // a formula covers this column — its value wins
2568
+ const pid = nameToId.get(k);
2569
+ if (pid == null)
2570
+ continue; // unknown param name — silently skip
2571
+ data.push({ paramId: pid, value: String(v ?? '') });
2572
+ }
2573
+ const evaluated = await Promise.all(formulaNames.map(async (name) => {
2574
+ const pid = nameToId.get(name);
2575
+ if (pid == null)
2576
+ return null;
2577
+ const { value, expression } = await this.evaluateFormula(formulas[name]);
2578
+ return { paramId: pid, value, formulaValue: expression };
2579
+ }));
2580
+ for (const cell of evaluated)
2581
+ if (cell)
2582
+ data.push(cell);
2583
+ iterations.push({ isChecked: true, data });
2584
+ }
2585
+ const body = {
2586
+ projectId,
2587
+ data: [
2588
+ {
2589
+ type: 'TC',
2590
+ id: Number.parseInt(tcNum, 10),
2591
+ versionId: payload.versionId,
2592
+ environmentType: env,
2593
+ projectId,
2594
+ iterations,
2595
+ },
2596
+ ],
2597
+ };
2598
+ await this.http.postJson('/tm/testdata/saveItrForDataGen', body);
2599
+ return { tcId: payload.tcId, inserted: iterations.length };
2600
+ }
2601
+ /** Environment key: caller wins; else fetch the project's configured envs and pick the first;
2602
+ * else 'general' (matches the SQA UI fallback). */
2603
+ async resolveEnvironment(projectId, requested) {
2604
+ if (requested && requested.length > 0)
2605
+ return requested;
2606
+ try {
2607
+ const envs = await this.http.getJson(`/pm/fields/8?projectId=${projectId}&fieldType=environment`);
2608
+ if (Array.isArray(envs) && envs.length > 0) {
2609
+ const key = str(envs[0], 'key', 'value', 'name');
2610
+ if (key)
2611
+ return key;
2612
+ }
2613
+ }
2614
+ catch { /* fall through */ }
2615
+ return 'general';
2616
+ }
2617
+ /** Parameter name + protected-flag lookup. Null when the parameter is deleted or cross-project. */
2618
+ async fetchParameterMeta(paramId) {
2619
+ let raw;
2620
+ try {
2621
+ raw = await this.http.getJson(`/tm/parameter/${paramId}`);
2622
+ }
2623
+ catch {
2624
+ return null;
2625
+ }
2626
+ let n = raw?.data ?? raw;
2627
+ if (Array.isArray(n))
2628
+ n = n.length > 0 ? n[0] : null;
2629
+ if (!n || typeof n !== 'object')
2630
+ return null;
2631
+ const name = str(n, 'name');
2632
+ if (!name)
2633
+ return null;
2634
+ return { name, protected: n?.protected === true };
2635
+ }
2636
+ // ---------------------------------------------------------------------- Formulas [Step 3b]
2637
+ /** List every formula the current project can use — DEFAULT (customer-wide prefabs) unioned with
2638
+ * CUSTOM entries scoped to the customer or this project. The backend endpoint already returns the
2639
+ * union when called with `projectId`; we just normalise the doc shape and derive `scope` from
2640
+ * (type + projectId). Cached per adapter instance. */
2641
+ async listFormulas() {
2642
+ if (this.formulaCache)
2643
+ return this.formulaCache;
2644
+ const projectId = this.requireProjectId();
2645
+ const pending = (async () => {
2646
+ const raw = await this.http.getJson(`/tm/syncFormulas/fetch?projectId=${projectId}`);
2647
+ const list = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw) ? raw : [];
2648
+ const out = [];
2649
+ for (const doc of list) {
2650
+ const f = normalizeFormula(doc, projectId);
2651
+ if (f)
2652
+ out.push(f);
2653
+ }
2654
+ return out;
2655
+ })().catch((e) => {
2656
+ // On failure, drop the memoised promise so a retry actually retries.
2657
+ this.formulaCache = null;
2658
+ throw e;
2659
+ });
2660
+ this.formulaCache = pending;
2661
+ return pending;
2662
+ }
2663
+ /** Evaluate one formula call and return { value, expression }. Order args by the formula's declared
2664
+ * parameter list so the stored `formulaValue` string is positional (`=NAME("a","b")`) — that's
2665
+ * what the SQA UI writes, and the backend just reads it verbatim on save. */
2666
+ async evaluateFormula(call) {
2667
+ const all = await this.listFormulas();
2668
+ // Both sides carry parens: the backend stores DEFAULT names as "FIRST_NAME()", and models copy the
2669
+ // SIGNATURE format ("FIRST_NAME(country name,sex,min,max)"). Strip on both, compare case-insensitively.
2670
+ const needle = bareFormulaName(call.name).toUpperCase();
2671
+ const meta = all.find((f) => bareFormulaName(f.name).toUpperCase() === needle);
2672
+ if (!meta) {
2673
+ throw new Error(`Unknown formula "${call.name}" — call tc_list_formulas to see what is available.`);
2674
+ }
2675
+ const fname = bareFormulaName(meta.name);
2676
+ // Arg keys arrive with trailing colons/whitespace ("country name:", " sex ") from both the model AND
2677
+ // the formula's own declared keyName. Normalise both sides before binding.
2678
+ const argKey = (k) => k.replace(/:$/, '').trim();
2679
+ const args = {};
2680
+ for (const [k, v] of Object.entries(call.args ?? {}))
2681
+ args[argKey(k)] = v;
2682
+ const ordered = meta.parameters.map((p) => args[argKey(p.keyName)] ?? '');
2683
+ // /tm/customFormula takes its OWN positional query keys, not the formula's display keyNames.
2684
+ const qs = new URLSearchParams({ formulaName: fname });
2685
+ const apiKeys = FORMULA_ARG_KEYS[fname.toUpperCase()] ?? ['value'];
2686
+ ordered.forEach((v, i) => { if (v !== '' && apiKeys[i])
2687
+ qs.append(apiKeys[i], v); });
2688
+ // ponytail: the compute endpoint's response envelope varies between formula types
2689
+ // (`{data:{value}}` for person-info, plain string for a few); coerce defensively.
2690
+ const resp = await this.http.getJson(`/tm/customFormula?${qs.toString()}`);
2691
+ const value = String(resp?.data?.value ?? resp?.value ?? resp?.data ?? resp ?? '');
2692
+ const quoted = ordered.map((a) => `"${String(a).replace(/"/g, '\\"')}"`).join(',');
2693
+ const expression = `=${fname}(${quoted})`;
2694
+ return { value, expression };
2695
+ }
2696
+ // ---------------------------------------------------------------------- Run status [Step 6]
2697
+ /** Real status of a run — reads step-level results direct from SQA REST because the desktop agent's
2698
+ * get_run only returns queue state (often stale). start/stop still hop for v1. */
2699
+ async getRun(runId) {
2700
+ const projectId = this.requireProjectId();
2701
+ const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
2702
+ // GET /exec/report/{runId}/{projectId} — the run rows the /reports page reads.
2703
+ const runs = await this.http.getJson(`/exec/report/${runId}/${projectId}`);
2704
+ if (!Array.isArray(runs) || runs.length === 0) {
2705
+ return { runId, state: 'queued' };
2706
+ }
2707
+ // Multi-TC runs return several rows; pick the first FAILED so tc_heal has a failing step to diagnose.
2708
+ let chosen = runs[0];
2709
+ for (const r of runs) {
2710
+ if (typeof r?.status === 'string' && r.status.toUpperCase() === 'FAILED') {
2711
+ chosen = r;
2712
+ break;
2713
+ }
2714
+ }
2715
+ const executionRunId = str(chosen, 'id', '_id') ?? '';
2716
+ const parentId = Number(chosen?.executionItemId ?? 0);
2717
+ const stepsResp = await this.http.postJson('/reports/execution/getstepResult', {
2718
+ executionId: Number.parseInt(numericId(runId), 10),
2719
+ executionRunId,
2720
+ parentId,
2721
+ projectId,
2722
+ });
2723
+ const rawSteps = Array.isArray(stepsResp?.data) ? stepsResp.data : [];
2724
+ const stepResults = rawSteps.map((s) => {
2725
+ const step = {
2726
+ n: typeof s?.seq === 'number' ? s.seq : 0,
2727
+ description: String(s?.description ?? ''),
2728
+ status: mapStepStatus(str(s, 'status') ?? ''),
2729
+ };
2730
+ const exc = str(s, 'exception');
2731
+ if (exc && exc.length > 0) {
2732
+ step.error = exc;
2733
+ }
2734
+ else {
2735
+ // SQA leaves `exception` null even on FAILED. The real cause sits in ERROR log lines
2736
+ // (e.g. "No Such Element Exception: ..."). Send ALL ERROR lines joined — a trailing
2737
+ // "JS disabled" line would otherwise mask the real NoSuchElement above it.
2738
+ const logs = s?.executionLogData?.executionLogs;
2739
+ if (Array.isArray(logs) && logs.length > 0) {
2740
+ const errorLines = [];
2741
+ for (const l of logs) {
2742
+ const t = String(l ?? '');
2743
+ if (t.includes('ERROR'))
2744
+ errorLines.push(t);
2745
+ }
2746
+ if (errorLines.length > 0)
2747
+ step.error = errorLines.join('\n');
2748
+ else
2749
+ step.error = String(logs[logs.length - 1] ?? '');
2750
+ }
2751
+ }
2752
+ return step;
2753
+ });
2754
+ return {
2755
+ runId,
2756
+ state: mapRunState(str(chosen, 'status') ?? ''),
2757
+ stepResults,
2758
+ reportUrl: `${base}/reports/execution/${runId}`,
2759
+ };
2760
+ }
2761
+ /** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
2762
+ * is paged (default 100) and API-key scoping can hide the selected project, which left
2763
+ * callers with an empty projectName. */
2764
+ async lookupProjectName(projectId) {
2765
+ try {
2766
+ const body = this.searchBody([{ column: 'id', value: projectId }, { column: 'deleted', value: false }], { selectFields: ['id', 'name'], limit: 1 });
2767
+ const raw = await this.http.postJson('/pm/project/search', body);
2768
+ const rows = pickArray(raw, 'data', 'content', 'projects');
2769
+ return str(rows[0], 'name', 'projectName') ?? '';
2770
+ }
2771
+ catch {
2772
+ return '';
2773
+ }
2774
+ }
2775
+ // --- Run dispatch (direct REST) --------------------------------------------
2776
+ // Trigger, stop, and local-agent roster all speak the SQA gateway directly.
2777
+ // The desktop agent still runs the actual TC — backend RPUSHes to Redis and the
2778
+ // agent's poll loop picks it up — but the KICKOFF event no longer hops through
2779
+ // /mcp:4040. Spec: common-mcp/plan/sqa-execution-trigger.md. Cloud runners and
2780
+ // SUITE runs are not yet ported and still hop (or error clearly).
2781
+ async startRun(target, opts) {
2782
+ const projectId = this.requireProjectId();
2783
+ if (!target.tcId) {
2784
+ if (target.suiteId) {
2785
+ throw new Error('startRun: SUITE runs are not yet ported to direct REST — set QA_MCP_URL so common-mcp ' +
2786
+ 'can hop to the SimplifyQA agent for suite dispatch, or run a single TC.');
2787
+ }
2788
+ throw new Error('startRun: target.tcId (or target.suiteId) is required.');
2789
+ }
2790
+ const tcId = target.tcId;
2791
+ const tcNum = Number.parseInt(numericId(tcId), 10);
2792
+ if (!Number.isFinite(tcNum))
2793
+ throw new Error(`startRun: could not resolve numeric id for ${tcId}.`);
2794
+ const mode = (opts.mode ?? 'local').toUpperCase();
2795
+ const rawTc = await this.fetchRawTestcase(tcId);
2796
+ if (!rawTc || Object.keys(rawTc).length === 0) {
2797
+ throw new Error(`startRun: test case ${tcId} not found.`);
2798
+ }
2799
+ // Version resolution — same fallback chain buildTestcasePayload/getDefaultIteration use.
2800
+ // Caller override (opts.versionId) wins; the name is looked up in the TC's versions[] so the
2801
+ // exec payload's versionName matches the picked versionId.
2802
+ let versionName;
2803
+ let versionId;
2804
+ if (opts.versionId) {
2805
+ versionId = opts.versionId;
2806
+ if (Array.isArray(rawTc?.versions)) {
2807
+ const picked = rawTc.versions.find((v) => str(v, 'id', '_id') === opts.versionId);
2808
+ if (picked)
2809
+ versionName = str(picked, 'name');
2810
+ }
2811
+ }
2812
+ versionName ?? (versionName = str(rawTc, 'versionName', 'selectedVersionName'));
2813
+ versionId ?? (versionId = str(rawTc, 'versionId', 'selectedVersionId'));
2814
+ if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
2815
+ versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
2816
+ versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
2817
+ }
2818
+ if ((!versionName || !versionId) && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
2819
+ const v = rawTc.versions[0];
2820
+ versionName ?? (versionName = str(v, 'name'));
2821
+ versionId ?? (versionId = str(v, 'id', '_id'));
2822
+ }
2823
+ versionName ?? (versionName = 'v1.0');
2824
+ versionId ?? (versionId = '');
2825
+ // ponytail: was `this.info()` — 5 round-trips (list_projects + 4 counts) for one field, and
2826
+ // returned projectName="" when the current project wasn't in the first 100-row page or was
2827
+ // filtered by API-key scope, which the /exec/execution endpoint rejects with HTTP 400.
2828
+ const projectName = await this.lookupProjectName(projectId);
2829
+ if (!projectName) {
2830
+ throw new Error(`startRun: could not resolve project name for projectId=${projectId}. ` +
2831
+ `The token may not have read access to this project.`);
2832
+ }
2833
+ const env = await this.resolveEnvironment(projectId, opts.environment);
2834
+ const tcType = String(str(rawTc, 'tcType') ?? 'WEB').toUpperCase();
2835
+ const executionType = String(str(rawTc, 'executionType') ?? 'AUTOMATION').toUpperCase();
2836
+ const code = str(rawTc, 'code') ?? '';
2837
+ const name = str(rawTc, 'name', 'title') ?? '';
2838
+ const description = str(rawTc, 'description') ?? '';
2839
+ const prerequisite = str(rawTc, 'prerequisite', 'preconditions') ?? '';
2840
+ const moduleIdNum = typeof rawTc?.moduleId === 'number' ? rawTc.moduleId : 0;
2841
+ const userstoryIdNum = typeof rawTc?.userstoryId === 'number' ? rawTc.userstoryId : 0;
2842
+ const tags = Array.isArray(rawTc?.tags) ? rawTc.tags : [];
2843
+ // Best-effort module name — targeted id lookup (not a full listModules scan, which now caps
2844
+ // at the default 100). Story name deliberately left empty; adds a per-run round-trip for a
2845
+ // cosmetic field. ponytail: enrich if a caller ever complains.
2846
+ let moduleName = '';
2847
+ if (moduleIdNum > 0) {
2848
+ try {
2849
+ const body = this.searchBody([{ column: 'id', value: moduleIdNum }, { column: 'deleted', value: false }], { selectFields: ['id', 'name'], limit: 1 });
2850
+ const raw = await this.http.postJson('/pm/module/search', body);
2851
+ const rows = pickArray(raw, 'data', 'content', 'modules');
2852
+ moduleName = str(rows[0], 'name') ?? '';
2853
+ }
2854
+ catch { /* leave empty */ }
2855
+ }
2856
+ const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId) : null;
2857
+ const platform = this.buildRunPlatform(tcType, mode, opts.browser, opts.deviceId);
2858
+ // UI uses `Math.random().toString(26).substr(5,6)` — plan doc confirms any string works.
2859
+ const stepId = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
2860
+ const body = {
2861
+ projectId,
2862
+ agentId,
2863
+ environmentType: env,
2864
+ executionStyle: (opts.strategy ?? 'serial').toUpperCase(),
2865
+ executionOS: this.detectExecutionOS(),
2866
+ executionTypeId: tcNum,
2867
+ executionTypeName: name,
2868
+ executionTypeCode: code,
2869
+ mailersList: {},
2870
+ cloudType: mode === 'CLOUD' ? 'VM' : 'NONE',
2871
+ mode,
2872
+ executionCategory: 'EXECUTION',
2873
+ releaseId: opts.releaseId ? Number.parseInt(opts.releaseId, 10) || 0 : 0,
2874
+ iterationId: opts.sprintId ? Number.parseInt(opts.sprintId, 10) || 0 : 0,
2875
+ executionTime: '',
2876
+ status: 'INPROGRESS',
2877
+ iterationsSelected: [],
2878
+ result: '',
2879
+ globalConfiguration: {},
2880
+ isRecordingEnabled: false,
2881
+ offset: -(new Date().getTimezoneOffset()),
2882
+ type: 'TESTCASE',
2883
+ executionType,
2884
+ executionItems: [{
2885
+ id: tcNum,
2886
+ name,
2887
+ description,
2888
+ versionId,
2889
+ versionName,
2890
+ seq: 1,
2891
+ stepId,
2892
+ stepCount: 0,
2893
+ technologyType: tcType,
2894
+ type: 'TC',
2895
+ platform,
2896
+ projectId,
2897
+ projectName,
2898
+ configuration: {},
2899
+ iterationsSelected: [],
2900
+ moduleId: moduleIdNum,
2901
+ userstoryId: userstoryIdNum,
2902
+ tags,
2903
+ code,
2904
+ prerequisite,
2905
+ moduleName,
2906
+ userstoryName: '',
2907
+ }],
2908
+ };
2909
+ const resp = await this.http.postJson('/exec/execution', body);
2910
+ const d = resp?.data ?? resp;
2911
+ const runId = str(d, 'id', 'executionId', '_id') ??
2912
+ (typeof d?.id === 'number' ? String(d.id) : undefined) ??
2913
+ (typeof d?.executionId === 'number' ? String(d.executionId) : undefined);
2914
+ if (!runId)
2915
+ throw new Error('startRun: backend accepted trigger but returned no run id.');
2916
+ return { runId };
2917
+ }
2918
+ async stopRun(runId) {
2919
+ const projectId = this.requireProjectId();
2920
+ const executionId = Number.parseInt(numericId(runId), 10);
2921
+ if (!Number.isFinite(executionId))
2922
+ throw new Error(`stopRun: invalid runId ${runId}.`);
2923
+ await this.http.postJson('/exec/execution/stopExecution', {
2924
+ projectId,
2925
+ executionId,
2926
+ killType: 'STOP_EXECUTION',
2927
+ });
2928
+ }
2929
+ async listCloudRunners() {
2930
+ const projectId = this.requireProjectId();
2931
+ const body = this.searchBody([{ column: 'projectId', value: projectId }], { limit: 1000 });
2932
+ const raw = await this.http.postJson('/pm/machineResources/search', body);
2933
+ const rows = pickArray(raw, 'data', 'content');
2934
+ return rows.map((n) => {
2935
+ const id = str(n, 'id', '_id') ?? '';
2936
+ const status = str(n, 'status') ?? '';
2937
+ const parentId = str(n, 'parentId');
2938
+ const type = str(n, 'type');
2939
+ const os = [parentId ? `CM-${parentId}` : null, type].filter(Boolean).join(' · ') || undefined;
2940
+ return {
2941
+ id,
2942
+ name: str(n, 'displayName', 'name') ?? id,
2943
+ online: status === 'IDLE',
2944
+ ...(status ? { status } : {}),
2945
+ ...(os ? { os } : {}),
2946
+ };
2947
+ });
2948
+ }
2949
+ async listLocalAgents() {
2950
+ // ponytail: no projectId filter — machine_registration.projectId is never written by
2951
+ // test-management/services/machine_registration.service.js (create only stamps
2952
+ // machineName/systemName/macAddress/ipAddress/platform/customerId). Filtering by projectId
2953
+ // strips every legacy row. customerId scoping is still applied server-side from the JWT.
2954
+ const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
2955
+ const raw = await this.http.postJson('/tm/machineRegistration/search', body);
2956
+ const rows = pickArray(raw, 'data', 'content', 'agents', 'machines');
2957
+ // ponytail: hostname override, same trick sqa-ui uses at
2958
+ // sqa-ui/.../machine-registration.component.ts:169. The DB `active` flag is only
2959
+ // written to `true` on first registration (fire-and-forget in executions/services/
2960
+ // executionQueue.service.js:49) and back to `false` by a 7s-stale cron. Every other poll
2961
+ // is a no-op, so a still-polling agent frequently shows `active:false`. When the row's
2962
+ // machineName matches THIS box's hostname, MCP is running on the same machine as the
2963
+ // agent — trust that over the stale DB flag. Upgrade path: if the backend adds a live
2964
+ // Redis-presence endpoint (ZSCORE SQAAGENTTRACKER), consult that here instead.
2965
+ const hostFull = os.hostname().toLowerCase();
2966
+ // Bare hostname: strip trailing .local/.lan/.localdomain AND take the first label. Handles
2967
+ // both directions of "short vs FQDN" mismatch — the agent may register as `Pratiks-MBP`
2968
+ // while os.hostname() returns `pratiks-mbp.local`, or vice-versa.
2969
+ const hostBare = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
2970
+ return rows.map((n) => {
2971
+ const id = str(n, 'id', '_id', 'agentId') ?? '';
2972
+ const agentName = str(n, 'machineName', 'name', 'host');
2973
+ const owner = str(n, 'owner', 'userName', 'createdBy');
2974
+ const hostAddr = str(n, 'macAddress', 'hostAddress');
2975
+ const dbActive = typeof n?.active === 'boolean' ? n.active : false;
2976
+ const nameLc = agentName?.toLowerCase() ?? '';
2977
+ const isMyBox = nameLc.length > 0 && hostBare.length > 0 && nameLc.includes(hostBare);
2978
+ return {
2979
+ id,
2980
+ ...(agentName ? { name: agentName } : {}),
2981
+ online: dbActive || isMyBox,
2982
+ ...(owner ? { owner } : {}),
2983
+ ...(hostAddr ? { host: hostAddr } : {}),
2984
+ };
2985
+ });
2986
+ }
2987
+ /**
2988
+ * Pick THE ACTING USER'S OWN active agent — never row [0] of a shared-tenant roster (which
2989
+ * would silently dispatch a local run to a teammate's box, and the run would sit queued until
2990
+ * TTL because it lands in a Redis key that no local agent on the caller's machine polls).
2991
+ *
2992
+ * SQA's `machineRegistration` row carries no owner_user_id column (unlike walnut, which matches
2993
+ * the JWT `id` claim to `owner_user_id`), so we identify "your" agent by hostname: common-mcp
2994
+ * runs on the caller's own box in the typical MCP setup, and SQA `machineName` is either the
2995
+ * bare OS hostname or a bracketed display form (`USER.NAME [ Hostname.local ]`) — both contain
2996
+ * `os.hostname()`. Filter → active only → hostname substring match → return the single hit.
2997
+ * Zero/many matches refuse honestly; a silent guess would just repeat the exact bug we're fixing.
2998
+ *
2999
+ * ponytail: hostname heuristic. Upgrade to owner-user-id match if the backend adds that column
3000
+ * to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
3001
+ */
3002
+ async resolveLocalAgentId(preferred) {
3003
+ if (preferred && preferred.length > 0)
3004
+ return preferred;
3005
+ const agents = await this.listLocalAgents();
3006
+ if (agents.length === 0) {
3007
+ throw new Error('startRun (LOCAL): no local agent registered. Register a machine in the SimplifyQA UI ' +
3008
+ 'first, or pass mode: "cloud" if a cloud runner is provisioned.');
3009
+ }
3010
+ const active = agents.filter((a) => a.online);
3011
+ if (active.length === 0) {
3012
+ throw new Error('startRun (LOCAL): no local agent is currently active (polling). Launch your SimplifyQA ' +
3013
+ 'desktop agent and retry — or pass `agentId` explicitly if you know the credential id.');
3014
+ }
3015
+ const hostFull = os.hostname().toLowerCase();
3016
+ // Same short-vs-FQDN handling as listLocalAgents — see the ponytail note there.
3017
+ const host = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
3018
+ const mine = active.filter((a) => host.length > 0 && (a.name ?? '').toLowerCase().includes(host));
3019
+ if (mine.length === 1)
3020
+ return mine[0].id;
3021
+ const listing = (arr) => arr.map((a) => ` - ${a.name ?? a.id} \`${a.id}\``).join('\n');
3022
+ if (mine.length > 1) {
3023
+ throw new Error(`startRun (LOCAL): multiple active agents match this machine (${host}). Pass \`agentId\` ` +
3024
+ `to pick one:\n${listing(mine)}`);
3025
+ }
3026
+ throw new Error(`startRun (LOCAL): could not auto-pick your local agent — no active agent's name matches ` +
3027
+ `this hostname (${host}). Pass \`agentId\` explicitly. Active agents:\n${listing(active)}`);
3028
+ }
3029
+ detectExecutionOS() {
3030
+ if (process.platform === 'win32')
3031
+ return 'WINDOWS';
3032
+ if (process.platform === 'darwin')
3033
+ return 'MAC';
3034
+ return 'LINUX';
3035
+ }
3036
+ buildRunPlatform(tcType, mode, browser, deviceId) {
3037
+ const t = tcType.toUpperCase();
3038
+ if (t === 'WEB') {
3039
+ if (mode === 'CLOUD') {
3040
+ const key = `${(browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
3041
+ return [{ name: key, id: key, technologyType: 'WEB', type: 'NONE' }];
3042
+ }
3043
+ const b = (browser ?? 'CHROME').toUpperCase();
3044
+ return [{ name: b, id: b, technologyType: 'WEB', type: 'NONE' }];
3045
+ }
3046
+ if (t === 'ANDROID' || t === 'IOS') {
3047
+ // Executor's AndroidSessionFactory.buildLocal calls requireDeviceId — no default. Fail
3048
+ // loud so the caller sees the fix (list devices, pick one) instead of a queued/error hang.
3049
+ if (!deviceId) {
3050
+ throw new Error(`${t} test cases need \`deviceId\`. Call android_list_devices to see the available ` +
3051
+ `devices, then re-run with \`deviceId\` set to the adb serial (LOCAL) or marketplace ` +
3052
+ `device name (CLOUD).`);
3053
+ }
3054
+ // ponytail: CLOUD path still ships `cloudDeviceType: undefined` — the executor needs it
3055
+ // (BROWSER_STACK / SAUCE_LABS / LAMBDA_INTEGRATION / SQA_DEVICE_FARM) to know which farm
3056
+ // to route to. Deferred with the marketplace port; LOCAL runs unaffected.
3057
+ return [{
3058
+ name: deviceId, id: deviceId, technologyType: t,
3059
+ type: mode === 'CLOUD' ? 'CLOUD_DEVICE' : 'NONE',
3060
+ }];
3061
+ }
3062
+ return [{ name: t, id: t, technologyType: t, type: 'NONE' }];
3063
+ }
3064
+ // --- Environments + datasets -------------------------------------------
3065
+ /** Direct REST — same endpoint `resolveEnvironment` uses. Works without an agent hop.
3066
+ * `key` is what testdata iterations store as `environmentType`; `id` is the uuid used by run dispatch. */
3067
+ async listEnvironments() {
3068
+ const projectId = this.requireProjectId();
3069
+ // Let fetch failures propagate rather than reading as "zero environments".
3070
+ const raw = await this.http.getJson(`/pm/fields/8?projectId=${projectId}&fieldType=environment`);
3071
+ const arr = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : [];
3072
+ const out = [];
3073
+ for (const e of arr) {
3074
+ if (!e || typeof e !== 'object' || e.deleted)
3075
+ continue;
3076
+ const key = str(e, 'key', 'value') ?? '';
3077
+ const name = str(e, 'name', 'label') ?? key;
3078
+ const id = str(e, 'id', '_id') ?? key;
3079
+ if (!key && !name)
3080
+ continue;
3081
+ out.push({ id, name, key, ...(key === 'general' ? { isDefault: true } : {}) });
3082
+ }
3083
+ return out;
3084
+ }
3085
+ async listDatasets(tcId) {
3086
+ const h = this.hop();
3087
+ return h?.listDatasets ? h.listDatasets(tcId) : [];
3088
+ }
3089
+ // --- Journal (recording seed lives on the agent's FS) ------------------
3090
+ async readJournal(tcId) {
3091
+ return this.requireHop('read_journal').readJournal(tcId);
3092
+ }
3093
+ async editJournal(tcId, ops) {
3094
+ return this.requireHop('edit_journal').editJournal(tcId, ops);
3095
+ }
3096
+ }
3097
+ //# sourceMappingURL=adapter.js.map