common-qa-mcp 0.10.1 → 0.12.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 (42) hide show
  1. package/dist/adapters/http.d.ts +4 -0
  2. package/dist/adapters/http.d.ts.map +1 -1
  3. package/dist/adapters/http.js +19 -0
  4. package/dist/adapters/http.js.map +1 -1
  5. package/dist/adapters/remoteMcp/adapter.js +1 -1
  6. package/dist/adapters/simplifyqa/adapter.d.ts +60 -2
  7. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  8. package/dist/adapters/simplifyqa/adapter.js +427 -47
  9. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  10. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  11. package/dist/adapters/simplifyqa/mapper.js +4 -0
  12. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  13. package/dist/adapters/types.d.ts +35 -1
  14. package/dist/adapters/types.d.ts.map +1 -1
  15. package/dist/domain/types.d.ts +38 -0
  16. package/dist/domain/types.d.ts.map +1 -1
  17. package/dist/initAgents.d.ts.map +1 -1
  18. package/dist/initAgents.js +32 -17
  19. package/dist/initAgents.js.map +1 -1
  20. package/dist/server.d.ts.map +1 -1
  21. package/dist/server.js +18 -12
  22. package/dist/server.js.map +1 -1
  23. package/dist/tools/backlog.tools.d.ts.map +1 -1
  24. package/dist/tools/backlog.tools.js +96 -0
  25. package/dist/tools/backlog.tools.js.map +1 -1
  26. package/dist/tools/discovery.tools.d.ts.map +1 -1
  27. package/dist/tools/discovery.tools.js +160 -10
  28. package/dist/tools/discovery.tools.js.map +1 -1
  29. package/dist/tools/recording.tools.js +74 -16
  30. package/dist/tools/recording.tools.js.map +1 -1
  31. package/dist/tools/report.tools.d.ts +12 -0
  32. package/dist/tools/report.tools.d.ts.map +1 -0
  33. package/dist/tools/report.tools.js +104 -0
  34. package/dist/tools/report.tools.js.map +1 -0
  35. package/dist/tools/testcase.tools.d.ts.map +1 -1
  36. package/dist/tools/testcase.tools.js +167 -12
  37. package/dist/tools/testcase.tools.js.map +1 -1
  38. package/dist/tools/xlsx.d.ts +8 -0
  39. package/dist/tools/xlsx.d.ts.map +1 -0
  40. package/dist/tools/xlsx.js +113 -0
  41. package/dist/tools/xlsx.js.map +1 -0
  42. package/package.json +1 -1
@@ -1,4 +1,6 @@
1
1
  import os from 'node:os';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
2
4
  import { makeClient } from '../http.js';
3
5
  import { RemoteMcpAdapter } from '../remoteMcp/adapter.js';
4
6
  import { applyCollectionSteps, buildTestcasePayload, deriveTcType, extractApiParamNames, extractParamNames, fromSqaStep, looksSensitive, normalizeSuiteEntry, numericId, paramDefault, pickArray, str, SUITE_UPDATE_ALLOWED_KEYS, TECH_BY_STEPTYPE, toDefectSummary, toEpic, toEpicSummary, toFeature, toFeatureSummary, toFunction, toFunctionSummary, toModuleSaved, toModuleSummary, toObject, toImageUrl, toReleaseSummary, toSavedRef, toSprintSummary, toStory, toStorySummary, toSuite, toSuiteSummary, toTaskSummary, toTcSummary, toTestCase, toVersionEntries, withTotal, } from './mapper.js';
