common-qa-mcp 0.5.0 → 0.7.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/README.md +37 -79
- package/dist/adapters/remoteMcp/adapter.d.ts +7 -1
- package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
- package/dist/adapters/remoteMcp/adapter.js +8 -3
- package/dist/adapters/remoteMcp/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/adapter.d.ts +112 -26
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/adapter.js +731 -240
- package/dist/adapters/simplifyqa/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/mapper.d.ts +5 -1
- package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/mapper.js +45 -1
- package/dist/adapters/simplifyqa/mapper.js.map +1 -1
- package/dist/adapters/types.d.ts +55 -13
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/adapters/walnut/actionTypes.d.ts +1 -1
- package/dist/adapters/walnut/actionTypes.d.ts.map +1 -1
- package/dist/adapters/walnut/actionTypes.js +37 -0
- package/dist/adapters/walnut/actionTypes.js.map +1 -1
- package/dist/adapters/walnut/mapper.d.ts.map +1 -1
- package/dist/adapters/walnut/mapper.js +12 -1
- package/dist/adapters/walnut/mapper.js.map +1 -1
- package/dist/domain/types.d.ts +105 -15
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +132 -26
- package/dist/initAgents.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +95 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/backlog.tools.d.ts.map +1 -1
- package/dist/tools/backlog.tools.js +24 -2
- package/dist/tools/backlog.tools.js.map +1 -1
- package/dist/tools/connection.tools.d.ts.map +1 -1
- package/dist/tools/connection.tools.js +15 -3
- package/dist/tools/connection.tools.js.map +1 -1
- package/dist/tools/defects.tools.d.ts.map +1 -1
- package/dist/tools/defects.tools.js +29 -3
- package/dist/tools/defects.tools.js.map +1 -1
- package/dist/tools/discovery.tools.d.ts.map +1 -1
- package/dist/tools/discovery.tools.js +304 -137
- package/dist/tools/discovery.tools.js.map +1 -1
- package/dist/tools/gapAnalysis.tools.d.ts +31 -0
- package/dist/tools/gapAnalysis.tools.d.ts.map +1 -0
- package/dist/tools/gapAnalysis.tools.js +287 -0
- package/dist/tools/gapAnalysis.tools.js.map +1 -0
- package/dist/tools/impactAnalysis.tools.d.ts +17 -0
- package/dist/tools/impactAnalysis.tools.d.ts.map +1 -0
- package/dist/tools/impactAnalysis.tools.js +352 -0
- package/dist/tools/impactAnalysis.tools.js.map +1 -0
- package/dist/tools/markdown.d.ts.map +1 -1
- package/dist/tools/markdown.js +9 -5
- package/dist/tools/markdown.js.map +1 -1
- package/dist/tools/objects.tools.d.ts.map +1 -1
- package/dist/tools/objects.tools.js +174 -18
- package/dist/tools/objects.tools.js.map +1 -1
- package/dist/tools/recording.tools.d.ts.map +1 -1
- package/dist/tools/recording.tools.js +108 -10
- package/dist/tools/recording.tools.js.map +1 -1
- package/dist/tools/run.tools.d.ts.map +1 -1
- package/dist/tools/run.tools.js +257 -63
- package/dist/tools/run.tools.js.map +1 -1
- package/dist/tools/tasks.tools.d.ts.map +1 -1
- package/dist/tools/tasks.tools.js +29 -3
- package/dist/tools/tasks.tools.js.map +1 -1
- package/dist/tools/testcase.tools.d.ts.map +1 -1
- package/dist/tools/testcase.tools.js +14 -5
- package/dist/tools/testcase.tools.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import { makeClient } from '../http.js';
|
|
3
3
|
import { RemoteMcpAdapter } from '../remoteMcp/adapter.js';
|
|
4
|
-
import { applyCollectionSteps, buildTestcasePayload, deriveTcType, extractParamNames, fromSqaStep, looksSensitive, normalizeSuiteEntry, numericId, paramDefault, pickArray, str, SUITE_UPDATE_ALLOWED_KEYS, TECH_BY_STEPTYPE, toDefectSummary, toEpic, toEpicSummary, toFeature, toFeatureSummary, toFunction, toFunctionSummary, toModuleSummary, toObject, toReleaseSummary, toSavedRef, toSprintSummary, toStory, toStorySummary, toSuite, toSuiteSummary, toTaskSummary, toTcSummary, toTestCase, toVersionEntries, withTotal, } from './mapper.js';
|
|
5
|
-
const PROJECT_URL_SEGMENT = {
|
|
6
|
-
testcase: 'testcase',
|
|
7
|
-
suite: 'suite',
|
|
8
|
-
run: 'reports/execution',
|
|
9
|
-
function: 'function',
|
|
10
|
-
};
|
|
4
|
+
import { applyCollectionSteps, buildTestcasePayload, deriveTcType, extractParamNames, fromSqaStep, looksSensitive, normalizeSuiteEntry, numericId, paramDefault, pickArray, str, SUITE_UPDATE_ALLOWED_KEYS, TECH_BY_STEPTYPE, toDefectSummary, toEpic, toEpicSummary, toFeature, toFeatureSummary, toFunction, toFunctionSummary, toModuleSaved, toModuleSummary, toObject, toReleaseSummary, toSavedRef, toSprintSummary, toStory, toStorySummary, toSuite, toSuiteSummary, toTaskSummary, toTcSummary, toTestCase, toVersionEntries, withTotal, } from './mapper.js';
|
|
11
5
|
/** SQA run-row status → neutral RunStatus.state. */
|
|
12
6
|
function mapRunState(s) {
|
|
13
7
|
switch (s.toUpperCase()) {
|
|
14
8
|
case 'PASSED': return 'passed';
|
|
15
9
|
case 'FAILED': return 'failed';
|
|
16
10
|
case 'STOPPED': return 'stopped';
|
|
17
|
-
case 'RUNNING':
|
|
18
|
-
case '
|
|
11
|
+
case 'RUNNING':
|
|
12
|
+
case 'INPROGRESS': return 'running';
|
|
13
|
+
case 'QUEUED':
|
|
14
|
+
case 'NOTEXECUTED': return 'queued';
|
|
19
15
|
default: return 'queued';
|
|
20
16
|
}
|
|
21
17
|
}
|
|
@@ -106,10 +102,14 @@ const STEP_ALLOWED_KEYS = [
|
|
|
106
102
|
'captureScreenshot', 'screenshotEnabled', 'fnVersionId', 'description', 'expectedResult',
|
|
107
103
|
'conditions', 'stepLevelActions', 'skipParamInReport', 'paramLoopduration', 'showGroups',
|
|
108
104
|
'isConditional', 'if', 'else', 'loop', 'isLoop', 'loopType', 'startFor', 'endFor',
|
|
109
|
-
'loopDuration', 'executionType', '
|
|
105
|
+
'loopDuration', 'executionType', 'switchToAndroidData', 'switchToIOSData',
|
|
110
106
|
'isDebug', 'manualStepsTopreview', 'sharedFnProjectName', 'isMigrated', 'customFields',
|
|
111
107
|
// ponytail: id / versionId are in the schema but always stripped for SAVENEWVERSION — the
|
|
112
108
|
// backend re-assigns them to the fresh version row, and keeping the old id would resurrect it.
|
|
109
|
+
// ponytail: recordedParam is intentionally dropped — the FE clears it on every save-as-new
|
|
110
|
+
// (testcase.component.ts:1370 `if (shouldClearRecordedParam) step.recordedParam = {}`) so the
|
|
111
|
+
// backend's prepareDataObjet doesn't burn recorded values into fresh ITR-1 rows. MCP has no
|
|
112
|
+
// recording flow, so nothing needs it kept.
|
|
113
113
|
];
|
|
114
114
|
function pick(obj, keys) {
|
|
115
115
|
const out = {};
|
|
@@ -120,22 +120,6 @@ function pick(obj, keys) {
|
|
|
120
120
|
out[k] = obj[k];
|
|
121
121
|
return out;
|
|
122
122
|
}
|
|
123
|
-
/** Filter a fetched list of source version names by the caller's `requested` subset. Empty request
|
|
124
|
-
* or undefined means "all". Unknown names hard-fail with the valid list so the LLM can retry with
|
|
125
|
-
* a corrected name instead of silently cloning nothing. Empty request array = user error. */
|
|
126
|
-
function filterVersionNames(available, requested, entityId) {
|
|
127
|
-
if (requested === undefined)
|
|
128
|
-
return available;
|
|
129
|
-
if (requested.length === 0) {
|
|
130
|
-
throw new Error(`versions: [] is not valid — omit the parameter to clone all versions, or pass at least one name. Source ${entityId} has: ${available.join(', ')}.`);
|
|
131
|
-
}
|
|
132
|
-
const availSet = new Set(available);
|
|
133
|
-
const unknown = requested.filter((n) => !availSet.has(n));
|
|
134
|
-
if (unknown.length) {
|
|
135
|
-
throw new Error(`Unknown version name(s) on ${entityId}: ${unknown.join(', ')}. Available versions: ${available.join(', ')}.`);
|
|
136
|
-
}
|
|
137
|
-
return [...new Set(requested)];
|
|
138
|
-
}
|
|
139
123
|
/** Guard against feeding an FN business key into a TC-only endpoint (or vice-versa). `numericId`
|
|
140
124
|
* strips ANY alpha prefix — so "FN-22" → "22" and would silently target TC-22 if the caller
|
|
141
125
|
* routed to the wrong tool. Reject any business key whose prefix doesn't match the expected
|
|
@@ -576,9 +560,16 @@ export class SimplifyQaAdapter {
|
|
|
576
560
|
}
|
|
577
561
|
}
|
|
578
562
|
/** For every step with an objectRef, resolve the object's display name (`disname`) via
|
|
579
|
-
* /tm/object/search (batched by distinct objectId). Mutates `objectRef.name` in place.
|
|
580
|
-
*
|
|
581
|
-
*
|
|
563
|
+
* /tm/object/search (batched by distinct objectId). Mutates `objectRef.name` in place. Mirrors the
|
|
564
|
+
* FE's `defaultFieldsMap.get('objectId').map` behavior used by the Excel export
|
|
565
|
+
* (tcfn.service.js:2876) so the tool surface matches the UI.
|
|
566
|
+
*
|
|
567
|
+
* An id that does NOT come back from the search (deleted, or never existed) is flagged
|
|
568
|
+
* `objectRef.missing = true` — this must NOT be silently indistinguishable from "the object
|
|
569
|
+
* exists but has no disname set" (see the "show me the objects in this test case" incident:
|
|
570
|
+
* falling back to the bare id with no signal made a genuinely-deleted object's step look
|
|
571
|
+
* identical to a normal one). Only a hard failure of the search call itself (network/auth) skips
|
|
572
|
+
* this — we can't tell missing-from-deleted apart from missing-from-can't-check in that case. */
|
|
582
573
|
async hydrateObjectNames(tc) {
|
|
583
574
|
const ids = new Set();
|
|
584
575
|
for (const s of tc.steps) {
|
|
@@ -611,10 +602,13 @@ export class SimplifyQaAdapter {
|
|
|
611
602
|
const name = byId.get(s.objectRef.id);
|
|
612
603
|
if (name)
|
|
613
604
|
s.objectRef.name = name;
|
|
605
|
+
else
|
|
606
|
+
s.objectRef.missing = true;
|
|
614
607
|
}
|
|
615
608
|
}
|
|
616
609
|
catch {
|
|
617
|
-
//
|
|
610
|
+
// Search call itself failed — can't distinguish missing-from-deleted vs. couldn't-check, so
|
|
611
|
+
// don't flag anything; the bare id still renders (existing best-effort behavior).
|
|
618
612
|
}
|
|
619
613
|
}
|
|
620
614
|
// ---------------------------------------------------------------------- Suites (read)
|
|
@@ -992,7 +986,7 @@ export class SimplifyQaAdapter {
|
|
|
992
986
|
const body = this.searchBody([
|
|
993
987
|
{ column: 'projectId', value: projectId },
|
|
994
988
|
{ column: 'deleted', value: false },
|
|
995
|
-
], { selectFields: ['id', 'code', 'name', 'parentId'] });
|
|
989
|
+
], { selectFields: ['id', 'code', 'name', 'parentId', 'isRoot', 'childIds'] });
|
|
996
990
|
const raw = await this.http.postJson('/pm/module/search', body);
|
|
997
991
|
const rows = pickArray(raw, 'data', 'content', 'modules');
|
|
998
992
|
return withTotal(rows.map(toModuleSummary), raw);
|
|
@@ -1022,19 +1016,71 @@ export class SimplifyQaAdapter {
|
|
|
1022
1016
|
};
|
|
1023
1017
|
}
|
|
1024
1018
|
/** POST /pm/module — server validates: name+isRoot+projectId required; parentId ignored when
|
|
1025
|
-
* isRoot:true. Duplicate name per project is rejected server-side.
|
|
1019
|
+
* isRoot:true. Duplicate name per project is rejected server-side. When `input.children` is
|
|
1020
|
+
* present, walks the subtree depth-first: creates the root, then each child under the id
|
|
1021
|
+
* returned for its parent. `childIds:[]` matches the working UI payload byte-for-byte.
|
|
1022
|
+
* Backend has no tx endpoint: on mid-walk failure the returned tree carries `failedAt`, and
|
|
1023
|
+
* every node BEFORE that point is already persisted. */
|
|
1026
1024
|
async createModule(input) {
|
|
1025
|
+
const root = await this.postOneModule(input.name, input.isRoot, input.projectId, input.description, input.parentId);
|
|
1026
|
+
if (input.children?.length) {
|
|
1027
|
+
root.children = await this.createSubtree(input.children, root.id, input.projectId, input.name, root);
|
|
1028
|
+
}
|
|
1029
|
+
return root;
|
|
1030
|
+
}
|
|
1031
|
+
/** Depth-first walk of a subtree. Stops at the first failure — everything already POSTed stays
|
|
1032
|
+
* (no rollback endpoint upstream) and the failure is bubbled back on the root via `failedAt`. */
|
|
1033
|
+
async createSubtree(children, parentId, projectId, parentPath, root) {
|
|
1034
|
+
const out = [];
|
|
1035
|
+
for (const child of children) {
|
|
1036
|
+
const path = `${parentPath}/${child.name}`;
|
|
1037
|
+
if (root.failedAt)
|
|
1038
|
+
return out;
|
|
1039
|
+
let saved;
|
|
1040
|
+
try {
|
|
1041
|
+
saved = await this.postOneModule(child.name, false, projectId, child.description, parentId);
|
|
1042
|
+
}
|
|
1043
|
+
catch (e) {
|
|
1044
|
+
root.failedAt = { path, error: e instanceof Error ? e.message : String(e) };
|
|
1045
|
+
return out;
|
|
1046
|
+
}
|
|
1047
|
+
if (child.children?.length) {
|
|
1048
|
+
saved.children = await this.createSubtree(child.children, saved.id, projectId, path, root);
|
|
1049
|
+
}
|
|
1050
|
+
out.push(saved);
|
|
1051
|
+
}
|
|
1052
|
+
return out;
|
|
1053
|
+
}
|
|
1054
|
+
async postOneModule(name, isRoot, projectId, description, parentId) {
|
|
1027
1055
|
const payload = {
|
|
1028
|
-
name
|
|
1029
|
-
isRoot
|
|
1030
|
-
projectId: Number.parseInt(
|
|
1056
|
+
name,
|
|
1057
|
+
isRoot,
|
|
1058
|
+
projectId: Number.parseInt(projectId, 10),
|
|
1059
|
+
childIds: [],
|
|
1031
1060
|
};
|
|
1032
|
-
if (
|
|
1033
|
-
payload['description'] =
|
|
1034
|
-
if (!
|
|
1035
|
-
payload['parentId'] = Number.parseInt(
|
|
1061
|
+
if (description)
|
|
1062
|
+
payload['description'] = description;
|
|
1063
|
+
if (!isRoot && parentId)
|
|
1064
|
+
payload['parentId'] = Number.parseInt(parentId, 10);
|
|
1036
1065
|
const raw = await this.http.postJson('/pm/module', payload);
|
|
1037
|
-
return
|
|
1066
|
+
return toModuleSaved(raw, name);
|
|
1067
|
+
}
|
|
1068
|
+
/** PUT /pm/module/:id — partial edit. Controller reads `projectId` off the body for its
|
|
1069
|
+
* `add_module` privilege check (module.controller.js:64), so it MUST be sent even when unchanged;
|
|
1070
|
+
* we default to the connected project. Only `name` / `description` are exposed — the controller
|
|
1071
|
+
* ignores everything else the validator accepts (isRoot, parentId, childIds). */
|
|
1072
|
+
async updateModule(id, patch) {
|
|
1073
|
+
const projectId = patch.projectId
|
|
1074
|
+
? Number.parseInt(numericId(patch.projectId), 10)
|
|
1075
|
+
: this.requireProjectId();
|
|
1076
|
+
const body = { projectId };
|
|
1077
|
+
if (patch.name !== undefined)
|
|
1078
|
+
body['name'] = patch.name;
|
|
1079
|
+
if (patch.description !== undefined)
|
|
1080
|
+
body['description'] = patch.description;
|
|
1081
|
+
const resolvedId = numericId(id);
|
|
1082
|
+
const raw = await this.http.putJson(`/pm/module/${resolvedId}`, body);
|
|
1083
|
+
return toSavedRef(raw ?? { id: resolvedId }, false);
|
|
1038
1084
|
}
|
|
1039
1085
|
/** PATCH /pm/module — two-phase per module.controller.js:94. `confirm:false` returns the per-id
|
|
1040
1086
|
* dependency footprint (test cases, functions, params, objects, defects, user stories). `confirm:true`
|
|
@@ -1159,7 +1205,13 @@ export class SimplifyQaAdapter {
|
|
|
1159
1205
|
// ---------------------------------------------------------------------- Deep links
|
|
1160
1206
|
productUrlFor(entity) {
|
|
1161
1207
|
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
1162
|
-
|
|
1208
|
+
switch (entity.kind) {
|
|
1209
|
+
case 'testcase': return `${base}/user/testcase/details/${entity.id}`;
|
|
1210
|
+
case 'function': return `${base}/user/function/fnDetails/${entity.id}`;
|
|
1211
|
+
case 'suite': return `${base}/user/suites/suite-details?id=${entity.id}`;
|
|
1212
|
+
// ponytail: run URL is built inline where parentId/projectId are known (see reportUrl above). Fallback stays generic.
|
|
1213
|
+
case 'run': return `${base}/user/reports/reports-page?id=${entity.id}`;
|
|
1214
|
+
}
|
|
1163
1215
|
}
|
|
1164
1216
|
// ---------------------------------------------------------------------- Agent hop (recording + not-yet-ported ops)
|
|
1165
1217
|
// Everything below either delegates to the local agent /mcp (recording surface, and writes/testdata/
|
|
@@ -1723,87 +1775,92 @@ export class SimplifyQaAdapter {
|
|
|
1723
1775
|
}
|
|
1724
1776
|
/** Clone a testcase in place (same project + module) with all steps, versions, testdata,
|
|
1725
1777
|
* parameters, object refs, and nested function calls carried over. Backed by the SQA native
|
|
1726
|
-
*
|
|
1727
|
-
*
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1731
|
-
|
|
1732
|
-
* `_copy2`, etc. When provided, a follow-up PUT renames the freshly-created TC to that name.
|
|
1733
|
-
*
|
|
1734
|
-
* IMPORTANT — the copy endpoint's `selversions` and `selectedEnv` fields are FILTERS, not
|
|
1735
|
-
* wildcards. Passing empty arrays makes the backend copy the TC shell only (no steps, no
|
|
1736
|
-
* testdata, since both live under versions and per-env iterations). So we fetch the source
|
|
1737
|
-
* TC's version names AND the project's environment names first and pass them explicitly. */
|
|
1738
|
-
async cloneTestCase(id, newTitle, versions) {
|
|
1778
|
+
* the FE in-editor "Save as new TC" dialog — `PUT /tm/testcase` with `operation:SAVEASNEWTC`.
|
|
1779
|
+
* Same-project only (no cross-project copy — use the FE tree menu for that). Clones ONE
|
|
1780
|
+
* source version. `tdCopy:true` clones the source's testdata iterations across every env;
|
|
1781
|
+
* `tdCopy:false` copies only the column structure (one empty ITR-1 per env, values fall back
|
|
1782
|
+
* to project-level parameter defaultValue if any). Mirrors the FE's "COPY TEST DATA" modal. */
|
|
1783
|
+
async cloneTestCase(id, newTitle, opts) {
|
|
1739
1784
|
assertBusinessKeyKind(id, 'TC');
|
|
1740
1785
|
const projectId = this.requireProjectId();
|
|
1741
|
-
const
|
|
1742
|
-
if (!
|
|
1786
|
+
const title = newTitle?.trim();
|
|
1787
|
+
if (!title)
|
|
1788
|
+
throw new Error('newTitle is required for save-as-new-testcase.');
|
|
1789
|
+
const rawTc = await this.fetchRawTestcase(id);
|
|
1790
|
+
if (!rawTc || Object.keys(rawTc).length === 0)
|
|
1743
1791
|
throw new Error(`test case ${id} not found`);
|
|
1744
1792
|
const sourceId = Number.parseInt(numericId(id), 10);
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
.
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
}
|
|
1756
|
-
const versionNames = filterVersionNames(availableNames, versions, id);
|
|
1757
|
-
// Fetch project's environment names — empty means per-env testdata rows are dropped
|
|
1758
|
-
// (only the "general" fallback iteration survives).
|
|
1759
|
-
const envsRaw = await this.http.getJson(`/pm/fields/8?projectId=${sourceProjId}&fieldType=environment`);
|
|
1760
|
-
const envsArr = Array.isArray(envsRaw) ? envsRaw : (envsRaw?.data ?? []);
|
|
1761
|
-
const envNames = envsArr
|
|
1762
|
-
.map((e) => str(e, 'name'))
|
|
1763
|
-
.filter((n) => !!n);
|
|
1764
|
-
await this.http.postJson('/tm/tcfnCopy/copytc', {
|
|
1765
|
-
type: 'TC',
|
|
1766
|
-
tcfnList: [{ id: sourceId, selversions: versionNames }],
|
|
1767
|
-
sourceProjId,
|
|
1768
|
-
sourceModuleId,
|
|
1769
|
-
destinationProjId: sourceProjId,
|
|
1770
|
-
destinationModuleId: sourceModuleId,
|
|
1771
|
-
selectedEnv: envNames,
|
|
1772
|
-
});
|
|
1773
|
-
// Find the newly-created copy: highest-id TC whose `projectLevelcopiedFrom.id` matches. Not
|
|
1774
|
-
// going through `searchBody` because the SQA search protocol lets a searchField have EITHER a
|
|
1775
|
-
// `value` (filter) OR a `sort` (ordering) — the helper only builds value entries.
|
|
1776
|
-
const searchRaw = await this.http.postJson('/tm/testcase/search', {
|
|
1777
|
-
searchFields: [
|
|
1778
|
-
{ column: 'deleted', value: false },
|
|
1779
|
-
{ column: 'projectId', value: sourceProjId },
|
|
1780
|
-
{ column: 'projectLevelcopiedFrom.id', value: sourceId },
|
|
1781
|
-
{ column: 'id', sort: 'dsc' },
|
|
1782
|
-
],
|
|
1783
|
-
startIndex: 0,
|
|
1784
|
-
limit: 1,
|
|
1785
|
-
selectFields: ['id', 'code', 'name'],
|
|
1786
|
-
});
|
|
1787
|
-
const rows = pickArray(searchRaw, 'data', 'content', 'testCases');
|
|
1788
|
-
if (rows.length === 0) {
|
|
1789
|
-
throw new Error('Clone was requested but the newly-created testcase could not be located afterwards. ' +
|
|
1790
|
-
'The backend may still be processing the copy, or the caller may lack the ' +
|
|
1791
|
-
'`copy_testcase_across_project` privilege on this project.');
|
|
1792
|
-
}
|
|
1793
|
-
const created = rows[0];
|
|
1794
|
-
const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
|
|
1795
|
-
// Optional rename: PUT /tm/testcase/:id with { name, projectId }.
|
|
1796
|
-
if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
|
|
1797
|
-
await this.http.putJson(`/tm/testcase/${newIdNum}`, {
|
|
1798
|
-
name: newTitle.trim(),
|
|
1799
|
-
projectId: sourceProjId,
|
|
1800
|
-
});
|
|
1801
|
-
created.name = newTitle.trim();
|
|
1793
|
+
// Resolve source version — same fallback chain as saveAsNewVersion.
|
|
1794
|
+
let sourceVersionId = opts.sourceVersionId;
|
|
1795
|
+
if (!sourceVersionId) {
|
|
1796
|
+
sourceVersionId = str(rawTc, 'versionId', 'selectedVersionId', 'selVersion');
|
|
1797
|
+
if (!sourceVersionId && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
|
|
1798
|
+
sourceVersionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId');
|
|
1799
|
+
}
|
|
1800
|
+
if (!sourceVersionId && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
|
|
1801
|
+
sourceVersionId = str(rawTc.versions[0], 'id', '_id');
|
|
1802
|
+
}
|
|
1802
1803
|
}
|
|
1804
|
+
// Source version's NAME (SAVEASNEWTC re-uses the same version name on the copy, per FE).
|
|
1805
|
+
const sourceVersionName = (() => {
|
|
1806
|
+
if (rawTc?.versionsDetails?.id === sourceVersionId)
|
|
1807
|
+
return str(rawTc.versionsDetails, 'name');
|
|
1808
|
+
const match = Array.isArray(rawTc?.versions)
|
|
1809
|
+
? rawTc.versions.find((v) => str(v, 'id', '_id') === sourceVersionId)
|
|
1810
|
+
: undefined;
|
|
1811
|
+
return match ? str(match, 'name') : str(rawTc?.versionsDetails, 'name');
|
|
1812
|
+
})() ?? 'v1.0';
|
|
1813
|
+
const rawSteps = await this.fetchStepsCollection(id, rawTc);
|
|
1814
|
+
const automationSteps = [];
|
|
1815
|
+
const manualSteps = [];
|
|
1816
|
+
for (const s of rawSteps) {
|
|
1817
|
+
const step = pick(s, STEP_ALLOWED_KEYS);
|
|
1818
|
+
const et = typeof step['executionType'] === 'string' ? String(step['executionType']).toUpperCase() : '';
|
|
1819
|
+
if (et === 'MANUAL')
|
|
1820
|
+
manualSteps.push(step);
|
|
1821
|
+
else
|
|
1822
|
+
automationSteps.push(step);
|
|
1823
|
+
}
|
|
1824
|
+
const tcfnObj = pick(rawTc, TCFNOBJ_ALLOWED_KEYS);
|
|
1825
|
+
tcfnObj['deleted'] = false;
|
|
1826
|
+
tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
|
|
1827
|
+
if (typeof tcfnObj['obsolete'] !== 'boolean')
|
|
1828
|
+
tcfnObj['obsolete'] = false;
|
|
1829
|
+
tcfnObj['name'] = title;
|
|
1830
|
+
tcfnObj['copiedFromTcId'] = sourceId;
|
|
1831
|
+
const prevSync = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
|
|
1832
|
+
? str(rawTc.versionsDetails, 'syncONOFF')
|
|
1833
|
+
: undefined;
|
|
1834
|
+
const versionsDetails = {
|
|
1835
|
+
name: sourceVersionName,
|
|
1836
|
+
id: '',
|
|
1837
|
+
syncONOFF: prevSync === 'SYNCOFF' ? '' : (prevSync ?? ''),
|
|
1838
|
+
deleted: false,
|
|
1839
|
+
executionType: str(rawTc, 'executionType') ?? 'AUTOMATION',
|
|
1840
|
+
functionProjectIds: [tcfnObj['projectId']],
|
|
1841
|
+
reviewApproveStatus: '',
|
|
1842
|
+
};
|
|
1843
|
+
const verData = {
|
|
1844
|
+
tdCopy: !!opts.tdCopy,
|
|
1845
|
+
...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
|
|
1846
|
+
};
|
|
1847
|
+
const payload = {
|
|
1848
|
+
tcfnObj,
|
|
1849
|
+
steps: { automationSteps, manualSteps },
|
|
1850
|
+
versionsDetails,
|
|
1851
|
+
operation: 'SAVEASNEWTC',
|
|
1852
|
+
type: 'TC',
|
|
1853
|
+
verData,
|
|
1854
|
+
};
|
|
1855
|
+
const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
1856
|
+
const flat = raw?.data ?? raw ?? {};
|
|
1857
|
+
const newId = str(flat, 'id', '_id', 'testcaseId');
|
|
1858
|
+
if (!newId)
|
|
1859
|
+
throw new Error('SAVEASNEWTC succeeded but returned no id.');
|
|
1803
1860
|
return {
|
|
1804
|
-
id:
|
|
1861
|
+
id: newId,
|
|
1805
1862
|
created: true,
|
|
1806
|
-
productUrl: this.productUrlFor({ kind: 'testcase', id:
|
|
1863
|
+
productUrl: this.productUrlFor({ kind: 'testcase', id: newId }),
|
|
1807
1864
|
};
|
|
1808
1865
|
}
|
|
1809
1866
|
// ---------------------------------------------------------------------- Functions (reusable)
|
|
@@ -1925,81 +1982,91 @@ export class SimplifyQaAdapter {
|
|
|
1925
1982
|
? `Not deleted — function is still referenced by ${refs.join('; ')}. Remove those references first.`
|
|
1926
1983
|
: 'Not deleted — the backend reported blocking dependencies.');
|
|
1927
1984
|
}
|
|
1928
|
-
/**
|
|
1929
|
-
*
|
|
1930
|
-
*
|
|
1931
|
-
|
|
1932
|
-
* provided a follow-up PUT `/tm/function/:id` renames the freshly-created FN. */
|
|
1933
|
-
async cloneFunction(id, newTitle, versions) {
|
|
1985
|
+
/** Save-as-new-function — same-project only. Mirrors FE's in-editor "Save as new function"
|
|
1986
|
+
* dialog: `PUT /tm/function` with `operation:SAVEASNEWFN`. Clones ONE source version.
|
|
1987
|
+
* `tdCopy:true` clones testdata; `tdCopy:false` copies structure only (empty ITR-1 per env). */
|
|
1988
|
+
async cloneFunction(id, newTitle, opts) {
|
|
1934
1989
|
assertBusinessKeyKind(id, 'FN');
|
|
1935
1990
|
const projectId = this.requireProjectId();
|
|
1991
|
+
const title = newTitle?.trim();
|
|
1992
|
+
if (!title)
|
|
1993
|
+
throw new Error('newTitle is required for save-as-new-function.');
|
|
1936
1994
|
const fnNum = numericId(id);
|
|
1937
1995
|
const rawFn = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
|
|
1938
|
-
let
|
|
1939
|
-
if (Array.isArray(
|
|
1940
|
-
|
|
1941
|
-
if (!
|
|
1996
|
+
let rawTc = rawFn?.data ?? rawFn;
|
|
1997
|
+
if (Array.isArray(rawTc))
|
|
1998
|
+
rawTc = rawTc.length > 0 ? rawTc[0] : {};
|
|
1999
|
+
if (!rawTc || typeof rawTc !== 'object' || Object.keys(rawTc).length === 0) {
|
|
1942
2000
|
throw new Error(`function ${id} not found`);
|
|
1943
2001
|
}
|
|
1944
2002
|
const sourceId = Number.parseInt(fnNum, 10);
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
.
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
{ column: 'projectLevelcopiedFrom.id', value: sourceId },
|
|
1978
|
-
{ column: 'id', sort: 'dsc' },
|
|
1979
|
-
],
|
|
1980
|
-
startIndex: 0,
|
|
1981
|
-
limit: 1,
|
|
1982
|
-
selectFields: ['id', 'code', 'name'],
|
|
1983
|
-
});
|
|
1984
|
-
const rows = pickArray(searchRaw, 'data', 'content', 'functions');
|
|
1985
|
-
if (rows.length === 0) {
|
|
1986
|
-
throw new Error('Clone was requested but the newly-created function could not be located afterwards. ' +
|
|
1987
|
-
'The backend may still be processing the copy, or the caller may lack the ' +
|
|
1988
|
-
'`copy_function_across_projects` privilege on this project.');
|
|
1989
|
-
}
|
|
1990
|
-
const created = rows[0];
|
|
1991
|
-
const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
|
|
1992
|
-
// Optional rename: PUT /tm/function/:id — inline validator accepts { name } only (no projectId).
|
|
1993
|
-
if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
|
|
1994
|
-
await this.http.putJson(`/tm/function/${newIdNum}`, {
|
|
1995
|
-
name: newTitle.trim(),
|
|
1996
|
-
});
|
|
1997
|
-
created.name = newTitle.trim();
|
|
2003
|
+
// Resolve source version — FN doc has no inline versionId, use version list.
|
|
2004
|
+
let sourceVersionId = opts.sourceVersionId;
|
|
2005
|
+
const versionRows = await this.fetchFunctionVersionRows(fnNum);
|
|
2006
|
+
if (!sourceVersionId && versionRows.length > 0) {
|
|
2007
|
+
sourceVersionId = str(versionRows[0], 'id', '_id', 'versionId');
|
|
2008
|
+
}
|
|
2009
|
+
const sourceVersionName = (() => {
|
|
2010
|
+
const match = versionRows.find((v) => str(v, 'id', '_id', 'versionId') === sourceVersionId);
|
|
2011
|
+
return match ? str(match, 'name') : (versionRows[0] ? str(versionRows[0], 'name') : undefined);
|
|
2012
|
+
})() ?? 'v1.0';
|
|
2013
|
+
const rawSteps = sourceVersionId
|
|
2014
|
+
? await this.http.getJson('/tm/function/steps' +
|
|
2015
|
+
`?projectId=${projectId}` +
|
|
2016
|
+
`&tcfnId=${fnNum}` +
|
|
2017
|
+
`&versionId=${encodeURIComponent(sourceVersionId)}` +
|
|
2018
|
+
'&type=FN').then((body) => (Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : []))
|
|
2019
|
+
: [];
|
|
2020
|
+
const automationSteps = [];
|
|
2021
|
+
const manualSteps = [];
|
|
2022
|
+
for (const s of rawSteps) {
|
|
2023
|
+
const step = pick(s, STEP_ALLOWED_KEYS);
|
|
2024
|
+
const et = typeof step['executionType'] === 'string' ? String(step['executionType']).toUpperCase() : '';
|
|
2025
|
+
if (et === 'MANUAL')
|
|
2026
|
+
manualSteps.push(step);
|
|
2027
|
+
else
|
|
2028
|
+
automationSteps.push(step);
|
|
2029
|
+
}
|
|
2030
|
+
const tcfnObj = pick(rawTc, TCFNOBJ_FN_ALLOWED_KEYS);
|
|
2031
|
+
tcfnObj['deleted'] = false;
|
|
2032
|
+
tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
|
|
2033
|
+
if (typeof tcfnObj['fnType'] !== 'string' || !tcfnObj['fnType']) {
|
|
2034
|
+
tcfnObj['fnType'] = str(rawTc, 'fnType', 'tcType') ?? 'WEB';
|
|
1998
2035
|
}
|
|
2036
|
+
tcfnObj['name'] = title;
|
|
2037
|
+
tcfnObj['copiedFromFnId'] = sourceId;
|
|
2038
|
+
const prevSync = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
|
|
2039
|
+
? str(rawTc.versionsDetails, 'syncONOFF')
|
|
2040
|
+
: undefined;
|
|
2041
|
+
const versionsDetails = {
|
|
2042
|
+
name: sourceVersionName,
|
|
2043
|
+
id: '',
|
|
2044
|
+
syncONOFF: prevSync === 'SYNCOFF' ? '' : (prevSync ?? ''),
|
|
2045
|
+
deleted: false,
|
|
2046
|
+
executionType: str(rawTc, 'executionType') ?? 'AUTOMATION',
|
|
2047
|
+
reviewApproveStatus: '',
|
|
2048
|
+
};
|
|
2049
|
+
const verData = {
|
|
2050
|
+
tdCopy: !!opts.tdCopy,
|
|
2051
|
+
...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
|
|
2052
|
+
};
|
|
2053
|
+
const payload = {
|
|
2054
|
+
tcfnObj,
|
|
2055
|
+
steps: { automationSteps, manualSteps },
|
|
2056
|
+
versionsDetails,
|
|
2057
|
+
operation: 'SAVEASNEWFN',
|
|
2058
|
+
type: 'FN',
|
|
2059
|
+
verData,
|
|
2060
|
+
};
|
|
2061
|
+
const raw = await this.http.putMultipart('/tm/function', 'functionObj', payload);
|
|
2062
|
+
const flat = raw?.data ?? raw ?? {};
|
|
2063
|
+
const newId = str(flat, 'id', '_id', 'functionId');
|
|
2064
|
+
if (!newId)
|
|
2065
|
+
throw new Error('SAVEASNEWFN succeeded but returned no id.');
|
|
1999
2066
|
return {
|
|
2000
|
-
id:
|
|
2067
|
+
id: newId,
|
|
2001
2068
|
created: true,
|
|
2002
|
-
productUrl: this.productUrlFor({ kind: 'function', id:
|
|
2069
|
+
productUrl: this.productUrlFor({ kind: 'function', id: newId }),
|
|
2003
2070
|
};
|
|
2004
2071
|
}
|
|
2005
2072
|
/** Snapshot the CURRENT server state of a function under a NEW version name — same FN id, new
|
|
@@ -2189,8 +2256,42 @@ export class SimplifyQaAdapter {
|
|
|
2189
2256
|
* ponytail: minimum body — no thumbnail_url / pageScreenShot_url / parameterId. The recording
|
|
2190
2257
|
* path enriches these; the tc_save path doesn't have screenshots to attach, and the object
|
|
2191
2258
|
* works for execution without them. */
|
|
2259
|
+
/** GET-OR-CREATE by exact `disname`, scoped to the project — despite its name and the old comment
|
|
2260
|
+
* here, `createObjectSeq` is a plain CREATE with no dedup of its own, so calling it unconditionally
|
|
2261
|
+
* on every save minted a fresh duplicate object for the same recorded element every time (root
|
|
2262
|
+
* cause of the "duplicate objects in the Object module" reports). Mirrors the SAME
|
|
2263
|
+
* search-then-reuse pattern already trusted for parameters (findParameterId/getOrCreateParameter):
|
|
2264
|
+
* 1. Exact case-insensitive name match found + SAME locator set → reuse the existing object as-is.
|
|
2265
|
+
* 2. Exact name match found + DIFFERENT locators (the element's attributes drifted) → UPDATE the
|
|
2266
|
+
* existing object in place (via updateObject's own locator-upsert) instead of creating a
|
|
2267
|
+
* sibling — one entry per element, not a growing pile of near-identical copies.
|
|
2268
|
+
* 3. No name match → create a new object, same as before.
|
|
2269
|
+
* Does NOT catch a different name resolving to the same physical element (no cross-project
|
|
2270
|
+
* locator-value search) — that would need a much broader, more expensive scan; out of scope here. */
|
|
2192
2271
|
async resolveObjectId(objectRef, projectId, moduleId, technology, objectTemplateId) {
|
|
2193
|
-
const
|
|
2272
|
+
const name = (objectRef.name ?? '').trim();
|
|
2273
|
+
const incomingLocators = objectRef.locators ?? [];
|
|
2274
|
+
if (name) {
|
|
2275
|
+
try {
|
|
2276
|
+
const candidates = await this.searchObjects({ query: name, limit: 20 });
|
|
2277
|
+
const existing = candidates.find((c) => c.name.trim().toLowerCase() === name.toLowerCase());
|
|
2278
|
+
if (existing?.id) {
|
|
2279
|
+
const key = (l) => `${l.name}::${l.value}`;
|
|
2280
|
+
const existingKeys = new Set(existing.locators.map(key));
|
|
2281
|
+
const incomingKeys = new Set(incomingLocators.map(key));
|
|
2282
|
+
const sameLocators = existingKeys.size === incomingKeys.size
|
|
2283
|
+
&& [...existingKeys].every((k) => incomingKeys.has(k));
|
|
2284
|
+
if (!sameLocators && incomingLocators.length) {
|
|
2285
|
+
await this.updateObject(existing.id, { locators: incomingLocators });
|
|
2286
|
+
}
|
|
2287
|
+
return Number.parseInt(existing.id, 10);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
catch {
|
|
2291
|
+
// Lookup failed — fall through to create rather than block the save over this.
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
const attributes = incomingLocators.map((l) => ({
|
|
2194
2295
|
name: l.name,
|
|
2195
2296
|
value: l.value,
|
|
2196
2297
|
...(l.unique === true ? { unique: true } : {}),
|
|
@@ -2200,7 +2301,7 @@ export class SimplifyQaAdapter {
|
|
|
2200
2301
|
moduleId,
|
|
2201
2302
|
objecttemplateId: objectTemplateId,
|
|
2202
2303
|
technology,
|
|
2203
|
-
disname:
|
|
2304
|
+
disname: name,
|
|
2204
2305
|
attributes,
|
|
2205
2306
|
parameterId: [],
|
|
2206
2307
|
description: '',
|
|
@@ -2209,7 +2310,7 @@ export class SimplifyQaAdapter {
|
|
|
2209
2310
|
const flat = raw?.data ?? raw;
|
|
2210
2311
|
const id = str(flat, 'id', '_id', 'objectId');
|
|
2211
2312
|
if (!id)
|
|
2212
|
-
throw new Error(`createObjectSeq returned no id for object "${
|
|
2313
|
+
throw new Error(`createObjectSeq returned no id for object "${name}": ${JSON.stringify(raw)}`);
|
|
2213
2314
|
return Number.parseInt(id, 10);
|
|
2214
2315
|
}
|
|
2215
2316
|
/** For every automation step, resolve `actionId` (from action name + technology) and
|
|
@@ -2317,11 +2418,58 @@ export class SimplifyQaAdapter {
|
|
|
2317
2418
|
}
|
|
2318
2419
|
// ---------------------------------------------------------------------- Suites (write) [Step 4]
|
|
2319
2420
|
async createSuite(s) {
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2421
|
+
const projectId = this.requireProjectId();
|
|
2422
|
+
const name = s.name?.trim();
|
|
2423
|
+
if (!name)
|
|
2424
|
+
throw new Error('name is required.');
|
|
2425
|
+
if (!s.members?.length)
|
|
2426
|
+
throw new Error('At least one test case is required to create a suite.');
|
|
2427
|
+
const entries = [];
|
|
2428
|
+
const categories = new Set();
|
|
2429
|
+
for (const m of s.members) {
|
|
2430
|
+
const tcNum = numericId(m.tcId);
|
|
2431
|
+
const raw = await this.fetchRawTestcase(tcNum);
|
|
2432
|
+
if (Object.keys(raw).length === 0)
|
|
2433
|
+
throw new Error(`test case ${m.tcId} not found (cannot add to suite)`);
|
|
2434
|
+
categories.add(String(str(raw, 'executionType') ?? '').toUpperCase() === 'MANUAL' ? 'MANUAL' : 'AUTOMATION');
|
|
2435
|
+
entries.push(await this.buildSuiteEntryFromTestcase(tcNum));
|
|
2436
|
+
}
|
|
2437
|
+
if (categories.size > 1) {
|
|
2438
|
+
throw new Error('Cannot create suite: the test cases are a mix of MANUAL and AUTOMATION — a suite can only ' +
|
|
2439
|
+
'hold ONE category. Split them into two suites, or pick only same-category test cases.');
|
|
2440
|
+
}
|
|
2441
|
+
const suiteType = [...categories][0] ?? 'AUTOMATION';
|
|
2442
|
+
entries.forEach((e, i) => { e['seq'] = i + 1; });
|
|
2443
|
+
const payload = {
|
|
2444
|
+
name,
|
|
2445
|
+
description: s.description ?? '',
|
|
2446
|
+
state: 'New',
|
|
2447
|
+
suiteType,
|
|
2448
|
+
projectId,
|
|
2449
|
+
autoDefectLog: false,
|
|
2450
|
+
autoReExecCount: 0,
|
|
2451
|
+
newBrowserSession: false,
|
|
2452
|
+
terminateOnFailure: false,
|
|
2453
|
+
autoReExecTriggerOn: 'AFTER_SUITE',
|
|
2454
|
+
minimumContainerCount: null,
|
|
2455
|
+
maximumContainerCount: null,
|
|
2456
|
+
testCases: entries,
|
|
2457
|
+
mailersList: {},
|
|
2458
|
+
tags: [],
|
|
2459
|
+
customFields: {},
|
|
2460
|
+
lastSelectedExecutionType: 'LOCAL',
|
|
2461
|
+
tcChanged: true,
|
|
2462
|
+
};
|
|
2463
|
+
const raw = await this.http.postMultipart('/tm/suites', 'suiteObj', payload);
|
|
2464
|
+
const flat = raw?.data ?? raw ?? {};
|
|
2465
|
+
const savedId = str(flat, 'id', '_id') ?? '';
|
|
2466
|
+
if (!savedId)
|
|
2467
|
+
throw new Error(`Suite create returned no id: ${JSON.stringify(raw)}`);
|
|
2468
|
+
return {
|
|
2469
|
+
id: savedId,
|
|
2470
|
+
created: true,
|
|
2471
|
+
productUrl: this.productUrlFor({ kind: 'suite', id: savedId }),
|
|
2472
|
+
};
|
|
2325
2473
|
}
|
|
2326
2474
|
/** Fetch raw suite, apply add/remove/reorder ops in memory, then PUT the whole doc back with a
|
|
2327
2475
|
* STRICT allow-listed payload (the SQA update validator rejects unknown keys on both suite-level
|
|
@@ -2513,15 +2661,23 @@ export class SimplifyQaAdapter {
|
|
|
2513
2661
|
* delete is refused rather than silently breaking those test cases; only a dependency-free object
|
|
2514
2662
|
* gets the real `isDelete:true` call.
|
|
2515
2663
|
*/
|
|
2516
|
-
|
|
2664
|
+
/** `PATCH /tm/object` with `isDelete:false` — the SAME dependency-check pre-flight the real
|
|
2665
|
+
* product UI fires before its own delete-confirm modal (object.component.ts onClickTableDelete):
|
|
2666
|
+
* a read-only probe, no restore/undelete side effect. Split out from deleteObject so a caller
|
|
2667
|
+
* (object_delete's preview step) can show this BEFORE asking the user to confirm, not just
|
|
2668
|
+
* discover it as a thrown error after the fact. */
|
|
2669
|
+
async checkObjectDependencies(id) {
|
|
2517
2670
|
const projectId = this.requireProjectId();
|
|
2518
2671
|
const numId = Number.parseInt(numericId(id), 10);
|
|
2519
2672
|
const checkRes = await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: false });
|
|
2520
2673
|
const row = checkRes?.data?.[0];
|
|
2521
2674
|
const countRefs = (m) => m ? Object.values(m).reduce((n, arr) => n + (Array.isArray(arr) ? arr.length : 0), 0) : 0;
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2675
|
+
return { tcCount: countRefs(row?.tcArr), fnCount: countRefs(row?.fnArr), wfCount: countRefs(row?.wfArr) };
|
|
2676
|
+
}
|
|
2677
|
+
async deleteObject(id) {
|
|
2678
|
+
const projectId = this.requireProjectId();
|
|
2679
|
+
const numId = Number.parseInt(numericId(id), 10);
|
|
2680
|
+
const { tcCount, fnCount, wfCount } = await this.checkObjectDependencies(id);
|
|
2525
2681
|
if (tcCount || fnCount || wfCount) {
|
|
2526
2682
|
const parts = [];
|
|
2527
2683
|
if (tcCount)
|
|
@@ -2573,12 +2729,41 @@ export class SimplifyQaAdapter {
|
|
|
2573
2729
|
else
|
|
2574
2730
|
attributes.push(merged);
|
|
2575
2731
|
}
|
|
2732
|
+
let moduleId = doc.moduleId;
|
|
2733
|
+
if (changes.moduleName !== undefined) {
|
|
2734
|
+
const modules = await this.listModules();
|
|
2735
|
+
const wanted = changes.moduleName.trim().toLowerCase();
|
|
2736
|
+
const match = modules.find((m) => m.name.toLowerCase() === wanted);
|
|
2737
|
+
if (!match) {
|
|
2738
|
+
const options = modules.map((m) => m.name).join(', ') || '(no modules configured in this project)';
|
|
2739
|
+
throw new Error(`Module "${changes.moduleName}" not found on object ${id}. Valid modules: ${options}`);
|
|
2740
|
+
}
|
|
2741
|
+
moduleId = Number.parseInt(match.id, 10);
|
|
2742
|
+
}
|
|
2743
|
+
let parameterId = doc.parameterId ?? [];
|
|
2744
|
+
if (changes.parameterNames !== undefined) {
|
|
2745
|
+
const projectId = this.requireProjectId();
|
|
2746
|
+
const resolved = [];
|
|
2747
|
+
const unresolved = [];
|
|
2748
|
+
for (const name of changes.parameterNames) {
|
|
2749
|
+
const pid = await this.findParameterId(projectId, name);
|
|
2750
|
+
if (pid != null)
|
|
2751
|
+
resolved.push(pid);
|
|
2752
|
+
else
|
|
2753
|
+
unresolved.push(name);
|
|
2754
|
+
}
|
|
2755
|
+
if (unresolved.length) {
|
|
2756
|
+
throw new Error(`Parameter(s) not found on object ${id}: ${unresolved.join(', ')}. Check the exact name ` +
|
|
2757
|
+
'(case-insensitive), or create the parameter first in the Parameters screen.');
|
|
2758
|
+
}
|
|
2759
|
+
parameterId = resolved;
|
|
2760
|
+
}
|
|
2576
2761
|
const body = {
|
|
2577
2762
|
disname: changes.name ?? doc.disname,
|
|
2578
|
-
description: doc.description,
|
|
2763
|
+
description: changes.description !== undefined ? changes.description : doc.description,
|
|
2579
2764
|
objecttemplateId: doc.objecttemplateId,
|
|
2580
|
-
parameterId
|
|
2581
|
-
moduleId
|
|
2765
|
+
parameterId,
|
|
2766
|
+
moduleId,
|
|
2582
2767
|
technology: doc.technology,
|
|
2583
2768
|
attributes,
|
|
2584
2769
|
projectId: this.requireProjectId(),
|
|
@@ -2810,11 +2995,51 @@ export class SimplifyQaAdapter {
|
|
|
2810
2995
|
}
|
|
2811
2996
|
// ---------------------------------------------------------------------- Run status [Step 6]
|
|
2812
2997
|
/** Real status of a run — reads step-level results direct from SQA REST because the desktop agent's
|
|
2813
|
-
* get_run only returns queue state (often stale).
|
|
2998
|
+
* get_run only returns queue state (often stale). For a SUITE run, returns per-TC rollup
|
|
2999
|
+
* (`children[]`) instead of step details — a suite doc's `type` field flags it. start/stop still
|
|
3000
|
+
* hop for v1. */
|
|
2814
3001
|
async getRun(runId) {
|
|
2815
3002
|
const projectId = this.requireProjectId();
|
|
2816
3003
|
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
2817
|
-
|
|
3004
|
+
const runIdNum = Number.parseInt(numericId(runId), 10);
|
|
3005
|
+
// Top-level execution doc (carries `type` = 'TC' or 'SUITE', and `executionItems[]` for a suite).
|
|
3006
|
+
// One extra POST per getRun call; a suite short-circuits here so no /exec/report is needed.
|
|
3007
|
+
if (Number.isFinite(runIdNum)) {
|
|
3008
|
+
const execBody = {
|
|
3009
|
+
searchFields: [
|
|
3010
|
+
{ column: 'id', value: runIdNum, regEx: false },
|
|
3011
|
+
{ column: 'projectId', value: projectId, regEx: false },
|
|
3012
|
+
{ column: 'deleted', value: false },
|
|
3013
|
+
],
|
|
3014
|
+
startIndex: 0,
|
|
3015
|
+
limit: 1,
|
|
3016
|
+
};
|
|
3017
|
+
const execRaw = await this.http.postJson('/exec/execution/search', execBody);
|
|
3018
|
+
const execDoc = pickArray(execRaw, 'data', 'content', 'executions')[0];
|
|
3019
|
+
if (execDoc && String(execDoc.type ?? '').toUpperCase() === 'SUITE') {
|
|
3020
|
+
const items = Array.isArray(execDoc.executionItems) ? execDoc.executionItems : [];
|
|
3021
|
+
const children = items.map((it) => {
|
|
3022
|
+
const child = {
|
|
3023
|
+
id: str(it, 'code') ?? String(it?.id ?? ''),
|
|
3024
|
+
state: mapRunState(str(it, 'status') ?? ''),
|
|
3025
|
+
};
|
|
3026
|
+
const name = str(it, 'name');
|
|
3027
|
+
if (name)
|
|
3028
|
+
child.title = name;
|
|
3029
|
+
return child;
|
|
3030
|
+
});
|
|
3031
|
+
const total = children.length;
|
|
3032
|
+
const done = children.filter((c) => c.state !== 'queued' && c.state !== 'running').length;
|
|
3033
|
+
const suiteNum = Number(execDoc.executionTypeId ?? 0);
|
|
3034
|
+
return {
|
|
3035
|
+
runId,
|
|
3036
|
+
state: mapRunState(str(execDoc, 'status') ?? ''),
|
|
3037
|
+
...(total > 0 ? { progress: { done, total }, children } : {}),
|
|
3038
|
+
reportUrl: `${base}/user/reports/reports-page?type=SUITE&parentId=${suiteNum}&id=${runIdNum}&projectId=${projectId}`,
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
// TC path — per-step results via /exec/report + /reports/execution/getstepResult.
|
|
2818
3043
|
const runs = await this.http.getJson(`/exec/report/${runId}/${projectId}`);
|
|
2819
3044
|
if (!Array.isArray(runs) || runs.length === 0) {
|
|
2820
3045
|
return { runId, state: 'queued' };
|
|
@@ -2870,9 +3095,37 @@ export class SimplifyQaAdapter {
|
|
|
2870
3095
|
runId,
|
|
2871
3096
|
state: mapRunState(str(chosen, 'status') ?? ''),
|
|
2872
3097
|
stepResults,
|
|
2873
|
-
reportUrl: `${base}/reports/
|
|
3098
|
+
reportUrl: `${base}/user/reports/reports-page?type=TESTCASE&parentId=${parentId}&id=${Number.parseInt(numericId(runId), 10)}&projectId=${projectId}`,
|
|
2874
3099
|
};
|
|
2875
3100
|
}
|
|
3101
|
+
/** Most recent execution runId for a SUITE — same `/exec/execution/search` the /reports UI's
|
|
3102
|
+
* current-executions view drives, filtered to `type=SUITE` + this suite's numeric id, sorted by
|
|
3103
|
+
* id desc, limit 1. Returns the numeric run id (what `getRun` accepts), or null if the suite has
|
|
3104
|
+
* never been executed. */
|
|
3105
|
+
async getLatestSuiteRun(suiteId) {
|
|
3106
|
+
const projectId = this.requireProjectId();
|
|
3107
|
+
const suiteNum = Number.parseInt(numericId(suiteId), 10);
|
|
3108
|
+
if (!Number.isFinite(suiteNum))
|
|
3109
|
+
throw new Error(`getLatestSuiteRun: invalid suiteId ${suiteId}.`);
|
|
3110
|
+
// Hand-rolled payload — searchBody doesn't emit `sort` or `operator` keys the /reports search wants.
|
|
3111
|
+
const body = {
|
|
3112
|
+
searchFields: [
|
|
3113
|
+
{ column: 'id', sort: 'dsc' },
|
|
3114
|
+
{ column: 'deleted', value: false },
|
|
3115
|
+
{ column: 'projectId', value: projectId, regEx: false },
|
|
3116
|
+
{ column: 'childExecution', value: false },
|
|
3117
|
+
{ column: 'parentExecutionCopy', value: true, operator: 'nor' },
|
|
3118
|
+
{ column: 'type', value: 'SUITE', regEx: false },
|
|
3119
|
+
{ column: 'executionTypeId', value: suiteNum, regEx: false },
|
|
3120
|
+
],
|
|
3121
|
+
startIndex: 0,
|
|
3122
|
+
limit: 1,
|
|
3123
|
+
};
|
|
3124
|
+
const raw = await this.http.postJson('/exec/execution/search', body);
|
|
3125
|
+
const rows = pickArray(raw, 'data', 'content', 'executions');
|
|
3126
|
+
const runId = str(rows[0], 'id', '_id');
|
|
3127
|
+
return runId ?? null;
|
|
3128
|
+
}
|
|
2876
3129
|
/** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
|
|
2877
3130
|
* is paged (default 100) and API-key scoping can hide the selected project, which left
|
|
2878
3131
|
* callers with an empty projectName. */
|
|
@@ -2907,36 +3160,60 @@ export class SimplifyQaAdapter {
|
|
|
2907
3160
|
if (!Number.isFinite(tcNum))
|
|
2908
3161
|
throw new Error(`startRun: could not resolve numeric id for ${tcId}.`);
|
|
2909
3162
|
const mode = (opts.mode ?? 'local').toUpperCase();
|
|
3163
|
+
// LOCAL fast-fail: the desktop agent is what actually executes the TC (it polls the Redis queue
|
|
3164
|
+
// the backend RPUSHes to). If :4040 isn't up, no roster / customer-search / dispatch will help —
|
|
3165
|
+
// fail here with a clear message instead of enqueuing a job nothing will drain.
|
|
3166
|
+
if (mode === 'LOCAL' && !(await this.probeLocalAgent())) {
|
|
3167
|
+
throw new Error(`startRun (LOCAL): SimplifyQA desktop agent is not reachable at ` +
|
|
3168
|
+
`${this.cfg.url ?? '(no agent URL configured — set QA_MCP_URL)'}. Start the desktop ` +
|
|
3169
|
+
`agent, then try again. Use mode:'cloud' to run on a cloud runner instead — it does not ` +
|
|
3170
|
+
`need the desktop agent.`);
|
|
3171
|
+
}
|
|
2910
3172
|
const rawTc = await this.fetchRawTestcase(tcId);
|
|
2911
3173
|
if (!rawTc || Object.keys(rawTc).length === 0) {
|
|
2912
3174
|
throw new Error(`startRun: test case ${tcId} not found.`);
|
|
2913
3175
|
}
|
|
2914
|
-
// Version resolution
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
3176
|
+
// Version resolution. When the caller supplies an override we accept EITHER the version id or
|
|
3177
|
+
// its name (matches the ask-user UX — a human types "v2.0", not a uuid) and hard-fail if it
|
|
3178
|
+
// resolves to nothing. Silent fallback would send a mismatched {versionId, versionName} to
|
|
3179
|
+
// /exec/execution: at best the backend 500s, at worst it silently runs the TC's current
|
|
3180
|
+
// version instead of the one asked for. No override → the existing fallback chain picks the
|
|
3181
|
+
// TC's current version, matching the UI's Version-chip default.
|
|
3182
|
+
const versions = Array.isArray(rawTc?.versions) ? rawTc.versions : [];
|
|
2917
3183
|
let versionName;
|
|
2918
3184
|
let versionId;
|
|
2919
3185
|
if (opts.versionId) {
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
const
|
|
2923
|
-
|
|
2924
|
-
|
|
3186
|
+
const picked = versions.find((v) => {
|
|
3187
|
+
const vid = str(v, 'id', '_id');
|
|
3188
|
+
const vname = str(v, 'name');
|
|
3189
|
+
return vid === opts.versionId || vname === opts.versionId;
|
|
3190
|
+
});
|
|
3191
|
+
if (!picked) {
|
|
3192
|
+
if (!versions.length) {
|
|
3193
|
+
throw new Error(`startRun: test case ${tcId} has no versions available — cannot pin version "${opts.versionId}".`);
|
|
3194
|
+
}
|
|
3195
|
+
const listing = versions
|
|
3196
|
+
.map((v) => ` • \`${str(v, 'id', '_id')}\` — ${str(v, 'name')}`)
|
|
3197
|
+
.join('\n');
|
|
3198
|
+
throw new Error(`startRun: version "${opts.versionId}" not found on ${tcId}. Available:\n${listing}`);
|
|
2925
3199
|
}
|
|
3200
|
+
versionId = str(picked, 'id', '_id');
|
|
3201
|
+
versionName = str(picked, 'name');
|
|
3202
|
+
}
|
|
3203
|
+
else {
|
|
3204
|
+
versionName = str(rawTc, 'versionName', 'selectedVersionName');
|
|
3205
|
+
versionId = str(rawTc, 'versionId', 'selectedVersionId');
|
|
3206
|
+
if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
|
|
3207
|
+
versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
|
|
3208
|
+
versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
|
|
3209
|
+
}
|
|
3210
|
+
if ((!versionName || !versionId) && versions.length > 0) {
|
|
3211
|
+
versionName ?? (versionName = str(versions[0], 'name'));
|
|
3212
|
+
versionId ?? (versionId = str(versions[0], 'id', '_id'));
|
|
3213
|
+
}
|
|
3214
|
+
versionName ?? (versionName = 'v1.0');
|
|
3215
|
+
versionId ?? (versionId = '');
|
|
2926
3216
|
}
|
|
2927
|
-
versionName ?? (versionName = str(rawTc, 'versionName', 'selectedVersionName'));
|
|
2928
|
-
versionId ?? (versionId = str(rawTc, 'versionId', 'selectedVersionId'));
|
|
2929
|
-
if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
|
|
2930
|
-
versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
|
|
2931
|
-
versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
|
|
2932
|
-
}
|
|
2933
|
-
if ((!versionName || !versionId) && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
|
|
2934
|
-
const v = rawTc.versions[0];
|
|
2935
|
-
versionName ?? (versionName = str(v, 'name'));
|
|
2936
|
-
versionId ?? (versionId = str(v, 'id', '_id'));
|
|
2937
|
-
}
|
|
2938
|
-
versionName ?? (versionName = 'v1.0');
|
|
2939
|
-
versionId ?? (versionId = '');
|
|
2940
3217
|
// ponytail: was `this.info()` — 5 round-trips (list_projects + 4 counts) for one field, and
|
|
2941
3218
|
// returned projectName="" when the current project wasn't in the first 100-row page or was
|
|
2942
3219
|
// filtered by API-key scope, which the /exec/execution endpoint rejects with HTTP 400.
|
|
@@ -2968,8 +3245,16 @@ export class SimplifyQaAdapter {
|
|
|
2968
3245
|
}
|
|
2969
3246
|
catch { /* leave empty */ }
|
|
2970
3247
|
}
|
|
2971
|
-
const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId)
|
|
2972
|
-
|
|
3248
|
+
const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId)
|
|
3249
|
+
: mode === 'REMOTE' ? await this.resolveRemoteAgentId(opts.agentId)
|
|
3250
|
+
: null;
|
|
3251
|
+
const platform = this.buildRunPlatform(tcType, mode, {
|
|
3252
|
+
...(opts.browser !== undefined ? { browser: opts.browser } : {}),
|
|
3253
|
+
...(opts.deviceId !== undefined ? { deviceId: opts.deviceId } : {}),
|
|
3254
|
+
...(opts.deviceType !== undefined ? { deviceType: opts.deviceType } : {}),
|
|
3255
|
+
...(opts.osVersion !== undefined ? { osVersion: opts.osVersion } : {}),
|
|
3256
|
+
...(opts.cloudDeviceType !== undefined ? { cloudDeviceType: opts.cloudDeviceType } : {}),
|
|
3257
|
+
});
|
|
2973
3258
|
// UI uses `Math.random().toString(26).substr(5,6)` — plan doc confirms any string works.
|
|
2974
3259
|
const stepId = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
|
|
2975
3260
|
const body = {
|
|
@@ -3041,6 +3326,25 @@ export class SimplifyQaAdapter {
|
|
|
3041
3326
|
killType: 'STOP_EXECUTION',
|
|
3042
3327
|
});
|
|
3043
3328
|
}
|
|
3329
|
+
/** GET the agent /mcp with a 1s timeout. Any HTTP response (incl. 4xx from an MCP server rejecting
|
|
3330
|
+
* a naked GET) proves the process is up; connect/DNS/timeout errors mean it is down. Replaces
|
|
3331
|
+
* the customer-wide `machineRegistration` round trip when all we needed was "is LOCAL possible?". */
|
|
3332
|
+
async probeLocalAgent() {
|
|
3333
|
+
if (!this.cfg.url)
|
|
3334
|
+
return false;
|
|
3335
|
+
const ac = new AbortController();
|
|
3336
|
+
const timer = setTimeout(() => ac.abort(), 1000);
|
|
3337
|
+
try {
|
|
3338
|
+
await fetch(this.cfg.url, { method: 'GET', signal: ac.signal });
|
|
3339
|
+
return true;
|
|
3340
|
+
}
|
|
3341
|
+
catch {
|
|
3342
|
+
return false;
|
|
3343
|
+
}
|
|
3344
|
+
finally {
|
|
3345
|
+
clearTimeout(timer);
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3044
3348
|
async listCloudRunners() {
|
|
3045
3349
|
const projectId = this.requireProjectId();
|
|
3046
3350
|
const body = this.searchBody([{ column: 'projectId', value: projectId }], { limit: 1000 });
|
|
@@ -3133,6 +3437,158 @@ export class SimplifyQaAdapter {
|
|
|
3133
3437
|
};
|
|
3134
3438
|
});
|
|
3135
3439
|
}
|
|
3440
|
+
/** Mobile devices for the project — the union of:
|
|
3441
|
+
* (a) the LOCAL roster from `/tm/mobileDevices/search` (adb-visible reals, emulators,
|
|
3442
|
+
* simulators registered via the desktop agent) — same source the UI's "Real Devices /
|
|
3443
|
+
* Emulators / Simulators" tabs read; `active` is a last-known connection flag stale by
|
|
3444
|
+
* design (the desktop agent writes it, a 7s-stale cron flips it back).
|
|
3445
|
+
* (b) the CLOUD roster from whichever farm the project has integrated — probed in the same
|
|
3446
|
+
* order as the UI (`execution-popup-modal-v2.component.ts:198-204`): LambdaTest,
|
|
3447
|
+
* BrowserStack, SauceLabs, SimplifyQA Device Farm. First provider that answers with a
|
|
3448
|
+
* non-empty list wins (matches the UI's `mobileIntergation.type` single-provider model —
|
|
3449
|
+
* one cloud provider per project). Cloud rows carry `deviceType:'CLOUD_DEVICE'` and a
|
|
3450
|
+
* `cloudDeviceType` tag so `tc_run` knows which farm to submit to.
|
|
3451
|
+
* A failure fetching one source doesn't kill the other — the tool logs and returns whatever
|
|
3452
|
+
* it could resolve. */
|
|
3453
|
+
async listMobileDevices(platform) {
|
|
3454
|
+
const local = await this.fetchLocalMobileDevices(platform).catch(() => []);
|
|
3455
|
+
const cloud = await this.fetchCloudMobileDevices(platform).catch(() => []);
|
|
3456
|
+
return [...local, ...cloud];
|
|
3457
|
+
}
|
|
3458
|
+
async fetchLocalMobileDevices(platform) {
|
|
3459
|
+
const body = this.searchBody([
|
|
3460
|
+
{ column: 'deleted', value: false },
|
|
3461
|
+
{ column: 'technologyType', value: platform },
|
|
3462
|
+
], { limit: 1000 });
|
|
3463
|
+
const raw = await this.http.postJson('/tm/mobileDevices/search', body);
|
|
3464
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3465
|
+
return rows.map((n) => {
|
|
3466
|
+
const id = str(n, 'deviceId', 'udid', 'id', '_id') ?? '';
|
|
3467
|
+
const name = str(n, 'deviceName', 'name', 'model') ?? id;
|
|
3468
|
+
const rawType = String(str(n, 'deviceType') ?? 'REAL_DEVICE').toUpperCase();
|
|
3469
|
+
// SQA stores "REAL" | "EMULATOR" | "SIMULATOR" — normalise "REAL" → "REAL_DEVICE" so the
|
|
3470
|
+
// adapter's downstream wire mapping stays consistent with the executor's enum.
|
|
3471
|
+
const deviceType = rawType === 'REAL' ? 'REAL_DEVICE'
|
|
3472
|
+
: rawType;
|
|
3473
|
+
const osVersion = str(n, 'osVersion');
|
|
3474
|
+
const status = str(n, 'status');
|
|
3475
|
+
const agentId = str(n, 'agentId');
|
|
3476
|
+
return {
|
|
3477
|
+
id, name, platform, deviceType,
|
|
3478
|
+
...(osVersion ? { osVersion } : {}),
|
|
3479
|
+
...(typeof n?.active === 'boolean' ? { active: n.active } : {}),
|
|
3480
|
+
...(status ? { status } : {}),
|
|
3481
|
+
...(agentId ? { agentId } : {}),
|
|
3482
|
+
};
|
|
3483
|
+
});
|
|
3484
|
+
}
|
|
3485
|
+
/** Probe cloud farms in the UI's order and return the first non-empty match. One provider per
|
|
3486
|
+
* project (the UI's `mobileIntergation.type` model), so no need to merge across farms. Every
|
|
3487
|
+
* fetch is wrapped so a 404/401 from one provider doesn't kill the whole list. */
|
|
3488
|
+
async fetchCloudMobileDevices(platform) {
|
|
3489
|
+
// Order matches execution-popup-modal-v2:198-204 (SauceLabs → BrowserStack → LambdaTest).
|
|
3490
|
+
// SQA Device Farm is the fallback the UI only reaches when LambdaTest is inactive (line 203).
|
|
3491
|
+
const attempts = [
|
|
3492
|
+
['SAUCE_LABS', () => this.fetchSauceLabsMobileDevices(platform)],
|
|
3493
|
+
['BROWSER_STACK', () => this.fetchBrowserStackMobileDevices(platform)],
|
|
3494
|
+
['LAMBDA_TEST', () => this.fetchLambdaTestMobileDevices(platform)],
|
|
3495
|
+
['SIMPLIFY_QA_DEVICE_FORM', () => this.fetchDeviceFarmMobileDevices(platform)],
|
|
3496
|
+
];
|
|
3497
|
+
for (const [, fetcher] of attempts) {
|
|
3498
|
+
const rows = await fetcher().catch(() => []);
|
|
3499
|
+
if (rows.length)
|
|
3500
|
+
return rows;
|
|
3501
|
+
}
|
|
3502
|
+
return [];
|
|
3503
|
+
}
|
|
3504
|
+
async fetchSauceLabsMobileDevices(platform) {
|
|
3505
|
+
const projectId = this.requireProjectId();
|
|
3506
|
+
const raw = await this.http.getJson(`/tm/marketPlace/sauceLabsDevices/SAUCE_LABS?projectId=${encodeURIComponent(projectId)}`);
|
|
3507
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3508
|
+
// SauceLabs returns OS as UPPERCASE ("IOS"/"ANDROID") — mirror execution-popup-modal-v2:447-448.
|
|
3509
|
+
const wanted = platform.toUpperCase();
|
|
3510
|
+
return rows
|
|
3511
|
+
.filter((n) => String(n?.os ?? '').toUpperCase() === wanted)
|
|
3512
|
+
.map((n) => {
|
|
3513
|
+
const id = str(n, 'id', 'udid', 'name') ?? '';
|
|
3514
|
+
const name = str(n, 'name', 'deviceName', 'model') ?? id;
|
|
3515
|
+
const osVersion = str(n, 'osVersion', 'os_version');
|
|
3516
|
+
return {
|
|
3517
|
+
id, name, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'SAUCE_LABS',
|
|
3518
|
+
...(osVersion ? { osVersion } : {}),
|
|
3519
|
+
};
|
|
3520
|
+
});
|
|
3521
|
+
}
|
|
3522
|
+
async fetchBrowserStackMobileDevices(platform) {
|
|
3523
|
+
const projectId = this.requireProjectId();
|
|
3524
|
+
const raw = await this.http.getJson(`/tm/testdata/browserStackDevices/BROWSER_STACK?projectId=${encodeURIComponent(projectId)}`);
|
|
3525
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3526
|
+
// BrowserStack returns OS as lowercase ("ios"/"android") — mirror execution-popup-modal-v2:434-435.
|
|
3527
|
+
const wanted = platform.toLowerCase();
|
|
3528
|
+
return rows
|
|
3529
|
+
.filter((n) => String(n?.os ?? '').toLowerCase() === wanted)
|
|
3530
|
+
.map((n) => {
|
|
3531
|
+
const id = str(n, 'id', 'device') ?? '';
|
|
3532
|
+
const name = str(n, 'device', 'name') ?? id;
|
|
3533
|
+
const osVersion = str(n, 'os_version', 'osVersion');
|
|
3534
|
+
return {
|
|
3535
|
+
id, name, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'BROWSER_STACK',
|
|
3536
|
+
...(osVersion ? { osVersion } : {}),
|
|
3537
|
+
};
|
|
3538
|
+
});
|
|
3539
|
+
}
|
|
3540
|
+
async fetchLambdaTestMobileDevices(platform) {
|
|
3541
|
+
const projectId = this.requireProjectId();
|
|
3542
|
+
const raw = await this.http.getJson(`/tm/marketPlace/lambdaTestDevices/LAMBDA_TEST?projectId=${encodeURIComponent(projectId)}`);
|
|
3543
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3544
|
+
// LambdaTest returns OS as lowercase in `platformName`/`os` — mirror the mapping in
|
|
3545
|
+
// execution-popup-modal-v2:472-493 (which composes a friendly display name + slug id).
|
|
3546
|
+
const wantedLower = platform.toLowerCase();
|
|
3547
|
+
const seen = new Set();
|
|
3548
|
+
const mapped = [];
|
|
3549
|
+
for (const d of rows) {
|
|
3550
|
+
const os = String(d?.platformName ?? d?.os ?? '').toLowerCase();
|
|
3551
|
+
if (os !== wantedLower)
|
|
3552
|
+
continue;
|
|
3553
|
+
const baseName = str(d, 'deviceName', 'name') ?? '';
|
|
3554
|
+
const version = str(d, 'version', 'platformVersion', 'osVersion') ?? '';
|
|
3555
|
+
const displayName = version
|
|
3556
|
+
? `${baseName} (${os === 'ios' ? 'iOS' : 'Android'} ${version})`
|
|
3557
|
+
: baseName;
|
|
3558
|
+
const udid = str(d, 'udid');
|
|
3559
|
+
const slug = `${baseName}_${version}_${os}`.toLowerCase()
|
|
3560
|
+
.replace(/[^a-z0-9_]+/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
3561
|
+
const id = udid ? `cloud_${udid}` : `cloud_${slug}`;
|
|
3562
|
+
if (seen.has(id))
|
|
3563
|
+
continue;
|
|
3564
|
+
seen.add(id);
|
|
3565
|
+
mapped.push({
|
|
3566
|
+
id, name: displayName, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'LAMBDA_TEST',
|
|
3567
|
+
...(version ? { osVersion: version } : {}),
|
|
3568
|
+
});
|
|
3569
|
+
}
|
|
3570
|
+
return mapped;
|
|
3571
|
+
}
|
|
3572
|
+
async fetchDeviceFarmMobileDevices(platform) {
|
|
3573
|
+
const projectId = this.requireProjectId();
|
|
3574
|
+
// SimplifyQA Device Farm is Appium-backed — endpoint mirrors marketPlace.route.js:25. The UI's
|
|
3575
|
+
// `getCloudFormDevicesForExec()` reads the same source when LambdaTest is inactive.
|
|
3576
|
+
const raw = await this.http.getJson(`/tm/marketPlace/deviceFarmDevices/SIMPLIFY_QA_DEVICE_FORM?projectId=${encodeURIComponent(projectId)}`);
|
|
3577
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3578
|
+
// Farm rows include `platform` (ANDROID/IOS) and `sdk` (osVersion). Mirror device-dropdown.ts:413-419.
|
|
3579
|
+
const wanted = platform.toUpperCase();
|
|
3580
|
+
return rows
|
|
3581
|
+
.filter((n) => String(n?.platform ?? '').toUpperCase() === wanted)
|
|
3582
|
+
.map((n) => {
|
|
3583
|
+
const id = str(n, 'udid', 'id') ?? '';
|
|
3584
|
+
const name = str(n, 'name', 'deviceName') ?? id;
|
|
3585
|
+
const osVersion = str(n, 'sdk', 'osVersion');
|
|
3586
|
+
return {
|
|
3587
|
+
id, name, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'SIMPLIFY_QA_DEVICE_FORM',
|
|
3588
|
+
...(osVersion ? { osVersion } : {}),
|
|
3589
|
+
};
|
|
3590
|
+
});
|
|
3591
|
+
}
|
|
3136
3592
|
/**
|
|
3137
3593
|
* Pick THE ACTING USER'S OWN agent — never row [0] of a shared-tenant roster (which would
|
|
3138
3594
|
* silently dispatch a local run to a teammate's box, and the run would sit queued until TTL
|
|
@@ -3148,8 +3604,6 @@ export class SimplifyQaAdapter {
|
|
|
3148
3604
|
* column to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
|
|
3149
3605
|
*/
|
|
3150
3606
|
async resolveLocalAgentId(preferred) {
|
|
3151
|
-
if (preferred && preferred.length > 0)
|
|
3152
|
-
return preferred;
|
|
3153
3607
|
const agents = await this.listLocalAgents();
|
|
3154
3608
|
if (agents.length === 0) {
|
|
3155
3609
|
throw new Error('startRun (LOCAL): no local agent registered. Register a machine in the SimplifyQA UI ' +
|
|
@@ -3159,6 +3613,18 @@ export class SimplifyQaAdapter {
|
|
|
3159
3613
|
// agent frequently shows `active:false` between the 5s stale-cron and the next heartbeat, and
|
|
3160
3614
|
// the executions service is off-limits for the real fix.
|
|
3161
3615
|
const mine = agents.filter((a) => a.isThisMachine);
|
|
3616
|
+
// Explicit agentId: only accept it when it belongs to THIS machine. LOCAL dispatches to the
|
|
3617
|
+
// desktop agent on this box (there is exactly one per machine); a teammate's credentialId under
|
|
3618
|
+
// mode:'local' was the silent-misroute bug isThisMachine exists to prevent.
|
|
3619
|
+
if (preferred && preferred.length > 0) {
|
|
3620
|
+
if (mine.some((a) => a.id === preferred))
|
|
3621
|
+
return preferred;
|
|
3622
|
+
const listing = agents.map((a) => ` - ${a.name ?? a.id} \`${a.id}\`${a.isThisMachine ? ' (this machine)' : ''}`).join('\n');
|
|
3623
|
+
throw new Error(`startRun (LOCAL): agentId "${preferred}" is not registered from THIS machine. LOCAL mode ` +
|
|
3624
|
+
`dispatches to this box's own desktop agent — teammate agents are for a REMOTE run. ` +
|
|
3625
|
+
`Omit agentId to auto-pick this machine's agent, or use mode:'cloud' for a cloud runner.\n` +
|
|
3626
|
+
`Registered agents:\n${listing}`);
|
|
3627
|
+
}
|
|
3162
3628
|
if (mine.length === 1)
|
|
3163
3629
|
return mine[0].id;
|
|
3164
3630
|
if (mine.length > 1) {
|
|
@@ -3166,14 +3632,33 @@ export class SimplifyQaAdapter {
|
|
|
3166
3632
|
throw new Error(`startRun (LOCAL): multiple agents registered from this machine. Pass \`agentId\` to ` +
|
|
3167
3633
|
`pick one:\n${listing}`);
|
|
3168
3634
|
}
|
|
3169
|
-
// No physical match
|
|
3170
|
-
|
|
3171
|
-
// the exact silent-misroute bug isThisMachine was written to prevent. Surface the roster so
|
|
3172
|
-
// the caller can pass `agentId` explicitly.
|
|
3173
|
-
const listing = agents.map((a) => ` - ${a.name ?? a.id} \`${a.id}\`${a.online ? '' : ' (offline)'}`).join('\n');
|
|
3635
|
+
// No physical match for this machine and no preferred — LOCAL cannot proceed.
|
|
3636
|
+
const roster = agents.map((a) => ` - ${a.name ?? a.id} \`${a.id}\`${a.online ? '' : ' (offline)'}`).join('\n');
|
|
3174
3637
|
throw new Error(`startRun (LOCAL): no agent registered from this machine (hostname / MAC / IP did not match ` +
|
|
3175
|
-
`any row). Launch the SimplifyQA desktop agent on this box, or
|
|
3176
|
-
`
|
|
3638
|
+
`any row). Launch the SimplifyQA desktop agent on this box, or use mode:'remote' to run on a ` +
|
|
3639
|
+
`teammate's agent.\nRegistered agents:\n${roster}`);
|
|
3640
|
+
}
|
|
3641
|
+
/** REMOTE dispatches to ANOTHER machine's desktop agent — must pin a specific credentialId (auto-pick
|
|
3642
|
+
* across teammate boxes is the exact silent-misroute this exists to prevent). Enforces:
|
|
3643
|
+
* (1) preferred is present, (2) it exists in the roster, (3) it is NOT this machine (use LOCAL for
|
|
3644
|
+
* that). Reachability of the remote agent is not probed — the backend queues the job and the
|
|
3645
|
+
* remote agent picks it up when it polls. */
|
|
3646
|
+
async resolveRemoteAgentId(preferred) {
|
|
3647
|
+
if (!preferred || preferred.length === 0) {
|
|
3648
|
+
throw new Error(`startRun (REMOTE): agentId is required. REMOTE dispatches to a specific teammate machine — ` +
|
|
3649
|
+
`there is no auto-pick. Call remote_agents to see the roster and pass one as \`agentId\`.`);
|
|
3650
|
+
}
|
|
3651
|
+
const agents = await this.listLocalAgents();
|
|
3652
|
+
const picked = agents.find((a) => a.id === preferred);
|
|
3653
|
+
if (!picked) {
|
|
3654
|
+
throw new Error(`startRun (REMOTE): agentId "${preferred}" is not a registered agent. Call remote_agents ` +
|
|
3655
|
+
`for the current roster.`);
|
|
3656
|
+
}
|
|
3657
|
+
if (picked.isThisMachine) {
|
|
3658
|
+
throw new Error(`startRun (REMOTE): agentId "${preferred}" is THIS machine's own agent — use mode:'local' ` +
|
|
3659
|
+
`for a run on this box.`);
|
|
3660
|
+
}
|
|
3661
|
+
return preferred;
|
|
3177
3662
|
}
|
|
3178
3663
|
detectExecutionOS() {
|
|
3179
3664
|
if (process.platform === 'win32')
|
|
@@ -3182,30 +3667,36 @@ export class SimplifyQaAdapter {
|
|
|
3182
3667
|
return 'MAC';
|
|
3183
3668
|
return 'LINUX';
|
|
3184
3669
|
}
|
|
3185
|
-
buildRunPlatform(tcType, mode,
|
|
3670
|
+
buildRunPlatform(tcType, mode, opts) {
|
|
3186
3671
|
const t = tcType.toUpperCase();
|
|
3187
3672
|
if (t === 'WEB') {
|
|
3188
3673
|
if (mode === 'CLOUD') {
|
|
3189
|
-
const key = `${(browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
|
|
3674
|
+
const key = `${(opts.browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
|
|
3190
3675
|
return [{ name: key, id: key, technologyType: 'WEB', type: 'NONE' }];
|
|
3191
3676
|
}
|
|
3192
|
-
const b = (browser ?? 'CHROME').toUpperCase();
|
|
3677
|
+
const b = (opts.browser ?? 'CHROME').toUpperCase();
|
|
3193
3678
|
return [{ name: b, id: b, technologyType: 'WEB', type: 'NONE' }];
|
|
3194
3679
|
}
|
|
3195
3680
|
if (t === 'ANDROID' || t === 'IOS') {
|
|
3196
3681
|
// Executor's AndroidSessionFactory.buildLocal calls requireDeviceId — no default. Fail
|
|
3197
3682
|
// loud so the caller sees the fix (list devices, pick one) instead of a queued/error hang.
|
|
3198
|
-
if (!deviceId) {
|
|
3199
|
-
throw new Error(`${t} test cases need \`deviceId\`. Call
|
|
3683
|
+
if (!opts.deviceId) {
|
|
3684
|
+
throw new Error(`${t} test cases need \`deviceId\`. Call mobile_list_devices to see the available ` +
|
|
3200
3685
|
`devices, then re-run with \`deviceId\` set to the adb serial (LOCAL) or marketplace ` +
|
|
3201
3686
|
`device name (CLOUD).`);
|
|
3202
3687
|
}
|
|
3203
|
-
//
|
|
3204
|
-
//
|
|
3205
|
-
|
|
3688
|
+
// Default LOCAL to REAL_DEVICE when caller doesn't say — mirrors the pre-deviceType shape.
|
|
3689
|
+
// CLOUD gets CLOUD_DEVICE for the same reason. Explicit deviceType wins over both.
|
|
3690
|
+
const type = opts.deviceType ?? (mode === 'CLOUD' ? 'CLOUD_DEVICE' : 'REAL_DEVICE');
|
|
3691
|
+
const isCloud = type.startsWith('CLOUD_');
|
|
3692
|
+
// ponytail: LambdaTest cloud runs work without app-URL preload for most flows — the
|
|
3693
|
+
// executor derives creds from the project's LT integration server-side. If a run fails
|
|
3694
|
+
// with "app not found" we'll port the UI's preloadLambdaAppMap flow (execution-popup-
|
|
3695
|
+
// modal-v2:613-641) and stamp appUrl/userName/accessKey here.
|
|
3206
3696
|
return [{
|
|
3207
|
-
name: deviceId, id: deviceId, technologyType: t,
|
|
3208
|
-
|
|
3697
|
+
name: opts.deviceId, id: opts.deviceId, technologyType: t, type,
|
|
3698
|
+
...(opts.osVersion ? { osVersion: opts.osVersion } : {}),
|
|
3699
|
+
...(isCloud && opts.cloudDeviceType ? { cloudDeviceType: opts.cloudDeviceType } : {}),
|
|
3209
3700
|
}];
|
|
3210
3701
|
}
|
|
3211
3702
|
return [{ name: t, id: t, technologyType: t, type: 'NONE' }];
|