common-qa-mcp 0.10.0 → 0.11.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.
- package/dist/adapters/http.d.ts +4 -0
- package/dist/adapters/http.d.ts.map +1 -1
- package/dist/adapters/http.js +19 -0
- package/dist/adapters/http.js.map +1 -1
- package/dist/adapters/remoteMcp/adapter.js +1 -1
- package/dist/adapters/simplifyqa/adapter.d.ts +48 -2
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/adapter.js +369 -46
- package/dist/adapters/simplifyqa/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/mapper.js +10 -2
- package/dist/adapters/simplifyqa/mapper.js.map +1 -1
- package/dist/adapters/types.d.ts +22 -1
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/domain/types.d.ts +28 -0
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +32 -17
- package/dist/initAgents.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +18 -12
- package/dist/server.js.map +1 -1
- package/dist/tools/backlog.tools.d.ts.map +1 -1
- package/dist/tools/backlog.tools.js +96 -0
- package/dist/tools/backlog.tools.js.map +1 -1
- package/dist/tools/discovery.tools.d.ts.map +1 -1
- package/dist/tools/discovery.tools.js +160 -10
- package/dist/tools/discovery.tools.js.map +1 -1
- package/dist/tools/recording.tools.js +74 -16
- package/dist/tools/recording.tools.js.map +1 -1
- package/dist/tools/report.tools.d.ts +12 -0
- package/dist/tools/report.tools.d.ts.map +1 -0
- package/dist/tools/report.tools.js +104 -0
- package/dist/tools/report.tools.js.map +1 -0
- package/dist/tools/testcase.tools.d.ts.map +1 -1
- package/dist/tools/testcase.tools.js +88 -11
- package/dist/tools/testcase.tools.js.map +1 -1
- package/dist/tools/xlsx.d.ts +8 -0
- package/dist/tools/xlsx.d.ts.map +1 -0
- package/dist/tools/xlsx.js +113 -0
- package/dist/tools/xlsx.js.map +1 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2943
|
+
type: accessType,
|
|
2731
2944
|
parameterType: 'Alphanumeric',
|
|
2732
2945
|
description: '',
|
|
2733
2946
|
unique: false,
|
|
@@ -3700,11 +3913,14 @@ export class SimplifyQaAdapter {
|
|
|
3700
3913
|
// LOCAL fast-fail: the desktop agent is what actually executes the TC (it polls the Redis queue
|
|
3701
3914
|
// the backend RPUSHes to). If :4040 isn't up, no roster / customer-search / dispatch will help —
|
|
3702
3915
|
// fail here with a clear message instead of enqueuing a job nothing will drain.
|
|
3703
|
-
if (mode === 'LOCAL'
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3916
|
+
if (mode === 'LOCAL') {
|
|
3917
|
+
const probe = await this.probeLocalAgentDetailed();
|
|
3918
|
+
if (!probe.up) {
|
|
3919
|
+
throw new Error(`startRun (LOCAL): could not reach the SimplifyQA desktop agent at ${probe.url ?? '(no URL)'} ` +
|
|
3920
|
+
`just now (${probe.reason ?? 'unknown'}) — checked live at dispatch, not from earlier in this chat. ` +
|
|
3921
|
+
`If the agent shows Online in the SimplifyQA UI, retry once; otherwise start the desktop agent. ` +
|
|
3922
|
+
`Use mode:'cloud' to run on a cloud runner instead — it does not need the desktop agent.`);
|
|
3923
|
+
}
|
|
3708
3924
|
}
|
|
3709
3925
|
const rawTc = await this.fetchRawTestcase(tcId);
|
|
3710
3926
|
if (!rawTc || Object.keys(rawTc).length === 0) {
|
|
@@ -3886,11 +4102,14 @@ export class SimplifyQaAdapter {
|
|
|
3886
4102
|
// Effective mode: caller pick wins (matches our tool's cascade UX); fall back to the suite doc's
|
|
3887
4103
|
// stored `executionType`, which the UI reads unconditionally (execution-popup-modal.ts:668).
|
|
3888
4104
|
const mode = (opts.mode ?? String(rawSuite.executionType ?? 'LOCAL')).toUpperCase();
|
|
3889
|
-
if (mode === 'LOCAL'
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
4105
|
+
if (mode === 'LOCAL') {
|
|
4106
|
+
const probe = await this.probeLocalAgentDetailed();
|
|
4107
|
+
if (!probe.up) {
|
|
4108
|
+
throw new Error(`startRun (LOCAL): could not reach the SimplifyQA desktop agent at ${probe.url ?? '(no URL)'} ` +
|
|
4109
|
+
`just now (${probe.reason ?? 'unknown'}) — checked live at dispatch, not from earlier in this chat. ` +
|
|
4110
|
+
`If the agent shows Online in the SimplifyQA UI, retry once; otherwise start the desktop agent. ` +
|
|
4111
|
+
`Use mode:'cloud' to run on a cloud runner instead — it does not need the desktop agent.`);
|
|
4112
|
+
}
|
|
3894
4113
|
}
|
|
3895
4114
|
const projectName = await this.lookupProjectName(projectId);
|
|
3896
4115
|
if (!projectName) {
|
|
@@ -4057,20 +4276,31 @@ export class SimplifyQaAdapter {
|
|
|
4057
4276
|
* a naked GET) proves the process is up; connect/DNS/timeout errors mean it is down. Replaces
|
|
4058
4277
|
* the customer-wide `machineRegistration` round trip when all we needed was "is LOCAL possible?". */
|
|
4059
4278
|
async probeLocalAgent() {
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4279
|
+
return (await this.probeLocalAgentDetailed()).up;
|
|
4280
|
+
}
|
|
4281
|
+
/** Live probe, never cached — every dispatch re-checks. A single 1s attempt misreported a busy-but-up
|
|
4282
|
+
* agent as offline (the "agent offline mid-chain" report), so: 3s per attempt, one retry, and the
|
|
4283
|
+
* failure reason is returned so callers can say WHY instead of a bare "not reachable". */
|
|
4284
|
+
async probeLocalAgentDetailed() {
|
|
4285
|
+
const url = this.cfg.url;
|
|
4286
|
+
if (!url)
|
|
4287
|
+
return { up: false, reason: 'no agent URL configured (set QA_MCP_URL)' };
|
|
4288
|
+
let reason = '';
|
|
4289
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
4290
|
+
const ac = new AbortController();
|
|
4291
|
+
const timer = setTimeout(() => ac.abort(), 3000);
|
|
4292
|
+
try {
|
|
4293
|
+
await fetch(url, { method: 'GET', signal: ac.signal });
|
|
4294
|
+
return { up: true, url };
|
|
4295
|
+
}
|
|
4296
|
+
catch (e) {
|
|
4297
|
+
reason = ac.signal.aborted ? 'no response within 3s' : (e instanceof Error ? (e.cause?.message ?? e.message) : String(e));
|
|
4298
|
+
}
|
|
4299
|
+
finally {
|
|
4300
|
+
clearTimeout(timer);
|
|
4301
|
+
}
|
|
4073
4302
|
}
|
|
4303
|
+
return { up: false, url, reason };
|
|
4074
4304
|
}
|
|
4075
4305
|
async listCloudRunners() {
|
|
4076
4306
|
const projectId = this.requireProjectId();
|
|
@@ -4454,11 +4684,104 @@ export class SimplifyQaAdapter {
|
|
|
4454
4684
|
return h?.listDatasets ? h.listDatasets(tcId) : [];
|
|
4455
4685
|
}
|
|
4456
4686
|
// --- Journal (recording seed lives on the agent's FS) ------------------
|
|
4687
|
+
// The agent keeps the recording journal. When it is NOT reachable (not running / no URL) the steps are
|
|
4688
|
+
// still readable and editable straight from the backend, the same way the SQA UI does it — so these two
|
|
4689
|
+
// fall back to REST instead of failing. When the agent IS up its behaviour is unchanged.
|
|
4690
|
+
agentUnreachable(e) {
|
|
4691
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
4692
|
+
return /Could not reach the product MCP|fetch failed|ECONNREFUSED/i.test(m);
|
|
4693
|
+
}
|
|
4457
4694
|
async readJournal(tcId) {
|
|
4458
|
-
|
|
4695
|
+
const h = this.hop();
|
|
4696
|
+
if (h) {
|
|
4697
|
+
try {
|
|
4698
|
+
return await h.readJournal(tcId);
|
|
4699
|
+
}
|
|
4700
|
+
catch (e) {
|
|
4701
|
+
if (!this.agentUnreachable(e))
|
|
4702
|
+
throw e;
|
|
4703
|
+
}
|
|
4704
|
+
}
|
|
4705
|
+
return this.stepsView(tcId);
|
|
4459
4706
|
}
|
|
4460
4707
|
async editJournal(tcId, ops) {
|
|
4461
|
-
|
|
4708
|
+
const h = this.hop();
|
|
4709
|
+
if (h) {
|
|
4710
|
+
try {
|
|
4711
|
+
return await h.editJournal(tcId, ops);
|
|
4712
|
+
}
|
|
4713
|
+
catch (e) {
|
|
4714
|
+
if (!this.agentUnreachable(e))
|
|
4715
|
+
throw e;
|
|
4716
|
+
}
|
|
4717
|
+
}
|
|
4718
|
+
return this.editStepsDirect(tcId, ops);
|
|
4719
|
+
}
|
|
4720
|
+
async stepsView(tcId) {
|
|
4721
|
+
const tc = await this.getTestCase(tcId);
|
|
4722
|
+
return { tcId, source: 'steps', steps: tc.steps ?? [] };
|
|
4723
|
+
}
|
|
4724
|
+
/** Agent-free step edit: read the test case, apply remove / update / move by step number, renumber, and
|
|
4725
|
+
* save through updateTestCase (which sends removed steps back with `deleted:true`, as the UI does). */
|
|
4726
|
+
async editStepsDirect(tcId, ops) {
|
|
4727
|
+
const tc = await this.getTestCase(tcId);
|
|
4728
|
+
let steps = [...(tc.steps ?? [])];
|
|
4729
|
+
for (const op of ops) {
|
|
4730
|
+
if (op.op === 'remove_step') {
|
|
4731
|
+
if (!steps.some((x) => x.n === op.n))
|
|
4732
|
+
throw new Error(`remove_step: no step ${op.n} on ${tcId} (it has ${steps.length}).`);
|
|
4733
|
+
steps = steps.filter((x) => x.n !== op.n);
|
|
4734
|
+
}
|
|
4735
|
+
else if (op.op === 'update_step') {
|
|
4736
|
+
const i = steps.findIndex((x) => x.n === op.n);
|
|
4737
|
+
if (i < 0)
|
|
4738
|
+
throw new Error(`update_step: no step ${op.n} on ${tcId} (it has ${steps.length}).`);
|
|
4739
|
+
const cur = steps[i];
|
|
4740
|
+
const patch = op.patch;
|
|
4741
|
+
steps[i] = {
|
|
4742
|
+
...cur, ...patch,
|
|
4743
|
+
...(patch.flags ? { flags: { ...cur.flags, ...patch.flags } } : {}),
|
|
4744
|
+
...(patch.objectRef ? { objectRef: { ...cur.objectRef, ...patch.objectRef } } : {}),
|
|
4745
|
+
};
|
|
4746
|
+
}
|
|
4747
|
+
else if (op.op === 'move_step') {
|
|
4748
|
+
const i = steps.findIndex((x) => x.n === op.n);
|
|
4749
|
+
if (i < 0)
|
|
4750
|
+
throw new Error(`move_step: no step ${op.n} on ${tcId} (it has ${steps.length}).`);
|
|
4751
|
+
const [moved] = steps.splice(i, 1);
|
|
4752
|
+
steps.splice(Math.max(0, Math.min(steps.length, op.to - 1)), 0, moved);
|
|
4753
|
+
}
|
|
4754
|
+
else if (op.op === 'insert_nested') {
|
|
4755
|
+
// Same shape the UI inserts (steps.component.ts addStep 'FUNCTION'): a FUNCTION step directly
|
|
4756
|
+
// followed by its FUNCTION_END, with the function's newest version pinned (function-step-card
|
|
4757
|
+
// onSelectFunction). The automation payload builder turns the neutral pair into wire steps.
|
|
4758
|
+
if (tc.category !== 'automated') {
|
|
4759
|
+
throw new Error(`insert_nested: ${tcId} is a manual test case — nested function calls can only be added to automated test cases.`);
|
|
4760
|
+
}
|
|
4761
|
+
const fn = await this.getFunction(op.tcId);
|
|
4762
|
+
const fnVersionId = fn.versions?.[0]?.id;
|
|
4763
|
+
if (!fnVersionId)
|
|
4764
|
+
throw new Error(`insert_nested: function ${op.tcId} has no version to pin.`);
|
|
4765
|
+
const idx = op.at != null ? Math.max(0, Math.min(steps.length, op.at - 1)) : steps.length;
|
|
4766
|
+
if (steps[idx - 1]?.stepType === 'function') {
|
|
4767
|
+
throw new Error(`insert_nested: position ${idx + 1} is inside the function call at step ${idx}.`);
|
|
4768
|
+
}
|
|
4769
|
+
const fnStep = {
|
|
4770
|
+
n: 0, stepType: 'function', actionType: 'function',
|
|
4771
|
+
description: op.name ?? fn.description ?? '',
|
|
4772
|
+
functionCall: { testCaseId: numericId(op.tcId), versionId: fnVersionId },
|
|
4773
|
+
};
|
|
4774
|
+
const endStep = { n: 0, stepType: 'function_end', actionType: 'function_end', description: '' };
|
|
4775
|
+
steps.splice(idx, 0, fnStep, endStep);
|
|
4776
|
+
}
|
|
4777
|
+
else {
|
|
4778
|
+
throw new Error(`The SimplifyQA desktop agent is not reachable, and '${op.op}' needs it. Without the agent only ` +
|
|
4779
|
+
'update_step, remove_step, move_step and insert_nested can be applied. Start the agent, or use those ops.');
|
|
4780
|
+
}
|
|
4781
|
+
steps = steps.map((x, k) => ({ ...x, n: k + 1 }));
|
|
4782
|
+
}
|
|
4783
|
+
await this.updateTestCase(tcId, { ...tc, steps });
|
|
4784
|
+
return this.stepsView(tcId);
|
|
4462
4785
|
}
|
|
4463
4786
|
}
|
|
4464
4787
|
//# sourceMappingURL=adapter.js.map
|