@@ -196,8 +198,6 @@ function versionDepIsEmpty(dep) {
196
198
  const NEEDS_PROJECT = 'No project selected. Call list_projects to see the available projects, then ' +
197
199
  'qa_switch_project({ projectId }) to choose one — test cases, runs and objects ' +
198
200
  'are all scoped to a project.';
199
- const HOP_NEEDED_WRITE = 'This SimplifyQA operation is not yet ported to direct REST — set QA_MCP_URL to the SimplifyQA ' +
200
- 'agent /mcp so common-mcp can hop for it, or open the SimplifyQA UI to perform it there.';
201
201
  /**
202
202
  * SimplifyQaAdapter — DIRECT-to-backend REST driver for SimplifyQA, mirroring WalnutAdapter's
203
203
  * design. Reads (list/get for projects/testcases/suites/stories/modules/releases/sprints/objects)
@@ -295,6 +295,11 @@ export class SimplifyQaAdapter {
295
295
  };
296
296
  if (opts.selectFields)
297
297
  body['selectFields'] = opts.selectFields;
298
+ // test-management's search treats `startIndex` as a PAGE number (skip = limit × startIndex) unless
299
+ // `skipStartIndex` is set, which makes it a raw row offset. Opt-in per call so other endpoints keep
300
+ // their existing behaviour.
301
+ if (opts.rawOffset)
302
+ body['skipStartIndex'] = 1;
298
303
  return body;
299
304
  }
300
305
  // ---------------------------------------------------------------------- Info + projects
@@ -446,6 +451,7 @@ export class SimplifyQaAdapter {
446
451
  const body = this.searchBody(fields, {
447
452
  startIndex: q.offset ?? 0,
448
453
  limit: q.limit ?? 100,
454
+ rawOffset: true,
449
455
  selectFields: ['id', 'code', 'name', 'priority', 'status', 'stepCount', 'tcType', 'executionType', 'obsolete'],
450
456
  });
451
457
  const raw = await this.http.postJson('/tm/testcase/search', body);
@@ -1208,6 +1214,146 @@ export class SimplifyQaAdapter {
1208
1214
  }
1209
1215
  await this.http.putJson(`/pm/module/bulkupdate/${id}`, { parentId: Number.parseInt(targetId, 10), isRoot: false });
1210
1216
  }
1217
+ /** PDF reports for one execution — the same `/reports/pdfV2/*` endpoints the SimplifyQA UI downloads
1218
+ * from, so content and format match the UI. The path values (`type`/`executionType`/`executionStyle`)
1219
+ * come from the execution doc, exactly as the UI takes them from its `displayDataExecution`.
1220
+ * Protected-parameter masking is done by the report service itself — nothing is re-rendered here. */
1221
+ async downloadReports(input) {
1222
+ const projectId = this.requireProjectId();
1223
+ const execNum = Number.parseInt(numericId(input.runId), 10);
1224
+ if (!Number.isFinite(execNum))
1225
+ throw new Error(`"${input.runId}" is not a valid execution id.`);
1226
+ if (!this.http.getBinary)
1227
+ throw new Error('This connection cannot download binary reports.');
1228
+ const execDoc = await this.fetchExecDoc(execNum, projectId);
1229
+ if (!execDoc)
1230
+ throw new Error(`Execution ${input.runId} was not found in this project.`);
1231
+ const state = mapRunState(str(execDoc, 'status') ?? '');
1232
+ if (state === 'queued' || state === 'running') {
1233
+ throw new Error(`Execution ${input.runId} is still ${state} — reports can only be generated once it has completed.`);
1234
+ }
1235
+ const isSuite = String(execDoc.type ?? '').toUpperCase() === 'SUITE';
1236
+ const type = str(execDoc, 'type') ?? '';
1237
+ const executionType = str(execDoc, 'executionType') ?? '';
1238
+ const executionStyle = str(execDoc, 'executionStyle') ?? '';
1239
+ const code = str(execDoc, 'executionTypeCode') ?? (isSuite ? 'SUITE' : 'TC');
1240
+ const tz = encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
1241
+ const enc = encodeURIComponent;
1242
+ const safe = (v) => v.replace(/[^\w.\-()\s]+/g, '_').trim() || 'report';
1243
+ await mkdir(input.outDir, { recursive: true });
1244
+ const files = [];
1245
+ const notes = [];
1246
+ const fetchPdf = async (path, what) => {
1247
+ let res;
1248
+ try {
1249
+ res = await this.http.getBinary(path);
1250
+ }
1251
+ catch (e) {
1252
+ throw new Error(`${what}: report generation failed — ${e instanceof Error ? e.message : String(e)}`);
1253
+ }
1254
+ const head = Buffer.from(res.data.subarray(0, 5)).toString('latin1');
1255
+ if (!head.startsWith('%PDF')) {
1256
+ throw new Error(`${what}: the report service did not return a PDF (${res.contentType || 'unknown type'}): ${Buffer.from(res.data.subarray(0, 200)).toString('utf8')}`);
1257
+ }
1258
+ return res.data;
1259
+ };
1260
+ const save = async (kind, label, fileName, data) => {
1261
+ const path = join(input.outDir, safe(fileName));
1262
+ await writeFile(path, data);
1263
+ files.push({ kind, label, path, bytes: data.byteLength });
1264
+ };
1265
+ const needTcValues = () => {
1266
+ if (!type || !executionType || !executionStyle) {
1267
+ throw new Error(`Execution ${input.runId} is missing type/executionType/executionStyle (got "${type}"/"${executionType}"/"${executionStyle}") — cannot build the report request.`);
1268
+ }
1269
+ };
1270
+ const tcUrl = (kind, runUuid) => `/reports/pdfV2/tc-${kind === 'detailed' ? 'detail' : 'summary'}/${projectId}/${execNum}/${enc(type)}/${enc(executionType)}/${enc(executionStyle)}/${enc(runUuid)}?tz=${tz}`;
1271
+ const pickRun = (runs) => runs.find((r) => String(r?.status ?? '').toUpperCase() === 'FAILED') ?? runs[0];
1272
+ const kindWord = (k) => (k === 'detailed' ? 'Detailed' : 'Summary');
1273
+ if (!isSuite) {
1274
+ if (input.kinds.includes('suite'))
1275
+ throw new Error('The suite report is only available for a suite execution; this is a test case execution.');
1276
+ const runs = await this.http.getJson(`/exec/report/${execNum}/${projectId}`);
1277
+ if (!Array.isArray(runs) || runs.length === 0)
1278
+ throw new Error(`Execution ${input.runId} has no run results to report on.`);
1279
+ const chosen = pickRun(runs);
1280
+ const runUuid = str(chosen, 'id', '_id') ?? '';
1281
+ if (!runUuid)
1282
+ throw new Error(`Execution ${input.runId}: could not find the run id for its results.`);
1283
+ if (runs.length > 1)
1284
+ notes.push(`Execution ${execNum} has ${runs.length} runs; used ${String(chosen?.status ?? '').toUpperCase() === 'FAILED' ? 'the first FAILED one' : 'the first one'} (${runUuid}).`);
1285
+ needTcValues();
1286
+ for (const kind of input.kinds) {
1287
+ const data = await fetchPdf(tcUrl(kind, runUuid), `${kind} report`);
1288
+ await save(kind, `${kindWord(kind)} report for ${code}`, `${code}_EX-${execNum}(${kindWord(kind)}).pdf`, data);
1289
+ }
1290
+ return { scope: 'testcase', runLabel: `EX-${execNum}`, files, notes };
1291
+ }
1292
+ // SUITE
1293
+ const suiteNum = Number(execDoc.executionTypeId ?? 0);
1294
+ for (const kind of input.kinds) {
1295
+ if (kind === 'suite') {
1296
+ const data = await fetchPdf(`/reports/pdfV2/su/${enc(type)}/${projectId}/${execNum}?tz=${tz}`, 'suite overall report');
1297
+ await save('suite', `Suite overall report for ${code}`, `${code}_EX-${execNum}.pdf`, data);
1298
+ continue;
1299
+ }
1300
+ needTcValues();
1301
+ const items = Array.isArray(execDoc.executionItems) ? execDoc.executionItems : [];
1302
+ if (!items.length)
1303
+ throw new Error(`Suite execution ${input.runId} has no test cases to report on.`);
1304
+ for (const it of items) {
1305
+ const name = str(it, 'name') ?? `TC_${str(it, 'testcaseId') ?? '?'}`;
1306
+ const raw = await this.http.postJson('/reports/execution/getExecutionRuns', {
1307
+ type: 'SU', projectId, parentId: suiteNum, executionId: execNum,
1308
+ tcId: Number.parseInt(numericId(str(it, 'testcaseId') ?? ''), 10),
1309
+ ...(str(it, 'versionId') ? { tcVersionId: str(it, 'versionId') } : {}),
1310
+ ...(str(it, 'stepId') ? { stepId: str(it, 'stepId') } : {}),
1311
+ });
1312
+ const runs = Array.isArray(raw?.data) ? raw.data : [];
1313
+ const runUuid = runs.length ? (str(pickRun(runs), 'id', '_id') ?? '') : '';
1314
+ if (!runUuid) {
1315
+ notes.push(`${name}: no run results (not executed) — skipped.`);
1316
+ continue;
1317
+ }
1318
+ const data = await fetchPdf(tcUrl(kind, runUuid), `${kind} report for ${name}`);
1319
+ await save(kind, `${kindWord(kind)} report for ${name}`, `${name}_EX-${execNum}(${kindWord(kind)}).pdf`, data);
1320
+ }
1321
+ }
1322
+ return { scope: 'suite', runLabel: `EX-${execNum}`, files, notes };
1323
+ }
1324
+ /** PATCH /pm/{epic|feature|userstory|defect|task} with `{ids, projectId, isDelete}`. Epic / feature /
1325
+ * story / defect are two-phase (`isDelete:false` returns the per-id dependency rows, and `isDelete:true`
1326
+ * silently skips ids that still have dependents, returning those rows again). Task has no dry-run and
1327
+ * no dependency check. `deleted` is therefore decided by the response shape: dependency rows come
1328
+ * back as an ARRAY, a real delete does not. */
1329
+ async deleteBacklogItem(input) {
1330
+ const path = { epic: '/pm/epic', feature: '/pm/feature', story: '/pm/userstory', defect: '/pm/defect', task: '/pm/task' }[input.kind];
1331
+ const id = Number.parseInt(numericId(input.id), 10);
1332
+ if (!Number.isFinite(id))
1333
+ throw new Error(`deleteBacklogItem: "${input.id}" is not a numeric id.`);
1334
+ const projectId = this.requireProjectId();
1335
+ const confirm = input.confirm === true;
1336
+ if (input.kind === 'task') {
1337
+ if (!confirm)
1338
+ return { dryRun: true, deleted: false, dependencies: [] };
1339
+ await this.http.patchJson(path, { ids: [id], projectId });
1340
+ return { dryRun: false, deleted: true, dependencies: [] };
1341
+ }
1342
+ const raw = await this.http.patchJson(path, { ids: [id], projectId, isDelete: confirm });
1343
+ const data = raw?.data ?? raw;
1344
+ const rows = Array.isArray(data) ? data : [];
1345
+ const dependencies = rows
1346
+ .filter((r) => r && typeof r === 'object')
1347
+ .map((r) => {
1348
+ const counts = {};
1349
+ for (const [k, v] of Object.entries(r))
1350
+ if (k.endsWith('Arr') && Array.isArray(v) && v.length)
1351
+ counts[k] = v;
1352
+ return { id: Number(r.id), counts };
1353
+ });
1354
+ const blocked = dependencies.some((d) => Object.keys(d.counts).length > 0);
1355
+ return { dryRun: !confirm, deleted: confirm && !Array.isArray(data) && !blocked, dependencies };
1356
+ }
1211
1357
  /** PATCH /pm/module — two-phase per module.controller.js:94. `confirm:false` returns the per-id
1212
1358
  * dependency footprint (test cases, functions, params, objects, defects, user stories). `confirm:true`
1213
1359
  * triggers the actual delete: server drops only ids with EMPTY dependencies; ids with references are
@@ -1443,12 +1589,6 @@ export class SimplifyQaAdapter {
1443
1589
  this.agentHop = new RemoteMcpAdapter(this.cfg);
1444
1590
  return this.agentHop;
1445
1591
  }
1446
- requireHop(op) {
1447
- const h = this.hop();
1448
- if (!h)
1449
- throw new Error(`${op}: ${HOP_NEEDED_WRITE}`);
1450
- return h;
1451
- }
1452
1592
  /** Recording tool schemas from the SQA agent — mirrored so the recording passthrough can register
1453
1593
  * them. No agent configured → `[]`, so no recording tools get registered. */
1454
1594
  async listAgentTools() {
@@ -1502,10 +1642,41 @@ export class SimplifyQaAdapter {
1502
1642
  paramIdByName,
1503
1643
  ...(stepResolutions ? { stepResolutions } : {}),
1504
1644
  });
1645
+ await this.appendRemovedSteps(id, tc, payload);
1505
1646
  // `!` — putMultipart is optional on QaHttp (walnut's wrapper opts out); makeClient always provides it.
1506
1647
  const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
1507
1648
  return toSavedRef(raw, false);
1508
1649
  }
