common-qa-mcp 0.2.0 → 0.3.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 (86) 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 +61 -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 +109 -0
  9. package/dist/adapters/http.js.map +1 -0
  10. package/dist/adapters/remoteMcp/adapter.d.ts +10 -1
  11. package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
  12. package/dist/adapters/remoteMcp/adapter.js +26 -3
  13. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  14. package/dist/adapters/simplifyqa/adapter.d.ts +154 -0
  15. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -0
  16. package/dist/adapters/simplifyqa/adapter.js +1150 -0
  17. package/dist/adapters/simplifyqa/adapter.js.map +1 -0
  18. package/dist/adapters/simplifyqa/mapper.d.ts +63 -0
  19. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -0
  20. package/dist/adapters/simplifyqa/mapper.js +409 -0
  21. package/dist/adapters/simplifyqa/mapper.js.map +1 -0
  22. package/dist/adapters/types.d.ts +48 -1
  23. package/dist/adapters/types.d.ts.map +1 -1
  24. package/dist/adapters/walnut/adapter.d.ts +231 -0
  25. package/dist/adapters/walnut/adapter.d.ts.map +1 -0
  26. package/dist/adapters/walnut/adapter.js +1083 -0
  27. package/dist/adapters/walnut/adapter.js.map +1 -0
  28. package/dist/adapters/walnut/mapper.d.ts +29 -9
  29. package/dist/adapters/walnut/mapper.d.ts.map +1 -1
  30. package/dist/adapters/walnut/mapper.js +136 -24
  31. package/dist/adapters/walnut/mapper.js.map +1 -1
  32. package/dist/config.d.ts +28 -14
  33. package/dist/config.d.ts.map +1 -1
  34. package/dist/config.js +37 -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 +36 -15
  39. package/dist/connection.js.map +1 -1
  40. package/dist/domain/types.d.ts +195 -1
  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 +29 -12
  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 +25 -1
  55. package/dist/server.js.map +1 -1
  56. package/dist/tools/connection.tools.js +5 -5
  57. package/dist/tools/connection.tools.js.map +1 -1
  58. package/dist/tools/discovery.tools.d.ts.map +1 -1
  59. package/dist/tools/discovery.tools.js +93 -1
  60. package/dist/tools/discovery.tools.js.map +1 -1
  61. package/dist/tools/formulas.tools.d.ts +7 -0
  62. package/dist/tools/formulas.tools.d.ts.map +1 -0
  63. package/dist/tools/formulas.tools.js +56 -0
  64. package/dist/tools/formulas.tools.js.map +1 -0
  65. package/dist/tools/heal.tools.d.ts +17 -0
  66. package/dist/tools/heal.tools.d.ts.map +1 -0
  67. package/dist/tools/heal.tools.js +76 -0
  68. package/dist/tools/heal.tools.js.map +1 -0
  69. package/dist/tools/recording.tools.d.ts.map +1 -1
  70. package/dist/tools/recording.tools.js +28 -7
  71. package/dist/tools/recording.tools.js.map +1 -1
  72. package/dist/tools/run.tools.d.ts.map +1 -1
  73. package/dist/tools/run.tools.js +378 -37
  74. package/dist/tools/run.tools.js.map +1 -1
  75. package/dist/tools/storySync.tools.d.ts +66 -0
  76. package/dist/tools/storySync.tools.d.ts.map +1 -0
  77. package/dist/tools/storySync.tools.js +213 -0
  78. package/dist/tools/storySync.tools.js.map +1 -0
  79. package/dist/tools/testcase.tools.d.ts.map +1 -1
  80. package/dist/tools/testcase.tools.js +17 -2
  81. package/dist/tools/testcase.tools.js.map +1 -1
  82. package/dist/tools/testdata.tools.d.ts +7 -0
  83. package/dist/tools/testdata.tools.d.ts.map +1 -0
  84. package/dist/tools/testdata.tools.js +161 -0
  85. package/dist/tools/testdata.tools.js.map +1 -0
  86. package/package.json +1 -1
