common-qa-mcp 0.4.1 → 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 +130 -36
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/adapter.js +833 -265
- 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 +98 -17
- 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 +154 -26
- 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 +263 -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
|
|
@@ -231,6 +215,9 @@ export class SimplifyQaAdapter {
|
|
|
231
215
|
// (from the Promise.all in saveGeneratedIterations) all await the same fetch instead of each firing
|
|
232
216
|
// their own /tm/syncFormulas/fetch before the cache is populated.
|
|
233
217
|
this.formulaCache = null;
|
|
218
|
+
// Same rationale for the backlog default state/priority per parentId (1=Epic, 2=Feature, 3=Story).
|
|
219
|
+
// Fields config barely changes mid-session; cache the in-flight promise so parallel creates share it.
|
|
220
|
+
this.backlogDefaultsCache = new Map();
|
|
234
221
|
// ---------------------------------------------------------------------- Automation resolvers
|
|
235
222
|
/** POST /tm/action/search → per (technology, name) → row (id + objectRequired + objectTemplate).
|
|
236
223
|
* Cached per adapter instance: the catalog is customer-wide and rarely changes during a session.
|
|
@@ -244,6 +231,9 @@ export class SimplifyQaAdapter {
|
|
|
244
231
|
* formulaCache. Populated from `POST /tm/objectTemplate/search` (confirmed endpoint,
|
|
245
232
|
* api-endpoints.ts `object.search`'s sibling `objectTemplate.search`). */
|
|
246
233
|
this.objectTemplateCache = null;
|
|
234
|
+
/** Gather every identifier this process can claim: bare hostname, non-loopback MAC addresses,
|
|
235
|
+
* non-loopback IPs. Cached per-adapter — network interfaces do not change mid-session. */
|
|
236
|
+
this.localIdsCache = null;
|
|
247
237
|
if (!cfg.baseUrl) {
|
|
248
238
|
throw new Error('SimplifyQaAdapter requires a baseUrl — set QA_BASE_URL or use a JWT with a `url` claim.');
|
|
249
239
|
}
|
|
@@ -570,9 +560,16 @@ export class SimplifyQaAdapter {
|
|
|
570
560
|
}
|
|
571
561
|
}
|
|
572
562
|
/** For every step with an objectRef, resolve the object's display name (`disname`) via
|
|
573
|
-
* /tm/object/search (batched by distinct objectId). Mutates `objectRef.name` in place.
|
|
574
|
-
*
|
|
575
|
-
*
|
|
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. */
|
|
576
573
|
async hydrateObjectNames(tc) {
|
|
577
574
|
const ids = new Set();
|
|
578
575
|
for (const s of tc.steps) {
|
|
@@ -605,10 +602,13 @@ export class SimplifyQaAdapter {
|
|
|
605
602
|
const name = byId.get(s.objectRef.id);
|
|
606
603
|
if (name)
|
|
607
604
|
s.objectRef.name = name;
|
|
605
|
+
else
|
|
606
|
+
s.objectRef.missing = true;
|
|
608
607
|
}
|
|
609
608
|
}
|
|
610
609
|
catch {
|
|
611
|
-
//
|
|
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).
|
|
612
612
|
}
|
|
613
613
|
}
|
|
614
614
|
// ---------------------------------------------------------------------- Suites (read)
|
|
@@ -738,12 +738,102 @@ export class SimplifyQaAdapter {
|
|
|
738
738
|
const rows = pickArray(raw, 'data', 'content', 'tasks');
|
|
739
739
|
return withTotal(rows.map(toTaskSummary), raw);
|
|
740
740
|
}
|
|
741
|
+
// ---------------------------------------------------------------------- Backlog defaults
|
|
742
|
+
/** Fetch the project's default `state` and `priority` for a backlog entity.
|
|
743
|
+
* parentId: 1 = Epic, 2 = Feature, 3 = User Story. Two-source resolution, in order:
|
|
744
|
+
*
|
|
745
|
+
* 1. `/pm/fields/:parentId?fieldType=defaultFld` — pick the field value marked `isDefault: true`;
|
|
746
|
+
* fall back to the first non-archived value (seed ships with `isDefault: false` everywhere
|
|
747
|
+
* until a customer configures one).
|
|
748
|
+
* 2. If either state or priority is still missing, hit the entity's `/search` endpoint for one
|
|
749
|
+
* existing row in the project and steal its state / priority. Real data is a more reliable
|
|
750
|
+
* signal than the fields config, which is customer-scoped for Epic/Feature (parentIds 1/2)
|
|
751
|
+
* and can be missing entirely on customers that pre-date the seeding step.
|
|
752
|
+
*
|
|
753
|
+
* Returns the value's `.key` (uppercase enum like `PROPOSED`, `MEDIUM`) — that is what the entity
|
|
754
|
+
* row stores; the field metadata declares `keyname: "key"` / `type: "KEY"` and the SQA UI binding
|
|
755
|
+
* (see epic-form.component.html: `keyname="key" type="KEY" [displayKeys]="{'field':'value'}"`) confirms
|
|
756
|
+
* key is stored, `.value` is only for display. Both sources are wrapped in try/catch: a transient
|
|
757
|
+
* upstream error must not block create; the backend surfaces its own error if invalid. */
|
|
758
|
+
async getBacklogDefaults(parentId) {
|
|
759
|
+
const cached = this.backlogDefaultsCache.get(parentId);
|
|
760
|
+
if (cached)
|
|
761
|
+
return cached;
|
|
762
|
+
const projectId = this.requireProjectId();
|
|
763
|
+
const p = (async () => {
|
|
764
|
+
const out = {};
|
|
765
|
+
// Source 1: /pm/fields config.
|
|
766
|
+
try {
|
|
767
|
+
const raw = await this.http.getJson(`/pm/fields/${parentId}?fieldType=defaultFld&projectId=${projectId}`);
|
|
768
|
+
const fields = Array.isArray(raw) ? raw : pickArray(raw, 'data', 'content', 'fields');
|
|
769
|
+
const pick = (key) => {
|
|
770
|
+
const field = fields.find((f) => f?.key === key);
|
|
771
|
+
const values = Array.isArray(field?.values) ? field.values : [];
|
|
772
|
+
const chosen = values.find((v) => v?.isDefault === true && v?.archive !== true)
|
|
773
|
+
?? values.find((v) => v?.archive !== true);
|
|
774
|
+
// Entity rows persist the uppercase enum (value.key). value.value is display-only.
|
|
775
|
+
const stored = str(chosen ?? {}, 'key', 'value');
|
|
776
|
+
return stored && stored.length > 0 ? stored : undefined;
|
|
777
|
+
};
|
|
778
|
+
const s = pick('state');
|
|
779
|
+
const pr = pick('priority');
|
|
780
|
+
if (s)
|
|
781
|
+
out.state = s;
|
|
782
|
+
if (pr)
|
|
783
|
+
out.priority = pr;
|
|
784
|
+
}
|
|
785
|
+
catch { /* fall through to Source 2 */ }
|
|
786
|
+
// Source 2: fall back to state/priority from an existing entity of the same kind in this project.
|
|
787
|
+
// Epic has no priority field, so we only need state for parentId=1.
|
|
788
|
+
const needsPriority = parentId !== 1 && !out.priority;
|
|
789
|
+
if (!out.state || needsPriority) {
|
|
790
|
+
try {
|
|
791
|
+
const path = parentId === 1 ? '/pm/epic/search'
|
|
792
|
+
: parentId === 2 ? '/pm/feature/search'
|
|
793
|
+
: '/pm/userstory/search';
|
|
794
|
+
// Story rows carry a scalar `projectId`; Epic/Feature rows carry an array `projectIds`.
|
|
795
|
+
const filters = [
|
|
796
|
+
{ column: 'deleted', value: false },
|
|
797
|
+
parentId === 3
|
|
798
|
+
? { column: 'projectId', value: projectId }
|
|
799
|
+
: { column: 'projectIds', value: projectId },
|
|
800
|
+
];
|
|
801
|
+
const body = this.searchBody(filters, { startIndex: 0, limit: 1 });
|
|
802
|
+
const raw = await this.http.postJson(path, body);
|
|
803
|
+
const rows = pickArray(raw, 'data', 'content');
|
|
804
|
+
const row = rows[0];
|
|
805
|
+
if (row) {
|
|
806
|
+
if (!out.state) {
|
|
807
|
+
const s = str(row, 'state', 'status');
|
|
808
|
+
if (s)
|
|
809
|
+
out.state = s;
|
|
810
|
+
}
|
|
811
|
+
if (needsPriority) {
|
|
812
|
+
const pr = str(row, 'priority');
|
|
813
|
+
if (pr)
|
|
814
|
+
out.priority = pr;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
catch { /* leave out.state / out.priority absent; caller can override */ }
|
|
819
|
+
}
|
|
820
|
+
return out;
|
|
821
|
+
})();
|
|
822
|
+
this.backlogDefaultsCache.set(parentId, p);
|
|
823
|
+
return p;
|
|
824
|
+
}
|
|
741
825
|
// ---------------------------------------------------------------------- Stories (write)
|
|
742
826
|
async createStory(input) {
|
|
827
|
+
const defaults = await this.getBacklogDefaults(3);
|
|
828
|
+
const state = input.state ?? defaults.state;
|
|
829
|
+
const priority = input.priority ?? defaults.priority;
|
|
743
830
|
const body = {
|
|
744
831
|
...input,
|
|
745
832
|
// SQA userstory expects a singular numeric projectId; fill from the connected project when omitted.
|
|
746
833
|
projectId: input.projectId ?? this.requireProjectId(),
|
|
834
|
+
...(state ? { state } : {}),
|
|
835
|
+
...(priority ? { priority } : {}),
|
|
836
|
+
customFields: input.customFields ?? {},
|
|
747
837
|
};
|
|
748
838
|
const raw = await this.http.postJson('/pm/userstory', body);
|
|
749
839
|
return toSavedRef(raw, true);
|
|
@@ -791,9 +881,13 @@ export class SimplifyQaAdapter {
|
|
|
791
881
|
return toEpic(doc && typeof doc === 'object' ? doc : {});
|
|
792
882
|
}
|
|
793
883
|
async createEpic(input) {
|
|
884
|
+
const defaults = await this.getBacklogDefaults(1);
|
|
885
|
+
const state = input.state ?? defaults.state;
|
|
794
886
|
const body = {
|
|
795
887
|
...input,
|
|
796
888
|
projectIds: input.projectIds ?? [this.requireProjectId()],
|
|
889
|
+
...(state ? { state } : {}),
|
|
890
|
+
customFields: input.customFields ?? {},
|
|
797
891
|
};
|
|
798
892
|
const raw = await this.http.postJson('/pm/epic', body);
|
|
799
893
|
return toSavedRef(raw, true);
|
|
@@ -855,9 +949,15 @@ export class SimplifyQaAdapter {
|
|
|
855
949
|
return toFeature(doc && typeof doc === 'object' ? doc : {});
|
|
856
950
|
}
|
|
857
951
|
async createFeature(input) {
|
|
952
|
+
const defaults = await this.getBacklogDefaults(2);
|
|
953
|
+
const state = input.state ?? defaults.state;
|
|
954
|
+
const priority = input.priority ?? defaults.priority;
|
|
858
955
|
const body = {
|
|
859
956
|
...input,
|
|
860
957
|
projectIds: input.projectIds ?? [this.requireProjectId()],
|
|
958
|
+
...(state ? { state } : {}),
|
|
959
|
+
...(priority ? { priority } : {}),
|
|
960
|
+
customFields: input.customFields ?? {},
|
|
861
961
|
};
|
|
862
962
|
const raw = await this.http.postJson('/pm/feature', body);
|
|
863
963
|
return toSavedRef(raw, true);
|
|
@@ -886,7 +986,7 @@ export class SimplifyQaAdapter {
|
|
|
886
986
|
const body = this.searchBody([
|
|
887
987
|
{ column: 'projectId', value: projectId },
|
|
888
988
|
{ column: 'deleted', value: false },
|
|
889
|
-
], { selectFields: ['id', 'code', 'name', 'parentId'] });
|
|
989
|
+
], { selectFields: ['id', 'code', 'name', 'parentId', 'isRoot', 'childIds'] });
|
|
890
990
|
const raw = await this.http.postJson('/pm/module/search', body);
|
|
891
991
|
const rows = pickArray(raw, 'data', 'content', 'modules');
|
|
892
992
|
return withTotal(rows.map(toModuleSummary), raw);
|
|
@@ -916,19 +1016,71 @@ export class SimplifyQaAdapter {
|
|
|
916
1016
|
};
|
|
917
1017
|
}
|
|
918
1018
|
/** POST /pm/module — server validates: name+isRoot+projectId required; parentId ignored when
|
|
919
|
-
* 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. */
|
|
920
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) {
|
|
921
1055
|
const payload = {
|
|
922
|
-
name
|
|
923
|
-
isRoot
|
|
924
|
-
projectId: Number.parseInt(
|
|
1056
|
+
name,
|
|
1057
|
+
isRoot,
|
|
1058
|
+
projectId: Number.parseInt(projectId, 10),
|
|
1059
|
+
childIds: [],
|
|
925
1060
|
};
|
|
926
|
-
if (
|
|
927
|
-
payload['description'] =
|
|
928
|
-
if (!
|
|
929
|
-
payload['parentId'] = Number.parseInt(
|
|
1061
|
+
if (description)
|
|
1062
|
+
payload['description'] = description;
|
|
1063
|
+
if (!isRoot && parentId)
|
|
1064
|
+
payload['parentId'] = Number.parseInt(parentId, 10);
|
|
930
1065
|
const raw = await this.http.postJson('/pm/module', payload);
|
|
931
|
-
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);
|
|
932
1084
|
}
|
|
933
1085
|
/** PATCH /pm/module — two-phase per module.controller.js:94. `confirm:false` returns the per-id
|
|
934
1086
|
* dependency footprint (test cases, functions, params, objects, defects, user stories). `confirm:true`
|
|
@@ -1053,7 +1205,13 @@ export class SimplifyQaAdapter {
|
|
|
1053
1205
|
// ---------------------------------------------------------------------- Deep links
|
|
1054
1206
|
productUrlFor(entity) {
|
|
1055
1207
|
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
1056
|
-
|
|
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
|
+
}
|
|
1057
1215
|
}
|
|
1058
1216
|
// ---------------------------------------------------------------------- Agent hop (recording + not-yet-ported ops)
|
|
1059
1217
|
// Everything below either delegates to the local agent /mcp (recording surface, and writes/testdata/
|
|
@@ -1617,87 +1775,92 @@ export class SimplifyQaAdapter {
|
|
|
1617
1775
|
}
|
|
1618
1776
|
/** Clone a testcase in place (same project + module) with all steps, versions, testdata,
|
|
1619
1777
|
* parameters, object refs, and nested function calls carried over. Backed by the SQA native
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
|
|
1626
|
-
* `_copy2`, etc. When provided, a follow-up PUT renames the freshly-created TC to that name.
|
|
1627
|
-
*
|
|
1628
|
-
* IMPORTANT — the copy endpoint's `selversions` and `selectedEnv` fields are FILTERS, not
|
|
1629
|
-
* wildcards. Passing empty arrays makes the backend copy the TC shell only (no steps, no
|
|
1630
|
-
* testdata, since both live under versions and per-env iterations). So we fetch the source
|
|
1631
|
-
* TC's version names AND the project's environment names first and pass them explicitly. */
|
|
1632
|
-
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) {
|
|
1633
1784
|
assertBusinessKeyKind(id, 'TC');
|
|
1634
1785
|
const projectId = this.requireProjectId();
|
|
1635
|
-
const
|
|
1636
|
-
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)
|
|
1637
1791
|
throw new Error(`test case ${id} not found`);
|
|
1638
1792
|
const sourceId = Number.parseInt(numericId(id), 10);
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
.
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
// `value` (filter) OR a `sort` (ordering) — the helper only builds value entries.
|
|
1670
|
-
const searchRaw = await this.http.postJson('/tm/testcase/search', {
|
|
1671
|
-
searchFields: [
|
|
1672
|
-
{ column: 'deleted', value: false },
|
|
1673
|
-
{ column: 'projectId', value: sourceProjId },
|
|
1674
|
-
{ column: 'projectLevelcopiedFrom.id', value: sourceId },
|
|
1675
|
-
{ column: 'id', sort: 'dsc' },
|
|
1676
|
-
],
|
|
1677
|
-
startIndex: 0,
|
|
1678
|
-
limit: 1,
|
|
1679
|
-
selectFields: ['id', 'code', 'name'],
|
|
1680
|
-
});
|
|
1681
|
-
const rows = pickArray(searchRaw, 'data', 'content', 'testCases');
|
|
1682
|
-
if (rows.length === 0) {
|
|
1683
|
-
throw new Error('Clone was requested but the newly-created testcase could not be located afterwards. ' +
|
|
1684
|
-
'The backend may still be processing the copy, or the caller may lack the ' +
|
|
1685
|
-
'`copy_testcase_across_project` privilege on this project.');
|
|
1686
|
-
}
|
|
1687
|
-
const created = rows[0];
|
|
1688
|
-
const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
|
|
1689
|
-
// Optional rename: PUT /tm/testcase/:id with { name, projectId }.
|
|
1690
|
-
if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
|
|
1691
|
-
await this.http.putJson(`/tm/testcase/${newIdNum}`, {
|
|
1692
|
-
name: newTitle.trim(),
|
|
1693
|
-
projectId: sourceProjId,
|
|
1694
|
-
});
|
|
1695
|
-
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
|
+
}
|
|
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);
|
|
1696
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.');
|
|
1697
1860
|
return {
|
|
1698
|
-
id:
|
|
1861
|
+
id: newId,
|
|
1699
1862
|
created: true,
|
|
1700
|
-
productUrl: this.productUrlFor({ kind: 'testcase', id:
|
|
1863
|
+
productUrl: this.productUrlFor({ kind: 'testcase', id: newId }),
|
|
1701
1864
|
};
|
|
1702
1865
|
}
|
|
1703
1866
|
// ---------------------------------------------------------------------- Functions (reusable)
|
|
@@ -1819,81 +1982,91 @@ export class SimplifyQaAdapter {
|
|
|
1819
1982
|
? `Not deleted — function is still referenced by ${refs.join('; ')}. Remove those references first.`
|
|
1820
1983
|
: 'Not deleted — the backend reported blocking dependencies.');
|
|
1821
1984
|
}
|
|
1822
|
-
/**
|
|
1823
|
-
*
|
|
1824
|
-
*
|
|
1825
|
-
|
|
1826
|
-
* provided a follow-up PUT `/tm/function/:id` renames the freshly-created FN. */
|
|
1827
|
-
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) {
|
|
1828
1989
|
assertBusinessKeyKind(id, 'FN');
|
|
1829
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.');
|
|
1830
1994
|
const fnNum = numericId(id);
|
|
1831
1995
|
const rawFn = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
|
|
1832
|
-
let
|
|
1833
|
-
if (Array.isArray(
|
|
1834
|
-
|
|
1835
|
-
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) {
|
|
1836
2000
|
throw new Error(`function ${id} not found`);
|
|
1837
2001
|
}
|
|
1838
2002
|
const sourceId = Number.parseInt(fnNum, 10);
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
.
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
});
|
|
1866
|
-
// Find the newly-created copy: highest-id FN whose `projectLevelcopiedFrom.id` matches source.
|
|
1867
|
-
const searchRaw = await this.http.postJson('/tm/function/search', {
|
|
1868
|
-
searchFields: [
|
|
1869
|
-
{ column: 'deleted', value: false },
|
|
1870
|
-
{ column: 'projectId', value: sourceProjId },
|
|
1871
|
-
{ column: 'projectLevelcopiedFrom.id', value: sourceId },
|
|
1872
|
-
{ column: 'id', sort: 'dsc' },
|
|
1873
|
-
],
|
|
1874
|
-
startIndex: 0,
|
|
1875
|
-
limit: 1,
|
|
1876
|
-
selectFields: ['id', 'code', 'name'],
|
|
1877
|
-
});
|
|
1878
|
-
const rows = pickArray(searchRaw, 'data', 'content', 'functions');
|
|
1879
|
-
if (rows.length === 0) {
|
|
1880
|
-
throw new Error('Clone was requested but the newly-created function could not be located afterwards. ' +
|
|
1881
|
-
'The backend may still be processing the copy, or the caller may lack the ' +
|
|
1882
|
-
'`copy_function_across_projects` privilege on this project.');
|
|
1883
|
-
}
|
|
1884
|
-
const created = rows[0];
|
|
1885
|
-
const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
|
|
1886
|
-
// Optional rename: PUT /tm/function/:id — inline validator accepts { name } only (no projectId).
|
|
1887
|
-
if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
|
|
1888
|
-
await this.http.putJson(`/tm/function/${newIdNum}`, {
|
|
1889
|
-
name: newTitle.trim(),
|
|
1890
|
-
});
|
|
1891
|
-
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);
|
|
1892
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';
|
|
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.');
|
|
1893
2066
|
return {
|
|
1894
|
-
id:
|
|
2067
|
+
id: newId,
|
|
1895
2068
|
created: true,
|
|
1896
|
-
productUrl: this.productUrlFor({ kind: 'function', id:
|
|
2069
|
+
productUrl: this.productUrlFor({ kind: 'function', id: newId }),
|
|
1897
2070
|
};
|
|
1898
2071
|
}
|
|
1899
2072
|
/** Snapshot the CURRENT server state of a function under a NEW version name — same FN id, new
|
|
@@ -2211,11 +2384,58 @@ export class SimplifyQaAdapter {
|
|
|
2211
2384
|
}
|
|
2212
2385
|
// ---------------------------------------------------------------------- Suites (write) [Step 4]
|
|
2213
2386
|
async createSuite(s) {
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
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
|
+
};
|
|
2219
2439
|
}
|
|
2220
2440
|
/** Fetch raw suite, apply add/remove/reorder ops in memory, then PUT the whole doc back with a
|
|
2221
2441
|
* STRICT allow-listed payload (the SQA update validator rejects unknown keys on both suite-level
|
|
@@ -2407,15 +2627,23 @@ export class SimplifyQaAdapter {
|
|
|
2407
2627
|
* delete is refused rather than silently breaking those test cases; only a dependency-free object
|
|
2408
2628
|
* gets the real `isDelete:true` call.
|
|
2409
2629
|
*/
|
|
2410
|
-
|
|
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) {
|
|
2411
2636
|
const projectId = this.requireProjectId();
|
|
2412
2637
|
const numId = Number.parseInt(numericId(id), 10);
|
|
2413
2638
|
const checkRes = await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: false });
|
|
2414
2639
|
const row = checkRes?.data?.[0];
|
|
2415
2640
|
const countRefs = (m) => m ? Object.values(m).reduce((n, arr) => n + (Array.isArray(arr) ? arr.length : 0), 0) : 0;
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
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);
|
|
2419
2647
|
if (tcCount || fnCount || wfCount) {
|
|
2420
2648
|
const parts = [];
|
|
2421
2649
|
if (tcCount)
|
|
@@ -2704,11 +2932,51 @@ export class SimplifyQaAdapter {
|
|
|
2704
2932
|
}
|
|
2705
2933
|
// ---------------------------------------------------------------------- Run status [Step 6]
|
|
2706
2934
|
/** Real status of a run — reads step-level results direct from SQA REST because the desktop agent's
|
|
2707
|
-
* 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. */
|
|
2708
2938
|
async getRun(runId) {
|
|
2709
2939
|
const projectId = this.requireProjectId();
|
|
2710
2940
|
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
2711
|
-
|
|
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.
|
|
2712
2980
|
const runs = await this.http.getJson(`/exec/report/${runId}/${projectId}`);
|
|
2713
2981
|
if (!Array.isArray(runs) || runs.length === 0) {
|
|
2714
2982
|
return { runId, state: 'queued' };
|
|
@@ -2764,9 +3032,37 @@ export class SimplifyQaAdapter {
|
|
|
2764
3032
|
runId,
|
|
2765
3033
|
state: mapRunState(str(chosen, 'status') ?? ''),
|
|
2766
3034
|
stepResults,
|
|
2767
|
-
reportUrl: `${base}/reports/
|
|
3035
|
+
reportUrl: `${base}/user/reports/reports-page?type=TESTCASE&parentId=${parentId}&id=${Number.parseInt(numericId(runId), 10)}&projectId=${projectId}`,
|
|
2768
3036
|
};
|
|
2769
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,
|
|
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;
|
|
3065
|
+
}
|
|
2770
3066
|
/** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
|
|
2771
3067
|
* is paged (default 100) and API-key scoping can hide the selected project, which left
|
|
2772
3068
|
* callers with an empty projectName. */
|
|
@@ -2801,36 +3097,60 @@ export class SimplifyQaAdapter {
|
|
|
2801
3097
|
if (!Number.isFinite(tcNum))
|
|
2802
3098
|
throw new Error(`startRun: could not resolve numeric id for ${tcId}.`);
|
|
2803
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
|
+
}
|
|
2804
3109
|
const rawTc = await this.fetchRawTestcase(tcId);
|
|
2805
3110
|
if (!rawTc || Object.keys(rawTc).length === 0) {
|
|
2806
3111
|
throw new Error(`startRun: test case ${tcId} not found.`);
|
|
2807
3112
|
}
|
|
2808
|
-
// Version resolution
|
|
2809
|
-
//
|
|
2810
|
-
//
|
|
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 : [];
|
|
2811
3120
|
let versionName;
|
|
2812
3121
|
let versionId;
|
|
2813
3122
|
if (opts.versionId) {
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
const
|
|
2817
|
-
|
|
2818
|
-
|
|
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}`);
|
|
2819
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 = '');
|
|
2820
3153
|
}
|
|
2821
|
-
versionName ?? (versionName = str(rawTc, 'versionName', 'selectedVersionName'));
|
|
2822
|
-
versionId ?? (versionId = str(rawTc, 'versionId', 'selectedVersionId'));
|
|
2823
|
-
if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
|
|
2824
|
-
versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
|
|
2825
|
-
versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
|
|
2826
|
-
}
|
|
2827
|
-
if ((!versionName || !versionId) && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
|
|
2828
|
-
const v = rawTc.versions[0];
|
|
2829
|
-
versionName ?? (versionName = str(v, 'name'));
|
|
2830
|
-
versionId ?? (versionId = str(v, 'id', '_id'));
|
|
2831
|
-
}
|
|
2832
|
-
versionName ?? (versionName = 'v1.0');
|
|
2833
|
-
versionId ?? (versionId = '');
|
|
2834
3154
|
// ponytail: was `this.info()` — 5 round-trips (list_projects + 4 counts) for one field, and
|
|
2835
3155
|
// returned projectName="" when the current project wasn't in the first 100-row page or was
|
|
2836
3156
|
// filtered by API-key scope, which the /exec/execution endpoint rejects with HTTP 400.
|
|
@@ -2862,8 +3182,16 @@ export class SimplifyQaAdapter {
|
|
|
2862
3182
|
}
|
|
2863
3183
|
catch { /* leave empty */ }
|
|
2864
3184
|
}
|
|
2865
|
-
const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId)
|
|
2866
|
-
|
|
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
|
+
});
|
|
2867
3195
|
// UI uses `Math.random().toString(26).substr(5,6)` — plan doc confirms any string works.
|
|
2868
3196
|
const stepId = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
|
|
2869
3197
|
const body = {
|
|
@@ -2935,6 +3263,25 @@ export class SimplifyQaAdapter {
|
|
|
2935
3263
|
killType: 'STOP_EXECUTION',
|
|
2936
3264
|
});
|
|
2937
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
|
+
}
|
|
2938
3285
|
async listCloudRunners() {
|
|
2939
3286
|
const projectId = this.requireProjectId();
|
|
2940
3287
|
const body = this.searchBody([{ column: 'projectId', value: projectId }], { limit: 1000 });
|
|
@@ -2955,6 +3302,46 @@ export class SimplifyQaAdapter {
|
|
|
2955
3302
|
};
|
|
2956
3303
|
});
|
|
2957
3304
|
}
|
|
3305
|
+
getLocalIdentifiers() {
|
|
3306
|
+
if (this.localIdsCache)
|
|
3307
|
+
return this.localIdsCache;
|
|
3308
|
+
const hostFull = os.hostname().toLowerCase();
|
|
3309
|
+
const hostBare = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
|
|
3310
|
+
const macs = new Set();
|
|
3311
|
+
const ips = new Set();
|
|
3312
|
+
const ifaces = os.networkInterfaces();
|
|
3313
|
+
for (const list of Object.values(ifaces)) {
|
|
3314
|
+
if (!list)
|
|
3315
|
+
continue;
|
|
3316
|
+
for (const iface of list) {
|
|
3317
|
+
if (iface.internal)
|
|
3318
|
+
continue;
|
|
3319
|
+
if (iface.mac && iface.mac !== '00:00:00:00:00:00')
|
|
3320
|
+
macs.add(iface.mac.toLowerCase());
|
|
3321
|
+
if (iface.address)
|
|
3322
|
+
ips.add(iface.address);
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
this.localIdsCache = { hostBare, macs, ips };
|
|
3326
|
+
return this.localIdsCache;
|
|
3327
|
+
}
|
|
3328
|
+
/** Does a machineRegistration row belong to the machine this MCP is running on?
|
|
3329
|
+
* A single positive match on hostname, MAC, or IP is enough — the local agent registers with
|
|
3330
|
+
* ALL of these, but the surviving match on the SQA row depends on OS quirks (Windows writes a
|
|
3331
|
+
* padded hostname, macOS writes `foo.local`, IPs shift on Wi-Fi handoff). Any one match is
|
|
3332
|
+
* proof enough; we don't try to reconcile the others. */
|
|
3333
|
+
isThisMachine(row, ids = this.getLocalIdentifiers()) {
|
|
3334
|
+
const name = (str(row, 'machineName', 'name', 'host') ?? '').toLowerCase();
|
|
3335
|
+
if (ids.hostBare.length > 0 && name.length > 0 && name.includes(ids.hostBare))
|
|
3336
|
+
return true;
|
|
3337
|
+
const mac = (str(row, 'macAddress') ?? '').toLowerCase();
|
|
3338
|
+
if (mac.length > 0 && ids.macs.has(mac))
|
|
3339
|
+
return true;
|
|
3340
|
+
const ip = str(row, 'ipAddress', 'hostAddress') ?? '';
|
|
3341
|
+
if (ip.length > 0 && ids.ips.has(ip))
|
|
3342
|
+
return true;
|
|
3343
|
+
return false;
|
|
3344
|
+
}
|
|
2958
3345
|
async listLocalAgents() {
|
|
2959
3346
|
// ponytail: no projectId filter — machine_registration.projectId is never written by
|
|
2960
3347
|
// test-management/services/machine_registration.service.js (create only stamps
|
|
@@ -2963,77 +3350,252 @@ export class SimplifyQaAdapter {
|
|
|
2963
3350
|
const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
|
|
2964
3351
|
const raw = await this.http.postJson('/tm/machineRegistration/search', body);
|
|
2965
3352
|
const rows = pickArray(raw, 'data', 'content', 'agents', 'machines');
|
|
2966
|
-
// ponytail:
|
|
2967
|
-
//
|
|
2968
|
-
//
|
|
2969
|
-
//
|
|
2970
|
-
// is
|
|
2971
|
-
//
|
|
2972
|
-
|
|
2973
|
-
// Redis-presence endpoint (ZSCORE SQAAGENTTRACKER), consult that here instead.
|
|
2974
|
-
const hostFull = os.hostname().toLowerCase();
|
|
2975
|
-
// Bare hostname: strip trailing .local/.lan/.localdomain AND take the first label. Handles
|
|
2976
|
-
// both directions of "short vs FQDN" mismatch — the agent may register as `Pratiks-MBP`
|
|
2977
|
-
// while os.hostname() returns `pratiks-mbp.local`, or vice-versa.
|
|
2978
|
-
const hostBare = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
|
|
3353
|
+
// ponytail: mirror sqa-ui/machine-registration.component.ts:169. The DB `active` flag is only
|
|
3354
|
+
// written to `true` on FIRST registration (fire-and-forget in executions/services/
|
|
3355
|
+
// executionQueue.service.js:49) and back to `false` by a 7s-stale cron. Every subsequent poll
|
|
3356
|
+
// is a no-op for the DB — a still-polling agent frequently shows `active:false`. Executions
|
|
3357
|
+
// service is off-limits for the fix, so when the row belongs to THIS machine (any of hostname
|
|
3358
|
+
// / MAC / IP matches), trust that over the stale DB flag.
|
|
3359
|
+
const ids = this.getLocalIdentifiers();
|
|
2979
3360
|
return rows.map((n) => {
|
|
2980
3361
|
const id = str(n, 'id', '_id', 'agentId') ?? '';
|
|
2981
3362
|
const agentName = str(n, 'machineName', 'name', 'host');
|
|
2982
3363
|
const owner = str(n, 'owner', 'userName', 'createdBy');
|
|
2983
3364
|
const hostAddr = str(n, 'macAddress', 'hostAddress');
|
|
2984
3365
|
const dbActive = typeof n?.active === 'boolean' ? n.active : false;
|
|
2985
|
-
const
|
|
2986
|
-
const isMyBox = nameLc.length > 0 && hostBare.length > 0 && nameLc.includes(hostBare);
|
|
3366
|
+
const isMyBox = this.isThisMachine(n, ids);
|
|
2987
3367
|
return {
|
|
2988
3368
|
id,
|
|
2989
3369
|
...(agentName ? { name: agentName } : {}),
|
|
2990
3370
|
online: dbActive || isMyBox,
|
|
3371
|
+
...(isMyBox ? { isThisMachine: true } : {}),
|
|
2991
3372
|
...(owner ? { owner } : {}),
|
|
2992
3373
|
...(hostAddr ? { host: hostAddr } : {}),
|
|
2993
3374
|
};
|
|
2994
3375
|
});
|
|
2995
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
|
+
}
|
|
2996
3529
|
/**
|
|
2997
|
-
* Pick THE ACTING USER'S OWN
|
|
2998
|
-
*
|
|
2999
|
-
*
|
|
3530
|
+
* Pick THE ACTING USER'S OWN agent — never row [0] of a shared-tenant roster (which would
|
|
3531
|
+
* silently dispatch a local run to a teammate's box, and the run would sit queued until TTL
|
|
3532
|
+
* because it lands in a Redis key that no local agent on the caller's machine polls).
|
|
3000
3533
|
*
|
|
3001
3534
|
* SQA's `machineRegistration` row carries no owner_user_id column (unlike walnut, which matches
|
|
3002
|
-
* the JWT `id` claim to `owner_user_id`), so we identify "your" agent by
|
|
3003
|
-
*
|
|
3004
|
-
*
|
|
3005
|
-
* `
|
|
3006
|
-
* Zero/many matches refuse honestly; a silent guess would just repeat the exact bug we're fixing.
|
|
3535
|
+
* the JWT `id` claim to `owner_user_id`), so we identify "your" agent by physical identity:
|
|
3536
|
+
* hostname, MAC address, or IP — any one match against `os.networkInterfaces()` on the box this
|
|
3537
|
+
* MCP is running on. When exactly one row is "this machine", pick it regardless of the DB
|
|
3538
|
+
* `active` flag (that flag is stale by design — see listLocalAgents for the executionQueue bug).
|
|
3007
3539
|
*
|
|
3008
|
-
* ponytail:
|
|
3009
|
-
* to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
|
|
3540
|
+
* ponytail: multi-identifier heuristic. Upgrade to owner-user-id match if the backend adds that
|
|
3541
|
+
* column to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
|
|
3010
3542
|
*/
|
|
3011
3543
|
async resolveLocalAgentId(preferred) {
|
|
3012
|
-
if (preferred && preferred.length > 0)
|
|
3013
|
-
return preferred;
|
|
3014
3544
|
const agents = await this.listLocalAgents();
|
|
3015
3545
|
if (agents.length === 0) {
|
|
3016
3546
|
throw new Error('startRun (LOCAL): no local agent registered. Register a machine in the SimplifyQA UI ' +
|
|
3017
3547
|
'first, or pass mode: "cloud" if a cloud runner is provisioned.');
|
|
3018
3548
|
}
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3549
|
+
// Priority 1: rows that physically match this machine. Ignore the DB `active` flag — a running
|
|
3550
|
+
// agent frequently shows `active:false` between the 5s stale-cron and the next heartbeat, and
|
|
3551
|
+
// the executions service is off-limits for the real fix.
|
|
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}`);
|
|
3023
3564
|
}
|
|
3024
|
-
const hostFull = os.hostname().toLowerCase();
|
|
3025
|
-
// Same short-vs-FQDN handling as listLocalAgents — see the ponytail note there.
|
|
3026
|
-
const host = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
|
|
3027
|
-
const mine = active.filter((a) => host.length > 0 && (a.name ?? '').toLowerCase().includes(host));
|
|
3028
3565
|
if (mine.length === 1)
|
|
3029
3566
|
return mine[0].id;
|
|
3030
|
-
const listing = (arr) => arr.map((a) => ` - ${a.name ?? a.id} \`${a.id}\``).join('\n');
|
|
3031
3567
|
if (mine.length > 1) {
|
|
3032
|
-
|
|
3033
|
-
|
|
3568
|
+
const listing = mine.map((a) => ` - ${a.name ?? a.id} \`${a.id}\``).join('\n');
|
|
3569
|
+
throw new Error(`startRun (LOCAL): multiple agents registered from this machine. Pass \`agentId\` to ` +
|
|
3570
|
+
`pick one:\n${listing}`);
|
|
3571
|
+
}
|
|
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');
|
|
3574
|
+
throw new Error(`startRun (LOCAL): no agent registered from this machine (hostname / MAC / IP did not match ` +
|
|
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.`);
|
|
3034
3597
|
}
|
|
3035
|
-
|
|
3036
|
-
`this hostname (${host}). Pass \`agentId\` explicitly. Active agents:\n${listing(active)}`);
|
|
3598
|
+
return preferred;
|
|
3037
3599
|
}
|
|
3038
3600
|
detectExecutionOS() {
|
|
3039
3601
|
if (process.platform === 'win32')
|
|
@@ -3042,30 +3604,36 @@ export class SimplifyQaAdapter {
|
|
|
3042
3604
|
return 'MAC';
|
|
3043
3605
|
return 'LINUX';
|
|
3044
3606
|
}
|
|
3045
|
-
buildRunPlatform(tcType, mode,
|
|
3607
|
+
buildRunPlatform(tcType, mode, opts) {
|
|
3046
3608
|
const t = tcType.toUpperCase();
|
|
3047
3609
|
if (t === 'WEB') {
|
|
3048
3610
|
if (mode === 'CLOUD') {
|
|
3049
|
-
const key = `${(browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
|
|
3611
|
+
const key = `${(opts.browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
|
|
3050
3612
|
return [{ name: key, id: key, technologyType: 'WEB', type: 'NONE' }];
|
|
3051
3613
|
}
|
|
3052
|
-
const b = (browser ?? 'CHROME').toUpperCase();
|
|
3614
|
+
const b = (opts.browser ?? 'CHROME').toUpperCase();
|
|
3053
3615
|
return [{ name: b, id: b, technologyType: 'WEB', type: 'NONE' }];
|
|
3054
3616
|
}
|
|
3055
3617
|
if (t === 'ANDROID' || t === 'IOS') {
|
|
3056
3618
|
// Executor's AndroidSessionFactory.buildLocal calls requireDeviceId — no default. Fail
|
|
3057
3619
|
// loud so the caller sees the fix (list devices, pick one) instead of a queued/error hang.
|
|
3058
|
-
if (!deviceId) {
|
|
3059
|
-
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 ` +
|
|
3060
3622
|
`devices, then re-run with \`deviceId\` set to the adb serial (LOCAL) or marketplace ` +
|
|
3061
3623
|
`device name (CLOUD).`);
|
|
3062
3624
|
}
|
|
3063
|
-
//
|
|
3064
|
-
//
|
|
3065
|
-
|
|
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.
|
|
3066
3633
|
return [{
|
|
3067
|
-
name: deviceId, id: deviceId, technologyType: t,
|
|
3068
|
-
|
|
3634
|
+
name: opts.deviceId, id: opts.deviceId, technologyType: t, type,
|
|
3635
|
+
...(opts.osVersion ? { osVersion: opts.osVersion } : {}),
|
|
3636
|
+
...(isCloud && opts.cloudDeviceType ? { cloudDeviceType: opts.cloudDeviceType } : {}),
|
|
3069
3637
|
}];
|
|
3070
3638
|
}
|
|
3071
3639
|
return [{ name: t, id: t, technologyType: t, type: 'NONE' }];
|