1650
+ /** The backend deletes a step only when it receives it with `deleted:true`; a step that is simply
1651
+ * absent from the payload is left untouched. So a step removed from the draft (remove_step) has to be
1652
+ * sent back explicitly as deleted, otherwise the save "succeeds" and the step is still there.
1653
+ * Only applies when the draft was hydrated from this test case (its steps carry `sourceId`), so a
1654
+ * hand-built draft with no source ids can never wipe the stored steps. Best-effort: a failed lookup
1655
+ * leaves the payload as it was. */
1656
+ async appendRemovedSteps(id, tc, payload) {
1657
+ const kept = new Set((tc.steps ?? []).map((s) => s.sourceId).filter((v) => !!v));
1658
+ if (kept.size === 0)
1659
+ return;
1660
+ try {
1661
+ const rawTc = await this.fetchRawTestcase(id);
1662
+ const current = await this.fetchStepsCollection(id, rawTc, tc.currentVersionId);
1663
+ const tcNum = Number.parseInt(numericId(id), 10);
1664
+ for (const r of current) {
1665
+ const sid = str(r, 'id', '_id');
1666
+ if (!sid || kept.has(sid) || r?.deleted === true)
1667
+ continue;
1668
+ const step = { ...pick(r, STEP_ALLOWED_KEYS), id: sid, deleted: true, parentId: tcNum };
1669
+ const vid = str(r, 'versionId');
1670
+ if (vid)
1671
+ step['versionId'] = vid;
1672
+ const bucket = String(r?.executionType ?? '').toUpperCase() === 'MANUAL' ? 'manualSteps' : 'automationSteps';
1673
+ payload.steps[bucket].push(step);
1674
+ }
1675
+ }
1676
+ catch {
1677
+ // lookup failed — save proceeds without delete markers
1678
+ }
1679
+ }
1509
1680
  /** Snapshot the CURRENT server state of a testcase under a NEW version name — same TC id,
1510
1681
  * new `tcfn_version` row with cloned steps. Backed by `PUT /tm/testcase` with
1511
1682
  * `operation:"SAVENEWVERSION"` (see tcfn.service.js:115/242/355 for the branches). The steps
@@ -1538,7 +1709,8 @@ export class SimplifyQaAdapter {
1538
1709
  sourceVersionId = str(rawTc.versions[0], 'id', '_id');
1539
1710
  }
1540
1711
  }
1541
- const rawSteps = await this.fetchStepsCollection(id, rawTc);
1712
+ // Steps must come from the SELECTED source version, not whichever version the TC has open.
1713
+ const rawSteps = await this.fetchStepsCollection(id, rawTc, sourceVersionId);
1542
1714
  const automationSteps = [];
1543
1715
  const manualSteps = [];
1544
1716
  for (const s of rawSteps) {
@@ -2029,7 +2201,8 @@ export class SimplifyQaAdapter {
2029
2201
  : undefined;
2030
2202
  return match ? str(match, 'name') : str(rawTc?.versionsDetails, 'name');
2031
2203
  })() ?? 'v1.0';
2032
- const rawSteps = await this.fetchStepsCollection(id, rawTc);
2204
+ // Steps must come from the SELECTED source version, not whichever version the TC has open.
2205
+ const rawSteps = await this.fetchStepsCollection(id, rawTc, sourceVersionId);
2033
2206
  const automationSteps = [];
2034
2207
  const manualSteps = [];
2035
2208
  for (const s of rawSteps) {
@@ -2412,10 +2585,13 @@ export class SimplifyQaAdapter {
2412
2585
  paramIdByName.set(name, id);
2413
2586
  }
2414
2587
  if (s.recordedParam) {
2588
+ const runtimeNames = new Set(s.runtimeParameters ?? []);
2415
2589
  for (const [name, value] of Object.entries(s.recordedParam)) {
2416
2590
  if (paramIdByName.has(name))
2417
2591
  continue;
2418
- const id = await this.getOrCreateParameter(projectId, moduleId, name, String(value ?? ''));
2592
+ // A store slot ("Runtime Parameter") must be a Runtime param so the agent hands the plugin a
2593
+ // writable argument; everything else stays Local.
2594
+ const id = await this.getOrCreateParameter(projectId, moduleId, name, String(value ?? ''), runtimeNames.has(name) ? 'Runtime' : 'Local');
2419
2595
  paramIdByName.set(name, id);
2420
2596
  }
2421
2597
  }
@@ -2456,7 +2632,16 @@ export class SimplifyQaAdapter {
2456
2632
  limit: 100000,
2457
2633
  selectFields: ['id', 'name', 'technology', 'objectTemplate', 'objectRequired'],
2458
2634
  };
2459
- const raw = await this.http.postJson('/tm/action/search', body);
2635
+ // Also ask for the plugin's `@Sync` uniqueId ("db-2"): action NAMES collide (executeQuery = db-2 AND
2636
+ // db-3), so a save must resolve by uniqueId when the backend exposes it. Not every backend build
2637
+ // accepts the column, so fall back to the plain field set — resolution then degrades to by-name.
2638
+ let raw;
2639
+ try {
2640
+ raw = await this.http.postJson('/tm/action/search', { ...body, selectFields: [...body.selectFields, 'uniqueId'] });
2641
+ }
2642
+ catch {
2643
+ raw = await this.http.postJson('/tm/action/search', body);
2644
+ }
2460
2645
  const rows = pickArray(raw, 'data', 'content', 'actions');
2461
2646
  // Capture diagnostic snapshot so downstream errors can surface WHY the fetch returned 0 rows.
2462
2647
  try {
@@ -2497,6 +2682,10 @@ export class SimplifyQaAdapter {
2497
2682
  }
2498
2683
  // Id-keyed entry so numeric-actionType round-trip lookups are O(1) instead of a scan.
2499
2684
  map.set(`id::${id}`, entry);
2685
+ // Plugin-uniqueId-keyed entry: the only collision-free way to pick db-2 vs db-3 etc.
2686
+ const uniq = str(row, 'uniqueId', 'actionUniqueId');
2687
+ if (uniq)
2688
+ map.set(`uniq::${uniq}`, entry);
2500
2689
  }
2501
2690
  return map;
2502
2691
  })().catch((e) => { this.actionCatalogCache = null; throw e; });
@@ -2518,7 +2707,7 @@ export class SimplifyQaAdapter {
2518
2707
  if (!sameLocators && incomingLocators.length) {
2519
2708
  await this.updateObject(existing.id, { locators: incomingLocators });
2520
2709
  }
2521
- return Number.parseInt(existing.id, 10);
2710
+ return { id: Number.parseInt(existing.id, 10) };
2522
2711
  }
2523
2712
  }
2524
2713
  catch {
@@ -2553,7 +2742,20 @@ export class SimplifyQaAdapter {
2553
2742
  const id = str(flat, 'id', '_id', 'objectId');
2554
2743
  if (!id)
2555
2744
  throw new Error(`createObjectSeq returned no id for object "${name}": ${JSON.stringify(raw)}`);
2556
- return Number.parseInt(id, 10);
2745
+ // The backend dedups a clashing name by suffixing it ("Sign In button" → "Sign In button_1") and
2746
+ // returns the stored `disname`. Surface it so the step can show the name actually in use.
2747
+ const storedName = (str(flat, 'disname') ?? '').trim();
2748
+ return { id: Number.parseInt(id, 10), ...(storedName && storedName !== name ? { storedName } : {}) };
2749
+ }
2750
+ /** Rewrite `from` → `to` in a step's description + objectRef.name after the backend renamed its
2751
+ * object. Skips matches already followed by a `_N` suffix so a re-run never double-suffixes. */
2752
+ applyObjectRename(step, from, to) {
2753
+ if (step.objectRef)
2754
+ step.objectRef.name = to;
2755
+ if (!step.description || !from)
2756
+ return;
2757
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2758
+ step.description = step.description.replace(new RegExp(`${escaped}(?!_\\d)`, 'i'), to);
2557
2759
  }
