common-qa-mcp 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/adapters/factory.d.ts +17 -7
- package/dist/adapters/factory.d.ts.map +1 -1
- package/dist/adapters/factory.js +44 -7
- package/dist/adapters/factory.js.map +1 -1
- package/dist/adapters/http.d.ts +50 -0
- package/dist/adapters/http.d.ts.map +1 -0
- package/dist/adapters/http.js +108 -0
- package/dist/adapters/http.js.map +1 -0
- package/dist/adapters/remoteMcp/adapter.d.ts +16 -2
- package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
- package/dist/adapters/remoteMcp/adapter.js +34 -5
- package/dist/adapters/remoteMcp/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/adapter.d.ts +437 -0
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/adapter.js +3097 -0
- package/dist/adapters/simplifyqa/adapter.js.map +1 -0
- package/dist/adapters/simplifyqa/mapper.d.ts +95 -0
- package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/mapper.js +858 -0
- package/dist/adapters/simplifyqa/mapper.js.map +1 -0
- package/dist/adapters/types.d.ts +168 -5
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/adapters/walnut/adapter.d.ts +235 -0
- package/dist/adapters/walnut/adapter.d.ts.map +1 -0
- package/dist/adapters/walnut/adapter.js +1114 -0
- package/dist/adapters/walnut/adapter.js.map +1 -0
- package/dist/adapters/walnut/mapper.d.ts +30 -9
- package/dist/adapters/walnut/mapper.d.ts.map +1 -1
- package/dist/adapters/walnut/mapper.js +149 -24
- package/dist/adapters/walnut/mapper.js.map +1 -1
- package/dist/config.d.ts +38 -14
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +73 -19
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +3 -2
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +44 -15
- package/dist/connection.js.map +1 -1
- package/dist/domain/types.d.ts +680 -5
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/drafts/store.d.ts +4 -1
- package/dist/drafts/store.d.ts.map +1 -1
- package/dist/drafts/store.js +7 -2
- package/dist/drafts/store.js.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +48 -18
- package/dist/initAgents.js.map +1 -1
- package/dist/requestConnection.d.ts +5 -3
- package/dist/requestConnection.d.ts.map +1 -1
- package/dist/requestConnection.js +11 -5
- package/dist/requestConnection.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +47 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/backlog.tools.d.ts +13 -0
- package/dist/tools/backlog.tools.d.ts.map +1 -0
- package/dist/tools/backlog.tools.js +399 -0
- package/dist/tools/backlog.tools.js.map +1 -0
- package/dist/tools/connection.tools.js +5 -5
- package/dist/tools/connection.tools.js.map +1 -1
- package/dist/tools/defects.tools.d.ts +7 -0
- package/dist/tools/defects.tools.d.ts.map +1 -0
- package/dist/tools/defects.tools.js +82 -0
- package/dist/tools/defects.tools.js.map +1 -0
- package/dist/tools/discovery.tools.d.ts.map +1 -1
- package/dist/tools/discovery.tools.js +951 -43
- package/dist/tools/discovery.tools.js.map +1 -1
- package/dist/tools/formulas.tools.d.ts +7 -0
- package/dist/tools/formulas.tools.d.ts.map +1 -0
- package/dist/tools/formulas.tools.js +56 -0
- package/dist/tools/formulas.tools.js.map +1 -0
- package/dist/tools/heal.tools.d.ts +17 -0
- package/dist/tools/heal.tools.d.ts.map +1 -0
- package/dist/tools/heal.tools.js +76 -0
- package/dist/tools/heal.tools.js.map +1 -0
- package/dist/tools/markdown.d.ts +3 -3
- package/dist/tools/markdown.d.ts.map +1 -1
- package/dist/tools/markdown.js +89 -18
- package/dist/tools/markdown.js.map +1 -1
- package/dist/tools/objects.tools.d.ts +7 -0
- package/dist/tools/objects.tools.d.ts.map +1 -0
- package/dist/tools/objects.tools.js +201 -0
- package/dist/tools/objects.tools.js.map +1 -0
- package/dist/tools/recording.tools.d.ts.map +1 -1
- package/dist/tools/recording.tools.js +92 -12
- 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 +464 -39
- package/dist/tools/run.tools.js.map +1 -1
- package/dist/tools/schemas.d.ts +21 -4
- package/dist/tools/schemas.d.ts.map +1 -1
- package/dist/tools/schemas.js +14 -1
- package/dist/tools/schemas.js.map +1 -1
- package/dist/tools/storySync.tools.d.ts +66 -0
- package/dist/tools/storySync.tools.d.ts.map +1 -0
- package/dist/tools/storySync.tools.js +213 -0
- package/dist/tools/storySync.tools.js.map +1 -0
- package/dist/tools/tasks.tools.d.ts +7 -0
- package/dist/tools/tasks.tools.d.ts.map +1 -0
- package/dist/tools/tasks.tools.js +75 -0
- package/dist/tools/tasks.tools.js.map +1 -0
- package/dist/tools/testcase.tools.d.ts.map +1 -1
- package/dist/tools/testcase.tools.js +162 -101
- package/dist/tools/testcase.tools.js.map +1 -1
- package/dist/tools/testdata.tools.d.ts +7 -0
- package/dist/tools/testdata.tools.d.ts.map +1 -0
- package/dist/tools/testdata.tools.js +249 -0
- package/dist/tools/testdata.tools.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,3097 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { makeClient } from '../http.js';
|
|
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
|
+
};
|
|
11
|
+
/** SQA run-row status → neutral RunStatus.state. */
|
|
12
|
+
function mapRunState(s) {
|
|
13
|
+
switch (s.toUpperCase()) {
|
|
14
|
+
case 'PASSED': return 'passed';
|
|
15
|
+
case 'FAILED': return 'failed';
|
|
16
|
+
case 'STOPPED': return 'stopped';
|
|
17
|
+
case 'RUNNING': return 'running';
|
|
18
|
+
case 'QUEUED': return 'queued';
|
|
19
|
+
default: return 'queued';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Formula names round-trip with parens on ("FIRST_NAME()") from the backend and sometimes with a full
|
|
23
|
+
* signature ("FIRST_NAME(country,sex)") from a caller. Reduce both to the bare identifier. */
|
|
24
|
+
function bareFormulaName(s) {
|
|
25
|
+
return s.replace(/\s*\(.*\)\s*$/, '').trim();
|
|
26
|
+
}
|
|
27
|
+
/** Query keys /tm/customFormula expects per formula, positionally — they are NOT the formula's declared
|
|
28
|
+
* keyNames. Mirrors the SQA UI (wfTestdataView.ts). Anything absent takes at most one arg as `value`.
|
|
29
|
+
* ponytail: table, not per-formula handlers — extend a row when a new multi-arg formula shows up. */
|
|
30
|
+
const FORMULA_ARG_KEYS = {
|
|
31
|
+
FIRST_NAME: ['country', 'sex', 'min_len', 'max_len'],
|
|
32
|
+
LAST_NAME: ['country', 'sex', 'min_len', 'max_len'],
|
|
33
|
+
FULL_NAME: ['country', 'sex', 'min_len', 'max_len'],
|
|
34
|
+
MIDDLE_NAME: ['country', 'sex', 'min_len', 'max_len'],
|
|
35
|
+
PREFIX: ['sex'],
|
|
36
|
+
DATE_OF_BIRTH: ['pattern', 'min_age', 'max_age'],
|
|
37
|
+
};
|
|
38
|
+
/** Normalise a formula document from `/tm/syncFormulas/fetch` into the neutral Formula shape. Returns
|
|
39
|
+
* null when the doc is missing a name — a nameless formula cannot be called. `scope` is inferred:
|
|
40
|
+
* DEFAULT → 'default'; CUSTOM with a matching projectId → 'project'; CUSTOM otherwise → 'customer'. */
|
|
41
|
+
function normalizeFormula(doc, projectId) {
|
|
42
|
+
if (!doc || typeof doc !== 'object')
|
|
43
|
+
return null;
|
|
44
|
+
const name = str(doc, 'name');
|
|
45
|
+
if (!name)
|
|
46
|
+
return null;
|
|
47
|
+
const type = str(doc, 'type') === 'CUSTOM' ? 'CUSTOM' : 'DEFAULT';
|
|
48
|
+
const docPid = Number(doc.projectId ?? 0);
|
|
49
|
+
const scope = type === 'DEFAULT' ? 'default' : docPid && docPid === projectId ? 'project' : 'customer';
|
|
50
|
+
const desc = (doc.description && typeof doc.description === 'object') ? doc.description : {};
|
|
51
|
+
const params = Array.isArray(desc.parameters)
|
|
52
|
+
? desc.parameters
|
|
53
|
+
.map((p) => (p && typeof p === 'object' && typeof p.keyName === 'string')
|
|
54
|
+
? { keyName: p.keyName, description: str(p, 'value', 'description') ?? undefined, dataType: str(p, 'dataType') ?? undefined }
|
|
55
|
+
: null)
|
|
56
|
+
.filter((p) => p !== null)
|
|
57
|
+
: [];
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
uniqueId: str(doc, 'uniqueId') ?? name,
|
|
61
|
+
type,
|
|
62
|
+
scope,
|
|
63
|
+
signature: str(desc, 'name') ?? `${name}()`,
|
|
64
|
+
details: str(desc, 'details') ?? undefined,
|
|
65
|
+
example: str(desc, 'example') ?? undefined,
|
|
66
|
+
parameters: params,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** SQA per-step status → neutral step status. */
|
|
70
|
+
function mapStepStatus(s) {
|
|
71
|
+
switch (s.toUpperCase()) {
|
|
72
|
+
case 'PASSED': return 'pass';
|
|
73
|
+
case 'FAILED': return 'fail';
|
|
74
|
+
case 'SKIPPED': return 'skip';
|
|
75
|
+
case 'STOPPED': return 'stop';
|
|
76
|
+
default: return s.toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Allowed keys for save-as-new-version. The update joi validator
|
|
80
|
+
* (testcaseValidator.js updateTcBodyValidation, ~line 187) is a STRICT allow-list — a raw doc's
|
|
81
|
+
* extras (`code`, `createdBy`, `updatedBy`, `stepCount`, `versions`, ...) crash the save with
|
|
82
|
+
* "is not allowed". Kept as string arrays so a schema drift is a one-line fix. */
|
|
83
|
+
const TCFNOBJ_ALLOWED_KEYS = [
|
|
84
|
+
'id', 'name', 'projectId', 'deleted', 'moduleId', 'userstoryId', 'obsolete', 'tcType', 'mbType',
|
|
85
|
+
'executionType', 'description', 'prerequisite', 'updateVersion', 'linkedIssues', 'attachments',
|
|
86
|
+
'tags', 'suiteId', 'defectId', 'customFields', 'reviewApproveStatus', 'copiedFromTcId',
|
|
87
|
+
'projectLevelCopy', 'originalExecutinType', 'projectLevelcopiedFrom', 'mailersList',
|
|
88
|
+
'configuration', 'executionPlatform',
|
|
89
|
+
];
|
|
90
|
+
// FN update validator (functionValidator.js `updateFnBodyValidation.tcfnObj`) diverges from the TC
|
|
91
|
+
// one in three ways that MATTER for SAVENEWVERSION: `fnType` REQUIRED (not `tcType`);
|
|
92
|
+
// `obsolete`/`suiteId`/`defectId`/`configuration`/`executionPlatform`/`tcType`/`mbType` NOT
|
|
93
|
+
// allowed; `copiedFromFnId` in place of `copiedFromTcId`. Sending TC's list to `/tm/function`
|
|
94
|
+
// trips joi with "not allowed" on obsolete and "required" on fnType.
|
|
95
|
+
const TCFNOBJ_FN_ALLOWED_KEYS = [
|
|
96
|
+
'id', 'name', 'projectId', 'deleted', 'moduleId', 'userstoryId', 'fnType',
|
|
97
|
+
'executionType', 'description', 'prerequisite', 'updateVersion', 'linkedIssues', 'attachments',
|
|
98
|
+
'tags', 'customFields', 'reviewApproveStatus', 'copiedFromFnId', 'projectLevelCopy',
|
|
99
|
+
'projectLevelcopiedFrom', 'mailersList', 'originalExecutinType', 'isMigrated', 'oldCode',
|
|
100
|
+
];
|
|
101
|
+
const STEP_ALLOWED_KEYS = [
|
|
102
|
+
'projectId', 'sharedFnProjectId', 'moduleId', 'apiData', 'lastActiveTab', 'userstoryId',
|
|
103
|
+
'updateVersion', 'type', 'parentId', 'objectId', 'stepType', 'functionId', 'actionId',
|
|
104
|
+
'parameterIds', 'objectParameterIds', 'parameterizedParamIds', 'attachments', 'compareFiles',
|
|
105
|
+
'compareFileType', 'deviceId', 'deleted', 'ignoreObject', 'browserType', 'technologyType', 'seq',
|
|
106
|
+
'captureScreenshot', 'screenshotEnabled', 'fnVersionId', 'description', 'expectedResult',
|
|
107
|
+
'conditions', 'stepLevelActions', 'skipParamInReport', 'paramLoopduration', 'showGroups',
|
|
108
|
+
'isConditional', 'if', 'else', 'loop', 'isLoop', 'loopType', 'startFor', 'endFor',
|
|
109
|
+
'loopDuration', 'executionType', 'recordedParam', 'switchToAndroidData', 'switchToIOSData',
|
|
110
|
+
'isDebug', 'manualStepsTopreview', 'sharedFnProjectName', 'isMigrated', 'customFields',
|
|
111
|
+
// ponytail: id / versionId are in the schema but always stripped for SAVENEWVERSION — the
|
|
112
|
+
// backend re-assigns them to the fresh version row, and keeping the old id would resurrect it.
|
|
113
|
+
];
|
|
114
|
+
function pick(obj, keys) {
|
|
115
|
+
const out = {};
|
|
116
|
+
if (!obj || typeof obj !== 'object')
|
|
117
|
+
return out;
|
|
118
|
+
for (const k of keys)
|
|
119
|
+
if (obj[k] !== undefined)
|
|
120
|
+
out[k] = obj[k];
|
|
121
|
+
return out;
|
|
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
|
+
/** Guard against feeding an FN business key into a TC-only endpoint (or vice-versa). `numericId`
|
|
140
|
+
* strips ANY alpha prefix — so "FN-22" → "22" and would silently target TC-22 if the caller
|
|
141
|
+
* routed to the wrong tool. Reject any business key whose prefix doesn't match the expected
|
|
142
|
+
* entity kind. Numeric ids and unrecognized-prefix strings pass through (leave that to backend). */
|
|
143
|
+
function assertBusinessKeyKind(id, expected) {
|
|
144
|
+
const m = id?.match(/^([A-Za-z]+)[_-]\d+$/);
|
|
145
|
+
if (!m)
|
|
146
|
+
return;
|
|
147
|
+
const prefix = m[1].toUpperCase();
|
|
148
|
+
if ((expected === 'TC' && prefix === 'FN') || (expected === 'FN' && prefix === 'TC')) {
|
|
149
|
+
const otherTool = expected === 'TC'
|
|
150
|
+
? 'this is a function key — use the fn_* tool (fn_clone, fn_save_as_new_version, get_function, delete_function)'
|
|
151
|
+
: 'this is a test-case key — use the tc_* tool (tc_clone, tc_save_as_new_version, tc_get, tc_delete)';
|
|
152
|
+
throw new Error(`Refusing to run against ${id}: ${otherTool}.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Coerce a raw {[projectId]: [ids]} map from a dep row into a plain Record<string, number[]>.
|
|
156
|
+
* Absent/malformed inputs collapse to `{}`. */
|
|
157
|
+
function toDepMap(v) {
|
|
158
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
159
|
+
return {};
|
|
160
|
+
const out = {};
|
|
161
|
+
for (const [k, arr] of Object.entries(v)) {
|
|
162
|
+
if (Array.isArray(arr))
|
|
163
|
+
out[k] = arr.filter((n) => Number.isFinite(n));
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
/** Normalise a raw dep row from PUT /tm/tcfnversion/delete into the neutral shape. Key set is
|
|
168
|
+
* narrower for FN — the service emits `{tcArr, fnArr, wfArr}` there vs the TC keyset. */
|
|
169
|
+
function normalizeVersionDep(row, parentType) {
|
|
170
|
+
const id = Number(row?.id ?? 0);
|
|
171
|
+
if (parentType === 'FN') {
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
tcArr: toDepMap(row?.tcArr),
|
|
175
|
+
fnArr: toDepMap(row?.fnArr),
|
|
176
|
+
wfArr: toDepMap(row?.wfArr),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const name = typeof row?.versionName === 'string' ? row.versionName : undefined;
|
|
180
|
+
return {
|
|
181
|
+
id,
|
|
182
|
+
...(name ? { versionName: name } : {}),
|
|
183
|
+
defectArr: toDepMap(row?.defectArr),
|
|
184
|
+
suiteArr: toDepMap(row?.suiteArr),
|
|
185
|
+
epArr: toDepMap(row?.epArr),
|
|
186
|
+
scenarioArr: toDepMap(row?.scenarioArr),
|
|
187
|
+
schedulerArr: toDepMap(row?.schedulerArr),
|
|
188
|
+
pipelineArr: toDepMap(row?.pipelineArr),
|
|
189
|
+
workflowArr: toDepMap(row?.workflowArr),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** True when every `*Arr` on the row is an empty map — i.e. no references block the delete.
|
|
193
|
+
* Mirrors the UI's own check (testcase.component.ts:2142 / function.component.ts:1617). */
|
|
194
|
+
function versionDepIsEmpty(dep) {
|
|
195
|
+
const maps = 'tcArr' in dep && 'wfArr' in dep && !('defectArr' in dep)
|
|
196
|
+
? [dep.tcArr, dep.fnArr, dep.wfArr]
|
|
197
|
+
: [
|
|
198
|
+
dep.defectArr, dep.suiteArr,
|
|
199
|
+
dep.epArr, dep.scenarioArr,
|
|
200
|
+
dep.schedulerArr, dep.pipelineArr,
|
|
201
|
+
dep.workflowArr,
|
|
202
|
+
];
|
|
203
|
+
return maps.every((m) => Object.keys(m).length === 0);
|
|
204
|
+
}
|
|
205
|
+
const NEEDS_PROJECT = 'No project selected. Call list_projects to see the available projects, then ' +
|
|
206
|
+
'qa_switch_project({ projectId }) to choose one — test cases, runs and objects ' +
|
|
207
|
+
'are all scoped to a project.';
|
|
208
|
+
const HOP_NEEDED_WRITE = 'This SimplifyQA operation is not yet ported to direct REST — set QA_MCP_URL to the SimplifyQA ' +
|
|
209
|
+
'agent /mcp so common-mcp can hop for it, or open the SimplifyQA UI to perform it there.';
|
|
210
|
+
/**
|
|
211
|
+
* SimplifyQaAdapter — DIRECT-to-backend REST driver for SimplifyQA, mirroring WalnutAdapter's
|
|
212
|
+
* design. Reads (list/get for projects/testcases/suites/stories/modules/releases/sprints/objects)
|
|
213
|
+
* hit the SimplifyQA backend REST directly via a `QaHttp` bound to `cfg.baseUrl`. Anything not yet
|
|
214
|
+
* ported (writes, testdata, run dispatch, marketplace, and the recording surface) delegates to
|
|
215
|
+
* the LOCAL AGENT's `/mcp` (`cfg.url`) via a lazily-built RemoteMcpAdapter — same "graceful hop"
|
|
216
|
+
* pattern walnut uses for recording. When the hop is not configured, unported ops surface a clear
|
|
217
|
+
* error instead of failing silently.
|
|
218
|
+
*
|
|
219
|
+
* This class deliberately does not `implements ProviderAdapter` (walnut trick): the factory casts
|
|
220
|
+
* it, and tools that call ported methods get direct REST; tools that call unported methods get
|
|
221
|
+
* the hop (or a clear error). Each porting step replaces one more delegation with a direct call.
|
|
222
|
+
*/
|
|
223
|
+
export class SimplifyQaAdapter {
|
|
224
|
+
constructor(cfg, transport) {
|
|
225
|
+
this.cfg = cfg;
|
|
226
|
+
this.agentHop = null;
|
|
227
|
+
// Formula list rarely changes during a session; cache the fetch response so the model can call
|
|
228
|
+
// tc_list_formulas without paying a round trip each time. Instance-scoped: switchProject rebuilds
|
|
229
|
+
// the adapter (see connection.ts), which drops this cache with it — no manual invalidation needed.
|
|
230
|
+
// We store the in-flight PROMISE, not the resolved array: several concurrent evaluateFormula calls
|
|
231
|
+
// (from the Promise.all in saveGeneratedIterations) all await the same fetch instead of each firing
|
|
232
|
+
// their own /tm/syncFormulas/fetch before the cache is populated.
|
|
233
|
+
this.formulaCache = null;
|
|
234
|
+
// ---------------------------------------------------------------------- Automation resolvers
|
|
235
|
+
/** POST /tm/action/search → per (technology, name) → row (id + objectRequired + objectTemplate).
|
|
236
|
+
* Cached per adapter instance: the catalog is customer-wide and rarely changes during a session.
|
|
237
|
+
* Keyed by `${technologyType}::${lowerCaseName}` so the same action name across two techs
|
|
238
|
+
* (e.g. "click" for WEB + ANDROID) resolves to the right numeric id. */
|
|
239
|
+
this.actionCatalogCache = null;
|
|
240
|
+
// ---------------------------------------------------------------------- Objects (read + delete)
|
|
241
|
+
// list/create/delete now go direct REST (same /tm/object base getObject already uses), mirroring
|
|
242
|
+
// the testcase/suite search + soft-delete convention.
|
|
243
|
+
/** name (UPPERCASE) → objecttemplateId. Rarely changes; cached per adapter instance like
|
|
244
|
+
* formulaCache. Populated from `POST /tm/objectTemplate/search` (confirmed endpoint,
|
|
245
|
+
* api-endpoints.ts `object.search`'s sibling `objectTemplate.search`). */
|
|
246
|
+
this.objectTemplateCache = null;
|
|
247
|
+
if (!cfg.baseUrl) {
|
|
248
|
+
throw new Error('SimplifyQaAdapter requires a baseUrl — set QA_BASE_URL or use a JWT with a `url` claim.');
|
|
249
|
+
}
|
|
250
|
+
this.http = makeClient({
|
|
251
|
+
baseUrl: cfg.baseUrl,
|
|
252
|
+
headers: () => this.headers(),
|
|
253
|
+
...transport,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// Auth headers mirror the Java RequestContext: bearer + x-project-id. `x-project-id` is only
|
|
257
|
+
// spread in when a project is selected — a session can be connected before one is chosen.
|
|
258
|
+
headers() {
|
|
259
|
+
return {
|
|
260
|
+
Authorization: `Bearer ${this.cfg.token}`,
|
|
261
|
+
...(this.cfg.projectId ? { 'x-project-id': this.cfg.projectId } : {}),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
requireProjectId() {
|
|
265
|
+
if (!this.cfg.projectId)
|
|
266
|
+
throw new Error(NEEDS_PROJECT);
|
|
267
|
+
const n = Number.parseInt(String(this.cfg.projectId).trim(), 10);
|
|
268
|
+
if (!Number.isFinite(n))
|
|
269
|
+
throw new Error(`Invalid projectId ${this.cfg.projectId} — expected a numeric id.`);
|
|
270
|
+
return n;
|
|
271
|
+
}
|
|
272
|
+
// Standard SQA `searchFields` payload builder — the SAME shape every /search endpoint expects.
|
|
273
|
+
// Every list op below uses this instead of hand-writing the same 6-line boilerplate.
|
|
274
|
+
searchBody(fields, opts = {}) {
|
|
275
|
+
const body = {
|
|
276
|
+
searchFields: fields.map((f) => ({
|
|
277
|
+
column: f.column,
|
|
278
|
+
value: f.value,
|
|
279
|
+
...(f.regEx ? { regEx: true } : {}),
|
|
280
|
+
})),
|
|
281
|
+
startIndex: opts.startIndex ?? 0,
|
|
282
|
+
limit: opts.limit ?? 100,
|
|
283
|
+
};
|
|
284
|
+
if (opts.selectFields)
|
|
285
|
+
body['selectFields'] = opts.selectFields;
|
|
286
|
+
return body;
|
|
287
|
+
}
|
|
288
|
+
// ---------------------------------------------------------------------- Info + projects
|
|
289
|
+
/** Validates baseUrl + token + (optional) project by running the same list_projects search the
|
|
290
|
+
* Java `infoResult` uses to resolve the project name — no separate /info endpoint on SQA.
|
|
291
|
+
* `counts` is a best-effort row count per entity type (test cases/suites/stories/objects), read
|
|
292
|
+
* from each `/search` endpoint's own `totalCount` (see searchTotalCount) — mirrors what Walnut's
|
|
293
|
+
* info() already returns via its own `counts`. */
|
|
294
|
+
async info() {
|
|
295
|
+
const projects = await this.listProjects();
|
|
296
|
+
const projectId = this.cfg.projectId ?? '';
|
|
297
|
+
const found = projects.find((p) => p.id === projectId);
|
|
298
|
+
const [testCases, suites, stories, objects] = await Promise.all([
|
|
299
|
+
this.searchTotalCount('/tm/testcase/search'),
|
|
300
|
+
this.searchTotalCount('/tm/suites/search'),
|
|
301
|
+
this.searchTotalCount('/pm/userstory/search'),
|
|
302
|
+
this.searchTotalCount('/tm/object/search'),
|
|
303
|
+
]);
|
|
304
|
+
return {
|
|
305
|
+
provider: 'simplifyqa',
|
|
306
|
+
projectId,
|
|
307
|
+
projectName: found?.name ?? '',
|
|
308
|
+
counts: { testCases, suites, stories, objects },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Projects the caller can access. Three code paths in priority order:
|
|
312
|
+
*
|
|
313
|
+
* 1. Config-supplied `QA_MCP_USER_ID` (needed for API-key auth — SQA keys are encrypted JWTs
|
|
314
|
+
* with no client-decryptable userId, and no whoami endpoint exists yet) → user-scoped
|
|
315
|
+
* two-step via `/pm/projectuserrole`.
|
|
316
|
+
* 2. Session JWT with `userId` claim → same two-step.
|
|
317
|
+
* 3. Neither → direct `/pm/project/search`; the backend scopes by the token owner where it
|
|
318
|
+
* can (may return tenant-wide for API keys). `totalCount` uses `rows.length` (backend's
|
|
319
|
+
* top-level `totalCount` is unreliable — counts pre-filter, includes deleted).
|
|
320
|
+
*
|
|
321
|
+
* The two-step avoids both problems: correct user scoping AND an accurate count. */
|
|
322
|
+
async listProjects() {
|
|
323
|
+
const userId = this.cfg.userId?.trim() || this.jwtUserId();
|
|
324
|
+
return userId ? this.listProjectsForUser(userId) : this.listProjectsDirect();
|
|
325
|
+
}
|
|
326
|
+
async listProjectsForUser(userId) {
|
|
327
|
+
const rolesRaw = await this.http.getJson(`/pm/projectuserrole?userId=${encodeURIComponent(userId)}`);
|
|
328
|
+
const roleRows = pickArray(rolesRaw, 'data', 'content');
|
|
329
|
+
const projectIds = Array.from(new Set(roleRows.map((r) => str(r, 'projectId')).filter((v) => !!v))).map((s) => Number.parseInt(s, 10)).filter((n) => Number.isFinite(n));
|
|
330
|
+
if (projectIds.length === 0)
|
|
331
|
+
return withTotal([], { totalCount: 0 });
|
|
332
|
+
const body = this.searchBody([
|
|
333
|
+
{ column: 'id', value: projectIds },
|
|
334
|
+
{ column: 'deleted', value: false },
|
|
335
|
+
], { selectFields: ['id', 'code', 'name'], limit: projectIds.length });
|
|
336
|
+
const raw = await this.http.postJson('/pm/project/search', body);
|
|
337
|
+
const rows = pickArray(raw, 'data', 'content', 'projects');
|
|
338
|
+
const projects = rows.map((n) => {
|
|
339
|
+
const code = str(n, 'code');
|
|
340
|
+
return {
|
|
341
|
+
id: str(n, 'id', '_id', 'projectId') ?? '',
|
|
342
|
+
...(code ? { code } : {}),
|
|
343
|
+
name: str(n, 'name', 'projectName') ?? '',
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
return withTotal(projects, { totalCount: projects.length });
|
|
347
|
+
}
|
|
348
|
+
/** Best-effort row count for an entity `/search` endpoint — posts a minimal (limit 1) project-scoped
|
|
349
|
+
* search and reads the `totalCount` the backend returns alongside `data` (the same envelope shape
|
|
350
|
+
* every /search endpoint in this codebase uses, confirmed via the real sqa-ui
|
|
351
|
+
* `{ totalCount: number, data: [...] }` response typing). Never throws — counts are a nice-to-have
|
|
352
|
+
* for project_info, not worth failing the whole call over; any failure (or a missing totalCount)
|
|
353
|
+
* falls back to 0 / the returned row count. */
|
|
354
|
+
async searchTotalCount(path) {
|
|
355
|
+
try {
|
|
356
|
+
const projectId = this.requireProjectId();
|
|
357
|
+
const body = this.searchBody([{ column: 'deleted', value: false }, { column: 'projectId', value: projectId }], { limit: 1 });
|
|
358
|
+
const raw = await this.http.postJson(path, body);
|
|
359
|
+
if (typeof raw?.totalCount === 'number')
|
|
360
|
+
return raw.totalCount;
|
|
361
|
+
return pickArray(raw, 'data', 'content').length;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async listProjectsDirect() {
|
|
368
|
+
const body = this.searchBody([{ column: 'deleted', value: false }], { selectFields: ['id', 'code', 'name'] });
|
|
369
|
+
const raw = await this.http.postJson('/pm/project/search', body);
|
|
370
|
+
const rows = pickArray(raw, 'data', 'content', 'projects');
|
|
371
|
+
const projects = rows.map((n) => {
|
|
372
|
+
const code = str(n, 'code');
|
|
373
|
+
return {
|
|
374
|
+
id: str(n, 'id', '_id', 'projectId') ?? '',
|
|
375
|
+
...(code ? { code } : {}),
|
|
376
|
+
name: str(n, 'name', 'projectName') ?? '',
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
// ponytail: backend `totalCount` on /pm/project/search is unreliable (counts pre-filter). Trust
|
|
380
|
+
// rows.length instead — accurate for the page we actually returned.
|
|
381
|
+
return withTotal(projects, { totalCount: projects.length });
|
|
382
|
+
}
|
|
383
|
+
/** Decode the `userId` claim from the SQA JWT payload WITHOUT verifying the signature. Backend
|
|
384
|
+
* re-verifies on every call; this only needs the id for `/pm/projectuserrole`. Returns undefined
|
|
385
|
+
* on any parse failure or when the token isn't a JWT. */
|
|
386
|
+
jwtUserId() {
|
|
387
|
+
const token = this.cfg.token;
|
|
388
|
+
if (!token)
|
|
389
|
+
return undefined;
|
|
390
|
+
try {
|
|
391
|
+
const t = token.startsWith('Bearer ') ? token.slice(7).trim() : token.trim();
|
|
392
|
+
const seg = t.split('.')[1];
|
|
393
|
+
if (!seg)
|
|
394
|
+
return undefined;
|
|
395
|
+
let b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
|
|
396
|
+
b64 += '='.repeat((4 - (b64.length % 4)) % 4);
|
|
397
|
+
const claims = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
|
398
|
+
const v = claims['userId'] ?? claims['id'] ?? claims['sub'];
|
|
399
|
+
if (typeof v === 'string' && v.length)
|
|
400
|
+
return v;
|
|
401
|
+
if (typeof v === 'number')
|
|
402
|
+
return String(v);
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// ---------------------------------------------------------------------- Test cases (read)
|
|
410
|
+
async listTestCases(q) {
|
|
411
|
+
const projectId = this.requireProjectId();
|
|
412
|
+
const fields = [
|
|
413
|
+
{ column: 'deleted', value: false },
|
|
414
|
+
{ column: 'projectId', value: projectId },
|
|
415
|
+
];
|
|
416
|
+
if (q.search)
|
|
417
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
418
|
+
if (q.status)
|
|
419
|
+
fields.push({ column: 'status', value: q.status });
|
|
420
|
+
const createdBy = this.resolveUserFilter(q.createdBy);
|
|
421
|
+
if (createdBy != null)
|
|
422
|
+
fields.push({ column: 'createdBy', value: createdBy });
|
|
423
|
+
const updatedBy = this.resolveUserFilter(q.updatedBy);
|
|
424
|
+
if (updatedBy != null)
|
|
425
|
+
fields.push({ column: 'updatedBy', value: updatedBy });
|
|
426
|
+
if (q.storyId)
|
|
427
|
+
fields.push({ column: 'userstoryId', value: Number.parseInt(numericId(q.storyId), 10) });
|
|
428
|
+
if (q.moduleId)
|
|
429
|
+
fields.push({ column: 'moduleId', value: Number.parseInt(numericId(q.moduleId), 10) });
|
|
430
|
+
if (typeof q.obsolete === 'boolean')
|
|
431
|
+
fields.push({ column: 'obsolete', value: q.obsolete });
|
|
432
|
+
if (q.filters?.length)
|
|
433
|
+
fields.push(...q.filters);
|
|
434
|
+
const body = this.searchBody(fields, {
|
|
435
|
+
startIndex: q.offset ?? 0,
|
|
436
|
+
limit: q.limit ?? 100,
|
|
437
|
+
selectFields: ['id', 'code', 'name', 'priority', 'status', 'stepCount', 'tcType', 'executionType', 'obsolete'],
|
|
438
|
+
});
|
|
439
|
+
const raw = await this.http.postJson('/tm/testcase/search', body);
|
|
440
|
+
const rows = pickArray(raw, 'data', 'content', 'testCases');
|
|
441
|
+
return withTotal(rows.map(toTcSummary), raw);
|
|
442
|
+
}
|
|
443
|
+
/** "me" → cfg.userId (numeric); numeric string → number; anything else → undefined. Callers
|
|
444
|
+
* should have resolved a name to a numeric id via `list_users` first. */
|
|
445
|
+
resolveUserFilter(v) {
|
|
446
|
+
if (!v)
|
|
447
|
+
return undefined;
|
|
448
|
+
const raw = v === 'me' ? (this.cfg.userId?.trim() ?? '') : v.trim();
|
|
449
|
+
if (!raw)
|
|
450
|
+
return undefined;
|
|
451
|
+
const n = Number.parseInt(raw, 10);
|
|
452
|
+
return Number.isFinite(n) ? n : undefined;
|
|
453
|
+
}
|
|
454
|
+
/** Neutral test case, steps included. Accepts numeric id or business code (`TC_123`).
|
|
455
|
+
* `versionId` overrides which version's steps to load — omit to accept the TC's currently-
|
|
456
|
+
* selected version. When the resolved version differs from the doc's current selection, we
|
|
457
|
+
* also patch `currentVersionId` on the returned tc so callers can render "showing version X". */
|
|
458
|
+
async getTestCase(id, versionId) {
|
|
459
|
+
const raw = await this.fetchRawTestcase(id);
|
|
460
|
+
if (!raw || Object.keys(raw).length === 0) {
|
|
461
|
+
throw new Error(`test case ${id} not found`);
|
|
462
|
+
}
|
|
463
|
+
const tc = toTestCase(raw);
|
|
464
|
+
// If the caller pinned a version, validate it exists on the TC — otherwise the /steps call
|
|
465
|
+
// just returns an empty array for an unknown id and the user sees "no steps" for a typo.
|
|
466
|
+
if (versionId) {
|
|
467
|
+
const known = (tc.versions ?? []).some((v) => v.id === versionId);
|
|
468
|
+
if (!known)
|
|
469
|
+
throw new Error(`version ${versionId} not found on test case ${id}`);
|
|
470
|
+
}
|
|
471
|
+
let rows = [];
|
|
472
|
+
try {
|
|
473
|
+
rows = await this.fetchStepsCollection(id, raw, versionId);
|
|
474
|
+
}
|
|
475
|
+
catch { /* fall through — inline steps stay */ }
|
|
476
|
+
const withSteps = rows.length > 0 ? applyCollectionSteps(tc, rows) : tc;
|
|
477
|
+
// Overlay currentVersionId to the resolved one and flip isCurrent flags on versions[]. Apply
|
|
478
|
+
// even when rows are empty — a caller who pinned versionId still expects the header + versions[]
|
|
479
|
+
// to reflect that choice (a legitimately-empty version shouldn't fall back to the TC's default).
|
|
480
|
+
const resolvedVer = versionId ?? tc.currentVersionId;
|
|
481
|
+
const patched = { ...withSteps, ...(resolvedVer ? { currentVersionId: resolvedVer } : {}) };
|
|
482
|
+
if (resolvedVer && patched.versions?.length) {
|
|
483
|
+
patched.versions = patched.versions.map((v) => ({ ...v, isCurrent: v.id === resolvedVer }));
|
|
484
|
+
}
|
|
485
|
+
if (rows.length > 0) {
|
|
486
|
+
await Promise.all([
|
|
487
|
+
this.hydrateFunctionNames(patched),
|
|
488
|
+
this.hydrateObjectNames(patched),
|
|
489
|
+
]);
|
|
490
|
+
}
|
|
491
|
+
return patched;
|
|
492
|
+
}
|
|
493
|
+
async fetchRawTestcase(id) {
|
|
494
|
+
const raw = await this.http.getJson(`/tm/testcase/getById/${numericId(id)}`);
|
|
495
|
+
let doc = raw?.data ?? raw;
|
|
496
|
+
if (Array.isArray(doc))
|
|
497
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
498
|
+
return doc && typeof doc === 'object' ? doc : {};
|
|
499
|
+
}
|
|
500
|
+
async fetchStepsCollection(tcId, rawTc, versionOverride) {
|
|
501
|
+
const projectId = this.requireProjectId();
|
|
502
|
+
const tcNum = numericId(tcId);
|
|
503
|
+
// Resolution order: explicit override → TC's currently-selected version (any of 4 field
|
|
504
|
+
// names, depending on the read model — Java tries all four) → first entry in versions[].
|
|
505
|
+
let versionId = versionOverride;
|
|
506
|
+
if (!versionId) {
|
|
507
|
+
versionId =
|
|
508
|
+
str(rawTc, 'versionId', 'selectedVersionId') ??
|
|
509
|
+
(rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
|
|
510
|
+
? str(rawTc.versionsDetails, 'id', '_id', 'versionId')
|
|
511
|
+
: undefined);
|
|
512
|
+
if (!versionId && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
|
|
513
|
+
versionId = str(rawTc.versions[0], 'id', '_id');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (!versionId)
|
|
517
|
+
return [];
|
|
518
|
+
const path = '/tm/testcase/steps' +
|
|
519
|
+
`?projectId=${projectId}` +
|
|
520
|
+
`&tcfnId=${tcNum}` +
|
|
521
|
+
`&versionId=${encodeURIComponent(versionId)}` +
|
|
522
|
+
'&type=TC';
|
|
523
|
+
const body = await this.http.getJson(path);
|
|
524
|
+
const rows = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
|
|
525
|
+
return rows;
|
|
526
|
+
}
|
|
527
|
+
/** For every FUNCTION step, resolve the referenced function's code + name via /tm/function search
|
|
528
|
+
* (batched by distinct functionId). Mutates `tc.steps[i].functionCall` in place with
|
|
529
|
+
* `testCaseCode`/`testCaseName`. Non-fatal on failure — the raw id still renders. Mirrors the
|
|
530
|
+
* Excel export builder in tcfn.service.js:2696-2703. */
|
|
531
|
+
async hydrateFunctionNames(tc) {
|
|
532
|
+
const ids = new Set();
|
|
533
|
+
for (const s of tc.steps) {
|
|
534
|
+
if (s.stepType === 'function' && s.functionCall?.testCaseId) {
|
|
535
|
+
ids.add(s.functionCall.testCaseId);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (!ids.size)
|
|
539
|
+
return;
|
|
540
|
+
try {
|
|
541
|
+
const projectId = this.requireProjectId();
|
|
542
|
+
const body = this.searchBody([
|
|
543
|
+
{ column: 'deleted', value: false },
|
|
544
|
+
{ column: 'projectId', value: projectId },
|
|
545
|
+
{ column: 'id', value: [...ids].map((x) => Number.parseInt(x, 10)).filter(Number.isFinite), regEx: false },
|
|
546
|
+
]);
|
|
547
|
+
const raw = await this.http.postJson('/tm/function/search', body);
|
|
548
|
+
const rows = pickArray(raw, 'data', 'content', 'functions');
|
|
549
|
+
const byId = new Map();
|
|
550
|
+
for (const r of rows) {
|
|
551
|
+
const rid = str(r, 'id', '_id', 'functionId');
|
|
552
|
+
if (!rid)
|
|
553
|
+
continue;
|
|
554
|
+
byId.set(rid, { code: str(r, 'code'), name: str(r, 'name', 'title') ?? '' });
|
|
555
|
+
}
|
|
556
|
+
for (const s of tc.steps) {
|
|
557
|
+
if (s.stepType !== 'function' || !s.functionCall)
|
|
558
|
+
continue;
|
|
559
|
+
const hit = byId.get(s.functionCall.testCaseId);
|
|
560
|
+
if (!hit)
|
|
561
|
+
continue;
|
|
562
|
+
if (hit.code)
|
|
563
|
+
s.functionCall.testCaseCode = hit.code;
|
|
564
|
+
if (hit.name)
|
|
565
|
+
s.functionCall.testCaseName = hit.name;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// Non-fatal: fall back to bare id in the render.
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/** 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. Non-fatal
|
|
574
|
+
* on failure — the bare id still renders. Mirrors the FE's `defaultFieldsMap.get('objectId').map`
|
|
575
|
+
* behavior used by the Excel export (tcfn.service.js:2876) so the tool surface matches the UI. */
|
|
576
|
+
async hydrateObjectNames(tc) {
|
|
577
|
+
const ids = new Set();
|
|
578
|
+
for (const s of tc.steps) {
|
|
579
|
+
if (s.objectRef?.id && !s.objectRef.name)
|
|
580
|
+
ids.add(s.objectRef.id);
|
|
581
|
+
}
|
|
582
|
+
if (!ids.size)
|
|
583
|
+
return;
|
|
584
|
+
try {
|
|
585
|
+
const projectId = this.requireProjectId();
|
|
586
|
+
const body = this.searchBody([
|
|
587
|
+
{ column: 'deleted', value: false },
|
|
588
|
+
{ column: 'projectId', value: projectId },
|
|
589
|
+
{ column: 'id', value: [...ids].map((x) => Number.parseInt(x, 10)).filter(Number.isFinite), regEx: false },
|
|
590
|
+
]);
|
|
591
|
+
const raw = await this.http.postJson('/tm/object/search', body);
|
|
592
|
+
const rows = pickArray(raw, 'data', 'content', 'objects');
|
|
593
|
+
const byId = new Map();
|
|
594
|
+
for (const r of rows) {
|
|
595
|
+
const rid = str(r, 'id', '_id');
|
|
596
|
+
if (!rid)
|
|
597
|
+
continue;
|
|
598
|
+
const name = str(r, 'disname', 'description', 'placeholder');
|
|
599
|
+
if (name)
|
|
600
|
+
byId.set(rid, name);
|
|
601
|
+
}
|
|
602
|
+
for (const s of tc.steps) {
|
|
603
|
+
if (!s.objectRef?.id || s.objectRef.name)
|
|
604
|
+
continue;
|
|
605
|
+
const name = byId.get(s.objectRef.id);
|
|
606
|
+
if (name)
|
|
607
|
+
s.objectRef.name = name;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
// Non-fatal: fall back to bare id in the render.
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// ---------------------------------------------------------------------- Suites (read)
|
|
615
|
+
async listSuites() {
|
|
616
|
+
const projectId = this.requireProjectId();
|
|
617
|
+
const body = this.searchBody([
|
|
618
|
+
{ column: 'deleted', value: false },
|
|
619
|
+
{ column: 'projectId', value: projectId },
|
|
620
|
+
]);
|
|
621
|
+
const raw = await this.http.postJson('/tm/suites/search', body);
|
|
622
|
+
const rows = pickArray(raw, 'data', 'content', 'suites');
|
|
623
|
+
return withTotal(rows.map(toSuiteSummary), raw);
|
|
624
|
+
}
|
|
625
|
+
async getSuite(id) {
|
|
626
|
+
const raw = await this.http.getJson(`/tm/suites/${numericId(id)}`);
|
|
627
|
+
let doc = raw?.data ?? raw;
|
|
628
|
+
if (Array.isArray(doc))
|
|
629
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
630
|
+
return toSuite(doc && typeof doc === 'object' ? doc : {});
|
|
631
|
+
}
|
|
632
|
+
// ---------------------------------------------------------------------- Stories (read)
|
|
633
|
+
async listStories(q) {
|
|
634
|
+
const projectId = this.requireProjectId();
|
|
635
|
+
const fields = [
|
|
636
|
+
{ column: 'deleted', value: false },
|
|
637
|
+
{ column: 'projectId', value: projectId },
|
|
638
|
+
];
|
|
639
|
+
if (q.search)
|
|
640
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
641
|
+
// SQA's userstory FK for sprint is `iterationId` — spec uses the neutral name `sprintId`.
|
|
642
|
+
if (q.sprintId)
|
|
643
|
+
fields.push({ column: 'iterationId', value: Number.parseInt(q.sprintId, 10) });
|
|
644
|
+
if (q.releaseId)
|
|
645
|
+
fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
|
|
646
|
+
const createdBy = this.resolveUserFilter(q.createdBy);
|
|
647
|
+
if (createdBy != null)
|
|
648
|
+
fields.push({ column: 'createdBy', value: createdBy });
|
|
649
|
+
const updatedBy = this.resolveUserFilter(q.updatedBy);
|
|
650
|
+
if (updatedBy != null)
|
|
651
|
+
fields.push({ column: 'updatedBy', value: updatedBy });
|
|
652
|
+
const ownerId = this.resolveUserFilter(q.ownerId);
|
|
653
|
+
if (ownerId != null)
|
|
654
|
+
fields.push({ column: 'ownerId', value: ownerId });
|
|
655
|
+
if (q.filters?.length)
|
|
656
|
+
fields.push(...q.filters);
|
|
657
|
+
const body = this.searchBody(fields, { limit: q.limit ?? 50 });
|
|
658
|
+
const raw = await this.http.postJson('/pm/userstory/search', body);
|
|
659
|
+
const rows = pickArray(raw, 'data', 'content', 'userStories', 'stories');
|
|
660
|
+
return withTotal(rows.map(toStorySummary), raw);
|
|
661
|
+
}
|
|
662
|
+
async getStory(id) {
|
|
663
|
+
const resolvedId = await this.resolveStoryNumericId(id);
|
|
664
|
+
const raw = await this.http.getJson(`/pm/userstory/${resolvedId}`);
|
|
665
|
+
const doc = Array.isArray(raw)
|
|
666
|
+
? (raw.length > 0 ? raw[0] : {})
|
|
667
|
+
: (raw?.data && typeof raw.data === 'object' ? raw.data : raw);
|
|
668
|
+
return toStory(doc && typeof doc === 'object' ? doc : {});
|
|
669
|
+
}
|
|
670
|
+
/** A US_XXX business key → the numeric userstoryId via `/pm/userstory/search`. Numeric passes
|
|
671
|
+
* through. SQA's GET-by-id parseInts the path, so a raw key would 404. */
|
|
672
|
+
async resolveStoryNumericId(ref) {
|
|
673
|
+
if (!ref)
|
|
674
|
+
return ref;
|
|
675
|
+
if (/^\d+$/.test(ref))
|
|
676
|
+
return ref;
|
|
677
|
+
const body = this.searchBody([{ column: 'code', value: ref }], { startIndex: 0, limit: 1 });
|
|
678
|
+
const raw = await this.http.postJson('/pm/userstory/search', body);
|
|
679
|
+
const rows = pickArray(raw, 'data', 'content', 'userStories', 'stories');
|
|
680
|
+
if (rows.length === 0)
|
|
681
|
+
throw new Error(`Story not found for business key: ${ref}`);
|
|
682
|
+
return str(rows[0], 'id', '_id', 'userstoryId') ?? ref;
|
|
683
|
+
}
|
|
684
|
+
// ---------------------------------------------------------------------- Defects (read)
|
|
685
|
+
/** `POST /pm/defect/search` — same searchFields protocol as userstory/testcase search. */
|
|
686
|
+
async listDefects(q) {
|
|
687
|
+
const projectId = this.requireProjectId();
|
|
688
|
+
const fields = [
|
|
689
|
+
{ column: 'deleted', value: false },
|
|
690
|
+
{ column: 'projectId', value: projectId },
|
|
691
|
+
];
|
|
692
|
+
if (q.search)
|
|
693
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
694
|
+
if (q.tcId)
|
|
695
|
+
fields.push({ column: 'selectedTestCase.testcaseId', value: [Number.parseInt(numericId(q.tcId), 10)] });
|
|
696
|
+
if (q.storyId)
|
|
697
|
+
fields.push({ column: 'userstoryId', value: Number.parseInt(numericId(q.storyId), 10) });
|
|
698
|
+
if (q.state)
|
|
699
|
+
fields.push({ column: 'state', value: q.state });
|
|
700
|
+
// ownerId is an array field — match with Mongo $in, not bare equality.
|
|
701
|
+
const assignedTo = this.resolveUserFilter(q.assignedTo);
|
|
702
|
+
if (assignedTo != null)
|
|
703
|
+
fields.push({ column: 'ownerId', value: { $in: [assignedTo] } });
|
|
704
|
+
const createdBy = this.resolveUserFilter(q.createdBy);
|
|
705
|
+
if (createdBy != null)
|
|
706
|
+
fields.push({ column: 'createdBy', value: createdBy });
|
|
707
|
+
if (q.filters?.length)
|
|
708
|
+
fields.push(...q.filters);
|
|
709
|
+
const body = this.searchBody(fields, { limit: q.limit ?? 50, startIndex: q.offset ?? 0 });
|
|
710
|
+
const raw = await this.http.postJson('/pm/defect/search', body);
|
|
711
|
+
const rows = pickArray(raw, 'data', 'content', 'defects');
|
|
712
|
+
return withTotal(rows.map(toDefectSummary), raw);
|
|
713
|
+
}
|
|
714
|
+
// ---------------------------------------------------------------------- Tasks (read)
|
|
715
|
+
/** `POST /pm/task/search` — same searchFields protocol as defect/userstory search. */
|
|
716
|
+
async listTasks(q) {
|
|
717
|
+
const projectId = this.requireProjectId();
|
|
718
|
+
const fields = [
|
|
719
|
+
{ column: 'deleted', value: false },
|
|
720
|
+
{ column: 'projectId', value: projectId },
|
|
721
|
+
];
|
|
722
|
+
if (q.search)
|
|
723
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
724
|
+
if (q.storyId)
|
|
725
|
+
fields.push({ column: 'userstoryId', value: Number.parseInt(numericId(q.storyId), 10) });
|
|
726
|
+
if (q.state)
|
|
727
|
+
fields.push({ column: 'state', value: q.state });
|
|
728
|
+
const assignedTo = this.resolveUserFilter(q.assignedTo);
|
|
729
|
+
if (assignedTo != null)
|
|
730
|
+
fields.push({ column: 'ownerId', value: { $in: [assignedTo] } });
|
|
731
|
+
const createdBy = this.resolveUserFilter(q.createdBy);
|
|
732
|
+
if (createdBy != null)
|
|
733
|
+
fields.push({ column: 'createdBy', value: createdBy });
|
|
734
|
+
if (q.filters?.length)
|
|
735
|
+
fields.push(...q.filters);
|
|
736
|
+
const body = this.searchBody(fields, { limit: q.limit ?? 50, startIndex: q.offset ?? 0 });
|
|
737
|
+
const raw = await this.http.postJson('/pm/task/search', body);
|
|
738
|
+
const rows = pickArray(raw, 'data', 'content', 'tasks');
|
|
739
|
+
return withTotal(rows.map(toTaskSummary), raw);
|
|
740
|
+
}
|
|
741
|
+
// ---------------------------------------------------------------------- Stories (write)
|
|
742
|
+
async createStory(input) {
|
|
743
|
+
const body = {
|
|
744
|
+
...input,
|
|
745
|
+
// SQA userstory expects a singular numeric projectId; fill from the connected project when omitted.
|
|
746
|
+
projectId: input.projectId ?? this.requireProjectId(),
|
|
747
|
+
};
|
|
748
|
+
const raw = await this.http.postJson('/pm/userstory', body);
|
|
749
|
+
return toSavedRef(raw, true);
|
|
750
|
+
}
|
|
751
|
+
async updateStory(id, patch) {
|
|
752
|
+
const resolvedId = await this.resolveStoryNumericId(id);
|
|
753
|
+
const raw = await this.http.putJson(`/pm/userstory/${resolvedId}`, patch);
|
|
754
|
+
return toSavedRef(raw ?? { id: resolvedId }, false);
|
|
755
|
+
}
|
|
756
|
+
// ---------------------------------------------------------------------- Epics
|
|
757
|
+
async listEpics(q) {
|
|
758
|
+
const projectId = this.requireProjectId();
|
|
759
|
+
const fields = [
|
|
760
|
+
{ column: 'deleted', value: false },
|
|
761
|
+
// Epic is multi-project in SQA — filter for members of the current project via $in.
|
|
762
|
+
{ column: 'projectIds', value: projectId },
|
|
763
|
+
];
|
|
764
|
+
if (q.search)
|
|
765
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
766
|
+
if (q.state)
|
|
767
|
+
fields.push({ column: 'state', value: q.state });
|
|
768
|
+
const createdBy = this.resolveUserFilter(q.createdBy);
|
|
769
|
+
if (createdBy != null)
|
|
770
|
+
fields.push({ column: 'createdBy', value: createdBy });
|
|
771
|
+
const updatedBy = this.resolveUserFilter(q.updatedBy);
|
|
772
|
+
if (updatedBy != null)
|
|
773
|
+
fields.push({ column: 'updatedBy', value: updatedBy });
|
|
774
|
+
const ownerId = this.resolveUserFilter(q.ownerId);
|
|
775
|
+
if (ownerId != null)
|
|
776
|
+
fields.push({ column: 'ownerId', value: ownerId });
|
|
777
|
+
const body = this.searchBody(fields, {
|
|
778
|
+
startIndex: q.offset ?? 0,
|
|
779
|
+
limit: q.limit ?? 50,
|
|
780
|
+
});
|
|
781
|
+
const raw = await this.http.postJson('/pm/epic/search', body);
|
|
782
|
+
const rows = pickArray(raw, 'data', 'content', 'epics');
|
|
783
|
+
return withTotal(rows.map(toEpicSummary), raw);
|
|
784
|
+
}
|
|
785
|
+
async getEpic(id) {
|
|
786
|
+
const resolvedId = await this.resolveEpicNumericId(id);
|
|
787
|
+
const raw = await this.http.getJson(`/pm/epic/${resolvedId}`);
|
|
788
|
+
const doc = Array.isArray(raw)
|
|
789
|
+
? (raw.length > 0 ? raw[0] : {})
|
|
790
|
+
: (raw?.data && typeof raw.data === 'object' ? raw.data : raw);
|
|
791
|
+
return toEpic(doc && typeof doc === 'object' ? doc : {});
|
|
792
|
+
}
|
|
793
|
+
async createEpic(input) {
|
|
794
|
+
const body = {
|
|
795
|
+
...input,
|
|
796
|
+
projectIds: input.projectIds ?? [this.requireProjectId()],
|
|
797
|
+
};
|
|
798
|
+
const raw = await this.http.postJson('/pm/epic', body);
|
|
799
|
+
return toSavedRef(raw, true);
|
|
800
|
+
}
|
|
801
|
+
async updateEpic(id, patch) {
|
|
802
|
+
const resolvedId = await this.resolveEpicNumericId(id);
|
|
803
|
+
const raw = await this.http.putJson(`/pm/epic/${resolvedId}`, patch);
|
|
804
|
+
return toSavedRef(raw ?? { id: resolvedId }, false);
|
|
805
|
+
}
|
|
806
|
+
/** EP_XXX business key → numeric epic id via `/pm/epic/search`. Numeric passes through. */
|
|
807
|
+
async resolveEpicNumericId(ref) {
|
|
808
|
+
if (!ref)
|
|
809
|
+
return ref;
|
|
810
|
+
if (/^\d+$/.test(ref))
|
|
811
|
+
return ref;
|
|
812
|
+
const body = this.searchBody([{ column: 'code', value: ref }], { startIndex: 0, limit: 1 });
|
|
813
|
+
const raw = await this.http.postJson('/pm/epic/search', body);
|
|
814
|
+
const rows = pickArray(raw, 'data', 'content', 'epics');
|
|
815
|
+
if (rows.length === 0)
|
|
816
|
+
throw new Error(`Epic not found for business key: ${ref}`);
|
|
817
|
+
return str(rows[0], 'id', '_id', 'epicId') ?? ref;
|
|
818
|
+
}
|
|
819
|
+
// ---------------------------------------------------------------------- Features
|
|
820
|
+
async listFeatures(q) {
|
|
821
|
+
const projectId = this.requireProjectId();
|
|
822
|
+
const fields = [
|
|
823
|
+
{ column: 'deleted', value: false },
|
|
824
|
+
{ column: 'projectIds', value: projectId },
|
|
825
|
+
];
|
|
826
|
+
if (q.search)
|
|
827
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
828
|
+
if (q.state)
|
|
829
|
+
fields.push({ column: 'state', value: q.state });
|
|
830
|
+
if (q.epicId)
|
|
831
|
+
fields.push({ column: 'epicId', value: Number.parseInt(q.epicId, 10) });
|
|
832
|
+
const createdBy = this.resolveUserFilter(q.createdBy);
|
|
833
|
+
if (createdBy != null)
|
|
834
|
+
fields.push({ column: 'createdBy', value: createdBy });
|
|
835
|
+
const updatedBy = this.resolveUserFilter(q.updatedBy);
|
|
836
|
+
if (updatedBy != null)
|
|
837
|
+
fields.push({ column: 'updatedBy', value: updatedBy });
|
|
838
|
+
const ownerId = this.resolveUserFilter(q.ownerId);
|
|
839
|
+
if (ownerId != null)
|
|
840
|
+
fields.push({ column: 'ownerId', value: ownerId });
|
|
841
|
+
const body = this.searchBody(fields, {
|
|
842
|
+
startIndex: q.offset ?? 0,
|
|
843
|
+
limit: q.limit ?? 50,
|
|
844
|
+
});
|
|
845
|
+
const raw = await this.http.postJson('/pm/feature/search', body);
|
|
846
|
+
const rows = pickArray(raw, 'data', 'content', 'features');
|
|
847
|
+
return withTotal(rows.map(toFeatureSummary), raw);
|
|
848
|
+
}
|
|
849
|
+
async getFeature(id) {
|
|
850
|
+
const resolvedId = await this.resolveFeatureNumericId(id);
|
|
851
|
+
const raw = await this.http.getJson(`/pm/feature/${resolvedId}`);
|
|
852
|
+
const doc = Array.isArray(raw)
|
|
853
|
+
? (raw.length > 0 ? raw[0] : {})
|
|
854
|
+
: (raw?.data && typeof raw.data === 'object' ? raw.data : raw);
|
|
855
|
+
return toFeature(doc && typeof doc === 'object' ? doc : {});
|
|
856
|
+
}
|
|
857
|
+
async createFeature(input) {
|
|
858
|
+
const body = {
|
|
859
|
+
...input,
|
|
860
|
+
projectIds: input.projectIds ?? [this.requireProjectId()],
|
|
861
|
+
};
|
|
862
|
+
const raw = await this.http.postJson('/pm/feature', body);
|
|
863
|
+
return toSavedRef(raw, true);
|
|
864
|
+
}
|
|
865
|
+
async updateFeature(id, patch) {
|
|
866
|
+
const resolvedId = await this.resolveFeatureNumericId(id);
|
|
867
|
+
const raw = await this.http.putJson(`/pm/feature/${resolvedId}`, patch);
|
|
868
|
+
return toSavedRef(raw ?? { id: resolvedId }, false);
|
|
869
|
+
}
|
|
870
|
+
/** FT_XXX business key → numeric feature id via `/pm/feature/search`. Numeric passes through. */
|
|
871
|
+
async resolveFeatureNumericId(ref) {
|
|
872
|
+
if (!ref)
|
|
873
|
+
return ref;
|
|
874
|
+
if (/^\d+$/.test(ref))
|
|
875
|
+
return ref;
|
|
876
|
+
const body = this.searchBody([{ column: 'code', value: ref }], { startIndex: 0, limit: 1 });
|
|
877
|
+
const raw = await this.http.postJson('/pm/feature/search', body);
|
|
878
|
+
const rows = pickArray(raw, 'data', 'content', 'features');
|
|
879
|
+
if (rows.length === 0)
|
|
880
|
+
throw new Error(`Feature not found for business key: ${ref}`);
|
|
881
|
+
return str(rows[0], 'id', '_id', 'featureId') ?? ref;
|
|
882
|
+
}
|
|
883
|
+
// ---------------------------------------------------------------------- Modules, releases, sprints
|
|
884
|
+
async listModules() {
|
|
885
|
+
const projectId = this.requireProjectId();
|
|
886
|
+
const body = this.searchBody([
|
|
887
|
+
{ column: 'projectId', value: projectId },
|
|
888
|
+
{ column: 'deleted', value: false },
|
|
889
|
+
], { selectFields: ['id', 'code', 'name', 'parentId'] });
|
|
890
|
+
const raw = await this.http.postJson('/pm/module/search', body);
|
|
891
|
+
const rows = pickArray(raw, 'data', 'content', 'modules');
|
|
892
|
+
return withTotal(rows.map(toModuleSummary), raw);
|
|
893
|
+
}
|
|
894
|
+
/** GET /pm/module/:id — server returns an array; empty array → NotFound. */
|
|
895
|
+
async getModule(id) {
|
|
896
|
+
const raw = await this.http.getJson(`/pm/module/${numericId(id)}`);
|
|
897
|
+
const rows = Array.isArray(raw) ? raw : pickArray(raw, 'data', 'content');
|
|
898
|
+
const doc = rows[0];
|
|
899
|
+
if (!doc)
|
|
900
|
+
throw new Error(`Module ${id} not found.`);
|
|
901
|
+
const summary = toModuleSummary(doc);
|
|
902
|
+
const code = str(doc, 'code');
|
|
903
|
+
const description = str(doc, 'description');
|
|
904
|
+
const projectId = doc?.projectId != null ? String(doc.projectId) : undefined;
|
|
905
|
+
const childIds = Array.isArray(doc?.childIds)
|
|
906
|
+
? doc.childIds.filter((n) => Number.isFinite(n))
|
|
907
|
+
: undefined;
|
|
908
|
+
const isRoot = typeof doc?.isRoot === 'boolean' ? doc.isRoot : undefined;
|
|
909
|
+
return {
|
|
910
|
+
...summary,
|
|
911
|
+
...(code ? { code } : {}),
|
|
912
|
+
...(description ? { description } : {}),
|
|
913
|
+
...(childIds ? { childIds } : {}),
|
|
914
|
+
...(projectId ? { projectId } : {}),
|
|
915
|
+
...(isRoot !== undefined ? { isRoot } : {}),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
/** POST /pm/module — server validates: name+isRoot+projectId required; parentId ignored when
|
|
919
|
+
* isRoot:true. Duplicate name per project is rejected server-side. */
|
|
920
|
+
async createModule(input) {
|
|
921
|
+
const payload = {
|
|
922
|
+
name: input.name,
|
|
923
|
+
isRoot: input.isRoot,
|
|
924
|
+
projectId: Number.parseInt(input.projectId, 10),
|
|
925
|
+
};
|
|
926
|
+
if (input.description)
|
|
927
|
+
payload['description'] = input.description;
|
|
928
|
+
if (!input.isRoot && input.parentId)
|
|
929
|
+
payload['parentId'] = Number.parseInt(input.parentId, 10);
|
|
930
|
+
const raw = await this.http.postJson('/pm/module', payload);
|
|
931
|
+
return toSavedRef(raw, true);
|
|
932
|
+
}
|
|
933
|
+
/** PATCH /pm/module — two-phase per module.controller.js:94. `confirm:false` returns the per-id
|
|
934
|
+
* dependency footprint (test cases, functions, params, objects, defects, user stories). `confirm:true`
|
|
935
|
+
* triggers the actual delete: server drops only ids with EMPTY dependencies; ids with references are
|
|
936
|
+
* silently skipped — we still return that list so the caller can act on it.
|
|
937
|
+
* ponytail: one method, `confirm` flag; mirrors the backend's two-call contract. */
|
|
938
|
+
async deleteModule(input) {
|
|
939
|
+
const numIds = input.ids.map((id) => Number.parseInt(numericId(id), 10));
|
|
940
|
+
const projectId = Number.parseInt(input.projectId, 10);
|
|
941
|
+
const isDelete = input.confirm === true;
|
|
942
|
+
const raw = await this.http.patchJson('/pm/module', { ids: numIds, projectId, isDelete });
|
|
943
|
+
const arr = Array.isArray(raw) ? raw : pickArray(raw, 'data', 'content');
|
|
944
|
+
const dependencies = arr
|
|
945
|
+
.filter((r) => r && typeof r === 'object')
|
|
946
|
+
.map((r) => ({
|
|
947
|
+
id: Number(r.id),
|
|
948
|
+
tcArr: Array.isArray(r.tcArr) ? r.tcArr : [],
|
|
949
|
+
fnArr: Array.isArray(r.fnArr) ? r.fnArr : [],
|
|
950
|
+
parameterArr: Array.isArray(r.parameterArr) ? r.parameterArr : [],
|
|
951
|
+
objArr: Array.isArray(r.objArr) ? r.objArr : [],
|
|
952
|
+
defectArr: Array.isArray(r.defectArr) ? r.defectArr : [],
|
|
953
|
+
usArr: Array.isArray(r.usArr) ? r.usArr : [],
|
|
954
|
+
}));
|
|
955
|
+
return { dryRun: !isDelete, dependencies };
|
|
956
|
+
}
|
|
957
|
+
/** PUT /tm/tcfnversion/delete — soft-delete ONE version of a TC or FN. Two-phase per
|
|
958
|
+
* tcfnversion.service.js:30 — `isDelete:false` (or `isDelete:true` with non-empty deps) →
|
|
959
|
+
* returns the dependency array unchanged; `isDelete:true` with EMPTY deps → soft-deletes the
|
|
960
|
+
* tcfn_version row + its steps + testdata + testdata iterations.
|
|
961
|
+
*
|
|
962
|
+
* ponytail: one method, `confirm` flag. Response shape decides `deleted`: if every dep row is
|
|
963
|
+
* fully empty (all `*Arr` are `{}` maps), the server actually deleted; otherwise it refused
|
|
964
|
+
* and just handed back the deps. We do not assume — we inspect. */
|
|
965
|
+
async deleteVersion(input) {
|
|
966
|
+
const projectId = this.requireProjectId();
|
|
967
|
+
const parentType = input.kind === 'tc' ? 'TC' : 'FN';
|
|
968
|
+
const isDelete = input.confirm === true;
|
|
969
|
+
const body = {
|
|
970
|
+
id: input.versionId,
|
|
971
|
+
parentId: Number.parseInt(numericId(input.parentId), 10),
|
|
972
|
+
parentType,
|
|
973
|
+
projectId,
|
|
974
|
+
name: input.versionName,
|
|
975
|
+
isDelete,
|
|
976
|
+
};
|
|
977
|
+
const raw = await this.http.putJson('/tm/tcfnversion/delete', body);
|
|
978
|
+
const data = raw?.data ?? raw;
|
|
979
|
+
// Distinguish "dep array" from "delete result": the service returns an ARRAY of dep rows for
|
|
980
|
+
// the dry-run + refused-delete branches, and a single mongo doc (findOneAndUpdate result) for
|
|
981
|
+
// the successful delete branch. Array → parse deps; single doc → deleted.
|
|
982
|
+
if (Array.isArray(data)) {
|
|
983
|
+
const dependencies = data
|
|
984
|
+
.filter((r) => r && typeof r === 'object')
|
|
985
|
+
.map((r) => normalizeVersionDep(r, parentType));
|
|
986
|
+
const hasBlockers = dependencies.some((d) => !versionDepIsEmpty(d));
|
|
987
|
+
return { dryRun: !isDelete, deleted: false, dependencies, hasBlockers };
|
|
988
|
+
}
|
|
989
|
+
// Single doc → successful delete (or upsert-echo when nothing matched, but the service only
|
|
990
|
+
// reaches that branch after emptyIds.push).
|
|
991
|
+
return { dryRun: false, deleted: true, dependencies: [], hasBlockers: false };
|
|
992
|
+
}
|
|
993
|
+
async listReleases(q) {
|
|
994
|
+
const fields = [{ column: 'deleted', value: false }];
|
|
995
|
+
if (q.scope === 'project')
|
|
996
|
+
fields.push({ column: 'projectIds', value: this.requireProjectId() });
|
|
997
|
+
const body = this.searchBody(fields);
|
|
998
|
+
const raw = await this.http.postJson('/pm/release/search', body);
|
|
999
|
+
const rows = pickArray(raw, 'data', 'content', 'releases');
|
|
1000
|
+
return withTotal(rows.map(toReleaseSummary), raw);
|
|
1001
|
+
}
|
|
1002
|
+
async listSprints(q) {
|
|
1003
|
+
const fields = [{ column: 'deleted', value: false }];
|
|
1004
|
+
if (q.releaseId)
|
|
1005
|
+
fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
|
|
1006
|
+
const body = this.searchBody(fields);
|
|
1007
|
+
const raw = await this.http.postJson('/pm/sprint/search', body);
|
|
1008
|
+
const rows = pickArray(raw, 'data', 'content', 'sprints');
|
|
1009
|
+
return withTotal(rows.map(toSprintSummary), raw);
|
|
1010
|
+
}
|
|
1011
|
+
/** POST /pm/users/search — the customer's users (router mounted at `/pm/users`, plural — see
|
|
1012
|
+
* project-management/express-app.js:181). Backend auto-scopes non-admin callers to users in
|
|
1013
|
+
* their assigned projects. Optional `projectId` narrows to that project's members; optional
|
|
1014
|
+
* `search` is a case-insensitive regex over `name`. */
|
|
1015
|
+
async listUsers(q) {
|
|
1016
|
+
const fields = [
|
|
1017
|
+
{ column: 'deleted', value: false },
|
|
1018
|
+
];
|
|
1019
|
+
if (q.search)
|
|
1020
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
1021
|
+
if (q.projectId)
|
|
1022
|
+
fields.push({ column: 'projectIds', value: Number.parseInt(q.projectId, 10) });
|
|
1023
|
+
const body = this.searchBody(fields, {
|
|
1024
|
+
startIndex: q.offset ?? 0,
|
|
1025
|
+
limit: q.limit ?? 100,
|
|
1026
|
+
selectFields: ['id', 'name', 'email', 'username', 'status'],
|
|
1027
|
+
});
|
|
1028
|
+
const raw = await this.http.postJson('/pm/users/search', body);
|
|
1029
|
+
const rows = pickArray(raw, 'data', 'content', 'users');
|
|
1030
|
+
const users = rows.map((n) => {
|
|
1031
|
+
const name = str(n, 'name');
|
|
1032
|
+
const email = str(n, 'email');
|
|
1033
|
+
const username = str(n, 'username');
|
|
1034
|
+
const status = str(n, 'status');
|
|
1035
|
+
return {
|
|
1036
|
+
id: str(n, 'id', '_id', 'userId') ?? '',
|
|
1037
|
+
...(name ? { name } : {}),
|
|
1038
|
+
...(email ? { email } : {}),
|
|
1039
|
+
...(username ? { username } : {}),
|
|
1040
|
+
...(status ? { status } : {}),
|
|
1041
|
+
};
|
|
1042
|
+
});
|
|
1043
|
+
return withTotal(users, raw);
|
|
1044
|
+
}
|
|
1045
|
+
// ---------------------------------------------------------------------- Objects (read)
|
|
1046
|
+
async getObject(id) {
|
|
1047
|
+
const raw = await this.http.getJson(`/tm/object/${numericId(id)}`);
|
|
1048
|
+
let doc = raw?.data ?? raw;
|
|
1049
|
+
if (Array.isArray(doc))
|
|
1050
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
1051
|
+
return toObject(doc && typeof doc === 'object' ? doc : {});
|
|
1052
|
+
}
|
|
1053
|
+
// ---------------------------------------------------------------------- Deep links
|
|
1054
|
+
productUrlFor(entity) {
|
|
1055
|
+
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
1056
|
+
return `${base}/${PROJECT_URL_SEGMENT[entity.kind]}/${entity.id}`;
|
|
1057
|
+
}
|
|
1058
|
+
// ---------------------------------------------------------------------- Agent hop (recording + not-yet-ported ops)
|
|
1059
|
+
// Everything below either delegates to the local agent /mcp (recording surface, and writes/testdata/
|
|
1060
|
+
// run dispatch we haven't ported to REST yet) or fails with a clear message when the hop is unset.
|
|
1061
|
+
// As each step of the port lands, the corresponding delegate is replaced with a direct REST impl.
|
|
1062
|
+
hop() {
|
|
1063
|
+
if (!this.cfg.url)
|
|
1064
|
+
return null;
|
|
1065
|
+
if (!this.agentHop)
|
|
1066
|
+
this.agentHop = new RemoteMcpAdapter(this.cfg);
|
|
1067
|
+
return this.agentHop;
|
|
1068
|
+
}
|
|
1069
|
+
requireHop(op) {
|
|
1070
|
+
const h = this.hop();
|
|
1071
|
+
if (!h)
|
|
1072
|
+
throw new Error(`${op}: ${HOP_NEEDED_WRITE}`);
|
|
1073
|
+
return h;
|
|
1074
|
+
}
|
|
1075
|
+
/** Recording tool schemas from the SQA agent — mirrored so the recording passthrough can register
|
|
1076
|
+
* them. No agent configured → `[]`, so no recording tools get registered. */
|
|
1077
|
+
async listAgentTools() {
|
|
1078
|
+
const h = this.hop();
|
|
1079
|
+
return h ? h.listAgentTools() : [];
|
|
1080
|
+
}
|
|
1081
|
+
/** Forward a recording-tool call to the agent /mcp. Same behavior as walnut. */
|
|
1082
|
+
async callRaw(name, args) {
|
|
1083
|
+
const h = this.hop();
|
|
1084
|
+
if (!h) {
|
|
1085
|
+
throw new Error("Recording needs a local SimplifyQA agent — set QA_MCP_URL to the agent's /mcp endpoint.");
|
|
1086
|
+
}
|
|
1087
|
+
return h.callRaw(name, args);
|
|
1088
|
+
}
|
|
1089
|
+
// ---------------------------------------------------------------------- Test cases (write) [Step 4]
|
|
1090
|
+
/** Two-step create: (1) resolve every `${name}` token in the steps to a project-scoped parameter id
|
|
1091
|
+
* (get-or-create) plus, for AUTOMATION, each step's actionId + objectId, (2) POST /tm/testcase
|
|
1092
|
+
* multipart with the assembled `testcaseObj`. */
|
|
1093
|
+
async createTestCase(tc) {
|
|
1094
|
+
const projectId = this.requireProjectId();
|
|
1095
|
+
const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
|
|
1096
|
+
const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
|
|
1097
|
+
const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
|
|
1098
|
+
const stepResolutions = tc.category === 'automated'
|
|
1099
|
+
? await this.resolveAutomationSteps(projectId, moduleId, tc)
|
|
1100
|
+
: undefined;
|
|
1101
|
+
const payload = buildTestcasePayload({
|
|
1102
|
+
projectId, moduleId,
|
|
1103
|
+
...(userstoryId != null ? { userstoryId } : {}),
|
|
1104
|
+
tc,
|
|
1105
|
+
paramIdByName,
|
|
1106
|
+
...(stepResolutions ? { stepResolutions } : {}),
|
|
1107
|
+
});
|
|
1108
|
+
const raw = await this.http.postMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
1109
|
+
return toSavedRef(raw, true);
|
|
1110
|
+
}
|
|
1111
|
+
/** Same shape as create, but with `tcfnObj.id` set. */
|
|
1112
|
+
async updateTestCase(id, tc) {
|
|
1113
|
+
const projectId = this.requireProjectId();
|
|
1114
|
+
const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
|
|
1115
|
+
const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
|
|
1116
|
+
const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
|
|
1117
|
+
const stepResolutions = tc.category === 'automated'
|
|
1118
|
+
? await this.resolveAutomationSteps(projectId, moduleId, tc)
|
|
1119
|
+
: undefined;
|
|
1120
|
+
const payload = buildTestcasePayload({
|
|
1121
|
+
projectId, moduleId,
|
|
1122
|
+
...(userstoryId != null ? { userstoryId } : {}),
|
|
1123
|
+
existingId: numericId(id),
|
|
1124
|
+
tc,
|
|
1125
|
+
paramIdByName,
|
|
1126
|
+
...(stepResolutions ? { stepResolutions } : {}),
|
|
1127
|
+
});
|
|
1128
|
+
// `!` — putMultipart is optional on QaHttp (walnut's wrapper opts out); makeClient always provides it.
|
|
1129
|
+
const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
1130
|
+
return toSavedRef(raw, false);
|
|
1131
|
+
}
|
|
1132
|
+
/** Snapshot the CURRENT server state of a testcase under a NEW version name — same TC id,
|
|
1133
|
+
* new `tcfn_version` row with cloned steps. Backed by `PUT /tm/testcase` with
|
|
1134
|
+
* `operation:"SAVENEWVERSION"` (see tcfn.service.js:115/242/355 for the branches). The steps
|
|
1135
|
+
* we fetch from the collection are re-POSTed with their `id` stripped so the backend inserts
|
|
1136
|
+
* fresh rows attached to the new version. When `tdCopy` is true, the source version's
|
|
1137
|
+
* testdata iterations are cloned across environments via `verData.selVersionId`.
|
|
1138
|
+
*
|
|
1139
|
+
* Not a mapper roundtrip: we resend the raw tcfnObj/steps we just fetched, so any fields the
|
|
1140
|
+
* neutral `TestCase` type doesn't carry (custom fields, mailers, exec platform, ...) are
|
|
1141
|
+
* preserved verbatim — the point of a version is "freeze what's there now". */
|
|
1142
|
+
async saveAsNewVersion(id, opts) {
|
|
1143
|
+
assertBusinessKeyKind(id, 'TC');
|
|
1144
|
+
const projectId = this.requireProjectId();
|
|
1145
|
+
const versionName = opts.versionName?.trim();
|
|
1146
|
+
if (!versionName)
|
|
1147
|
+
throw new Error('versionName is required.');
|
|
1148
|
+
const rawTc = await this.fetchRawTestcase(id);
|
|
1149
|
+
if (!rawTc || Object.keys(rawTc).length === 0) {
|
|
1150
|
+
throw new Error(`test case ${id} not found`);
|
|
1151
|
+
}
|
|
1152
|
+
// Resolve source version: caller override → current versionId on the TC → versionsDetails.id
|
|
1153
|
+
// → first entry in versions[]. Falls through to '' (empty) if none — backend then skips tdCopy.
|
|
1154
|
+
let sourceVersionId = opts.sourceVersionId;
|
|
1155
|
+
if (!sourceVersionId) {
|
|
1156
|
+
sourceVersionId = str(rawTc, 'versionId', 'selectedVersionId', 'selVersion');
|
|
1157
|
+
if (!sourceVersionId && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
|
|
1158
|
+
sourceVersionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId');
|
|
1159
|
+
}
|
|
1160
|
+
if (!sourceVersionId && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
|
|
1161
|
+
sourceVersionId = str(rawTc.versions[0], 'id', '_id');
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const rawSteps = await this.fetchStepsCollection(id, rawTc);
|
|
1165
|
+
const automationSteps = [];
|
|
1166
|
+
const manualSteps = [];
|
|
1167
|
+
for (const s of rawSteps) {
|
|
1168
|
+
// Project down to the step joi allow-list (testcaseValidator.js:244-306 + :307-367). Backend
|
|
1169
|
+
// re-assigns id/versionId/parentId under the new version row, so drop those regardless.
|
|
1170
|
+
const step = pick(s, STEP_ALLOWED_KEYS);
|
|
1171
|
+
const et = typeof step['executionType'] === 'string' ? String(step['executionType']).toUpperCase() : '';
|
|
1172
|
+
if (et === 'MANUAL')
|
|
1173
|
+
manualSteps.push(step);
|
|
1174
|
+
else
|
|
1175
|
+
automationSteps.push(step);
|
|
1176
|
+
}
|
|
1177
|
+
// Project down to the tcfnObj joi allow-list (testcaseValidator.js:189-227). Anything else on
|
|
1178
|
+
// the raw doc (code, createdBy, updatedBy, stepCount, etc.) trips "is not allowed".
|
|
1179
|
+
const tcfnObj = pick(rawTc, TCFNOBJ_ALLOWED_KEYS);
|
|
1180
|
+
tcfnObj['deleted'] = false;
|
|
1181
|
+
tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
|
|
1182
|
+
// Required-but-boolean fields on the update schema. Coerce so the presence is guaranteed.
|
|
1183
|
+
if (typeof tcfnObj['obsolete'] !== 'boolean')
|
|
1184
|
+
tcfnObj['obsolete'] = false;
|
|
1185
|
+
// Version stub: empty `id` tells the backend to create a fresh row. SYNCOFF normalised to
|
|
1186
|
+
// '' matches the FE (testcase.component.ts:1399) — it's a client-side "don't inherit"
|
|
1187
|
+
// marker, not a server value.
|
|
1188
|
+
const prevSync = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
|
|
1189
|
+
? str(rawTc.versionsDetails, 'syncONOFF')
|
|
1190
|
+
: undefined;
|
|
1191
|
+
const versionsDetails = {
|
|
1192
|
+
name: versionName,
|
|
1193
|
+
id: '',
|
|
1194
|
+
syncONOFF: prevSync === 'SYNCOFF' ? '' : (prevSync ?? ''),
|
|
1195
|
+
deleted: false,
|
|
1196
|
+
executionType: str(rawTc, 'executionType') ?? 'AUTOMATION',
|
|
1197
|
+
functionProjectIds: [projectId],
|
|
1198
|
+
reviewApproveStatus: '',
|
|
1199
|
+
};
|
|
1200
|
+
const verData = {
|
|
1201
|
+
tdCopy: !!opts.tdCopy,
|
|
1202
|
+
...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
|
|
1203
|
+
};
|
|
1204
|
+
const payload = {
|
|
1205
|
+
tcfnObj,
|
|
1206
|
+
steps: { automationSteps, manualSteps },
|
|
1207
|
+
versionsDetails,
|
|
1208
|
+
operation: 'SAVENEWVERSION',
|
|
1209
|
+
type: 'TC',
|
|
1210
|
+
verData,
|
|
1211
|
+
};
|
|
1212
|
+
const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
1213
|
+
const flat = raw?.data ?? raw ?? {};
|
|
1214
|
+
const savedId = str(flat, 'id', '_id', 'testcaseId') ?? numericId(id);
|
|
1215
|
+
const newVersionId = str(flat, 'versionId') ??
|
|
1216
|
+
(flat?.versionsDetails && typeof flat.versionsDetails === 'object'
|
|
1217
|
+
? str(flat.versionsDetails, 'id')
|
|
1218
|
+
: undefined);
|
|
1219
|
+
return {
|
|
1220
|
+
id: savedId,
|
|
1221
|
+
created: false,
|
|
1222
|
+
productUrl: this.productUrlFor({ kind: 'testcase', id: savedId }),
|
|
1223
|
+
...(newVersionId ? { artifacts: [`versionId=${newVersionId}`] } : {}),
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
/** STEPSTOFN — mirrors the FE "Save as function" flow (create-new-fn-modal.component.ts:88-197):
|
|
1227
|
+
* pick a subset of the TC's steps, retype to FN, POST /tm/function multipart with the
|
|
1228
|
+
* `functionObj` payload. Backend re-assigns step ids/versionId under the newly created FN version.
|
|
1229
|
+
*
|
|
1230
|
+
* ponytail: file attachments (STEP_ATTACMENTS / STEP_COMAPAREFILES multipart entries) are NOT
|
|
1231
|
+
* forwarded — MCP callers don't ship blobs. Add if a real caller needs them.
|
|
1232
|
+
* ponytail: sub-step flattening (FE lines 124-127) is skipped — `/tm/testcase/steps` returns
|
|
1233
|
+
* flat top-level rows; nested FN expansions aren't in that read. Handle if that changes. */
|
|
1234
|
+
async saveStepsAsFunction(tcId, opts) {
|
|
1235
|
+
const projectId = this.requireProjectId();
|
|
1236
|
+
const name = opts.name?.trim();
|
|
1237
|
+
if (!name)
|
|
1238
|
+
throw new Error('name is required.');
|
|
1239
|
+
if (!opts.moduleId)
|
|
1240
|
+
throw new Error('moduleId is required.');
|
|
1241
|
+
const seqSet = new Set(opts.stepSeqs);
|
|
1242
|
+
if (seqSet.size === 0)
|
|
1243
|
+
throw new Error('stepSeqs is required.');
|
|
1244
|
+
const rawTc = await this.fetchRawTestcase(tcId);
|
|
1245
|
+
if (!rawTc || Object.keys(rawTc).length === 0) {
|
|
1246
|
+
throw new Error(`test case ${tcId} not found`);
|
|
1247
|
+
}
|
|
1248
|
+
const sourceVersionId = str(rawTc, 'versionId', 'selectedVersionId') ??
|
|
1249
|
+
(rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
|
|
1250
|
+
? str(rawTc.versionsDetails, 'id', '_id', 'versionId')
|
|
1251
|
+
: undefined) ??
|
|
1252
|
+
(Array.isArray(rawTc?.versions) && rawTc.versions.length > 0
|
|
1253
|
+
? str(rawTc.versions[0], 'id', '_id')
|
|
1254
|
+
: undefined);
|
|
1255
|
+
const rawSteps = await this.fetchStepsCollection(tcId, rawTc);
|
|
1256
|
+
const picked = rawSteps
|
|
1257
|
+
.filter((s) => seqSet.has(Number(s.seq)))
|
|
1258
|
+
.sort((a, b) => Number(a.seq) - Number(b.seq));
|
|
1259
|
+
if (picked.length === 0) {
|
|
1260
|
+
throw new Error(`no steps on ${tcId} match stepSeqs [${opts.stepSeqs.join(',')}]`);
|
|
1261
|
+
}
|
|
1262
|
+
const automationSteps = [];
|
|
1263
|
+
const manualSteps = [];
|
|
1264
|
+
let newSeq = 0;
|
|
1265
|
+
for (const s of picked) {
|
|
1266
|
+
const step = { ...s };
|
|
1267
|
+
// Server-assigned refs stripped so backend re-creates them under the new FN version.
|
|
1268
|
+
delete step.id;
|
|
1269
|
+
delete step._id;
|
|
1270
|
+
delete step.versionId;
|
|
1271
|
+
delete step.parentId;
|
|
1272
|
+
delete step.subSteps;
|
|
1273
|
+
delete step.deleted;
|
|
1274
|
+
delete step.isFunctionEdit;
|
|
1275
|
+
delete step.sharedFNsteps;
|
|
1276
|
+
delete step.sharedFnProjectId;
|
|
1277
|
+
step.type = 'FN';
|
|
1278
|
+
step.moduleId = opts.moduleId;
|
|
1279
|
+
step.seq = ++newSeq;
|
|
1280
|
+
// FE mirror (line 129-134): TM only accepts numeric parameterIds.
|
|
1281
|
+
if (Array.isArray(step.parameterIds)) {
|
|
1282
|
+
step.parameterIds = step.parameterIds.filter((p) => typeof p === 'number');
|
|
1283
|
+
}
|
|
1284
|
+
// FE mirror (line 136-150): shed conditional-step UI expansion.
|
|
1285
|
+
if (step.conditions?.children?.length) {
|
|
1286
|
+
for (const c of step.conditions.children) {
|
|
1287
|
+
delete c.groupedActions;
|
|
1288
|
+
delete c.stepActions;
|
|
1289
|
+
delete c.objectOfparameters;
|
|
1290
|
+
if (Array.isArray(c.parameterIds)) {
|
|
1291
|
+
c.parameterIds = c.parameterIds.filter((p) => typeof p === 'number');
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
const forced = opts.executionType;
|
|
1296
|
+
const bucket = forced
|
|
1297
|
+
?? (String(step.executionType ?? '').toUpperCase() === 'MANUAL' ? 'MANUAL' : 'AUTOMATION');
|
|
1298
|
+
if (bucket === 'MANUAL')
|
|
1299
|
+
manualSteps.push(step);
|
|
1300
|
+
else
|
|
1301
|
+
automationSteps.push(step);
|
|
1302
|
+
}
|
|
1303
|
+
// FE mirror (line 192): DUO iff both buckets populated.
|
|
1304
|
+
const execType = automationSteps.length && manualSteps.length
|
|
1305
|
+
? 'DUO'
|
|
1306
|
+
: manualSteps.length ? 'MANUAL' : 'AUTOMATION';
|
|
1307
|
+
// FE mirror (line 271-276): all steps share technologyType → use that, else fall back to TC's.
|
|
1308
|
+
const techs = new Set(picked.map((s) => str(s, 'technologyType')).filter(Boolean));
|
|
1309
|
+
const fnType = techs.size === 1
|
|
1310
|
+
? [...techs][0]
|
|
1311
|
+
: (str(rawTc, 'tcType') ?? 'WEB');
|
|
1312
|
+
const tcfnObj = {
|
|
1313
|
+
name,
|
|
1314
|
+
projectId,
|
|
1315
|
+
moduleId: opts.moduleId,
|
|
1316
|
+
executionType: execType,
|
|
1317
|
+
fnType,
|
|
1318
|
+
reviewApproveStatus: '',
|
|
1319
|
+
};
|
|
1320
|
+
// FE default (function.model.ts:68) — backend requires versionsDetails.name.
|
|
1321
|
+
const versionsDetails = { name: 'v1.0', id: '', syncONOFF: '' };
|
|
1322
|
+
// testcaseId MUST be numeric here — tcfn.service.js:1124 uses it in a Mongo query
|
|
1323
|
+
// against `testdata_iteration.testcaseId` (stored as a Number). A string "42" won't
|
|
1324
|
+
// match `42`, so tdCopy silently returns zero source iterations. FE sends a number
|
|
1325
|
+
// (create-new-fn-modal.component.ts:31 `@Input('tcfnId') _tcfnId: number`).
|
|
1326
|
+
const verData = {
|
|
1327
|
+
tdCopy: !!opts.copyTestData,
|
|
1328
|
+
testcaseId: Number.parseInt(numericId(tcId), 10),
|
|
1329
|
+
fromParent: 'TC',
|
|
1330
|
+
...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
|
|
1331
|
+
};
|
|
1332
|
+
const payload = {
|
|
1333
|
+
tcfnObj,
|
|
1334
|
+
steps: { automationSteps, manualSteps },
|
|
1335
|
+
versionsDetails,
|
|
1336
|
+
operation: 'STEPSTOFN',
|
|
1337
|
+
type: 'FN',
|
|
1338
|
+
verData,
|
|
1339
|
+
};
|
|
1340
|
+
const raw = await this.http.postMultipart('/tm/function', 'functionObj', payload);
|
|
1341
|
+
return toSavedRef(raw, true);
|
|
1342
|
+
}
|
|
1343
|
+
/** Look up the freshly-created FN's version id — STEPSTOFN's response inlines the FN but not its
|
|
1344
|
+
* version, and the replace-in-TC step needs `fnVersionId`. Mirrors the FE
|
|
1345
|
+
* (`create-new-fn-modal.component.ts:getFnVersion`): search `tcfnversion` by parentType=FN
|
|
1346
|
+
* ordered by createdAt asc, take the first. */
|
|
1347
|
+
async fetchFnVersionId(fnId) {
|
|
1348
|
+
const projectId = this.requireProjectId();
|
|
1349
|
+
const body = {
|
|
1350
|
+
searchFields: [
|
|
1351
|
+
{ column: 'createdAt', sort: 'asc' },
|
|
1352
|
+
{ column: 'deleted', value: false, regEx: false },
|
|
1353
|
+
{ column: 'projectId', value: projectId, regEx: false },
|
|
1354
|
+
{ column: 'parentType', value: 'FN', regEx: false },
|
|
1355
|
+
{ column: 'parentId', value: Number.parseInt(numericId(fnId), 10), regEx: false },
|
|
1356
|
+
],
|
|
1357
|
+
startIndex: 0,
|
|
1358
|
+
limit: 10000,
|
|
1359
|
+
};
|
|
1360
|
+
const raw = await this.http.postJson('/tm/tcfnversion/search', body);
|
|
1361
|
+
const rows = pickArray(raw, 'data', 'content');
|
|
1362
|
+
const versionId = rows.length > 0 ? str(rows[0], 'id', '_id') : undefined;
|
|
1363
|
+
if (!versionId)
|
|
1364
|
+
throw new Error(`could not resolve fnVersionId for function ${fnId}`);
|
|
1365
|
+
return versionId;
|
|
1366
|
+
}
|
|
1367
|
+
/** Second half of extract-to-function-and-replace-in-source (FE
|
|
1368
|
+
* `testcase.component.ts:createNewFns → onSave`): after the FN exists, PUT the source TC with the
|
|
1369
|
+
* extracted step rows marked `deleted:true` and a synthetic FUNCTION + FUNCTION_END pair inserted
|
|
1370
|
+
* at the first extracted position. The backend automatically:
|
|
1371
|
+
* - drops testdata columns for params only the deleted steps referenced (allParameters skips
|
|
1372
|
+
* `step.deleted:true` at tcfn.service.js:411)
|
|
1373
|
+
* - injects a per-env FN cell into every TC iteration row using the FN's default iteration
|
|
1374
|
+
* (tcfn.service.js:1032-1085) — so testdata rewiring is free.
|
|
1375
|
+
*
|
|
1376
|
+
* ponytail: refuses DUO / SYNCON test cases and picks that span control-flow or nested FN
|
|
1377
|
+
* brackets. Those cases (v2) need parallel mirroring into the other execution-type array and
|
|
1378
|
+
* bracket-aware validation — out of scope for the first pass. */
|
|
1379
|
+
async replaceStepsWithFunctionCall(tcId, opts) {
|
|
1380
|
+
const projectId = this.requireProjectId();
|
|
1381
|
+
const seqSet = new Set(opts.extractedSeqs);
|
|
1382
|
+
if (seqSet.size === 0)
|
|
1383
|
+
throw new Error('extractedSeqs is required.');
|
|
1384
|
+
const sortedSeqs = [...seqSet].sort((a, b) => a - b);
|
|
1385
|
+
// FE `checkStepsContinueOrNot`: picks must be a contiguous seq range or the resulting FN would
|
|
1386
|
+
// be non-executable and the TC layout would fracture.
|
|
1387
|
+
for (let i = 1; i < sortedSeqs.length; i++) {
|
|
1388
|
+
if (sortedSeqs[i] !== sortedSeqs[i - 1] + 1) {
|
|
1389
|
+
throw new Error(`extractedSeqs must be contiguous — got [${sortedSeqs.join(',')}]`);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
const rawTc = await this.fetchRawTestcase(tcId);
|
|
1393
|
+
if (!rawTc || Object.keys(rawTc).length === 0)
|
|
1394
|
+
throw new Error(`test case ${tcId} not found`);
|
|
1395
|
+
const execType = String(str(rawTc, 'executionType') ?? '').toUpperCase();
|
|
1396
|
+
if (execType === 'DUO') {
|
|
1397
|
+
throw new Error('DUO test cases are not yet supported for replaceInSource — split the extraction into AUTOMATION-only or MANUAL-only first.');
|
|
1398
|
+
}
|
|
1399
|
+
const versionsDetailsRaw = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object' ? rawTc.versionsDetails : {};
|
|
1400
|
+
const syncOnOff = str(versionsDetailsRaw, 'syncONOFF');
|
|
1401
|
+
if (syncOnOff === 'SYNCON') {
|
|
1402
|
+
throw new Error('SYNCON (mirrored automation/manual) test cases are not yet supported for replaceInSource.');
|
|
1403
|
+
}
|
|
1404
|
+
const sourceVersionId = str(rawTc, 'versionId', 'selectedVersionId') ??
|
|
1405
|
+
str(versionsDetailsRaw, 'id', '_id', 'versionId') ??
|
|
1406
|
+
(Array.isArray(rawTc?.versions) && rawTc.versions.length > 0
|
|
1407
|
+
? str(rawTc.versions[0], 'id', '_id')
|
|
1408
|
+
: undefined);
|
|
1409
|
+
if (!sourceVersionId)
|
|
1410
|
+
throw new Error(`test case ${tcId} has no versionId — cannot save`);
|
|
1411
|
+
const rawSteps = await this.fetchStepsCollection(tcId, rawTc);
|
|
1412
|
+
// Refuse picks that would break brackets. FUNCTION/FUNCTION_END pairs must not be half-picked;
|
|
1413
|
+
// control-flow starts (IF/WHILE_START/FOR_START/…) must not span the range partially.
|
|
1414
|
+
const CONTROL_TYPES = new Set([
|
|
1415
|
+
'FUNCTION', 'FUNCTION_END', 'IF', 'ELSE_IF', 'ELSE', 'IF_END',
|
|
1416
|
+
'FOR_START', 'FOR_END', 'WHILE_START', 'WHILE_END', 'DO', 'DO_WHILE_END',
|
|
1417
|
+
'ITERATOR_START', 'ITERATOR_END', 'LOOP_ITERATION_START', 'LOOP_ITERATION_END',
|
|
1418
|
+
]);
|
|
1419
|
+
for (const s of rawSteps) {
|
|
1420
|
+
if (!seqSet.has(Number(s.seq)))
|
|
1421
|
+
continue;
|
|
1422
|
+
const t = String(s.stepType ?? '').toUpperCase();
|
|
1423
|
+
if (CONTROL_TYPES.has(t)) {
|
|
1424
|
+
throw new Error(`step seq ${s.seq} is a control-flow/FN bracket step (${t}) — extract a plain step range`);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const insertAt = sortedSeqs[0];
|
|
1428
|
+
// Build the FUNCTION + FUNCTION_END pair. FE parent (testcase.component.ts:1953+1988-1994)
|
|
1429
|
+
// overrides `type` from 'FN' to 'TC' before splicing — the FN-call lives inside a TC, so it's
|
|
1430
|
+
// typed accordingly. `id`/`versionId` omitted so backend creates fresh rows.
|
|
1431
|
+
const stepLevelActions = {
|
|
1432
|
+
skip: false, captureScreenshot: false, continueOnFailure: false, skippedReport: false,
|
|
1433
|
+
};
|
|
1434
|
+
const fnCallStep = {
|
|
1435
|
+
seq: insertAt,
|
|
1436
|
+
stepType: 'FUNCTION',
|
|
1437
|
+
type: 'TC',
|
|
1438
|
+
functionId: Number.parseInt(numericId(opts.functionId), 10),
|
|
1439
|
+
fnVersionId: opts.fnVersionId,
|
|
1440
|
+
description: '',
|
|
1441
|
+
projectId,
|
|
1442
|
+
moduleId: opts.fnModuleId,
|
|
1443
|
+
executionType: execType || 'AUTOMATION',
|
|
1444
|
+
technologyType: opts.technologyType,
|
|
1445
|
+
stepLevelActions,
|
|
1446
|
+
};
|
|
1447
|
+
const fnEndStep = {
|
|
1448
|
+
seq: insertAt + 1,
|
|
1449
|
+
stepType: 'FUNCTION_END',
|
|
1450
|
+
type: 'TC',
|
|
1451
|
+
projectId,
|
|
1452
|
+
moduleId: opts.fnModuleId,
|
|
1453
|
+
executionType: execType || 'AUTOMATION',
|
|
1454
|
+
technologyType: opts.technologyType,
|
|
1455
|
+
stepLevelActions,
|
|
1456
|
+
};
|
|
1457
|
+
// Kept steps: preserve id/versionId/parentId (this is an update, not a fresh save) and pass
|
|
1458
|
+
// through as-is. Extracted steps: same, plus `deleted:true` — the backend's param collector
|
|
1459
|
+
// (tcfn.service.js:411) skips deleted rows, so their paramIds automatically drop from the TC's
|
|
1460
|
+
// rebuilt testdata columns. Do NOT strip parameterIds / objectParameterIds / parameterizedParamIds
|
|
1461
|
+
// from the deleted rows — those are what `allParameters` compares against to know what to prune.
|
|
1462
|
+
const kept = [];
|
|
1463
|
+
const deleted = [];
|
|
1464
|
+
for (const s of rawSteps) {
|
|
1465
|
+
const step = { ...s };
|
|
1466
|
+
delete step.subSteps; // transient — flat-only per the STEPSTOFN comment
|
|
1467
|
+
if (seqSet.has(Number(step.seq))) {
|
|
1468
|
+
step.deleted = true;
|
|
1469
|
+
delete step.objectOfparameters; // FE mirror (testcase.component.ts:1272)
|
|
1470
|
+
deleted.push(step);
|
|
1471
|
+
}
|
|
1472
|
+
else {
|
|
1473
|
+
kept.push(step);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
kept.sort((a, b) => Number(a.seq) - Number(b.seq));
|
|
1477
|
+
// Splice fn-call pair at the insert position. seqs 1..N are re-assigned at the end so nothing
|
|
1478
|
+
// downstream can misalign.
|
|
1479
|
+
const afterInsert = kept.filter((k) => Number(k.seq) > sortedSeqs[sortedSeqs.length - 1]);
|
|
1480
|
+
const beforeInsert = kept.filter((k) => Number(k.seq) < insertAt);
|
|
1481
|
+
const orderedLive = [...beforeInsert, fnCallStep, fnEndStep, ...afterInsert];
|
|
1482
|
+
let seq = 1;
|
|
1483
|
+
for (const s of orderedLive) {
|
|
1484
|
+
s.seq = seq++;
|
|
1485
|
+
}
|
|
1486
|
+
// Backend expects both automationSteps and manualSteps buckets. Live steps route by their
|
|
1487
|
+
// executionType; deleted steps come along in their own bucket (FE mirror lines 1268-1284).
|
|
1488
|
+
const automationSteps = [];
|
|
1489
|
+
const manualSteps = [];
|
|
1490
|
+
const bucketFor = (s) => {
|
|
1491
|
+
const et = String(s?.executionType ?? '').toUpperCase();
|
|
1492
|
+
return et === 'MANUAL' ? 'MANUAL' : 'AUTOMATION';
|
|
1493
|
+
};
|
|
1494
|
+
for (const s of orderedLive)
|
|
1495
|
+
(bucketFor(s) === 'MANUAL' ? manualSteps : automationSteps).push(s);
|
|
1496
|
+
for (const s of deleted)
|
|
1497
|
+
(bucketFor(s) === 'MANUAL' ? manualSteps : automationSteps).push(s);
|
|
1498
|
+
// tcfnObj: strict allow-list — extras trip the joi validator. Backend mirrors the FE's normal
|
|
1499
|
+
// update PUT (testcase.component.ts:1376+1495): `operation:"TC"`, `type:"TC"`, same versionsDetails.
|
|
1500
|
+
const tcfnObj = pick(rawTc, TCFNOBJ_ALLOWED_KEYS);
|
|
1501
|
+
tcfnObj['deleted'] = false;
|
|
1502
|
+
tcfnObj['id'] = Number.parseInt(numericId(tcId), 10);
|
|
1503
|
+
tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
|
|
1504
|
+
if (typeof tcfnObj['obsolete'] !== 'boolean')
|
|
1505
|
+
tcfnObj['obsolete'] = false;
|
|
1506
|
+
// Backend joi validator refuses an empty versionsDetails.name (BADREQUEST from /tm/testcase). The
|
|
1507
|
+
// name isn't always inlined on the raw TC's `versionsDetails` — for TCs opened via search or
|
|
1508
|
+
// certain list endpoints, only `versionsDetails.id` is present. Walk the version list to find
|
|
1509
|
+
// the row whose id matches sourceVersionId and use ITS name; fall back to 'v1.0' (the SQA
|
|
1510
|
+
// default set by project.service.js when a version is first created).
|
|
1511
|
+
let versionName = str(versionsDetailsRaw, 'name');
|
|
1512
|
+
if (!versionName && Array.isArray(rawTc?.versions)) {
|
|
1513
|
+
const match = rawTc.versions.find((v) => str(v, 'id', '_id') === sourceVersionId);
|
|
1514
|
+
versionName = match ? str(match, 'name') : undefined;
|
|
1515
|
+
}
|
|
1516
|
+
if (!versionName)
|
|
1517
|
+
versionName = 'v1.0';
|
|
1518
|
+
const versionsDetails = {
|
|
1519
|
+
name: versionName,
|
|
1520
|
+
id: sourceVersionId,
|
|
1521
|
+
deleted: false,
|
|
1522
|
+
syncONOFF: syncOnOff ?? '',
|
|
1523
|
+
executionType: execType || 'AUTOMATION',
|
|
1524
|
+
functionProjectIds: [projectId],
|
|
1525
|
+
reviewApproveStatus: str(versionsDetailsRaw, 'reviewApproveStatus') ?? '',
|
|
1526
|
+
};
|
|
1527
|
+
const payload = {
|
|
1528
|
+
tcfnObj,
|
|
1529
|
+
steps: { automationSteps, manualSteps },
|
|
1530
|
+
versionsDetails,
|
|
1531
|
+
operation: 'TC',
|
|
1532
|
+
type: 'TC',
|
|
1533
|
+
};
|
|
1534
|
+
const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
1535
|
+
const flat = raw?.data ?? raw ?? {};
|
|
1536
|
+
const savedId = str(flat, 'id', '_id', 'testcaseId') ?? numericId(tcId);
|
|
1537
|
+
return {
|
|
1538
|
+
id: savedId,
|
|
1539
|
+
created: false,
|
|
1540
|
+
productUrl: this.productUrlFor({ kind: 'testcase', id: savedId }),
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
/** One-shot extract-to-function-and-replace. Chains STEPSTOFN → fetch fn version id → replace in
|
|
1544
|
+
* source TC. If step 2 or 3 fails after step 1 succeeded, the caller sees the fn id in the error
|
|
1545
|
+
* so they can retry or clean up the phantom function. */
|
|
1546
|
+
async extractStepsAsFunctionAndReplace(tcId, opts) {
|
|
1547
|
+
const fnRef = await this.saveStepsAsFunction(tcId, opts);
|
|
1548
|
+
let fnVersionId;
|
|
1549
|
+
try {
|
|
1550
|
+
fnVersionId = await this.fetchFnVersionId(fnRef.id);
|
|
1551
|
+
}
|
|
1552
|
+
catch (e) {
|
|
1553
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1554
|
+
throw new Error(`Function ${fnRef.id} was created but its version lookup failed — the source TC was NOT modified. ${msg}`);
|
|
1555
|
+
}
|
|
1556
|
+
// Reuse tech detection from saveStepsAsFunction (same rule: single tech across picks → that
|
|
1557
|
+
// tech, else fall back to the source TC's tcType).
|
|
1558
|
+
const rawTc = await this.fetchRawTestcase(tcId);
|
|
1559
|
+
const rawSteps = await this.fetchStepsCollection(tcId, rawTc);
|
|
1560
|
+
const picked = rawSteps.filter((s) => opts.stepSeqs.includes(Number(s.seq)));
|
|
1561
|
+
const techs = new Set(picked.map((s) => str(s, 'technologyType')).filter(Boolean));
|
|
1562
|
+
const technologyType = techs.size === 1 ? [...techs][0] : (str(rawTc, 'tcType') ?? 'WEB');
|
|
1563
|
+
try {
|
|
1564
|
+
const tcRef = await this.replaceStepsWithFunctionCall(tcId, {
|
|
1565
|
+
extractedSeqs: opts.stepSeqs,
|
|
1566
|
+
functionId: fnRef.id,
|
|
1567
|
+
fnVersionId,
|
|
1568
|
+
fnModuleId: opts.moduleId,
|
|
1569
|
+
technologyType,
|
|
1570
|
+
});
|
|
1571
|
+
return { ...tcRef, fnId: fnRef.id, fnVersionId };
|
|
1572
|
+
}
|
|
1573
|
+
catch (e) {
|
|
1574
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1575
|
+
throw new Error(`Function ${fnRef.id} (version ${fnVersionId}) was created but replacing steps in ${tcId} failed — clean up the function or retry with tc_replace_steps_with_function. ${msg}`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
/** Soft-delete. `/tm/testcase/delete` returns HTTP 200 either way — its `data` is `true` on real
|
|
1579
|
+
* delete, or an array of dependency records when the TC is still referenced by a defect, suite,
|
|
1580
|
+
* execution plan, scenario, workflow, pipeline, or scheduler (nothing was deleted). Inspect the
|
|
1581
|
+
* shape and throw so the tool surfaces it as a failure instead of a fake success. */
|
|
1582
|
+
async deleteTestCase(id) {
|
|
1583
|
+
const projectId = this.requireProjectId();
|
|
1584
|
+
const raw = await this.http.postJson('/tm/testcase/delete', {
|
|
1585
|
+
ids: [Number.parseInt(numericId(id), 10)],
|
|
1586
|
+
projectId,
|
|
1587
|
+
isDelete: true,
|
|
1588
|
+
});
|
|
1589
|
+
const data = raw?.data;
|
|
1590
|
+
if (data === true)
|
|
1591
|
+
return;
|
|
1592
|
+
const deps = Array.isArray(data) ? data[0] : undefined;
|
|
1593
|
+
const list = (m) => m && typeof m === 'object' ? Object.values(m).flat().map(String) : [];
|
|
1594
|
+
const refs = [];
|
|
1595
|
+
const groups = [
|
|
1596
|
+
['defectArr', 'defects'], ['suiteArr', 'suites'], ['epArr', 'execution plans'],
|
|
1597
|
+
['scenarioArr', 'scenarios'], ['wfArr', 'workflows'], ['pipelineArr', 'pipelines'],
|
|
1598
|
+
['schedulerArr', 'schedulers'],
|
|
1599
|
+
];
|
|
1600
|
+
for (const [key, label] of groups) {
|
|
1601
|
+
const ids = list(deps?.[key]);
|
|
1602
|
+
if (ids.length)
|
|
1603
|
+
refs.push(`${label} (${ids.join(', ')})`);
|
|
1604
|
+
}
|
|
1605
|
+
throw new Error(refs.length
|
|
1606
|
+
? `Not deleted — test case is still referenced by ${refs.join('; ')}. Remove those references first.`
|
|
1607
|
+
: 'Not deleted — the backend reported blocking dependencies.');
|
|
1608
|
+
}
|
|
1609
|
+
/** Clone a testcase in place (same project + module) with all steps, versions, testdata,
|
|
1610
|
+
* parameters, object refs, and nested function calls carried over. Backed by the SQA native
|
|
1611
|
+
* `/tm/tcfnCopy/copytc` endpoint (see test-management/src/controllers/tcfnCopy.controller.js).
|
|
1612
|
+
*
|
|
1613
|
+
* Requires the `copy_testcase_across_project` privilege on the current project — enforced
|
|
1614
|
+
* server-side regardless of whether the copy is in-project or across projects.
|
|
1615
|
+
*
|
|
1616
|
+
* When `newTitle` is omitted the backend auto-names the copy as `{sourceName}_copy1`,
|
|
1617
|
+
* `_copy2`, etc. When provided, a follow-up PUT renames the freshly-created TC to that name.
|
|
1618
|
+
*
|
|
1619
|
+
* IMPORTANT — the copy endpoint's `selversions` and `selectedEnv` fields are FILTERS, not
|
|
1620
|
+
* wildcards. Passing empty arrays makes the backend copy the TC shell only (no steps, no
|
|
1621
|
+
* testdata, since both live under versions and per-env iterations). So we fetch the source
|
|
1622
|
+
* TC's version names AND the project's environment names first and pass them explicitly. */
|
|
1623
|
+
async cloneTestCase(id, newTitle, versions) {
|
|
1624
|
+
assertBusinessKeyKind(id, 'TC');
|
|
1625
|
+
const projectId = this.requireProjectId();
|
|
1626
|
+
const src = await this.fetchRawTestcase(id);
|
|
1627
|
+
if (!src || Object.keys(src).length === 0)
|
|
1628
|
+
throw new Error(`test case ${id} not found`);
|
|
1629
|
+
const sourceId = Number.parseInt(numericId(id), 10);
|
|
1630
|
+
const sourceProjId = typeof src.projectId === 'number' ? src.projectId : projectId;
|
|
1631
|
+
const sourceModuleId = typeof src.moduleId === 'number' ? src.moduleId : 0;
|
|
1632
|
+
// Fetch source's version names — required, empty means no versions get copied.
|
|
1633
|
+
const versionsRaw = await this.http.getJson(`/tm/testcase/version/${sourceId}/${sourceProjId}`);
|
|
1634
|
+
const versionsArr = Array.isArray(versionsRaw) ? versionsRaw : (versionsRaw?.data ?? []);
|
|
1635
|
+
const availableNames = versionsArr
|
|
1636
|
+
.map((v) => str(v, 'name'))
|
|
1637
|
+
.filter((n) => !!n);
|
|
1638
|
+
if (availableNames.length === 0) {
|
|
1639
|
+
throw new Error(`Cannot clone ${id}: source has no versions to copy.`);
|
|
1640
|
+
}
|
|
1641
|
+
const versionNames = filterVersionNames(availableNames, versions, id);
|
|
1642
|
+
// Fetch project's environment names — empty means per-env testdata rows are dropped
|
|
1643
|
+
// (only the "general" fallback iteration survives).
|
|
1644
|
+
const envsRaw = await this.http.getJson(`/pm/fields/8?projectId=${sourceProjId}&fieldType=environment`);
|
|
1645
|
+
const envsArr = Array.isArray(envsRaw) ? envsRaw : (envsRaw?.data ?? []);
|
|
1646
|
+
const envNames = envsArr
|
|
1647
|
+
.map((e) => str(e, 'name'))
|
|
1648
|
+
.filter((n) => !!n);
|
|
1649
|
+
await this.http.postJson('/tm/tcfnCopy/copytc', {
|
|
1650
|
+
type: 'TC',
|
|
1651
|
+
tcfnList: [{ id: sourceId, selversions: versionNames }],
|
|
1652
|
+
sourceProjId,
|
|
1653
|
+
sourceModuleId,
|
|
1654
|
+
destinationProjId: sourceProjId,
|
|
1655
|
+
destinationModuleId: sourceModuleId,
|
|
1656
|
+
selectedEnv: envNames,
|
|
1657
|
+
});
|
|
1658
|
+
// Find the newly-created copy: highest-id TC whose `projectLevelcopiedFrom.id` matches. Not
|
|
1659
|
+
// going through `searchBody` because the SQA search protocol lets a searchField have EITHER a
|
|
1660
|
+
// `value` (filter) OR a `sort` (ordering) — the helper only builds value entries.
|
|
1661
|
+
const searchRaw = await this.http.postJson('/tm/testcase/search', {
|
|
1662
|
+
searchFields: [
|
|
1663
|
+
{ column: 'deleted', value: false },
|
|
1664
|
+
{ column: 'projectId', value: sourceProjId },
|
|
1665
|
+
{ column: 'projectLevelcopiedFrom.id', value: sourceId },
|
|
1666
|
+
{ column: 'id', sort: 'dsc' },
|
|
1667
|
+
],
|
|
1668
|
+
startIndex: 0,
|
|
1669
|
+
limit: 1,
|
|
1670
|
+
selectFields: ['id', 'code', 'name'],
|
|
1671
|
+
});
|
|
1672
|
+
const rows = pickArray(searchRaw, 'data', 'content', 'testCases');
|
|
1673
|
+
if (rows.length === 0) {
|
|
1674
|
+
throw new Error('Clone was requested but the newly-created testcase could not be located afterwards. ' +
|
|
1675
|
+
'The backend may still be processing the copy, or the caller may lack the ' +
|
|
1676
|
+
'`copy_testcase_across_project` privilege on this project.');
|
|
1677
|
+
}
|
|
1678
|
+
const created = rows[0];
|
|
1679
|
+
const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
|
|
1680
|
+
// Optional rename: PUT /tm/testcase/:id with { name, projectId }.
|
|
1681
|
+
if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
|
|
1682
|
+
await this.http.putJson(`/tm/testcase/${newIdNum}`, {
|
|
1683
|
+
name: newTitle.trim(),
|
|
1684
|
+
projectId: sourceProjId,
|
|
1685
|
+
});
|
|
1686
|
+
created.name = newTitle.trim();
|
|
1687
|
+
}
|
|
1688
|
+
return {
|
|
1689
|
+
id: String(newIdNum),
|
|
1690
|
+
created: true,
|
|
1691
|
+
productUrl: this.productUrlFor({ kind: 'testcase', id: String(newIdNum) }),
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
// ---------------------------------------------------------------------- Functions (reusable)
|
|
1695
|
+
// Same tcfnObj collection as testcases — type='FN' instead of 'TC'. Search / getById / delete /
|
|
1696
|
+
// steps all mirror the testcase endpoints one-to-one.
|
|
1697
|
+
async listFunctions(q) {
|
|
1698
|
+
const projectId = this.requireProjectId();
|
|
1699
|
+
const fields = [
|
|
1700
|
+
{ column: 'deleted', value: false },
|
|
1701
|
+
{ column: 'projectId', value: projectId },
|
|
1702
|
+
];
|
|
1703
|
+
if (q.search)
|
|
1704
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
1705
|
+
if (q.fnType)
|
|
1706
|
+
fields.push({ column: 'fnType', value: q.fnType });
|
|
1707
|
+
if (q.executionType)
|
|
1708
|
+
fields.push({ column: 'executionType', value: q.executionType });
|
|
1709
|
+
if (q.moduleId)
|
|
1710
|
+
fields.push({ column: 'moduleId', value: Number.parseInt(numericId(q.moduleId), 10) });
|
|
1711
|
+
const createdBy = this.resolveUserFilter(q.createdBy);
|
|
1712
|
+
if (createdBy != null)
|
|
1713
|
+
fields.push({ column: 'createdBy', value: createdBy });
|
|
1714
|
+
const updatedBy = this.resolveUserFilter(q.updatedBy);
|
|
1715
|
+
if (updatedBy != null)
|
|
1716
|
+
fields.push({ column: 'updatedBy', value: updatedBy });
|
|
1717
|
+
const body = this.searchBody(fields, {
|
|
1718
|
+
startIndex: q.offset ?? 0,
|
|
1719
|
+
limit: q.limit ?? 100,
|
|
1720
|
+
selectFields: ['id', 'code', 'name', 'description', 'fnType', 'executionType',
|
|
1721
|
+
'reviewApproveStatus', 'stepCount'],
|
|
1722
|
+
});
|
|
1723
|
+
const raw = await this.http.postJson('/tm/function/search', body);
|
|
1724
|
+
const rows = pickArray(raw, 'data', 'content', 'functions');
|
|
1725
|
+
return withTotal(rows.map(toFunctionSummary), raw);
|
|
1726
|
+
}
|
|
1727
|
+
async getFunction(id) {
|
|
1728
|
+
const projectId = this.requireProjectId();
|
|
1729
|
+
const fnNum = numericId(id);
|
|
1730
|
+
const raw = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
|
|
1731
|
+
let doc = raw?.data ?? raw;
|
|
1732
|
+
if (Array.isArray(doc))
|
|
1733
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
1734
|
+
if (!doc || typeof doc !== 'object' || Object.keys(doc).length === 0) {
|
|
1735
|
+
throw new Error(`function ${id} not found`);
|
|
1736
|
+
}
|
|
1737
|
+
const fn = toFunction(doc);
|
|
1738
|
+
// Fetch version rows ONCE — reused to (a) pick the newest for the steps call and (b) hydrate
|
|
1739
|
+
// `fn.versions` for the caller. The FN doc from `/tm/function/:id/:pid` has NO inline versions[]
|
|
1740
|
+
// (unlike TC's getById), so this sibling endpoint is the only source. Non-fatal on failure.
|
|
1741
|
+
let versionRows = [];
|
|
1742
|
+
try {
|
|
1743
|
+
versionRows = await this.fetchFunctionVersionRows(fnNum);
|
|
1744
|
+
}
|
|
1745
|
+
catch { /* leave empty */ }
|
|
1746
|
+
const currentVersionId = versionRows.length
|
|
1747
|
+
? str(versionRows[0], 'id', '_id', 'versionId')
|
|
1748
|
+
: undefined;
|
|
1749
|
+
if (versionRows.length)
|
|
1750
|
+
fn.versions = toVersionEntries(versionRows, currentVersionId);
|
|
1751
|
+
if (currentVersionId) {
|
|
1752
|
+
try {
|
|
1753
|
+
const rows = await this.fetchFunctionStepsForVersion(fnNum, currentVersionId);
|
|
1754
|
+
if (rows.length > 0)
|
|
1755
|
+
fn.steps = rows.map((s, i) => fromSqaStep(s, i + 1));
|
|
1756
|
+
}
|
|
1757
|
+
catch { /* leave steps empty */ }
|
|
1758
|
+
}
|
|
1759
|
+
return fn;
|
|
1760
|
+
}
|
|
1761
|
+
/** Steps for one FN version. Unlike TC docs, the FN doc from `/tm/function/:id/:projectId`
|
|
1762
|
+
* doesn't carry a versionId inline — callers resolve the target versionId (usually the newest,
|
|
1763
|
+
* from `fetchFunctionVersionRows`) and pass it here. */
|
|
1764
|
+
async fetchFunctionStepsForVersion(fnNum, versionId) {
|
|
1765
|
+
const projectId = this.requireProjectId();
|
|
1766
|
+
const path = '/tm/function/steps' +
|
|
1767
|
+
`?projectId=${projectId}` +
|
|
1768
|
+
`&tcfnId=${fnNum}` +
|
|
1769
|
+
`&versionId=${encodeURIComponent(versionId)}` +
|
|
1770
|
+
'&type=FN';
|
|
1771
|
+
const body = await this.http.getJson(path);
|
|
1772
|
+
return Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
|
|
1773
|
+
}
|
|
1774
|
+
/** The function's version rows, newest-first. */
|
|
1775
|
+
async fetchFunctionVersionRows(fnNum) {
|
|
1776
|
+
const projectId = this.requireProjectId();
|
|
1777
|
+
const raw = await this.http.getJson(`/tm/function/version/${fnNum}/${projectId}`);
|
|
1778
|
+
const rows = pickArray(raw, 'data', 'content', 'versions');
|
|
1779
|
+
if (!rows.length)
|
|
1780
|
+
return [];
|
|
1781
|
+
return [...rows].sort((a, b) => Number(b?.versionNumber ?? 0) - Number(a?.versionNumber ?? 0));
|
|
1782
|
+
}
|
|
1783
|
+
/** Soft-delete a function. `/tm/function/delete` returns HTTP 200 either way — its `data` is
|
|
1784
|
+
* `true` on real delete, or an array of dependency records when the FN is still referenced by a
|
|
1785
|
+
* TC / FN / workflow (nothing was deleted). Inspect the shape and throw so the tool surfaces it
|
|
1786
|
+
* as a failure instead of a fake success. */
|
|
1787
|
+
async deleteFunction(id) {
|
|
1788
|
+
const projectId = this.requireProjectId();
|
|
1789
|
+
const raw = await this.http.postJson('/tm/function/delete', {
|
|
1790
|
+
ids: [Number.parseInt(numericId(id), 10)],
|
|
1791
|
+
projectId,
|
|
1792
|
+
isDelete: true,
|
|
1793
|
+
});
|
|
1794
|
+
const data = raw?.data;
|
|
1795
|
+
if (data === true)
|
|
1796
|
+
return;
|
|
1797
|
+
const deps = Array.isArray(data) ? data[0] : undefined;
|
|
1798
|
+
const list = (m) => m && typeof m === 'object' ? Object.values(m).flat().map(String) : [];
|
|
1799
|
+
const tcs = list(deps?.tcArr);
|
|
1800
|
+
const fns = list(deps?.fnArr);
|
|
1801
|
+
const wfs = list(deps?.wfArr);
|
|
1802
|
+
const refs = [];
|
|
1803
|
+
if (tcs.length)
|
|
1804
|
+
refs.push(`test cases (${tcs.join(', ')})`);
|
|
1805
|
+
if (fns.length)
|
|
1806
|
+
refs.push(`functions (${fns.join(', ')})`);
|
|
1807
|
+
if (wfs.length)
|
|
1808
|
+
refs.push(`workflows (${wfs.join(', ')})`);
|
|
1809
|
+
throw new Error(refs.length
|
|
1810
|
+
? `Not deleted — function is still referenced by ${refs.join('; ')}. Remove those references first.`
|
|
1811
|
+
: 'Not deleted — the backend reported blocking dependencies.');
|
|
1812
|
+
}
|
|
1813
|
+
/** Copy a function in place — new tcfnObj row, all versions/steps/testdata carried over. Same
|
|
1814
|
+
* shape as `cloneTestCase`: version names + env names come from the source, then POST to
|
|
1815
|
+
* `/tm/tcfnCopy/copytc` with `type:'FN'` (backend enforces `copy_function_across_projects`).
|
|
1816
|
+
* When `newTitle` is omitted the backend auto-names as `{sourceName}_copy1`, etc.; when
|
|
1817
|
+
* provided a follow-up PUT `/tm/function/:id` renames the freshly-created FN. */
|
|
1818
|
+
async cloneFunction(id, newTitle, versions) {
|
|
1819
|
+
assertBusinessKeyKind(id, 'FN');
|
|
1820
|
+
const projectId = this.requireProjectId();
|
|
1821
|
+
const fnNum = numericId(id);
|
|
1822
|
+
const rawFn = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
|
|
1823
|
+
let src = rawFn?.data ?? rawFn;
|
|
1824
|
+
if (Array.isArray(src))
|
|
1825
|
+
src = src.length > 0 ? src[0] : {};
|
|
1826
|
+
if (!src || typeof src !== 'object' || Object.keys(src).length === 0) {
|
|
1827
|
+
throw new Error(`function ${id} not found`);
|
|
1828
|
+
}
|
|
1829
|
+
const sourceId = Number.parseInt(fnNum, 10);
|
|
1830
|
+
const sourceProjId = typeof src.projectId === 'number' ? src.projectId : projectId;
|
|
1831
|
+
const sourceModuleId = typeof src.moduleId === 'number' ? src.moduleId : 0;
|
|
1832
|
+
// Fetch source's version names — required, empty means no versions get copied.
|
|
1833
|
+
const versionsRaw = await this.http.getJson(`/tm/function/version/${sourceId}/${sourceProjId}`);
|
|
1834
|
+
const versionsArr = Array.isArray(versionsRaw) ? versionsRaw : (versionsRaw?.data ?? []);
|
|
1835
|
+
const availableNames = versionsArr
|
|
1836
|
+
.map((v) => str(v, 'name'))
|
|
1837
|
+
.filter((n) => !!n);
|
|
1838
|
+
if (availableNames.length === 0) {
|
|
1839
|
+
throw new Error(`Cannot clone ${id}: source has no versions to copy.`);
|
|
1840
|
+
}
|
|
1841
|
+
const versionNames = filterVersionNames(availableNames, versions, id);
|
|
1842
|
+
// Same environment fetch as cloneTestCase — empty means per-env testdata rows are dropped.
|
|
1843
|
+
const envsRaw = await this.http.getJson(`/pm/fields/8?projectId=${sourceProjId}&fieldType=environment`);
|
|
1844
|
+
const envsArr = Array.isArray(envsRaw) ? envsRaw : (envsRaw?.data ?? []);
|
|
1845
|
+
const envNames = envsArr
|
|
1846
|
+
.map((e) => str(e, 'name'))
|
|
1847
|
+
.filter((n) => !!n);
|
|
1848
|
+
await this.http.postJson('/tm/tcfnCopy/copytc', {
|
|
1849
|
+
type: 'FN',
|
|
1850
|
+
tcfnList: [{ id: sourceId, selversions: versionNames }],
|
|
1851
|
+
sourceProjId,
|
|
1852
|
+
sourceModuleId,
|
|
1853
|
+
destinationProjId: sourceProjId,
|
|
1854
|
+
destinationModuleId: sourceModuleId,
|
|
1855
|
+
selectedEnv: envNames,
|
|
1856
|
+
});
|
|
1857
|
+
// Find the newly-created copy: highest-id FN whose `projectLevelcopiedFrom.id` matches source.
|
|
1858
|
+
const searchRaw = await this.http.postJson('/tm/function/search', {
|
|
1859
|
+
searchFields: [
|
|
1860
|
+
{ column: 'deleted', value: false },
|
|
1861
|
+
{ column: 'projectId', value: sourceProjId },
|
|
1862
|
+
{ column: 'projectLevelcopiedFrom.id', value: sourceId },
|
|
1863
|
+
{ column: 'id', sort: 'dsc' },
|
|
1864
|
+
],
|
|
1865
|
+
startIndex: 0,
|
|
1866
|
+
limit: 1,
|
|
1867
|
+
selectFields: ['id', 'code', 'name'],
|
|
1868
|
+
});
|
|
1869
|
+
const rows = pickArray(searchRaw, 'data', 'content', 'functions');
|
|
1870
|
+
if (rows.length === 0) {
|
|
1871
|
+
throw new Error('Clone was requested but the newly-created function could not be located afterwards. ' +
|
|
1872
|
+
'The backend may still be processing the copy, or the caller may lack the ' +
|
|
1873
|
+
'`copy_function_across_projects` privilege on this project.');
|
|
1874
|
+
}
|
|
1875
|
+
const created = rows[0];
|
|
1876
|
+
const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
|
|
1877
|
+
// Optional rename: PUT /tm/function/:id — inline validator accepts { name } only (no projectId).
|
|
1878
|
+
if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
|
|
1879
|
+
await this.http.putJson(`/tm/function/${newIdNum}`, {
|
|
1880
|
+
name: newTitle.trim(),
|
|
1881
|
+
});
|
|
1882
|
+
created.name = newTitle.trim();
|
|
1883
|
+
}
|
|
1884
|
+
return {
|
|
1885
|
+
id: String(newIdNum),
|
|
1886
|
+
created: true,
|
|
1887
|
+
productUrl: this.productUrlFor({ kind: 'function', id: String(newIdNum) }),
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
/** Snapshot the CURRENT server state of a function under a NEW version name — same FN id, new
|
|
1891
|
+
* `tcfn_version` row with cloned steps. Mirrors `saveAsNewVersion` (see there for the shape
|
|
1892
|
+
* rationale) with `type:'FN'` and the multipart route `PUT /tm/function` / key `functionObj`.
|
|
1893
|
+
* Source-version resolution goes through the FN's `/tm/function/version/:id/:pid` list — the
|
|
1894
|
+
* FN doc has no inline versionId. */
|
|
1895
|
+
async saveAsNewFunctionVersion(id, opts) {
|
|
1896
|
+
assertBusinessKeyKind(id, 'FN');
|
|
1897
|
+
const projectId = this.requireProjectId();
|
|
1898
|
+
const versionName = opts.versionName?.trim();
|
|
1899
|
+
if (!versionName)
|
|
1900
|
+
throw new Error('versionName is required.');
|
|
1901
|
+
const fnNum = numericId(id);
|
|
1902
|
+
const rawFn = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
|
|
1903
|
+
let rawTc = rawFn?.data ?? rawFn;
|
|
1904
|
+
if (Array.isArray(rawTc))
|
|
1905
|
+
rawTc = rawTc.length > 0 ? rawTc[0] : {};
|
|
1906
|
+
if (!rawTc || typeof rawTc !== 'object' || Object.keys(rawTc).length === 0) {
|
|
1907
|
+
throw new Error(`function ${id} not found`);
|
|
1908
|
+
}
|
|
1909
|
+
// FN doc has no inline versionId — resolve from the version list. Caller override wins.
|
|
1910
|
+
let sourceVersionId = opts.sourceVersionId;
|
|
1911
|
+
if (!sourceVersionId) {
|
|
1912
|
+
const versionRows = await this.fetchFunctionVersionRows(fnNum);
|
|
1913
|
+
if (versionRows.length > 0)
|
|
1914
|
+
sourceVersionId = str(versionRows[0], 'id', '_id', 'versionId');
|
|
1915
|
+
}
|
|
1916
|
+
// Fetch steps under the resolved (or newest) version.
|
|
1917
|
+
const rawSteps = sourceVersionId
|
|
1918
|
+
? await this.http.getJson('/tm/function/steps' +
|
|
1919
|
+
`?projectId=${projectId}` +
|
|
1920
|
+
`&tcfnId=${fnNum}` +
|
|
1921
|
+
`&versionId=${encodeURIComponent(sourceVersionId)}` +
|
|
1922
|
+
'&type=FN').then((body) => (Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : []))
|
|
1923
|
+
: [];
|
|
1924
|
+
const automationSteps = [];
|
|
1925
|
+
const manualSteps = [];
|
|
1926
|
+
for (const s of rawSteps) {
|
|
1927
|
+
const step = pick(s, STEP_ALLOWED_KEYS);
|
|
1928
|
+
const et = typeof step['executionType'] === 'string' ? String(step['executionType']).toUpperCase() : '';
|
|
1929
|
+
if (et === 'MANUAL')
|
|
1930
|
+
manualSteps.push(step);
|
|
1931
|
+
else
|
|
1932
|
+
automationSteps.push(step);
|
|
1933
|
+
}
|
|
1934
|
+
const tcfnObj = pick(rawTc, TCFNOBJ_FN_ALLOWED_KEYS);
|
|
1935
|
+
tcfnObj['deleted'] = false;
|
|
1936
|
+
tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
|
|
1937
|
+
// FN update joi REQUIRES `fnType` and REJECTS `obsolete`. Guarantee fnType from the raw doc
|
|
1938
|
+
// (falls through to legacy `tcType` if a pre-migration FN row is missing fnType); never set
|
|
1939
|
+
// obsolete.
|
|
1940
|
+
if (typeof tcfnObj['fnType'] !== 'string' || !tcfnObj['fnType']) {
|
|
1941
|
+
const fallback = str(rawTc, 'fnType', 'tcType') ?? 'WEB';
|
|
1942
|
+
tcfnObj['fnType'] = fallback;
|
|
1943
|
+
}
|
|
1944
|
+
// Version stub mirrors function.component.ts:900-907 — empty id tells the backend to create a
|
|
1945
|
+
// fresh row; SYNCOFF is normalised to '' the same way as the TC path. FN update joi REJECTS
|
|
1946
|
+
// `functionProjectIds` (allowed on TC's versionsDetails but not FN's — see functionValidator.js
|
|
1947
|
+
// updateFnBodyValidation.versionsDetails), so don't include it.
|
|
1948
|
+
const prevSync = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
|
|
1949
|
+
? str(rawTc.versionsDetails, 'syncONOFF')
|
|
1950
|
+
: undefined;
|
|
1951
|
+
const versionsDetails = {
|
|
1952
|
+
name: versionName,
|
|
1953
|
+
id: '',
|
|
1954
|
+
syncONOFF: prevSync === 'SYNCOFF' ? '' : (prevSync ?? ''),
|
|
1955
|
+
deleted: false,
|
|
1956
|
+
executionType: str(rawTc, 'executionType') ?? 'AUTOMATION',
|
|
1957
|
+
reviewApproveStatus: '',
|
|
1958
|
+
};
|
|
1959
|
+
const verData = {
|
|
1960
|
+
tdCopy: !!opts.tdCopy,
|
|
1961
|
+
...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
|
|
1962
|
+
};
|
|
1963
|
+
const payload = {
|
|
1964
|
+
tcfnObj,
|
|
1965
|
+
steps: { automationSteps, manualSteps },
|
|
1966
|
+
versionsDetails,
|
|
1967
|
+
operation: 'SAVENEWVERSION',
|
|
1968
|
+
type: 'FN',
|
|
1969
|
+
verData,
|
|
1970
|
+
};
|
|
1971
|
+
const raw = await this.http.putMultipart('/tm/function', 'functionObj', payload);
|
|
1972
|
+
const flat = raw?.data ?? raw ?? {};
|
|
1973
|
+
const savedId = str(flat, 'id', '_id', 'functionId', 'testcaseId') ?? fnNum;
|
|
1974
|
+
const newVersionId = str(flat, 'versionId') ??
|
|
1975
|
+
(flat?.versionsDetails && typeof flat.versionsDetails === 'object'
|
|
1976
|
+
? str(flat.versionsDetails, 'id')
|
|
1977
|
+
: undefined);
|
|
1978
|
+
return {
|
|
1979
|
+
id: savedId,
|
|
1980
|
+
created: false,
|
|
1981
|
+
productUrl: this.productUrlFor({ kind: 'function', id: savedId }),
|
|
1982
|
+
...(newVersionId ? { artifacts: [`versionId=${newVersionId}`] } : {}),
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
/** Never throw on unresolvable storyRef — the Java code logs and moves on so the save still lands. */
|
|
1986
|
+
async resolveStoryNumericIdOrNull(storyRef) {
|
|
1987
|
+
try {
|
|
1988
|
+
const s = await this.resolveStoryNumericId(storyRef);
|
|
1989
|
+
const n = Number.parseInt(s, 10);
|
|
1990
|
+
return Number.isFinite(n) ? n : undefined;
|
|
1991
|
+
}
|
|
1992
|
+
catch {
|
|
1993
|
+
return undefined;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
/** Resolve every param name referenced by the TC to a SQA parameter id via get-or-create.
|
|
1997
|
+
* Sources: (a) `${name}` tokens in every step's description (manual + automation), and
|
|
1998
|
+
* (b) keys of every automation step's `recordedParam` (action-argument names like
|
|
1999
|
+
* "packageName", "activity", "keys"). Name-uniqueness is project-wide on the backend, so
|
|
2000
|
+
* get-or-create means every TC using `${appUrl}` (or the same "packageName" recorded param)
|
|
2001
|
+
* links to the same parameter row. */
|
|
2002
|
+
async resolveParamIds(projectId, moduleId, tc) {
|
|
2003
|
+
const paramIdByName = new Map();
|
|
2004
|
+
const steps = Array.isArray(tc.steps) ? tc.steps : [];
|
|
2005
|
+
for (const s of steps) {
|
|
2006
|
+
for (const name of extractParamNames(s.description)) {
|
|
2007
|
+
if (paramIdByName.has(name))
|
|
2008
|
+
continue;
|
|
2009
|
+
const defaultValue = paramDefault(name, tc.data, s.value);
|
|
2010
|
+
const id = await this.getOrCreateParameter(projectId, moduleId, name, defaultValue);
|
|
2011
|
+
paramIdByName.set(name, id);
|
|
2012
|
+
}
|
|
2013
|
+
if (s.recordedParam) {
|
|
2014
|
+
for (const [name, value] of Object.entries(s.recordedParam)) {
|
|
2015
|
+
if (paramIdByName.has(name))
|
|
2016
|
+
continue;
|
|
2017
|
+
const id = await this.getOrCreateParameter(projectId, moduleId, name, String(value ?? ''));
|
|
2018
|
+
paramIdByName.set(name, id);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return paramIdByName;
|
|
2023
|
+
}
|
|
2024
|
+
actionCatalog() {
|
|
2025
|
+
if (this.actionCatalogCache)
|
|
2026
|
+
return this.actionCatalogCache;
|
|
2027
|
+
const projectId = this.requireProjectId();
|
|
2028
|
+
this.actionCatalogCache = (async () => {
|
|
2029
|
+
// `projectId in [0, projectId]` — 0 is the customer-wide default catalog, projectId is the
|
|
2030
|
+
// per-project overrides layered on top. Same filter the SQA UI uses (steps.model.ts:59).
|
|
2031
|
+
const body = {
|
|
2032
|
+
searchFields: [
|
|
2033
|
+
{ column: 'deleted', value: false },
|
|
2034
|
+
{ column: 'projectId', value: { $in: [0, projectId] } },
|
|
2035
|
+
],
|
|
2036
|
+
startIndex: 0,
|
|
2037
|
+
limit: 100000,
|
|
2038
|
+
selectFields: ['id', 'name', 'technology', 'objectTemplate', 'objectRequired'],
|
|
2039
|
+
};
|
|
2040
|
+
const raw = await this.http.postJson('/tm/action/search', body);
|
|
2041
|
+
const rows = pickArray(raw, 'data', 'content', 'actions');
|
|
2042
|
+
const map = new Map();
|
|
2043
|
+
for (const row of rows) {
|
|
2044
|
+
const idStr = str(row, 'id', '_id', 'actionId');
|
|
2045
|
+
const name = str(row, 'name');
|
|
2046
|
+
const tech = str(row, 'technology');
|
|
2047
|
+
if (!idStr || !name || !tech)
|
|
2048
|
+
continue;
|
|
2049
|
+
const id = Number.parseInt(idStr, 10);
|
|
2050
|
+
if (!Number.isFinite(id))
|
|
2051
|
+
continue;
|
|
2052
|
+
const objectRequired = row?.objectRequired === true;
|
|
2053
|
+
const otRaw = row?.objectTemplate;
|
|
2054
|
+
const objectTemplate = typeof otRaw === 'number' ? otRaw
|
|
2055
|
+
: typeof otRaw === 'string' && /^\d+$/.test(otRaw) ? Number.parseInt(otRaw, 10)
|
|
2056
|
+
: undefined;
|
|
2057
|
+
// Prefer the higher-priority project override when the same (tech, name) has both a
|
|
2058
|
+
// default and a project row: last write wins if we sort defaults first. Backend returns
|
|
2059
|
+
// whichever ordering; we resolve determinism by preferring a non-zero projectId row.
|
|
2060
|
+
const key = `${tech.toUpperCase()}::${name.toLowerCase()}`;
|
|
2061
|
+
const prev = map.get(key);
|
|
2062
|
+
const isProjectOverride = typeof row?.projectId === 'number' && row.projectId !== 0;
|
|
2063
|
+
if (!prev || isProjectOverride)
|
|
2064
|
+
map.set(key, { id, objectRequired, objectTemplate });
|
|
2065
|
+
}
|
|
2066
|
+
return map;
|
|
2067
|
+
})().catch((e) => { this.actionCatalogCache = null; throw e; });
|
|
2068
|
+
return this.actionCatalogCache;
|
|
2069
|
+
}
|
|
2070
|
+
/** POST /tm/object/createObjectSeq — get-or-create the shared object row by locator match. The
|
|
2071
|
+
* backend does the dedup (`object.controller.js:268-370`) — same-locators reuses, same-disname-
|
|
2072
|
+
* different-locators creates a suffixed one. Returns the numeric object id.
|
|
2073
|
+
*
|
|
2074
|
+
* ponytail: minimum body — no thumbnail_url / pageScreenShot_url / parameterId. The recording
|
|
2075
|
+
* path enriches these; the tc_save path doesn't have screenshots to attach, and the object
|
|
2076
|
+
* works for execution without them. */
|
|
2077
|
+
async resolveObjectId(objectRef, projectId, moduleId, technology, objectTemplateId) {
|
|
2078
|
+
const attributes = (objectRef.locators ?? []).map((l) => ({
|
|
2079
|
+
name: l.name,
|
|
2080
|
+
value: l.value,
|
|
2081
|
+
...(l.unique === true ? { unique: true } : {}),
|
|
2082
|
+
}));
|
|
2083
|
+
const body = {
|
|
2084
|
+
projectId,
|
|
2085
|
+
moduleId,
|
|
2086
|
+
objecttemplateId: objectTemplateId,
|
|
2087
|
+
technology,
|
|
2088
|
+
disname: objectRef.name ?? '',
|
|
2089
|
+
attributes,
|
|
2090
|
+
parameterId: [],
|
|
2091
|
+
description: '',
|
|
2092
|
+
};
|
|
2093
|
+
const raw = await this.http.postJson('/tm/object/createObjectSeq', body);
|
|
2094
|
+
const flat = raw?.data ?? raw;
|
|
2095
|
+
const id = str(flat, 'id', '_id', 'objectId');
|
|
2096
|
+
if (!id)
|
|
2097
|
+
throw new Error(`createObjectSeq returned no id for object "${objectRef.name}": ${JSON.stringify(raw)}`);
|
|
2098
|
+
return Number.parseInt(id, 10);
|
|
2099
|
+
}
|
|
2100
|
+
/** For every automation step, resolve `actionId` (from action name + technology) and
|
|
2101
|
+
* `objectId` (via createObjectSeq get-or-create) so the mapper can emit a valid step. Runs
|
|
2102
|
+
* once per save, aligned by step index. */
|
|
2103
|
+
async resolveAutomationSteps(projectId, moduleId, tc) {
|
|
2104
|
+
const catalog = await this.actionCatalog();
|
|
2105
|
+
const steps = Array.isArray(tc.steps) ? tc.steps : [];
|
|
2106
|
+
const out = [];
|
|
2107
|
+
for (let i = 0; i < steps.length; i++) {
|
|
2108
|
+
const s = steps[i];
|
|
2109
|
+
const tech = (TECH_BY_STEPTYPE[s.stepType] ?? deriveTcType(tc)).toUpperCase();
|
|
2110
|
+
const name = (s.actionType ?? '').trim();
|
|
2111
|
+
if (!name) {
|
|
2112
|
+
throw new Error(`Automation step ${i + 1} ("${s.description}"): missing actionType — the neutral Step.actionType must name an SQA action (e.g. "Click", "Type Text", "Launch Application").`);
|
|
2113
|
+
}
|
|
2114
|
+
const meta = catalog.get(`${tech}::${name.toLowerCase()}`);
|
|
2115
|
+
if (!meta) {
|
|
2116
|
+
throw new Error(`Automation step ${i + 1}: no SQA action named "${name}" for technology ${tech}. Check the action catalog in the SQA UI.`);
|
|
2117
|
+
}
|
|
2118
|
+
let objectId = null;
|
|
2119
|
+
if (meta.objectRequired) {
|
|
2120
|
+
if (!s.objectRef || !s.objectRef.name || !(s.objectRef.locators?.length)) {
|
|
2121
|
+
throw new Error(`Automation step ${i + 1} ("${name}"): action requires an object but Step.objectRef is missing name/locators. Provide objectRef:{name, locators:[{name, value}]}.`);
|
|
2122
|
+
}
|
|
2123
|
+
if (meta.objectTemplate == null) {
|
|
2124
|
+
throw new Error(`Automation step ${i + 1} ("${name}"): action ${meta.id} has no objectTemplate — cannot create/reuse an object without knowing which template it belongs to.`);
|
|
2125
|
+
}
|
|
2126
|
+
objectId = await this.resolveObjectId(s.objectRef, projectId, moduleId, tech, meta.objectTemplate);
|
|
2127
|
+
}
|
|
2128
|
+
out.push({ actionId: meta.id, objectId });
|
|
2129
|
+
}
|
|
2130
|
+
return out;
|
|
2131
|
+
}
|
|
2132
|
+
/** Case-insensitive project-scoped exact-name lookup. Backend duplicate-name check uses Mongo
|
|
2133
|
+
* collation strength:2, so a case-sensitive search here misses params stored under different
|
|
2134
|
+
* casing — the next POST then trips "Duplicate Name Error". Returns null when not found. */
|
|
2135
|
+
async findParameterId(projectId, name) {
|
|
2136
|
+
const body = this.searchBody([
|
|
2137
|
+
{ column: 'name', value: name },
|
|
2138
|
+
{ column: 'projectId', value: projectId },
|
|
2139
|
+
], { startIndex: 0, limit: 10 });
|
|
2140
|
+
// The `searchFields` shape supports `ignorecase:true` per-field; searchBody doesn't emit it,
|
|
2141
|
+
// so add it inline for the name column.
|
|
2142
|
+
body['searchFields'][0]['ignorecase'] = true;
|
|
2143
|
+
const raw = await this.http.postJson('/tm/parameter/search', body);
|
|
2144
|
+
const rows = pickArray(raw, 'data', 'content');
|
|
2145
|
+
for (const row of rows) {
|
|
2146
|
+
const rowName = str(row, 'name');
|
|
2147
|
+
if (rowName === name) {
|
|
2148
|
+
const id = str(row, 'id', '_id', 'parameterId');
|
|
2149
|
+
if (id)
|
|
2150
|
+
return Number.parseInt(id, 10);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
return null;
|
|
2154
|
+
}
|
|
2155
|
+
async getOrCreateParameter(projectId, moduleId, name, defaultValue) {
|
|
2156
|
+
const existing = await this.findParameterId(projectId, name);
|
|
2157
|
+
if (existing != null)
|
|
2158
|
+
return existing;
|
|
2159
|
+
const body = {
|
|
2160
|
+
name,
|
|
2161
|
+
projectId,
|
|
2162
|
+
protected: looksSensitive(name),
|
|
2163
|
+
...(moduleId > 0 ? { moduleId } : {}),
|
|
2164
|
+
defaultValue,
|
|
2165
|
+
// Match the SQA UI's Add-Parameter defaults so API-created params render identically. Without
|
|
2166
|
+
// these the UI shows "Parameter Type*" empty and refuses to save edits.
|
|
2167
|
+
type: 'Local',
|
|
2168
|
+
parameterType: 'Alphanumeric',
|
|
2169
|
+
description: '',
|
|
2170
|
+
unique: false,
|
|
2171
|
+
concat: '',
|
|
2172
|
+
paramAttributes: { minLength: 0, maxLength: 0, prefix: '', suffix: '' },
|
|
2173
|
+
};
|
|
2174
|
+
let raw;
|
|
2175
|
+
try {
|
|
2176
|
+
raw = await this.http.postJson('/tm/parameter', body);
|
|
2177
|
+
}
|
|
2178
|
+
catch (e) {
|
|
2179
|
+
// Backend rejects a race-lost duplicate with 500 "Duplicate Name" when the concurrent search
|
|
2180
|
+
// missed the just-created row. Recover by re-querying — same name, project-scoped.
|
|
2181
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2182
|
+
if (/Duplicate Name|BADREQUEST/i.test(msg)) {
|
|
2183
|
+
const after = await this.findParameterId(projectId, name);
|
|
2184
|
+
if (after != null)
|
|
2185
|
+
return after;
|
|
2186
|
+
}
|
|
2187
|
+
throw e;
|
|
2188
|
+
}
|
|
2189
|
+
// The id lives at various nesting levels depending on which path the backend took (bare doc,
|
|
2190
|
+
// `data`, or `message[0]` for a race-lost duplicate). Try all three.
|
|
2191
|
+
let id = str(raw, 'id', '_id', 'parameterId') ?? str(raw?.data, 'id', '_id', 'parameterId');
|
|
2192
|
+
if (!id) {
|
|
2193
|
+
const m = raw?.message;
|
|
2194
|
+
if (Array.isArray(m) && m.length > 0)
|
|
2195
|
+
id = str(m[0], 'id', '_id', 'parameterId');
|
|
2196
|
+
else if (m && typeof m === 'object')
|
|
2197
|
+
id = str(m, 'id', '_id', 'parameterId');
|
|
2198
|
+
}
|
|
2199
|
+
if (!id)
|
|
2200
|
+
throw new Error(`getOrCreateParameter: no id in response: ${JSON.stringify(raw)}`);
|
|
2201
|
+
return Number.parseInt(id, 10);
|
|
2202
|
+
}
|
|
2203
|
+
// ---------------------------------------------------------------------- Suites (write) [Step 4]
|
|
2204
|
+
async createSuite(s) {
|
|
2205
|
+
// Java stubs this out ("create_suite is not supported yet on SimplifyQA"). Mirror that.
|
|
2206
|
+
if (this.hop())
|
|
2207
|
+
return this.requireHop('create_suite').createSuite(s);
|
|
2208
|
+
throw new Error('create_suite is not supported yet on SimplifyQA — create the suite in the SimplifyQA UI, ' +
|
|
2209
|
+
'then add test cases via suite_add_testcases.');
|
|
2210
|
+
}
|
|
2211
|
+
/** Fetch raw suite, apply add/remove/reorder ops in memory, then PUT the whole doc back with a
|
|
2212
|
+
* STRICT allow-listed payload (the SQA update validator rejects unknown keys on both suite-level
|
|
2213
|
+
* and testCases[] entries). */
|
|
2214
|
+
async updateSuiteMembers(id, ops) {
|
|
2215
|
+
if (!Array.isArray(ops))
|
|
2216
|
+
throw new Error('ops must be an array');
|
|
2217
|
+
const raw = await this.fetchRawSuite(id);
|
|
2218
|
+
if (Object.keys(raw).length === 0)
|
|
2219
|
+
throw new Error(`suite ${id} not found`);
|
|
2220
|
+
const currentRows = Array.isArray(raw['testCases']) ? raw['testCases'] : [];
|
|
2221
|
+
const members = currentRows.map(normalizeSuiteEntry);
|
|
2222
|
+
for (const op of ops) {
|
|
2223
|
+
const tcNum = numericId(String(op.tcId));
|
|
2224
|
+
switch (op.op) {
|
|
2225
|
+
case 'add': {
|
|
2226
|
+
const already = members.some((m) => String(m['testcaseId']) === tcNum);
|
|
2227
|
+
if (!already)
|
|
2228
|
+
members.push(await this.buildSuiteEntryFromTestcase(tcNum));
|
|
2229
|
+
break;
|
|
2230
|
+
}
|
|
2231
|
+
case 'remove': {
|
|
2232
|
+
for (let i = members.length - 1; i >= 0; i--) {
|
|
2233
|
+
if (String(members[i]['testcaseId']) === tcNum)
|
|
2234
|
+
members.splice(i, 1);
|
|
2235
|
+
}
|
|
2236
|
+
break;
|
|
2237
|
+
}
|
|
2238
|
+
case 'reorder': {
|
|
2239
|
+
const newOrder = op.order ?? -1;
|
|
2240
|
+
const idx = members.findIndex((m) => String(m['testcaseId']) === tcNum);
|
|
2241
|
+
if (idx < 0 || newOrder < 1 || newOrder > members.length)
|
|
2242
|
+
break;
|
|
2243
|
+
const [moved] = members.splice(idx, 1);
|
|
2244
|
+
if (moved)
|
|
2245
|
+
members.splice(newOrder - 1, 0, moved);
|
|
2246
|
+
break;
|
|
2247
|
+
}
|
|
2248
|
+
default: throw new Error(`suite op '${op.op}' not supported (add|remove|reorder)`);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
// Renumber `seq` contiguously from 1 after any mutation.
|
|
2252
|
+
members.forEach((m, i) => { m['seq'] = i + 1; });
|
|
2253
|
+
const payload = {};
|
|
2254
|
+
for (const k of SUITE_UPDATE_ALLOWED_KEYS) {
|
|
2255
|
+
if (raw[k] !== undefined)
|
|
2256
|
+
payload[k] = raw[k];
|
|
2257
|
+
}
|
|
2258
|
+
payload['tcChanged'] = true;
|
|
2259
|
+
payload['testCases'] = members;
|
|
2260
|
+
await this.http.putMultipart(`/tm/suites/${numericId(id)}`, 'suiteObj', payload);
|
|
2261
|
+
return this.getSuite(id);
|
|
2262
|
+
}
|
|
2263
|
+
/** Soft-delete via `isDelete:true`. `isDelete:false` restores. */
|
|
2264
|
+
async deleteSuite(id) {
|
|
2265
|
+
const projectId = this.requireProjectId();
|
|
2266
|
+
await this.http.patchJson('/tm/suites', {
|
|
2267
|
+
ids: [Number.parseInt(numericId(id), 10)],
|
|
2268
|
+
projectId,
|
|
2269
|
+
isDelete: true,
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
async fetchRawSuite(id) {
|
|
2273
|
+
const raw = await this.http.getJson(`/tm/suites/${numericId(id)}`);
|
|
2274
|
+
let doc = raw?.data ?? raw;
|
|
2275
|
+
if (Array.isArray(doc))
|
|
2276
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
2277
|
+
return doc && typeof doc === 'object' ? doc : {};
|
|
2278
|
+
}
|
|
2279
|
+
/** Assemble the strict allow-listed suite entry for an ADD op. Required fields per the SQA
|
|
2280
|
+
* validator: testcaseId, code, seq (set by the caller), version, versionId, tcType. */
|
|
2281
|
+
async buildSuiteEntryFromTestcase(tcNum) {
|
|
2282
|
+
const raw = await this.fetchRawTestcase(tcNum);
|
|
2283
|
+
if (Object.keys(raw).length === 0)
|
|
2284
|
+
throw new Error(`test case ${tcNum} not found (cannot add to suite)`);
|
|
2285
|
+
const code = str(raw, 'code', 'tcCode') ?? `TC_${tcNum}`;
|
|
2286
|
+
const tcType = str(raw, 'tcType') ?? 'WEB';
|
|
2287
|
+
let versionName = str(raw, 'versionName', 'selectedVersionName');
|
|
2288
|
+
let versionId = str(raw, 'versionId', 'selectedVersionId');
|
|
2289
|
+
if ((!versionName || !versionId) && Array.isArray(raw?.versions) && raw.versions.length > 0) {
|
|
2290
|
+
const v = raw.versions[0];
|
|
2291
|
+
versionName ?? (versionName = str(v, 'name'));
|
|
2292
|
+
versionId ?? (versionId = str(v, 'id', '_id'));
|
|
2293
|
+
}
|
|
2294
|
+
return {
|
|
2295
|
+
testcaseId: Number.parseInt(tcNum, 10),
|
|
2296
|
+
code,
|
|
2297
|
+
tcType,
|
|
2298
|
+
...(raw?.projectId !== undefined ? { projectId: raw.projectId } : {}),
|
|
2299
|
+
version: versionName ?? 'v1.0',
|
|
2300
|
+
versionId: versionId ?? '',
|
|
2301
|
+
executionPlatform: { LOCAL: {}, REMOTE: {}, CLOUD: {} },
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
async resolveObjectTemplateId(technology) {
|
|
2305
|
+
if (!this.objectTemplateCache) {
|
|
2306
|
+
this.objectTemplateCache = (async () => {
|
|
2307
|
+
const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
|
|
2308
|
+
const raw = await this.http.postJson('/tm/objectTemplate/search', body);
|
|
2309
|
+
const rows = pickArray(raw, 'data', 'content', 'objectTemplates');
|
|
2310
|
+
const map = new Map();
|
|
2311
|
+
for (const r of rows) {
|
|
2312
|
+
const name = str(r, 'name');
|
|
2313
|
+
const id = str(r, 'id', '_id');
|
|
2314
|
+
if (name && id)
|
|
2315
|
+
map.set(name.toUpperCase(), Number.parseInt(id, 10));
|
|
2316
|
+
}
|
|
2317
|
+
return map;
|
|
2318
|
+
})().catch((e) => {
|
|
2319
|
+
this.objectTemplateCache = null; // failure isn't memoised — a retry actually retries
|
|
2320
|
+
throw e;
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
const map = await this.objectTemplateCache;
|
|
2324
|
+
const id = map.get(technology.toUpperCase());
|
|
2325
|
+
if (id == null) {
|
|
2326
|
+
throw new Error(`Unknown object type/technology "${technology}"` +
|
|
2327
|
+
(map.size ? ` — known: ${[...map.keys()].join(', ')}.` : ' — no object templates found in this project.'));
|
|
2328
|
+
}
|
|
2329
|
+
return id;
|
|
2330
|
+
}
|
|
2331
|
+
/** `POST /tm/object/search` — objects visible in the current project. `q.query` free-text searches
|
|
2332
|
+
* `disname` — the UI's "Name" column (confirmed against the real sqa-ui Object Repository grid;
|
|
2333
|
+
* `description` is a separate, optional field and is NOT what users mean by an object's name). */
|
|
2334
|
+
async searchObjects(q) {
|
|
2335
|
+
const projectId = this.requireProjectId();
|
|
2336
|
+
const fields = [
|
|
2337
|
+
{ column: 'deleted', value: false },
|
|
2338
|
+
{ column: 'projectId', value: projectId },
|
|
2339
|
+
];
|
|
2340
|
+
if (q.query)
|
|
2341
|
+
fields.push({ column: 'disname', value: q.query, regEx: true });
|
|
2342
|
+
const body = this.searchBody(fields, { startIndex: q.offset ?? 0, limit: q.limit ?? 100 });
|
|
2343
|
+
const raw = await this.http.postJson('/tm/object/search', body);
|
|
2344
|
+
const rows = pickArray(raw, 'data', 'content', 'objects');
|
|
2345
|
+
return rows.map(toObject);
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Create a new object — `POST /tm/object`, plain JSON (NOT multipart, unlike testcase/suite writes;
|
|
2349
|
+
* confirmed against the real sqa-ui ObjectService.saveNewObject). `o.objectType` is REQUIRED: it
|
|
2350
|
+
* resolves to the object's `objecttemplateId` — structural, and (per updateObject's contract) not
|
|
2351
|
+
* editable after creation. `moduleId` auto-resolves to the project's "Default Module" (matching the
|
|
2352
|
+
* real UI's `object.component.ts` `openForm()`, which pre-selects it for every new object) — best
|
|
2353
|
+
* effort, never blocks creation if no such module exists or the lookup fails.
|
|
2354
|
+
*/
|
|
2355
|
+
async createObject(o) {
|
|
2356
|
+
if (!o.objectType) {
|
|
2357
|
+
throw new Error('createObject: objectType is required (e.g. "WEB", "ANDROID") — it selects the object template ' +
|
|
2358
|
+
'and cannot be changed after the object is created.');
|
|
2359
|
+
}
|
|
2360
|
+
const projectId = this.requireProjectId();
|
|
2361
|
+
const objecttemplateId = await this.resolveObjectTemplateId(o.objectType);
|
|
2362
|
+
const moduleId = await this.resolveDefaultModuleId();
|
|
2363
|
+
const body = {
|
|
2364
|
+
disname: o.name,
|
|
2365
|
+
objecttemplateId,
|
|
2366
|
+
parameterId: [],
|
|
2367
|
+
projectId,
|
|
2368
|
+
technology: o.objectType,
|
|
2369
|
+
...(moduleId != null ? { moduleId } : {}),
|
|
2370
|
+
attributes: o.locators.map((l) => ({ name: l.name, value: l.value, ...(l.unique ? { unique: true } : {}) })),
|
|
2371
|
+
};
|
|
2372
|
+
const raw = await this.http.postJson('/tm/object', body);
|
|
2373
|
+
const doc = (raw?.data && typeof raw.data === 'object' ? raw.data : raw) ?? {};
|
|
2374
|
+
return toObject(doc);
|
|
2375
|
+
}
|
|
2376
|
+
/** The current project's module literally named "Default Module" (case-insensitive) — mirrors the
|
|
2377
|
+
* real sqa-ui `object.component.ts` `openForm()` auto-selection. Best-effort: returns undefined
|
|
2378
|
+
* (never throws) when no such module exists or the lookup fails, so object creation still proceeds
|
|
2379
|
+
* without a module rather than being blocked by this convenience. */
|
|
2380
|
+
async resolveDefaultModuleId() {
|
|
2381
|
+
try {
|
|
2382
|
+
const modules = await this.listModules();
|
|
2383
|
+
const found = modules.find((m) => m.name?.toLowerCase() === 'default module');
|
|
2384
|
+
return found ? Number.parseInt(found.id, 10) : undefined;
|
|
2385
|
+
}
|
|
2386
|
+
catch {
|
|
2387
|
+
return undefined;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
2391
|
+
* Soft-delete — `PATCH /tm/object` (NOT POST .../delete like testcase/suite; confirmed against the
|
|
2392
|
+
* real sqa-ui ObjectService.deleteObject), body `{ids, projectId, isDelete:true}`.
|
|
2393
|
+
*
|
|
2394
|
+
* DEPENDENCY-CHECKED first: the SAME route with `isDelete:false` is a DRY RUN that returns, per
|
|
2395
|
+
* object, which test cases / functions / workflows still reference it (`tcArr`/`fnArr`/`wfArr`, each
|
|
2396
|
+
* keyed by projectId → an array of referencing ids — mirrors the real UI's
|
|
2397
|
+
* deleteDependencyForObject flow, object.component.html/.ts). If anything still references it, the
|
|
2398
|
+
* delete is refused rather than silently breaking those test cases; only a dependency-free object
|
|
2399
|
+
* gets the real `isDelete:true` call.
|
|
2400
|
+
*/
|
|
2401
|
+
async deleteObject(id) {
|
|
2402
|
+
const projectId = this.requireProjectId();
|
|
2403
|
+
const numId = Number.parseInt(numericId(id), 10);
|
|
2404
|
+
const checkRes = await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: false });
|
|
2405
|
+
const row = checkRes?.data?.[0];
|
|
2406
|
+
const countRefs = (m) => m ? Object.values(m).reduce((n, arr) => n + (Array.isArray(arr) ? arr.length : 0), 0) : 0;
|
|
2407
|
+
const tcCount = countRefs(row?.tcArr);
|
|
2408
|
+
const fnCount = countRefs(row?.fnArr);
|
|
2409
|
+
const wfCount = countRefs(row?.wfArr);
|
|
2410
|
+
if (tcCount || fnCount || wfCount) {
|
|
2411
|
+
const parts = [];
|
|
2412
|
+
if (tcCount)
|
|
2413
|
+
parts.push(`${tcCount} test case${tcCount === 1 ? '' : 's'}`);
|
|
2414
|
+
if (fnCount)
|
|
2415
|
+
parts.push(`${fnCount} function${fnCount === 1 ? '' : 's'}`);
|
|
2416
|
+
if (wfCount)
|
|
2417
|
+
parts.push(`${wfCount} workflow${wfCount === 1 ? '' : 's'}`);
|
|
2418
|
+
throw new Error(`Cannot delete object ${id} — still used by ${parts.join(', ')}. Remove those references first ` +
|
|
2419
|
+
'(or review/delete them in the SimplifyQA UI, which shows exactly which ones).');
|
|
2420
|
+
}
|
|
2421
|
+
await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: true });
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Update an object — `PUT /tm/object/:id`, plain JSON (NOT multipart, unlike testcase/suite writes;
|
|
2425
|
+
* confirmed against the real sqa-ui ObjectService.update). The backend expects the FULL document
|
|
2426
|
+
* back, so this reads the current one first and echoes every field it doesn't touch
|
|
2427
|
+
* (disname/description/objecttemplateId/parameterId/moduleId/technology/updateVersion — the
|
|
2428
|
+
* optimistic-concurrency token the real form also round-trips).
|
|
2429
|
+
*
|
|
2430
|
+
* `changes.locators` UPSERTS by locator name into the existing attribute list — an entry with a
|
|
2431
|
+
* matching name gets its value/unique updated in place, everything else on the object is left alone,
|
|
2432
|
+
* and a name with no existing match is appended as a new locator.
|
|
2433
|
+
*
|
|
2434
|
+
* `technology` (object type) and `objecttemplateId` are DELIBERATELY never taken from `changes` —
|
|
2435
|
+
* they are always echoed back from the existing doc. Both are structural (they determine which
|
|
2436
|
+
* attribute set is even valid for this object), so editing them here — outside the create flow — is
|
|
2437
|
+
* out of scope; changing them means creating a new object, not "updating" this one.
|
|
2438
|
+
*/
|
|
2439
|
+
async updateObject(id, changes) {
|
|
2440
|
+
const numId = numericId(id);
|
|
2441
|
+
const raw = await this.http.getJson(`/tm/object/${numId}`);
|
|
2442
|
+
const doc = (raw?.data && typeof raw.data === 'object' ? raw.data : raw) ?? {};
|
|
2443
|
+
if (!doc || typeof doc !== 'object' || Object.keys(doc).length === 0) {
|
|
2444
|
+
throw new Error(`object ${id} not found`);
|
|
2445
|
+
}
|
|
2446
|
+
const attributes = Array.isArray(doc.attributes)
|
|
2447
|
+
? doc.attributes.map((a) => ({
|
|
2448
|
+
name: String(a?.name ?? ''),
|
|
2449
|
+
value: String(a?.value ?? ''),
|
|
2450
|
+
...(a?.unique === true ? { unique: true } : {}),
|
|
2451
|
+
}))
|
|
2452
|
+
: [];
|
|
2453
|
+
for (const loc of changes.locators ?? []) {
|
|
2454
|
+
const idx = attributes.findIndex((a) => a.name === loc.name);
|
|
2455
|
+
const merged = { name: loc.name, value: loc.value, ...(loc.unique !== undefined ? { unique: loc.unique } : {}) };
|
|
2456
|
+
if (idx >= 0)
|
|
2457
|
+
attributes[idx] = { ...attributes[idx], ...merged };
|
|
2458
|
+
else
|
|
2459
|
+
attributes.push(merged);
|
|
2460
|
+
}
|
|
2461
|
+
const body = {
|
|
2462
|
+
disname: changes.name ?? doc.disname,
|
|
2463
|
+
description: doc.description,
|
|
2464
|
+
objecttemplateId: doc.objecttemplateId,
|
|
2465
|
+
parameterId: doc.parameterId ?? [],
|
|
2466
|
+
moduleId: doc.moduleId,
|
|
2467
|
+
technology: doc.technology,
|
|
2468
|
+
attributes,
|
|
2469
|
+
projectId: this.requireProjectId(),
|
|
2470
|
+
updateVersion: doc.updateVersion ?? 0,
|
|
2471
|
+
};
|
|
2472
|
+
const res = await this.http.putJson(`/tm/object/${numId}`, body);
|
|
2473
|
+
const updated = (res?.data && typeof res.data === 'object' ? res.data : res) ?? doc;
|
|
2474
|
+
return toObject(updated);
|
|
2475
|
+
}
|
|
2476
|
+
// ---------------------------------------------------------------------- Actions catalog
|
|
2477
|
+
async actionsCatalog(platform) {
|
|
2478
|
+
return this.requireHop('actions_catalog').actionsCatalog(platform);
|
|
2479
|
+
}
|
|
2480
|
+
// ---------------------------------------------------------------------- Testdata generation [Step 3]
|
|
2481
|
+
/** Fetch the default (template) iteration row for a TC. Fills gaps from the raw TC:
|
|
2482
|
+
* - versionId: falls back through versionId → selectedVersionId → versionsDetails.id → versions[0].id
|
|
2483
|
+
* - executionType (required by the backend Mongo filter): read from raw TC, default AUTOMATION
|
|
2484
|
+
* - environmentType: caller value wins; else /pm/fields/8 first configured env; else 'general' */
|
|
2485
|
+
async getDefaultIteration(q) {
|
|
2486
|
+
const projectId = this.requireProjectId();
|
|
2487
|
+
const tcNum = numericId(q.tcId);
|
|
2488
|
+
let versionId = q.versionId;
|
|
2489
|
+
let execType;
|
|
2490
|
+
if (!versionId || !execType) {
|
|
2491
|
+
const raw = await this.fetchRawTestcase(q.tcId);
|
|
2492
|
+
if (!versionId) {
|
|
2493
|
+
versionId = str(raw, 'versionId', 'selectedVersionId', 'selVersion');
|
|
2494
|
+
if (!versionId && raw?.versionsDetails && typeof raw.versionsDetails === 'object') {
|
|
2495
|
+
versionId = str(raw.versionsDetails, 'id', '_id', 'versionId');
|
|
2496
|
+
}
|
|
2497
|
+
if (!versionId && Array.isArray(raw?.versions) && raw.versions.length > 0) {
|
|
2498
|
+
versionId = str(raw.versions[0], 'id', '_id');
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
execType = str(raw, 'executionType');
|
|
2502
|
+
if (!versionId)
|
|
2503
|
+
throw new Error(`test case ${q.tcId} has no versionId — pass one explicitly`);
|
|
2504
|
+
}
|
|
2505
|
+
if (!execType)
|
|
2506
|
+
execType = 'AUTOMATION';
|
|
2507
|
+
const env = await this.resolveEnvironment(projectId, q.environmentType);
|
|
2508
|
+
const path = '/tm/testdata/getDefaultItr' +
|
|
2509
|
+
`?projectId=${projectId}` +
|
|
2510
|
+
`&testcaseId=${tcNum}` +
|
|
2511
|
+
`&versionId=${encodeURIComponent(versionId)}` +
|
|
2512
|
+
`&environmentType=${encodeURIComponent(env)}` +
|
|
2513
|
+
'&collection=TC' +
|
|
2514
|
+
`&executionType=${encodeURIComponent(execType)}`;
|
|
2515
|
+
const raw = await this.http.getJson(path);
|
|
2516
|
+
const paramsMap = raw?.data?.parametersData;
|
|
2517
|
+
const parameters = [];
|
|
2518
|
+
if (paramsMap && typeof paramsMap === 'object' && !Array.isArray(paramsMap)) {
|
|
2519
|
+
for (const [key, value] of Object.entries(paramsMap)) {
|
|
2520
|
+
const pid = Number.parseInt(key, 10);
|
|
2521
|
+
if (!Number.isFinite(pid))
|
|
2522
|
+
continue;
|
|
2523
|
+
// Protected params get masked below — never assign this raw value to `seed` unmasked.
|
|
2524
|
+
const rawValue = String(value?.value ?? '');
|
|
2525
|
+
const meta = await this.fetchParameterMeta(pid);
|
|
2526
|
+
if (!meta)
|
|
2527
|
+
continue; // parameter deleted — skip
|
|
2528
|
+
const seed = meta.protected ? (rawValue ? '*****' : '(empty)') : rawValue;
|
|
2529
|
+
parameters.push({ paramId: pid, name: meta.name, seed, ...(meta.protected ? { protected: true } : {}) });
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
return { tcId: q.tcId, versionId: versionId, environmentType: env, parameters };
|
|
2533
|
+
}
|
|
2534
|
+
/** Persist N generated iteration rows. Rebuilds the name → paramId map from a fresh template
|
|
2535
|
+
* fetch — no cache, because a stale id would silently write to the wrong parameter. */
|
|
2536
|
+
async saveGeneratedIterations(payload) {
|
|
2537
|
+
if (!payload.rows || payload.rows.length === 0)
|
|
2538
|
+
throw new Error('rows[] required and non-empty');
|
|
2539
|
+
if (!payload.versionId)
|
|
2540
|
+
throw new Error('versionId required — call get_default_iteration first');
|
|
2541
|
+
const projectId = this.requireProjectId();
|
|
2542
|
+
const tcNum = numericId(payload.tcId);
|
|
2543
|
+
const env = payload.environmentType || 'general';
|
|
2544
|
+
// Reuse the caller's already-fetched template when supplied — the tc_generate_testdata tool
|
|
2545
|
+
// fetches it just before calling us. Skipping the re-fetch avoids a duplicate /getDefaultItr +
|
|
2546
|
+
// N /tm/parameter/{id} round trips per save.
|
|
2547
|
+
const tplParams = payload.parameters ?? (await this.getDefaultIteration({
|
|
2548
|
+
tcId: payload.tcId,
|
|
2549
|
+
versionId: payload.versionId,
|
|
2550
|
+
environmentType: env,
|
|
2551
|
+
})).parameters;
|
|
2552
|
+
const nameToId = new Map();
|
|
2553
|
+
for (const p of tplParams)
|
|
2554
|
+
nameToId.set(p.name, p.paramId);
|
|
2555
|
+
const formulas = payload.formulas ?? {};
|
|
2556
|
+
const formulaNames = Object.keys(formulas);
|
|
2557
|
+
// Per-row: evaluate the row's formula columns concurrently. The compute endpoint mints RANDOM
|
|
2558
|
+
// values so each call must still happen (no dedup across rows), but within a row the calls are
|
|
2559
|
+
// independent — Promise.all cuts wall time roughly by the formula count.
|
|
2560
|
+
// ponytail: per-row concurrency only. Firing all rows × formulas at once would work but might
|
|
2561
|
+
// stress the external DATA_GENERATION_URL proxy; scale wider only if the row-level speedup is not enough.
|
|
2562
|
+
const iterations = [];
|
|
2563
|
+
for (const row of payload.rows) {
|
|
2564
|
+
const data = [];
|
|
2565
|
+
for (const [k, v] of Object.entries(row)) {
|
|
2566
|
+
if (formulaNames.includes(k))
|
|
2567
|
+
continue; // a formula covers this column — its value wins
|
|
2568
|
+
const pid = nameToId.get(k);
|
|
2569
|
+
if (pid == null)
|
|
2570
|
+
continue; // unknown param name — silently skip
|
|
2571
|
+
data.push({ paramId: pid, value: String(v ?? '') });
|
|
2572
|
+
}
|
|
2573
|
+
const evaluated = await Promise.all(formulaNames.map(async (name) => {
|
|
2574
|
+
const pid = nameToId.get(name);
|
|
2575
|
+
if (pid == null)
|
|
2576
|
+
return null;
|
|
2577
|
+
const { value, expression } = await this.evaluateFormula(formulas[name]);
|
|
2578
|
+
return { paramId: pid, value, formulaValue: expression };
|
|
2579
|
+
}));
|
|
2580
|
+
for (const cell of evaluated)
|
|
2581
|
+
if (cell)
|
|
2582
|
+
data.push(cell);
|
|
2583
|
+
iterations.push({ isChecked: true, data });
|
|
2584
|
+
}
|
|
2585
|
+
const body = {
|
|
2586
|
+
projectId,
|
|
2587
|
+
data: [
|
|
2588
|
+
{
|
|
2589
|
+
type: 'TC',
|
|
2590
|
+
id: Number.parseInt(tcNum, 10),
|
|
2591
|
+
versionId: payload.versionId,
|
|
2592
|
+
environmentType: env,
|
|
2593
|
+
projectId,
|
|
2594
|
+
iterations,
|
|
2595
|
+
},
|
|
2596
|
+
],
|
|
2597
|
+
};
|
|
2598
|
+
await this.http.postJson('/tm/testdata/saveItrForDataGen', body);
|
|
2599
|
+
return { tcId: payload.tcId, inserted: iterations.length };
|
|
2600
|
+
}
|
|
2601
|
+
/** Environment key: caller wins; else fetch the project's configured envs and pick the first;
|
|
2602
|
+
* else 'general' (matches the SQA UI fallback). */
|
|
2603
|
+
async resolveEnvironment(projectId, requested) {
|
|
2604
|
+
if (requested && requested.length > 0)
|
|
2605
|
+
return requested;
|
|
2606
|
+
try {
|
|
2607
|
+
const envs = await this.http.getJson(`/pm/fields/8?projectId=${projectId}&fieldType=environment`);
|
|
2608
|
+
if (Array.isArray(envs) && envs.length > 0) {
|
|
2609
|
+
const key = str(envs[0], 'key', 'value', 'name');
|
|
2610
|
+
if (key)
|
|
2611
|
+
return key;
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
catch { /* fall through */ }
|
|
2615
|
+
return 'general';
|
|
2616
|
+
}
|
|
2617
|
+
/** Parameter name + protected-flag lookup. Null when the parameter is deleted or cross-project. */
|
|
2618
|
+
async fetchParameterMeta(paramId) {
|
|
2619
|
+
let raw;
|
|
2620
|
+
try {
|
|
2621
|
+
raw = await this.http.getJson(`/tm/parameter/${paramId}`);
|
|
2622
|
+
}
|
|
2623
|
+
catch {
|
|
2624
|
+
return null;
|
|
2625
|
+
}
|
|
2626
|
+
let n = raw?.data ?? raw;
|
|
2627
|
+
if (Array.isArray(n))
|
|
2628
|
+
n = n.length > 0 ? n[0] : null;
|
|
2629
|
+
if (!n || typeof n !== 'object')
|
|
2630
|
+
return null;
|
|
2631
|
+
const name = str(n, 'name');
|
|
2632
|
+
if (!name)
|
|
2633
|
+
return null;
|
|
2634
|
+
return { name, protected: n?.protected === true };
|
|
2635
|
+
}
|
|
2636
|
+
// ---------------------------------------------------------------------- Formulas [Step 3b]
|
|
2637
|
+
/** List every formula the current project can use — DEFAULT (customer-wide prefabs) unioned with
|
|
2638
|
+
* CUSTOM entries scoped to the customer or this project. The backend endpoint already returns the
|
|
2639
|
+
* union when called with `projectId`; we just normalise the doc shape and derive `scope` from
|
|
2640
|
+
* (type + projectId). Cached per adapter instance. */
|
|
2641
|
+
async listFormulas() {
|
|
2642
|
+
if (this.formulaCache)
|
|
2643
|
+
return this.formulaCache;
|
|
2644
|
+
const projectId = this.requireProjectId();
|
|
2645
|
+
const pending = (async () => {
|
|
2646
|
+
const raw = await this.http.getJson(`/tm/syncFormulas/fetch?projectId=${projectId}`);
|
|
2647
|
+
const list = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw) ? raw : [];
|
|
2648
|
+
const out = [];
|
|
2649
|
+
for (const doc of list) {
|
|
2650
|
+
const f = normalizeFormula(doc, projectId);
|
|
2651
|
+
if (f)
|
|
2652
|
+
out.push(f);
|
|
2653
|
+
}
|
|
2654
|
+
return out;
|
|
2655
|
+
})().catch((e) => {
|
|
2656
|
+
// On failure, drop the memoised promise so a retry actually retries.
|
|
2657
|
+
this.formulaCache = null;
|
|
2658
|
+
throw e;
|
|
2659
|
+
});
|
|
2660
|
+
this.formulaCache = pending;
|
|
2661
|
+
return pending;
|
|
2662
|
+
}
|
|
2663
|
+
/** Evaluate one formula call and return { value, expression }. Order args by the formula's declared
|
|
2664
|
+
* parameter list so the stored `formulaValue` string is positional (`=NAME("a","b")`) — that's
|
|
2665
|
+
* what the SQA UI writes, and the backend just reads it verbatim on save. */
|
|
2666
|
+
async evaluateFormula(call) {
|
|
2667
|
+
const all = await this.listFormulas();
|
|
2668
|
+
// Both sides carry parens: the backend stores DEFAULT names as "FIRST_NAME()", and models copy the
|
|
2669
|
+
// SIGNATURE format ("FIRST_NAME(country name,sex,min,max)"). Strip on both, compare case-insensitively.
|
|
2670
|
+
const needle = bareFormulaName(call.name).toUpperCase();
|
|
2671
|
+
const meta = all.find((f) => bareFormulaName(f.name).toUpperCase() === needle);
|
|
2672
|
+
if (!meta) {
|
|
2673
|
+
throw new Error(`Unknown formula "${call.name}" — call tc_list_formulas to see what is available.`);
|
|
2674
|
+
}
|
|
2675
|
+
const fname = bareFormulaName(meta.name);
|
|
2676
|
+
// Arg keys arrive with trailing colons/whitespace ("country name:", " sex ") from both the model AND
|
|
2677
|
+
// the formula's own declared keyName. Normalise both sides before binding.
|
|
2678
|
+
const argKey = (k) => k.replace(/:$/, '').trim();
|
|
2679
|
+
const args = {};
|
|
2680
|
+
for (const [k, v] of Object.entries(call.args ?? {}))
|
|
2681
|
+
args[argKey(k)] = v;
|
|
2682
|
+
const ordered = meta.parameters.map((p) => args[argKey(p.keyName)] ?? '');
|
|
2683
|
+
// /tm/customFormula takes its OWN positional query keys, not the formula's display keyNames.
|
|
2684
|
+
const qs = new URLSearchParams({ formulaName: fname });
|
|
2685
|
+
const apiKeys = FORMULA_ARG_KEYS[fname.toUpperCase()] ?? ['value'];
|
|
2686
|
+
ordered.forEach((v, i) => { if (v !== '' && apiKeys[i])
|
|
2687
|
+
qs.append(apiKeys[i], v); });
|
|
2688
|
+
// ponytail: the compute endpoint's response envelope varies between formula types
|
|
2689
|
+
// (`{data:{value}}` for person-info, plain string for a few); coerce defensively.
|
|
2690
|
+
const resp = await this.http.getJson(`/tm/customFormula?${qs.toString()}`);
|
|
2691
|
+
const value = String(resp?.data?.value ?? resp?.value ?? resp?.data ?? resp ?? '');
|
|
2692
|
+
const quoted = ordered.map((a) => `"${String(a).replace(/"/g, '\\"')}"`).join(',');
|
|
2693
|
+
const expression = `=${fname}(${quoted})`;
|
|
2694
|
+
return { value, expression };
|
|
2695
|
+
}
|
|
2696
|
+
// ---------------------------------------------------------------------- Run status [Step 6]
|
|
2697
|
+
/** Real status of a run — reads step-level results direct from SQA REST because the desktop agent's
|
|
2698
|
+
* get_run only returns queue state (often stale). start/stop still hop for v1. */
|
|
2699
|
+
async getRun(runId) {
|
|
2700
|
+
const projectId = this.requireProjectId();
|
|
2701
|
+
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
2702
|
+
// GET /exec/report/{runId}/{projectId} — the run rows the /reports page reads.
|
|
2703
|
+
const runs = await this.http.getJson(`/exec/report/${runId}/${projectId}`);
|
|
2704
|
+
if (!Array.isArray(runs) || runs.length === 0) {
|
|
2705
|
+
return { runId, state: 'queued' };
|
|
2706
|
+
}
|
|
2707
|
+
// Multi-TC runs return several rows; pick the first FAILED so tc_heal has a failing step to diagnose.
|
|
2708
|
+
let chosen = runs[0];
|
|
2709
|
+
for (const r of runs) {
|
|
2710
|
+
if (typeof r?.status === 'string' && r.status.toUpperCase() === 'FAILED') {
|
|
2711
|
+
chosen = r;
|
|
2712
|
+
break;
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
const executionRunId = str(chosen, 'id', '_id') ?? '';
|
|
2716
|
+
const parentId = Number(chosen?.executionItemId ?? 0);
|
|
2717
|
+
const stepsResp = await this.http.postJson('/reports/execution/getstepResult', {
|
|
2718
|
+
executionId: Number.parseInt(numericId(runId), 10),
|
|
2719
|
+
executionRunId,
|
|
2720
|
+
parentId,
|
|
2721
|
+
projectId,
|
|
2722
|
+
});
|
|
2723
|
+
const rawSteps = Array.isArray(stepsResp?.data) ? stepsResp.data : [];
|
|
2724
|
+
const stepResults = rawSteps.map((s) => {
|
|
2725
|
+
const step = {
|
|
2726
|
+
n: typeof s?.seq === 'number' ? s.seq : 0,
|
|
2727
|
+
description: String(s?.description ?? ''),
|
|
2728
|
+
status: mapStepStatus(str(s, 'status') ?? ''),
|
|
2729
|
+
};
|
|
2730
|
+
const exc = str(s, 'exception');
|
|
2731
|
+
if (exc && exc.length > 0) {
|
|
2732
|
+
step.error = exc;
|
|
2733
|
+
}
|
|
2734
|
+
else {
|
|
2735
|
+
// SQA leaves `exception` null even on FAILED. The real cause sits in ERROR log lines
|
|
2736
|
+
// (e.g. "No Such Element Exception: ..."). Send ALL ERROR lines joined — a trailing
|
|
2737
|
+
// "JS disabled" line would otherwise mask the real NoSuchElement above it.
|
|
2738
|
+
const logs = s?.executionLogData?.executionLogs;
|
|
2739
|
+
if (Array.isArray(logs) && logs.length > 0) {
|
|
2740
|
+
const errorLines = [];
|
|
2741
|
+
for (const l of logs) {
|
|
2742
|
+
const t = String(l ?? '');
|
|
2743
|
+
if (t.includes('ERROR'))
|
|
2744
|
+
errorLines.push(t);
|
|
2745
|
+
}
|
|
2746
|
+
if (errorLines.length > 0)
|
|
2747
|
+
step.error = errorLines.join('\n');
|
|
2748
|
+
else
|
|
2749
|
+
step.error = String(logs[logs.length - 1] ?? '');
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return step;
|
|
2753
|
+
});
|
|
2754
|
+
return {
|
|
2755
|
+
runId,
|
|
2756
|
+
state: mapRunState(str(chosen, 'status') ?? ''),
|
|
2757
|
+
stepResults,
|
|
2758
|
+
reportUrl: `${base}/reports/execution/${runId}`,
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
/** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
|
|
2762
|
+
* is paged (default 100) and API-key scoping can hide the selected project, which left
|
|
2763
|
+
* callers with an empty projectName. */
|
|
2764
|
+
async lookupProjectName(projectId) {
|
|
2765
|
+
try {
|
|
2766
|
+
const body = this.searchBody([{ column: 'id', value: projectId }, { column: 'deleted', value: false }], { selectFields: ['id', 'name'], limit: 1 });
|
|
2767
|
+
const raw = await this.http.postJson('/pm/project/search', body);
|
|
2768
|
+
const rows = pickArray(raw, 'data', 'content', 'projects');
|
|
2769
|
+
return str(rows[0], 'name', 'projectName') ?? '';
|
|
2770
|
+
}
|
|
2771
|
+
catch {
|
|
2772
|
+
return '';
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
// --- Run dispatch (direct REST) --------------------------------------------
|
|
2776
|
+
// Trigger, stop, and local-agent roster all speak the SQA gateway directly.
|
|
2777
|
+
// The desktop agent still runs the actual TC — backend RPUSHes to Redis and the
|
|
2778
|
+
// agent's poll loop picks it up — but the KICKOFF event no longer hops through
|
|
2779
|
+
// /mcp:4040. Spec: common-mcp/plan/sqa-execution-trigger.md. Cloud runners and
|
|
2780
|
+
// SUITE runs are not yet ported and still hop (or error clearly).
|
|
2781
|
+
async startRun(target, opts) {
|
|
2782
|
+
const projectId = this.requireProjectId();
|
|
2783
|
+
if (!target.tcId) {
|
|
2784
|
+
if (target.suiteId) {
|
|
2785
|
+
throw new Error('startRun: SUITE runs are not yet ported to direct REST — set QA_MCP_URL so common-mcp ' +
|
|
2786
|
+
'can hop to the SimplifyQA agent for suite dispatch, or run a single TC.');
|
|
2787
|
+
}
|
|
2788
|
+
throw new Error('startRun: target.tcId (or target.suiteId) is required.');
|
|
2789
|
+
}
|
|
2790
|
+
const tcId = target.tcId;
|
|
2791
|
+
const tcNum = Number.parseInt(numericId(tcId), 10);
|
|
2792
|
+
if (!Number.isFinite(tcNum))
|
|
2793
|
+
throw new Error(`startRun: could not resolve numeric id for ${tcId}.`);
|
|
2794
|
+
const mode = (opts.mode ?? 'local').toUpperCase();
|
|
2795
|
+
const rawTc = await this.fetchRawTestcase(tcId);
|
|
2796
|
+
if (!rawTc || Object.keys(rawTc).length === 0) {
|
|
2797
|
+
throw new Error(`startRun: test case ${tcId} not found.`);
|
|
2798
|
+
}
|
|
2799
|
+
// Version resolution — same fallback chain buildTestcasePayload/getDefaultIteration use.
|
|
2800
|
+
// Caller override (opts.versionId) wins; the name is looked up in the TC's versions[] so the
|
|
2801
|
+
// exec payload's versionName matches the picked versionId.
|
|
2802
|
+
let versionName;
|
|
2803
|
+
let versionId;
|
|
2804
|
+
if (opts.versionId) {
|
|
2805
|
+
versionId = opts.versionId;
|
|
2806
|
+
if (Array.isArray(rawTc?.versions)) {
|
|
2807
|
+
const picked = rawTc.versions.find((v) => str(v, 'id', '_id') === opts.versionId);
|
|
2808
|
+
if (picked)
|
|
2809
|
+
versionName = str(picked, 'name');
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
versionName ?? (versionName = str(rawTc, 'versionName', 'selectedVersionName'));
|
|
2813
|
+
versionId ?? (versionId = str(rawTc, 'versionId', 'selectedVersionId'));
|
|
2814
|
+
if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
|
|
2815
|
+
versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
|
|
2816
|
+
versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
|
|
2817
|
+
}
|
|
2818
|
+
if ((!versionName || !versionId) && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
|
|
2819
|
+
const v = rawTc.versions[0];
|
|
2820
|
+
versionName ?? (versionName = str(v, 'name'));
|
|
2821
|
+
versionId ?? (versionId = str(v, 'id', '_id'));
|
|
2822
|
+
}
|
|
2823
|
+
versionName ?? (versionName = 'v1.0');
|
|
2824
|
+
versionId ?? (versionId = '');
|
|
2825
|
+
// ponytail: was `this.info()` — 5 round-trips (list_projects + 4 counts) for one field, and
|
|
2826
|
+
// returned projectName="" when the current project wasn't in the first 100-row page or was
|
|
2827
|
+
// filtered by API-key scope, which the /exec/execution endpoint rejects with HTTP 400.
|
|
2828
|
+
const projectName = await this.lookupProjectName(projectId);
|
|
2829
|
+
if (!projectName) {
|
|
2830
|
+
throw new Error(`startRun: could not resolve project name for projectId=${projectId}. ` +
|
|
2831
|
+
`The token may not have read access to this project.`);
|
|
2832
|
+
}
|
|
2833
|
+
const env = await this.resolveEnvironment(projectId, opts.environment);
|
|
2834
|
+
const tcType = String(str(rawTc, 'tcType') ?? 'WEB').toUpperCase();
|
|
2835
|
+
const executionType = String(str(rawTc, 'executionType') ?? 'AUTOMATION').toUpperCase();
|
|
2836
|
+
const code = str(rawTc, 'code') ?? '';
|
|
2837
|
+
const name = str(rawTc, 'name', 'title') ?? '';
|
|
2838
|
+
const description = str(rawTc, 'description') ?? '';
|
|
2839
|
+
const prerequisite = str(rawTc, 'prerequisite', 'preconditions') ?? '';
|
|
2840
|
+
const moduleIdNum = typeof rawTc?.moduleId === 'number' ? rawTc.moduleId : 0;
|
|
2841
|
+
const userstoryIdNum = typeof rawTc?.userstoryId === 'number' ? rawTc.userstoryId : 0;
|
|
2842
|
+
const tags = Array.isArray(rawTc?.tags) ? rawTc.tags : [];
|
|
2843
|
+
// Best-effort module name — targeted id lookup (not a full listModules scan, which now caps
|
|
2844
|
+
// at the default 100). Story name deliberately left empty; adds a per-run round-trip for a
|
|
2845
|
+
// cosmetic field. ponytail: enrich if a caller ever complains.
|
|
2846
|
+
let moduleName = '';
|
|
2847
|
+
if (moduleIdNum > 0) {
|
|
2848
|
+
try {
|
|
2849
|
+
const body = this.searchBody([{ column: 'id', value: moduleIdNum }, { column: 'deleted', value: false }], { selectFields: ['id', 'name'], limit: 1 });
|
|
2850
|
+
const raw = await this.http.postJson('/pm/module/search', body);
|
|
2851
|
+
const rows = pickArray(raw, 'data', 'content', 'modules');
|
|
2852
|
+
moduleName = str(rows[0], 'name') ?? '';
|
|
2853
|
+
}
|
|
2854
|
+
catch { /* leave empty */ }
|
|
2855
|
+
}
|
|
2856
|
+
const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId) : null;
|
|
2857
|
+
const platform = this.buildRunPlatform(tcType, mode, opts.browser, opts.deviceId);
|
|
2858
|
+
// UI uses `Math.random().toString(26).substr(5,6)` — plan doc confirms any string works.
|
|
2859
|
+
const stepId = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
|
|
2860
|
+
const body = {
|
|
2861
|
+
projectId,
|
|
2862
|
+
agentId,
|
|
2863
|
+
environmentType: env,
|
|
2864
|
+
executionStyle: (opts.strategy ?? 'serial').toUpperCase(),
|
|
2865
|
+
executionOS: this.detectExecutionOS(),
|
|
2866
|
+
executionTypeId: tcNum,
|
|
2867
|
+
executionTypeName: name,
|
|
2868
|
+
executionTypeCode: code,
|
|
2869
|
+
mailersList: {},
|
|
2870
|
+
cloudType: mode === 'CLOUD' ? 'VM' : 'NONE',
|
|
2871
|
+
mode,
|
|
2872
|
+
executionCategory: 'EXECUTION',
|
|
2873
|
+
releaseId: opts.releaseId ? Number.parseInt(opts.releaseId, 10) || 0 : 0,
|
|
2874
|
+
iterationId: opts.sprintId ? Number.parseInt(opts.sprintId, 10) || 0 : 0,
|
|
2875
|
+
executionTime: '',
|
|
2876
|
+
status: 'INPROGRESS',
|
|
2877
|
+
iterationsSelected: [],
|
|
2878
|
+
result: '',
|
|
2879
|
+
globalConfiguration: {},
|
|
2880
|
+
isRecordingEnabled: false,
|
|
2881
|
+
offset: -(new Date().getTimezoneOffset()),
|
|
2882
|
+
type: 'TESTCASE',
|
|
2883
|
+
executionType,
|
|
2884
|
+
executionItems: [{
|
|
2885
|
+
id: tcNum,
|
|
2886
|
+
name,
|
|
2887
|
+
description,
|
|
2888
|
+
versionId,
|
|
2889
|
+
versionName,
|
|
2890
|
+
seq: 1,
|
|
2891
|
+
stepId,
|
|
2892
|
+
stepCount: 0,
|
|
2893
|
+
technologyType: tcType,
|
|
2894
|
+
type: 'TC',
|
|
2895
|
+
platform,
|
|
2896
|
+
projectId,
|
|
2897
|
+
projectName,
|
|
2898
|
+
configuration: {},
|
|
2899
|
+
iterationsSelected: [],
|
|
2900
|
+
moduleId: moduleIdNum,
|
|
2901
|
+
userstoryId: userstoryIdNum,
|
|
2902
|
+
tags,
|
|
2903
|
+
code,
|
|
2904
|
+
prerequisite,
|
|
2905
|
+
moduleName,
|
|
2906
|
+
userstoryName: '',
|
|
2907
|
+
}],
|
|
2908
|
+
};
|
|
2909
|
+
const resp = await this.http.postJson('/exec/execution', body);
|
|
2910
|
+
const d = resp?.data ?? resp;
|
|
2911
|
+
const runId = str(d, 'id', 'executionId', '_id') ??
|
|
2912
|
+
(typeof d?.id === 'number' ? String(d.id) : undefined) ??
|
|
2913
|
+
(typeof d?.executionId === 'number' ? String(d.executionId) : undefined);
|
|
2914
|
+
if (!runId)
|
|
2915
|
+
throw new Error('startRun: backend accepted trigger but returned no run id.');
|
|
2916
|
+
return { runId };
|
|
2917
|
+
}
|
|
2918
|
+
async stopRun(runId) {
|
|
2919
|
+
const projectId = this.requireProjectId();
|
|
2920
|
+
const executionId = Number.parseInt(numericId(runId), 10);
|
|
2921
|
+
if (!Number.isFinite(executionId))
|
|
2922
|
+
throw new Error(`stopRun: invalid runId ${runId}.`);
|
|
2923
|
+
await this.http.postJson('/exec/execution/stopExecution', {
|
|
2924
|
+
projectId,
|
|
2925
|
+
executionId,
|
|
2926
|
+
killType: 'STOP_EXECUTION',
|
|
2927
|
+
});
|
|
2928
|
+
}
|
|
2929
|
+
async listCloudRunners() {
|
|
2930
|
+
const projectId = this.requireProjectId();
|
|
2931
|
+
const body = this.searchBody([{ column: 'projectId', value: projectId }], { limit: 1000 });
|
|
2932
|
+
const raw = await this.http.postJson('/pm/machineResources/search', body);
|
|
2933
|
+
const rows = pickArray(raw, 'data', 'content');
|
|
2934
|
+
return rows.map((n) => {
|
|
2935
|
+
const id = str(n, 'id', '_id') ?? '';
|
|
2936
|
+
const status = str(n, 'status') ?? '';
|
|
2937
|
+
const parentId = str(n, 'parentId');
|
|
2938
|
+
const type = str(n, 'type');
|
|
2939
|
+
const os = [parentId ? `CM-${parentId}` : null, type].filter(Boolean).join(' · ') || undefined;
|
|
2940
|
+
return {
|
|
2941
|
+
id,
|
|
2942
|
+
name: str(n, 'displayName', 'name') ?? id,
|
|
2943
|
+
online: status === 'IDLE',
|
|
2944
|
+
...(status ? { status } : {}),
|
|
2945
|
+
...(os ? { os } : {}),
|
|
2946
|
+
};
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
async listLocalAgents() {
|
|
2950
|
+
// ponytail: no projectId filter — machine_registration.projectId is never written by
|
|
2951
|
+
// test-management/services/machine_registration.service.js (create only stamps
|
|
2952
|
+
// machineName/systemName/macAddress/ipAddress/platform/customerId). Filtering by projectId
|
|
2953
|
+
// strips every legacy row. customerId scoping is still applied server-side from the JWT.
|
|
2954
|
+
const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
|
|
2955
|
+
const raw = await this.http.postJson('/tm/machineRegistration/search', body);
|
|
2956
|
+
const rows = pickArray(raw, 'data', 'content', 'agents', 'machines');
|
|
2957
|
+
// ponytail: hostname override, same trick sqa-ui uses at
|
|
2958
|
+
// sqa-ui/.../machine-registration.component.ts:169. The DB `active` flag is only
|
|
2959
|
+
// written to `true` on first registration (fire-and-forget in executions/services/
|
|
2960
|
+
// executionQueue.service.js:49) and back to `false` by a 7s-stale cron. Every other poll
|
|
2961
|
+
// is a no-op, so a still-polling agent frequently shows `active:false`. When the row's
|
|
2962
|
+
// machineName matches THIS box's hostname, MCP is running on the same machine as the
|
|
2963
|
+
// agent — trust that over the stale DB flag. Upgrade path: if the backend adds a live
|
|
2964
|
+
// Redis-presence endpoint (ZSCORE SQAAGENTTRACKER), consult that here instead.
|
|
2965
|
+
const hostFull = os.hostname().toLowerCase();
|
|
2966
|
+
// Bare hostname: strip trailing .local/.lan/.localdomain AND take the first label. Handles
|
|
2967
|
+
// both directions of "short vs FQDN" mismatch — the agent may register as `Pratiks-MBP`
|
|
2968
|
+
// while os.hostname() returns `pratiks-mbp.local`, or vice-versa.
|
|
2969
|
+
const hostBare = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
|
|
2970
|
+
return rows.map((n) => {
|
|
2971
|
+
const id = str(n, 'id', '_id', 'agentId') ?? '';
|
|
2972
|
+
const agentName = str(n, 'machineName', 'name', 'host');
|
|
2973
|
+
const owner = str(n, 'owner', 'userName', 'createdBy');
|
|
2974
|
+
const hostAddr = str(n, 'macAddress', 'hostAddress');
|
|
2975
|
+
const dbActive = typeof n?.active === 'boolean' ? n.active : false;
|
|
2976
|
+
const nameLc = agentName?.toLowerCase() ?? '';
|
|
2977
|
+
const isMyBox = nameLc.length > 0 && hostBare.length > 0 && nameLc.includes(hostBare);
|
|
2978
|
+
return {
|
|
2979
|
+
id,
|
|
2980
|
+
...(agentName ? { name: agentName } : {}),
|
|
2981
|
+
online: dbActive || isMyBox,
|
|
2982
|
+
...(owner ? { owner } : {}),
|
|
2983
|
+
...(hostAddr ? { host: hostAddr } : {}),
|
|
2984
|
+
};
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
/**
|
|
2988
|
+
* Pick THE ACTING USER'S OWN active agent — never row [0] of a shared-tenant roster (which
|
|
2989
|
+
* would silently dispatch a local run to a teammate's box, and the run would sit queued until
|
|
2990
|
+
* TTL because it lands in a Redis key that no local agent on the caller's machine polls).
|
|
2991
|
+
*
|
|
2992
|
+
* SQA's `machineRegistration` row carries no owner_user_id column (unlike walnut, which matches
|
|
2993
|
+
* the JWT `id` claim to `owner_user_id`), so we identify "your" agent by hostname: common-mcp
|
|
2994
|
+
* runs on the caller's own box in the typical MCP setup, and SQA `machineName` is either the
|
|
2995
|
+
* bare OS hostname or a bracketed display form (`USER.NAME [ Hostname.local ]`) — both contain
|
|
2996
|
+
* `os.hostname()`. Filter → active only → hostname substring match → return the single hit.
|
|
2997
|
+
* Zero/many matches refuse honestly; a silent guess would just repeat the exact bug we're fixing.
|
|
2998
|
+
*
|
|
2999
|
+
* ponytail: hostname heuristic. Upgrade to owner-user-id match if the backend adds that column
|
|
3000
|
+
* to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
|
|
3001
|
+
*/
|
|
3002
|
+
async resolveLocalAgentId(preferred) {
|
|
3003
|
+
if (preferred && preferred.length > 0)
|
|
3004
|
+
return preferred;
|
|
3005
|
+
const agents = await this.listLocalAgents();
|
|
3006
|
+
if (agents.length === 0) {
|
|
3007
|
+
throw new Error('startRun (LOCAL): no local agent registered. Register a machine in the SimplifyQA UI ' +
|
|
3008
|
+
'first, or pass mode: "cloud" if a cloud runner is provisioned.');
|
|
3009
|
+
}
|
|
3010
|
+
const active = agents.filter((a) => a.online);
|
|
3011
|
+
if (active.length === 0) {
|
|
3012
|
+
throw new Error('startRun (LOCAL): no local agent is currently active (polling). Launch your SimplifyQA ' +
|
|
3013
|
+
'desktop agent and retry — or pass `agentId` explicitly if you know the credential id.');
|
|
3014
|
+
}
|
|
3015
|
+
const hostFull = os.hostname().toLowerCase();
|
|
3016
|
+
// Same short-vs-FQDN handling as listLocalAgents — see the ponytail note there.
|
|
3017
|
+
const host = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
|
|
3018
|
+
const mine = active.filter((a) => host.length > 0 && (a.name ?? '').toLowerCase().includes(host));
|
|
3019
|
+
if (mine.length === 1)
|
|
3020
|
+
return mine[0].id;
|
|
3021
|
+
const listing = (arr) => arr.map((a) => ` - ${a.name ?? a.id} \`${a.id}\``).join('\n');
|
|
3022
|
+
if (mine.length > 1) {
|
|
3023
|
+
throw new Error(`startRun (LOCAL): multiple active agents match this machine (${host}). Pass \`agentId\` ` +
|
|
3024
|
+
`to pick one:\n${listing(mine)}`);
|
|
3025
|
+
}
|
|
3026
|
+
throw new Error(`startRun (LOCAL): could not auto-pick your local agent — no active agent's name matches ` +
|
|
3027
|
+
`this hostname (${host}). Pass \`agentId\` explicitly. Active agents:\n${listing(active)}`);
|
|
3028
|
+
}
|
|
3029
|
+
detectExecutionOS() {
|
|
3030
|
+
if (process.platform === 'win32')
|
|
3031
|
+
return 'WINDOWS';
|
|
3032
|
+
if (process.platform === 'darwin')
|
|
3033
|
+
return 'MAC';
|
|
3034
|
+
return 'LINUX';
|
|
3035
|
+
}
|
|
3036
|
+
buildRunPlatform(tcType, mode, browser, deviceId) {
|
|
3037
|
+
const t = tcType.toUpperCase();
|
|
3038
|
+
if (t === 'WEB') {
|
|
3039
|
+
if (mode === 'CLOUD') {
|
|
3040
|
+
const key = `${(browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
|
|
3041
|
+
return [{ name: key, id: key, technologyType: 'WEB', type: 'NONE' }];
|
|
3042
|
+
}
|
|
3043
|
+
const b = (browser ?? 'CHROME').toUpperCase();
|
|
3044
|
+
return [{ name: b, id: b, technologyType: 'WEB', type: 'NONE' }];
|
|
3045
|
+
}
|
|
3046
|
+
if (t === 'ANDROID' || t === 'IOS') {
|
|
3047
|
+
// Executor's AndroidSessionFactory.buildLocal calls requireDeviceId — no default. Fail
|
|
3048
|
+
// loud so the caller sees the fix (list devices, pick one) instead of a queued/error hang.
|
|
3049
|
+
if (!deviceId) {
|
|
3050
|
+
throw new Error(`${t} test cases need \`deviceId\`. Call android_list_devices to see the available ` +
|
|
3051
|
+
`devices, then re-run with \`deviceId\` set to the adb serial (LOCAL) or marketplace ` +
|
|
3052
|
+
`device name (CLOUD).`);
|
|
3053
|
+
}
|
|
3054
|
+
// ponytail: CLOUD path still ships `cloudDeviceType: undefined` — the executor needs it
|
|
3055
|
+
// (BROWSER_STACK / SAUCE_LABS / LAMBDA_INTEGRATION / SQA_DEVICE_FARM) to know which farm
|
|
3056
|
+
// to route to. Deferred with the marketplace port; LOCAL runs unaffected.
|
|
3057
|
+
return [{
|
|
3058
|
+
name: deviceId, id: deviceId, technologyType: t,
|
|
3059
|
+
type: mode === 'CLOUD' ? 'CLOUD_DEVICE' : 'NONE',
|
|
3060
|
+
}];
|
|
3061
|
+
}
|
|
3062
|
+
return [{ name: t, id: t, technologyType: t, type: 'NONE' }];
|
|
3063
|
+
}
|
|
3064
|
+
// --- Environments + datasets -------------------------------------------
|
|
3065
|
+
/** Direct REST — same endpoint `resolveEnvironment` uses. Works without an agent hop.
|
|
3066
|
+
* `key` is what testdata iterations store as `environmentType`; `id` is the uuid used by run dispatch. */
|
|
3067
|
+
async listEnvironments() {
|
|
3068
|
+
const projectId = this.requireProjectId();
|
|
3069
|
+
// Let fetch failures propagate rather than reading as "zero environments".
|
|
3070
|
+
const raw = await this.http.getJson(`/pm/fields/8?projectId=${projectId}&fieldType=environment`);
|
|
3071
|
+
const arr = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : [];
|
|
3072
|
+
const out = [];
|
|
3073
|
+
for (const e of arr) {
|
|
3074
|
+
if (!e || typeof e !== 'object' || e.deleted)
|
|
3075
|
+
continue;
|
|
3076
|
+
const key = str(e, 'key', 'value') ?? '';
|
|
3077
|
+
const name = str(e, 'name', 'label') ?? key;
|
|
3078
|
+
const id = str(e, 'id', '_id') ?? key;
|
|
3079
|
+
if (!key && !name)
|
|
3080
|
+
continue;
|
|
3081
|
+
out.push({ id, name, key, ...(key === 'general' ? { isDefault: true } : {}) });
|
|
3082
|
+
}
|
|
3083
|
+
return out;
|
|
3084
|
+
}
|
|
3085
|
+
async listDatasets(tcId) {
|
|
3086
|
+
const h = this.hop();
|
|
3087
|
+
return h?.listDatasets ? h.listDatasets(tcId) : [];
|
|
3088
|
+
}
|
|
3089
|
+
// --- Journal (recording seed lives on the agent's FS) ------------------
|
|
3090
|
+
async readJournal(tcId) {
|
|
3091
|
+
return this.requireHop('read_journal').readJournal(tcId);
|
|
3092
|
+
}
|
|
3093
|
+
async editJournal(tcId, ops) {
|
|
3094
|
+
return this.requireHop('edit_journal').editJournal(tcId, ops);
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
//# sourceMappingURL=adapter.js.map
|