common-qa-mcp 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/factory.d.ts +1 -1
- package/dist/adapters/factory.d.ts.map +1 -1
- package/dist/adapters/factory.js +12 -29
- package/dist/adapters/factory.js.map +1 -1
- package/dist/adapters/http.js +2 -3
- package/dist/adapters/http.js.map +1 -1
- package/dist/adapters/remoteMcp/adapter.d.ts +7 -2
- package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
- package/dist/adapters/remoteMcp/adapter.js +9 -3
- package/dist/adapters/remoteMcp/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/adapter.d.ts +309 -23
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/adapter.js +2042 -86
- package/dist/adapters/simplifyqa/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/mapper.d.ts +43 -11
- package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
- package/dist/adapters/simplifyqa/mapper.js +470 -21
- package/dist/adapters/simplifyqa/mapper.js.map +1 -1
- package/dist/adapters/types.d.ts +126 -5
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/adapters/walnut/adapter.d.ts +6 -2
- package/dist/adapters/walnut/adapter.d.ts.map +1 -1
- package/dist/adapters/walnut/adapter.js +33 -2
- package/dist/adapters/walnut/adapter.js.map +1 -1
- package/dist/adapters/walnut/mapper.d.ts +2 -1
- package/dist/adapters/walnut/mapper.d.ts.map +1 -1
- package/dist/adapters/walnut/mapper.js +13 -0
- package/dist/adapters/walnut/mapper.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +15 -7
- package/dist/connection.js.map +1 -1
- package/dist/domain/types.d.ts +485 -4
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +22 -9
- package/dist/initAgents.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +23 -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/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 +912 -55
- package/dist/tools/discovery.tools.js.map +1 -1
- 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 +64 -5
- 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 +102 -18
- 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/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 +146 -100
- package/dist/tools/testcase.tools.js.map +1 -1
- package/dist/tools/testdata.tools.d.ts.map +1 -1
- package/dist/tools/testdata.tools.js +99 -11
- package/dist/tools/testdata.tools.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import { makeClient } from '../http.js';
|
|
3
3
|
import { RemoteMcpAdapter } from '../remoteMcp/adapter.js';
|
|
4
|
-
import { applyCollectionSteps, buildTestcasePayload, extractParamNames, looksSensitive, normalizeSuiteEntry, numericId, paramDefault, pickArray, str, SUITE_UPDATE_ALLOWED_KEYS, toModuleSummary, toObject, toReleaseSummary, toSavedRef, toSprintSummary, toStory, toStorySummary, toSuite, toSuiteSummary, toTcSummary, toTestCase, } from './mapper.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
5
|
const PROJECT_URL_SEGMENT = {
|
|
6
6
|
testcase: 'testcase',
|
|
7
7
|
suite: 'suite',
|
|
8
8
|
run: 'reports/execution',
|
|
9
|
+
function: 'function',
|
|
9
10
|
};
|
|
10
11
|
/** SQA run-row status → neutral RunStatus.state. */
|
|
11
12
|
function mapRunState(s) {
|
|
@@ -75,6 +76,132 @@ function mapStepStatus(s) {
|
|
|
75
76
|
default: return s.toLowerCase();
|
|
76
77
|
}
|
|
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
|
+
}
|
|
78
205
|
const NEEDS_PROJECT = 'No project selected. Call list_projects to see the available projects, then ' +
|
|
79
206
|
'qa_switch_project({ projectId }) to choose one — test cases, runs and objects ' +
|
|
80
207
|
'are all scoped to a project.';
|
|
@@ -83,7 +210,7 @@ const HOP_NEEDED_WRITE = 'This SimplifyQA operation is not yet ported to direct
|
|
|
83
210
|
/**
|
|
84
211
|
* SimplifyQaAdapter — DIRECT-to-backend REST driver for SimplifyQA, mirroring WalnutAdapter's
|
|
85
212
|
* design. Reads (list/get for projects/testcases/suites/stories/modules/releases/sprints/objects)
|
|
86
|
-
* hit the
|
|
213
|
+
* hit the SimplifyQA backend REST directly via a `QaHttp` bound to `cfg.baseUrl`. Anything not yet
|
|
87
214
|
* ported (writes, testdata, run dispatch, marketplace, and the recording surface) delegates to
|
|
88
215
|
* the LOCAL AGENT's `/mcp` (`cfg.url`) via a lazily-built RemoteMcpAdapter — same "graceful hop"
|
|
89
216
|
* pattern walnut uses for recording. When the hop is not configured, unported ops surface a clear
|
|
@@ -104,6 +231,19 @@ export class SimplifyQaAdapter {
|
|
|
104
231
|
// (from the Promise.all in saveGeneratedIterations) all await the same fetch instead of each firing
|
|
105
232
|
// their own /tm/syncFormulas/fetch before the cache is populated.
|
|
106
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;
|
|
107
247
|
if (!cfg.baseUrl) {
|
|
108
248
|
throw new Error('SimplifyQaAdapter requires a baseUrl — set QA_BASE_URL or use a JWT with a `url` claim.');
|
|
109
249
|
}
|
|
@@ -119,7 +259,6 @@ export class SimplifyQaAdapter {
|
|
|
119
259
|
return {
|
|
120
260
|
Authorization: `Bearer ${this.cfg.token}`,
|
|
121
261
|
...(this.cfg.projectId ? { 'x-project-id': this.cfg.projectId } : {}),
|
|
122
|
-
'Content-Type': 'application/json',
|
|
123
262
|
};
|
|
124
263
|
}
|
|
125
264
|
requireProjectId() {
|
|
@@ -140,7 +279,7 @@ export class SimplifyQaAdapter {
|
|
|
140
279
|
...(f.regEx ? { regEx: true } : {}),
|
|
141
280
|
})),
|
|
142
281
|
startIndex: opts.startIndex ?? 0,
|
|
143
|
-
limit: opts.limit ??
|
|
282
|
+
limit: opts.limit ?? 100,
|
|
144
283
|
};
|
|
145
284
|
if (opts.selectFields)
|
|
146
285
|
body['selectFields'] = opts.selectFields;
|
|
@@ -148,26 +287,124 @@ export class SimplifyQaAdapter {
|
|
|
148
287
|
}
|
|
149
288
|
// ---------------------------------------------------------------------- Info + projects
|
|
150
289
|
/** Validates baseUrl + token + (optional) project by running the same list_projects search the
|
|
151
|
-
* Java `infoResult` uses to resolve the project name — no separate /info endpoint on SQA.
|
|
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`. */
|
|
152
294
|
async info() {
|
|
153
295
|
const projects = await this.listProjects();
|
|
154
296
|
const projectId = this.cfg.projectId ?? '';
|
|
155
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
|
+
]);
|
|
156
304
|
return {
|
|
157
305
|
provider: 'simplifyqa',
|
|
158
306
|
projectId,
|
|
159
307
|
projectName: found?.name ?? '',
|
|
308
|
+
counts: { testCases, suites, stories, objects },
|
|
160
309
|
};
|
|
161
310
|
}
|
|
162
|
-
/**
|
|
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. */
|
|
163
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() {
|
|
164
368
|
const body = this.searchBody([{ column: 'deleted', value: false }], { selectFields: ['id', 'code', 'name'] });
|
|
165
369
|
const raw = await this.http.postJson('/pm/project/search', body);
|
|
166
370
|
const rows = pickArray(raw, 'data', 'content', 'projects');
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
}
|
|
171
408
|
}
|
|
172
409
|
// ---------------------------------------------------------------------- Test cases (read)
|
|
173
410
|
async listTestCases(q) {
|
|
@@ -180,35 +417,78 @@ export class SimplifyQaAdapter {
|
|
|
180
417
|
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
181
418
|
if (q.status)
|
|
182
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);
|
|
183
434
|
const body = this.searchBody(fields, {
|
|
184
435
|
startIndex: q.offset ?? 0,
|
|
185
436
|
limit: q.limit ?? 100,
|
|
186
|
-
selectFields: ['id', 'code', 'name', 'priority', 'status', 'stepCount', 'tcType', 'executionType'],
|
|
437
|
+
selectFields: ['id', 'code', 'name', 'priority', 'status', 'stepCount', 'tcType', 'executionType', 'obsolete'],
|
|
187
438
|
});
|
|
188
439
|
const raw = await this.http.postJson('/tm/testcase/search', body);
|
|
189
440
|
const rows = pickArray(raw, 'data', 'content', 'testCases');
|
|
190
|
-
return rows.map(toTcSummary);
|
|
441
|
+
return withTotal(rows.map(toTcSummary), raw);
|
|
191
442
|
}
|
|
192
|
-
/**
|
|
193
|
-
|
|
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) {
|
|
194
459
|
const raw = await this.fetchRawTestcase(id);
|
|
195
460
|
if (!raw || Object.keys(raw).length === 0) {
|
|
196
461
|
throw new Error(`test case ${id} not found`);
|
|
197
462
|
}
|
|
198
463
|
const tc = toTestCase(raw);
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
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 = [];
|
|
203
472
|
try {
|
|
204
|
-
|
|
205
|
-
if (rows.length > 0)
|
|
206
|
-
return applyCollectionSteps(tc, rows);
|
|
473
|
+
rows = await this.fetchStepsCollection(id, raw, versionId);
|
|
207
474
|
}
|
|
208
|
-
catch {
|
|
209
|
-
|
|
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 }));
|
|
210
484
|
}
|
|
211
|
-
|
|
485
|
+
if (rows.length > 0) {
|
|
486
|
+
await Promise.all([
|
|
487
|
+
this.hydrateFunctionNames(patched),
|
|
488
|
+
this.hydrateObjectNames(patched),
|
|
489
|
+
]);
|
|
490
|
+
}
|
|
491
|
+
return patched;
|
|
212
492
|
}
|
|
213
493
|
async fetchRawTestcase(id) {
|
|
214
494
|
const raw = await this.http.getJson(`/tm/testcase/getById/${numericId(id)}`);
|
|
@@ -217,16 +497,21 @@ export class SimplifyQaAdapter {
|
|
|
217
497
|
doc = doc.length > 0 ? doc[0] : {};
|
|
218
498
|
return doc && typeof doc === 'object' ? doc : {};
|
|
219
499
|
}
|
|
220
|
-
async fetchStepsCollection(tcId, rawTc) {
|
|
500
|
+
async fetchStepsCollection(tcId, rawTc, versionOverride) {
|
|
221
501
|
const projectId = this.requireProjectId();
|
|
222
502
|
const tcNum = numericId(tcId);
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
}
|
|
230
515
|
}
|
|
231
516
|
if (!versionId)
|
|
232
517
|
return [];
|
|
@@ -239,6 +524,93 @@ export class SimplifyQaAdapter {
|
|
|
239
524
|
const rows = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
|
|
240
525
|
return rows;
|
|
241
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
|
+
}
|
|
242
614
|
// ---------------------------------------------------------------------- Suites (read)
|
|
243
615
|
async listSuites() {
|
|
244
616
|
const projectId = this.requireProjectId();
|
|
@@ -248,7 +620,7 @@ export class SimplifyQaAdapter {
|
|
|
248
620
|
]);
|
|
249
621
|
const raw = await this.http.postJson('/tm/suites/search', body);
|
|
250
622
|
const rows = pickArray(raw, 'data', 'content', 'suites');
|
|
251
|
-
return rows.map(toSuiteSummary);
|
|
623
|
+
return withTotal(rows.map(toSuiteSummary), raw);
|
|
252
624
|
}
|
|
253
625
|
async getSuite(id) {
|
|
254
626
|
const raw = await this.http.getJson(`/tm/suites/${numericId(id)}`);
|
|
@@ -271,10 +643,21 @@ export class SimplifyQaAdapter {
|
|
|
271
643
|
fields.push({ column: 'iterationId', value: Number.parseInt(q.sprintId, 10) });
|
|
272
644
|
if (q.releaseId)
|
|
273
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);
|
|
274
657
|
const body = this.searchBody(fields, { limit: q.limit ?? 50 });
|
|
275
658
|
const raw = await this.http.postJson('/pm/userstory/search', body);
|
|
276
659
|
const rows = pickArray(raw, 'data', 'content', 'userStories', 'stories');
|
|
277
|
-
return rows.map(toStorySummary);
|
|
660
|
+
return withTotal(rows.map(toStorySummary), raw);
|
|
278
661
|
}
|
|
279
662
|
async getStory(id) {
|
|
280
663
|
const resolvedId = await this.resolveStoryNumericId(id);
|
|
@@ -298,16 +681,314 @@ export class SimplifyQaAdapter {
|
|
|
298
681
|
throw new Error(`Story not found for business key: ${ref}`);
|
|
299
682
|
return str(rows[0], 'id', '_id', 'userstoryId') ?? ref;
|
|
300
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
|
+
}
|
|
301
883
|
// ---------------------------------------------------------------------- Modules, releases, sprints
|
|
302
884
|
async listModules() {
|
|
303
885
|
const projectId = this.requireProjectId();
|
|
304
886
|
const body = this.searchBody([
|
|
305
887
|
{ column: 'projectId', value: projectId },
|
|
306
888
|
{ column: 'deleted', value: false },
|
|
307
|
-
], { selectFields: ['id', 'code', 'name', 'parentId']
|
|
889
|
+
], { selectFields: ['id', 'code', 'name', 'parentId'] });
|
|
308
890
|
const raw = await this.http.postJson('/pm/module/search', body);
|
|
309
891
|
const rows = pickArray(raw, 'data', 'content', 'modules');
|
|
310
|
-
return rows.map(toModuleSummary);
|
|
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 };
|
|
311
992
|
}
|
|
312
993
|
async listReleases(q) {
|
|
313
994
|
const fields = [{ column: 'deleted', value: false }];
|
|
@@ -316,7 +997,7 @@ export class SimplifyQaAdapter {
|
|
|
316
997
|
const body = this.searchBody(fields);
|
|
317
998
|
const raw = await this.http.postJson('/pm/release/search', body);
|
|
318
999
|
const rows = pickArray(raw, 'data', 'content', 'releases');
|
|
319
|
-
return rows.map(toReleaseSummary);
|
|
1000
|
+
return withTotal(rows.map(toReleaseSummary), raw);
|
|
320
1001
|
}
|
|
321
1002
|
async listSprints(q) {
|
|
322
1003
|
const fields = [{ column: 'deleted', value: false }];
|
|
@@ -325,7 +1006,41 @@ export class SimplifyQaAdapter {
|
|
|
325
1006
|
const body = this.searchBody(fields);
|
|
326
1007
|
const raw = await this.http.postJson('/pm/sprint/search', body);
|
|
327
1008
|
const rows = pickArray(raw, 'data', 'content', 'sprints');
|
|
328
|
-
return rows.map(toSprintSummary);
|
|
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);
|
|
329
1044
|
}
|
|
330
1045
|
// ---------------------------------------------------------------------- Objects (read)
|
|
331
1046
|
async getObject(id) {
|
|
@@ -373,48 +1088,908 @@ export class SimplifyQaAdapter {
|
|
|
373
1088
|
}
|
|
374
1089
|
// ---------------------------------------------------------------------- Test cases (write) [Step 4]
|
|
375
1090
|
/** Two-step create: (1) resolve every `${name}` token in the steps to a project-scoped parameter id
|
|
376
|
-
* (get-or-create), (2) POST /tm/testcase
|
|
1091
|
+
* (get-or-create) plus, for AUTOMATION, each step's actionId + objectId, (2) POST /tm/testcase
|
|
1092
|
+
* multipart with the assembled `testcaseObj`. */
|
|
377
1093
|
async createTestCase(tc) {
|
|
378
1094
|
const projectId = this.requireProjectId();
|
|
379
1095
|
const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
|
|
380
1096
|
const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
|
|
381
1097
|
const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
|
|
1098
|
+
const stepResolutions = tc.category === 'automated'
|
|
1099
|
+
? await this.resolveAutomationSteps(projectId, moduleId, tc)
|
|
1100
|
+
: undefined;
|
|
382
1101
|
const payload = buildTestcasePayload({
|
|
383
1102
|
projectId, moduleId,
|
|
384
1103
|
...(userstoryId != null ? { userstoryId } : {}),
|
|
385
1104
|
tc,
|
|
386
1105
|
paramIdByName,
|
|
387
|
-
|
|
1106
|
+
...(stepResolutions ? { stepResolutions } : {}),
|
|
388
1107
|
});
|
|
389
1108
|
const raw = await this.http.postMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
390
1109
|
return toSavedRef(raw, true);
|
|
391
1110
|
}
|
|
392
|
-
/** Same shape as create, but with `tcfnObj.id` set
|
|
1111
|
+
/** Same shape as create, but with `tcfnObj.id` set. */
|
|
393
1112
|
async updateTestCase(id, tc) {
|
|
394
1113
|
const projectId = this.requireProjectId();
|
|
395
1114
|
const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
|
|
396
1115
|
const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
|
|
397
1116
|
const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
|
|
1117
|
+
const stepResolutions = tc.category === 'automated'
|
|
1118
|
+
? await this.resolveAutomationSteps(projectId, moduleId, tc)
|
|
1119
|
+
: undefined;
|
|
398
1120
|
const payload = buildTestcasePayload({
|
|
399
1121
|
projectId, moduleId,
|
|
400
1122
|
...(userstoryId != null ? { userstoryId } : {}),
|
|
401
1123
|
existingId: numericId(id),
|
|
402
1124
|
tc,
|
|
403
1125
|
paramIdByName,
|
|
404
|
-
|
|
1126
|
+
...(stepResolutions ? { stepResolutions } : {}),
|
|
405
1127
|
});
|
|
406
1128
|
// `!` — putMultipart is optional on QaHttp (walnut's wrapper opts out); makeClient always provides it.
|
|
407
1129
|
const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
408
1130
|
return toSavedRef(raw, false);
|
|
409
1131
|
}
|
|
410
|
-
/**
|
|
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 = opts.versionId ||
|
|
1249
|
+
(str(rawTc, 'versionId', 'selectedVersionId') ??
|
|
1250
|
+
(rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
|
|
1251
|
+
? str(rawTc.versionsDetails, 'id', '_id', 'versionId')
|
|
1252
|
+
: undefined) ??
|
|
1253
|
+
(Array.isArray(rawTc?.versions) && rawTc.versions.length > 0
|
|
1254
|
+
? str(rawTc.versions[0], 'id', '_id')
|
|
1255
|
+
: undefined));
|
|
1256
|
+
const rawSteps = await this.fetchStepsCollection(tcId, rawTc, opts.versionId);
|
|
1257
|
+
const picked = rawSteps
|
|
1258
|
+
.filter((s) => seqSet.has(Number(s.seq)))
|
|
1259
|
+
.sort((a, b) => Number(a.seq) - Number(b.seq));
|
|
1260
|
+
if (picked.length === 0) {
|
|
1261
|
+
throw new Error(`no steps on ${tcId} match stepSeqs [${opts.stepSeqs.join(',')}]`);
|
|
1262
|
+
}
|
|
1263
|
+
const automationSteps = [];
|
|
1264
|
+
const manualSteps = [];
|
|
1265
|
+
let newSeq = 0;
|
|
1266
|
+
for (const s of picked) {
|
|
1267
|
+
const step = { ...s };
|
|
1268
|
+
// Server-assigned refs stripped so backend re-creates them under the new FN version.
|
|
1269
|
+
delete step.id;
|
|
1270
|
+
delete step._id;
|
|
1271
|
+
delete step.versionId;
|
|
1272
|
+
delete step.parentId;
|
|
1273
|
+
delete step.subSteps;
|
|
1274
|
+
delete step.deleted;
|
|
1275
|
+
delete step.isFunctionEdit;
|
|
1276
|
+
delete step.sharedFNsteps;
|
|
1277
|
+
delete step.sharedFnProjectId;
|
|
1278
|
+
step.type = 'FN';
|
|
1279
|
+
step.moduleId = opts.moduleId;
|
|
1280
|
+
step.seq = ++newSeq;
|
|
1281
|
+
// FE mirror (line 129-134): TM only accepts numeric parameterIds.
|
|
1282
|
+
if (Array.isArray(step.parameterIds)) {
|
|
1283
|
+
step.parameterIds = step.parameterIds.filter((p) => typeof p === 'number');
|
|
1284
|
+
}
|
|
1285
|
+
// FE mirror (line 136-150): shed conditional-step UI expansion.
|
|
1286
|
+
if (step.conditions?.children?.length) {
|
|
1287
|
+
for (const c of step.conditions.children) {
|
|
1288
|
+
delete c.groupedActions;
|
|
1289
|
+
delete c.stepActions;
|
|
1290
|
+
delete c.objectOfparameters;
|
|
1291
|
+
if (Array.isArray(c.parameterIds)) {
|
|
1292
|
+
c.parameterIds = c.parameterIds.filter((p) => typeof p === 'number');
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
const forced = opts.executionType;
|
|
1297
|
+
const bucket = forced
|
|
1298
|
+
?? (String(step.executionType ?? '').toUpperCase() === 'MANUAL' ? 'MANUAL' : 'AUTOMATION');
|
|
1299
|
+
if (bucket === 'MANUAL')
|
|
1300
|
+
manualSteps.push(step);
|
|
1301
|
+
else
|
|
1302
|
+
automationSteps.push(step);
|
|
1303
|
+
}
|
|
1304
|
+
// FE mirror (line 192): DUO iff both buckets populated.
|
|
1305
|
+
const execType = automationSteps.length && manualSteps.length
|
|
1306
|
+
? 'DUO'
|
|
1307
|
+
: manualSteps.length ? 'MANUAL' : 'AUTOMATION';
|
|
1308
|
+
// FE mirror (line 271-276): all steps share technologyType → use that, else fall back to TC's.
|
|
1309
|
+
const techs = new Set(picked.map((s) => str(s, 'technologyType')).filter(Boolean));
|
|
1310
|
+
const fnType = techs.size === 1
|
|
1311
|
+
? [...techs][0]
|
|
1312
|
+
: (str(rawTc, 'tcType') ?? 'WEB');
|
|
1313
|
+
const tcfnObj = {
|
|
1314
|
+
name,
|
|
1315
|
+
projectId,
|
|
1316
|
+
moduleId: opts.moduleId,
|
|
1317
|
+
executionType: execType,
|
|
1318
|
+
fnType,
|
|
1319
|
+
reviewApproveStatus: '',
|
|
1320
|
+
};
|
|
1321
|
+
// FE default (function.model.ts:68) — backend requires versionsDetails.name.
|
|
1322
|
+
const versionsDetails = { name: 'v1.0', id: '', syncONOFF: '' };
|
|
1323
|
+
// testcaseId MUST be numeric here — tcfn.service.js:1124 uses it in a Mongo query
|
|
1324
|
+
// against `testdata_iteration.testcaseId` (stored as a Number). A string "42" won't
|
|
1325
|
+
// match `42`, so tdCopy silently returns zero source iterations. FE sends a number
|
|
1326
|
+
// (create-new-fn-modal.component.ts:31 `@Input('tcfnId') _tcfnId: number`).
|
|
1327
|
+
const verData = {
|
|
1328
|
+
tdCopy: !!opts.copyTestData,
|
|
1329
|
+
testcaseId: Number.parseInt(numericId(tcId), 10),
|
|
1330
|
+
fromParent: 'TC',
|
|
1331
|
+
...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
|
|
1332
|
+
};
|
|
1333
|
+
const payload = {
|
|
1334
|
+
tcfnObj,
|
|
1335
|
+
steps: { automationSteps, manualSteps },
|
|
1336
|
+
versionsDetails,
|
|
1337
|
+
operation: 'STEPSTOFN',
|
|
1338
|
+
type: 'FN',
|
|
1339
|
+
verData,
|
|
1340
|
+
};
|
|
1341
|
+
const raw = await this.http.postMultipart('/tm/function', 'functionObj', payload);
|
|
1342
|
+
return toSavedRef(raw, true);
|
|
1343
|
+
}
|
|
1344
|
+
/** Look up the freshly-created FN's version id — STEPSTOFN's response inlines the FN but not its
|
|
1345
|
+
* version, and the replace-in-TC step needs `fnVersionId`. Mirrors the FE
|
|
1346
|
+
* (`create-new-fn-modal.component.ts:getFnVersion`): search `tcfnversion` by parentType=FN
|
|
1347
|
+
* ordered by createdAt asc, take the first. */
|
|
1348
|
+
async fetchFnVersionId(fnId) {
|
|
1349
|
+
const projectId = this.requireProjectId();
|
|
1350
|
+
const body = {
|
|
1351
|
+
searchFields: [
|
|
1352
|
+
{ column: 'createdAt', sort: 'asc' },
|
|
1353
|
+
{ column: 'deleted', value: false, regEx: false },
|
|
1354
|
+
{ column: 'projectId', value: projectId, regEx: false },
|
|
1355
|
+
{ column: 'parentType', value: 'FN', regEx: false },
|
|
1356
|
+
{ column: 'parentId', value: Number.parseInt(numericId(fnId), 10), regEx: false },
|
|
1357
|
+
],
|
|
1358
|
+
startIndex: 0,
|
|
1359
|
+
limit: 10000,
|
|
1360
|
+
};
|
|
1361
|
+
const raw = await this.http.postJson('/tm/tcfnversion/search', body);
|
|
1362
|
+
const rows = pickArray(raw, 'data', 'content');
|
|
1363
|
+
const versionId = rows.length > 0 ? str(rows[0], 'id', '_id') : undefined;
|
|
1364
|
+
if (!versionId)
|
|
1365
|
+
throw new Error(`could not resolve fnVersionId for function ${fnId}`);
|
|
1366
|
+
return versionId;
|
|
1367
|
+
}
|
|
1368
|
+
/** Second half of extract-to-function-and-replace-in-source (FE
|
|
1369
|
+
* `testcase.component.ts:createNewFns → onSave`): after the FN exists, PUT the source TC with the
|
|
1370
|
+
* extracted step rows marked `deleted:true` and a synthetic FUNCTION + FUNCTION_END pair inserted
|
|
1371
|
+
* at the first extracted position. The backend automatically:
|
|
1372
|
+
* - drops testdata columns for params only the deleted steps referenced (allParameters skips
|
|
1373
|
+
* `step.deleted:true` at tcfn.service.js:411)
|
|
1374
|
+
* - injects a per-env FN cell into every TC iteration row using the FN's default iteration
|
|
1375
|
+
* (tcfn.service.js:1032-1085) — so testdata rewiring is free.
|
|
1376
|
+
*
|
|
1377
|
+
* ponytail: refuses DUO / SYNCON test cases and picks that span control-flow or nested FN
|
|
1378
|
+
* brackets. Those cases (v2) need parallel mirroring into the other execution-type array and
|
|
1379
|
+
* bracket-aware validation — out of scope for the first pass. */
|
|
1380
|
+
async replaceStepsWithFunctionCall(tcId, opts) {
|
|
1381
|
+
const projectId = this.requireProjectId();
|
|
1382
|
+
const seqSet = new Set(opts.extractedSeqs);
|
|
1383
|
+
if (seqSet.size === 0)
|
|
1384
|
+
throw new Error('extractedSeqs is required.');
|
|
1385
|
+
const sortedSeqs = [...seqSet].sort((a, b) => a - b);
|
|
1386
|
+
// FE `checkStepsContinueOrNot`: picks must be a contiguous seq range or the resulting FN would
|
|
1387
|
+
// be non-executable and the TC layout would fracture.
|
|
1388
|
+
for (let i = 1; i < sortedSeqs.length; i++) {
|
|
1389
|
+
if (sortedSeqs[i] !== sortedSeqs[i - 1] + 1) {
|
|
1390
|
+
throw new Error(`extractedSeqs must be contiguous — got [${sortedSeqs.join(',')}]`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
const rawTc = await this.fetchRawTestcase(tcId);
|
|
1394
|
+
if (!rawTc || Object.keys(rawTc).length === 0)
|
|
1395
|
+
throw new Error(`test case ${tcId} not found`);
|
|
1396
|
+
// `versionsDetailsRaw` reflects the TC's CURRENTLY-SELECTED version's inline details — only
|
|
1397
|
+
// trustworthy when opts.versionId targets that same version (the common case). When targeting a
|
|
1398
|
+
// DIFFERENT version, prefer that version's own row from rawTc.versions[] for name/executionType/
|
|
1399
|
+
// reviewApproveStatus below; syncONOFF isn't carried per-version there, so it falls back to ''.
|
|
1400
|
+
const versionsDetailsRaw = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object' ? rawTc.versionsDetails : {};
|
|
1401
|
+
const currentVersionId = str(rawTc, 'versionId', 'selectedVersionId') ?? str(versionsDetailsRaw, 'id', '_id', 'versionId');
|
|
1402
|
+
const targetsCurrentVersion = !opts.versionId || opts.versionId === currentVersionId;
|
|
1403
|
+
const versionMatch = Array.isArray(rawTc?.versions)
|
|
1404
|
+
? rawTc.versions.find((v) => str(v, 'id', '_id') === opts.versionId)
|
|
1405
|
+
: undefined;
|
|
1406
|
+
const execType = String((targetsCurrentVersion ? str(rawTc, 'executionType') : str(versionMatch, 'executionType')) ?? '').toUpperCase();
|
|
1407
|
+
if (execType === 'DUO') {
|
|
1408
|
+
throw new Error('DUO test cases are not yet supported for replaceInSource — split the extraction into AUTOMATION-only or MANUAL-only first.');
|
|
1409
|
+
}
|
|
1410
|
+
const syncOnOff = targetsCurrentVersion ? str(versionsDetailsRaw, 'syncONOFF') : undefined;
|
|
1411
|
+
if (syncOnOff === 'SYNCON') {
|
|
1412
|
+
throw new Error('SYNCON (mirrored automation/manual) test cases are not yet supported for replaceInSource.');
|
|
1413
|
+
}
|
|
1414
|
+
const sourceVersionId = opts.versionId ||
|
|
1415
|
+
currentVersionId ||
|
|
1416
|
+
(Array.isArray(rawTc?.versions) && rawTc.versions.length > 0
|
|
1417
|
+
? str(rawTc.versions[0], 'id', '_id')
|
|
1418
|
+
: undefined);
|
|
1419
|
+
if (!sourceVersionId)
|
|
1420
|
+
throw new Error(`test case ${tcId} has no versionId — cannot save`);
|
|
1421
|
+
const rawSteps = await this.fetchStepsCollection(tcId, rawTc, opts.versionId);
|
|
1422
|
+
// Refuse picks that would break brackets. FUNCTION/FUNCTION_END pairs must not be half-picked;
|
|
1423
|
+
// control-flow starts (IF/WHILE_START/FOR_START/…) must not span the range partially.
|
|
1424
|
+
const CONTROL_TYPES = new Set([
|
|
1425
|
+
'FUNCTION', 'FUNCTION_END', 'IF', 'ELSE_IF', 'ELSE', 'IF_END',
|
|
1426
|
+
'FOR_START', 'FOR_END', 'WHILE_START', 'WHILE_END', 'DO', 'DO_WHILE_END',
|
|
1427
|
+
'ITERATOR_START', 'ITERATOR_END', 'LOOP_ITERATION_START', 'LOOP_ITERATION_END',
|
|
1428
|
+
]);
|
|
1429
|
+
for (const s of rawSteps) {
|
|
1430
|
+
if (!seqSet.has(Number(s.seq)))
|
|
1431
|
+
continue;
|
|
1432
|
+
const t = String(s.stepType ?? '').toUpperCase();
|
|
1433
|
+
if (CONTROL_TYPES.has(t)) {
|
|
1434
|
+
throw new Error(`step seq ${s.seq} is a control-flow/FN bracket step (${t}) — extract a plain step range`);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const insertAt = sortedSeqs[0];
|
|
1438
|
+
// Build the FUNCTION + FUNCTION_END pair. FE parent (testcase.component.ts:1953+1988-1994)
|
|
1439
|
+
// overrides `type` from 'FN' to 'TC' before splicing — the FN-call lives inside a TC, so it's
|
|
1440
|
+
// typed accordingly. `id`/`versionId` omitted so backend creates fresh rows.
|
|
1441
|
+
const stepLevelActions = {
|
|
1442
|
+
skip: false, captureScreenshot: false, continueOnFailure: false, skippedReport: false,
|
|
1443
|
+
};
|
|
1444
|
+
const fnCallStep = {
|
|
1445
|
+
seq: insertAt,
|
|
1446
|
+
stepType: 'FUNCTION',
|
|
1447
|
+
type: 'TC',
|
|
1448
|
+
functionId: Number.parseInt(numericId(opts.functionId), 10),
|
|
1449
|
+
fnVersionId: opts.fnVersionId,
|
|
1450
|
+
description: '',
|
|
1451
|
+
projectId,
|
|
1452
|
+
moduleId: opts.fnModuleId,
|
|
1453
|
+
executionType: execType || 'AUTOMATION',
|
|
1454
|
+
technologyType: opts.technologyType,
|
|
1455
|
+
stepLevelActions,
|
|
1456
|
+
};
|
|
1457
|
+
const fnEndStep = {
|
|
1458
|
+
seq: insertAt + 1,
|
|
1459
|
+
stepType: 'FUNCTION_END',
|
|
1460
|
+
type: 'TC',
|
|
1461
|
+
projectId,
|
|
1462
|
+
moduleId: opts.fnModuleId,
|
|
1463
|
+
executionType: execType || 'AUTOMATION',
|
|
1464
|
+
technologyType: opts.technologyType,
|
|
1465
|
+
stepLevelActions,
|
|
1466
|
+
};
|
|
1467
|
+
// Kept steps: preserve id/versionId/parentId (this is an update, not a fresh save) and pass
|
|
1468
|
+
// through as-is. Extracted steps: same, plus `deleted:true` — the backend's param collector
|
|
1469
|
+
// (tcfn.service.js:411) skips deleted rows, so their paramIds automatically drop from the TC's
|
|
1470
|
+
// rebuilt testdata columns. Do NOT strip parameterIds / objectParameterIds / parameterizedParamIds
|
|
1471
|
+
// from the deleted rows — those are what `allParameters` compares against to know what to prune.
|
|
1472
|
+
const kept = [];
|
|
1473
|
+
const deleted = [];
|
|
1474
|
+
for (const s of rawSteps) {
|
|
1475
|
+
const step = { ...s };
|
|
1476
|
+
delete step.subSteps; // transient — flat-only per the STEPSTOFN comment
|
|
1477
|
+
if (seqSet.has(Number(step.seq))) {
|
|
1478
|
+
step.deleted = true;
|
|
1479
|
+
delete step.objectOfparameters; // FE mirror (testcase.component.ts:1272)
|
|
1480
|
+
deleted.push(step);
|
|
1481
|
+
}
|
|
1482
|
+
else {
|
|
1483
|
+
kept.push(step);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
kept.sort((a, b) => Number(a.seq) - Number(b.seq));
|
|
1487
|
+
// Splice fn-call pair at the insert position. seqs 1..N are re-assigned at the end so nothing
|
|
1488
|
+
// downstream can misalign.
|
|
1489
|
+
const afterInsert = kept.filter((k) => Number(k.seq) > sortedSeqs[sortedSeqs.length - 1]);
|
|
1490
|
+
const beforeInsert = kept.filter((k) => Number(k.seq) < insertAt);
|
|
1491
|
+
const orderedLive = [...beforeInsert, fnCallStep, fnEndStep, ...afterInsert];
|
|
1492
|
+
let seq = 1;
|
|
1493
|
+
for (const s of orderedLive) {
|
|
1494
|
+
s.seq = seq++;
|
|
1495
|
+
}
|
|
1496
|
+
// Backend expects both automationSteps and manualSteps buckets. Live steps route by their
|
|
1497
|
+
// executionType; deleted steps come along in their own bucket (FE mirror lines 1268-1284).
|
|
1498
|
+
const automationSteps = [];
|
|
1499
|
+
const manualSteps = [];
|
|
1500
|
+
const bucketFor = (s) => {
|
|
1501
|
+
const et = String(s?.executionType ?? '').toUpperCase();
|
|
1502
|
+
return et === 'MANUAL' ? 'MANUAL' : 'AUTOMATION';
|
|
1503
|
+
};
|
|
1504
|
+
for (const s of orderedLive)
|
|
1505
|
+
(bucketFor(s) === 'MANUAL' ? manualSteps : automationSteps).push(s);
|
|
1506
|
+
for (const s of deleted)
|
|
1507
|
+
(bucketFor(s) === 'MANUAL' ? manualSteps : automationSteps).push(s);
|
|
1508
|
+
// tcfnObj: strict allow-list — extras trip the joi validator. Backend mirrors the FE's normal
|
|
1509
|
+
// update PUT (testcase.component.ts:1376+1495): `operation:"TC"`, `type:"TC"`, same versionsDetails.
|
|
1510
|
+
const tcfnObj = pick(rawTc, TCFNOBJ_ALLOWED_KEYS);
|
|
1511
|
+
tcfnObj['deleted'] = false;
|
|
1512
|
+
tcfnObj['id'] = Number.parseInt(numericId(tcId), 10);
|
|
1513
|
+
tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
|
|
1514
|
+
if (typeof tcfnObj['obsolete'] !== 'boolean')
|
|
1515
|
+
tcfnObj['obsolete'] = false;
|
|
1516
|
+
// Backend joi validator refuses an empty versionsDetails.name (BADREQUEST from /tm/testcase).
|
|
1517
|
+
// `versionsDetailsRaw` only describes the CURRENT version — when targeting a different one,
|
|
1518
|
+
// its name/reviewApproveStatus belong to the wrong version, so prefer `versionMatch` instead.
|
|
1519
|
+
// Fall back to 'v1.0' (the SQA default set by project.service.js) if nothing resolves.
|
|
1520
|
+
const versionName = (targetsCurrentVersion ? str(versionsDetailsRaw, 'name') : undefined) ??
|
|
1521
|
+
str(versionMatch, 'name') ??
|
|
1522
|
+
'v1.0';
|
|
1523
|
+
const reviewApproveStatus = (targetsCurrentVersion ? str(versionsDetailsRaw, 'reviewApproveStatus') : undefined) ??
|
|
1524
|
+
str(versionMatch, 'reviewApproveStatus') ??
|
|
1525
|
+
'';
|
|
1526
|
+
const versionsDetails = {
|
|
1527
|
+
name: versionName,
|
|
1528
|
+
id: sourceVersionId,
|
|
1529
|
+
deleted: false,
|
|
1530
|
+
syncONOFF: syncOnOff ?? '',
|
|
1531
|
+
executionType: execType || 'AUTOMATION',
|
|
1532
|
+
functionProjectIds: [projectId],
|
|
1533
|
+
reviewApproveStatus,
|
|
1534
|
+
};
|
|
1535
|
+
const payload = {
|
|
1536
|
+
tcfnObj,
|
|
1537
|
+
steps: { automationSteps, manualSteps },
|
|
1538
|
+
versionsDetails,
|
|
1539
|
+
operation: 'TC',
|
|
1540
|
+
type: 'TC',
|
|
1541
|
+
};
|
|
1542
|
+
const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
1543
|
+
const flat = raw?.data ?? raw ?? {};
|
|
1544
|
+
const savedId = str(flat, 'id', '_id', 'testcaseId') ?? numericId(tcId);
|
|
1545
|
+
return {
|
|
1546
|
+
id: savedId,
|
|
1547
|
+
created: false,
|
|
1548
|
+
productUrl: this.productUrlFor({ kind: 'testcase', id: savedId }),
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
/** One-shot extract-to-function-and-replace. Chains STEPSTOFN → fetch fn version id → replace in
|
|
1552
|
+
* source TC. If step 2 or 3 fails after step 1 succeeded, the caller sees the fn id in the error
|
|
1553
|
+
* so they can retry or clean up the phantom function. */
|
|
1554
|
+
async extractStepsAsFunctionAndReplace(tcId, opts) {
|
|
1555
|
+
const fnRef = await this.saveStepsAsFunction(tcId, opts);
|
|
1556
|
+
let fnVersionId;
|
|
1557
|
+
try {
|
|
1558
|
+
fnVersionId = await this.fetchFnVersionId(fnRef.id);
|
|
1559
|
+
}
|
|
1560
|
+
catch (e) {
|
|
1561
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1562
|
+
throw new Error(`Function ${fnRef.id} was created but its version lookup failed — the source TC was NOT modified. ${msg}`);
|
|
1563
|
+
}
|
|
1564
|
+
// Reuse tech detection from saveStepsAsFunction (same rule: single tech across picks → that
|
|
1565
|
+
// tech, else fall back to the source TC's tcType).
|
|
1566
|
+
const rawTc = await this.fetchRawTestcase(tcId);
|
|
1567
|
+
const rawSteps = await this.fetchStepsCollection(tcId, rawTc, opts.versionId);
|
|
1568
|
+
const picked = rawSteps.filter((s) => opts.stepSeqs.includes(Number(s.seq)));
|
|
1569
|
+
const techs = new Set(picked.map((s) => str(s, 'technologyType')).filter(Boolean));
|
|
1570
|
+
const technologyType = techs.size === 1 ? [...techs][0] : (str(rawTc, 'tcType') ?? 'WEB');
|
|
1571
|
+
try {
|
|
1572
|
+
const tcRef = await this.replaceStepsWithFunctionCall(tcId, {
|
|
1573
|
+
extractedSeqs: opts.stepSeqs,
|
|
1574
|
+
functionId: fnRef.id,
|
|
1575
|
+
fnVersionId,
|
|
1576
|
+
fnModuleId: opts.moduleId,
|
|
1577
|
+
technologyType,
|
|
1578
|
+
versionId: opts.versionId,
|
|
1579
|
+
});
|
|
1580
|
+
return { ...tcRef, fnId: fnRef.id, fnVersionId };
|
|
1581
|
+
}
|
|
1582
|
+
catch (e) {
|
|
1583
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1584
|
+
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}`);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
/** Soft-delete. `/tm/testcase/delete` returns HTTP 200 either way — its `data` is `true` on real
|
|
1588
|
+
* delete, or an array of dependency records when the TC is still referenced by a defect, suite,
|
|
1589
|
+
* execution plan, scenario, workflow, pipeline, or scheduler (nothing was deleted). Inspect the
|
|
1590
|
+
* shape and throw so the tool surfaces it as a failure instead of a fake success. */
|
|
411
1591
|
async deleteTestCase(id) {
|
|
412
1592
|
const projectId = this.requireProjectId();
|
|
413
|
-
await this.http.postJson('/tm/testcase/delete', {
|
|
1593
|
+
const raw = await this.http.postJson('/tm/testcase/delete', {
|
|
414
1594
|
ids: [Number.parseInt(numericId(id), 10)],
|
|
415
1595
|
projectId,
|
|
416
1596
|
isDelete: true,
|
|
417
1597
|
});
|
|
1598
|
+
const data = raw?.data;
|
|
1599
|
+
if (data === true)
|
|
1600
|
+
return;
|
|
1601
|
+
const deps = Array.isArray(data) ? data[0] : undefined;
|
|
1602
|
+
const list = (m) => m && typeof m === 'object' ? Object.values(m).flat().map(String) : [];
|
|
1603
|
+
const refs = [];
|
|
1604
|
+
const groups = [
|
|
1605
|
+
['defectArr', 'defects'], ['suiteArr', 'suites'], ['epArr', 'execution plans'],
|
|
1606
|
+
['scenarioArr', 'scenarios'], ['wfArr', 'workflows'], ['pipelineArr', 'pipelines'],
|
|
1607
|
+
['schedulerArr', 'schedulers'],
|
|
1608
|
+
];
|
|
1609
|
+
for (const [key, label] of groups) {
|
|
1610
|
+
const ids = list(deps?.[key]);
|
|
1611
|
+
if (ids.length)
|
|
1612
|
+
refs.push(`${label} (${ids.join(', ')})`);
|
|
1613
|
+
}
|
|
1614
|
+
throw new Error(refs.length
|
|
1615
|
+
? `Not deleted — test case is still referenced by ${refs.join('; ')}. Remove those references first.`
|
|
1616
|
+
: 'Not deleted — the backend reported blocking dependencies.');
|
|
1617
|
+
}
|
|
1618
|
+
/** Clone a testcase in place (same project + module) with all steps, versions, testdata,
|
|
1619
|
+
* parameters, object refs, and nested function calls carried over. Backed by the SQA native
|
|
1620
|
+
* `/tm/tcfnCopy/copytc` endpoint (see test-management/src/controllers/tcfnCopy.controller.js).
|
|
1621
|
+
*
|
|
1622
|
+
* Requires the `copy_testcase_across_project` privilege on the current project — enforced
|
|
1623
|
+
* server-side regardless of whether the copy is in-project or across projects.
|
|
1624
|
+
*
|
|
1625
|
+
* When `newTitle` is omitted the backend auto-names the copy as `{sourceName}_copy1`,
|
|
1626
|
+
* `_copy2`, etc. When provided, a follow-up PUT renames the freshly-created TC to that name.
|
|
1627
|
+
*
|
|
1628
|
+
* IMPORTANT — the copy endpoint's `selversions` and `selectedEnv` fields are FILTERS, not
|
|
1629
|
+
* wildcards. Passing empty arrays makes the backend copy the TC shell only (no steps, no
|
|
1630
|
+
* testdata, since both live under versions and per-env iterations). So we fetch the source
|
|
1631
|
+
* TC's version names AND the project's environment names first and pass them explicitly. */
|
|
1632
|
+
async cloneTestCase(id, newTitle, versions) {
|
|
1633
|
+
assertBusinessKeyKind(id, 'TC');
|
|
1634
|
+
const projectId = this.requireProjectId();
|
|
1635
|
+
const src = await this.fetchRawTestcase(id);
|
|
1636
|
+
if (!src || Object.keys(src).length === 0)
|
|
1637
|
+
throw new Error(`test case ${id} not found`);
|
|
1638
|
+
const sourceId = Number.parseInt(numericId(id), 10);
|
|
1639
|
+
const sourceProjId = typeof src.projectId === 'number' ? src.projectId : projectId;
|
|
1640
|
+
const sourceModuleId = typeof src.moduleId === 'number' ? src.moduleId : 0;
|
|
1641
|
+
// Fetch source's version names — required, empty means no versions get copied.
|
|
1642
|
+
const versionsRaw = await this.http.getJson(`/tm/testcase/version/${sourceId}/${sourceProjId}`);
|
|
1643
|
+
const versionsArr = Array.isArray(versionsRaw) ? versionsRaw : (versionsRaw?.data ?? []);
|
|
1644
|
+
const availableNames = versionsArr
|
|
1645
|
+
.map((v) => str(v, 'name'))
|
|
1646
|
+
.filter((n) => !!n);
|
|
1647
|
+
if (availableNames.length === 0) {
|
|
1648
|
+
throw new Error(`Cannot clone ${id}: source has no versions to copy.`);
|
|
1649
|
+
}
|
|
1650
|
+
const versionNames = filterVersionNames(availableNames, versions, id);
|
|
1651
|
+
// Fetch project's environment names — empty means per-env testdata rows are dropped
|
|
1652
|
+
// (only the "general" fallback iteration survives).
|
|
1653
|
+
const envsRaw = await this.http.getJson(`/pm/fields/8?projectId=${sourceProjId}&fieldType=environment`);
|
|
1654
|
+
const envsArr = Array.isArray(envsRaw) ? envsRaw : (envsRaw?.data ?? []);
|
|
1655
|
+
const envNames = envsArr
|
|
1656
|
+
.map((e) => str(e, 'name'))
|
|
1657
|
+
.filter((n) => !!n);
|
|
1658
|
+
await this.http.postJson('/tm/tcfnCopy/copytc', {
|
|
1659
|
+
type: 'TC',
|
|
1660
|
+
tcfnList: [{ id: sourceId, selversions: versionNames }],
|
|
1661
|
+
sourceProjId,
|
|
1662
|
+
sourceModuleId,
|
|
1663
|
+
destinationProjId: sourceProjId,
|
|
1664
|
+
destinationModuleId: sourceModuleId,
|
|
1665
|
+
selectedEnv: envNames,
|
|
1666
|
+
});
|
|
1667
|
+
// Find the newly-created copy: highest-id TC whose `projectLevelcopiedFrom.id` matches. Not
|
|
1668
|
+
// going through `searchBody` because the SQA search protocol lets a searchField have EITHER a
|
|
1669
|
+
// `value` (filter) OR a `sort` (ordering) — the helper only builds value entries.
|
|
1670
|
+
const searchRaw = await this.http.postJson('/tm/testcase/search', {
|
|
1671
|
+
searchFields: [
|
|
1672
|
+
{ column: 'deleted', value: false },
|
|
1673
|
+
{ column: 'projectId', value: sourceProjId },
|
|
1674
|
+
{ column: 'projectLevelcopiedFrom.id', value: sourceId },
|
|
1675
|
+
{ column: 'id', sort: 'dsc' },
|
|
1676
|
+
],
|
|
1677
|
+
startIndex: 0,
|
|
1678
|
+
limit: 1,
|
|
1679
|
+
selectFields: ['id', 'code', 'name'],
|
|
1680
|
+
});
|
|
1681
|
+
const rows = pickArray(searchRaw, 'data', 'content', 'testCases');
|
|
1682
|
+
if (rows.length === 0) {
|
|
1683
|
+
throw new Error('Clone was requested but the newly-created testcase could not be located afterwards. ' +
|
|
1684
|
+
'The backend may still be processing the copy, or the caller may lack the ' +
|
|
1685
|
+
'`copy_testcase_across_project` privilege on this project.');
|
|
1686
|
+
}
|
|
1687
|
+
const created = rows[0];
|
|
1688
|
+
const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
|
|
1689
|
+
// Optional rename: PUT /tm/testcase/:id with { name, projectId }.
|
|
1690
|
+
if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
|
|
1691
|
+
await this.http.putJson(`/tm/testcase/${newIdNum}`, {
|
|
1692
|
+
name: newTitle.trim(),
|
|
1693
|
+
projectId: sourceProjId,
|
|
1694
|
+
});
|
|
1695
|
+
created.name = newTitle.trim();
|
|
1696
|
+
}
|
|
1697
|
+
return {
|
|
1698
|
+
id: String(newIdNum),
|
|
1699
|
+
created: true,
|
|
1700
|
+
productUrl: this.productUrlFor({ kind: 'testcase', id: String(newIdNum) }),
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
// ---------------------------------------------------------------------- Functions (reusable)
|
|
1704
|
+
// Same tcfnObj collection as testcases — type='FN' instead of 'TC'. Search / getById / delete /
|
|
1705
|
+
// steps all mirror the testcase endpoints one-to-one.
|
|
1706
|
+
async listFunctions(q) {
|
|
1707
|
+
const projectId = this.requireProjectId();
|
|
1708
|
+
const fields = [
|
|
1709
|
+
{ column: 'deleted', value: false },
|
|
1710
|
+
{ column: 'projectId', value: projectId },
|
|
1711
|
+
];
|
|
1712
|
+
if (q.search)
|
|
1713
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
1714
|
+
if (q.fnType)
|
|
1715
|
+
fields.push({ column: 'fnType', value: q.fnType });
|
|
1716
|
+
if (q.executionType)
|
|
1717
|
+
fields.push({ column: 'executionType', value: q.executionType });
|
|
1718
|
+
if (q.moduleId)
|
|
1719
|
+
fields.push({ column: 'moduleId', value: Number.parseInt(numericId(q.moduleId), 10) });
|
|
1720
|
+
const createdBy = this.resolveUserFilter(q.createdBy);
|
|
1721
|
+
if (createdBy != null)
|
|
1722
|
+
fields.push({ column: 'createdBy', value: createdBy });
|
|
1723
|
+
const updatedBy = this.resolveUserFilter(q.updatedBy);
|
|
1724
|
+
if (updatedBy != null)
|
|
1725
|
+
fields.push({ column: 'updatedBy', value: updatedBy });
|
|
1726
|
+
const body = this.searchBody(fields, {
|
|
1727
|
+
startIndex: q.offset ?? 0,
|
|
1728
|
+
limit: q.limit ?? 100,
|
|
1729
|
+
selectFields: ['id', 'code', 'name', 'description', 'fnType', 'executionType',
|
|
1730
|
+
'reviewApproveStatus', 'stepCount'],
|
|
1731
|
+
});
|
|
1732
|
+
const raw = await this.http.postJson('/tm/function/search', body);
|
|
1733
|
+
const rows = pickArray(raw, 'data', 'content', 'functions');
|
|
1734
|
+
return withTotal(rows.map(toFunctionSummary), raw);
|
|
1735
|
+
}
|
|
1736
|
+
async getFunction(id) {
|
|
1737
|
+
const projectId = this.requireProjectId();
|
|
1738
|
+
const fnNum = numericId(id);
|
|
1739
|
+
const raw = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
|
|
1740
|
+
let doc = raw?.data ?? raw;
|
|
1741
|
+
if (Array.isArray(doc))
|
|
1742
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
1743
|
+
if (!doc || typeof doc !== 'object' || Object.keys(doc).length === 0) {
|
|
1744
|
+
throw new Error(`function ${id} not found`);
|
|
1745
|
+
}
|
|
1746
|
+
const fn = toFunction(doc);
|
|
1747
|
+
// Fetch version rows ONCE — reused to (a) pick the newest for the steps call and (b) hydrate
|
|
1748
|
+
// `fn.versions` for the caller. The FN doc from `/tm/function/:id/:pid` has NO inline versions[]
|
|
1749
|
+
// (unlike TC's getById), so this sibling endpoint is the only source. Non-fatal on failure.
|
|
1750
|
+
let versionRows = [];
|
|
1751
|
+
try {
|
|
1752
|
+
versionRows = await this.fetchFunctionVersionRows(fnNum);
|
|
1753
|
+
}
|
|
1754
|
+
catch { /* leave empty */ }
|
|
1755
|
+
const currentVersionId = versionRows.length
|
|
1756
|
+
? str(versionRows[0], 'id', '_id', 'versionId')
|
|
1757
|
+
: undefined;
|
|
1758
|
+
if (versionRows.length)
|
|
1759
|
+
fn.versions = toVersionEntries(versionRows, currentVersionId);
|
|
1760
|
+
if (currentVersionId) {
|
|
1761
|
+
try {
|
|
1762
|
+
const rows = await this.fetchFunctionStepsForVersion(fnNum, currentVersionId);
|
|
1763
|
+
if (rows.length > 0)
|
|
1764
|
+
fn.steps = rows.map((s, i) => fromSqaStep(s, i + 1));
|
|
1765
|
+
}
|
|
1766
|
+
catch { /* leave steps empty */ }
|
|
1767
|
+
}
|
|
1768
|
+
return fn;
|
|
1769
|
+
}
|
|
1770
|
+
/** Steps for one FN version. Unlike TC docs, the FN doc from `/tm/function/:id/:projectId`
|
|
1771
|
+
* doesn't carry a versionId inline — callers resolve the target versionId (usually the newest,
|
|
1772
|
+
* from `fetchFunctionVersionRows`) and pass it here. */
|
|
1773
|
+
async fetchFunctionStepsForVersion(fnNum, versionId) {
|
|
1774
|
+
const projectId = this.requireProjectId();
|
|
1775
|
+
const path = '/tm/function/steps' +
|
|
1776
|
+
`?projectId=${projectId}` +
|
|
1777
|
+
`&tcfnId=${fnNum}` +
|
|
1778
|
+
`&versionId=${encodeURIComponent(versionId)}` +
|
|
1779
|
+
'&type=FN';
|
|
1780
|
+
const body = await this.http.getJson(path);
|
|
1781
|
+
return Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
|
|
1782
|
+
}
|
|
1783
|
+
/** The function's version rows, newest-first. */
|
|
1784
|
+
async fetchFunctionVersionRows(fnNum) {
|
|
1785
|
+
const projectId = this.requireProjectId();
|
|
1786
|
+
const raw = await this.http.getJson(`/tm/function/version/${fnNum}/${projectId}`);
|
|
1787
|
+
const rows = pickArray(raw, 'data', 'content', 'versions');
|
|
1788
|
+
if (!rows.length)
|
|
1789
|
+
return [];
|
|
1790
|
+
return [...rows].sort((a, b) => Number(b?.versionNumber ?? 0) - Number(a?.versionNumber ?? 0));
|
|
1791
|
+
}
|
|
1792
|
+
/** Soft-delete a function. `/tm/function/delete` returns HTTP 200 either way — its `data` is
|
|
1793
|
+
* `true` on real delete, or an array of dependency records when the FN is still referenced by a
|
|
1794
|
+
* TC / FN / workflow (nothing was deleted). Inspect the shape and throw so the tool surfaces it
|
|
1795
|
+
* as a failure instead of a fake success. */
|
|
1796
|
+
async deleteFunction(id) {
|
|
1797
|
+
const projectId = this.requireProjectId();
|
|
1798
|
+
const raw = await this.http.postJson('/tm/function/delete', {
|
|
1799
|
+
ids: [Number.parseInt(numericId(id), 10)],
|
|
1800
|
+
projectId,
|
|
1801
|
+
isDelete: true,
|
|
1802
|
+
});
|
|
1803
|
+
const data = raw?.data;
|
|
1804
|
+
if (data === true)
|
|
1805
|
+
return;
|
|
1806
|
+
const deps = Array.isArray(data) ? data[0] : undefined;
|
|
1807
|
+
const list = (m) => m && typeof m === 'object' ? Object.values(m).flat().map(String) : [];
|
|
1808
|
+
const tcs = list(deps?.tcArr);
|
|
1809
|
+
const fns = list(deps?.fnArr);
|
|
1810
|
+
const wfs = list(deps?.wfArr);
|
|
1811
|
+
const refs = [];
|
|
1812
|
+
if (tcs.length)
|
|
1813
|
+
refs.push(`test cases (${tcs.join(', ')})`);
|
|
1814
|
+
if (fns.length)
|
|
1815
|
+
refs.push(`functions (${fns.join(', ')})`);
|
|
1816
|
+
if (wfs.length)
|
|
1817
|
+
refs.push(`workflows (${wfs.join(', ')})`);
|
|
1818
|
+
throw new Error(refs.length
|
|
1819
|
+
? `Not deleted — function is still referenced by ${refs.join('; ')}. Remove those references first.`
|
|
1820
|
+
: 'Not deleted — the backend reported blocking dependencies.');
|
|
1821
|
+
}
|
|
1822
|
+
/** Copy a function in place — new tcfnObj row, all versions/steps/testdata carried over. Same
|
|
1823
|
+
* shape as `cloneTestCase`: version names + env names come from the source, then POST to
|
|
1824
|
+
* `/tm/tcfnCopy/copytc` with `type:'FN'` (backend enforces `copy_function_across_projects`).
|
|
1825
|
+
* When `newTitle` is omitted the backend auto-names as `{sourceName}_copy1`, etc.; when
|
|
1826
|
+
* provided a follow-up PUT `/tm/function/:id` renames the freshly-created FN. */
|
|
1827
|
+
async cloneFunction(id, newTitle, versions) {
|
|
1828
|
+
assertBusinessKeyKind(id, 'FN');
|
|
1829
|
+
const projectId = this.requireProjectId();
|
|
1830
|
+
const fnNum = numericId(id);
|
|
1831
|
+
const rawFn = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
|
|
1832
|
+
let src = rawFn?.data ?? rawFn;
|
|
1833
|
+
if (Array.isArray(src))
|
|
1834
|
+
src = src.length > 0 ? src[0] : {};
|
|
1835
|
+
if (!src || typeof src !== 'object' || Object.keys(src).length === 0) {
|
|
1836
|
+
throw new Error(`function ${id} not found`);
|
|
1837
|
+
}
|
|
1838
|
+
const sourceId = Number.parseInt(fnNum, 10);
|
|
1839
|
+
const sourceProjId = typeof src.projectId === 'number' ? src.projectId : projectId;
|
|
1840
|
+
const sourceModuleId = typeof src.moduleId === 'number' ? src.moduleId : 0;
|
|
1841
|
+
// Fetch source's version names — required, empty means no versions get copied.
|
|
1842
|
+
const versionsRaw = await this.http.getJson(`/tm/function/version/${sourceId}/${sourceProjId}`);
|
|
1843
|
+
const versionsArr = Array.isArray(versionsRaw) ? versionsRaw : (versionsRaw?.data ?? []);
|
|
1844
|
+
const availableNames = versionsArr
|
|
1845
|
+
.map((v) => str(v, 'name'))
|
|
1846
|
+
.filter((n) => !!n);
|
|
1847
|
+
if (availableNames.length === 0) {
|
|
1848
|
+
throw new Error(`Cannot clone ${id}: source has no versions to copy.`);
|
|
1849
|
+
}
|
|
1850
|
+
const versionNames = filterVersionNames(availableNames, versions, id);
|
|
1851
|
+
// Same environment fetch as cloneTestCase — empty means per-env testdata rows are dropped.
|
|
1852
|
+
const envsRaw = await this.http.getJson(`/pm/fields/8?projectId=${sourceProjId}&fieldType=environment`);
|
|
1853
|
+
const envsArr = Array.isArray(envsRaw) ? envsRaw : (envsRaw?.data ?? []);
|
|
1854
|
+
const envNames = envsArr
|
|
1855
|
+
.map((e) => str(e, 'name'))
|
|
1856
|
+
.filter((n) => !!n);
|
|
1857
|
+
await this.http.postJson('/tm/tcfnCopy/copytc', {
|
|
1858
|
+
type: 'FN',
|
|
1859
|
+
tcfnList: [{ id: sourceId, selversions: versionNames }],
|
|
1860
|
+
sourceProjId,
|
|
1861
|
+
sourceModuleId,
|
|
1862
|
+
destinationProjId: sourceProjId,
|
|
1863
|
+
destinationModuleId: sourceModuleId,
|
|
1864
|
+
selectedEnv: envNames,
|
|
1865
|
+
});
|
|
1866
|
+
// Find the newly-created copy: highest-id FN whose `projectLevelcopiedFrom.id` matches source.
|
|
1867
|
+
const searchRaw = await this.http.postJson('/tm/function/search', {
|
|
1868
|
+
searchFields: [
|
|
1869
|
+
{ column: 'deleted', value: false },
|
|
1870
|
+
{ column: 'projectId', value: sourceProjId },
|
|
1871
|
+
{ column: 'projectLevelcopiedFrom.id', value: sourceId },
|
|
1872
|
+
{ column: 'id', sort: 'dsc' },
|
|
1873
|
+
],
|
|
1874
|
+
startIndex: 0,
|
|
1875
|
+
limit: 1,
|
|
1876
|
+
selectFields: ['id', 'code', 'name'],
|
|
1877
|
+
});
|
|
1878
|
+
const rows = pickArray(searchRaw, 'data', 'content', 'functions');
|
|
1879
|
+
if (rows.length === 0) {
|
|
1880
|
+
throw new Error('Clone was requested but the newly-created function could not be located afterwards. ' +
|
|
1881
|
+
'The backend may still be processing the copy, or the caller may lack the ' +
|
|
1882
|
+
'`copy_function_across_projects` privilege on this project.');
|
|
1883
|
+
}
|
|
1884
|
+
const created = rows[0];
|
|
1885
|
+
const newIdNum = Number.parseInt(str(created, 'id', '_id') ?? '', 10);
|
|
1886
|
+
// Optional rename: PUT /tm/function/:id — inline validator accepts { name } only (no projectId).
|
|
1887
|
+
if (newTitle && newTitle.trim() && Number.isFinite(newIdNum)) {
|
|
1888
|
+
await this.http.putJson(`/tm/function/${newIdNum}`, {
|
|
1889
|
+
name: newTitle.trim(),
|
|
1890
|
+
});
|
|
1891
|
+
created.name = newTitle.trim();
|
|
1892
|
+
}
|
|
1893
|
+
return {
|
|
1894
|
+
id: String(newIdNum),
|
|
1895
|
+
created: true,
|
|
1896
|
+
productUrl: this.productUrlFor({ kind: 'function', id: String(newIdNum) }),
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
/** Snapshot the CURRENT server state of a function under a NEW version name — same FN id, new
|
|
1900
|
+
* `tcfn_version` row with cloned steps. Mirrors `saveAsNewVersion` (see there for the shape
|
|
1901
|
+
* rationale) with `type:'FN'` and the multipart route `PUT /tm/function` / key `functionObj`.
|
|
1902
|
+
* Source-version resolution goes through the FN's `/tm/function/version/:id/:pid` list — the
|
|
1903
|
+
* FN doc has no inline versionId. */
|
|
1904
|
+
async saveAsNewFunctionVersion(id, opts) {
|
|
1905
|
+
assertBusinessKeyKind(id, 'FN');
|
|
1906
|
+
const projectId = this.requireProjectId();
|
|
1907
|
+
const versionName = opts.versionName?.trim();
|
|
1908
|
+
if (!versionName)
|
|
1909
|
+
throw new Error('versionName is required.');
|
|
1910
|
+
const fnNum = numericId(id);
|
|
1911
|
+
const rawFn = await this.http.getJson(`/tm/function/${fnNum}/${projectId}`);
|
|
1912
|
+
let rawTc = rawFn?.data ?? rawFn;
|
|
1913
|
+
if (Array.isArray(rawTc))
|
|
1914
|
+
rawTc = rawTc.length > 0 ? rawTc[0] : {};
|
|
1915
|
+
if (!rawTc || typeof rawTc !== 'object' || Object.keys(rawTc).length === 0) {
|
|
1916
|
+
throw new Error(`function ${id} not found`);
|
|
1917
|
+
}
|
|
1918
|
+
// FN doc has no inline versionId — resolve from the version list. Caller override wins.
|
|
1919
|
+
let sourceVersionId = opts.sourceVersionId;
|
|
1920
|
+
if (!sourceVersionId) {
|
|
1921
|
+
const versionRows = await this.fetchFunctionVersionRows(fnNum);
|
|
1922
|
+
if (versionRows.length > 0)
|
|
1923
|
+
sourceVersionId = str(versionRows[0], 'id', '_id', 'versionId');
|
|
1924
|
+
}
|
|
1925
|
+
// Fetch steps under the resolved (or newest) version.
|
|
1926
|
+
const rawSteps = sourceVersionId
|
|
1927
|
+
? await this.http.getJson('/tm/function/steps' +
|
|
1928
|
+
`?projectId=${projectId}` +
|
|
1929
|
+
`&tcfnId=${fnNum}` +
|
|
1930
|
+
`&versionId=${encodeURIComponent(sourceVersionId)}` +
|
|
1931
|
+
'&type=FN').then((body) => (Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : []))
|
|
1932
|
+
: [];
|
|
1933
|
+
const automationSteps = [];
|
|
1934
|
+
const manualSteps = [];
|
|
1935
|
+
for (const s of rawSteps) {
|
|
1936
|
+
const step = pick(s, STEP_ALLOWED_KEYS);
|
|
1937
|
+
const et = typeof step['executionType'] === 'string' ? String(step['executionType']).toUpperCase() : '';
|
|
1938
|
+
if (et === 'MANUAL')
|
|
1939
|
+
manualSteps.push(step);
|
|
1940
|
+
else
|
|
1941
|
+
automationSteps.push(step);
|
|
1942
|
+
}
|
|
1943
|
+
const tcfnObj = pick(rawTc, TCFNOBJ_FN_ALLOWED_KEYS);
|
|
1944
|
+
tcfnObj['deleted'] = false;
|
|
1945
|
+
tcfnObj['projectId'] = typeof tcfnObj['projectId'] === 'number' ? tcfnObj['projectId'] : projectId;
|
|
1946
|
+
// FN update joi REQUIRES `fnType` and REJECTS `obsolete`. Guarantee fnType from the raw doc
|
|
1947
|
+
// (falls through to legacy `tcType` if a pre-migration FN row is missing fnType); never set
|
|
1948
|
+
// obsolete.
|
|
1949
|
+
if (typeof tcfnObj['fnType'] !== 'string' || !tcfnObj['fnType']) {
|
|
1950
|
+
const fallback = str(rawTc, 'fnType', 'tcType') ?? 'WEB';
|
|
1951
|
+
tcfnObj['fnType'] = fallback;
|
|
1952
|
+
}
|
|
1953
|
+
// Version stub mirrors function.component.ts:900-907 — empty id tells the backend to create a
|
|
1954
|
+
// fresh row; SYNCOFF is normalised to '' the same way as the TC path. FN update joi REJECTS
|
|
1955
|
+
// `functionProjectIds` (allowed on TC's versionsDetails but not FN's — see functionValidator.js
|
|
1956
|
+
// updateFnBodyValidation.versionsDetails), so don't include it.
|
|
1957
|
+
const prevSync = rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
|
|
1958
|
+
? str(rawTc.versionsDetails, 'syncONOFF')
|
|
1959
|
+
: undefined;
|
|
1960
|
+
const versionsDetails = {
|
|
1961
|
+
name: versionName,
|
|
1962
|
+
id: '',
|
|
1963
|
+
syncONOFF: prevSync === 'SYNCOFF' ? '' : (prevSync ?? ''),
|
|
1964
|
+
deleted: false,
|
|
1965
|
+
executionType: str(rawTc, 'executionType') ?? 'AUTOMATION',
|
|
1966
|
+
reviewApproveStatus: '',
|
|
1967
|
+
};
|
|
1968
|
+
const verData = {
|
|
1969
|
+
tdCopy: !!opts.tdCopy,
|
|
1970
|
+
...(sourceVersionId ? { selVersionId: sourceVersionId } : {}),
|
|
1971
|
+
};
|
|
1972
|
+
const payload = {
|
|
1973
|
+
tcfnObj,
|
|
1974
|
+
steps: { automationSteps, manualSteps },
|
|
1975
|
+
versionsDetails,
|
|
1976
|
+
operation: 'SAVENEWVERSION',
|
|
1977
|
+
type: 'FN',
|
|
1978
|
+
verData,
|
|
1979
|
+
};
|
|
1980
|
+
const raw = await this.http.putMultipart('/tm/function', 'functionObj', payload);
|
|
1981
|
+
const flat = raw?.data ?? raw ?? {};
|
|
1982
|
+
const savedId = str(flat, 'id', '_id', 'functionId', 'testcaseId') ?? fnNum;
|
|
1983
|
+
const newVersionId = str(flat, 'versionId') ??
|
|
1984
|
+
(flat?.versionsDetails && typeof flat.versionsDetails === 'object'
|
|
1985
|
+
? str(flat.versionsDetails, 'id')
|
|
1986
|
+
: undefined);
|
|
1987
|
+
return {
|
|
1988
|
+
id: savedId,
|
|
1989
|
+
created: false,
|
|
1990
|
+
productUrl: this.productUrlFor({ kind: 'function', id: savedId }),
|
|
1991
|
+
...(newVersionId ? { artifacts: [`versionId=${newVersionId}`] } : {}),
|
|
1992
|
+
};
|
|
418
1993
|
}
|
|
419
1994
|
/** Never throw on unresolvable storyRef — the Java code logs and moves on so the save still lands. */
|
|
420
1995
|
async resolveStoryNumericIdOrNull(storyRef) {
|
|
@@ -427,24 +2002,142 @@ export class SimplifyQaAdapter {
|
|
|
427
2002
|
return undefined;
|
|
428
2003
|
}
|
|
429
2004
|
}
|
|
430
|
-
/** Resolve every
|
|
431
|
-
*
|
|
432
|
-
*
|
|
2005
|
+
/** Resolve every param name referenced by the TC to a SQA parameter id via get-or-create.
|
|
2006
|
+
* Sources: (a) `${name}` tokens in every step's description (manual + automation), and
|
|
2007
|
+
* (b) keys of every automation step's `recordedParam` (action-argument names like
|
|
2008
|
+
* "packageName", "activity", "keys"). Name-uniqueness is project-wide on the backend, so
|
|
2009
|
+
* get-or-create means every TC using `${appUrl}` (or the same "packageName" recorded param)
|
|
2010
|
+
* links to the same parameter row. */
|
|
433
2011
|
async resolveParamIds(projectId, moduleId, tc) {
|
|
434
2012
|
const paramIdByName = new Map();
|
|
435
2013
|
const steps = Array.isArray(tc.steps) ? tc.steps : [];
|
|
436
2014
|
for (const s of steps) {
|
|
437
|
-
const
|
|
438
|
-
for (const name of names) {
|
|
2015
|
+
for (const name of extractParamNames(s.description)) {
|
|
439
2016
|
if (paramIdByName.has(name))
|
|
440
2017
|
continue;
|
|
441
2018
|
const defaultValue = paramDefault(name, tc.data, s.value);
|
|
442
2019
|
const id = await this.getOrCreateParameter(projectId, moduleId, name, defaultValue);
|
|
443
2020
|
paramIdByName.set(name, id);
|
|
444
2021
|
}
|
|
2022
|
+
if (s.recordedParam) {
|
|
2023
|
+
for (const [name, value] of Object.entries(s.recordedParam)) {
|
|
2024
|
+
if (paramIdByName.has(name))
|
|
2025
|
+
continue;
|
|
2026
|
+
const id = await this.getOrCreateParameter(projectId, moduleId, name, String(value ?? ''));
|
|
2027
|
+
paramIdByName.set(name, id);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
445
2030
|
}
|
|
446
2031
|
return paramIdByName;
|
|
447
2032
|
}
|
|
2033
|
+
actionCatalog() {
|
|
2034
|
+
if (this.actionCatalogCache)
|
|
2035
|
+
return this.actionCatalogCache;
|
|
2036
|
+
const projectId = this.requireProjectId();
|
|
2037
|
+
this.actionCatalogCache = (async () => {
|
|
2038
|
+
// `projectId in [0, projectId]` — 0 is the customer-wide default catalog, projectId is the
|
|
2039
|
+
// per-project overrides layered on top. Same filter the SQA UI uses (steps.model.ts:59).
|
|
2040
|
+
const body = {
|
|
2041
|
+
searchFields: [
|
|
2042
|
+
{ column: 'deleted', value: false },
|
|
2043
|
+
{ column: 'projectId', value: { $in: [0, projectId] } },
|
|
2044
|
+
],
|
|
2045
|
+
startIndex: 0,
|
|
2046
|
+
limit: 100000,
|
|
2047
|
+
selectFields: ['id', 'name', 'technology', 'objectTemplate', 'objectRequired'],
|
|
2048
|
+
};
|
|
2049
|
+
const raw = await this.http.postJson('/tm/action/search', body);
|
|
2050
|
+
const rows = pickArray(raw, 'data', 'content', 'actions');
|
|
2051
|
+
const map = new Map();
|
|
2052
|
+
for (const row of rows) {
|
|
2053
|
+
const idStr = str(row, 'id', '_id', 'actionId');
|
|
2054
|
+
const name = str(row, 'name');
|
|
2055
|
+
const tech = str(row, 'technology');
|
|
2056
|
+
if (!idStr || !name || !tech)
|
|
2057
|
+
continue;
|
|
2058
|
+
const id = Number.parseInt(idStr, 10);
|
|
2059
|
+
if (!Number.isFinite(id))
|
|
2060
|
+
continue;
|
|
2061
|
+
const objectRequired = row?.objectRequired === true;
|
|
2062
|
+
const otRaw = row?.objectTemplate;
|
|
2063
|
+
const objectTemplate = typeof otRaw === 'number' ? otRaw
|
|
2064
|
+
: typeof otRaw === 'string' && /^\d+$/.test(otRaw) ? Number.parseInt(otRaw, 10)
|
|
2065
|
+
: undefined;
|
|
2066
|
+
// Prefer the higher-priority project override when the same (tech, name) has both a
|
|
2067
|
+
// default and a project row: last write wins if we sort defaults first. Backend returns
|
|
2068
|
+
// whichever ordering; we resolve determinism by preferring a non-zero projectId row.
|
|
2069
|
+
const key = `${tech.toUpperCase()}::${name.toLowerCase()}`;
|
|
2070
|
+
const prev = map.get(key);
|
|
2071
|
+
const isProjectOverride = typeof row?.projectId === 'number' && row.projectId !== 0;
|
|
2072
|
+
if (!prev || isProjectOverride)
|
|
2073
|
+
map.set(key, { id, objectRequired, objectTemplate });
|
|
2074
|
+
}
|
|
2075
|
+
return map;
|
|
2076
|
+
})().catch((e) => { this.actionCatalogCache = null; throw e; });
|
|
2077
|
+
return this.actionCatalogCache;
|
|
2078
|
+
}
|
|
2079
|
+
/** POST /tm/object/createObjectSeq — get-or-create the shared object row by locator match. The
|
|
2080
|
+
* backend does the dedup (`object.controller.js:268-370`) — same-locators reuses, same-disname-
|
|
2081
|
+
* different-locators creates a suffixed one. Returns the numeric object id.
|
|
2082
|
+
*
|
|
2083
|
+
* ponytail: minimum body — no thumbnail_url / pageScreenShot_url / parameterId. The recording
|
|
2084
|
+
* path enriches these; the tc_save path doesn't have screenshots to attach, and the object
|
|
2085
|
+
* works for execution without them. */
|
|
2086
|
+
async resolveObjectId(objectRef, projectId, moduleId, technology, objectTemplateId) {
|
|
2087
|
+
const attributes = (objectRef.locators ?? []).map((l) => ({
|
|
2088
|
+
name: l.name,
|
|
2089
|
+
value: l.value,
|
|
2090
|
+
...(l.unique === true ? { unique: true } : {}),
|
|
2091
|
+
}));
|
|
2092
|
+
const body = {
|
|
2093
|
+
projectId,
|
|
2094
|
+
moduleId,
|
|
2095
|
+
objecttemplateId: objectTemplateId,
|
|
2096
|
+
technology,
|
|
2097
|
+
disname: objectRef.name ?? '',
|
|
2098
|
+
attributes,
|
|
2099
|
+
parameterId: [],
|
|
2100
|
+
description: '',
|
|
2101
|
+
};
|
|
2102
|
+
const raw = await this.http.postJson('/tm/object/createObjectSeq', body);
|
|
2103
|
+
const flat = raw?.data ?? raw;
|
|
2104
|
+
const id = str(flat, 'id', '_id', 'objectId');
|
|
2105
|
+
if (!id)
|
|
2106
|
+
throw new Error(`createObjectSeq returned no id for object "${objectRef.name}": ${JSON.stringify(raw)}`);
|
|
2107
|
+
return Number.parseInt(id, 10);
|
|
2108
|
+
}
|
|
2109
|
+
/** For every automation step, resolve `actionId` (from action name + technology) and
|
|
2110
|
+
* `objectId` (via createObjectSeq get-or-create) so the mapper can emit a valid step. Runs
|
|
2111
|
+
* once per save, aligned by step index. */
|
|
2112
|
+
async resolveAutomationSteps(projectId, moduleId, tc) {
|
|
2113
|
+
const catalog = await this.actionCatalog();
|
|
2114
|
+
const steps = Array.isArray(tc.steps) ? tc.steps : [];
|
|
2115
|
+
const out = [];
|
|
2116
|
+
for (let i = 0; i < steps.length; i++) {
|
|
2117
|
+
const s = steps[i];
|
|
2118
|
+
const tech = (TECH_BY_STEPTYPE[s.stepType] ?? deriveTcType(tc)).toUpperCase();
|
|
2119
|
+
const name = (s.actionType ?? '').trim();
|
|
2120
|
+
if (!name) {
|
|
2121
|
+
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").`);
|
|
2122
|
+
}
|
|
2123
|
+
const meta = catalog.get(`${tech}::${name.toLowerCase()}`);
|
|
2124
|
+
if (!meta) {
|
|
2125
|
+
throw new Error(`Automation step ${i + 1}: no SQA action named "${name}" for technology ${tech}. Check the action catalog in the SQA UI.`);
|
|
2126
|
+
}
|
|
2127
|
+
let objectId = null;
|
|
2128
|
+
if (meta.objectRequired) {
|
|
2129
|
+
if (!s.objectRef || !s.objectRef.name || !(s.objectRef.locators?.length)) {
|
|
2130
|
+
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}]}.`);
|
|
2131
|
+
}
|
|
2132
|
+
if (meta.objectTemplate == null) {
|
|
2133
|
+
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.`);
|
|
2134
|
+
}
|
|
2135
|
+
objectId = await this.resolveObjectId(s.objectRef, projectId, moduleId, tech, meta.objectTemplate);
|
|
2136
|
+
}
|
|
2137
|
+
out.push({ actionId: meta.id, objectId });
|
|
2138
|
+
}
|
|
2139
|
+
return out;
|
|
2140
|
+
}
|
|
448
2141
|
/** Case-insensitive project-scoped exact-name lookup. Backend duplicate-name check uses Mongo
|
|
449
2142
|
* collation strength:2, so a case-sensitive search here misses params stored under different
|
|
450
2143
|
* casing — the next POST then trips "Duplicate Name Error". Returns null when not found. */
|
|
@@ -617,10 +2310,178 @@ export class SimplifyQaAdapter {
|
|
|
617
2310
|
executionPlatform: { LOCAL: {}, REMOTE: {}, CLOUD: {} },
|
|
618
2311
|
};
|
|
619
2312
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
2313
|
+
async resolveObjectTemplateId(technology) {
|
|
2314
|
+
if (!this.objectTemplateCache) {
|
|
2315
|
+
this.objectTemplateCache = (async () => {
|
|
2316
|
+
const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
|
|
2317
|
+
const raw = await this.http.postJson('/tm/objectTemplate/search', body);
|
|
2318
|
+
const rows = pickArray(raw, 'data', 'content', 'objectTemplates');
|
|
2319
|
+
const map = new Map();
|
|
2320
|
+
for (const r of rows) {
|
|
2321
|
+
const name = str(r, 'name');
|
|
2322
|
+
const id = str(r, 'id', '_id');
|
|
2323
|
+
if (name && id)
|
|
2324
|
+
map.set(name.toUpperCase(), Number.parseInt(id, 10));
|
|
2325
|
+
}
|
|
2326
|
+
return map;
|
|
2327
|
+
})().catch((e) => {
|
|
2328
|
+
this.objectTemplateCache = null; // failure isn't memoised — a retry actually retries
|
|
2329
|
+
throw e;
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
const map = await this.objectTemplateCache;
|
|
2333
|
+
const id = map.get(technology.toUpperCase());
|
|
2334
|
+
if (id == null) {
|
|
2335
|
+
throw new Error(`Unknown object type/technology "${technology}"` +
|
|
2336
|
+
(map.size ? ` — known: ${[...map.keys()].join(', ')}.` : ' — no object templates found in this project.'));
|
|
2337
|
+
}
|
|
2338
|
+
return id;
|
|
2339
|
+
}
|
|
2340
|
+
/** `POST /tm/object/search` — objects visible in the current project. `q.query` free-text searches
|
|
2341
|
+
* `disname` — the UI's "Name" column (confirmed against the real sqa-ui Object Repository grid;
|
|
2342
|
+
* `description` is a separate, optional field and is NOT what users mean by an object's name). */
|
|
2343
|
+
async searchObjects(q) {
|
|
2344
|
+
const projectId = this.requireProjectId();
|
|
2345
|
+
const fields = [
|
|
2346
|
+
{ column: 'deleted', value: false },
|
|
2347
|
+
{ column: 'projectId', value: projectId },
|
|
2348
|
+
];
|
|
2349
|
+
if (q.query)
|
|
2350
|
+
fields.push({ column: 'disname', value: q.query, regEx: true });
|
|
2351
|
+
const body = this.searchBody(fields, { startIndex: q.offset ?? 0, limit: q.limit ?? 100 });
|
|
2352
|
+
const raw = await this.http.postJson('/tm/object/search', body);
|
|
2353
|
+
const rows = pickArray(raw, 'data', 'content', 'objects');
|
|
2354
|
+
return rows.map(toObject);
|
|
2355
|
+
}
|
|
2356
|
+
/**
|
|
2357
|
+
* Create a new object — `POST /tm/object`, plain JSON (NOT multipart, unlike testcase/suite writes;
|
|
2358
|
+
* confirmed against the real sqa-ui ObjectService.saveNewObject). `o.objectType` is REQUIRED: it
|
|
2359
|
+
* resolves to the object's `objecttemplateId` — structural, and (per updateObject's contract) not
|
|
2360
|
+
* editable after creation. `moduleId` auto-resolves to the project's "Default Module" (matching the
|
|
2361
|
+
* real UI's `object.component.ts` `openForm()`, which pre-selects it for every new object) — best
|
|
2362
|
+
* effort, never blocks creation if no such module exists or the lookup fails.
|
|
2363
|
+
*/
|
|
2364
|
+
async createObject(o) {
|
|
2365
|
+
if (!o.objectType) {
|
|
2366
|
+
throw new Error('createObject: objectType is required (e.g. "WEB", "ANDROID") — it selects the object template ' +
|
|
2367
|
+
'and cannot be changed after the object is created.');
|
|
2368
|
+
}
|
|
2369
|
+
const projectId = this.requireProjectId();
|
|
2370
|
+
const objecttemplateId = await this.resolveObjectTemplateId(o.objectType);
|
|
2371
|
+
const moduleId = await this.resolveDefaultModuleId();
|
|
2372
|
+
const body = {
|
|
2373
|
+
disname: o.name,
|
|
2374
|
+
objecttemplateId,
|
|
2375
|
+
parameterId: [],
|
|
2376
|
+
projectId,
|
|
2377
|
+
technology: o.objectType,
|
|
2378
|
+
...(moduleId != null ? { moduleId } : {}),
|
|
2379
|
+
attributes: o.locators.map((l) => ({ name: l.name, value: l.value, ...(l.unique ? { unique: true } : {}) })),
|
|
2380
|
+
};
|
|
2381
|
+
const raw = await this.http.postJson('/tm/object', body);
|
|
2382
|
+
const doc = (raw?.data && typeof raw.data === 'object' ? raw.data : raw) ?? {};
|
|
2383
|
+
return toObject(doc);
|
|
2384
|
+
}
|
|
2385
|
+
/** The current project's module literally named "Default Module" (case-insensitive) — mirrors the
|
|
2386
|
+
* real sqa-ui `object.component.ts` `openForm()` auto-selection. Best-effort: returns undefined
|
|
2387
|
+
* (never throws) when no such module exists or the lookup fails, so object creation still proceeds
|
|
2388
|
+
* without a module rather than being blocked by this convenience. */
|
|
2389
|
+
async resolveDefaultModuleId() {
|
|
2390
|
+
try {
|
|
2391
|
+
const modules = await this.listModules();
|
|
2392
|
+
const found = modules.find((m) => m.name?.toLowerCase() === 'default module');
|
|
2393
|
+
return found ? Number.parseInt(found.id, 10) : undefined;
|
|
2394
|
+
}
|
|
2395
|
+
catch {
|
|
2396
|
+
return undefined;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* Soft-delete — `PATCH /tm/object` (NOT POST .../delete like testcase/suite; confirmed against the
|
|
2401
|
+
* real sqa-ui ObjectService.deleteObject), body `{ids, projectId, isDelete:true}`.
|
|
2402
|
+
*
|
|
2403
|
+
* DEPENDENCY-CHECKED first: the SAME route with `isDelete:false` is a DRY RUN that returns, per
|
|
2404
|
+
* object, which test cases / functions / workflows still reference it (`tcArr`/`fnArr`/`wfArr`, each
|
|
2405
|
+
* keyed by projectId → an array of referencing ids — mirrors the real UI's
|
|
2406
|
+
* deleteDependencyForObject flow, object.component.html/.ts). If anything still references it, the
|
|
2407
|
+
* delete is refused rather than silently breaking those test cases; only a dependency-free object
|
|
2408
|
+
* gets the real `isDelete:true` call.
|
|
2409
|
+
*/
|
|
2410
|
+
async deleteObject(id) {
|
|
2411
|
+
const projectId = this.requireProjectId();
|
|
2412
|
+
const numId = Number.parseInt(numericId(id), 10);
|
|
2413
|
+
const checkRes = await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: false });
|
|
2414
|
+
const row = checkRes?.data?.[0];
|
|
2415
|
+
const countRefs = (m) => m ? Object.values(m).reduce((n, arr) => n + (Array.isArray(arr) ? arr.length : 0), 0) : 0;
|
|
2416
|
+
const tcCount = countRefs(row?.tcArr);
|
|
2417
|
+
const fnCount = countRefs(row?.fnArr);
|
|
2418
|
+
const wfCount = countRefs(row?.wfArr);
|
|
2419
|
+
if (tcCount || fnCount || wfCount) {
|
|
2420
|
+
const parts = [];
|
|
2421
|
+
if (tcCount)
|
|
2422
|
+
parts.push(`${tcCount} test case${tcCount === 1 ? '' : 's'}`);
|
|
2423
|
+
if (fnCount)
|
|
2424
|
+
parts.push(`${fnCount} function${fnCount === 1 ? '' : 's'}`);
|
|
2425
|
+
if (wfCount)
|
|
2426
|
+
parts.push(`${wfCount} workflow${wfCount === 1 ? '' : 's'}`);
|
|
2427
|
+
throw new Error(`Cannot delete object ${id} — still used by ${parts.join(', ')}. Remove those references first ` +
|
|
2428
|
+
'(or review/delete them in the SimplifyQA UI, which shows exactly which ones).');
|
|
2429
|
+
}
|
|
2430
|
+
await this.http.patchJson('/tm/object', { ids: [numId], projectId, isDelete: true });
|
|
2431
|
+
}
|
|
2432
|
+
/**
|
|
2433
|
+
* Update an object — `PUT /tm/object/:id`, plain JSON (NOT multipart, unlike testcase/suite writes;
|
|
2434
|
+
* confirmed against the real sqa-ui ObjectService.update). The backend expects the FULL document
|
|
2435
|
+
* back, so this reads the current one first and echoes every field it doesn't touch
|
|
2436
|
+
* (disname/description/objecttemplateId/parameterId/moduleId/technology/updateVersion — the
|
|
2437
|
+
* optimistic-concurrency token the real form also round-trips).
|
|
2438
|
+
*
|
|
2439
|
+
* `changes.locators` UPSERTS by locator name into the existing attribute list — an entry with a
|
|
2440
|
+
* matching name gets its value/unique updated in place, everything else on the object is left alone,
|
|
2441
|
+
* and a name with no existing match is appended as a new locator.
|
|
2442
|
+
*
|
|
2443
|
+
* `technology` (object type) and `objecttemplateId` are DELIBERATELY never taken from `changes` —
|
|
2444
|
+
* they are always echoed back from the existing doc. Both are structural (they determine which
|
|
2445
|
+
* attribute set is even valid for this object), so editing them here — outside the create flow — is
|
|
2446
|
+
* out of scope; changing them means creating a new object, not "updating" this one.
|
|
2447
|
+
*/
|
|
2448
|
+
async updateObject(id, changes) {
|
|
2449
|
+
const numId = numericId(id);
|
|
2450
|
+
const raw = await this.http.getJson(`/tm/object/${numId}`);
|
|
2451
|
+
const doc = (raw?.data && typeof raw.data === 'object' ? raw.data : raw) ?? {};
|
|
2452
|
+
if (!doc || typeof doc !== 'object' || Object.keys(doc).length === 0) {
|
|
2453
|
+
throw new Error(`object ${id} not found`);
|
|
2454
|
+
}
|
|
2455
|
+
const attributes = Array.isArray(doc.attributes)
|
|
2456
|
+
? doc.attributes.map((a) => ({
|
|
2457
|
+
name: String(a?.name ?? ''),
|
|
2458
|
+
value: String(a?.value ?? ''),
|
|
2459
|
+
...(a?.unique === true ? { unique: true } : {}),
|
|
2460
|
+
}))
|
|
2461
|
+
: [];
|
|
2462
|
+
for (const loc of changes.locators ?? []) {
|
|
2463
|
+
const idx = attributes.findIndex((a) => a.name === loc.name);
|
|
2464
|
+
const merged = { name: loc.name, value: loc.value, ...(loc.unique !== undefined ? { unique: loc.unique } : {}) };
|
|
2465
|
+
if (idx >= 0)
|
|
2466
|
+
attributes[idx] = { ...attributes[idx], ...merged };
|
|
2467
|
+
else
|
|
2468
|
+
attributes.push(merged);
|
|
2469
|
+
}
|
|
2470
|
+
const body = {
|
|
2471
|
+
disname: changes.name ?? doc.disname,
|
|
2472
|
+
description: doc.description,
|
|
2473
|
+
objecttemplateId: doc.objecttemplateId,
|
|
2474
|
+
parameterId: doc.parameterId ?? [],
|
|
2475
|
+
moduleId: doc.moduleId,
|
|
2476
|
+
technology: doc.technology,
|
|
2477
|
+
attributes,
|
|
2478
|
+
projectId: this.requireProjectId(),
|
|
2479
|
+
updateVersion: doc.updateVersion ?? 0,
|
|
2480
|
+
};
|
|
2481
|
+
const res = await this.http.putJson(`/tm/object/${numId}`, body);
|
|
2482
|
+
const updated = (res?.data && typeof res.data === 'object' ? res.data : res) ?? doc;
|
|
2483
|
+
return toObject(updated);
|
|
2484
|
+
}
|
|
624
2485
|
// ---------------------------------------------------------------------- Actions catalog
|
|
625
2486
|
async actionsCatalog(platform) {
|
|
626
2487
|
return this.requireHop('actions_catalog').actionsCatalog(platform);
|
|
@@ -668,11 +2529,13 @@ export class SimplifyQaAdapter {
|
|
|
668
2529
|
const pid = Number.parseInt(key, 10);
|
|
669
2530
|
if (!Number.isFinite(pid))
|
|
670
2531
|
continue;
|
|
671
|
-
|
|
672
|
-
const
|
|
673
|
-
|
|
2532
|
+
// Protected params get masked below — never assign this raw value to `seed` unmasked.
|
|
2533
|
+
const rawValue = String(value?.value ?? '');
|
|
2534
|
+
const meta = await this.fetchParameterMeta(pid);
|
|
2535
|
+
if (!meta)
|
|
674
2536
|
continue; // parameter deleted — skip
|
|
675
|
-
|
|
2537
|
+
const seed = meta.protected ? (rawValue ? '*****' : '(empty)') : rawValue;
|
|
2538
|
+
parameters.push({ paramId: pid, name: meta.name, seed, ...(meta.protected ? { protected: true } : {}) });
|
|
676
2539
|
}
|
|
677
2540
|
}
|
|
678
2541
|
return { tcId: q.tcId, versionId: versionId, environmentType: env, parameters };
|
|
@@ -760,9 +2623,8 @@ export class SimplifyQaAdapter {
|
|
|
760
2623
|
catch { /* fall through */ }
|
|
761
2624
|
return 'general';
|
|
762
2625
|
}
|
|
763
|
-
/** Parameter name
|
|
764
|
-
|
|
765
|
-
async fetchParameterName(paramId) {
|
|
2626
|
+
/** Parameter name + protected-flag lookup. Null when the parameter is deleted or cross-project. */
|
|
2627
|
+
async fetchParameterMeta(paramId) {
|
|
766
2628
|
let raw;
|
|
767
2629
|
try {
|
|
768
2630
|
raw = await this.http.getJson(`/tm/parameter/${paramId}`);
|
|
@@ -775,7 +2637,10 @@ export class SimplifyQaAdapter {
|
|
|
775
2637
|
n = n.length > 0 ? n[0] : null;
|
|
776
2638
|
if (!n || typeof n !== 'object')
|
|
777
2639
|
return null;
|
|
778
|
-
|
|
2640
|
+
const name = str(n, 'name');
|
|
2641
|
+
if (!name)
|
|
2642
|
+
return null;
|
|
2643
|
+
return { name, protected: n?.protected === true };
|
|
779
2644
|
}
|
|
780
2645
|
// ---------------------------------------------------------------------- Formulas [Step 3b]
|
|
781
2646
|
/** List every formula the current project can use — DEFAULT (customer-wide prefabs) unioned with
|
|
@@ -902,6 +2767,20 @@ export class SimplifyQaAdapter {
|
|
|
902
2767
|
reportUrl: `${base}/reports/execution/${runId}`,
|
|
903
2768
|
};
|
|
904
2769
|
}
|
|
2770
|
+
/** Targeted name lookup for a single project by numeric id — the whole-list `listProjects`
|
|
2771
|
+
* is paged (default 100) and API-key scoping can hide the selected project, which left
|
|
2772
|
+
* callers with an empty projectName. */
|
|
2773
|
+
async lookupProjectName(projectId) {
|
|
2774
|
+
try {
|
|
2775
|
+
const body = this.searchBody([{ column: 'id', value: projectId }, { column: 'deleted', value: false }], { selectFields: ['id', 'name'], limit: 1 });
|
|
2776
|
+
const raw = await this.http.postJson('/pm/project/search', body);
|
|
2777
|
+
const rows = pickArray(raw, 'data', 'content', 'projects');
|
|
2778
|
+
return str(rows[0], 'name', 'projectName') ?? '';
|
|
2779
|
+
}
|
|
2780
|
+
catch {
|
|
2781
|
+
return '';
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
905
2784
|
// --- Run dispatch (direct REST) --------------------------------------------
|
|
906
2785
|
// Trigger, stop, and local-agent roster all speak the SQA gateway directly.
|
|
907
2786
|
// The desktop agent still runs the actual TC — backend RPUSHes to Redis and the
|
|
@@ -927,8 +2806,20 @@ export class SimplifyQaAdapter {
|
|
|
927
2806
|
throw new Error(`startRun: test case ${tcId} not found.`);
|
|
928
2807
|
}
|
|
929
2808
|
// Version resolution — same fallback chain buildTestcasePayload/getDefaultIteration use.
|
|
930
|
-
|
|
931
|
-
|
|
2809
|
+
// Caller override (opts.versionId) wins; the name is looked up in the TC's versions[] so the
|
|
2810
|
+
// exec payload's versionName matches the picked versionId.
|
|
2811
|
+
let versionName;
|
|
2812
|
+
let versionId;
|
|
2813
|
+
if (opts.versionId) {
|
|
2814
|
+
versionId = opts.versionId;
|
|
2815
|
+
if (Array.isArray(rawTc?.versions)) {
|
|
2816
|
+
const picked = rawTc.versions.find((v) => str(v, 'id', '_id') === opts.versionId);
|
|
2817
|
+
if (picked)
|
|
2818
|
+
versionName = str(picked, 'name');
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
versionName ?? (versionName = str(rawTc, 'versionName', 'selectedVersionName'));
|
|
2822
|
+
versionId ?? (versionId = str(rawTc, 'versionId', 'selectedVersionId'));
|
|
932
2823
|
if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
|
|
933
2824
|
versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
|
|
934
2825
|
versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
|
|
@@ -940,7 +2831,14 @@ export class SimplifyQaAdapter {
|
|
|
940
2831
|
}
|
|
941
2832
|
versionName ?? (versionName = 'v1.0');
|
|
942
2833
|
versionId ?? (versionId = '');
|
|
943
|
-
|
|
2834
|
+
// ponytail: was `this.info()` — 5 round-trips (list_projects + 4 counts) for one field, and
|
|
2835
|
+
// returned projectName="" when the current project wasn't in the first 100-row page or was
|
|
2836
|
+
// filtered by API-key scope, which the /exec/execution endpoint rejects with HTTP 400.
|
|
2837
|
+
const projectName = await this.lookupProjectName(projectId);
|
|
2838
|
+
if (!projectName) {
|
|
2839
|
+
throw new Error(`startRun: could not resolve project name for projectId=${projectId}. ` +
|
|
2840
|
+
`The token may not have read access to this project.`);
|
|
2841
|
+
}
|
|
944
2842
|
const env = await this.resolveEnvironment(projectId, opts.environment);
|
|
945
2843
|
const tcType = String(str(rawTc, 'tcType') ?? 'WEB').toUpperCase();
|
|
946
2844
|
const executionType = String(str(rawTc, 'executionType') ?? 'AUTOMATION').toUpperCase();
|
|
@@ -951,14 +2849,16 @@ export class SimplifyQaAdapter {
|
|
|
951
2849
|
const moduleIdNum = typeof rawTc?.moduleId === 'number' ? rawTc.moduleId : 0;
|
|
952
2850
|
const userstoryIdNum = typeof rawTc?.userstoryId === 'number' ? rawTc.userstoryId : 0;
|
|
953
2851
|
const tags = Array.isArray(rawTc?.tags) ? rawTc.tags : [];
|
|
954
|
-
// Best-effort module name —
|
|
955
|
-
//
|
|
956
|
-
//
|
|
2852
|
+
// Best-effort module name — targeted id lookup (not a full listModules scan, which now caps
|
|
2853
|
+
// at the default 100). Story name deliberately left empty; adds a per-run round-trip for a
|
|
2854
|
+
// cosmetic field. ponytail: enrich if a caller ever complains.
|
|
957
2855
|
let moduleName = '';
|
|
958
2856
|
if (moduleIdNum > 0) {
|
|
959
2857
|
try {
|
|
960
|
-
const
|
|
961
|
-
|
|
2858
|
+
const body = this.searchBody([{ column: 'id', value: moduleIdNum }, { column: 'deleted', value: false }], { selectFields: ['id', 'name'], limit: 1 });
|
|
2859
|
+
const raw = await this.http.postJson('/pm/module/search', body);
|
|
2860
|
+
const rows = pickArray(raw, 'data', 'content', 'modules');
|
|
2861
|
+
moduleName = str(rows[0], 'name') ?? '';
|
|
962
2862
|
}
|
|
963
2863
|
catch { /* leave empty */ }
|
|
964
2864
|
}
|
|
@@ -979,8 +2879,8 @@ export class SimplifyQaAdapter {
|
|
|
979
2879
|
cloudType: mode === 'CLOUD' ? 'VM' : 'NONE',
|
|
980
2880
|
mode,
|
|
981
2881
|
executionCategory: 'EXECUTION',
|
|
982
|
-
releaseId: 0,
|
|
983
|
-
iterationId: 0,
|
|
2882
|
+
releaseId: opts.releaseId ? Number.parseInt(opts.releaseId, 10) || 0 : 0,
|
|
2883
|
+
iterationId: opts.sprintId ? Number.parseInt(opts.sprintId, 10) || 0 : 0,
|
|
984
2884
|
executionTime: '',
|
|
985
2885
|
status: 'INPROGRESS',
|
|
986
2886
|
iterationsSelected: [],
|
|
@@ -1003,7 +2903,7 @@ export class SimplifyQaAdapter {
|
|
|
1003
2903
|
type: 'TC',
|
|
1004
2904
|
platform,
|
|
1005
2905
|
projectId,
|
|
1006
|
-
projectName
|
|
2906
|
+
projectName,
|
|
1007
2907
|
configuration: {},
|
|
1008
2908
|
iterationsSelected: [],
|
|
1009
2909
|
moduleId: moduleIdNum,
|
|
@@ -1035,27 +2935,61 @@ export class SimplifyQaAdapter {
|
|
|
1035
2935
|
killType: 'STOP_EXECUTION',
|
|
1036
2936
|
});
|
|
1037
2937
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
2938
|
+
async listCloudRunners() {
|
|
2939
|
+
const projectId = this.requireProjectId();
|
|
2940
|
+
const body = this.searchBody([{ column: 'projectId', value: projectId }], { limit: 1000 });
|
|
2941
|
+
const raw = await this.http.postJson('/pm/machineResources/search', body);
|
|
2942
|
+
const rows = pickArray(raw, 'data', 'content');
|
|
2943
|
+
return rows.map((n) => {
|
|
2944
|
+
const id = str(n, 'id', '_id') ?? '';
|
|
2945
|
+
const status = str(n, 'status') ?? '';
|
|
2946
|
+
const parentId = str(n, 'parentId');
|
|
2947
|
+
const type = str(n, 'type');
|
|
2948
|
+
const os = [parentId ? `CM-${parentId}` : null, type].filter(Boolean).join(' · ') || undefined;
|
|
2949
|
+
return {
|
|
2950
|
+
id,
|
|
2951
|
+
name: str(n, 'displayName', 'name') ?? id,
|
|
2952
|
+
online: status === 'IDLE',
|
|
2953
|
+
...(status ? { status } : {}),
|
|
2954
|
+
...(os ? { os } : {}),
|
|
2955
|
+
};
|
|
2956
|
+
});
|
|
2957
|
+
}
|
|
1040
2958
|
async listLocalAgents() {
|
|
2959
|
+
// ponytail: no projectId filter — machine_registration.projectId is never written by
|
|
2960
|
+
// test-management/services/machine_registration.service.js (create only stamps
|
|
2961
|
+
// machineName/systemName/macAddress/ipAddress/platform/customerId). Filtering by projectId
|
|
2962
|
+
// strips every legacy row. customerId scoping is still applied server-side from the JWT.
|
|
1041
2963
|
const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
|
|
1042
2964
|
const raw = await this.http.postJson('/tm/machineRegistration/search', body);
|
|
1043
2965
|
const rows = pickArray(raw, 'data', 'content', 'agents', 'machines');
|
|
2966
|
+
// ponytail: hostname override, same trick sqa-ui uses at
|
|
2967
|
+
// sqa-ui/.../machine-registration.component.ts:169. The DB `active` flag is only
|
|
2968
|
+
// written to `true` on first registration (fire-and-forget in executions/services/
|
|
2969
|
+
// executionQueue.service.js:49) and back to `false` by a 7s-stale cron. Every other poll
|
|
2970
|
+
// is a no-op, so a still-polling agent frequently shows `active:false`. When the row's
|
|
2971
|
+
// machineName matches THIS box's hostname, MCP is running on the same machine as the
|
|
2972
|
+
// agent — trust that over the stale DB flag. Upgrade path: if the backend adds a live
|
|
2973
|
+
// Redis-presence endpoint (ZSCORE SQAAGENTTRACKER), consult that here instead.
|
|
2974
|
+
const hostFull = os.hostname().toLowerCase();
|
|
2975
|
+
// Bare hostname: strip trailing .local/.lan/.localdomain AND take the first label. Handles
|
|
2976
|
+
// both directions of "short vs FQDN" mismatch — the agent may register as `Pratiks-MBP`
|
|
2977
|
+
// while os.hostname() returns `pratiks-mbp.local`, or vice-versa.
|
|
2978
|
+
const hostBare = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
|
|
1044
2979
|
return rows.map((n) => {
|
|
1045
2980
|
const id = str(n, 'id', '_id', 'agentId') ?? '';
|
|
1046
2981
|
const agentName = str(n, 'machineName', 'name', 'host');
|
|
1047
2982
|
const owner = str(n, 'owner', 'userName', 'createdBy');
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
const active = typeof n?.active === 'boolean' ? n.active : false;
|
|
2983
|
+
const hostAddr = str(n, 'macAddress', 'hostAddress');
|
|
2984
|
+
const dbActive = typeof n?.active === 'boolean' ? n.active : false;
|
|
2985
|
+
const nameLc = agentName?.toLowerCase() ?? '';
|
|
2986
|
+
const isMyBox = nameLc.length > 0 && hostBare.length > 0 && nameLc.includes(hostBare);
|
|
1053
2987
|
return {
|
|
1054
2988
|
id,
|
|
1055
2989
|
...(agentName ? { name: agentName } : {}),
|
|
1056
|
-
online:
|
|
2990
|
+
online: dbActive || isMyBox,
|
|
1057
2991
|
...(owner ? { owner } : {}),
|
|
1058
|
-
...(
|
|
2992
|
+
...(hostAddr ? { host: hostAddr } : {}),
|
|
1059
2993
|
};
|
|
1060
2994
|
});
|
|
1061
2995
|
}
|
|
@@ -1087,8 +3021,10 @@ export class SimplifyQaAdapter {
|
|
|
1087
3021
|
throw new Error('startRun (LOCAL): no local agent is currently active (polling). Launch your SimplifyQA ' +
|
|
1088
3022
|
'desktop agent and retry — or pass `agentId` explicitly if you know the credential id.');
|
|
1089
3023
|
}
|
|
1090
|
-
const
|
|
1091
|
-
|
|
3024
|
+
const hostFull = os.hostname().toLowerCase();
|
|
3025
|
+
// Same short-vs-FQDN handling as listLocalAgents — see the ponytail note there.
|
|
3026
|
+
const host = hostFull.replace(/\.(local|lan|localdomain)$/, '').split('.')[0] ?? hostFull;
|
|
3027
|
+
const mine = active.filter((a) => host.length > 0 && (a.name ?? '').toLowerCase().includes(host));
|
|
1092
3028
|
if (mine.length === 1)
|
|
1093
3029
|
return mine[0].id;
|
|
1094
3030
|
const listing = (arr) => arr.map((a) => ` - ${a.name ?? a.id} \`${a.id}\``).join('\n');
|
|
@@ -1109,6 +3045,10 @@ export class SimplifyQaAdapter {
|
|
|
1109
3045
|
buildRunPlatform(tcType, mode, browser, deviceId) {
|
|
1110
3046
|
const t = tcType.toUpperCase();
|
|
1111
3047
|
if (t === 'WEB') {
|
|
3048
|
+
if (mode === 'CLOUD') {
|
|
3049
|
+
const key = `${(browser ?? 'CHROME').toUpperCase()}_HEADLESS`;
|
|
3050
|
+
return [{ name: key, id: key, technologyType: 'WEB', type: 'NONE' }];
|
|
3051
|
+
}
|
|
1112
3052
|
const b = (browser ?? 'CHROME').toUpperCase();
|
|
1113
3053
|
return [{ name: b, id: b, technologyType: 'WEB', type: 'NONE' }];
|
|
1114
3054
|
}
|
|
@@ -1130,10 +3070,26 @@ export class SimplifyQaAdapter {
|
|
|
1130
3070
|
}
|
|
1131
3071
|
return [{ name: t, id: t, technologyType: t, type: 'NONE' }];
|
|
1132
3072
|
}
|
|
1133
|
-
// --- Environments + datasets -------------------------------------------
|
|
3073
|
+
// --- Environments + datasets -------------------------------------------
|
|
3074
|
+
/** Direct REST — same endpoint `resolveEnvironment` uses. Works without an agent hop.
|
|
3075
|
+
* `key` is what testdata iterations store as `environmentType`; `id` is the uuid used by run dispatch. */
|
|
1134
3076
|
async listEnvironments() {
|
|
1135
|
-
const
|
|
1136
|
-
|
|
3077
|
+
const projectId = this.requireProjectId();
|
|
3078
|
+
// Let fetch failures propagate rather than reading as "zero environments".
|
|
3079
|
+
const raw = await this.http.getJson(`/pm/fields/8?projectId=${projectId}&fieldType=environment`);
|
|
3080
|
+
const arr = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : [];
|
|
3081
|
+
const out = [];
|
|
3082
|
+
for (const e of arr) {
|
|
3083
|
+
if (!e || typeof e !== 'object' || e.deleted)
|
|
3084
|
+
continue;
|
|
3085
|
+
const key = str(e, 'key', 'value') ?? '';
|
|
3086
|
+
const name = str(e, 'name', 'label') ?? key;
|
|
3087
|
+
const id = str(e, 'id', '_id') ?? key;
|
|
3088
|
+
if (!key && !name)
|
|
3089
|
+
continue;
|
|
3090
|
+
out.push({ id, name, key, ...(key === 'general' ? { isDefault: true } : {}) });
|
|
3091
|
+
}
|
|
3092
|
+
return out;
|
|
1137
3093
|
}
|
|
1138
3094
|
async listDatasets(tcId) {
|
|
1139
3095
|
const h = this.hop();
|