2558
2760
  /** For every automation step, resolve `actionId` (from action name + technology) and
2559
2761
  * `objectId` (via createObjectSeq get-or-create) so the mapper can emit a valid step. Runs
@@ -2590,9 +2792,14 @@ export class SimplifyQaAdapter {
2590
2792
  // `<lowercase name>` for model-authored by-name lookups. Tech is not in the key because
2591
2793
  // action rows have no top-level `technology` field (SQA UI resolves via objectTemplate lookup).
2592
2794
  const isNumericId = /^\d+$/.test(name);
2593
- let meta = isNumericId
2594
- ? catalog.get(`id::${Number.parseInt(name, 10)}`)
2595
- : catalog.get(name.toLowerCase());
2795
+ // A recorder step carries the plugin's uniqueId; prefer it — names are not unique ("executeQuery"
2796
+ // is db-2 AND db-3) so by-name can silently save the wrong overload.
2797
+ // Scoped to database steps: other technologies keep the existing by-name/by-id resolution untouched.
2798
+ let meta = s.stepType === 'database' && s.actionUniqueId ? catalog.get(`uniq::${s.actionUniqueId}`) : undefined;
2799
+ if (!meta)
2800
+ meta = isNumericId
2801
+ ? catalog.get(`id::${Number.parseInt(name, 10)}`)
2802
+ : catalog.get(name.toLowerCase());
2596
2803
  // Fallback: if the by-id scan misses (action created in a different project and thus not in
2597
2804
  // the UI's projectId-filtered catalog), fetch it directly via GET /tm/action/:id. Only tries
2598
2805
  // for numeric-id lookups — model-authored name lookups have no id to fetch.
@@ -2660,7 +2867,10 @@ export class SimplifyQaAdapter {
2660
2867
  if (meta.objectTemplate == null) {
2661
2868
  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.`);
2662
2869
  }
2663
- objectId = await this.resolveObjectId(s.objectRef, projectId, moduleId, tech, meta.objectTemplate);
2870
+ const resolved = await this.resolveObjectId(s.objectRef, projectId, moduleId, tech, meta.objectTemplate);
2871
+ objectId = resolved.id;
2872
+ if (resolved.storedName)
2873
+ this.applyObjectRename(s, s.objectRef.name.trim(), resolved.storedName);
2664
2874
  }
2665
2875
  else {
2666
2876
  throw new Error(`Automation step ${i + 1} ("${name}"): action requires an object but Step.objectRef is missing. Provide objectRef:{id} to reuse a known one, objectRef:{name} to look one up by name in the project, or objectRef:{name, locators:[{name, value}]} to create a new one.`);
@@ -2685,7 +2895,10 @@ export class SimplifyQaAdapter {
2685
2895
  const rows = pickArray(raw, 'data', 'content');
2686
2896
  for (const row of rows) {
2687
2897
  const rowName = str(row, 'name');
2688
- if (rowName === name) {
2898
+ // Case-insensitive: the search above sets ignorecase, and the backend's duplicate-name
2899
+ // collation is case-insensitive too — an exact-case compare here misses an existing "host"
2900
+ // (vs "Host") and the next POST trips "Duplicate Name Error".
2901
+ if (rowName != null && rowName.toLowerCase() === name.toLowerCase()) {
2689
2902
  const id = str(row, 'id', '_id', 'parameterId');
2690
2903
  if (id)
2691
2904
  return Number.parseInt(id, 10);
@@ -2715,7 +2928,7 @@ export class SimplifyQaAdapter {
2715
2928
  }
2716
2929
  return null;
2717
2930
  }
2718
- async getOrCreateParameter(projectId, moduleId, name, defaultValue) {
2931
+ async getOrCreateParameter(projectId, moduleId, name, defaultValue, accessType = 'Local') {
2719
2932
  const existing = await this.findParameterId(projectId, name);
2720
2933
  if (existing != null)
2721
2934
  return existing;
@@ -2727,7 +2940,7 @@ export class SimplifyQaAdapter {
2727
2940
  defaultValue,
2728
2941
  // Match the SQA UI's Add-Parameter defaults so API-created params render identically. Without
2729
2942
  // these the UI shows "Parameter Type*" empty and refuses to save edits.
2730
- type: 'Local',
2943
+ type: accessType,
2731
2944
  parameterType: 'Alphanumeric',
2732
2945
  description: '',
2733
2946
  unique: false,
@@ -3227,7 +3440,11 @@ export class SimplifyQaAdapter {
3227
3440
  if (!meta)
3228
3441
  continue; // parameter deleted — skip
3229
3442
  const isProtected = meta.protected || looksSensitive(meta.name);
3230
- const seed = isProtected ? (rawValue ? '*****' : '(empty)') : rawValue;
3443
+ // includeProtected is INTERNAL (copyTestdata): the real value never leaves the adapter on the
3444
+ // model-facing path (tc_get_testdata), which leaves it off.
3445
+ const seed = isProtected && !q.includeProtected
3446
+ ? (rawValue ? '*****' : '(empty)')
3447
+ : rawValue;
3231
3448
  parameters.push({ paramId: pid, name: meta.name, seed, ...(isProtected ? { protected: true } : {}) });
3232
3449
  }
3233
3450
  }
@@ -3300,6 +3517,59 @@ export class SimplifyQaAdapter {
3300
3517
  await this.http.postJson('/tm/testdata/saveItrForDataGen', body);
3301
3518
  return { tcId: payload.tcId, inserted: iterations.length };
3302
3519
  }
3520
+ /** Copy the default-iteration values of one TC onto another, matching parameters BY NAME.
3521
+ * Used by tc_automate: a manual TC converted to automated must carry the manual TC's testdata,
3522
+ * not the values that happened to be typed during the recording. Protected source values are read
3523
+ * unmasked internally so they can be copied — they are never returned to the model.
3524
+ * A source parameter with an EMPTY seed is skipped, so a value the recording generated for a
3525
+ * field the manual TC never filled is left in place. */
3526
+ async copyTestdata(q) {
3527
+ if (!q.fromTcId || !q.toTcId)
3528
+ throw new Error('copyTestdata: fromTcId and toTcId are required');
3529
+ const source = await this.getDefaultIteration({
3530
+ tcId: q.fromTcId,
3531
+ ...(q.environmentType ? { environmentType: q.environmentType } : {}),
3532
+ includeProtected: true,
3533
+ });
3534
+ const target = await this.getDefaultIteration({
3535
+ tcId: q.toTcId,
3536
+ ...(q.environmentType ? { environmentType: q.environmentType } : {}),
3537
+ });
3538
+ const targetByLower = new Map();
3539
+ for (const p of target.parameters) {
3540
+ targetByLower.set(p.name.trim().toLowerCase(), { paramId: p.paramId, name: p.name });
3541
+ }
3542
+ const row = {}; // keyed by the TARGET parameter's exact name
3543
+ const skipped = [];
3544
+ let copied = 0;
3545
+ for (const p of source.parameters) {
3546
+ const hit = targetByLower.get(p.name.trim().toLowerCase());
3547
+ if (!hit) {
3548
+ skipped.push(p.name);
3549
+ continue;
3550
+ }
3551
+ if (p.seed === '')
3552
+ continue; // nothing seeded on the source — keep the generated value
3553
+ row[hit.name] = p.seed;
3554
+ copied++;
3555
+ }
3556
+ if (copied > 0) {
3557
+ await this.saveGeneratedIterations({
3558
+ tcId: q.toTcId,
3559
+ versionId: target.versionId,
3560
+ environmentType: target.environmentType,
3561
+ rows: [row],
3562
+ parameters: target.parameters,
3563
+ });
3564
+ }
3565
+ return {
3566
+ fromTcId: q.fromTcId,
3567
+ toTcId: q.toTcId,
3568
+ environmentType: target.environmentType,
3569
+ copied,
3570
+ skipped,
3571
+ };
3572
+ }
3303
3573
  /** Environment key: caller wins; else fetch the project's configured envs and pick the first;
3304
3574
  * else 'general' (matches the SQA UI fallback). */