@@ -0,0 +1,1150 @@
1
+ import os from 'node:os';
2
+ import { makeClient } from '../http.js';
3
+ import { RemoteMcpAdapter } from '../remoteMcp/adapter.js';
4
+ import { applyCollectionSteps, buildTestcasePayload, extractParamNames, looksSensitive, normalizeSuiteEntry, numericId, paramDefault, pickArray, str, SUITE_UPDATE_ALLOWED_KEYS, toModuleSummary, toObject, toReleaseSummary, toSavedRef, toSprintSummary, toStory, toStorySummary, toSuite, toSuiteSummary, toTcSummary, toTestCase, } from './mapper.js';
5
+ const PROJECT_URL_SEGMENT = {
6
+ testcase: 'testcase',
7
+ suite: 'suite',
8
+ run: 'reports/execution',
9
+ };
10
+ /** SQA run-row status → neutral RunStatus.state. */
11
+ function mapRunState(s) {
12
+ switch (s.toUpperCase()) {
13
+ case 'PASSED': return 'passed';
14
+ case 'FAILED': return 'failed';
15
+ case 'STOPPED': return 'stopped';
16
+ case 'RUNNING': return 'running';
17
+ case 'QUEUED': return 'queued';
18
+ default: return 'queued';
19
+ }
20
+ }
21
+ /** Formula names round-trip with parens on ("FIRST_NAME()") from the backend and sometimes with a full
22
+ * signature ("FIRST_NAME(country,sex)") from a caller. Reduce both to the bare identifier. */
23
+ function bareFormulaName(s) {
24
+ return s.replace(/\s*\(.*\)\s*$/, '').trim();
25
+ }
26
+ /** Query keys /tm/customFormula expects per formula, positionally — they are NOT the formula's declared
27
+ * keyNames. Mirrors the SQA UI (wfTestdataView.ts). Anything absent takes at most one arg as `value`.
28
+ * ponytail: table, not per-formula handlers — extend a row when a new multi-arg formula shows up. */
29
+ const FORMULA_ARG_KEYS = {
30
+ FIRST_NAME: ['country', 'sex', 'min_len', 'max_len'],
31
+ LAST_NAME: ['country', 'sex', 'min_len', 'max_len'],
32
+ FULL_NAME: ['country', 'sex', 'min_len', 'max_len'],
33
+ MIDDLE_NAME: ['country', 'sex', 'min_len', 'max_len'],
34
+ PREFIX: ['sex'],
35
+ DATE_OF_BIRTH: ['pattern', 'min_age', 'max_age'],
36
+ };
37
+ /** Normalise a formula document from `/tm/syncFormulas/fetch` into the neutral Formula shape. Returns
38
+ * null when the doc is missing a name — a nameless formula cannot be called. `scope` is inferred:
39
+ * DEFAULT → 'default'; CUSTOM with a matching projectId → 'project'; CUSTOM otherwise → 'customer'. */
40
+ function normalizeFormula(doc, projectId) {
41
+ if (!doc || typeof doc !== 'object')
42
+ return null;
43
+ const name = str(doc, 'name');
44
+ if (!name)
45
+ return null;
46
+ const type = str(doc, 'type') === 'CUSTOM' ? 'CUSTOM' : 'DEFAULT';
47
+ const docPid = Number(doc.projectId ?? 0);
48
+ const scope = type === 'DEFAULT' ? 'default' : docPid && docPid === projectId ? 'project' : 'customer';
49
+ const desc = (doc.description && typeof doc.description === 'object') ? doc.description : {};
50
+ const params = Array.isArray(desc.parameters)
51
+ ? desc.parameters
52
+ .map((p) => (p && typeof p === 'object' && typeof p.keyName === 'string')
53
+ ? { keyName: p.keyName, description: str(p, 'value', 'description') ?? undefined, dataType: str(p, 'dataType') ?? undefined }
54
+ : null)
55
+ .filter((p) => p !== null)
56
+ : [];
57
+ return {
58
+ name,
59
+ uniqueId: str(doc, 'uniqueId') ?? name,
60
+ type,
61
+ scope,
62
+ signature: str(desc, 'name') ?? `${name}()`,
63
+ details: str(desc, 'details') ?? undefined,
64
+ example: str(desc, 'example') ?? undefined,
65
+ parameters: params,
66
+ };
67
+ }
68
+ /** SQA per-step status → neutral step status. */
69
+ function mapStepStatus(s) {
70
+ switch (s.toUpperCase()) {
71
+ case 'PASSED': return 'pass';
72
+ case 'FAILED': return 'fail';
73
+ case 'SKIPPED': return 'skip';
74
+ case 'STOPPED': return 'stop';
75
+ default: return s.toLowerCase();
76
+ }
77
+ }
78
+ const NEEDS_PROJECT = 'No project selected. Call list_projects to see the available projects, then ' +
79
+ 'qa_switch_project({ projectId }) to choose one — test cases, runs and objects ' +
80
+ 'are all scoped to a project.';
81
+ const HOP_NEEDED_WRITE = 'This SimplifyQA operation is not yet ported to direct REST — set QA_MCP_URL to the SimplifyQA ' +
82
+ 'agent /mcp so common-mcp can hop for it, or open the SimplifyQA UI to perform it there.';
83
+ /**
84
+ * SimplifyQaAdapter — DIRECT-to-backend REST driver for SimplifyQA, mirroring WalnutAdapter's
85
+ * design. Reads (list/get for projects/testcases/suites/stories/modules/releases/sprints/objects)
86
+ * hit the SQA TM backend REST directly via a `QaHttp` bound to `cfg.baseUrl`. Anything not yet
87
+ * ported (writes, testdata, run dispatch, marketplace, and the recording surface) delegates to
88
+ * the LOCAL AGENT's `/mcp` (`cfg.url`) via a lazily-built RemoteMcpAdapter — same "graceful hop"
89
+ * pattern walnut uses for recording. When the hop is not configured, unported ops surface a clear
90
+ * error instead of failing silently.
91
+ *
92
+ * This class deliberately does not `implements ProviderAdapter` (walnut trick): the factory casts
93
+ * it, and tools that call ported methods get direct REST; tools that call unported methods get
94
+ * the hop (or a clear error). Each porting step replaces one more delegation with a direct call.
95
+ */
96
+ export class SimplifyQaAdapter {
97
+ constructor(cfg, transport) {
98
+ this.cfg = cfg;
99
+ this.agentHop = null;
100
+ // Formula list rarely changes during a session; cache the fetch response so the model can call
101
+ // tc_list_formulas without paying a round trip each time. Instance-scoped: switchProject rebuilds
102
+ // the adapter (see connection.ts), which drops this cache with it — no manual invalidation needed.
103
+ // We store the in-flight PROMISE, not the resolved array: several concurrent evaluateFormula calls
104
+ // (from the Promise.all in saveGeneratedIterations) all await the same fetch instead of each firing
105
+ // their own /tm/syncFormulas/fetch before the cache is populated.
106
+ this.formulaCache = null;
107
+ if (!cfg.baseUrl) {
108
+ throw new Error('SimplifyQaAdapter requires a baseUrl — set QA_BASE_URL or use a JWT with a `url` claim.');
109
+ }
110
+ this.http = makeClient({
111
+ baseUrl: cfg.baseUrl,
112
+ headers: () => this.headers(),
113
+ ...transport,
114
+ });
115
+ }
116
+ // Auth headers mirror the Java RequestContext: bearer + x-project-id. `x-project-id` is only
117
+ // spread in when a project is selected — a session can be connected before one is chosen.
118
+ headers() {
119
+ return {
120
+ Authorization: `Bearer ${this.cfg.token}`,
121
+ ...(this.cfg.projectId ? { 'x-project-id': this.cfg.projectId } : {}),
122
+ 'Content-Type': 'application/json',
123
+ };
124
+ }
125
+ requireProjectId() {
126
+ if (!this.cfg.projectId)
127
+ throw new Error(NEEDS_PROJECT);
128
+ const n = Number.parseInt(String(this.cfg.projectId).trim(), 10);
129
+ if (!Number.isFinite(n))
130
+ throw new Error(`Invalid projectId ${this.cfg.projectId} — expected a numeric id.`);
131
+ return n;
132
+ }
133
+ // Standard SQA `searchFields` payload builder — the SAME shape every /search endpoint expects.
134
+ // Every list op below uses this instead of hand-writing the same 6-line boilerplate.
135
+ searchBody(fields, opts = {}) {
136
+ const body = {
137
+ searchFields: fields.map((f) => ({
138
+ column: f.column,
139
+ value: f.value,
140
+ ...(f.regEx ? { regEx: true } : {}),
141
+ })),
142
+ startIndex: opts.startIndex ?? 0,
143
+ limit: opts.limit ?? 1000,
144
+ };
145
+ if (opts.selectFields)
146
+ body['selectFields'] = opts.selectFields;
147
+ return body;
148
+ }
149
+ // ---------------------------------------------------------------------- Info + projects
150
+ /** Validates baseUrl + token + (optional) project by running the same list_projects search the
151
+ * Java `infoResult` uses to resolve the project name — no separate /info endpoint on SQA. */
152
+ async info() {
153
+ const projects = await this.listProjects();
154
+ const projectId = this.cfg.projectId ?? '';
155
+ const found = projects.find((p) => p.id === projectId);
156
+ return {
157
+ provider: 'simplifyqa',
158
+ projectId,
159
+ projectName: found?.name ?? '',
160
+ };
161
+ }
162
+ /** `POST /pm/project/search` — projects the token can see. NOT project-scoped. */
163
+ async listProjects() {
164
+ const body = this.searchBody([{ column: 'deleted', value: false }], { selectFields: ['id', 'code', 'name'] });
165
+ const raw = await this.http.postJson('/pm/project/search', body);
166
+ const rows = pickArray(raw, 'data', 'content', 'projects');
167
+ return rows.map((n) => ({
168
+ id: str(n, 'id', '_id', 'projectId') ?? '',
169
+ name: str(n, 'name', 'projectName') ?? '',
170
+ }));
171
+ }
172
+ // ---------------------------------------------------------------------- Test cases (read)
173
+ async listTestCases(q) {
174
+ const projectId = this.requireProjectId();
175
+ const fields = [
176
+ { column: 'deleted', value: false },
177
+ { column: 'projectId', value: projectId },
178
+ ];
179
+ if (q.search)
180
+ fields.push({ column: 'name', value: q.search, regEx: true });
181
+ if (q.status)
182
+ fields.push({ column: 'status', value: q.status });
183
+ const body = this.searchBody(fields, {
184
+ startIndex: q.offset ?? 0,
185
+ limit: q.limit ?? 100,
186
+ selectFields: ['id', 'code', 'name', 'priority', 'status', 'stepCount', 'tcType', 'executionType'],
187
+ });
188
+ const raw = await this.http.postJson('/tm/testcase/search', body);
189
+ const rows = pickArray(raw, 'data', 'content', 'testCases');
190
+ return rows.map(toTcSummary);
191
+ }
192
+ /** Neutral test case, steps included. Accepts numeric id or business code (`TC_123`). */
193
+ async getTestCase(id) {
194
+ const raw = await this.fetchRawTestcase(id);
195
+ if (!raw || Object.keys(raw).length === 0) {
196
+ throw new Error(`test case ${id} not found`);
197
+ }
198
+ const tc = toTestCase(raw);
199
+ // Prefer the steps COLLECTION — that's the source of truth the SQA UI, executor, and save
200
+ // paths use today. Inline steps.manualSteps / steps.automationSteps on the TC doc drift
201
+ // against it. Fall back to inline (already on `tc` from toTestCase) if the collection
202
+ // fetch is empty or throws — legacy TCs that never migrated still surface something.
203
+ try {
204
+ const rows = await this.fetchStepsCollection(id, raw);
205
+ if (rows.length > 0)
206
+ return applyCollectionSteps(tc, rows);
207
+ }
208
+ catch {
209
+ // ignore — inline steps stay
210
+ }
211
+ return tc;
212
+ }
213
+ async fetchRawTestcase(id) {
214
+ const raw = await this.http.getJson(`/tm/testcase/getById/${numericId(id)}`);
215
+ let doc = raw?.data ?? raw;
216
+ if (Array.isArray(doc))
217
+ doc = doc.length > 0 ? doc[0] : {};
218
+ return doc && typeof doc === 'object' ? doc : {};
219
+ }
220
+ async fetchStepsCollection(tcId, rawTc) {
221
+ const projectId = this.requireProjectId();
222
+ const tcNum = numericId(tcId);
223
+ // versionId lives under a few different keys depending on the read model — Java tries all four.
224
+ let versionId = str(rawTc, 'versionId', 'selectedVersionId') ??
225
+ (rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
226
+ ? str(rawTc.versionsDetails, 'id', '_id', 'versionId')
227
+ : undefined);
228
+ if (!versionId && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
229
+ versionId = str(rawTc.versions[0], 'id', '_id');
230
+ }
231
+ if (!versionId)
232
+ return [];
233
+ const path = '/tm/testcase/steps' +
234
+ `?projectId=${projectId}` +
235
+ `&tcfnId=${tcNum}` +
236
+ `&versionId=${encodeURIComponent(versionId)}` +
237
+ '&type=TC';
238
+ const body = await this.http.getJson(path);
239
+ const rows = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
240
+ return rows;
241
+ }
242
+ // ---------------------------------------------------------------------- Suites (read)
243
+ async listSuites() {
244
+ const projectId = this.requireProjectId();
245
+ const body = this.searchBody([
246
+ { column: 'deleted', value: false },
247
+ { column: 'projectId', value: projectId },
248
+ ]);
249
+ const raw = await this.http.postJson('/tm/suites/search', body);
250
+ const rows = pickArray(raw, 'data', 'content', 'suites');
251
+ return rows.map(toSuiteSummary);
252
+ }
253
+ async getSuite(id) {
254
+ const raw = await this.http.getJson(`/tm/suites/${numericId(id)}`);
255
+ let doc = raw?.data ?? raw;
256
+ if (Array.isArray(doc))
257
+ doc = doc.length > 0 ? doc[0] : {};
258
+ return toSuite(doc && typeof doc === 'object' ? doc : {});
259
+ }
260
+ // ---------------------------------------------------------------------- Stories (read)
261
+ async listStories(q) {
262
+ const projectId = this.requireProjectId();
263
+ const fields = [
264
+ { column: 'deleted', value: false },
265
+ { column: 'projectId', value: projectId },
266
+ ];
267
+ if (q.search)
268
+ fields.push({ column: 'name', value: q.search, regEx: true });
269
+ // SQA's userstory FK for sprint is `iterationId` — spec uses the neutral name `sprintId`.
270
+ if (q.sprintId)
271
+ fields.push({ column: 'iterationId', value: Number.parseInt(q.sprintId, 10) });
272
+ if (q.releaseId)
273
+ fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
274
+ const body = this.searchBody(fields, { limit: q.limit ?? 50 });
275
+ const raw = await this.http.postJson('/pm/userstory/search', body);
276
+ const rows = pickArray(raw, 'data', 'content', 'userStories', 'stories');
277
+ return rows.map(toStorySummary);
278
+ }
279
+ async getStory(id) {
280
+ const resolvedId = await this.resolveStoryNumericId(id);
281
+ const raw = await this.http.getJson(`/pm/userstory/${resolvedId}`);
282
+ const doc = Array.isArray(raw)
283
+ ? (raw.length > 0 ? raw[0] : {})
284
+ : (raw?.data && typeof raw.data === 'object' ? raw.data : raw);
285
+ return toStory(doc && typeof doc === 'object' ? doc : {});
286
+ }
287
+ /** A US_XXX business key → the numeric userstoryId via `/pm/userstory/search`. Numeric passes
288
+ * through. SQA's GET-by-id parseInts the path, so a raw key would 404. */
289
+ async resolveStoryNumericId(ref) {
290
+ if (!ref)
291
+ return ref;
292
+ if (/^\d+$/.test(ref))
293
+ return ref;
294
+ const body = this.searchBody([{ column: 'code', value: ref }], { startIndex: 0, limit: 1 });
295
+ const raw = await this.http.postJson('/pm/userstory/search', body);
296
+ const rows = pickArray(raw, 'data', 'content', 'userStories', 'stories');
297
+ if (rows.length === 0)
298
+ throw new Error(`Story not found for business key: ${ref}`);
299
+ return str(rows[0], 'id', '_id', 'userstoryId') ?? ref;
300
+ }
301
+ // ---------------------------------------------------------------------- Modules, releases, sprints
302
+ async listModules() {
303
+ const projectId = this.requireProjectId();
304
+ const body = this.searchBody([
305
+ { column: 'projectId', value: projectId },
306
+ { column: 'deleted', value: false },
307
+ ], { selectFields: ['id', 'code', 'name', 'parentId'], limit: 10000 });
308
+ const raw = await this.http.postJson('/pm/module/search', body);
309
+ const rows = pickArray(raw, 'data', 'content', 'modules');
310
+ return rows.map(toModuleSummary);
311
+ }
312
+ async listReleases(q) {
313
+ const fields = [{ column: 'deleted', value: false }];
314
+ if (q.scope === 'project')
315
+ fields.push({ column: 'projectIds', value: this.requireProjectId() });
316
+ const body = this.searchBody(fields);
317
+ const raw = await this.http.postJson('/pm/release/search', body);
318
+ const rows = pickArray(raw, 'data', 'content', 'releases');
319
+ return rows.map(toReleaseSummary);
320
+ }
321
+ async listSprints(q) {
322
+ const fields = [{ column: 'deleted', value: false }];
323
+ if (q.releaseId)
324
+ fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
325
+ const body = this.searchBody(fields);
326
+ const raw = await this.http.postJson('/pm/sprint/search', body);
327
+ const rows = pickArray(raw, 'data', 'content', 'sprints');
328
+ return rows.map(toSprintSummary);
329
+ }
330
+ // ---------------------------------------------------------------------- Objects (read)
331
+ async getObject(id) {
332
+ const raw = await this.http.getJson(`/tm/object/${numericId(id)}`);
333
+ let doc = raw?.data ?? raw;
334
+ if (Array.isArray(doc))
335
+ doc = doc.length > 0 ? doc[0] : {};
336
+ return toObject(doc && typeof doc === 'object' ? doc : {});
337
+ }
338
+ // ---------------------------------------------------------------------- Deep links
339
+ productUrlFor(entity) {
340
+ const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
341
+ return `${base}/${PROJECT_URL_SEGMENT[entity.kind]}/${entity.id}`;
342
+ }
343
+ // ---------------------------------------------------------------------- Agent hop (recording + not-yet-ported ops)
344
+ // Everything below either delegates to the local agent /mcp (recording surface, and writes/testdata/
345
+ // run dispatch we haven't ported to REST yet) or fails with a clear message when the hop is unset.
346
+ // As each step of the port lands, the corresponding delegate is replaced with a direct REST impl.
347
+ hop() {
348
+ if (!this.cfg.url)
349
+ return null;
350
+ if (!this.agentHop)
351
+ this.agentHop = new RemoteMcpAdapter(this.cfg);
352
+ return this.agentHop;
353
+ }
354
+ requireHop(op) {
355
+ const h = this.hop();
356
+ if (!h)
357
+ throw new Error(`${op}: ${HOP_NEEDED_WRITE}`);
358
+ return h;
359
+ }
360
+ /** Recording tool schemas from the SQA agent — mirrored so the recording passthrough can register
361
+ * them. No agent configured → `[]`, so no recording tools get registered. */
362
+ async listAgentTools() {
363
+ const h = this.hop();
364
+ return h ? h.listAgentTools() : [];
365
+ }
366
+ /** Forward a recording-tool call to the agent /mcp. Same behavior as walnut. */
367
+ async callRaw(name, args) {
368
+ const h = this.hop();
369
+ if (!h) {
370
+ throw new Error("Recording needs a local SimplifyQA agent — set QA_MCP_URL to the agent's /mcp endpoint.");
371
+ }
372
+ return h.callRaw(name, args);
373
+ }
374
+ // ---------------------------------------------------------------------- Test cases (write) [Step 4]
375
+ /** Two-step create: (1) resolve every `${name}` token in the steps to a project-scoped parameter id
376
+ * (get-or-create), (2) POST /tm/testcase multipart with the assembled `testcaseObj`. */
377
+ async createTestCase(tc) {
378
+ const projectId = this.requireProjectId();
379
+ const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
380
+ const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
381
+ const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
382
+ const payload = buildTestcasePayload({
383
+ projectId, moduleId,
384
+ ...(userstoryId != null ? { userstoryId } : {}),
385
+ tc,
386
+ paramIdByName,
387
+ operation: 'CREATE',
388
+ });
389
+ const raw = await this.http.postMultipart('/tm/testcase', 'testcaseObj', payload);
390
+ return toSavedRef(raw, true);
391
+ }
392
+ /** Same shape as create, but with `tcfnObj.id` set and operation:'UPDATE'. */
393
+ async updateTestCase(id, tc) {
394
+ const projectId = this.requireProjectId();
395
+ const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
396
+ const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
397
+ const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
398
+ const payload = buildTestcasePayload({
399
+ projectId, moduleId,
400
+ ...(userstoryId != null ? { userstoryId } : {}),
401
+ existingId: numericId(id),
402
+ tc,
403
+ paramIdByName,
404
+ operation: 'UPDATE',
405
+ });
406
+ // `!` — putMultipart is optional on QaHttp (walnut's wrapper opts out); makeClient always provides it.
407
+ const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
408
+ return toSavedRef(raw, false);
409
+ }
410
+ /** Soft-delete (recoverable via recycle bin). `isDelete:true` = delete; `false` = restore. */
411
+ async deleteTestCase(id) {
412
+ const projectId = this.requireProjectId();
413
+ await this.http.postJson('/tm/testcase/delete', {
414
+ ids: [Number.parseInt(numericId(id), 10)],
415
+ projectId,
416
+ isDelete: true,
417
+ });
418
+ }
419
+ /** Never throw on unresolvable storyRef — the Java code logs and moves on so the save still lands. */
420
+ async resolveStoryNumericIdOrNull(storyRef) {
421
+ try {
422
+ const s = await this.resolveStoryNumericId(storyRef);
423
+ const n = Number.parseInt(s, 10);
424
+ return Number.isFinite(n) ? n : undefined;
425
+ }
426
+ catch {
427
+ return undefined;
428
+ }
429
+ }
430
+ /** Resolve every `${name}` token across every step to a SQA parameter id via get-or-create.
431
+ * Name-uniqueness is project-wide on the backend; get-or-create means every TC using
432
+ * `${appUrl}` links to the same parameter row. */
433
+ async resolveParamIds(projectId, moduleId, tc) {
434
+ const paramIdByName = new Map();
435
+ const steps = Array.isArray(tc.steps) ? tc.steps : [];
436
+ for (const s of steps) {
437
+ const names = extractParamNames(s.description);
438
+ for (const name of names) {
439
+ if (paramIdByName.has(name))
440
+ continue;
441
+ const defaultValue = paramDefault(name, tc.data, s.value);
442
+ const id = await this.getOrCreateParameter(projectId, moduleId, name, defaultValue);
443
+ paramIdByName.set(name, id);
444
+ }
445
+ }
446
+ return paramIdByName;
447
+ }
448
+ /** Case-insensitive project-scoped exact-name lookup. Backend duplicate-name check uses Mongo
449
+ * collation strength:2, so a case-sensitive search here misses params stored under different
450
+ * casing — the next POST then trips "Duplicate Name Error". Returns null when not found. */
451
+ async findParameterId(projectId, name) {
452
+ const body = this.searchBody([
453
+ { column: 'name', value: name },
454
+ { column: 'projectId', value: projectId },
455
+ ], { startIndex: 0, limit: 10 });
456
+ // The `searchFields` shape supports `ignorecase:true` per-field; searchBody doesn't emit it,
457
+ // so add it inline for the name column.
458
+ body['searchFields'][0]['ignorecase'] = true;
459
+ const raw = await this.http.postJson('/tm/parameter/search', body);
460
+ const rows = pickArray(raw, 'data', 'content');
461
+ for (const row of rows) {
462
+ const rowName = str(row, 'name');
463
+ if (rowName === name) {
464
+ const id = str(row, 'id', '_id', 'parameterId');
465
+ if (id)
466
+ return Number.parseInt(id, 10);
467
+ }
468
+ }
469
+ return null;
470
+ }
471
+ async getOrCreateParameter(projectId, moduleId, name, defaultValue) {
472
+ const existing = await this.findParameterId(projectId, name);
473
+ if (existing != null)
474
+ return existing;
475
+ const body = {
476
+ name,
477
+ projectId,
478
+ protected: looksSensitive(name),
479
+ ...(moduleId > 0 ? { moduleId } : {}),
480
+ defaultValue,
481
+ // Match the SQA UI's Add-Parameter defaults so API-created params render identically. Without
482
+ // these the UI shows "Parameter Type*" empty and refuses to save edits.
483
+ type: 'Local',
484
+ parameterType: 'Alphanumeric',
485
+ description: '',
486
+ unique: false,
487
+ concat: '',
488
+ paramAttributes: { minLength: 0, maxLength: 0, prefix: '', suffix: '' },
489
+ };
490
+ let raw;
491
+ try {
492
+ raw = await this.http.postJson('/tm/parameter', body);
493
+ }
494
+ catch (e) {
495
+ // Backend rejects a race-lost duplicate with 500 "Duplicate Name" when the concurrent search
496
+ // missed the just-created row. Recover by re-querying — same name, project-scoped.
497
+ const msg = e instanceof Error ? e.message : String(e);
498
+ if (/Duplicate Name|BADREQUEST/i.test(msg)) {
499
+ const after = await this.findParameterId(projectId, name);
500
+ if (after != null)
501
+ return after;
502
+ }
503
+ throw e;
504
+ }
505
+ // The id lives at various nesting levels depending on which path the backend took (bare doc,
506
+ // `data`, or `message[0]` for a race-lost duplicate). Try all three.
507
+ let id = str(raw, 'id', '_id', 'parameterId') ?? str(raw?.data, 'id', '_id', 'parameterId');
508
+ if (!id) {
509
+ const m = raw?.message;
510
+ if (Array.isArray(m) && m.length > 0)
511
+ id = str(m[0], 'id', '_id', 'parameterId');
512
+ else if (m && typeof m === 'object')
513
+ id = str(m, 'id', '_id', 'parameterId');
514
+ }
515
+ if (!id)
516
+ throw new Error(`getOrCreateParameter: no id in response: ${JSON.stringify(raw)}`);
517
+ return Number.parseInt(id, 10);
518
+ }
519
+ // ---------------------------------------------------------------------- Suites (write) [Step 4]
520
+ async createSuite(s) {
521
+ // Java stubs this out ("create_suite is not supported yet on SimplifyQA"). Mirror that.
522
+ if (this.hop())
523
+ return this.requireHop('create_suite').createSuite(s);
524
+ throw new Error('create_suite is not supported yet on SimplifyQA — create the suite in the SimplifyQA UI, ' +
525
+ 'then add test cases via suite_add_testcases.');
526
+ }
527
+ /** Fetch raw suite, apply add/remove/reorder ops in memory, then PUT the whole doc back with a
528
+ * STRICT allow-listed payload (the SQA update validator rejects unknown keys on both suite-level
529
+ * and testCases[] entries). */
530
+ async updateSuiteMembers(id, ops) {
531
+ if (!Array.isArray(ops))
532
+ throw new Error('ops must be an array');
533
+ const raw = await this.fetchRawSuite(id);
534
+ if (Object.keys(raw).length === 0)
535
+ throw new Error(`suite ${id} not found`);
536
+ const currentRows = Array.isArray(raw['testCases']) ? raw['testCases'] : [];
537
+ const members = currentRows.map(normalizeSuiteEntry);
538
+ for (const op of ops) {
539
+ const tcNum = numericId(String(op.tcId));
540
+ switch (op.op) {
541
+ case 'add': {
542
+ const already = members.some((m) => String(m['testcaseId']) === tcNum);
543
+ if (!already)
544
+ members.push(await this.buildSuiteEntryFromTestcase(tcNum));
545
+ break;
546
+ }
547
+ case 'remove': {
548
+ for (let i = members.length - 1; i >= 0; i--) {
549
+ if (String(members[i]['testcaseId']) === tcNum)
550
+ members.splice(i, 1);
551
+ }
552
+ break;
553
+ }
554
+ case 'reorder': {
555
+ const newOrder = op.order ?? -1;
556
+ const idx = members.findIndex((m) => String(m['testcaseId']) === tcNum);
557
+ if (idx < 0 || newOrder < 1 || newOrder > members.length)
558
+ break;
559
+ const [moved] = members.splice(idx, 1);
560
+ if (moved)
561
+ members.splice(newOrder - 1, 0, moved);
562
+ break;
563
+ }
564
+ default: throw new Error(`suite op '${op.op}' not supported (add|remove|reorder)`);
565
+ }
566
+ }
567
+ // Renumber `seq` contiguously from 1 after any mutation.
568
+ members.forEach((m, i) => { m['seq'] = i + 1; });
569
+ const payload = {};
570
+ for (const k of SUITE_UPDATE_ALLOWED_KEYS) {
571
+ if (raw[k] !== undefined)
572
+ payload[k] = raw[k];
573
+ }
574
+ payload['tcChanged'] = true;
575
+ payload['testCases'] = members;
576
+ await this.http.putMultipart(`/tm/suites/${numericId(id)}`, 'suiteObj', payload);
577
+ return this.getSuite(id);
578
+ }
579
+ /** Soft-delete via `isDelete:true`. `isDelete:false` restores. */
580
+ async deleteSuite(id) {
581
+ const projectId = this.requireProjectId();
582
+ await this.http.patchJson('/tm/suites', {
583
+ ids: [Number.parseInt(numericId(id), 10)],
584
+ projectId,
585
+ isDelete: true,
586
+ });
587
+ }
588
+ async fetchRawSuite(id) {
589
+ const raw = await this.http.getJson(`/tm/suites/${numericId(id)}`);
590
+ let doc = raw?.data ?? raw;
591
+ if (Array.isArray(doc))
592
+ doc = doc.length > 0 ? doc[0] : {};
593
+ return doc && typeof doc === 'object' ? doc : {};
594
+ }
595
+ /** Assemble the strict allow-listed suite entry for an ADD op. Required fields per the SQA
596
+ * validator: testcaseId, code, seq (set by the caller), version, versionId, tcType. */
597
+ async buildSuiteEntryFromTestcase(tcNum) {
598
+ const raw = await this.fetchRawTestcase(tcNum);
599
+ if (Object.keys(raw).length === 0)
600
+ throw new Error(`test case ${tcNum} not found (cannot add to suite)`);
601
+ const code = str(raw, 'code', 'tcCode') ?? `TC_${tcNum}`;
602
+ const tcType = str(raw, 'tcType') ?? 'WEB';
603
+ let versionName = str(raw, 'versionName', 'selectedVersionName');
604
+ let versionId = str(raw, 'versionId', 'selectedVersionId');
605
+ if ((!versionName || !versionId) && Array.isArray(raw?.versions) && raw.versions.length > 0) {
606
+ const v = raw.versions[0];
607
+ versionName ?? (versionName = str(v, 'name'));
608
+ versionId ?? (versionId = str(v, 'id', '_id'));
609
+ }
610
+ return {
611
+ testcaseId: Number.parseInt(tcNum, 10),
612
+ code,
613
+ tcType,
614
+ ...(raw?.projectId !== undefined ? { projectId: raw.projectId } : {}),
615
+ version: versionName ?? 'v1.0',
616
+ versionId: versionId ?? '',
617
+ executionPlatform: { LOCAL: {}, REMOTE: {}, CLOUD: {} },
618
+ };
619
+ }
620
+ // ---------------------------------------------------------------------- Object search + write [Step 4]
621
+ // No exposed common-mcp tool calls these on ProviderAdapter today for SQA — leave on hop.
622
+ async searchObjects(q) { return this.requireHop('search_objects').searchObjects(q); }
623
+ async createObject(o) { return this.requireHop('create_object').createObject(o); }
624
+ // ---------------------------------------------------------------------- Actions catalog
625
+ async actionsCatalog(platform) {
626
+ return this.requireHop('actions_catalog').actionsCatalog(platform);
627
+ }
628
+ // ---------------------------------------------------------------------- Testdata generation [Step 3]
629
+ /** Fetch the default (template) iteration row for a TC. Fills gaps from the raw TC:
630
+ * - versionId: falls back through versionId → selectedVersionId → versionsDetails.id → versions[0].id
631
+ * - executionType (required by the backend Mongo filter): read from raw TC, default AUTOMATION
632
+ * - environmentType: caller value wins; else /pm/fields/8 first configured env; else 'general' */
633
+ async getDefaultIteration(q) {
634
+ const projectId = this.requireProjectId();
635
+ const tcNum = numericId(q.tcId);
636
+ let versionId = q.versionId;
637
+ let execType;
638
+ if (!versionId || !execType) {
639
+ const raw = await this.fetchRawTestcase(q.tcId);
640
+ if (!versionId) {
641
+ versionId = str(raw, 'versionId', 'selectedVersionId', 'selVersion');
642
+ if (!versionId && raw?.versionsDetails && typeof raw.versionsDetails === 'object') {
643
+ versionId = str(raw.versionsDetails, 'id', '_id', 'versionId');
644
+ }
645
+ if (!versionId && Array.isArray(raw?.versions) && raw.versions.length > 0) {
646
+ versionId = str(raw.versions[0], 'id', '_id');
647
+ }
648
+ }
649
+ execType = str(raw, 'executionType');
650
+ if (!versionId)
651
+ throw new Error(`test case ${q.tcId} has no versionId — pass one explicitly`);
652
+ }
653
+ if (!execType)
654
+ execType = 'AUTOMATION';
655
+ const env = await this.resolveEnvironment(projectId, q.environmentType);
656
+ const path = '/tm/testdata/getDefaultItr' +
657
+ `?projectId=${projectId}` +
658
+ `&testcaseId=${tcNum}` +
659
+ `&versionId=${encodeURIComponent(versionId)}` +
660
+ `&environmentType=${encodeURIComponent(env)}` +
661
+ '&collection=TC' +
662
+ `&executionType=${encodeURIComponent(execType)}`;
663
+ const raw = await this.http.getJson(path);
664
+ const paramsMap = raw?.data?.parametersData;
665
+ const parameters = [];
666
+ if (paramsMap && typeof paramsMap === 'object' && !Array.isArray(paramsMap)) {
667
+ for (const [key, value] of Object.entries(paramsMap)) {
668
+ const pid = Number.parseInt(key, 10);
669
+ if (!Number.isFinite(pid))
670
+ continue;
671
+ const seed = String(value?.value ?? '');
672
+ const name = await this.fetchParameterName(pid);
673
+ if (!name)
674
+ continue; // parameter deleted — skip
675
+ parameters.push({ paramId: pid, name, seed });
676
+ }
677
+ }
678
+ return { tcId: q.tcId, versionId: versionId, environmentType: env, parameters };
679
+ }
680
+ /** Persist N generated iteration rows. Rebuilds the name → paramId map from a fresh template
681
+ * fetch — no cache, because a stale id would silently write to the wrong parameter. */
682
+ async saveGeneratedIterations(payload) {
683
+ if (!payload.rows || payload.rows.length === 0)
684
+ throw new Error('rows[] required and non-empty');
685
+ if (!payload.versionId)
686
+ throw new Error('versionId required — call get_default_iteration first');
687
+ const projectId = this.requireProjectId();
688
+ const tcNum = numericId(payload.tcId);
689
+ const env = payload.environmentType || 'general';
690
+ // Reuse the caller's already-fetched template when supplied — the tc_generate_testdata tool
691
+ // fetches it just before calling us. Skipping the re-fetch avoids a duplicate /getDefaultItr +
692
+ // N /tm/parameter/{id} round trips per save.
693
+ const tplParams = payload.parameters ?? (await this.getDefaultIteration({
694
+ tcId: payload.tcId,
695
+ versionId: payload.versionId,
696
+ environmentType: env,
697
+ })).parameters;
698
+ const nameToId = new Map();
699
+ for (const p of tplParams)
700
+ nameToId.set(p.name, p.paramId);
701
+ const formulas = payload.formulas ?? {};
702
+ const formulaNames = Object.keys(formulas);
703
+ // Per-row: evaluate the row's formula columns concurrently. The compute endpoint mints RANDOM
704
+ // values so each call must still happen (no dedup across rows), but within a row the calls are
705
+ // independent — Promise.all cuts wall time roughly by the formula count.
706
+ // ponytail: per-row concurrency only. Firing all rows × formulas at once would work but might
707
+ // stress the external DATA_GENERATION_URL proxy; scale wider only if the row-level speedup is not enough.
708
+ const iterations = [];
709
+ for (const row of payload.rows) {
710
+ const data = [];
711
+ for (const [k, v] of Object.entries(row)) {
712
+ if (formulaNames.includes(k))
713
+ continue; // a formula covers this column — its value wins
714
+ const pid = nameToId.get(k);
715
+ if (pid == null)
716
+ continue; // unknown param name — silently skip
717
+ data.push({ paramId: pid, value: String(v ?? '') });
718
+ }
719
+ const evaluated = await Promise.all(formulaNames.map(async (name) => {
720
+ const pid = nameToId.get(name);
721
+ if (pid == null)
722
+ return null;
723
+ const { value, expression } = await this.evaluateFormula(formulas[name]);
724
+ return { paramId: pid, value, formulaValue: expression };
725
+ }));
726
+ for (const cell of evaluated)
727
+ if (cell)
728
+ data.push(cell);
729
+ iterations.push({ isChecked: true, data });
730
+ }
731
+ const body = {
732
+ projectId,
733
+ data: [
734
+ {
735
+ type: 'TC',
736
+ id: Number.parseInt(tcNum, 10),
737
+ versionId: payload.versionId,
738
+ environmentType: env,
739
+ projectId,
740
+ iterations,
741
+ },
742
+ ],
743
+ };
744
+ await this.http.postJson('/tm/testdata/saveItrForDataGen', body);
745
+ return { tcId: payload.tcId, inserted: iterations.length };
746
+ }
747
+ /** Environment key: caller wins; else fetch the project's configured envs and pick the first;
748
+ * else 'general' (matches the SQA UI fallback). */
749
+ async resolveEnvironment(projectId, requested) {
750
+ if (requested && requested.length > 0)
751
+ return requested;
752
+ try {
753
+ const envs = await this.http.getJson(`/pm/fields/8?projectId=${projectId}&fieldType=environment`);
754
+ if (Array.isArray(envs) && envs.length > 0) {
755
+ const key = str(envs[0], 'key', 'value', 'name');
756
+ if (key)
757
+ return key;
758
+ }
759
+ }
760
+ catch { /* fall through */ }
761
+ return 'general';
762
+ }
763
+ /** Parameter name lookup for paramId → name enrichment. Null when the parameter is deleted or
764
+ * belongs to a different project. */
765
+ async fetchParameterName(paramId) {
766
+ let raw;
767
+ try {
768
+ raw = await this.http.getJson(`/tm/parameter/${paramId}`);
769
+ }
770
+ catch {
771
+ return null;
772
+ }
773
+ let n = raw?.data ?? raw;
774
+ if (Array.isArray(n))
775
+ n = n.length > 0 ? n[0] : null;
776
+ if (!n || typeof n !== 'object')
777
+ return null;
778
+ return str(n, 'name') ?? null;
779
+ }
780
+ // ---------------------------------------------------------------------- Formulas [Step 3b]
781
+ /** List every formula the current project can use — DEFAULT (customer-wide prefabs) unioned with
782
+ * CUSTOM entries scoped to the customer or this project. The backend endpoint already returns the
783
+ * union when called with `projectId`; we just normalise the doc shape and derive `scope` from
784
+ * (type + projectId). Cached per adapter instance. */
785
+ async listFormulas() {
786
+ if (this.formulaCache)
787
+ return this.formulaCache;
788
+ const projectId = this.requireProjectId();
789
+ const pending = (async () => {
790
+ const raw = await this.http.getJson(`/tm/syncFormulas/fetch?projectId=${projectId}`);
791
+ const list = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw) ? raw : [];
792
+ const out = [];
793
+ for (const doc of list) {
794
+ const f = normalizeFormula(doc, projectId);
795
+ if (f)
796
+ out.push(f);
797
+ }
798
+ return out;
799
+ })().catch((e) => {
800
+ // On failure, drop the memoised promise so a retry actually retries.
801
+ this.formulaCache = null;
802
+ throw e;
803
+ });
804
+ this.formulaCache = pending;
805
+ return pending;
806
+ }
807
+ /** Evaluate one formula call and return { value, expression }. Order args by the formula's declared
808
+ * parameter list so the stored `formulaValue` string is positional (`=NAME("a","b")`) — that's
809
+ * what the SQA UI writes, and the backend just reads it verbatim on save. */
810
+ async evaluateFormula(call) {
811
+ const all = await this.listFormulas();
812
+ // Both sides carry parens: the backend stores DEFAULT names as "FIRST_NAME()", and models copy the
813
+ // SIGNATURE format ("FIRST_NAME(country name,sex,min,max)"). Strip on both, compare case-insensitively.
814
+ const needle = bareFormulaName(call.name).toUpperCase();
815
+ const meta = all.find((f) => bareFormulaName(f.name).toUpperCase() === needle);
816
+ if (!meta) {
817
+ throw new Error(`Unknown formula "${call.name}" — call tc_list_formulas to see what is available.`);
818
+ }
819
+ const fname = bareFormulaName(meta.name);
820
+ // Arg keys arrive with trailing colons/whitespace ("country name:", " sex ") from both the model AND
821
+ // the formula's own declared keyName. Normalise both sides before binding.
822
+ const argKey = (k) => k.replace(/:$/, '').trim();
823
+ const args = {};
824
+ for (const [k, v] of Object.entries(call.args ?? {}))
825
+ args[argKey(k)] = v;
826
+ const ordered = meta.parameters.map((p) => args[argKey(p.keyName)] ?? '');
827
+ // /tm/customFormula takes its OWN positional query keys, not the formula's display keyNames.
828
+ const qs = new URLSearchParams({ formulaName: fname });
829
+ const apiKeys = FORMULA_ARG_KEYS[fname.toUpperCase()] ?? ['value'];
830
+ ordered.forEach((v, i) => { if (v !== '' && apiKeys[i])
831
+ qs.append(apiKeys[i], v); });
832
+ // ponytail: the compute endpoint's response envelope varies between formula types
833
+ // (`{data:{value}}` for person-info, plain string for a few); coerce defensively.
834
+ const resp = await this.http.getJson(`/tm/customFormula?${qs.toString()}`);
835
+ const value = String(resp?.data?.value ?? resp?.value ?? resp?.data ?? resp ?? '');
836
+ const quoted = ordered.map((a) => `"${String(a).replace(/"/g, '\\"')}"`).join(',');
837
+ const expression = `=${fname}(${quoted})`;
838
+ return { value, expression };
839
+ }
840
+ // ---------------------------------------------------------------------- Run status [Step 6]
841
+ /** Real status of a run — reads step-level results direct from SQA REST because the desktop agent's
842
+ * get_run only returns queue state (often stale). start/stop still hop for v1. */
843
+ async getRun(runId) {
844
+ const projectId = this.requireProjectId();
845
+ const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
846
+ // GET /exec/report/{runId}/{projectId} — the run rows the /reports page reads.
847
+ const runs = await this.http.getJson(`/exec/report/${runId}/${projectId}`);
848
+ if (!Array.isArray(runs) || runs.length === 0) {
849
+ return { runId, state: 'queued' };
850
+ }
851
+ // Multi-TC runs return several rows; pick the first FAILED so tc_heal has a failing step to diagnose.
852
+ let chosen = runs[0];
853
+ for (const r of runs) {
854
+ if (typeof r?.status === 'string' && r.status.toUpperCase() === 'FAILED') {
855
+ chosen = r;
856
+ break;
857
+ }
858
+ }
859
+ const executionRunId = str(chosen, 'id', '_id') ?? '';
860
+ const parentId = Number(chosen?.executionItemId ?? 0);
861
+ const stepsResp = await this.http.postJson('/reports/execution/getstepResult', {
862
+ executionId: Number.parseInt(numericId(runId), 10),
863
+ executionRunId,
864
+ parentId,
865
+ projectId,
866
+ });
867
+ const rawSteps = Array.isArray(stepsResp?.data) ? stepsResp.data : [];
868
+ const stepResults = rawSteps.map((s) => {
869
+ const step = {
870
+ n: typeof s?.seq === 'number' ? s.seq : 0,
871
+ description: String(s?.description ?? ''),
872
+ status: mapStepStatus(str(s, 'status') ?? ''),
873
+ };
874
+ const exc = str(s, 'exception');
875
+ if (exc && exc.length > 0) {
876
+ step.error = exc;
877
+ }
878
+ else {
879
+ // SQA leaves `exception` null even on FAILED. The real cause sits in ERROR log lines
880
+ // (e.g. "No Such Element Exception: ..."). Send ALL ERROR lines joined — a trailing
881
+ // "JS disabled" line would otherwise mask the real NoSuchElement above it.
882
+ const logs = s?.executionLogData?.executionLogs;
883
+ if (Array.isArray(logs) && logs.length > 0) {
884
+ const errorLines = [];
885
+ for (const l of logs) {
886
+ const t = String(l ?? '');
887
+ if (t.includes('ERROR'))
888
+ errorLines.push(t);
889
+ }
890
+ if (errorLines.length > 0)
891
+ step.error = errorLines.join('\n');
892
+ else
893
+ step.error = String(logs[logs.length - 1] ?? '');
894
+ }
895
+ }
896
+ return step;
897
+ });
898
+ return {
899
+ runId,
900
+ state: mapRunState(str(chosen, 'status') ?? ''),
901
+ stepResults,
902
+ reportUrl: `${base}/reports/execution/${runId}`,
903
+ };
904
+ }
905
+ // --- Run dispatch (direct REST) --------------------------------------------
906
+ // Trigger, stop, and local-agent roster all speak the SQA gateway directly.
907
+ // The desktop agent still runs the actual TC — backend RPUSHes to Redis and the
908
+ // agent's poll loop picks it up — but the KICKOFF event no longer hops through
909
+ // /mcp:4040. Spec: common-mcp/plan/sqa-execution-trigger.md. Cloud runners and
910
+ // SUITE runs are not yet ported and still hop (or error clearly).
911
+ async startRun(target, opts) {
912
+ const projectId = this.requireProjectId();
913
+ if (!target.tcId) {
914
+ if (target.suiteId) {
915
+ throw new Error('startRun: SUITE runs are not yet ported to direct REST — set QA_MCP_URL so common-mcp ' +
916
+ 'can hop to the SimplifyQA agent for suite dispatch, or run a single TC.');
917
+ }
918
+ throw new Error('startRun: target.tcId (or target.suiteId) is required.');
919
+ }
920
+ const tcId = target.tcId;
921
+ const tcNum = Number.parseInt(numericId(tcId), 10);
922
+ if (!Number.isFinite(tcNum))
923
+ throw new Error(`startRun: could not resolve numeric id for ${tcId}.`);
924
+ const mode = (opts.mode ?? 'local').toUpperCase();
925
+ const rawTc = await this.fetchRawTestcase(tcId);
926
+ if (!rawTc || Object.keys(rawTc).length === 0) {
927
+ throw new Error(`startRun: test case ${tcId} not found.`);
928
+ }
929
+ // Version resolution — same fallback chain buildTestcasePayload/getDefaultIteration use.
930
+ let versionName = str(rawTc, 'versionName', 'selectedVersionName');
931
+ let versionId = str(rawTc, 'versionId', 'selectedVersionId');
932
+ if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
933
+ versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
934
+ versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
935
+ }
936
+ if ((!versionName || !versionId) && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
937
+ const v = rawTc.versions[0];
938
+ versionName ?? (versionName = str(v, 'name'));
939
+ versionId ?? (versionId = str(v, 'id', '_id'));
940
+ }
941
+ versionName ?? (versionName = 'v1.0');
942
+ versionId ?? (versionId = '');
943
+ const info = await this.info();
944
+ const env = await this.resolveEnvironment(projectId, opts.environment);
945
+ const tcType = String(str(rawTc, 'tcType') ?? 'WEB').toUpperCase();
946
+ const executionType = String(str(rawTc, 'executionType') ?? 'AUTOMATION').toUpperCase();
947
+ const code = str(rawTc, 'code') ?? '';
948
+ const name = str(rawTc, 'name', 'title') ?? '';
949
+ const description = str(rawTc, 'description') ?? '';
950
+ const prerequisite = str(rawTc, 'prerequisite', 'preconditions') ?? '';
951
+ const moduleIdNum = typeof rawTc?.moduleId === 'number' ? rawTc.moduleId : 0;
952
+ const userstoryIdNum = typeof rawTc?.userstoryId === 'number' ? rawTc.userstoryId : 0;
953
+ const tags = Array.isArray(rawTc?.tags) ? rawTc.tags : [];
954
+ // Best-effort module name — backend doesn't validate it functionally; one /pm/module/search
955
+ // covers every id, so the extra call is cheap. Story name deliberately left empty; adds a
956
+ // per-run round-trip for a cosmetic field. ponytail: enrich if a caller ever complains.
957
+ let moduleName = '';
958
+ if (moduleIdNum > 0) {
959
+ try {
960
+ const modules = await this.listModules();
961
+ moduleName = modules.find((m) => m.id === String(moduleIdNum))?.name ?? '';
962
+ }
963
+ catch { /* leave empty */ }
964
+ }
965
+ const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId) : null;
966
+ const platform = this.buildRunPlatform(tcType, mode, opts.browser, opts.deviceId);
967
+ // UI uses `Math.random().toString(26).substr(5,6)` — plan doc confirms any string works.
968
+ const stepId = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
969
+ const body = {
970
+ projectId,
971
+ agentId,
972
+ environmentType: env,
973
+ executionStyle: (opts.strategy ?? 'serial').toUpperCase(),
974
+ executionOS: this.detectExecutionOS(),
975
+ executionTypeId: tcNum,
976
+ executionTypeName: name,
977
+ executionTypeCode: code,
978
+ mailersList: {},
979
+ cloudType: mode === 'CLOUD' ? 'VM' : 'NONE',
980
+ mode,
981
+ executionCategory: 'EXECUTION',
982
+ releaseId: 0,
983
+ iterationId: 0,
984
+ executionTime: '',
985
+ status: 'INPROGRESS',
986
+ iterationsSelected: [],
987
+ result: '',
988
+ globalConfiguration: {},
989
+ isRecordingEnabled: false,
990
+ offset: -(new Date().getTimezoneOffset()),
991
+ type: 'TESTCASE',
992
+ executionType,
993
+ executionItems: [{
994
+ id: tcNum,
995
+ name,
996
+ description,
997
+ versionId,
998
+ versionName,
999
+ seq: 1,
1000
+ stepId,
1001
+ stepCount: 0,
1002
+ technologyType: tcType,
1003
+ type: 'TC',
1004
+ platform,
1005
+ projectId,
1006
+ projectName: info.projectName,
1007
+ configuration: {},
1008
+ iterationsSelected: [],
1009
+ moduleId: moduleIdNum,
1010
+ userstoryId: userstoryIdNum,
1011
+ tags,
1012
+ code,
1013
+ prerequisite,
1014
+ moduleName,
1015
+ userstoryName: '',
1016
+ }],
1017
+ };
1018
+ const resp = await this.http.postJson('/exec/execution', body);
1019
+ const d = resp?.data ?? resp;
1020
+ const runId = str(d, 'id', 'executionId', '_id') ??
1021
+ (typeof d?.id === 'number' ? String(d.id) : undefined) ??
1022
+ (typeof d?.executionId === 'number' ? String(d.executionId) : undefined);
1023
+ if (!runId)
1024
+ throw new Error('startRun: backend accepted trigger but returned no run id.');
1025
+ return { runId };
1026
+ }
1027
+ async stopRun(runId) {
1028
+ const projectId = this.requireProjectId();
1029
+ const executionId = Number.parseInt(numericId(runId), 10);
1030
+ if (!Number.isFinite(executionId))
1031
+ throw new Error(`stopRun: invalid runId ${runId}.`);
1032
+ await this.http.postJson('/exec/execution/stopExecution', {
1033
+ projectId,
1034
+ executionId,
1035
+ killType: 'STOP_EXECUTION',
1036
+ });
1037
+ }
1038
+ // Cloud runner roster is not yet ported — plan doc only covers LOCAL trigger + stop + agents.
1039
+ async listCloudRunners() { return this.requireHop('list_cloud_runners').listCloudRunners(); }
1040
+ async listLocalAgents() {
1041
+ const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
1042
+ const raw = await this.http.postJson('/tm/machineRegistration/search', body);
1043
+ const rows = pickArray(raw, 'data', 'content', 'agents', 'machines');
1044
+ return rows.map((n) => {
1045
+ const id = str(n, 'id', '_id', 'agentId') ?? '';
1046
+ const agentName = str(n, 'machineName', 'name', 'host');
1047
+ const owner = str(n, 'owner', 'userName', 'createdBy');
1048
+ const host = str(n, 'macAddress', 'hostAddress');
1049
+ // `active` is the real Redis-heartbeat flag on machineRegistration — the executor only picks
1050
+ // up runs whose agent is currently polling. A stale registration with `active: false` will
1051
+ // sit in Redis until TTL and never execute, so surface the real state to the caller.
1052
+ const active = typeof n?.active === 'boolean' ? n.active : false;
1053
+ return {
1054
+ id,
1055
+ ...(agentName ? { name: agentName } : {}),
1056
+ online: active,
1057
+ ...(owner ? { owner } : {}),
1058
+ ...(host ? { host } : {}),
1059
+ };
1060
+ });
1061
+ }
1062
+ /**
1063
+ * Pick THE ACTING USER'S OWN active agent — never row [0] of a shared-tenant roster (which
1064
+ * would silently dispatch a local run to a teammate's box, and the run would sit queued until
1065
+ * TTL because it lands in a Redis key that no local agent on the caller's machine polls).
1066
+ *
1067
+ * SQA's `machineRegistration` row carries no owner_user_id column (unlike walnut, which matches
1068
+ * the JWT `id` claim to `owner_user_id`), so we identify "your" agent by hostname: common-mcp
1069
+ * runs on the caller's own box in the typical MCP setup, and SQA `machineName` is either the
1070
+ * bare OS hostname or a bracketed display form (`USER.NAME [ Hostname.local ]`) — both contain
1071
+ * `os.hostname()`. Filter → active only → hostname substring match → return the single hit.
1072
+ * Zero/many matches refuse honestly; a silent guess would just repeat the exact bug we're fixing.
1073
+ *
1074
+ * ponytail: hostname heuristic. Upgrade to owner-user-id match if the backend adds that column
1075
+ * to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
1076
+ */
1077
+ async resolveLocalAgentId(preferred) {
1078
+ if (preferred && preferred.length > 0)
1079
+ return preferred;
1080
+ const agents = await this.listLocalAgents();
1081
+ if (agents.length === 0) {
1082
+ throw new Error('startRun (LOCAL): no local agent registered. Register a machine in the SimplifyQA UI ' +
1083
+ 'first, or pass mode: "cloud" if a cloud runner is provisioned.');
1084
+ }
1085
+ const active = agents.filter((a) => a.online);
1086
+ if (active.length === 0) {
1087
+ throw new Error('startRun (LOCAL): no local agent is currently active (polling). Launch your SimplifyQA ' +
1088
+ 'desktop agent and retry — or pass `agentId` explicitly if you know the credential id.');
1089
+ }
1090
+ const host = os.hostname().toLowerCase();
1091
+ const mine = active.filter((a) => (a.name ?? '').toLowerCase().includes(host));
1092
+ if (mine.length === 1)
1093
+ return mine[0].id;
1094
+ const listing = (arr) => arr.map((a) => ` - ${a.name ?? a.id} \`${a.id}\``).join('\n');
1095
+ if (mine.length > 1) {
1096
+ throw new Error(`startRun (LOCAL): multiple active agents match this machine (${host}). Pass \`agentId\` ` +
1097
+ `to pick one:\n${listing(mine)}`);
1098
+ }
1099
+ throw new Error(`startRun (LOCAL): could not auto-pick your local agent — no active agent's name matches ` +
1100
+ `this hostname (${host}). Pass \`agentId\` explicitly. Active agents:\n${listing(active)}`);
1101
+ }
1102
+ detectExecutionOS() {
1103
+ if (process.platform === 'win32')
1104
+ return 'WINDOWS';
1105
+ if (process.platform === 'darwin')
1106
+ return 'MAC';
1107
+ return 'LINUX';
1108
+ }
1109
+ buildRunPlatform(tcType, mode, browser, deviceId) {
1110
+ const t = tcType.toUpperCase();
1111
+ if (t === 'WEB') {
1112
+ const b = (browser ?? 'CHROME').toUpperCase();
1113
+ return [{ name: b, id: b, technologyType: 'WEB', type: 'NONE' }];
1114
+ }
1115
+ if (t === 'ANDROID' || t === 'IOS') {
1116
+ // Executor's AndroidSessionFactory.buildLocal calls requireDeviceId — no default. Fail
1117
+ // loud so the caller sees the fix (list devices, pick one) instead of a queued/error hang.
1118
+ if (!deviceId) {
1119
+ throw new Error(`${t} test cases need \`deviceId\`. Call android_list_devices to see the available ` +
1120
+ `devices, then re-run with \`deviceId\` set to the adb serial (LOCAL) or marketplace ` +
1121
+ `device name (CLOUD).`);
1122
+ }
1123
+ // ponytail: CLOUD path still ships `cloudDeviceType: undefined` — the executor needs it
1124
+ // (BROWSER_STACK / SAUCE_LABS / LAMBDA_INTEGRATION / SQA_DEVICE_FARM) to know which farm
1125
+ // to route to. Deferred with the marketplace port; LOCAL runs unaffected.
1126
+ return [{
1127
+ name: deviceId, id: deviceId, technologyType: t,
1128
+ type: mode === 'CLOUD' ? 'CLOUD_DEVICE' : 'NONE',
1129
+ }];
1130
+ }
1131
+ return [{ name: t, id: t, technologyType: t, type: 'NONE' }];
1132
+ }
1133
+ // --- Environments + datasets ------------------------------------------- (fine to hop for now)
1134
+ async listEnvironments() {
1135
+ const h = this.hop();
1136
+ return h ? h.listEnvironments() : [];
1137
+ }
1138
+ async listDatasets(tcId) {
1139
+ const h = this.hop();
1140
+ return h?.listDatasets ? h.listDatasets(tcId) : [];
1141
+ }
1142
+ // --- Journal (recording seed lives on the agent's FS) ------------------
1143
+ async readJournal(tcId) {
1144
+ return this.requireHop('read_journal').readJournal(tcId);
1145
+ }
1146
+ async editJournal(tcId, ops) {
1147
+ return this.requireHop('edit_journal').editJournal(tcId, ops);
1148
+ }
1149
+ }
1150
+ //# sourceMappingURL=adapter.js.map