common-qa-mcp 0.8.0 → 0.9.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/remoteMcp/adapter.js +1 -1
- package/dist/adapters/simplifyqa/adapter.d.ts +48 -4
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/adapter.js +585 -63
- package/dist/adapters/simplifyqa/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/mapper.d.ts +2 -2
- package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/mapper.js +257 -64
- package/dist/adapters/simplifyqa/mapper.js.map +1 -1
- package/dist/adapters/types.d.ts +10 -1
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/domain/types.d.ts +46 -4
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/domain/validate.d.ts.map +1 -1
- package/dist/domain/validate.js +5 -1
- package/dist/domain/validate.js.map +1 -1
- package/dist/drafts/store.d.ts +9 -0
- package/dist/drafts/store.d.ts.map +1 -1
- package/dist/drafts/store.js +12 -0
- package/dist/drafts/store.js.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +48 -16
- package/dist/initAgents.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +6 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/recording.tools.d.ts.map +1 -1
- package/dist/tools/recording.tools.js +234 -0
- package/dist/tools/recording.tools.js.map +1 -1
- package/dist/tools/run.tools.d.ts +4 -0
- package/dist/tools/run.tools.d.ts.map +1 -1
- package/dist/tools/run.tools.js +360 -114
- package/dist/tools/run.tools.js.map +1 -1
- package/dist/tools/schemas.d.ts +41 -49
- package/dist/tools/schemas.d.ts.map +1 -1
- package/dist/tools/schemas.js +19 -1
- package/dist/tools/schemas.js.map +1 -1
- package/dist/tools/tcEditPublisher.d.ts +20 -0
- package/dist/tools/tcEditPublisher.d.ts.map +1 -0
- package/dist/tools/tcEditPublisher.js +46 -0
- package/dist/tools/tcEditPublisher.js.map +1 -0
- package/dist/tools/testcase.tools.d.ts.map +1 -1
- package/dist/tools/testcase.tools.js +303 -10
- package/dist/tools/testcase.tools.js.map +1 -1
- package/dist/tools/testdata.tools.d.ts.map +1 -1
- package/dist/tools/testdata.tools.js +60 -6
- package/dist/tools/testdata.tools.js.map +1 -1
- package/package.json +1 -1
|
@@ -65,13 +65,17 @@ function normalizeFormula(doc, projectId) {
|
|
|
65
65
|
parameters: params,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
-
/** SQA per-step status → neutral step status.
|
|
68
|
+
/** SQA per-step status → neutral step status. Matches RunState terminals ('passed'/'failed'/
|
|
69
|
+
* 'skipped'/'stopped') so the same icon/formatting logic works for top-level state AND per-step
|
|
70
|
+
* rows (run.tools.ts:180 keys off these values — the earlier 'pass'/'fail' names left every
|
|
71
|
+
* step icon defaulting to '•'). Non-terminal SQA states (INFO, INPROGRESS, NOTEXECUTED) pass
|
|
72
|
+
* through lowercased so the tool can still surface them as diagnostic text. */
|
|
69
73
|
function mapStepStatus(s) {
|
|
70
74
|
switch (s.toUpperCase()) {
|
|
71
|
-
case 'PASSED': return '
|
|
72
|
-
case 'FAILED': return '
|
|
73
|
-
case 'SKIPPED': return '
|
|
74
|
-
case 'STOPPED': return '
|
|
75
|
+
case 'PASSED': return 'passed';
|
|
76
|
+
case 'FAILED': return 'failed';
|
|
77
|
+
case 'SKIPPED': return 'skipped';
|
|
78
|
+
case 'STOPPED': return 'stopped';
|
|
75
79
|
default: return s.toLowerCase();
|
|
76
80
|
}
|
|
77
81
|
}
|
|
@@ -227,6 +231,9 @@ export class SimplifyQaAdapter {
|
|
|
227
231
|
* Keyed by `${technologyType}::${lowerCaseName}` so the same action name across two techs
|
|
228
232
|
* (e.g. "click" for WEB + ANDROID) resolves to the right numeric id. */
|
|
229
233
|
this.actionCatalogCache = null;
|
|
234
|
+
/** Populated by actionCatalog() on each fetch. Surfaced in resolveAutomationSteps error messages
|
|
235
|
+
* so a user hitting "no SQA action named X" can see what the fetch actually returned. */
|
|
236
|
+
this.actionCatalogFetchDiag = 'never fetched';
|
|
230
237
|
// ---------------------------------------------------------------------- Objects (read + delete)
|
|
231
238
|
// list/create/delete now go direct REST (same /tm/object base getObject already uses), mirroring
|
|
232
239
|
// the testcase/suite search + soft-delete convention.
|
|
@@ -254,6 +261,18 @@ export class SimplifyQaAdapter {
|
|
|
254
261
|
...(this.cfg.projectId ? { 'x-project-id': this.cfg.projectId } : {}),
|
|
255
262
|
};
|
|
256
263
|
}
|
|
264
|
+
/** Public session context for MCP viewer/recorder windows that need to authenticate the sqa-ui
|
|
265
|
+
* they load. cfg itself stays private; this getter is the one seam through which tc_edit_open
|
|
266
|
+
* and record tools can forward token + baseUrl + projectId to Electron without asking the LLM. */
|
|
267
|
+
getSessionContext() {
|
|
268
|
+
// Constructor already rejected missing baseUrl (~line 265), so `!` is safe here.
|
|
269
|
+
return {
|
|
270
|
+
token: this.cfg.token,
|
|
271
|
+
baseUrl: this.cfg.baseUrl,
|
|
272
|
+
...(this.cfg.projectId ? { projectId: this.cfg.projectId } : {}),
|
|
273
|
+
...(this.cfg.userId ? { userId: this.cfg.userId } : {}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
257
276
|
requireProjectId() {
|
|
258
277
|
if (!this.cfg.projectId)
|
|
259
278
|
throw new Error(NEEDS_PROJECT);
|
|
@@ -470,7 +489,11 @@ export class SimplifyQaAdapter {
|
|
|
470
489
|
// Overlay currentVersionId to the resolved one and flip isCurrent flags on versions[]. Apply
|
|
471
490
|
// even when rows are empty — a caller who pinned versionId still expects the header + versions[]
|
|
472
491
|
// to reflect that choice (a legitimately-empty version shouldn't fall back to the TC's default).
|
|
473
|
-
|
|
492
|
+
// ponytail: mirror fetchStepsCollection's resolution ladder — the raw doc from /tm/testcase/getById
|
|
493
|
+
// does NOT include a top-level versionId, only a `versions[]` array. Without the versions[0] fallback,
|
|
494
|
+
// tc.currentVersionId stays undefined → tc_save emits versionsDetails.id:"" → backend mints a
|
|
495
|
+
// phantom version and orphans the saved steps.
|
|
496
|
+
const resolvedVer = versionId ?? tc.currentVersionId ?? (Array.isArray(tc.versions) ? tc.versions[0]?.id : undefined);
|
|
474
497
|
const patched = { ...withSteps, ...(resolvedVer ? { currentVersionId: resolvedVer } : {}) };
|
|
475
498
|
if (resolvedVer && patched.versions?.length) {
|
|
476
499
|
patched.versions = patched.versions.map((v) => ({ ...v, isCurrent: v.id === resolvedVer }));
|
|
@@ -997,15 +1020,41 @@ export class SimplifyQaAdapter {
|
|
|
997
1020
|
return str(rows[0], 'id', '_id', 'featureId') ?? ref;
|
|
998
1021
|
}
|
|
999
1022
|
// ---------------------------------------------------------------------- Modules, releases, sprints
|
|
1023
|
+
/** Pages internally until the server returns a short page (or the total is reached) — the tool
|
|
1024
|
+
* has no limit/offset of its own and always wants the WHOLE project tree; `searchBody`'s default
|
|
1025
|
+
* page size (100) previously truncated any project with more modules than that silently. */
|
|
1000
1026
|
async listModules() {
|
|
1001
1027
|
const projectId = this.requireProjectId();
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1028
|
+
const PAGE = 100;
|
|
1029
|
+
const SAFETY_CAP = 5000;
|
|
1030
|
+
const rows = [];
|
|
1031
|
+
let totalCount;
|
|
1032
|
+
let startIndex = 0;
|
|
1033
|
+
for (;;) {
|
|
1034
|
+
const body = this.searchBody([
|
|
1035
|
+
{ column: 'projectId', value: projectId },
|
|
1036
|
+
{ column: 'deleted', value: false },
|
|
1037
|
+
], { selectFields: ['id', 'code', 'name', 'parentId', 'isRoot', 'childIds'], startIndex, limit: PAGE });
|
|
1038
|
+
const raw = await this.http.postJson('/pm/module/search', body);
|
|
1039
|
+
const page = pickArray(raw, 'data', 'content', 'modules');
|
|
1040
|
+
if (totalCount === undefined) {
|
|
1041
|
+
const t = raw?.totalCount ?? raw?.total ?? raw?.totalRecords;
|
|
1042
|
+
if (typeof t === 'number')
|
|
1043
|
+
totalCount = t;
|
|
1044
|
+
}
|
|
1045
|
+
rows.push(...page);
|
|
1046
|
+
if (page.length < PAGE)
|
|
1047
|
+
break;
|
|
1048
|
+
if (rows.length >= SAFETY_CAP)
|
|
1049
|
+
break;
|
|
1050
|
+
if (totalCount !== undefined && rows.length >= totalCount)
|
|
1051
|
+
break;
|
|
1052
|
+
startIndex += PAGE;
|
|
1053
|
+
}
|
|
1054
|
+
const mapped = rows.map(toModuleSummary);
|
|
1055
|
+
if (totalCount !== undefined)
|
|
1056
|
+
mapped.totalCount = totalCount;
|
|
1057
|
+
return mapped;
|
|
1009
1058
|
}
|
|
1010
1059
|
/** GET /pm/module/:id — server returns an array; empty array → NotFound. */
|
|
1011
1060
|
async getModule(id) {
|
|
@@ -1208,23 +1257,73 @@ export class SimplifyQaAdapter {
|
|
|
1208
1257
|
// reaches that branch after emptyIds.push).
|
|
1209
1258
|
return { dryRun: false, deleted: true, dependencies: [], hasBlockers: false };
|
|
1210
1259
|
}
|
|
1260
|
+
/** `list_releases` has no limit/offset of its own — it always wants the WHOLE visible set — so
|
|
1261
|
+
* page internally past `searchBody`'s default 100 instead of silently truncating (same fix as
|
|
1262
|
+
* listModules). */
|
|
1211
1263
|
async listReleases(q) {
|
|
1212
1264
|
const fields = [{ column: 'deleted', value: false }];
|
|
1213
1265
|
if (q.scope === 'project')
|
|
1214
1266
|
fields.push({ column: 'projectIds', value: this.requireProjectId() });
|
|
1215
|
-
const
|
|
1216
|
-
const
|
|
1217
|
-
const rows =
|
|
1218
|
-
|
|
1267
|
+
const PAGE = 100;
|
|
1268
|
+
const SAFETY_CAP = 5000;
|
|
1269
|
+
const rows = [];
|
|
1270
|
+
let totalCount;
|
|
1271
|
+
let startIndex = 0;
|
|
1272
|
+
for (;;) {
|
|
1273
|
+
const body = this.searchBody(fields, { startIndex, limit: PAGE });
|
|
1274
|
+
const raw = await this.http.postJson('/pm/release/search', body);
|
|
1275
|
+
const page = pickArray(raw, 'data', 'content', 'releases');
|
|
1276
|
+
if (totalCount === undefined) {
|
|
1277
|
+
const t = raw?.totalCount ?? raw?.total ?? raw?.totalRecords;
|
|
1278
|
+
if (typeof t === 'number')
|
|
1279
|
+
totalCount = t;
|
|
1280
|
+
}
|
|
1281
|
+
rows.push(...page);
|
|
1282
|
+
if (page.length < PAGE)
|
|
1283
|
+
break;
|
|
1284
|
+
if (rows.length >= SAFETY_CAP)
|
|
1285
|
+
break;
|
|
1286
|
+
if (totalCount !== undefined && rows.length >= totalCount)
|
|
1287
|
+
break;
|
|
1288
|
+
startIndex += PAGE;
|
|
1289
|
+
}
|
|
1290
|
+
const mapped = rows.map(toReleaseSummary);
|
|
1291
|
+
if (totalCount !== undefined)
|
|
1292
|
+
mapped.totalCount = totalCount;
|
|
1293
|
+
return mapped;
|
|
1219
1294
|
}
|
|
1295
|
+
/** Same unconditional-pagination fix as listReleases — `list_sprints` has no limit/offset either. */
|
|
1220
1296
|
async listSprints(q) {
|
|
1221
1297
|
const fields = [{ column: 'deleted', value: false }];
|
|
1222
1298
|
if (q.releaseId)
|
|
1223
1299
|
fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
|
|
1224
|
-
const
|
|
1225
|
-
const
|
|
1226
|
-
const rows =
|
|
1227
|
-
|
|
1300
|
+
const PAGE = 100;
|
|
1301
|
+
const SAFETY_CAP = 5000;
|
|
1302
|
+
const rows = [];
|
|
1303
|
+
let totalCount;
|
|
1304
|
+
let startIndex = 0;
|
|
1305
|
+
for (;;) {
|
|
1306
|
+
const body = this.searchBody(fields, { startIndex, limit: PAGE });
|
|
1307
|
+
const raw = await this.http.postJson('/pm/sprint/search', body);
|
|
1308
|
+
const page = pickArray(raw, 'data', 'content', 'sprints');
|
|
1309
|
+
if (totalCount === undefined) {
|
|
1310
|
+
const t = raw?.totalCount ?? raw?.total ?? raw?.totalRecords;
|
|
1311
|
+
if (typeof t === 'number')
|
|
1312
|
+
totalCount = t;
|
|
1313
|
+
}
|
|
1314
|
+
rows.push(...page);
|
|
1315
|
+
if (page.length < PAGE)
|
|
1316
|
+
break;
|
|
1317
|
+
if (rows.length >= SAFETY_CAP)
|
|
1318
|
+
break;
|
|
1319
|
+
if (totalCount !== undefined && rows.length >= totalCount)
|
|
1320
|
+
break;
|
|
1321
|
+
startIndex += PAGE;
|
|
1322
|
+
}
|
|
1323
|
+
const mapped = rows.map(toSprintSummary);
|
|
1324
|
+
if (totalCount !== undefined)
|
|
1325
|
+
mapped.totalCount = totalCount;
|
|
1326
|
+
return mapped;
|
|
1228
1327
|
}
|
|
1229
1328
|
/** POST /pm/users/search — the customer's users (router mounted at `/pm/users`, plural — see
|
|
1230
1329
|
* project-management/express-app.js:181). Backend auto-scopes non-admin callers to users in
|
|
@@ -2317,8 +2416,10 @@ export class SimplifyQaAdapter {
|
|
|
2317
2416
|
return this.actionCatalogCache;
|
|
2318
2417
|
const projectId = this.requireProjectId();
|
|
2319
2418
|
this.actionCatalogCache = (async () => {
|
|
2320
|
-
//
|
|
2321
|
-
//
|
|
2419
|
+
// Use the SAME filter the SQA UI uses (mobile-app/angular/.../steps.model.ts:59):
|
|
2420
|
+
// {projectId: {$in:[0, projectId]}}. Removing it caused /tm/action/search to return 0 rows in
|
|
2421
|
+
// practice — the backend endpoint apparently requires it as-is despite server-side resolvers
|
|
2422
|
+
// using only {customerId, deleted:false}. Keep the UI's filter to stay compatible.
|
|
2322
2423
|
const body = {
|
|
2323
2424
|
searchFields: [
|
|
2324
2425
|
{ column: 'deleted', value: false },
|
|
@@ -2330,12 +2431,25 @@ export class SimplifyQaAdapter {
|
|
|
2330
2431
|
};
|
|
2331
2432
|
const raw = await this.http.postJson('/tm/action/search', body);
|
|
2332
2433
|
const rows = pickArray(raw, 'data', 'content', 'actions');
|
|
2434
|
+
// Capture diagnostic snapshot so downstream errors can surface WHY the fetch returned 0 rows.
|
|
2435
|
+
try {
|
|
2436
|
+
const topKeys = raw && typeof raw === 'object' ? Object.keys(raw).slice(0, 10).join(',') : typeof raw;
|
|
2437
|
+
const totalCount = raw?.totalCount ?? raw?.total ?? 'n/a';
|
|
2438
|
+
const firstRowKeys = rows[0] && typeof rows[0] === 'object' ? Object.keys(rows[0]).slice(0, 12).join(',') : 'no-rows';
|
|
2439
|
+
this.actionCatalogFetchDiag = `rows.length=${rows.length} totalCount=${totalCount} raw.keys=[${topKeys}] firstRowKeys=[${firstRowKeys}] projectId=${projectId}`;
|
|
2440
|
+
}
|
|
2441
|
+
catch { /* diagnostic must not throw */ }
|
|
2333
2442
|
const map = new Map();
|
|
2334
2443
|
for (const row of rows) {
|
|
2335
2444
|
const idStr = str(row, 'id', '_id', 'actionId');
|
|
2336
2445
|
const name = str(row, 'name');
|
|
2337
|
-
|
|
2338
|
-
|
|
2446
|
+
// Diagnostic proved action rows have NO top-level `technology` field
|
|
2447
|
+
// (firstRowKeys=[id,name,objectRequired,objectTemplate]). SQA UI derives tech by looking up
|
|
2448
|
+
// objectTemplate → object_templates.name — we don't (would need a second fetch). Just require
|
|
2449
|
+
// id; drop the tech-in-key and let names collide across techs. Round-trip lookups (by numeric
|
|
2450
|
+
// id) are unaffected. Model-authored by-name lookups get whichever action registered last for
|
|
2451
|
+
// that name — good enough until cross-tech name collisions actually bite.
|
|
2452
|
+
if (!idStr)
|
|
2339
2453
|
continue;
|
|
2340
2454
|
const id = Number.parseInt(idStr, 10);
|
|
2341
2455
|
if (!Number.isFinite(id))
|
|
@@ -2345,14 +2459,17 @@ export class SimplifyQaAdapter {
|
|
|
2345
2459
|
const objectTemplate = typeof otRaw === 'number' ? otRaw
|
|
2346
2460
|
: typeof otRaw === 'string' && /^\d+$/.test(otRaw) ? Number.parseInt(otRaw, 10)
|
|
2347
2461
|
: undefined;
|
|
2348
|
-
|
|
2349
|
-
//
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2462
|
+
const entry = { id, objectRequired, ...(objectTemplate !== undefined ? { objectTemplate } : {}) };
|
|
2463
|
+
// Name-only key for by-name lookups (may collide across techs — see comment above).
|
|
2464
|
+
if (name) {
|
|
2465
|
+
const key = name.toLowerCase();
|
|
2466
|
+
const prev = map.get(key);
|
|
2467
|
+
const isProjectOverride = typeof row?.projectId === 'number' && row.projectId !== 0;
|
|
2468
|
+
if (!prev || isProjectOverride)
|
|
2469
|
+
map.set(key, entry);
|
|
2470
|
+
}
|
|
2471
|
+
// Id-keyed entry so numeric-actionType round-trip lookups are O(1) instead of a scan.
|
|
2472
|
+
map.set(`id::${id}`, entry);
|
|
2356
2473
|
}
|
|
2357
2474
|
return map;
|
|
2358
2475
|
})().catch((e) => { this.actionCatalogCache = null; throw e; });
|
|
@@ -2409,27 +2526,106 @@ export class SimplifyQaAdapter {
|
|
|
2409
2526
|
async resolveAutomationSteps(projectId, moduleId, tc) {
|
|
2410
2527
|
const catalog = await this.actionCatalog();
|
|
2411
2528
|
const steps = Array.isArray(tc.steps) ? tc.steps : [];
|
|
2529
|
+
// ponytail: objectId can be `undefined` here to mean "don't emit — preserve the backend value".
|
|
2530
|
+
// Used when an existing step (sourceId set) has no round-trip objectRef.id, e.g. because it was
|
|
2531
|
+
// recorded before objectRequired was flipped on the action. Emitting null would $set-clobber the DB.
|
|
2532
|
+
// actionId also nullable for control-flow / function / switch / function_end — those steps don't
|
|
2533
|
+
// have an action; buildAutomationPayload omits action/object emit when actionId is null.
|
|
2412
2534
|
const out = [];
|
|
2413
2535
|
for (let i = 0; i < steps.length; i++) {
|
|
2414
2536
|
const s = steps[i];
|
|
2537
|
+
// ponytail: control-flow / function / switch / function_end steps have no action to resolve.
|
|
2538
|
+
// Push a null-actionId slot so index alignment survives; mapper checks and skips action/object
|
|
2539
|
+
// emit for these. Without this, IF/FOR/WHILE/FUNCTION steps throw "missing actionType" on save.
|
|
2540
|
+
if (s.stepType === 'control_flow' || s.stepType === 'function' || s.stepType === 'function_end' || s.stepType === 'switch' || s.stepType === 'manual') {
|
|
2541
|
+
out.push({ actionId: null, objectId: null });
|
|
2542
|
+
continue;
|
|
2543
|
+
}
|
|
2415
2544
|
const tech = (TECH_BY_STEPTYPE[s.stepType] ?? deriveTcType(tc)).toUpperCase();
|
|
2416
2545
|
const name = (s.actionType ?? '').trim();
|
|
2417
2546
|
if (!name) {
|
|
2418
2547
|
throw new Error(`Automation step ${i + 1} ("${s.description}"): missing actionType — the neutral Step.actionType must name an SQA action (e.g. "Click", "Type Text", "Launch Application").`);
|
|
2419
2548
|
}
|
|
2420
|
-
|
|
2549
|
+
// Round-trip support: a TC hydrated via getTestCase carries actionType = String(actionId), so
|
|
2550
|
+
// on save we may see a numeric string here. Catalog is dual-keyed: `id::N` for round-trip,
|
|
2551
|
+
// `<lowercase name>` for model-authored by-name lookups. Tech is not in the key because
|
|
2552
|
+
// action rows have no top-level `technology` field (SQA UI resolves via objectTemplate lookup).
|
|
2553
|
+
const isNumericId = /^\d+$/.test(name);
|
|
2554
|
+
let meta = isNumericId
|
|
2555
|
+
? catalog.get(`id::${Number.parseInt(name, 10)}`)
|
|
2556
|
+
: catalog.get(name.toLowerCase());
|
|
2557
|
+
// Fallback: if the by-id scan misses (action created in a different project and thus not in
|
|
2558
|
+
// the UI's projectId-filtered catalog), fetch it directly via GET /tm/action/:id. Only tries
|
|
2559
|
+
// for numeric-id lookups — model-authored name lookups have no id to fetch.
|
|
2560
|
+
let fallbackDiag = '';
|
|
2561
|
+
if (!meta && isNumericId) {
|
|
2562
|
+
try {
|
|
2563
|
+
const raw = await this.http.getJson(`/tm/action/${Number.parseInt(name, 10)}`);
|
|
2564
|
+
const row = raw?.data && typeof raw.data === 'object' ? raw.data
|
|
2565
|
+
: Array.isArray(raw?.data) ? raw.data[0]
|
|
2566
|
+
: Array.isArray(raw) ? raw[0]
|
|
2567
|
+
: raw;
|
|
2568
|
+
const idStr = str(row, 'id', '_id', 'actionId');
|
|
2569
|
+
// ponytail: no `technology` gate. Catalog builder proved action rows have no top-level
|
|
2570
|
+
// technology field (see comment at ~line 2357); requiring it here made the fallback
|
|
2571
|
+
// never succeed — every cross-project action id threw "no SQA action named X".
|
|
2572
|
+
if (idStr) {
|
|
2573
|
+
const otRaw = row?.objectTemplate;
|
|
2574
|
+
const objectTemplate = typeof otRaw === 'number' ? otRaw
|
|
2575
|
+
: typeof otRaw === 'string' && /^\d+$/.test(otRaw) ? Number.parseInt(otRaw, 10)
|
|
2576
|
+
: undefined;
|
|
2577
|
+
meta = {
|
|
2578
|
+
id: Number.parseInt(idStr, 10),
|
|
2579
|
+
objectRequired: row?.objectRequired === true,
|
|
2580
|
+
...(objectTemplate !== undefined ? { objectTemplate } : {}),
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
else {
|
|
2584
|
+
fallbackDiag = ` [GET /tm/action/${name} returned no usable row (idStr=null). Raw keys: ${row && typeof row === 'object' ? Object.keys(row).join(',') : typeof row}]`;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
catch (e) {
|
|
2588
|
+
fallbackDiag = ` [GET /tm/action/${name} failed: ${e.message}]`;
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2421
2591
|
if (!meta) {
|
|
2422
|
-
throw new Error(`Automation step ${i + 1}: no SQA action named "${name}" for technology ${tech}.
|
|
2592
|
+
throw new Error(`Automation step ${i + 1}: no SQA action named "${name}" for technology ${tech}. Catalog size=${catalog.size}, numericId=${isNumericId}. FetchDiag: ${this.actionCatalogFetchDiag}.${fallbackDiag}`);
|
|
2423
2593
|
}
|
|
2424
2594
|
let objectId = null;
|
|
2425
2595
|
if (meta.objectRequired) {
|
|
2426
|
-
|
|
2427
|
-
|
|
2596
|
+
// Resolution ladder (first match wins):
|
|
2597
|
+
// 1. objectRef.id set → use directly (round-trip OR LLM referencing a known object)
|
|
2598
|
+
// 2. sourceId set but no objectRef.id → preserve DB value (existing step, backend may
|
|
2599
|
+
// have null historically; emitting anything would $set-clobber)
|
|
2600
|
+
// 3. objectRef.name only (no locators) → project-scoped name lookup (LLM added a step
|
|
2601
|
+
// referencing an existing Object Repository entry by name — mirrors UI dropdown)
|
|
2602
|
+
// 4. objectRef.name + locators → get-or-create via createObjectSeq (recorder path)
|
|
2603
|
+
// 5. nothing usable → throw with the three-option error so the caller can pick
|
|
2604
|
+
const roundTripId = s.objectRef?.id ? Number.parseInt(String(s.objectRef.id), 10) : NaN;
|
|
2605
|
+
if (Number.isFinite(roundTripId)) {
|
|
2606
|
+
objectId = roundTripId;
|
|
2607
|
+
}
|
|
2608
|
+
else if (s.sourceId) {
|
|
2609
|
+
objectId = undefined;
|
|
2610
|
+
}
|
|
2611
|
+
else if (s.objectRef?.name && !(s.objectRef.locators?.length)) {
|
|
2612
|
+
const found = await this.findObjectIdByName(projectId, s.objectRef.name);
|
|
2613
|
+
if (found != null) {
|
|
2614
|
+
objectId = found;
|
|
2615
|
+
}
|
|
2616
|
+
else {
|
|
2617
|
+
throw new Error(`Automation step ${i + 1} ("${name}"): no object named "${s.objectRef.name}" found in this project. Either pass objectRef.id to reuse an existing one, or add objectRef.locators:[{name, value}, ...] to create a new object with that name.`);
|
|
2618
|
+
}
|
|
2428
2619
|
}
|
|
2429
|
-
if (
|
|
2430
|
-
|
|
2620
|
+
else if (s.objectRef?.name && s.objectRef.locators?.length) {
|
|
2621
|
+
if (meta.objectTemplate == null) {
|
|
2622
|
+
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.`);
|
|
2623
|
+
}
|
|
2624
|
+
objectId = await this.resolveObjectId(s.objectRef, projectId, moduleId, tech, meta.objectTemplate);
|
|
2625
|
+
}
|
|
2626
|
+
else {
|
|
2627
|
+
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.`);
|
|
2431
2628
|
}
|
|
2432
|
-
objectId = await this.resolveObjectId(s.objectRef, projectId, moduleId, tech, meta.objectTemplate);
|
|
2433
2629
|
}
|
|
2434
2630
|
out.push({ actionId: meta.id, objectId });
|
|
2435
2631
|
}
|
|
@@ -2458,6 +2654,28 @@ export class SimplifyQaAdapter {
|
|
|
2458
2654
|
}
|
|
2459
2655
|
return null;
|
|
2460
2656
|
}
|
|
2657
|
+
/** Case-insensitive project-scoped lookup of an existing object by `disname` (the UI's Name).
|
|
2658
|
+
* Lets tc_draft_update pass `objectRef:{name}` for an already-defined object without also
|
|
2659
|
+
* supplying locators — mirrors what a user picking from the Object Repository dropdown does. */
|
|
2660
|
+
async findObjectIdByName(projectId, name) {
|
|
2661
|
+
const body = this.searchBody([
|
|
2662
|
+
{ column: 'disname', value: name },
|
|
2663
|
+
{ column: 'projectId', value: projectId },
|
|
2664
|
+
{ column: 'deleted', value: false },
|
|
2665
|
+
], { startIndex: 0, limit: 10 });
|
|
2666
|
+
body['searchFields'][0]['ignorecase'] = true;
|
|
2667
|
+
const raw = await this.http.postJson('/tm/object/search', body);
|
|
2668
|
+
const rows = pickArray(raw, 'data', 'content', 'objects');
|
|
2669
|
+
for (const row of rows) {
|
|
2670
|
+
const rowName = str(row, 'disname');
|
|
2671
|
+
if (rowName?.toLowerCase() === name.toLowerCase()) {
|
|
2672
|
+
const id = str(row, 'id', '_id');
|
|
2673
|
+
if (id)
|
|
2674
|
+
return Number.parseInt(id, 10);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return null;
|
|
2678
|
+
}
|
|
2461
2679
|
async getOrCreateParameter(projectId, moduleId, name, defaultValue) {
|
|
2462
2680
|
const existing = await this.findParameterId(projectId, name);
|
|
2463
2681
|
if (existing != null)
|
|
@@ -2629,6 +2847,25 @@ export class SimplifyQaAdapter {
|
|
|
2629
2847
|
doc = doc.length > 0 ? doc[0] : {};
|
|
2630
2848
|
return doc && typeof doc === 'object' ? doc : {};
|
|
2631
2849
|
}
|
|
2850
|
+
/** Mirrors sqa-ui `buildExecutionPlatform` (suites.component.ts:779) so MCP-added TCs save with
|
|
2851
|
+
* the same per-row platform the UI would set — otherwise the suite trips the "select execution
|
|
2852
|
+
* platform" warning at run time. Special types self-identify; WEB falls back to Chrome so
|
|
2853
|
+
* suite_run doesn't send `platform: []`; mobile with no bound device stays empty (UI does same). */
|
|
2854
|
+
buildExecutionPlatformForEntry(raw, tcType) {
|
|
2855
|
+
const special = new Set(['API', 'MAINFRAME', 'SAP', 'DESKTOP', 'DATABASE']);
|
|
2856
|
+
if (special.has(tcType)) {
|
|
2857
|
+
const p = { id: tcType, name: tcType, technologyType: tcType, type: 'NONE' };
|
|
2858
|
+
return { LOCAL: p, REMOTE: { ...p }, CLOUD: { ...p } };
|
|
2859
|
+
}
|
|
2860
|
+
const own = raw?.['executionPlatform'];
|
|
2861
|
+
if (own && typeof own === 'object' && own.id) {
|
|
2862
|
+
return { LOCAL: own, REMOTE: {}, CLOUD: {} };
|
|
2863
|
+
}
|
|
2864
|
+
if (tcType === 'WEB') {
|
|
2865
|
+
return { LOCAL: { id: 'CHROME', name: 'Chrome', technologyType: 'WEB', type: 'NONE' }, REMOTE: {}, CLOUD: {} };
|
|
2866
|
+
}
|
|
2867
|
+
return { LOCAL: {}, REMOTE: {}, CLOUD: {} };
|
|
2868
|
+
}
|
|
2632
2869
|
/** Assemble the strict allow-listed suite entry for an ADD op. Required fields per the SQA
|
|
2633
2870
|
* validator: testcaseId, code, seq (set by the caller), version, versionId, tcType. */
|
|
2634
2871
|
async buildSuiteEntryFromTestcase(tcNum) {
|
|
@@ -2651,7 +2888,7 @@ export class SimplifyQaAdapter {
|
|
|
2651
2888
|
...(raw?.projectId !== undefined ? { projectId: raw.projectId } : {}),
|
|
2652
2889
|
version: versionName ?? 'v1.0',
|
|
2653
2890
|
versionId: versionId ?? '',
|
|
2654
|
-
executionPlatform:
|
|
2891
|
+
executionPlatform: this.buildExecutionPlatformForEntry(raw, tcType),
|
|
2655
2892
|
};
|
|
2656
2893
|
}
|
|
2657
2894
|
async resolveObjectTemplateId(technology) {
|
|
@@ -2888,7 +3125,19 @@ export class SimplifyQaAdapter {
|
|
|
2888
3125
|
}
|
|
2889
3126
|
// ---------------------------------------------------------------------- Actions catalog
|
|
2890
3127
|
async actionsCatalog(platform) {
|
|
2891
|
-
|
|
3128
|
+
// Build from the live backend catalog (already cached per-connection). Each entry gets its
|
|
3129
|
+
// numeric id + objectRequired flag so callers can round-trip actionType ↔ actionId. Ignores
|
|
3130
|
+
// `platform` — action rows carry no top-level technology; tech disambiguation is a follow-up
|
|
3131
|
+
// (would need an object_templates lookup). Skips the `id::N` reverse-lookup keys.
|
|
3132
|
+
void platform;
|
|
3133
|
+
const catalog = await this.actionCatalog();
|
|
3134
|
+
const defs = [];
|
|
3135
|
+
for (const [key, meta] of catalog) {
|
|
3136
|
+
if (key.startsWith('id::'))
|
|
3137
|
+
continue; // reverse-lookup entry — already covered by name key
|
|
3138
|
+
defs.push({ actionType: key, platform: 'unknown', id: meta.id, objectRequired: meta.objectRequired });
|
|
3139
|
+
}
|
|
3140
|
+
return defs;
|
|
2892
3141
|
}
|
|
2893
3142
|
// ---------------------------------------------------------------------- Testdata generation [Step 3]
|
|
2894
3143
|
/** Fetch the default (template) iteration row for a TC. Fills gaps from the raw TC:
|
|
@@ -3194,7 +3443,24 @@ export class SimplifyQaAdapter {
|
|
|
3194
3443
|
executionId, executionRunId, parentId, projectId,
|
|
3195
3444
|
});
|
|
3196
3445
|
const rawSteps = Array.isArray(stepsResp?.data) ? stepsResp.data : [];
|
|
3197
|
-
|
|
3446
|
+
// Backend logs control-flow markers as INFO rows with `description: null` (see
|
|
3447
|
+
// executionStepResult.service.js insertLoopIterationStart/insertFunctionEnd/… — the same
|
|
3448
|
+
// executionRunId carries LOOP_ITERATION_START/END, FUNCTION_START/END, FUNCTION_ITERATION_*,
|
|
3449
|
+
// WHILE_START/END alongside the real action rows). Left in, they fill the text response
|
|
3450
|
+
// with blank `• step N: (info)` lines that hide the actual failing step. Filter here so
|
|
3451
|
+
// stepResults contains only debuggable rows.
|
|
3452
|
+
const NOISE_STEP_TYPES = new Set([
|
|
3453
|
+
'LOOP_ITERATION_START', 'LOOP_ITERATION_END',
|
|
3454
|
+
'FUNCTION_START', 'FUNCTION_END',
|
|
3455
|
+
'FUNCTION_ITERATION_START', 'FUNCTION_ITERATION_END',
|
|
3456
|
+
'FOR_START', 'FOR_END', 'WHILE_START', 'WHILE_END',
|
|
3457
|
+
'ITERATOR_START', 'ITERATOR_END', 'DO',
|
|
3458
|
+
]);
|
|
3459
|
+
const filtered = rawSteps.filter((s) => {
|
|
3460
|
+
const st = String(s?.stepType ?? '').toUpperCase();
|
|
3461
|
+
return !NOISE_STEP_TYPES.has(st);
|
|
3462
|
+
});
|
|
3463
|
+
return filtered.map((s) => {
|
|
3198
3464
|
const step = {
|
|
3199
3465
|
n: typeof s?.seq === 'number' ? s.seq : 0,
|
|
3200
3466
|
description: String(s?.description ?? ''),
|
|
@@ -3264,15 +3530,15 @@ export class SimplifyQaAdapter {
|
|
|
3264
3530
|
reportUrl: `${base}/user/reports/reports-page?type=TESTCASE&parentId=${parentId}&id=${runIdNum}&projectId=${projectId}`,
|
|
3265
3531
|
};
|
|
3266
3532
|
}
|
|
3267
|
-
/** Most recent execution runId for a SUITE — same `/exec/execution/search` the /reports UI's
|
|
3268
|
-
* current-executions view drives, filtered
|
|
3269
|
-
*
|
|
3270
|
-
*
|
|
3271
|
-
async
|
|
3533
|
+
/** Most recent execution runId for a SUITE or TC — same `/exec/execution/search` the /reports UI's
|
|
3534
|
+
* current-executions view drives, filtered by `type` + the entity's numeric id, sorted id desc,
|
|
3535
|
+
* limit 1. `childExecution:false` + the `parentExecutionCopy nor` clause together drop suite-child
|
|
3536
|
+
* TC rows, so a TC lookup returns its LATEST STANDALONE run, not the child spawned inside a suite. */
|
|
3537
|
+
async getLatestRunFor(type, id) {
|
|
3272
3538
|
const projectId = this.requireProjectId();
|
|
3273
|
-
const
|
|
3274
|
-
if (!Number.isFinite(
|
|
3275
|
-
throw new Error(`
|
|
3539
|
+
const num = Number.parseInt(numericId(id), 10);
|
|
3540
|
+
if (!Number.isFinite(num))
|
|
3541
|
+
throw new Error(`getLatestRunFor(${type}): invalid id ${id}.`);
|
|
3276
3542
|
// Hand-rolled payload — searchBody doesn't emit `sort` or `operator` keys the /reports search wants.
|
|
3277
3543
|
const body = {
|
|
3278
3544
|
searchFields: [
|
|
@@ -3281,16 +3547,83 @@ export class SimplifyQaAdapter {
|
|
|
3281
3547
|
{ column: 'projectId', value: projectId, regEx: false },
|
|
3282
3548
|
{ column: 'childExecution', value: false },
|
|
3283
3549
|
{ column: 'parentExecutionCopy', value: true, operator: 'nor' },
|
|
3284
|
-
{ column: 'type', value:
|
|
3285
|
-
{ column: 'executionTypeId', value:
|
|
3550
|
+
{ column: 'type', value: type, regEx: false },
|
|
3551
|
+
{ column: 'executionTypeId', value: num, regEx: false },
|
|
3286
3552
|
],
|
|
3287
3553
|
startIndex: 0,
|
|
3288
3554
|
limit: 1,
|
|
3289
3555
|
};
|
|
3290
3556
|
const raw = await this.http.postJson('/exec/execution/search', body);
|
|
3291
3557
|
const rows = pickArray(raw, 'data', 'content', 'executions');
|
|
3292
|
-
|
|
3293
|
-
|
|
3558
|
+
return str(rows[0], 'id', '_id') ?? null;
|
|
3559
|
+
}
|
|
3560
|
+
async getLatestSuiteRun(suiteId) { return this.getLatestRunFor('SUITE', suiteId); }
|
|
3561
|
+
async getLatestTestcaseRun(tcId) { return this.getLatestRunFor('TC', tcId); }
|
|
3562
|
+
/** Top-N recent runs — one HTTP call, same standalone-only filters as `getLatestRunFor`. For each
|
|
3563
|
+
* SUITE row the exec doc's `executionItems[]` already carries per-TC status, so the tally is
|
|
3564
|
+
* computed inline (no extra call per run). TC rows omit the tally counters. Sorted newest-first
|
|
3565
|
+
* by `id` (execution ids are monotonic). Bounded — callers should cap `limit` at the tool layer
|
|
3566
|
+
* so a runaway request can't pull thousands of rows. */
|
|
3567
|
+
async listRecentRuns(type, id, limit) {
|
|
3568
|
+
const projectId = this.requireProjectId();
|
|
3569
|
+
const num = Number.parseInt(numericId(id), 10);
|
|
3570
|
+
if (!Number.isFinite(num))
|
|
3571
|
+
throw new Error(`listRecentRuns(${type}): invalid id ${id}.`);
|
|
3572
|
+
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
3573
|
+
const body = {
|
|
3574
|
+
searchFields: [
|
|
3575
|
+
{ column: 'id', sort: 'dsc' },
|
|
3576
|
+
{ column: 'deleted', value: false },
|
|
3577
|
+
{ column: 'projectId', value: projectId, regEx: false },
|
|
3578
|
+
{ column: 'childExecution', value: false },
|
|
3579
|
+
{ column: 'parentExecutionCopy', value: true, operator: 'nor' },
|
|
3580
|
+
{ column: 'type', value: type, regEx: false },
|
|
3581
|
+
{ column: 'executionTypeId', value: num, regEx: false },
|
|
3582
|
+
],
|
|
3583
|
+
startIndex: 0,
|
|
3584
|
+
limit,
|
|
3585
|
+
};
|
|
3586
|
+
const raw = await this.http.postJson('/exec/execution/search', body);
|
|
3587
|
+
const rows = pickArray(raw, 'data', 'content', 'executions');
|
|
3588
|
+
return rows.map((r) => {
|
|
3589
|
+
const runId = str(r, 'id', '_id') ?? '';
|
|
3590
|
+
const runIdNum = Number.parseInt(runId, 10);
|
|
3591
|
+
const summary = {
|
|
3592
|
+
runId,
|
|
3593
|
+
state: mapRunState(str(r, 'status') ?? ''),
|
|
3594
|
+
};
|
|
3595
|
+
const startedAt = str(r, 'startTime', 'createdAt');
|
|
3596
|
+
const endedAt = str(r, 'endTime');
|
|
3597
|
+
if (startedAt)
|
|
3598
|
+
summary.startedAt = startedAt;
|
|
3599
|
+
if (endedAt)
|
|
3600
|
+
summary.endedAt = endedAt;
|
|
3601
|
+
if (Number.isFinite(runIdNum)) {
|
|
3602
|
+
const entityNum = Number(r?.executionTypeId ?? num);
|
|
3603
|
+
summary.reportUrl = `${base}/user/reports/reports-page?type=${type === 'SUITE' ? 'SUITE' : 'TESTCASE'}&parentId=${entityNum}&id=${runIdNum}&projectId=${projectId}`;
|
|
3604
|
+
}
|
|
3605
|
+
if (type === 'SUITE') {
|
|
3606
|
+
const items = Array.isArray(r?.executionItems) ? r.executionItems : [];
|
|
3607
|
+
let passed = 0, failed = 0, skipped = 0, notExecuted = 0;
|
|
3608
|
+
for (const it of items) {
|
|
3609
|
+
const s = String(it?.status ?? '').toUpperCase();
|
|
3610
|
+
if (s === 'PASSED')
|
|
3611
|
+
passed++;
|
|
3612
|
+
else if (s === 'FAILED')
|
|
3613
|
+
failed++;
|
|
3614
|
+
else if (s === 'SKIPPED')
|
|
3615
|
+
skipped++;
|
|
3616
|
+
else if (s === 'NOTEXECUTED')
|
|
3617
|
+
notExecuted++;
|
|
3618
|
+
}
|
|
3619
|
+
summary.totalTestCases = items.length;
|
|
3620
|
+
summary.passed = passed;
|
|
3621
|
+
summary.failed = failed;
|
|
3622
|
+
summary.skipped = skipped;
|
|
3623
|
+
summary.notExecuted = notExecuted;
|
|
3624
|
+
}
|
|
3625
|
+
return summary;
|
|
3626
|
+
});
|
|
3294
3627
|
}
|
|
3295
3628
|
/** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
|
|
3296
3629
|
* is paged (default 100) and API-key scoping can hide the selected project, which left
|
|
@@ -3314,11 +3647,10 @@ export class SimplifyQaAdapter {
|
|
|
3314
3647
|
// SUITE runs are not yet ported and still hop (or error clearly).
|
|
3315
3648
|
async startRun(target, opts) {
|
|
3316
3649
|
const projectId = this.requireProjectId();
|
|
3650
|
+
if (target.suiteId && !target.tcId) {
|
|
3651
|
+
return this.startSuiteRun(target.suiteId, opts);
|
|
3652
|
+
}
|
|
3317
3653
|
if (!target.tcId) {
|
|
3318
|
-
if (target.suiteId) {
|
|
3319
|
-
throw new Error('startRun: SUITE runs are not yet ported to direct REST — set QA_MCP_URL so common-mcp ' +
|
|
3320
|
-
'can hop to the SimplifyQA agent for suite dispatch, or run a single TC.');
|
|
3321
|
-
}
|
|
3322
3654
|
throw new Error('startRun: target.tcId (or target.suiteId) is required.');
|
|
3323
3655
|
}
|
|
3324
3656
|
const tcId = target.tcId;
|
|
@@ -3486,11 +3818,201 @@ export class SimplifyQaAdapter {
|
|
|
3486
3818
|
const executionId = Number.parseInt(numericId(runId), 10);
|
|
3487
3819
|
if (!Number.isFinite(executionId))
|
|
3488
3820
|
throw new Error(`stopRun: invalid runId ${runId}.`);
|
|
3489
|
-
|
|
3821
|
+
// Mirror sqa-ui reports.component.ts:2041-2054: cloud-parallel needs a dedicated killType so
|
|
3822
|
+
// the backend fans the Redis kill out to every child agent (executions/…/execution.service.js
|
|
3823
|
+
// :4610-4657). Plain STOP_EXECUTION only pushes to the parent's agentId, leaving cloud-parallel
|
|
3824
|
+
// child VMs running even though their DB rows read FAILED.
|
|
3825
|
+
const doc = await this.fetchExecDoc(executionId, projectId);
|
|
3826
|
+
const mode = String(doc?.mode ?? '').toUpperCase();
|
|
3827
|
+
const style = String(doc?.executionStyle ?? '').toUpperCase();
|
|
3828
|
+
const killType = mode === 'CLOUD' && style === 'PARALLEL' ? 'STOP_EXECUTION_PARALLEL_CLOUD' : 'STOP_EXECUTION';
|
|
3829
|
+
await this.http.postJson('/exec/execution/stopExecution', { projectId, executionId, killType });
|
|
3830
|
+
}
|
|
3831
|
+
/** SUITE dispatch. Mirrors `startRun` above (which handles TC) — same `/exec/execution` endpoint,
|
|
3832
|
+
* same LOCAL-agent fast-fail, same project-name / environment resolution. What differs is on the
|
|
3833
|
+
* wire: `type: 'SUITE'`, `executionType` sourced from `suite.suiteType`, `mode` defaulting to
|
|
3834
|
+
* `suite.executionType`, a populated `globalConfiguration` block, and one `executionItems` entry
|
|
3835
|
+
* PER member — each carrying the suite row's persisted `versionId` / `stepId` / `platform` / `seq`
|
|
3836
|
+
* (NOT a re-derived random step id). See sqa-ui execution-popup-modal.component.ts:621-1105 SU
|
|
3837
|
+
* branch for the authoritative UI builder; every field below traces back to a line there. */
|
|
3838
|
+
async startSuiteRun(suiteId, opts) {
|
|
3839
|
+
const projectId = this.requireProjectId();
|
|
3840
|
+
const suiteNum = Number.parseInt(numericId(suiteId), 10);
|
|
3841
|
+
if (!Number.isFinite(suiteNum))
|
|
3842
|
+
throw new Error(`startRun: could not resolve numeric id for ${suiteId}.`);
|
|
3843
|
+
const rawSuite = await this.fetchRawSuite(suiteId);
|
|
3844
|
+
if (!rawSuite || Object.keys(rawSuite).length === 0) {
|
|
3845
|
+
throw new Error(`startRun: suite ${suiteId} not found.`);
|
|
3846
|
+
}
|
|
3847
|
+
// Effective mode: caller pick wins (matches our tool's cascade UX); fall back to the suite doc's
|
|
3848
|
+
// stored `executionType`, which the UI reads unconditionally (execution-popup-modal.ts:668).
|
|
3849
|
+
const mode = (opts.mode ?? String(rawSuite.executionType ?? 'LOCAL')).toUpperCase();
|
|
3850
|
+
if (mode === 'LOCAL' && !(await this.probeLocalAgent())) {
|
|
3851
|
+
throw new Error(`startRun (LOCAL): SimplifyQA desktop agent is not reachable at ` +
|
|
3852
|
+
`${this.cfg.url ?? '(no agent URL configured — set QA_MCP_URL)'}. Start the desktop ` +
|
|
3853
|
+
`agent, then try again. Use mode:'cloud' to run on a cloud runner instead — it does not ` +
|
|
3854
|
+
`need the desktop agent.`);
|
|
3855
|
+
}
|
|
3856
|
+
const projectName = await this.lookupProjectName(projectId);
|
|
3857
|
+
if (!projectName) {
|
|
3858
|
+
throw new Error(`startRun: could not resolve project name for projectId=${projectId}. ` +
|
|
3859
|
+
`The token may not have read access to this project.`);
|
|
3860
|
+
}
|
|
3861
|
+
const env = await this.resolveEnvironment(projectId, opts.environment);
|
|
3862
|
+
const suiteType = String(rawSuite.suiteType ?? 'AUTOMATION').toUpperCase();
|
|
3863
|
+
// Build one executionItems entry per suite row, in suite order. Silently skip rows whose TC no
|
|
3864
|
+
// longer exists (matches UI :880 `if (!data) return;`). Every item uses the suite row's stored
|
|
3865
|
+
// versionId + stepId + per-mode platform — regenerating any of those splits step correlation
|
|
3866
|
+
// and breaks the agent's iteration join. Missing platform for a row is not an error here (UI
|
|
3867
|
+
// sends `[]`) — the agent tolerates it for MANUAL suites; AUTOMATION suites without a saved
|
|
3868
|
+
// platform were misconfigured upstream and would fail regardless.
|
|
3869
|
+
const members = Array.isArray(rawSuite.testCases) ? rawSuite.testCases : [];
|
|
3870
|
+
const executionItems = [];
|
|
3871
|
+
let seqCounter = 0;
|
|
3872
|
+
for (const row of members) {
|
|
3873
|
+
seqCounter++;
|
|
3874
|
+
const memberTcId = row?.testcaseId;
|
|
3875
|
+
if (memberTcId === undefined || memberTcId === null)
|
|
3876
|
+
continue;
|
|
3877
|
+
let rawTc;
|
|
3878
|
+
try {
|
|
3879
|
+
rawTc = await this.fetchRawTestcase(String(memberTcId));
|
|
3880
|
+
}
|
|
3881
|
+
catch {
|
|
3882
|
+
continue;
|
|
3883
|
+
}
|
|
3884
|
+
if (!rawTc || Object.keys(rawTc).length === 0)
|
|
3885
|
+
continue;
|
|
3886
|
+
const tcType = String(str(rawTc, 'tcType') ?? 'WEB').toUpperCase();
|
|
3887
|
+
const versions = Array.isArray(rawTc?.versions) ? rawTc.versions : [];
|
|
3888
|
+
const rowVersionId = row?.versionId ?? row?.version;
|
|
3889
|
+
const versionMatch = rowVersionId
|
|
3890
|
+
? versions.find((v) => str(v, 'id', '_id') === String(rowVersionId) || str(v, 'name') === String(rowVersionId))
|
|
3891
|
+
: undefined;
|
|
3892
|
+
const versionId = str(versionMatch, 'id', '_id') ?? (rowVersionId !== undefined ? String(rowVersionId) : '');
|
|
3893
|
+
const versionName = str(versionMatch, 'name') ?? str(rawTc, 'versionName', 'selectedVersionName') ?? 'v1.0';
|
|
3894
|
+
// Per-mode platform from the suite row. UI reads `data1.executionPlatform[mode]` (:882-965);
|
|
3895
|
+
// when saved, it's a single object → wrap in `[obj]`; if the row didn't save one for this
|
|
3896
|
+
// mode, send `[]` and let the backend reject/accept per its own rules.
|
|
3897
|
+
const savedPlatform = row?.executionPlatform?.[mode];
|
|
3898
|
+
let platform = [];
|
|
3899
|
+
if (savedPlatform && typeof savedPlatform === 'object') {
|
|
3900
|
+
const p = {
|
|
3901
|
+
name: savedPlatform.name,
|
|
3902
|
+
id: savedPlatform.id ?? savedPlatform.name,
|
|
3903
|
+
technologyType: String(savedPlatform.technologyType ?? tcType).toUpperCase(),
|
|
3904
|
+
type: savedPlatform.type ?? 'NONE',
|
|
3905
|
+
};
|
|
3906
|
+
if (savedPlatform.cloudDeviceType)
|
|
3907
|
+
p['cloudDeviceType'] = savedPlatform.cloudDeviceType;
|
|
3908
|
+
if (savedPlatform.osVersion)
|
|
3909
|
+
p['osVersion'] = savedPlatform.osVersion;
|
|
3910
|
+
platform = [p];
|
|
3911
|
+
}
|
|
3912
|
+
else if (['API', 'MAINFRAME', 'SAP', 'DESKTOP', 'DATABASE'].includes(tcType)) {
|
|
3913
|
+
platform = [{ name: tcType, id: tcType, technologyType: tcType, type: 'NONE' }];
|
|
3914
|
+
}
|
|
3915
|
+
const moduleIdNum = typeof rawTc?.moduleId === 'number' ? rawTc.moduleId : 0;
|
|
3916
|
+
let moduleName = '';
|
|
3917
|
+
if (moduleIdNum > 0) {
|
|
3918
|
+
try {
|
|
3919
|
+
const body = this.searchBody([{ column: 'id', value: moduleIdNum }, { column: 'deleted', value: false }], { selectFields: ['id', 'name'], limit: 1 });
|
|
3920
|
+
const raw = await this.http.postJson('/pm/module/search', body);
|
|
3921
|
+
const rows = pickArray(raw, 'data', 'content', 'modules');
|
|
3922
|
+
moduleName = str(rows[0], 'name') ?? '';
|
|
3923
|
+
}
|
|
3924
|
+
catch { /* leave empty */ }
|
|
3925
|
+
}
|
|
3926
|
+
executionItems.push({
|
|
3927
|
+
id: typeof rawTc?.id === 'number' ? rawTc.id : Number.parseInt(numericId(String(memberTcId)), 10),
|
|
3928
|
+
name: str(rawTc, 'name', 'title') ?? '',
|
|
3929
|
+
description: str(rawTc, 'description') ?? '',
|
|
3930
|
+
versionId,
|
|
3931
|
+
versionName,
|
|
3932
|
+
seq: typeof row?.seq === 'number' ? row.seq : seqCounter,
|
|
3933
|
+
stepId: row?.stepId ?? '', // MUST come from the suite row, never regenerated
|
|
3934
|
+
stepCount: -1, // suite items send -1 (TC sends 0)
|
|
3935
|
+
technologyType: tcType,
|
|
3936
|
+
type: 'TC', // every item is 'TC'; suite-ness lives at top-level type
|
|
3937
|
+
platform,
|
|
3938
|
+
projectId,
|
|
3939
|
+
projectName,
|
|
3940
|
+
configuration: {},
|
|
3941
|
+
iterationsSelected: [],
|
|
3942
|
+
moduleId: moduleIdNum,
|
|
3943
|
+
userstoryId: typeof rawTc?.userstoryId === 'number' ? rawTc.userstoryId : 0,
|
|
3944
|
+
tags: Array.isArray(rawTc?.tags) ? rawTc.tags : [],
|
|
3945
|
+
code: str(rawTc, 'code') ?? '',
|
|
3946
|
+
prerequisite: str(rawTc, 'prerequisite', 'preconditions') ?? '',
|
|
3947
|
+
moduleName,
|
|
3948
|
+
userstoryName: '',
|
|
3949
|
+
});
|
|
3950
|
+
}
|
|
3951
|
+
if (executionItems.length === 0) {
|
|
3952
|
+
throw new Error(`startRun: suite ${suiteId} has no runnable test cases (all members were deleted, ` +
|
|
3953
|
+
`unreadable, or filtered out).`);
|
|
3954
|
+
}
|
|
3955
|
+
// agentId derivation (mirrors UI :677). LOCAL always uses this machine's agent; REMOTE/CLOUD
|
|
3956
|
+
// pin the suite doc's saved `agentId` (or null when the suite wasn't pinned). CLOUD_AGENT
|
|
3957
|
+
// fallback (:771-774) promotes `cloudType` to 'SQA'.
|
|
3958
|
+
let agentId;
|
|
3959
|
+
if (mode === 'LOCAL') {
|
|
3960
|
+
agentId = await this.resolveLocalAgentId(opts.agentId);
|
|
3961
|
+
}
|
|
3962
|
+
else if (mode === 'REMOTE') {
|
|
3963
|
+
const preferred = opts.agentId ?? (typeof rawSuite.agentId === 'string' ? rawSuite.agentId : undefined);
|
|
3964
|
+
agentId = preferred ? await this.resolveRemoteAgentId(preferred) : null;
|
|
3965
|
+
}
|
|
3966
|
+
else {
|
|
3967
|
+
agentId = typeof rawSuite.agentId === 'string' && rawSuite.agentId.length > 0 ? rawSuite.agentId : null;
|
|
3968
|
+
}
|
|
3969
|
+
let cloudType = mode === 'CLOUD' ? 'VM' : 'NONE';
|
|
3970
|
+
if (!agentId && typeof rawSuite.cloudAgentId === 'string' && rawSuite.cloudAgentId.length > 0) {
|
|
3971
|
+
agentId = rawSuite.cloudAgentId;
|
|
3972
|
+
cloudType = 'SQA';
|
|
3973
|
+
}
|
|
3974
|
+
const gc = rawSuite.globalConfiguration ?? {};
|
|
3975
|
+
const body = {
|
|
3490
3976
|
projectId,
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3977
|
+
environmentType: env,
|
|
3978
|
+
executionStyle: (opts.strategy ?? 'serial').toUpperCase(),
|
|
3979
|
+
executionOS: this.detectExecutionOS(),
|
|
3980
|
+
executionTypeId: suiteNum,
|
|
3981
|
+
executionTypeName: str(rawSuite, 'name') ?? '',
|
|
3982
|
+
executionTypeCode: str(rawSuite, 'code') ?? '',
|
|
3983
|
+
mailersList: rawSuite.mailersList ?? {},
|
|
3984
|
+
cloudType,
|
|
3985
|
+
mode,
|
|
3986
|
+
executionCategory: 'EXECUTION',
|
|
3987
|
+
releaseId: opts.releaseId ? Number.parseInt(opts.releaseId, 10) || 0 : 0,
|
|
3988
|
+
iterationId: opts.sprintId ? Number.parseInt(opts.sprintId, 10) || 0 : 0,
|
|
3989
|
+
executionTime: '',
|
|
3990
|
+
status: 'INPROGRESS',
|
|
3991
|
+
iterationsSelected: [],
|
|
3992
|
+
result: '',
|
|
3993
|
+
globalConfiguration: {
|
|
3994
|
+
TERMINATE_ON_FAIL: opts.terminateOnFailure ?? gc.TERMINATE_ON_FAIL ?? false,
|
|
3995
|
+
NEW_BROWSER_SESSION: gc.NEW_BROWSER_SESSION ?? false,
|
|
3996
|
+
RE_EXECUTION_COUNT: gc.RE_EXECUTION_COUNT ?? 0,
|
|
3997
|
+
RE_EXECUTION_TYPE: gc.RE_EXECUTION_TYPE ?? 'AFTER_TESTCASE',
|
|
3998
|
+
MINIMUM_CONTAINER_COUNT: gc.MINIMUM_CONTAINER_COUNT ?? 1,
|
|
3999
|
+
MAXIMUM_CONTAINER_COUNT: opts.maxConcurrent ?? gc.MAXIMUM_CONTAINER_COUNT ?? 5,
|
|
4000
|
+
},
|
|
4001
|
+
isRecordingEnabled: false,
|
|
4002
|
+
offset: -(new Date().getTimezoneOffset()),
|
|
4003
|
+
type: 'SUITE',
|
|
4004
|
+
executionType: suiteType,
|
|
4005
|
+
agentId,
|
|
4006
|
+
executionItems,
|
|
4007
|
+
};
|
|
4008
|
+
const resp = await this.http.postJson('/exec/execution', body);
|
|
4009
|
+
const d = resp?.data ?? resp;
|
|
4010
|
+
const runId = str(d, 'id', 'executionId', '_id') ??
|
|
4011
|
+
(typeof d?.id === 'number' ? String(d.id) : undefined) ??
|
|
4012
|
+
(typeof d?.executionId === 'number' ? String(d.executionId) : undefined);
|
|
4013
|
+
if (!runId)
|
|
4014
|
+
throw new Error('startRun: backend accepted suite trigger but returned no run id.');
|
|
4015
|
+
return { runId };
|
|
3494
4016
|
}
|
|
3495
4017
|
/** GET the agent /mcp with a 1s timeout. Any HTTP response (incl. 4xx from an MCP server rejecting
|
|
3496
4018
|
* a naked GET) proves the process is up; connect/DNS/timeout errors mean it is down. Replaces
|