3305
3575
  async resolveEnvironment(projectId, requested) {
@@ -3700,11 +3970,14 @@ export class SimplifyQaAdapter {
3700
3970
  // LOCAL fast-fail: the desktop agent is what actually executes the TC (it polls the Redis queue
3701
3971
  // the backend RPUSHes to). If :4040 isn't up, no roster / customer-search / dispatch will help —
3702
3972
  // fail here with a clear message instead of enqueuing a job nothing will drain.
3703
- if (mode === 'LOCAL' && !(await this.probeLocalAgent())) {
3704
- throw new Error(`startRun (LOCAL): SimplifyQA desktop agent is not reachable at ` +
3705
- `${this.cfg.url ?? '(no agent URL configured — set QA_MCP_URL)'}. Start the desktop ` +
3706
- `agent, then try again. Use mode:'cloud' to run on a cloud runner instead — it does not ` +
3707
- `need the desktop agent.`);
3973
+ if (mode === 'LOCAL') {
3974
+ const probe = await this.probeLocalAgentDetailed();
3975
+ if (!probe.up) {
3976
+ throw new Error(`startRun (LOCAL): could not reach the SimplifyQA desktop agent at ${probe.url ?? '(no URL)'} ` +
3977
+ `just now (${probe.reason ?? 'unknown'}) — checked live at dispatch, not from earlier in this chat. ` +
3978
+ `If the agent shows Online in the SimplifyQA UI, retry once; otherwise start the desktop agent. ` +
3979
+ `Use mode:'cloud' to run on a cloud runner instead — it does not need the desktop agent.`);
3980
+ }
3708
3981
  }
3709
3982
  const rawTc = await this.fetchRawTestcase(tcId);
3710
3983
  if (!rawTc || Object.keys(rawTc).length === 0) {
@@ -3886,11 +4159,14 @@ export class SimplifyQaAdapter {
3886
4159
  // Effective mode: caller pick wins (matches our tool's cascade UX); fall back to the suite doc's
3887
4160
  // stored `executionType`, which the UI reads unconditionally (execution-popup-modal.ts:668).
3888
4161
  const mode = (opts.mode ?? String(rawSuite.executionType ?? 'LOCAL')).toUpperCase();
3889
- if (mode === 'LOCAL' && !(await this.probeLocalAgent())) {
3890
- throw new Error(`startRun (LOCAL): SimplifyQA desktop agent is not reachable at ` +
3891
- `${this.cfg.url ?? '(no agent URL configured — set QA_MCP_URL)'}. Start the desktop ` +
3892
- `agent, then try again. Use mode:'cloud' to run on a cloud runner instead — it does not ` +
3893
- `need the desktop agent.`);
4162
+ if (mode === 'LOCAL') {
4163
+ const probe = await this.probeLocalAgentDetailed();
4164
+ if (!probe.up) {
4165
+ throw new Error(`startRun (LOCAL): could not reach the SimplifyQA desktop agent at ${probe.url ?? '(no URL)'} ` +
4166
+ `just now (${probe.reason ?? 'unknown'}) — checked live at dispatch, not from earlier in this chat. ` +
4167
+ `If the agent shows Online in the SimplifyQA UI, retry once; otherwise start the desktop agent. ` +
4168
+ `Use mode:'cloud' to run on a cloud runner instead — it does not need the desktop agent.`);
4169
+ }
3894
4170
  }
3895
4171
  const projectName = await this.lookupProjectName(projectId);
3896
4172
  if (!projectName) {
@@ -4057,20 +4333,31 @@ export class SimplifyQaAdapter {
4057
4333
  * a naked GET) proves the process is up; connect/DNS/timeout errors mean it is down. Replaces
4058
4334
  * the customer-wide `machineRegistration` round trip when all we needed was "is LOCAL possible?". */
4059
4335
  async probeLocalAgent() {
4060
- if (!this.cfg.url)
4061
- return false;
4062
- const ac = new AbortController();
4063
- const timer = setTimeout(() => ac.abort(), 1000);
4064
- try {
4065
- await fetch(this.cfg.url, { method: 'GET', signal: ac.signal });
4066
- return true;
4067
- }
4068
- catch {
4069
- return false;
4070
- }
4071
- finally {
4072
- clearTimeout(timer);
4336
+ return (await this.probeLocalAgentDetailed()).up;
4337
+ }
4338
+ /** Live probe, never cached — every dispatch re-checks. A single 1s attempt misreported a busy-but-up
4339
+ * agent as offline (the "agent offline mid-chain" report), so: 3s per attempt, one retry, and the
4340
+ * failure reason is returned so callers can say WHY instead of a bare "not reachable". */
4341
+ async probeLocalAgentDetailed() {
4342
+ const url = this.cfg.url;
4343
+ if (!url)
4344
+ return { up: false, reason: 'no agent URL configured (set QA_MCP_URL)' };
4345
+ let reason = '';
4346
+ for (let attempt = 0; attempt < 2; attempt++) {
4347
+ const ac = new AbortController();
4348
+ const timer = setTimeout(() => ac.abort(), 3000);
4349
+ try {
4350
+ await fetch(url, { method: 'GET', signal: ac.signal });
4351
+ return { up: true, url };
4352
+ }
4353
+ catch (e) {
4354
+ reason = ac.signal.aborted ? 'no response within 3s' : (e instanceof Error ? (e.cause?.message ?? e.message) : String(e));
4355
+ }
4356
+ finally {
4357
+ clearTimeout(timer);
4358
+ }
4073
4359
  }
4360
+ return { up: false, url, reason };
4074
4361
  }
4075
4362
  async listCloudRunners() {
4076
4363
  const projectId = this.requireProjectId();
@@ -4454,11 +4741,104 @@ export class SimplifyQaAdapter {
4454
4741
  return h?.listDatasets ? h.listDatasets(tcId) : [];
4455
4742
  }
4456
4743
  // --- Journal (recording seed lives on the agent's FS) ------------------
4744
+ // The agent keeps the recording journal. When it is NOT reachable (not running / no URL) the steps are
4745
+ // still readable and editable straight from the backend, the same way the SQA UI does it — so these two
4746
+ // fall back to REST instead of failing. When the agent IS up its behaviour is unchanged.
4747
+ agentUnreachable(e) {
4748
+ const m = e instanceof Error ? e.message : String(e);
4749
+ return /Could not reach the product MCP|fetch failed|ECONNREFUSED/i.test(m);
4750
+ }
4457
4751
  async readJournal(tcId) {
4458
- return this.requireHop('read_journal').readJournal(tcId);
4752
+ const h = this.hop();
4753
+ if (h) {
4754
+ try {
4755
+ return await h.readJournal(tcId);
4756
+ }
4757
+ catch (e) {
4758
+ if (!this.agentUnreachable(e))
4759
+ throw e;
4760
+ }
4761
+ }
4762
+ return this.stepsView(tcId);
4459
4763
  }
4460
4764
  async editJournal(tcId, ops) {
4461
- return this.requireHop('edit_journal').editJournal(tcId, ops);
4765
+ const h = this.hop();
4766
+ if (h) {
4767
+ try {
4768
+ return await h.editJournal(tcId, ops);
4769
+ }
4770
+ catch (e) {
4771
+ if (!this.agentUnreachable(e))
4772
+ throw e;
4773
+ }
4774
+ }
4775
+ return this.editStepsDirect(tcId, ops);
4776
+ }
4777
+ async stepsView(tcId) {
4778
+ const tc = await this.getTestCase(tcId);
4779
+ return { tcId, source: 'steps', steps: tc.steps ?? [] };
4780
+ }
4781
+ /** Agent-free step edit: read the test case, apply remove / update / move by step number, renumber, and
4782
+ * save through updateTestCase (which sends removed steps back with `deleted:true`, as the UI does). */
4783
+ async editStepsDirect(tcId, ops) {
4784
+ const tc = await this.getTestCase(tcId);
4785
+ let steps = [...(tc.steps ?? [])];
4786
+ for (const op of ops) {
4787
+ if (op.op === 'remove_step') {
4788
+ if (!steps.some((x) => x.n === op.n))
4789
+ throw new Error(`remove_step: no step ${op.n} on ${tcId} (it has ${steps.length}).`);
4790
+ steps = steps.filter((x) => x.n !== op.n);
4791
+ }
4792
+ else if (op.op === 'update_step') {
4793
+ const i = steps.findIndex((x) => x.n === op.n);
4794
+ if (i < 0)
4795
+ throw new Error(`update_step: no step ${op.n} on ${tcId} (it has ${steps.length}).`);
4796
+ const cur = steps[i];
4797
+ const patch = op.patch;
4798
+ steps[i] = {
4799
+ ...cur, ...patch,
4800
+ ...(patch.flags ? { flags: { ...cur.flags, ...patch.flags } } : {}),
4801
+ ...(patch.objectRef ? { objectRef: { ...cur.objectRef, ...patch.objectRef } } : {}),
4802
+ };
4803
+ }
4804
+ else if (op.op === 'move_step') {
4805
+ const i = steps.findIndex((x) => x.n === op.n);
4806
+ if (i < 0)
4807
+ throw new Error(`move_step: no step ${op.n} on ${tcId} (it has ${steps.length}).`);
4808
+ const [moved] = steps.splice(i, 1);
4809
+ steps.splice(Math.max(0, Math.min(steps.length, op.to - 1)), 0, moved);
4810
+ }
4811
+ else if (op.op === 'insert_nested') {
4812
+ // Same shape the UI inserts (steps.component.ts addStep 'FUNCTION'): a FUNCTION step directly
4813
+ // followed by its FUNCTION_END, with the function's newest version pinned (function-step-card
4814
+ // onSelectFunction). The automation payload builder turns the neutral pair into wire steps.
4815
+ if (tc.category !== 'automated') {
4816
+ throw new Error(`insert_nested: ${tcId} is a manual test case — nested function calls can only be added to automated test cases.`);
4817
+ }
4818
+ const fn = await this.getFunction(op.tcId);
4819
+ const fnVersionId = fn.versions?.[0]?.id;
4820
+ if (!fnVersionId)
4821
+ throw new Error(`insert_nested: function ${op.tcId} has no version to pin.`);
4822
+ const idx = op.at != null ? Math.max(0, Math.min(steps.length, op.at - 1)) : steps.length;
4823
+ if (steps[idx - 1]?.stepType === 'function') {
4824
+ throw new Error(`insert_nested: position ${idx + 1} is inside the function call at step ${idx}.`);
4825
+ }
4826
+ const fnStep = {
4827
+ n: 0, stepType: 'function', actionType: 'function',
4828
+ description: op.name ?? fn.description ?? '',
4829
+ functionCall: { testCaseId: numericId(op.tcId), versionId: fnVersionId },
4830
+ };
4831
+ const endStep = { n: 0, stepType: 'function_end', actionType: 'function_end', description: '' };
4832
+ steps.splice(idx, 0, fnStep, endStep);
4833
+ }
4834
+ else {
4835
+ throw new Error(`The SimplifyQA desktop agent is not reachable, and '${op.op}' needs it. Without the agent only ` +
4836
+ 'update_step, remove_step, move_step and insert_nested can be applied. Start the agent, or use those ops.');
4837
+ }
4838
+ steps = steps.map((x, k) => ({ ...x, n: k + 1 }));
4839
+ }
4840
+ await this.updateTestCase(tcId, { ...tc, steps });
4841
+ return this.stepsView(tcId);
4462
4842
  }
4463
4843
  }
4464
4844
  //# sourceMappingURL=adapter.js.map