common-qa-mcp 0.5.0 → 0.6.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.d.ts +4 -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 +93 -26
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/adapter.js +662 -234
- 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 +35 -1
- package/dist/adapters/simplifyqa/mapper.js.map +1 -1
- package/dist/adapters/types.d.ts +47 -10
- 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 +95 -15
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +31 -11
- package/dist/initAgents.js.map +1 -1
- package/dist/server.js +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/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 +111 -6
- 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 +5 -2
- 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
|
|
@@ -2317,11 +2384,58 @@ export class SimplifyQaAdapter {
|
|
|
2317
2384
|
}
|
|
2318
2385
|
// ---------------------------------------------------------------------- Suites (write) [Step 4]
|
|
2319
2386
|
async createSuite(s) {
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2387
|
+
const projectId = this.requireProjectId();
|
|
2388
|
+
const name = s.name?.trim();
|
|
2389
|
+
if (!name)
|
|
2390
|
+
throw new Error('name is required.');
|
|
2391
|
+
if (!s.members?.length)
|
|
2392
|
+
throw new Error('At least one test case is required to create a suite.');
|
|
2393
|
+
const entries = [];
|
|
2394
|
+
const categories = new Set();
|
|
2395
|
+
for (const m of s.members) {
|
|
2396
|
+
const tcNum = numericId(m.tcId);
|
|
2397
|
+
const raw = await this.fetchRawTestcase(tcNum);
|
|
2398
|
+
if (Object.keys(raw).length === 0)
|
|
2399
|
+
throw new Error(`test case ${m.tcId} not found (cannot add to suite)`);
|
|
2400
|
+
categories.add(String(str(raw, 'executionType') ?? '').toUpperCase() === 'MANUAL' ? 'MANUAL' : 'AUTOMATION');
|
|
2401
|
+
entries.push(await this.buildSuiteEntryFromTestcase(tcNum));
|
|
2402
|
+
}
|
|
2403
|
+
if (categories.size > 1) {
|
|
2404
|
+
throw new Error('Cannot create suite: the test cases are a mix of MANUAL and AUTOMATION — a suite can only ' +
|
|
2405
|
+
'hold ONE category. Split them into two suites, or pick only same-category test cases.');
|
|
2406
|
+
}
|
|
2407
|
+
const suiteType = [...categories][0] ?? 'AUTOMATION';
|
|
2408
|
+
entries.forEach((e, i) => { e['seq'] = i + 1; });
|
|
2409
|
+
const payload = {
|
|
2410
|
+
name,
|
|
2411
|
+
description: s.description ?? '',
|
|
2412
|
+
state: 'New',
|
|
2413
|
+
suiteType,
|
|
2414
|
+
projectId,
|
|
2415
|
+
autoDefectLog: false,
|
|
2416
|
+
autoReExecCount: 0,
|
|
2417
|
+
newBrowserSession: false,
|
|
2418
|
+
terminateOnFailure: false,
|
|
2419
|
+
autoReExecTriggerOn: 'AFTER_SUITE',
|
|
2420
|
+
minimumContainerCount: null,
|
|
2421
|
+
maximumContainerCount: null,
|
|
2422
|
+
testCases: entries,
|
|
2423
|
+
mailersList: {},
|
|
2424
|
+
tags: [],
|
|
2425
|
+
customFields: {},
|
|
2426
|
+
lastSelectedExecutionType: 'LOCAL',
|
|
2427
|
+
tcChanged: true,
|
|
2428
|
+
};
|
|
2429
|
+
const raw = await this.http.postMultipart('/tm/suites', 'suiteObj', payload);
|
|
2430
|
+
const flat = raw?.data ?? raw ?? {};
|
|
2431
|
+
const savedId = str(flat, 'id', '_id') ?? '';
|
|
2432
|
+
if (!savedId)
|
|
2433
|
+
throw new Error(`Suite create returned no id: ${JSON.stringify(raw)}`);
|
|
2434
|
+
return {
|
|
2435
|
+
id: savedId,
|
|
2436
|
+
created: true,
|
|
2437
|
+
productUrl: this.productUrlFor({ kind: 'suite', id: savedId }),
|
|
2438
|
+
};
|
|
2325
2439
|
}
|
|
2326
2440
|
/** Fetch raw suite, apply add/remove/reorder ops in memory, then PUT the whole doc back with a
|
|
2327
2441
|
* STRICT allow-listed payload (the SQA update validator rejects unknown keys on both suite-level
|
|
@@ -2513,15 +2627,23 @@ export class SimplifyQaAdapter {
|
|
|
2513
2627
|
* delete is refused rather than silently breaking those test cases; only a dependency-free object
|
|
2514
2628
|
* gets the real `isDelete:true` call.
|
|
2515
2629
|
*/
|
|
2516
|
-
|
|
2630
|
+
/** `PATCH /tm/object` with `isDelete:false` — the SAME dependency-check pre-flight the real
|
|
2631
|
+
* product UI fires before its own delete-confirm modal (object.component.ts onClickTableDelete):
|
|
2632
|
+
* a read-only probe, no restore/undelete side effect. Split out from deleteObject so a caller
|
|
2633
|
+
* (object_delete's preview step) can show this BEFORE asking the user to confirm, not just
|
|
2634
|
+
* discover it as a thrown error after the fact. */
|
|
2635
|
+
async checkObjectDependencies(id) {
|
|
2517
2636
|
const projectId = this.requireProjectId();
|
|
2518
2637
|
const numId = Number.parseInt(numericId(id), 10);
|
|
2519
2638
|
const checkRes = await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: false });
|
|
2520
2639
|
const row = checkRes?.data?.[0];
|
|
2521
2640
|
const countRefs = (m) => m ? Object.values(m).reduce((n, arr) => n + (Array.isArray(arr) ? arr.length : 0), 0) : 0;
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2641
|
+
return { tcCount: countRefs(row?.tcArr), fnCount: countRefs(row?.fnArr), wfCount: countRefs(row?.wfArr) };
|
|
2642
|
+
}
|
|
2643
|
+
async deleteObject(id) {
|
|
2644
|
+
const projectId = this.requireProjectId();
|
|
2645
|
+
const numId = Number.parseInt(numericId(id), 10);
|
|
2646
|
+
const { tcCount, fnCount, wfCount } = await this.checkObjectDependencies(id);
|
|
2525
2647
|
if (tcCount || fnCount || wfCount) {
|
|
2526
2648
|
const parts = [];
|
|
2527
2649
|
if (tcCount)
|
|
@@ -2810,11 +2932,51 @@ export class SimplifyQaAdapter {
|
|
|
2810
2932
|
}
|
|
2811
2933
|
// ---------------------------------------------------------------------- Run status [Step 6]
|
|
2812
2934
|
/** 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).
|
|
2935
|
+
* get_run only returns queue state (often stale). For a SUITE run, returns per-TC rollup
|
|
2936
|
+
* (`children[]`) instead of step details — a suite doc's `type` field flags it. start/stop still
|
|
2937
|
+
* hop for v1. */
|
|
2814
2938
|
async getRun(runId) {
|
|
2815
2939
|
const projectId = this.requireProjectId();
|
|
2816
2940
|
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
2817
|
-
|
|
2941
|
+
const runIdNum = Number.parseInt(numericId(runId), 10);
|
|
2942
|
+
// Top-level execution doc (carries `type` = 'TC' or 'SUITE', and `executionItems[]` for a suite).
|
|
2943
|
+
// One extra POST per getRun call; a suite short-circuits here so no /exec/report is needed.
|
|
2944
|
+
if (Number.isFinite(runIdNum)) {
|
|
2945
|
+
const execBody = {
|
|
2946
|
+
searchFields: [
|
|
2947
|
+
{ column: 'id', value: runIdNum, regEx: false },
|
|
2948
|
+
{ column: 'projectId', value: projectId, regEx: false },
|
|
2949
|
+
{ column: 'deleted', value: false },
|
|
2950
|
+
],
|
|
2951
|
+
startIndex: 0,
|
|
2952
|
+
limit: 1,
|
|
2953
|
+
};
|
|
2954
|
+
const execRaw = await this.http.postJson('/exec/execution/search', execBody);
|
|
2955
|
+
const execDoc = pickArray(execRaw, 'data', 'content', 'executions')[0];
|
|
2956
|
+
if (execDoc && String(execDoc.type ?? '').toUpperCase() === 'SUITE') {
|
|
2957
|
+
const items = Array.isArray(execDoc.executionItems) ? execDoc.executionItems : [];
|
|
2958
|
+
const children = items.map((it) => {
|
|
2959
|
+
const child = {
|
|
2960
|
+
id: str(it, 'code') ?? String(it?.id ?? ''),
|
|
2961
|
+
state: mapRunState(str(it, 'status') ?? ''),
|
|
2962
|
+
};
|
|
2963
|
+
const name = str(it, 'name');
|
|
2964
|
+
if (name)
|
|
2965
|
+
child.title = name;
|
|
2966
|
+
return child;
|
|
2967
|
+
});
|
|
2968
|
+
const total = children.length;
|
|
2969
|
+
const done = children.filter((c) => c.state !== 'queued' && c.state !== 'running').length;
|
|
2970
|
+
const suiteNum = Number(execDoc.executionTypeId ?? 0);
|
|
2971
|
+
return {
|
|
2972
|
+
runId,
|
|
2973
|
+
state: mapRunState(str(execDoc, 'status') ?? ''),
|
|
2974
|
+
...(total > 0 ? { progress: { done, total }, children } : {}),
|
|
2975
|
+
reportUrl: `${base}/user/reports/reports-page?type=SUITE&parentId=${suiteNum}&id=${runIdNum}&projectId=${projectId}`,
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
// TC path — per-step results via /exec/report + /reports/execution/getstepResult.
|
|
2818
2980
|
const runs = await this.http.getJson(`/exec/report/${runId}/${projectId}`);
|
|
2819
2981
|
if (!Array.isArray(runs) || runs.length === 0) {
|
|
2820
2982
|
return { runId, state: 'queued' };
|
|
@@ -2870,8 +3032,36 @@ export class SimplifyQaAdapter {
|
|
|
2870
3032
|
runId,
|
|
2871
3033
|
state: mapRunState(str(chosen, 'status') ?? ''),
|
|
2872
3034
|
stepResults,
|
|
2873
|
-
reportUrl: `${base}/reports/
|
|
3035
|
+
reportUrl: `${base}/user/reports/reports-page?type=TESTCASE&parentId=${parentId}&id=${Number.parseInt(numericId(runId), 10)}&projectId=${projectId}`,
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
/** Most recent execution runId for a SUITE — same `/exec/execution/search` the /reports UI's
|
|
3039
|
+
* current-executions view drives, filtered to `type=SUITE` + this suite's numeric id, sorted by
|
|
3040
|
+
* id desc, limit 1. Returns the numeric run id (what `getRun` accepts), or null if the suite has
|
|
3041
|
+
* never been executed. */
|
|
3042
|
+
async getLatestSuiteRun(suiteId) {
|
|
3043
|
+
const projectId = this.requireProjectId();
|
|
3044
|
+
const suiteNum = Number.parseInt(numericId(suiteId), 10);
|
|
3045
|
+
if (!Number.isFinite(suiteNum))
|
|
3046
|
+
throw new Error(`getLatestSuiteRun: invalid suiteId ${suiteId}.`);
|
|
3047
|
+
// Hand-rolled payload — searchBody doesn't emit `sort` or `operator` keys the /reports search wants.
|
|
3048
|
+
const body = {
|
|
3049
|
+
searchFields: [
|
|
3050
|
+
{ column: 'id', sort: 'dsc' },
|
|
3051
|
+
{ column: 'deleted', value: false },
|
|
3052
|
+
{ column: 'projectId', value: projectId, regEx: false },
|
|
3053
|
+
{ column: 'childExecution', value: false },
|
|
3054
|
+
{ column: 'parentExecutionCopy', value: true, operator: 'nor' },
|
|
3055
|
+
{ column: 'type', value: 'SUITE', regEx: false },
|
|
3056
|
+
{ column: 'executionTypeId', value: suiteNum, regEx: false },
|
|
3057
|
+
],
|
|
3058
|
+
startIndex: 0,
|
|
3059
|
+
limit: 1,
|
|
2874
3060
|
};
|
|
3061
|
+
const raw = await this.http.postJson('/exec/execution/search', body);
|
|
3062
|
+
const rows = pickArray(raw, 'data', 'content', 'executions');
|
|
3063
|
+
const runId = str(rows[0], 'id', '_id');
|
|
3064
|
+
return runId ?? null;
|
|
2875
3065
|
}
|
|
2876
3066
|
/** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
|
|
2877
3067
|
* is paged (default 100) and API-key scoping can hide the selected project, which left
|
|
@@ -2907,36 +3097,60 @@ export class SimplifyQaAdapter {
|
|
|
2907
3097
|
if (!Number.isFinite(tcNum))
|
|
2908
3098
|
throw new Error(`startRun: could not resolve numeric id for ${tcId}.`);
|
|
2909
3099
|
const mode = (opts.mode ?? 'local').toUpperCase();
|
|
3100
|
+
// LOCAL fast-fail: the desktop agent is what actually executes the TC (it polls the Redis queue
|
|
3101
|
+
// the backend RPUSHes to). If :4040 isn't up, no roster / customer-search / dispatch will help —
|
|
3102
|
+
// fail here with a clear message instead of enqueuing a job nothing will drain.
|
|
3103
|
+
if (mode === 'LOCAL' && !(await this.probeLocalAgent())) {
|
|
3104
|
+
throw new Error(`startRun (LOCAL): SimplifyQA desktop agent is not reachable at ` +
|
|
3105
|
+
`${this.cfg.url ?? '(no agent URL configured — set QA_MCP_URL)'}. Start the desktop ` +
|
|
3106
|
+
`agent, then try again. Use mode:'cloud' to run on a cloud runner instead — it does not ` +
|
|
3107
|
+
`need the desktop agent.`);
|
|
3108
|
+
}
|
|
2910
3109
|
const rawTc = await this.fetchRawTestcase(tcId);
|
|
2911
3110
|
if (!rawTc || Object.keys(rawTc).length === 0) {
|
|
2912
3111
|
throw new Error(`startRun: test case ${tcId} not found.`);
|
|
2913
3112
|
}
|
|
2914
|
-
// Version resolution
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
3113
|
+
// Version resolution. When the caller supplies an override we accept EITHER the version id or
|
|
3114
|
+
// its name (matches the ask-user UX — a human types "v2.0", not a uuid) and hard-fail if it
|
|
3115
|
+
// resolves to nothing. Silent fallback would send a mismatched {versionId, versionName} to
|
|
3116
|
+
// /exec/execution: at best the backend 500s, at worst it silently runs the TC's current
|
|
3117
|
+
// version instead of the one asked for. No override → the existing fallback chain picks the
|
|
3118
|
+
// TC's current version, matching the UI's Version-chip default.
|
|
3119
|
+
const versions = Array.isArray(rawTc?.versions) ? rawTc.versions : [];
|
|
2917
3120
|
let versionName;
|
|
2918
3121
|
let versionId;
|
|
2919
3122
|
if (opts.versionId) {
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
const
|
|
2923
|
-
|
|
2924
|
-
|
|
3123
|
+
const picked = versions.find((v) => {
|
|
3124
|
+
const vid = str(v, 'id', '_id');
|
|
3125
|
+
const vname = str(v, 'name');
|
|
3126
|
+
return vid === opts.versionId || vname === opts.versionId;
|
|
3127
|
+
});
|
|
3128
|
+
if (!picked) {
|
|
3129
|
+
if (!versions.length) {
|
|
3130
|
+
throw new Error(`startRun: test case ${tcId} has no versions available — cannot pin version "${opts.versionId}".`);
|
|
3131
|
+
}
|
|
3132
|
+
const listing = versions
|
|
3133
|
+
.map((v) => ` • \`${str(v, 'id', '_id')}\` — ${str(v, 'name')}`)
|
|
3134
|
+
.join('\n');
|
|
3135
|
+
throw new Error(`startRun: version "${opts.versionId}" not found on ${tcId}. Available:\n${listing}`);
|
|
2925
3136
|
}
|
|
3137
|
+
versionId = str(picked, 'id', '_id');
|
|
3138
|
+
versionName = str(picked, 'name');
|
|
3139
|
+
}
|
|
3140
|
+
else {
|
|
3141
|
+
versionName = str(rawTc, 'versionName', 'selectedVersionName');
|
|
3142
|
+
versionId = str(rawTc, 'versionId', 'selectedVersionId');
|
|
3143
|
+
if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
|
|
3144
|
+
versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
|
|
3145
|
+
versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
|
|
3146
|
+
}
|
|
3147
|
+
if ((!versionName || !versionId) && versions.length > 0) {
|
|
3148
|
+
versionName ?? (versionName = str(versions[0], 'name'));
|
|
3149
|
+
versionId ?? (versionId = str(versions[0], 'id', '_id'));
|
|
3150
|
+
}
|
|
3151
|
+
versionName ?? (versionName = 'v1.0');
|
|
3152
|
+
versionId ?? (versionId = '');
|
|
2926
3153
|
}
|
|
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
3154
|
// ponytail: was `this.info()` — 5 round-trips (list_projects + 4 counts) for one field, and
|
|
2941
3155
|
// returned projectName="" when the current project wasn't in the first 100-row page or was
|
|
2942
3156
|
// filtered by API-key scope, which the /exec/execution endpoint rejects with HTTP 400.
|
|
@@ -2968,8 +3182,16 @@ export class SimplifyQaAdapter {
|
|
|
2968
3182
|
}
|
|
2969
3183
|
catch { /* leave empty */ }
|
|
2970
3184
|
}
|
|
2971
|
-
const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId)
|
|
2972
|
-
|
|
3185
|
+
const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId)
|
|
3186
|
+
: mode === 'REMOTE' ? await this.resolveRemoteAgentId(opts.agentId)
|
|
3187
|
+
: null;
|
|
3188
|
+
const platform = this.buildRunPlatform(tcType, mode, {
|
|
3189
|
+
...(opts.browser !== undefined ? { browser: opts.browser } : {}),
|
|
3190
|
+
...(opts.deviceId !== undefined ? { deviceId: opts.deviceId } : {}),
|
|
3191
|
+
...(opts.deviceType !== undefined ? { deviceType: opts.deviceType } : {}),
|
|
3192
|
+
...(opts.osVersion !== undefined ? { osVersion: opts.osVersion } : {}),
|
|
3193
|
+
...(opts.cloudDeviceType !== undefined ? { cloudDeviceType: opts.cloudDeviceType } : {}),
|
|
3194
|
+
});
|
|
2973
3195
|
// UI uses `Math.random().toString(26).substr(5,6)` — plan doc confirms any string works.
|
|
2974
3196
|
const stepId = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
|
|
2975
3197
|
const body = {
|
|
@@ -3041,6 +3263,25 @@ export class SimplifyQaAdapter {
|
|
|
3041
3263
|
killType: 'STOP_EXECUTION',
|
|
3042
3264
|
});
|
|
3043
3265
|
}
|
|
3266
|
+
/** GET the agent /mcp with a 1s timeout. Any HTTP response (incl. 4xx from an MCP server rejecting
|
|
3267
|
+
* a naked GET) proves the process is up; connect/DNS/timeout errors mean it is down. Replaces
|
|
3268
|
+
* the customer-wide `machineRegistration` round trip when all we needed was "is LOCAL possible?". */
|
|
3269
|
+
async probeLocalAgent() {
|
|
3270
|
+
if (!this.cfg.url)
|
|
3271
|
+
return false;
|
|
3272
|
+
const ac = new AbortController();
|
|
3273
|
+
const timer = setTimeout(() => ac.abort(), 1000);
|
|
3274
|
+
try {
|
|
3275
|
+
await fetch(this.cfg.url, { method: 'GET', signal: ac.signal });
|
|
3276
|
+
return true;
|
|
3277
|
+
}
|
|
3278
|
+
catch {
|
|
3279
|
+
return false;
|
|
3280
|
+
}
|
|
3281
|
+
finally {
|
|
3282
|
+
clearTimeout(timer);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3044
3285
|
async listCloudRunners() {
|
|
3045
3286
|
const projectId = this.requireProjectId();
|
|
3046
3287
|
const body = this.searchBody([{ column: 'projectId', value: projectId }], { limit: 1000 });
|
|
@@ -3133,6 +3374,158 @@ export class SimplifyQaAdapter {
|
|
|
3133
3374
|
};
|
|
3134
3375
|
});
|
|
3135
3376
|
}
|
|
3377
|
+
/** Mobile devices for the project — the union of:
|
|
3378
|
+
* (a) the LOCAL roster from `/tm/mobileDevices/search` (adb-visible reals, emulators,
|
|
3379
|
+
* simulators registered via the desktop agent) — same source the UI's "Real Devices /
|
|
3380
|
+
* Emulators / Simulators" tabs read; `active` is a last-known connection flag stale by
|
|
3381
|
+
* design (the desktop agent writes it, a 7s-stale cron flips it back).
|
|
3382
|
+
* (b) the CLOUD roster from whichever farm the project has integrated — probed in the same
|
|
3383
|
+
* order as the UI (`execution-popup-modal-v2.component.ts:198-204`): LambdaTest,
|
|
3384
|
+
* BrowserStack, SauceLabs, SimplifyQA Device Farm. First provider that answers with a
|
|
3385
|
+
* non-empty list wins (matches the UI's `mobileIntergation.type` single-provider model —
|
|
3386
|
+
* one cloud provider per project). Cloud rows carry `deviceType:'CLOUD_DEVICE'` and a
|
|
3387
|
+
* `cloudDeviceType` tag so `tc_run` knows which farm to submit to.
|
|
3388
|
+
* A failure fetching one source doesn't kill the other — the tool logs and returns whatever
|
|
3389
|
+
* it could resolve. */
|
|
3390
|
+
async listMobileDevices(platform) {
|
|
3391
|
+
const local = await this.fetchLocalMobileDevices(platform).catch(() => []);
|
|
3392
|
+
const cloud = await this.fetchCloudMobileDevices(platform).catch(() => []);
|
|
3393
|
+
return [...local, ...cloud];
|
|
3394
|
+
}
|
|
3395
|
+
async fetchLocalMobileDevices(platform) {
|
|
3396
|
+
const body = this.searchBody([
|
|
3397
|
+
{ column: 'deleted', value: false },
|
|
3398
|
+
{ column: 'technologyType', value: platform },
|
|
3399
|
+
], { limit: 1000 });
|
|
3400
|
+
const raw = await this.http.postJson('/tm/mobileDevices/search', body);
|
|
3401
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3402
|
+
return rows.map((n) => {
|
|
3403
|
+
const id = str(n, 'deviceId', 'udid', 'id', '_id') ?? '';
|
|
3404
|
+
const name = str(n, 'deviceName', 'name', 'model') ?? id;
|
|
3405
|
+
const rawType = String(str(n, 'deviceType') ?? 'REAL_DEVICE').toUpperCase();
|
|
3406
|
+
// SQA stores "REAL" | "EMULATOR" | "SIMULATOR" — normalise "REAL" → "REAL_DEVICE" so the
|
|
3407
|
+
// adapter's downstream wire mapping stays consistent with the executor's enum.
|
|
3408
|
+
const deviceType = rawType === 'REAL' ? 'REAL_DEVICE'
|
|
3409
|
+
: rawType;
|
|
3410
|
+
const osVersion = str(n, 'osVersion');
|
|
3411
|
+
const status = str(n, 'status');
|
|
3412
|
+
const agentId = str(n, 'agentId');
|
|
3413
|
+
return {
|
|
3414
|
+
id, name, platform, deviceType,
|
|
3415
|
+
...(osVersion ? { osVersion } : {}),
|
|
3416
|
+
...(typeof n?.active === 'boolean' ? { active: n.active } : {}),
|
|
3417
|
+
...(status ? { status } : {}),
|
|
3418
|
+
...(agentId ? { agentId } : {}),
|
|
3419
|
+
};
|
|
3420
|
+
});
|
|
3421
|
+
}
|
|
3422
|
+
/** Probe cloud farms in the UI's order and return the first non-empty match. One provider per
|
|
3423
|
+
* project (the UI's `mobileIntergation.type` model), so no need to merge across farms. Every
|
|
3424
|
+
* fetch is wrapped so a 404/401 from one provider doesn't kill the whole list. */
|
|
3425
|
+
async fetchCloudMobileDevices(platform) {
|
|
3426
|
+
// Order matches execution-popup-modal-v2:198-204 (SauceLabs → BrowserStack → LambdaTest).
|
|
3427
|
+
// SQA Device Farm is the fallback the UI only reaches when LambdaTest is inactive (line 203).
|
|
3428
|
+
const attempts = [
|
|
3429
|
+
['SAUCE_LABS', () => this.fetchSauceLabsMobileDevices(platform)],
|
|
3430
|
+
['BROWSER_STACK', () => this.fetchBrowserStackMobileDevices(platform)],
|
|
3431
|
+
['LAMBDA_TEST', () => this.fetchLambdaTestMobileDevices(platform)],
|
|
3432
|
+
['SIMPLIFY_QA_DEVICE_FORM', () => this.fetchDeviceFarmMobileDevices(platform)],
|
|
3433
|
+
];
|
|
3434
|
+
for (const [, fetcher] of attempts) {
|
|
3435
|
+
const rows = await fetcher().catch(() => []);
|
|
3436
|
+
if (rows.length)
|
|
3437
|
+
return rows;
|
|
3438
|
+
}
|
|
3439
|
+
return [];
|
|
3440
|
+
}
|
|
3441
|
+
async fetchSauceLabsMobileDevices(platform) {
|
|
3442
|
+
const projectId = this.requireProjectId();
|
|
3443
|
+
const raw = await this.http.getJson(`/tm/marketPlace/sauceLabsDevices/SAUCE_LABS?projectId=${encodeURIComponent(projectId)}`);
|
|
3444
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3445
|
+
// SauceLabs returns OS as UPPERCASE ("IOS"/"ANDROID") — mirror execution-popup-modal-v2:447-448.
|
|
3446
|
+
const wanted = platform.toUpperCase();
|
|
3447
|
+
return rows
|
|
3448
|
+
.filter((n) => String(n?.os ?? '').toUpperCase() === wanted)
|
|
3449
|
+
.map((n) => {
|
|
3450
|
+
const id = str(n, 'id', 'udid', 'name') ?? '';
|
|
3451
|
+
const name = str(n, 'name', 'deviceName', 'model') ?? id;
|
|
3452
|
+
const osVersion = str(n, 'osVersion', 'os_version');
|
|
3453
|
+
return {
|
|
3454
|
+
id, name, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'SAUCE_LABS',
|
|
3455
|
+
...(osVersion ? { osVersion } : {}),
|
|
3456
|
+
};
|
|
3457
|
+
});
|
|
3458
|
+
}
|
|
3459
|
+
async fetchBrowserStackMobileDevices(platform) {
|
|
3460
|
+
const projectId = this.requireProjectId();
|
|
3461
|
+
const raw = await this.http.getJson(`/tm/testdata/browserStackDevices/BROWSER_STACK?projectId=${encodeURIComponent(projectId)}`);
|
|
3462
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3463
|
+
// BrowserStack returns OS as lowercase ("ios"/"android") — mirror execution-popup-modal-v2:434-435.
|
|
3464
|
+
const wanted = platform.toLowerCase();
|
|
3465
|
+
return rows
|
|
3466
|
+
.filter((n) => String(n?.os ?? '').toLowerCase() === wanted)
|
|
3467
|
+
.map((n) => {
|
|
3468
|
+
const id = str(n, 'id', 'device') ?? '';
|
|
3469
|
+
const name = str(n, 'device', 'name') ?? id;
|
|
3470
|
+
const osVersion = str(n, 'os_version', 'osVersion');
|
|
3471
|
+
return {
|
|
3472
|
+
id, name, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'BROWSER_STACK',
|
|
3473
|
+
...(osVersion ? { osVersion } : {}),
|
|
3474
|
+
};
|
|
3475
|
+
});
|
|
3476
|
+
}
|
|
3477
|
+
async fetchLambdaTestMobileDevices(platform) {
|
|
3478
|
+
const projectId = this.requireProjectId();
|
|
3479
|
+
const raw = await this.http.getJson(`/tm/marketPlace/lambdaTestDevices/LAMBDA_TEST?projectId=${encodeURIComponent(projectId)}`);
|
|
3480
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3481
|
+
// LambdaTest returns OS as lowercase in `platformName`/`os` — mirror the mapping in
|
|
3482
|
+
// execution-popup-modal-v2:472-493 (which composes a friendly display name + slug id).
|
|
3483
|
+
const wantedLower = platform.toLowerCase();
|
|
3484
|
+
const seen = new Set();
|
|
3485
|
+
const mapped = [];
|
|
3486
|
+
for (const d of rows) {
|
|
3487
|
+
const os = String(d?.platformName ?? d?.os ?? '').toLowerCase();
|
|
3488
|
+
if (os !== wantedLower)
|
|
3489
|
+
continue;
|
|
3490
|
+
const baseName = str(d, 'deviceName', 'name') ?? '';
|
|
3491
|
+
const version = str(d, 'version', 'platformVersion', 'osVersion') ?? '';
|
|
3492
|
+
const displayName = version
|
|
3493
|
+
? `${baseName} (${os === 'ios' ? 'iOS' : 'Android'} ${version})`
|
|
3494
|
+
: baseName;
|
|
3495
|
+
const udid = str(d, 'udid');
|
|
3496
|
+
const slug = `${baseName}_${version}_${os}`.toLowerCase()
|
|
3497
|
+
.replace(/[^a-z0-9_]+/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
3498
|
+
const id = udid ? `cloud_${udid}` : `cloud_${slug}`;
|
|
3499
|
+
if (seen.has(id))
|
|
3500
|
+
continue;
|
|
3501
|
+
seen.add(id);
|
|
3502
|
+
mapped.push({
|
|
3503
|
+
id, name: displayName, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'LAMBDA_TEST',
|
|
3504
|
+
...(version ? { osVersion: version } : {}),
|
|
3505
|
+
});
|
|
3506
|
+
}
|
|
3507
|
+
return mapped;
|
|
3508
|
+
}
|
|
3509
|
+
async fetchDeviceFarmMobileDevices(platform) {
|
|
3510
|
+
const projectId = this.requireProjectId();
|
|
3511
|
+
// SimplifyQA Device Farm is Appium-backed — endpoint mirrors marketPlace.route.js:25. The UI's
|
|
3512
|
+
// `getCloudFormDevicesForExec()` reads the same source when LambdaTest is inactive.
|
|
3513
|
+
const raw = await this.http.getJson(`/tm/marketPlace/deviceFarmDevices/SIMPLIFY_QA_DEVICE_FORM?projectId=${encodeURIComponent(projectId)}`);
|
|
3514
|
+
const rows = pickArray(raw, 'data', 'content', 'devices');
|
|
3515
|
+
// Farm rows include `platform` (ANDROID/IOS) and `sdk` (osVersion). Mirror device-dropdown.ts:413-419.
|
|
3516
|
+
const wanted = platform.toUpperCase();
|
|
3517
|
+
return rows
|
|
3518
|
+
.filter((n) => String(n?.platform ?? '').toUpperCase() === wanted)
|
|
3519
|
+
.map((n) => {
|
|
3520
|
+
const id = str(n, 'udid', 'id') ?? '';
|
|
3521
|
+
const name = str(n, 'name', 'deviceName') ?? id;
|
|
3522
|
+
const osVersion = str(n, 'sdk', 'osVersion');
|
|
3523
|
+
return {
|
|
3524
|
+
id, name, platform, deviceType: 'CLOUD_DEVICE', cloudDeviceType: 'SIMPLIFY_QA_DEVICE_FORM',
|
|
3525
|
+
...(osVersion ? { osVersion } : {}),
|
|
3526
|
+
};
|
|
3527
|
+
});
|
|
3528
|
+
}
|
|
3136
3529
|
/**
|
|
3137
3530
|
* Pick THE ACTING USER'S OWN agent — never row [0] of a shared-tenant roster (which would
|
|
3138
3531
|
* silently dispatch a local run to a teammate's box, and the run would sit queued until TTL
|
|
@@ -3148,8 +3541,6 @@ export class SimplifyQaAdapter {
|
|
|
3148
3541
|
* column to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
|
|
3149
3542
|
*/
|
|
3150
3543
|
async resolveLocalAgentId(preferred) {
|
|
3151
|
-
if (preferred && preferred.length > 0)
|
|
3152
|
-
return preferred;
|
|
3153
3544
|
const agents = await this.listLocalAgents();
|
|
3154
3545
|
if (agents.length === 0) {
|
|
3155
3546
|
throw new Error('startRun (LOCAL): no local agent registered. Register a machine in the SimplifyQA UI ' +
|
|
@@ -3159,6 +3550,18 @@ export class SimplifyQaAdapter {
|
|
|
3159
3550
|
// agent frequently shows `active:false` between the 5s stale-cron and the next heartbeat, and
|
|
3160
3551
|
// the executions service is off-limits for the real fix.
|
|
3161
3552
|
const mine = agents.filter((a) => a.isThisMachine);
|
|
3553
|
+
// Explicit agentId: only accept it when it belongs to THIS machine. LOCAL dispatches to the
|
|
3554
|
+
// desktop agent on this box (there is exactly one per machine); a teammate's credentialId under
|
|
3555
|
+
// mode:'local' was the silent-misroute bug isThisMachine exists to prevent.
|
|
3556
|
+
if (preferred && preferred.length > 0) {
|
|
3557
|
+
if (mine.some((a) => a.id === preferred))
|
|
3558
|
+
return preferred;
|
|
3559
|
+
const listing = agents.map((a) => ` - ${a.name ?? a.id} \`${a.id}\`${a.isThisMachine ? ' (this machine)' : ''}`).join('\n');
|
|
3560
|
+
throw new Error(`startRun (LOCAL): agentId "${preferred}" is not registered from THIS machine. LOCAL mode ` +
|
|
3561
|
+
`dispatches to this box's own desktop agent — teammate agents are for a REMOTE run. ` +
|
|
3562
|
+
`Omit agentId to auto-pick this machine's agent, or use mode:'cloud' for a cloud runner.\n` +
|
|
3563
|
+
`Registered agents:\n${listing}`);
|
|
3564
|
+
}
|
|
3162
3565
|
if (mine.length === 1)
|
|
3163
3566
|
return mine[0].id;
|
|
3164
3567
|
if (mine.length > 1) {
|
|
@@ -3166,14 +3569,33 @@ export class SimplifyQaAdapter {
|
|
|
3166
3569
|
throw new Error(`startRun (LOCAL): multiple agents registered from this machine. Pass \`agentId\` to ` +
|
|
3167
3570
|
`pick one:\n${listing}`);
|
|
3168
3571
|
}
|
|
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');
|
|
3572
|
+
// No physical match for this machine and no preferred — LOCAL cannot proceed.
|
|
3573
|
+
const roster = agents.map((a) => ` - ${a.name ?? a.id} \`${a.id}\`${a.online ? '' : ' (offline)'}`).join('\n');
|
|
3174
3574
|
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
|
-
`
|
|
3575
|
+
`any row). Launch the SimplifyQA desktop agent on this box, or use mode:'remote' to run on a ` +
|
|
3576
|
+
`teammate's agent.\nRegistered agents:\n${roster}`);
|
|
3577
|
+
}
|
|
3578
|
+
/** REMOTE dispatches to ANOTHER machine's desktop agent — must pin a specific credentialId (auto-pick
|
|
3579
|
+
* across teammate boxes is the exact silent-misroute this exists to prevent). Enforces:
|
|
3580
|
+
* (1) preferred is present, (2) it exists in the roster, (3) it is NOT this machine (use LOCAL for
|
|
3581
|
+
* that). Reachability of the remote agent is not probed — the backend queues the job and the
|
|
3582
|
+
* remote agent picks it up when it polls. */
|
|
3583
|
+
async resolveRemoteAgentId(preferred) {
|
|
3584
|
+
if (!preferred || preferred.length === 0) {
|
|
3585
|
+
throw new Error(`startRun (REMOTE): agentId is required. REMOTE dispatches to a specific teammate machine — ` +
|
|
3586
|
+
`there is no auto-pick. Call remote_agents to see the roster and pass one as \`agentId\`.`);
|
|
3587
|
+
}
|
|
3588
|
+
const agents = await this.listLocalAgents();
|
|
3589
|
+
const picked = agents.find((a) => a.id === preferred);
|
|
3590
|
+
if (!picked) {
|
|
3591
|
+
throw new Error(`startRun (REMOTE): agentId "${preferred}" is not a registered agent. Call remote_agents ` +
|
|
3592
|
+
`for the current roster.`);
|
|
3593
|
+
}
|
|
3594
|
+
if (picked.isThisMachine) {
|
|
3595
|
+
throw new Error(`startRun (REMOTE): agentId "${preferred}" is THIS machine's own agent — use mode:'local' ` +
|
|
3596
|
+
`for a run on this box.`);
|
|
3597
|
+
}
|
|
3598
|
+
return preferred;
|
|
3177
3599
|
}
|
|
3178
3600
|
detectExecutionOS() {
|
|
3179
3601
|
if (process.platform === 'win32')
|
|
@@ -3182,30 +3604,36 @@ export class SimplifyQaAdapter {
|
|
|
3182
3604
|
return 'MAC';
|
|
3183
3605
|
return 'LINUX';
|
|
3184
3606
|
}
|
|
3185
|
-
buildRunPlatform(tcType, mode,
|
|
3607
|
+
buildRunPlatform(tcType, mode, opts) {
|
|
3186
3608
|
const t = tcType.toUpperCase();
|
|
3187
3609
|
if (t === 'WEB') {
|
|
3188
3610
|
if (mode === 'CLOUD') {
|
|
3189
|
-
const key = `${(browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
|
|
3611
|
+
const key = `${(opts.browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
|
|
3190
3612
|
return [{ name: key, id: key, technologyType: 'WEB', type: 'NONE' }];
|
|
3191
3613
|
}
|
|
3192
|
-
const b = (browser ?? 'CHROME').toUpperCase();
|
|
3614
|
+
const b = (opts.browser ?? 'CHROME').toUpperCase();
|
|
3193
3615
|
return [{ name: b, id: b, technologyType: 'WEB', type: 'NONE' }];
|
|
3194
3616
|
}
|
|
3195
3617
|
if (t === 'ANDROID' || t === 'IOS') {
|
|
3196
3618
|
// Executor's AndroidSessionFactory.buildLocal calls requireDeviceId — no default. Fail
|
|
3197
3619
|
// 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
|
|
3620
|
+
if (!opts.deviceId) {
|
|
3621
|
+
throw new Error(`${t} test cases need \`deviceId\`. Call mobile_list_devices to see the available ` +
|
|
3200
3622
|
`devices, then re-run with \`deviceId\` set to the adb serial (LOCAL) or marketplace ` +
|
|
3201
3623
|
`device name (CLOUD).`);
|
|
3202
3624
|
}
|
|
3203
|
-
//
|
|
3204
|
-
//
|
|
3205
|
-
|
|
3625
|
+
// Default LOCAL to REAL_DEVICE when caller doesn't say — mirrors the pre-deviceType shape.
|
|
3626
|
+
// CLOUD gets CLOUD_DEVICE for the same reason. Explicit deviceType wins over both.
|
|
3627
|
+
const type = opts.deviceType ?? (mode === 'CLOUD' ? 'CLOUD_DEVICE' : 'REAL_DEVICE');
|
|
3628
|
+
const isCloud = type.startsWith('CLOUD_');
|
|
3629
|
+
// ponytail: LambdaTest cloud runs work without app-URL preload for most flows — the
|
|
3630
|
+
// executor derives creds from the project's LT integration server-side. If a run fails
|
|
3631
|
+
// with "app not found" we'll port the UI's preloadLambdaAppMap flow (execution-popup-
|
|
3632
|
+
// modal-v2:613-641) and stamp appUrl/userName/accessKey here.
|
|
3206
3633
|
return [{
|
|
3207
|
-
name: deviceId, id: deviceId, technologyType: t,
|
|
3208
|
-
|
|
3634
|
+
name: opts.deviceId, id: opts.deviceId, technologyType: t, type,
|
|
3635
|
+
...(opts.osVersion ? { osVersion: opts.osVersion } : {}),
|
|
3636
|
+
...(isCloud && opts.cloudDeviceType ? { cloudDeviceType: opts.cloudDeviceType } : {}),
|
|
3209
3637
|
}];
|
|
3210
3638
|
}
|
|
3211
3639
|
return [{ name: t, id: t, technologyType: t, type: 'NONE' }];
|