common-qa-mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/adapters/factory.d.ts +17 -7
- package/dist/adapters/factory.d.ts.map +1 -1
- package/dist/adapters/factory.js +61 -7
- package/dist/adapters/factory.js.map +1 -1
- package/dist/adapters/http.d.ts +50 -0
- package/dist/adapters/http.d.ts.map +1 -0
- package/dist/adapters/http.js +109 -0
- package/dist/adapters/http.js.map +1 -0
- package/dist/adapters/remoteMcp/adapter.d.ts +10 -1
- package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
- package/dist/adapters/remoteMcp/adapter.js +26 -3
- package/dist/adapters/remoteMcp/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/adapter.d.ts +154 -0
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/adapter.js +1150 -0
- package/dist/adapters/simplifyqa/adapter.js.map +1 -0
- package/dist/adapters/simplifyqa/mapper.d.ts +63 -0
- package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/mapper.js +409 -0
- package/dist/adapters/simplifyqa/mapper.js.map +1 -0
- package/dist/adapters/types.d.ts +48 -1
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/adapters/walnut/adapter.d.ts +231 -0
- package/dist/adapters/walnut/adapter.d.ts.map +1 -0
- package/dist/adapters/walnut/adapter.js +1083 -0
- package/dist/adapters/walnut/adapter.js.map +1 -0
- package/dist/adapters/walnut/mapper.d.ts +29 -9
- package/dist/adapters/walnut/mapper.d.ts.map +1 -1
- package/dist/adapters/walnut/mapper.js +136 -24
- package/dist/adapters/walnut/mapper.js.map +1 -1
- package/dist/config.d.ts +28 -14
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +37 -19
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +3 -2
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +36 -15
- package/dist/connection.js.map +1 -1
- package/dist/domain/types.d.ts +195 -1
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/drafts/store.d.ts +4 -1
- package/dist/drafts/store.d.ts.map +1 -1
- package/dist/drafts/store.js +7 -2
- package/dist/drafts/store.js.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +29 -12
- package/dist/initAgents.js.map +1 -1
- package/dist/requestConnection.d.ts +5 -3
- package/dist/requestConnection.d.ts.map +1 -1
- package/dist/requestConnection.js +11 -5
- package/dist/requestConnection.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +25 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/connection.tools.js +5 -5
- package/dist/tools/connection.tools.js.map +1 -1
- package/dist/tools/discovery.tools.d.ts.map +1 -1
- package/dist/tools/discovery.tools.js +93 -1
- package/dist/tools/discovery.tools.js.map +1 -1
- package/dist/tools/formulas.tools.d.ts +7 -0
- package/dist/tools/formulas.tools.d.ts.map +1 -0
- package/dist/tools/formulas.tools.js +56 -0
- package/dist/tools/formulas.tools.js.map +1 -0
- package/dist/tools/heal.tools.d.ts +17 -0
- package/dist/tools/heal.tools.d.ts.map +1 -0
- package/dist/tools/heal.tools.js +76 -0
- package/dist/tools/heal.tools.js.map +1 -0
- package/dist/tools/recording.tools.d.ts.map +1 -1
- package/dist/tools/recording.tools.js +28 -7
- 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 +378 -37
- package/dist/tools/run.tools.js.map +1 -1
- package/dist/tools/storySync.tools.d.ts +66 -0
- package/dist/tools/storySync.tools.d.ts.map +1 -0
- package/dist/tools/storySync.tools.js +213 -0
- package/dist/tools/storySync.tools.js.map +1 -0
- package/dist/tools/testcase.tools.d.ts.map +1 -1
- package/dist/tools/testcase.tools.js +17 -2
- package/dist/tools/testcase.tools.js.map +1 -1
- package/dist/tools/testdata.tools.d.ts +7 -0
- package/dist/tools/testdata.tools.d.ts.map +1 -0
- package/dist/tools/testdata.tools.js +161 -0
- package/dist/tools/testdata.tools.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { makeClient } from '../http.js';
|
|
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';
|
|
5
|
+
const PROJECT_URL_SEGMENT = {
|
|
6
|
+
testcase: 'testcase',
|
|
7
|
+
suite: 'suite',
|
|
8
|
+
run: 'reports/execution',
|
|
9
|
+
};
|
|
10
|
+
/** SQA run-row status → neutral RunStatus.state. */
|
|
11
|
+
function mapRunState(s) {
|
|
12
|
+
switch (s.toUpperCase()) {
|
|
13
|
+
case 'PASSED': return 'passed';
|
|
14
|
+
case 'FAILED': return 'failed';
|
|
15
|
+
case 'STOPPED': return 'stopped';
|
|
16
|
+
case 'RUNNING': return 'running';
|
|
17
|
+
case 'QUEUED': return 'queued';
|
|
18
|
+
default: return 'queued';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Formula names round-trip with parens on ("FIRST_NAME()") from the backend and sometimes with a full
|
|
22
|
+
* signature ("FIRST_NAME(country,sex)") from a caller. Reduce both to the bare identifier. */
|
|
23
|
+
function bareFormulaName(s) {
|
|
24
|
+
return s.replace(/\s*\(.*\)\s*$/, '').trim();
|
|
25
|
+
}
|
|
26
|
+
/** Query keys /tm/customFormula expects per formula, positionally — they are NOT the formula's declared
|
|
27
|
+
* keyNames. Mirrors the SQA UI (wfTestdataView.ts). Anything absent takes at most one arg as `value`.
|
|
28
|
+
* ponytail: table, not per-formula handlers — extend a row when a new multi-arg formula shows up. */
|
|
29
|
+
const FORMULA_ARG_KEYS = {
|
|
30
|
+
FIRST_NAME: ['country', 'sex', 'min_len', 'max_len'],
|
|
31
|
+
LAST_NAME: ['country', 'sex', 'min_len', 'max_len'],
|
|
32
|
+
FULL_NAME: ['country', 'sex', 'min_len', 'max_len'],
|
|
33
|
+
MIDDLE_NAME: ['country', 'sex', 'min_len', 'max_len'],
|
|
34
|
+
PREFIX: ['sex'],
|
|
35
|
+
DATE_OF_BIRTH: ['pattern', 'min_age', 'max_age'],
|
|
36
|
+
};
|
|
37
|
+
/** Normalise a formula document from `/tm/syncFormulas/fetch` into the neutral Formula shape. Returns
|
|
38
|
+
* null when the doc is missing a name — a nameless formula cannot be called. `scope` is inferred:
|
|
39
|
+
* DEFAULT → 'default'; CUSTOM with a matching projectId → 'project'; CUSTOM otherwise → 'customer'. */
|
|
40
|
+
function normalizeFormula(doc, projectId) {
|
|
41
|
+
if (!doc || typeof doc !== 'object')
|
|
42
|
+
return null;
|
|
43
|
+
const name = str(doc, 'name');
|
|
44
|
+
if (!name)
|
|
45
|
+
return null;
|
|
46
|
+
const type = str(doc, 'type') === 'CUSTOM' ? 'CUSTOM' : 'DEFAULT';
|
|
47
|
+
const docPid = Number(doc.projectId ?? 0);
|
|
48
|
+
const scope = type === 'DEFAULT' ? 'default' : docPid && docPid === projectId ? 'project' : 'customer';
|
|
49
|
+
const desc = (doc.description && typeof doc.description === 'object') ? doc.description : {};
|
|
50
|
+
const params = Array.isArray(desc.parameters)
|
|
51
|
+
? desc.parameters
|
|
52
|
+
.map((p) => (p && typeof p === 'object' && typeof p.keyName === 'string')
|
|
53
|
+
? { keyName: p.keyName, description: str(p, 'value', 'description') ?? undefined, dataType: str(p, 'dataType') ?? undefined }
|
|
54
|
+
: null)
|
|
55
|
+
.filter((p) => p !== null)
|
|
56
|
+
: [];
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
uniqueId: str(doc, 'uniqueId') ?? name,
|
|
60
|
+
type,
|
|
61
|
+
scope,
|
|
62
|
+
signature: str(desc, 'name') ?? `${name}()`,
|
|
63
|
+
details: str(desc, 'details') ?? undefined,
|
|
64
|
+
example: str(desc, 'example') ?? undefined,
|
|
65
|
+
parameters: params,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** SQA per-step status → neutral step status. */
|
|
69
|
+
function mapStepStatus(s) {
|
|
70
|
+
switch (s.toUpperCase()) {
|
|
71
|
+
case 'PASSED': return 'pass';
|
|
72
|
+
case 'FAILED': return 'fail';
|
|
73
|
+
case 'SKIPPED': return 'skip';
|
|
74
|
+
case 'STOPPED': return 'stop';
|
|
75
|
+
default: return s.toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const NEEDS_PROJECT = 'No project selected. Call list_projects to see the available projects, then ' +
|
|
79
|
+
'qa_switch_project({ projectId }) to choose one — test cases, runs and objects ' +
|
|
80
|
+
'are all scoped to a project.';
|
|
81
|
+
const HOP_NEEDED_WRITE = 'This SimplifyQA operation is not yet ported to direct REST — set QA_MCP_URL to the SimplifyQA ' +
|
|
82
|
+
'agent /mcp so common-mcp can hop for it, or open the SimplifyQA UI to perform it there.';
|
|
83
|
+
/**
|
|
84
|
+
* SimplifyQaAdapter — DIRECT-to-backend REST driver for SimplifyQA, mirroring WalnutAdapter's
|
|
85
|
+
* design. Reads (list/get for projects/testcases/suites/stories/modules/releases/sprints/objects)
|
|
86
|
+
* hit the SQA TM backend REST directly via a `QaHttp` bound to `cfg.baseUrl`. Anything not yet
|
|
87
|
+
* ported (writes, testdata, run dispatch, marketplace, and the recording surface) delegates to
|
|
88
|
+
* the LOCAL AGENT's `/mcp` (`cfg.url`) via a lazily-built RemoteMcpAdapter — same "graceful hop"
|
|
89
|
+
* pattern walnut uses for recording. When the hop is not configured, unported ops surface a clear
|
|
90
|
+
* error instead of failing silently.
|
|
91
|
+
*
|
|
92
|
+
* This class deliberately does not `implements ProviderAdapter` (walnut trick): the factory casts
|
|
93
|
+
* it, and tools that call ported methods get direct REST; tools that call unported methods get
|
|
94
|
+
* the hop (or a clear error). Each porting step replaces one more delegation with a direct call.
|
|
95
|
+
*/
|
|
96
|
+
export class SimplifyQaAdapter {
|
|
97
|
+
constructor(cfg, transport) {
|
|
98
|
+
this.cfg = cfg;
|
|
99
|
+
this.agentHop = null;
|
|
100
|
+
// Formula list rarely changes during a session; cache the fetch response so the model can call
|
|
101
|
+
// tc_list_formulas without paying a round trip each time. Instance-scoped: switchProject rebuilds
|
|
102
|
+
// the adapter (see connection.ts), which drops this cache with it — no manual invalidation needed.
|
|
103
|
+
// We store the in-flight PROMISE, not the resolved array: several concurrent evaluateFormula calls
|
|
104
|
+
// (from the Promise.all in saveGeneratedIterations) all await the same fetch instead of each firing
|
|
105
|
+
// their own /tm/syncFormulas/fetch before the cache is populated.
|
|
106
|
+
this.formulaCache = null;
|
|
107
|
+
if (!cfg.baseUrl) {
|
|
108
|
+
throw new Error('SimplifyQaAdapter requires a baseUrl — set QA_BASE_URL or use a JWT with a `url` claim.');
|
|
109
|
+
}
|
|
110
|
+
this.http = makeClient({
|
|
111
|
+
baseUrl: cfg.baseUrl,
|
|
112
|
+
headers: () => this.headers(),
|
|
113
|
+
...transport,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// Auth headers mirror the Java RequestContext: bearer + x-project-id. `x-project-id` is only
|
|
117
|
+
// spread in when a project is selected — a session can be connected before one is chosen.
|
|
118
|
+
headers() {
|
|
119
|
+
return {
|
|
120
|
+
Authorization: `Bearer ${this.cfg.token}`,
|
|
121
|
+
...(this.cfg.projectId ? { 'x-project-id': this.cfg.projectId } : {}),
|
|
122
|
+
'Content-Type': 'application/json',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
requireProjectId() {
|
|
126
|
+
if (!this.cfg.projectId)
|
|
127
|
+
throw new Error(NEEDS_PROJECT);
|
|
128
|
+
const n = Number.parseInt(String(this.cfg.projectId).trim(), 10);
|
|
129
|
+
if (!Number.isFinite(n))
|
|
130
|
+
throw new Error(`Invalid projectId ${this.cfg.projectId} — expected a numeric id.`);
|
|
131
|
+
return n;
|
|
132
|
+
}
|
|
133
|
+
// Standard SQA `searchFields` payload builder — the SAME shape every /search endpoint expects.
|
|
134
|
+
// Every list op below uses this instead of hand-writing the same 6-line boilerplate.
|
|
135
|
+
searchBody(fields, opts = {}) {
|
|
136
|
+
const body = {
|
|
137
|
+
searchFields: fields.map((f) => ({
|
|
138
|
+
column: f.column,
|
|
139
|
+
value: f.value,
|
|
140
|
+
...(f.regEx ? { regEx: true } : {}),
|
|
141
|
+
})),
|
|
142
|
+
startIndex: opts.startIndex ?? 0,
|
|
143
|
+
limit: opts.limit ?? 1000,
|
|
144
|
+
};
|
|
145
|
+
if (opts.selectFields)
|
|
146
|
+
body['selectFields'] = opts.selectFields;
|
|
147
|
+
return body;
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------- Info + projects
|
|
150
|
+
/** 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. */
|
|
152
|
+
async info() {
|
|
153
|
+
const projects = await this.listProjects();
|
|
154
|
+
const projectId = this.cfg.projectId ?? '';
|
|
155
|
+
const found = projects.find((p) => p.id === projectId);
|
|
156
|
+
return {
|
|
157
|
+
provider: 'simplifyqa',
|
|
158
|
+
projectId,
|
|
159
|
+
projectName: found?.name ?? '',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** `POST /pm/project/search` — projects the token can see. NOT project-scoped. */
|
|
163
|
+
async listProjects() {
|
|
164
|
+
const body = this.searchBody([{ column: 'deleted', value: false }], { selectFields: ['id', 'code', 'name'] });
|
|
165
|
+
const raw = await this.http.postJson('/pm/project/search', body);
|
|
166
|
+
const rows = pickArray(raw, 'data', 'content', 'projects');
|
|
167
|
+
return rows.map((n) => ({
|
|
168
|
+
id: str(n, 'id', '_id', 'projectId') ?? '',
|
|
169
|
+
name: str(n, 'name', 'projectName') ?? '',
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------- Test cases (read)
|
|
173
|
+
async listTestCases(q) {
|
|
174
|
+
const projectId = this.requireProjectId();
|
|
175
|
+
const fields = [
|
|
176
|
+
{ column: 'deleted', value: false },
|
|
177
|
+
{ column: 'projectId', value: projectId },
|
|
178
|
+
];
|
|
179
|
+
if (q.search)
|
|
180
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
181
|
+
if (q.status)
|
|
182
|
+
fields.push({ column: 'status', value: q.status });
|
|
183
|
+
const body = this.searchBody(fields, {
|
|
184
|
+
startIndex: q.offset ?? 0,
|
|
185
|
+
limit: q.limit ?? 100,
|
|
186
|
+
selectFields: ['id', 'code', 'name', 'priority', 'status', 'stepCount', 'tcType', 'executionType'],
|
|
187
|
+
});
|
|
188
|
+
const raw = await this.http.postJson('/tm/testcase/search', body);
|
|
189
|
+
const rows = pickArray(raw, 'data', 'content', 'testCases');
|
|
190
|
+
return rows.map(toTcSummary);
|
|
191
|
+
}
|
|
192
|
+
/** Neutral test case, steps included. Accepts numeric id or business code (`TC_123`). */
|
|
193
|
+
async getTestCase(id) {
|
|
194
|
+
const raw = await this.fetchRawTestcase(id);
|
|
195
|
+
if (!raw || Object.keys(raw).length === 0) {
|
|
196
|
+
throw new Error(`test case ${id} not found`);
|
|
197
|
+
}
|
|
198
|
+
const tc = toTestCase(raw);
|
|
199
|
+
// Prefer the steps COLLECTION — that's the source of truth the SQA UI, executor, and save
|
|
200
|
+
// paths use today. Inline steps.manualSteps / steps.automationSteps on the TC doc drift
|
|
201
|
+
// against it. Fall back to inline (already on `tc` from toTestCase) if the collection
|
|
202
|
+
// fetch is empty or throws — legacy TCs that never migrated still surface something.
|
|
203
|
+
try {
|
|
204
|
+
const rows = await this.fetchStepsCollection(id, raw);
|
|
205
|
+
if (rows.length > 0)
|
|
206
|
+
return applyCollectionSteps(tc, rows);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// ignore — inline steps stay
|
|
210
|
+
}
|
|
211
|
+
return tc;
|
|
212
|
+
}
|
|
213
|
+
async fetchRawTestcase(id) {
|
|
214
|
+
const raw = await this.http.getJson(`/tm/testcase/getById/${numericId(id)}`);
|
|
215
|
+
let doc = raw?.data ?? raw;
|
|
216
|
+
if (Array.isArray(doc))
|
|
217
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
218
|
+
return doc && typeof doc === 'object' ? doc : {};
|
|
219
|
+
}
|
|
220
|
+
async fetchStepsCollection(tcId, rawTc) {
|
|
221
|
+
const projectId = this.requireProjectId();
|
|
222
|
+
const tcNum = numericId(tcId);
|
|
223
|
+
// versionId lives under a few different keys depending on the read model — Java tries all four.
|
|
224
|
+
let versionId = str(rawTc, 'versionId', 'selectedVersionId') ??
|
|
225
|
+
(rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object'
|
|
226
|
+
? str(rawTc.versionsDetails, 'id', '_id', 'versionId')
|
|
227
|
+
: undefined);
|
|
228
|
+
if (!versionId && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
|
|
229
|
+
versionId = str(rawTc.versions[0], 'id', '_id');
|
|
230
|
+
}
|
|
231
|
+
if (!versionId)
|
|
232
|
+
return [];
|
|
233
|
+
const path = '/tm/testcase/steps' +
|
|
234
|
+
`?projectId=${projectId}` +
|
|
235
|
+
`&tcfnId=${tcNum}` +
|
|
236
|
+
`&versionId=${encodeURIComponent(versionId)}` +
|
|
237
|
+
'&type=TC';
|
|
238
|
+
const body = await this.http.getJson(path);
|
|
239
|
+
const rows = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : [];
|
|
240
|
+
return rows;
|
|
241
|
+
}
|
|
242
|
+
// ---------------------------------------------------------------------- Suites (read)
|
|
243
|
+
async listSuites() {
|
|
244
|
+
const projectId = this.requireProjectId();
|
|
245
|
+
const body = this.searchBody([
|
|
246
|
+
{ column: 'deleted', value: false },
|
|
247
|
+
{ column: 'projectId', value: projectId },
|
|
248
|
+
]);
|
|
249
|
+
const raw = await this.http.postJson('/tm/suites/search', body);
|
|
250
|
+
const rows = pickArray(raw, 'data', 'content', 'suites');
|
|
251
|
+
return rows.map(toSuiteSummary);
|
|
252
|
+
}
|
|
253
|
+
async getSuite(id) {
|
|
254
|
+
const raw = await this.http.getJson(`/tm/suites/${numericId(id)}`);
|
|
255
|
+
let doc = raw?.data ?? raw;
|
|
256
|
+
if (Array.isArray(doc))
|
|
257
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
258
|
+
return toSuite(doc && typeof doc === 'object' ? doc : {});
|
|
259
|
+
}
|
|
260
|
+
// ---------------------------------------------------------------------- Stories (read)
|
|
261
|
+
async listStories(q) {
|
|
262
|
+
const projectId = this.requireProjectId();
|
|
263
|
+
const fields = [
|
|
264
|
+
{ column: 'deleted', value: false },
|
|
265
|
+
{ column: 'projectId', value: projectId },
|
|
266
|
+
];
|
|
267
|
+
if (q.search)
|
|
268
|
+
fields.push({ column: 'name', value: q.search, regEx: true });
|
|
269
|
+
// SQA's userstory FK for sprint is `iterationId` — spec uses the neutral name `sprintId`.
|
|
270
|
+
if (q.sprintId)
|
|
271
|
+
fields.push({ column: 'iterationId', value: Number.parseInt(q.sprintId, 10) });
|
|
272
|
+
if (q.releaseId)
|
|
273
|
+
fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
|
|
274
|
+
const body = this.searchBody(fields, { limit: q.limit ?? 50 });
|
|
275
|
+
const raw = await this.http.postJson('/pm/userstory/search', body);
|
|
276
|
+
const rows = pickArray(raw, 'data', 'content', 'userStories', 'stories');
|
|
277
|
+
return rows.map(toStorySummary);
|
|
278
|
+
}
|
|
279
|
+
async getStory(id) {
|
|
280
|
+
const resolvedId = await this.resolveStoryNumericId(id);
|
|
281
|
+
const raw = await this.http.getJson(`/pm/userstory/${resolvedId}`);
|
|
282
|
+
const doc = Array.isArray(raw)
|
|
283
|
+
? (raw.length > 0 ? raw[0] : {})
|
|
284
|
+
: (raw?.data && typeof raw.data === 'object' ? raw.data : raw);
|
|
285
|
+
return toStory(doc && typeof doc === 'object' ? doc : {});
|
|
286
|
+
}
|
|
287
|
+
/** A US_XXX business key → the numeric userstoryId via `/pm/userstory/search`. Numeric passes
|
|
288
|
+
* through. SQA's GET-by-id parseInts the path, so a raw key would 404. */
|
|
289
|
+
async resolveStoryNumericId(ref) {
|
|
290
|
+
if (!ref)
|
|
291
|
+
return ref;
|
|
292
|
+
if (/^\d+$/.test(ref))
|
|
293
|
+
return ref;
|
|
294
|
+
const body = this.searchBody([{ column: 'code', value: ref }], { startIndex: 0, limit: 1 });
|
|
295
|
+
const raw = await this.http.postJson('/pm/userstory/search', body);
|
|
296
|
+
const rows = pickArray(raw, 'data', 'content', 'userStories', 'stories');
|
|
297
|
+
if (rows.length === 0)
|
|
298
|
+
throw new Error(`Story not found for business key: ${ref}`);
|
|
299
|
+
return str(rows[0], 'id', '_id', 'userstoryId') ?? ref;
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------- Modules, releases, sprints
|
|
302
|
+
async listModules() {
|
|
303
|
+
const projectId = this.requireProjectId();
|
|
304
|
+
const body = this.searchBody([
|
|
305
|
+
{ column: 'projectId', value: projectId },
|
|
306
|
+
{ column: 'deleted', value: false },
|
|
307
|
+
], { selectFields: ['id', 'code', 'name', 'parentId'], limit: 10000 });
|
|
308
|
+
const raw = await this.http.postJson('/pm/module/search', body);
|
|
309
|
+
const rows = pickArray(raw, 'data', 'content', 'modules');
|
|
310
|
+
return rows.map(toModuleSummary);
|
|
311
|
+
}
|
|
312
|
+
async listReleases(q) {
|
|
313
|
+
const fields = [{ column: 'deleted', value: false }];
|
|
314
|
+
if (q.scope === 'project')
|
|
315
|
+
fields.push({ column: 'projectIds', value: this.requireProjectId() });
|
|
316
|
+
const body = this.searchBody(fields);
|
|
317
|
+
const raw = await this.http.postJson('/pm/release/search', body);
|
|
318
|
+
const rows = pickArray(raw, 'data', 'content', 'releases');
|
|
319
|
+
return rows.map(toReleaseSummary);
|
|
320
|
+
}
|
|
321
|
+
async listSprints(q) {
|
|
322
|
+
const fields = [{ column: 'deleted', value: false }];
|
|
323
|
+
if (q.releaseId)
|
|
324
|
+
fields.push({ column: 'releaseId', value: Number.parseInt(q.releaseId, 10) });
|
|
325
|
+
const body = this.searchBody(fields);
|
|
326
|
+
const raw = await this.http.postJson('/pm/sprint/search', body);
|
|
327
|
+
const rows = pickArray(raw, 'data', 'content', 'sprints');
|
|
328
|
+
return rows.map(toSprintSummary);
|
|
329
|
+
}
|
|
330
|
+
// ---------------------------------------------------------------------- Objects (read)
|
|
331
|
+
async getObject(id) {
|
|
332
|
+
const raw = await this.http.getJson(`/tm/object/${numericId(id)}`);
|
|
333
|
+
let doc = raw?.data ?? raw;
|
|
334
|
+
if (Array.isArray(doc))
|
|
335
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
336
|
+
return toObject(doc && typeof doc === 'object' ? doc : {});
|
|
337
|
+
}
|
|
338
|
+
// ---------------------------------------------------------------------- Deep links
|
|
339
|
+
productUrlFor(entity) {
|
|
340
|
+
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
341
|
+
return `${base}/${PROJECT_URL_SEGMENT[entity.kind]}/${entity.id}`;
|
|
342
|
+
}
|
|
343
|
+
// ---------------------------------------------------------------------- Agent hop (recording + not-yet-ported ops)
|
|
344
|
+
// Everything below either delegates to the local agent /mcp (recording surface, and writes/testdata/
|
|
345
|
+
// run dispatch we haven't ported to REST yet) or fails with a clear message when the hop is unset.
|
|
346
|
+
// As each step of the port lands, the corresponding delegate is replaced with a direct REST impl.
|
|
347
|
+
hop() {
|
|
348
|
+
if (!this.cfg.url)
|
|
349
|
+
return null;
|
|
350
|
+
if (!this.agentHop)
|
|
351
|
+
this.agentHop = new RemoteMcpAdapter(this.cfg);
|
|
352
|
+
return this.agentHop;
|
|
353
|
+
}
|
|
354
|
+
requireHop(op) {
|
|
355
|
+
const h = this.hop();
|
|
356
|
+
if (!h)
|
|
357
|
+
throw new Error(`${op}: ${HOP_NEEDED_WRITE}`);
|
|
358
|
+
return h;
|
|
359
|
+
}
|
|
360
|
+
/** Recording tool schemas from the SQA agent — mirrored so the recording passthrough can register
|
|
361
|
+
* them. No agent configured → `[]`, so no recording tools get registered. */
|
|
362
|
+
async listAgentTools() {
|
|
363
|
+
const h = this.hop();
|
|
364
|
+
return h ? h.listAgentTools() : [];
|
|
365
|
+
}
|
|
366
|
+
/** Forward a recording-tool call to the agent /mcp. Same behavior as walnut. */
|
|
367
|
+
async callRaw(name, args) {
|
|
368
|
+
const h = this.hop();
|
|
369
|
+
if (!h) {
|
|
370
|
+
throw new Error("Recording needs a local SimplifyQA agent — set QA_MCP_URL to the agent's /mcp endpoint.");
|
|
371
|
+
}
|
|
372
|
+
return h.callRaw(name, args);
|
|
373
|
+
}
|
|
374
|
+
// ---------------------------------------------------------------------- Test cases (write) [Step 4]
|
|
375
|
+
/** 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 multipart with the assembled `testcaseObj`. */
|
|
377
|
+
async createTestCase(tc) {
|
|
378
|
+
const projectId = this.requireProjectId();
|
|
379
|
+
const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
|
|
380
|
+
const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
|
|
381
|
+
const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
|
|
382
|
+
const payload = buildTestcasePayload({
|
|
383
|
+
projectId, moduleId,
|
|
384
|
+
...(userstoryId != null ? { userstoryId } : {}),
|
|
385
|
+
tc,
|
|
386
|
+
paramIdByName,
|
|
387
|
+
operation: 'CREATE',
|
|
388
|
+
});
|
|
389
|
+
const raw = await this.http.postMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
390
|
+
return toSavedRef(raw, true);
|
|
391
|
+
}
|
|
392
|
+
/** Same shape as create, but with `tcfnObj.id` set and operation:'UPDATE'. */
|
|
393
|
+
async updateTestCase(id, tc) {
|
|
394
|
+
const projectId = this.requireProjectId();
|
|
395
|
+
const moduleId = Number.parseInt(String(tc.folderOrModule ?? '0'), 10) || 0;
|
|
396
|
+
const userstoryId = tc.storyRef ? await this.resolveStoryNumericIdOrNull(tc.storyRef) : undefined;
|
|
397
|
+
const paramIdByName = await this.resolveParamIds(projectId, moduleId, tc);
|
|
398
|
+
const payload = buildTestcasePayload({
|
|
399
|
+
projectId, moduleId,
|
|
400
|
+
...(userstoryId != null ? { userstoryId } : {}),
|
|
401
|
+
existingId: numericId(id),
|
|
402
|
+
tc,
|
|
403
|
+
paramIdByName,
|
|
404
|
+
operation: 'UPDATE',
|
|
405
|
+
});
|
|
406
|
+
// `!` — putMultipart is optional on QaHttp (walnut's wrapper opts out); makeClient always provides it.
|
|
407
|
+
const raw = await this.http.putMultipart('/tm/testcase', 'testcaseObj', payload);
|
|
408
|
+
return toSavedRef(raw, false);
|
|
409
|
+
}
|
|
410
|
+
/** Soft-delete (recoverable via recycle bin). `isDelete:true` = delete; `false` = restore. */
|
|
411
|
+
async deleteTestCase(id) {
|
|
412
|
+
const projectId = this.requireProjectId();
|
|
413
|
+
await this.http.postJson('/tm/testcase/delete', {
|
|
414
|
+
ids: [Number.parseInt(numericId(id), 10)],
|
|
415
|
+
projectId,
|
|
416
|
+
isDelete: true,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/** Never throw on unresolvable storyRef — the Java code logs and moves on so the save still lands. */
|
|
420
|
+
async resolveStoryNumericIdOrNull(storyRef) {
|
|
421
|
+
try {
|
|
422
|
+
const s = await this.resolveStoryNumericId(storyRef);
|
|
423
|
+
const n = Number.parseInt(s, 10);
|
|
424
|
+
return Number.isFinite(n) ? n : undefined;
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Resolve every `${name}` token across every step to a SQA parameter id via get-or-create.
|
|
431
|
+
* Name-uniqueness is project-wide on the backend; get-or-create means every TC using
|
|
432
|
+
* `${appUrl}` links to the same parameter row. */
|
|
433
|
+
async resolveParamIds(projectId, moduleId, tc) {
|
|
434
|
+
const paramIdByName = new Map();
|
|
435
|
+
const steps = Array.isArray(tc.steps) ? tc.steps : [];
|
|
436
|
+
for (const s of steps) {
|
|
437
|
+
const names = extractParamNames(s.description);
|
|
438
|
+
for (const name of names) {
|
|
439
|
+
if (paramIdByName.has(name))
|
|
440
|
+
continue;
|
|
441
|
+
const defaultValue = paramDefault(name, tc.data, s.value);
|
|
442
|
+
const id = await this.getOrCreateParameter(projectId, moduleId, name, defaultValue);
|
|
443
|
+
paramIdByName.set(name, id);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return paramIdByName;
|
|
447
|
+
}
|
|
448
|
+
/** Case-insensitive project-scoped exact-name lookup. Backend duplicate-name check uses Mongo
|
|
449
|
+
* collation strength:2, so a case-sensitive search here misses params stored under different
|
|
450
|
+
* casing — the next POST then trips "Duplicate Name Error". Returns null when not found. */
|
|
451
|
+
async findParameterId(projectId, name) {
|
|
452
|
+
const body = this.searchBody([
|
|
453
|
+
{ column: 'name', value: name },
|
|
454
|
+
{ column: 'projectId', value: projectId },
|
|
455
|
+
], { startIndex: 0, limit: 10 });
|
|
456
|
+
// The `searchFields` shape supports `ignorecase:true` per-field; searchBody doesn't emit it,
|
|
457
|
+
// so add it inline for the name column.
|
|
458
|
+
body['searchFields'][0]['ignorecase'] = true;
|
|
459
|
+
const raw = await this.http.postJson('/tm/parameter/search', body);
|
|
460
|
+
const rows = pickArray(raw, 'data', 'content');
|
|
461
|
+
for (const row of rows) {
|
|
462
|
+
const rowName = str(row, 'name');
|
|
463
|
+
if (rowName === name) {
|
|
464
|
+
const id = str(row, 'id', '_id', 'parameterId');
|
|
465
|
+
if (id)
|
|
466
|
+
return Number.parseInt(id, 10);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
async getOrCreateParameter(projectId, moduleId, name, defaultValue) {
|
|
472
|
+
const existing = await this.findParameterId(projectId, name);
|
|
473
|
+
if (existing != null)
|
|
474
|
+
return existing;
|
|
475
|
+
const body = {
|
|
476
|
+
name,
|
|
477
|
+
projectId,
|
|
478
|
+
protected: looksSensitive(name),
|
|
479
|
+
...(moduleId > 0 ? { moduleId } : {}),
|
|
480
|
+
defaultValue,
|
|
481
|
+
// Match the SQA UI's Add-Parameter defaults so API-created params render identically. Without
|
|
482
|
+
// these the UI shows "Parameter Type*" empty and refuses to save edits.
|
|
483
|
+
type: 'Local',
|
|
484
|
+
parameterType: 'Alphanumeric',
|
|
485
|
+
description: '',
|
|
486
|
+
unique: false,
|
|
487
|
+
concat: '',
|
|
488
|
+
paramAttributes: { minLength: 0, maxLength: 0, prefix: '', suffix: '' },
|
|
489
|
+
};
|
|
490
|
+
let raw;
|
|
491
|
+
try {
|
|
492
|
+
raw = await this.http.postJson('/tm/parameter', body);
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
// Backend rejects a race-lost duplicate with 500 "Duplicate Name" when the concurrent search
|
|
496
|
+
// missed the just-created row. Recover by re-querying — same name, project-scoped.
|
|
497
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
498
|
+
if (/Duplicate Name|BADREQUEST/i.test(msg)) {
|
|
499
|
+
const after = await this.findParameterId(projectId, name);
|
|
500
|
+
if (after != null)
|
|
501
|
+
return after;
|
|
502
|
+
}
|
|
503
|
+
throw e;
|
|
504
|
+
}
|
|
505
|
+
// The id lives at various nesting levels depending on which path the backend took (bare doc,
|
|
506
|
+
// `data`, or `message[0]` for a race-lost duplicate). Try all three.
|
|
507
|
+
let id = str(raw, 'id', '_id', 'parameterId') ?? str(raw?.data, 'id', '_id', 'parameterId');
|
|
508
|
+
if (!id) {
|
|
509
|
+
const m = raw?.message;
|
|
510
|
+
if (Array.isArray(m) && m.length > 0)
|
|
511
|
+
id = str(m[0], 'id', '_id', 'parameterId');
|
|
512
|
+
else if (m && typeof m === 'object')
|
|
513
|
+
id = str(m, 'id', '_id', 'parameterId');
|
|
514
|
+
}
|
|
515
|
+
if (!id)
|
|
516
|
+
throw new Error(`getOrCreateParameter: no id in response: ${JSON.stringify(raw)}`);
|
|
517
|
+
return Number.parseInt(id, 10);
|
|
518
|
+
}
|
|
519
|
+
// ---------------------------------------------------------------------- Suites (write) [Step 4]
|
|
520
|
+
async createSuite(s) {
|
|
521
|
+
// Java stubs this out ("create_suite is not supported yet on SimplifyQA"). Mirror that.
|
|
522
|
+
if (this.hop())
|
|
523
|
+
return this.requireHop('create_suite').createSuite(s);
|
|
524
|
+
throw new Error('create_suite is not supported yet on SimplifyQA — create the suite in the SimplifyQA UI, ' +
|
|
525
|
+
'then add test cases via suite_add_testcases.');
|
|
526
|
+
}
|
|
527
|
+
/** Fetch raw suite, apply add/remove/reorder ops in memory, then PUT the whole doc back with a
|
|
528
|
+
* STRICT allow-listed payload (the SQA update validator rejects unknown keys on both suite-level
|
|
529
|
+
* and testCases[] entries). */
|
|
530
|
+
async updateSuiteMembers(id, ops) {
|
|
531
|
+
if (!Array.isArray(ops))
|
|
532
|
+
throw new Error('ops must be an array');
|
|
533
|
+
const raw = await this.fetchRawSuite(id);
|
|
534
|
+
if (Object.keys(raw).length === 0)
|
|
535
|
+
throw new Error(`suite ${id} not found`);
|
|
536
|
+
const currentRows = Array.isArray(raw['testCases']) ? raw['testCases'] : [];
|
|
537
|
+
const members = currentRows.map(normalizeSuiteEntry);
|
|
538
|
+
for (const op of ops) {
|
|
539
|
+
const tcNum = numericId(String(op.tcId));
|
|
540
|
+
switch (op.op) {
|
|
541
|
+
case 'add': {
|
|
542
|
+
const already = members.some((m) => String(m['testcaseId']) === tcNum);
|
|
543
|
+
if (!already)
|
|
544
|
+
members.push(await this.buildSuiteEntryFromTestcase(tcNum));
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
case 'remove': {
|
|
548
|
+
for (let i = members.length - 1; i >= 0; i--) {
|
|
549
|
+
if (String(members[i]['testcaseId']) === tcNum)
|
|
550
|
+
members.splice(i, 1);
|
|
551
|
+
}
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
case 'reorder': {
|
|
555
|
+
const newOrder = op.order ?? -1;
|
|
556
|
+
const idx = members.findIndex((m) => String(m['testcaseId']) === tcNum);
|
|
557
|
+
if (idx < 0 || newOrder < 1 || newOrder > members.length)
|
|
558
|
+
break;
|
|
559
|
+
const [moved] = members.splice(idx, 1);
|
|
560
|
+
if (moved)
|
|
561
|
+
members.splice(newOrder - 1, 0, moved);
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
default: throw new Error(`suite op '${op.op}' not supported (add|remove|reorder)`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Renumber `seq` contiguously from 1 after any mutation.
|
|
568
|
+
members.forEach((m, i) => { m['seq'] = i + 1; });
|
|
569
|
+
const payload = {};
|
|
570
|
+
for (const k of SUITE_UPDATE_ALLOWED_KEYS) {
|
|
571
|
+
if (raw[k] !== undefined)
|
|
572
|
+
payload[k] = raw[k];
|
|
573
|
+
}
|
|
574
|
+
payload['tcChanged'] = true;
|
|
575
|
+
payload['testCases'] = members;
|
|
576
|
+
await this.http.putMultipart(`/tm/suites/${numericId(id)}`, 'suiteObj', payload);
|
|
577
|
+
return this.getSuite(id);
|
|
578
|
+
}
|
|
579
|
+
/** Soft-delete via `isDelete:true`. `isDelete:false` restores. */
|
|
580
|
+
async deleteSuite(id) {
|
|
581
|
+
const projectId = this.requireProjectId();
|
|
582
|
+
await this.http.patchJson('/tm/suites', {
|
|
583
|
+
ids: [Number.parseInt(numericId(id), 10)],
|
|
584
|
+
projectId,
|
|
585
|
+
isDelete: true,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
async fetchRawSuite(id) {
|
|
589
|
+
const raw = await this.http.getJson(`/tm/suites/${numericId(id)}`);
|
|
590
|
+
let doc = raw?.data ?? raw;
|
|
591
|
+
if (Array.isArray(doc))
|
|
592
|
+
doc = doc.length > 0 ? doc[0] : {};
|
|
593
|
+
return doc && typeof doc === 'object' ? doc : {};
|
|
594
|
+
}
|
|
595
|
+
/** Assemble the strict allow-listed suite entry for an ADD op. Required fields per the SQA
|
|
596
|
+
* validator: testcaseId, code, seq (set by the caller), version, versionId, tcType. */
|
|
597
|
+
async buildSuiteEntryFromTestcase(tcNum) {
|
|
598
|
+
const raw = await this.fetchRawTestcase(tcNum);
|
|
599
|
+
if (Object.keys(raw).length === 0)
|
|
600
|
+
throw new Error(`test case ${tcNum} not found (cannot add to suite)`);
|
|
601
|
+
const code = str(raw, 'code', 'tcCode') ?? `TC_${tcNum}`;
|
|
602
|
+
const tcType = str(raw, 'tcType') ?? 'WEB';
|
|
603
|
+
let versionName = str(raw, 'versionName', 'selectedVersionName');
|
|
604
|
+
let versionId = str(raw, 'versionId', 'selectedVersionId');
|
|
605
|
+
if ((!versionName || !versionId) && Array.isArray(raw?.versions) && raw.versions.length > 0) {
|
|
606
|
+
const v = raw.versions[0];
|
|
607
|
+
versionName ?? (versionName = str(v, 'name'));
|
|
608
|
+
versionId ?? (versionId = str(v, 'id', '_id'));
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
testcaseId: Number.parseInt(tcNum, 10),
|
|
612
|
+
code,
|
|
613
|
+
tcType,
|
|
614
|
+
...(raw?.projectId !== undefined ? { projectId: raw.projectId } : {}),
|
|
615
|
+
version: versionName ?? 'v1.0',
|
|
616
|
+
versionId: versionId ?? '',
|
|
617
|
+
executionPlatform: { LOCAL: {}, REMOTE: {}, CLOUD: {} },
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
// ---------------------------------------------------------------------- Object search + write [Step 4]
|
|
621
|
+
// No exposed common-mcp tool calls these on ProviderAdapter today for SQA — leave on hop.
|
|
622
|
+
async searchObjects(q) { return this.requireHop('search_objects').searchObjects(q); }
|
|
623
|
+
async createObject(o) { return this.requireHop('create_object').createObject(o); }
|
|
624
|
+
// ---------------------------------------------------------------------- Actions catalog
|
|
625
|
+
async actionsCatalog(platform) {
|
|
626
|
+
return this.requireHop('actions_catalog').actionsCatalog(platform);
|
|
627
|
+
}
|
|
628
|
+
// ---------------------------------------------------------------------- Testdata generation [Step 3]
|
|
629
|
+
/** Fetch the default (template) iteration row for a TC. Fills gaps from the raw TC:
|
|
630
|
+
* - versionId: falls back through versionId → selectedVersionId → versionsDetails.id → versions[0].id
|
|
631
|
+
* - executionType (required by the backend Mongo filter): read from raw TC, default AUTOMATION
|
|
632
|
+
* - environmentType: caller value wins; else /pm/fields/8 first configured env; else 'general' */
|
|
633
|
+
async getDefaultIteration(q) {
|
|
634
|
+
const projectId = this.requireProjectId();
|
|
635
|
+
const tcNum = numericId(q.tcId);
|
|
636
|
+
let versionId = q.versionId;
|
|
637
|
+
let execType;
|
|
638
|
+
if (!versionId || !execType) {
|
|
639
|
+
const raw = await this.fetchRawTestcase(q.tcId);
|
|
640
|
+
if (!versionId) {
|
|
641
|
+
versionId = str(raw, 'versionId', 'selectedVersionId', 'selVersion');
|
|
642
|
+
if (!versionId && raw?.versionsDetails && typeof raw.versionsDetails === 'object') {
|
|
643
|
+
versionId = str(raw.versionsDetails, 'id', '_id', 'versionId');
|
|
644
|
+
}
|
|
645
|
+
if (!versionId && Array.isArray(raw?.versions) && raw.versions.length > 0) {
|
|
646
|
+
versionId = str(raw.versions[0], 'id', '_id');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
execType = str(raw, 'executionType');
|
|
650
|
+
if (!versionId)
|
|
651
|
+
throw new Error(`test case ${q.tcId} has no versionId — pass one explicitly`);
|
|
652
|
+
}
|
|
653
|
+
if (!execType)
|
|
654
|
+
execType = 'AUTOMATION';
|
|
655
|
+
const env = await this.resolveEnvironment(projectId, q.environmentType);
|
|
656
|
+
const path = '/tm/testdata/getDefaultItr' +
|
|
657
|
+
`?projectId=${projectId}` +
|
|
658
|
+
`&testcaseId=${tcNum}` +
|
|
659
|
+
`&versionId=${encodeURIComponent(versionId)}` +
|
|
660
|
+
`&environmentType=${encodeURIComponent(env)}` +
|
|
661
|
+
'&collection=TC' +
|
|
662
|
+
`&executionType=${encodeURIComponent(execType)}`;
|
|
663
|
+
const raw = await this.http.getJson(path);
|
|
664
|
+
const paramsMap = raw?.data?.parametersData;
|
|
665
|
+
const parameters = [];
|
|
666
|
+
if (paramsMap && typeof paramsMap === 'object' && !Array.isArray(paramsMap)) {
|
|
667
|
+
for (const [key, value] of Object.entries(paramsMap)) {
|
|
668
|
+
const pid = Number.parseInt(key, 10);
|
|
669
|
+
if (!Number.isFinite(pid))
|
|
670
|
+
continue;
|
|
671
|
+
const seed = String(value?.value ?? '');
|
|
672
|
+
const name = await this.fetchParameterName(pid);
|
|
673
|
+
if (!name)
|
|
674
|
+
continue; // parameter deleted — skip
|
|
675
|
+
parameters.push({ paramId: pid, name, seed });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return { tcId: q.tcId, versionId: versionId, environmentType: env, parameters };
|
|
679
|
+
}
|
|
680
|
+
/** Persist N generated iteration rows. Rebuilds the name → paramId map from a fresh template
|
|
681
|
+
* fetch — no cache, because a stale id would silently write to the wrong parameter. */
|
|
682
|
+
async saveGeneratedIterations(payload) {
|
|
683
|
+
if (!payload.rows || payload.rows.length === 0)
|
|
684
|
+
throw new Error('rows[] required and non-empty');
|
|
685
|
+
if (!payload.versionId)
|
|
686
|
+
throw new Error('versionId required — call get_default_iteration first');
|
|
687
|
+
const projectId = this.requireProjectId();
|
|
688
|
+
const tcNum = numericId(payload.tcId);
|
|
689
|
+
const env = payload.environmentType || 'general';
|
|
690
|
+
// Reuse the caller's already-fetched template when supplied — the tc_generate_testdata tool
|
|
691
|
+
// fetches it just before calling us. Skipping the re-fetch avoids a duplicate /getDefaultItr +
|
|
692
|
+
// N /tm/parameter/{id} round trips per save.
|
|
693
|
+
const tplParams = payload.parameters ?? (await this.getDefaultIteration({
|
|
694
|
+
tcId: payload.tcId,
|
|
695
|
+
versionId: payload.versionId,
|
|
696
|
+
environmentType: env,
|
|
697
|
+
})).parameters;
|
|
698
|
+
const nameToId = new Map();
|
|
699
|
+
for (const p of tplParams)
|
|
700
|
+
nameToId.set(p.name, p.paramId);
|
|
701
|
+
const formulas = payload.formulas ?? {};
|
|
702
|
+
const formulaNames = Object.keys(formulas);
|
|
703
|
+
// Per-row: evaluate the row's formula columns concurrently. The compute endpoint mints RANDOM
|
|
704
|
+
// values so each call must still happen (no dedup across rows), but within a row the calls are
|
|
705
|
+
// independent — Promise.all cuts wall time roughly by the formula count.
|
|
706
|
+
// ponytail: per-row concurrency only. Firing all rows × formulas at once would work but might
|
|
707
|
+
// stress the external DATA_GENERATION_URL proxy; scale wider only if the row-level speedup is not enough.
|
|
708
|
+
const iterations = [];
|
|
709
|
+
for (const row of payload.rows) {
|
|
710
|
+
const data = [];
|
|
711
|
+
for (const [k, v] of Object.entries(row)) {
|
|
712
|
+
if (formulaNames.includes(k))
|
|
713
|
+
continue; // a formula covers this column — its value wins
|
|
714
|
+
const pid = nameToId.get(k);
|
|
715
|
+
if (pid == null)
|
|
716
|
+
continue; // unknown param name — silently skip
|
|
717
|
+
data.push({ paramId: pid, value: String(v ?? '') });
|
|
718
|
+
}
|
|
719
|
+
const evaluated = await Promise.all(formulaNames.map(async (name) => {
|
|
720
|
+
const pid = nameToId.get(name);
|
|
721
|
+
if (pid == null)
|
|
722
|
+
return null;
|
|
723
|
+
const { value, expression } = await this.evaluateFormula(formulas[name]);
|
|
724
|
+
return { paramId: pid, value, formulaValue: expression };
|
|
725
|
+
}));
|
|
726
|
+
for (const cell of evaluated)
|
|
727
|
+
if (cell)
|
|
728
|
+
data.push(cell);
|
|
729
|
+
iterations.push({ isChecked: true, data });
|
|
730
|
+
}
|
|
731
|
+
const body = {
|
|
732
|
+
projectId,
|
|
733
|
+
data: [
|
|
734
|
+
{
|
|
735
|
+
type: 'TC',
|
|
736
|
+
id: Number.parseInt(tcNum, 10),
|
|
737
|
+
versionId: payload.versionId,
|
|
738
|
+
environmentType: env,
|
|
739
|
+
projectId,
|
|
740
|
+
iterations,
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
};
|
|
744
|
+
await this.http.postJson('/tm/testdata/saveItrForDataGen', body);
|
|
745
|
+
return { tcId: payload.tcId, inserted: iterations.length };
|
|
746
|
+
}
|
|
747
|
+
/** Environment key: caller wins; else fetch the project's configured envs and pick the first;
|
|
748
|
+
* else 'general' (matches the SQA UI fallback). */
|
|
749
|
+
async resolveEnvironment(projectId, requested) {
|
|
750
|
+
if (requested && requested.length > 0)
|
|
751
|
+
return requested;
|
|
752
|
+
try {
|
|
753
|
+
const envs = await this.http.getJson(`/pm/fields/8?projectId=${projectId}&fieldType=environment`);
|
|
754
|
+
if (Array.isArray(envs) && envs.length > 0) {
|
|
755
|
+
const key = str(envs[0], 'key', 'value', 'name');
|
|
756
|
+
if (key)
|
|
757
|
+
return key;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
catch { /* fall through */ }
|
|
761
|
+
return 'general';
|
|
762
|
+
}
|
|
763
|
+
/** Parameter name lookup for paramId → name enrichment. Null when the parameter is deleted or
|
|
764
|
+
* belongs to a different project. */
|
|
765
|
+
async fetchParameterName(paramId) {
|
|
766
|
+
let raw;
|
|
767
|
+
try {
|
|
768
|
+
raw = await this.http.getJson(`/tm/parameter/${paramId}`);
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
let n = raw?.data ?? raw;
|
|
774
|
+
if (Array.isArray(n))
|
|
775
|
+
n = n.length > 0 ? n[0] : null;
|
|
776
|
+
if (!n || typeof n !== 'object')
|
|
777
|
+
return null;
|
|
778
|
+
return str(n, 'name') ?? null;
|
|
779
|
+
}
|
|
780
|
+
// ---------------------------------------------------------------------- Formulas [Step 3b]
|
|
781
|
+
/** List every formula the current project can use — DEFAULT (customer-wide prefabs) unioned with
|
|
782
|
+
* CUSTOM entries scoped to the customer or this project. The backend endpoint already returns the
|
|
783
|
+
* union when called with `projectId`; we just normalise the doc shape and derive `scope` from
|
|
784
|
+
* (type + projectId). Cached per adapter instance. */
|
|
785
|
+
async listFormulas() {
|
|
786
|
+
if (this.formulaCache)
|
|
787
|
+
return this.formulaCache;
|
|
788
|
+
const projectId = this.requireProjectId();
|
|
789
|
+
const pending = (async () => {
|
|
790
|
+
const raw = await this.http.getJson(`/tm/syncFormulas/fetch?projectId=${projectId}`);
|
|
791
|
+
const list = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw) ? raw : [];
|
|
792
|
+
const out = [];
|
|
793
|
+
for (const doc of list) {
|
|
794
|
+
const f = normalizeFormula(doc, projectId);
|
|
795
|
+
if (f)
|
|
796
|
+
out.push(f);
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
})().catch((e) => {
|
|
800
|
+
// On failure, drop the memoised promise so a retry actually retries.
|
|
801
|
+
this.formulaCache = null;
|
|
802
|
+
throw e;
|
|
803
|
+
});
|
|
804
|
+
this.formulaCache = pending;
|
|
805
|
+
return pending;
|
|
806
|
+
}
|
|
807
|
+
/** Evaluate one formula call and return { value, expression }. Order args by the formula's declared
|
|
808
|
+
* parameter list so the stored `formulaValue` string is positional (`=NAME("a","b")`) — that's
|
|
809
|
+
* what the SQA UI writes, and the backend just reads it verbatim on save. */
|
|
810
|
+
async evaluateFormula(call) {
|
|
811
|
+
const all = await this.listFormulas();
|
|
812
|
+
// Both sides carry parens: the backend stores DEFAULT names as "FIRST_NAME()", and models copy the
|
|
813
|
+
// SIGNATURE format ("FIRST_NAME(country name,sex,min,max)"). Strip on both, compare case-insensitively.
|
|
814
|
+
const needle = bareFormulaName(call.name).toUpperCase();
|
|
815
|
+
const meta = all.find((f) => bareFormulaName(f.name).toUpperCase() === needle);
|
|
816
|
+
if (!meta) {
|
|
817
|
+
throw new Error(`Unknown formula "${call.name}" — call tc_list_formulas to see what is available.`);
|
|
818
|
+
}
|
|
819
|
+
const fname = bareFormulaName(meta.name);
|
|
820
|
+
// Arg keys arrive with trailing colons/whitespace ("country name:", " sex ") from both the model AND
|
|
821
|
+
// the formula's own declared keyName. Normalise both sides before binding.
|
|
822
|
+
const argKey = (k) => k.replace(/:$/, '').trim();
|
|
823
|
+
const args = {};
|
|
824
|
+
for (const [k, v] of Object.entries(call.args ?? {}))
|
|
825
|
+
args[argKey(k)] = v;
|
|
826
|
+
const ordered = meta.parameters.map((p) => args[argKey(p.keyName)] ?? '');
|
|
827
|
+
// /tm/customFormula takes its OWN positional query keys, not the formula's display keyNames.
|
|
828
|
+
const qs = new URLSearchParams({ formulaName: fname });
|
|
829
|
+
const apiKeys = FORMULA_ARG_KEYS[fname.toUpperCase()] ?? ['value'];
|
|
830
|
+
ordered.forEach((v, i) => { if (v !== '' && apiKeys[i])
|
|
831
|
+
qs.append(apiKeys[i], v); });
|
|
832
|
+
// ponytail: the compute endpoint's response envelope varies between formula types
|
|
833
|
+
// (`{data:{value}}` for person-info, plain string for a few); coerce defensively.
|
|
834
|
+
const resp = await this.http.getJson(`/tm/customFormula?${qs.toString()}`);
|
|
835
|
+
const value = String(resp?.data?.value ?? resp?.value ?? resp?.data ?? resp ?? '');
|
|
836
|
+
const quoted = ordered.map((a) => `"${String(a).replace(/"/g, '\\"')}"`).join(',');
|
|
837
|
+
const expression = `=${fname}(${quoted})`;
|
|
838
|
+
return { value, expression };
|
|
839
|
+
}
|
|
840
|
+
// ---------------------------------------------------------------------- Run status [Step 6]
|
|
841
|
+
/** Real status of a run — reads step-level results direct from SQA REST because the desktop agent's
|
|
842
|
+
* get_run only returns queue state (often stale). start/stop still hop for v1. */
|
|
843
|
+
async getRun(runId) {
|
|
844
|
+
const projectId = this.requireProjectId();
|
|
845
|
+
const base = (this.cfg.baseUrl ?? '').replace(/\/+$/, '');
|
|
846
|
+
// GET /exec/report/{runId}/{projectId} — the run rows the /reports page reads.
|
|
847
|
+
const runs = await this.http.getJson(`/exec/report/${runId}/${projectId}`);
|
|
848
|
+
if (!Array.isArray(runs) || runs.length === 0) {
|
|
849
|
+
return { runId, state: 'queued' };
|
|
850
|
+
}
|
|
851
|
+
// Multi-TC runs return several rows; pick the first FAILED so tc_heal has a failing step to diagnose.
|
|
852
|
+
let chosen = runs[0];
|
|
853
|
+
for (const r of runs) {
|
|
854
|
+
if (typeof r?.status === 'string' && r.status.toUpperCase() === 'FAILED') {
|
|
855
|
+
chosen = r;
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const executionRunId = str(chosen, 'id', '_id') ?? '';
|
|
860
|
+
const parentId = Number(chosen?.executionItemId ?? 0);
|
|
861
|
+
const stepsResp = await this.http.postJson('/reports/execution/getstepResult', {
|
|
862
|
+
executionId: Number.parseInt(numericId(runId), 10),
|
|
863
|
+
executionRunId,
|
|
864
|
+
parentId,
|
|
865
|
+
projectId,
|
|
866
|
+
});
|
|
867
|
+
const rawSteps = Array.isArray(stepsResp?.data) ? stepsResp.data : [];
|
|
868
|
+
const stepResults = rawSteps.map((s) => {
|
|
869
|
+
const step = {
|
|
870
|
+
n: typeof s?.seq === 'number' ? s.seq : 0,
|
|
871
|
+
description: String(s?.description ?? ''),
|
|
872
|
+
status: mapStepStatus(str(s, 'status') ?? ''),
|
|
873
|
+
};
|
|
874
|
+
const exc = str(s, 'exception');
|
|
875
|
+
if (exc && exc.length > 0) {
|
|
876
|
+
step.error = exc;
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
// SQA leaves `exception` null even on FAILED. The real cause sits in ERROR log lines
|
|
880
|
+
// (e.g. "No Such Element Exception: ..."). Send ALL ERROR lines joined — a trailing
|
|
881
|
+
// "JS disabled" line would otherwise mask the real NoSuchElement above it.
|
|
882
|
+
const logs = s?.executionLogData?.executionLogs;
|
|
883
|
+
if (Array.isArray(logs) && logs.length > 0) {
|
|
884
|
+
const errorLines = [];
|
|
885
|
+
for (const l of logs) {
|
|
886
|
+
const t = String(l ?? '');
|
|
887
|
+
if (t.includes('ERROR'))
|
|
888
|
+
errorLines.push(t);
|
|
889
|
+
}
|
|
890
|
+
if (errorLines.length > 0)
|
|
891
|
+
step.error = errorLines.join('\n');
|
|
892
|
+
else
|
|
893
|
+
step.error = String(logs[logs.length - 1] ?? '');
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return step;
|
|
897
|
+
});
|
|
898
|
+
return {
|
|
899
|
+
runId,
|
|
900
|
+
state: mapRunState(str(chosen, 'status') ?? ''),
|
|
901
|
+
stepResults,
|
|
902
|
+
reportUrl: `${base}/reports/execution/${runId}`,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
// --- Run dispatch (direct REST) --------------------------------------------
|
|
906
|
+
// Trigger, stop, and local-agent roster all speak the SQA gateway directly.
|
|
907
|
+
// The desktop agent still runs the actual TC — backend RPUSHes to Redis and the
|
|
908
|
+
// agent's poll loop picks it up — but the KICKOFF event no longer hops through
|
|
909
|
+
// /mcp:4040. Spec: common-mcp/plan/sqa-execution-trigger.md. Cloud runners and
|
|
910
|
+
// SUITE runs are not yet ported and still hop (or error clearly).
|
|
911
|
+
async startRun(target, opts) {
|
|
912
|
+
const projectId = this.requireProjectId();
|
|
913
|
+
if (!target.tcId) {
|
|
914
|
+
if (target.suiteId) {
|
|
915
|
+
throw new Error('startRun: SUITE runs are not yet ported to direct REST — set QA_MCP_URL so common-mcp ' +
|
|
916
|
+
'can hop to the SimplifyQA agent for suite dispatch, or run a single TC.');
|
|
917
|
+
}
|
|
918
|
+
throw new Error('startRun: target.tcId (or target.suiteId) is required.');
|
|
919
|
+
}
|
|
920
|
+
const tcId = target.tcId;
|
|
921
|
+
const tcNum = Number.parseInt(numericId(tcId), 10);
|
|
922
|
+
if (!Number.isFinite(tcNum))
|
|
923
|
+
throw new Error(`startRun: could not resolve numeric id for ${tcId}.`);
|
|
924
|
+
const mode = (opts.mode ?? 'local').toUpperCase();
|
|
925
|
+
const rawTc = await this.fetchRawTestcase(tcId);
|
|
926
|
+
if (!rawTc || Object.keys(rawTc).length === 0) {
|
|
927
|
+
throw new Error(`startRun: test case ${tcId} not found.`);
|
|
928
|
+
}
|
|
929
|
+
// Version resolution — same fallback chain buildTestcasePayload/getDefaultIteration use.
|
|
930
|
+
let versionName = str(rawTc, 'versionName', 'selectedVersionName');
|
|
931
|
+
let versionId = str(rawTc, 'versionId', 'selectedVersionId');
|
|
932
|
+
if ((!versionName || !versionId) && rawTc?.versionsDetails && typeof rawTc.versionsDetails === 'object') {
|
|
933
|
+
versionName ?? (versionName = str(rawTc.versionsDetails, 'name'));
|
|
934
|
+
versionId ?? (versionId = str(rawTc.versionsDetails, 'id', '_id', 'versionId'));
|
|
935
|
+
}
|
|
936
|
+
if ((!versionName || !versionId) && Array.isArray(rawTc?.versions) && rawTc.versions.length > 0) {
|
|
937
|
+
const v = rawTc.versions[0];
|
|
938
|
+
versionName ?? (versionName = str(v, 'name'));
|
|
939
|
+
versionId ?? (versionId = str(v, 'id', '_id'));
|
|
940
|
+
}
|
|
941
|
+
versionName ?? (versionName = 'v1.0');
|
|
942
|
+
versionId ?? (versionId = '');
|
|
943
|
+
const info = await this.info();
|
|
944
|
+
const env = await this.resolveEnvironment(projectId, opts.environment);
|
|
945
|
+
const tcType = String(str(rawTc, 'tcType') ?? 'WEB').toUpperCase();
|
|
946
|
+
const executionType = String(str(rawTc, 'executionType') ?? 'AUTOMATION').toUpperCase();
|
|
947
|
+
const code = str(rawTc, 'code') ?? '';
|
|
948
|
+
const name = str(rawTc, 'name', 'title') ?? '';
|
|
949
|
+
const description = str(rawTc, 'description') ?? '';
|
|
950
|
+
const prerequisite = str(rawTc, 'prerequisite', 'preconditions') ?? '';
|
|
951
|
+
const moduleIdNum = typeof rawTc?.moduleId === 'number' ? rawTc.moduleId : 0;
|
|
952
|
+
const userstoryIdNum = typeof rawTc?.userstoryId === 'number' ? rawTc.userstoryId : 0;
|
|
953
|
+
const tags = Array.isArray(rawTc?.tags) ? rawTc.tags : [];
|
|
954
|
+
// Best-effort module name — backend doesn't validate it functionally; one /pm/module/search
|
|
955
|
+
// covers every id, so the extra call is cheap. Story name deliberately left empty; adds a
|
|
956
|
+
// per-run round-trip for a cosmetic field. ponytail: enrich if a caller ever complains.
|
|
957
|
+
let moduleName = '';
|
|
958
|
+
if (moduleIdNum > 0) {
|
|
959
|
+
try {
|
|
960
|
+
const modules = await this.listModules();
|
|
961
|
+
moduleName = modules.find((m) => m.id === String(moduleIdNum))?.name ?? '';
|
|
962
|
+
}
|
|
963
|
+
catch { /* leave empty */ }
|
|
964
|
+
}
|
|
965
|
+
const agentId = mode === 'LOCAL' ? await this.resolveLocalAgentId(opts.agentId) : null;
|
|
966
|
+
const platform = this.buildRunPlatform(tcType, mode, opts.browser, opts.deviceId);
|
|
967
|
+
// UI uses `Math.random().toString(26).substr(5,6)` — plan doc confirms any string works.
|
|
968
|
+
const stepId = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
|
|
969
|
+
const body = {
|
|
970
|
+
projectId,
|
|
971
|
+
agentId,
|
|
972
|
+
environmentType: env,
|
|
973
|
+
executionStyle: (opts.strategy ?? 'serial').toUpperCase(),
|
|
974
|
+
executionOS: this.detectExecutionOS(),
|
|
975
|
+
executionTypeId: tcNum,
|
|
976
|
+
executionTypeName: name,
|
|
977
|
+
executionTypeCode: code,
|
|
978
|
+
mailersList: {},
|
|
979
|
+
cloudType: mode === 'CLOUD' ? 'VM' : 'NONE',
|
|
980
|
+
mode,
|
|
981
|
+
executionCategory: 'EXECUTION',
|
|
982
|
+
releaseId: 0,
|
|
983
|
+
iterationId: 0,
|
|
984
|
+
executionTime: '',
|
|
985
|
+
status: 'INPROGRESS',
|
|
986
|
+
iterationsSelected: [],
|
|
987
|
+
result: '',
|
|
988
|
+
globalConfiguration: {},
|
|
989
|
+
isRecordingEnabled: false,
|
|
990
|
+
offset: -(new Date().getTimezoneOffset()),
|
|
991
|
+
type: 'TESTCASE',
|
|
992
|
+
executionType,
|
|
993
|
+
executionItems: [{
|
|
994
|
+
id: tcNum,
|
|
995
|
+
name,
|
|
996
|
+
description,
|
|
997
|
+
versionId,
|
|
998
|
+
versionName,
|
|
999
|
+
seq: 1,
|
|
1000
|
+
stepId,
|
|
1001
|
+
stepCount: 0,
|
|
1002
|
+
technologyType: tcType,
|
|
1003
|
+
type: 'TC',
|
|
1004
|
+
platform,
|
|
1005
|
+
projectId,
|
|
1006
|
+
projectName: info.projectName,
|
|
1007
|
+
configuration: {},
|
|
1008
|
+
iterationsSelected: [],
|
|
1009
|
+
moduleId: moduleIdNum,
|
|
1010
|
+
userstoryId: userstoryIdNum,
|
|
1011
|
+
tags,
|
|
1012
|
+
code,
|
|
1013
|
+
prerequisite,
|
|
1014
|
+
moduleName,
|
|
1015
|
+
userstoryName: '',
|
|
1016
|
+
}],
|
|
1017
|
+
};
|
|
1018
|
+
const resp = await this.http.postJson('/exec/execution', body);
|
|
1019
|
+
const d = resp?.data ?? resp;
|
|
1020
|
+
const runId = str(d, 'id', 'executionId', '_id') ??
|
|
1021
|
+
(typeof d?.id === 'number' ? String(d.id) : undefined) ??
|
|
1022
|
+
(typeof d?.executionId === 'number' ? String(d.executionId) : undefined);
|
|
1023
|
+
if (!runId)
|
|
1024
|
+
throw new Error('startRun: backend accepted trigger but returned no run id.');
|
|
1025
|
+
return { runId };
|
|
1026
|
+
}
|
|
1027
|
+
async stopRun(runId) {
|
|
1028
|
+
const projectId = this.requireProjectId();
|
|
1029
|
+
const executionId = Number.parseInt(numericId(runId), 10);
|
|
1030
|
+
if (!Number.isFinite(executionId))
|
|
1031
|
+
throw new Error(`stopRun: invalid runId ${runId}.`);
|
|
1032
|
+
await this.http.postJson('/exec/execution/stopExecution', {
|
|
1033
|
+
projectId,
|
|
1034
|
+
executionId,
|
|
1035
|
+
killType: 'STOP_EXECUTION',
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
// Cloud runner roster is not yet ported — plan doc only covers LOCAL trigger + stop + agents.
|
|
1039
|
+
async listCloudRunners() { return this.requireHop('list_cloud_runners').listCloudRunners(); }
|
|
1040
|
+
async listLocalAgents() {
|
|
1041
|
+
const body = this.searchBody([{ column: 'deleted', value: false }], { limit: 1000 });
|
|
1042
|
+
const raw = await this.http.postJson('/tm/machineRegistration/search', body);
|
|
1043
|
+
const rows = pickArray(raw, 'data', 'content', 'agents', 'machines');
|
|
1044
|
+
return rows.map((n) => {
|
|
1045
|
+
const id = str(n, 'id', '_id', 'agentId') ?? '';
|
|
1046
|
+
const agentName = str(n, 'machineName', 'name', 'host');
|
|
1047
|
+
const owner = str(n, 'owner', 'userName', 'createdBy');
|
|
1048
|
+
const host = str(n, 'macAddress', 'hostAddress');
|
|
1049
|
+
// `active` is the real Redis-heartbeat flag on machineRegistration — the executor only picks
|
|
1050
|
+
// up runs whose agent is currently polling. A stale registration with `active: false` will
|
|
1051
|
+
// sit in Redis until TTL and never execute, so surface the real state to the caller.
|
|
1052
|
+
const active = typeof n?.active === 'boolean' ? n.active : false;
|
|
1053
|
+
return {
|
|
1054
|
+
id,
|
|
1055
|
+
...(agentName ? { name: agentName } : {}),
|
|
1056
|
+
online: active,
|
|
1057
|
+
...(owner ? { owner } : {}),
|
|
1058
|
+
...(host ? { host } : {}),
|
|
1059
|
+
};
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Pick THE ACTING USER'S OWN active agent — never row [0] of a shared-tenant roster (which
|
|
1064
|
+
* would silently dispatch a local run to a teammate's box, and the run would sit queued until
|
|
1065
|
+
* TTL because it lands in a Redis key that no local agent on the caller's machine polls).
|
|
1066
|
+
*
|
|
1067
|
+
* SQA's `machineRegistration` row carries no owner_user_id column (unlike walnut, which matches
|
|
1068
|
+
* the JWT `id` claim to `owner_user_id`), so we identify "your" agent by hostname: common-mcp
|
|
1069
|
+
* runs on the caller's own box in the typical MCP setup, and SQA `machineName` is either the
|
|
1070
|
+
* bare OS hostname or a bracketed display form (`USER.NAME [ Hostname.local ]`) — both contain
|
|
1071
|
+
* `os.hostname()`. Filter → active only → hostname substring match → return the single hit.
|
|
1072
|
+
* Zero/many matches refuse honestly; a silent guess would just repeat the exact bug we're fixing.
|
|
1073
|
+
*
|
|
1074
|
+
* ponytail: hostname heuristic. Upgrade to owner-user-id match if the backend adds that column
|
|
1075
|
+
* to machineRegistration (mirror walnut's `resolveLocalAgentCredential` at that point).
|
|
1076
|
+
*/
|
|
1077
|
+
async resolveLocalAgentId(preferred) {
|
|
1078
|
+
if (preferred && preferred.length > 0)
|
|
1079
|
+
return preferred;
|
|
1080
|
+
const agents = await this.listLocalAgents();
|
|
1081
|
+
if (agents.length === 0) {
|
|
1082
|
+
throw new Error('startRun (LOCAL): no local agent registered. Register a machine in the SimplifyQA UI ' +
|
|
1083
|
+
'first, or pass mode: "cloud" if a cloud runner is provisioned.');
|
|
1084
|
+
}
|
|
1085
|
+
const active = agents.filter((a) => a.online);
|
|
1086
|
+
if (active.length === 0) {
|
|
1087
|
+
throw new Error('startRun (LOCAL): no local agent is currently active (polling). Launch your SimplifyQA ' +
|
|
1088
|
+
'desktop agent and retry — or pass `agentId` explicitly if you know the credential id.');
|
|
1089
|
+
}
|
|
1090
|
+
const host = os.hostname().toLowerCase();
|
|
1091
|
+
const mine = active.filter((a) => (a.name ?? '').toLowerCase().includes(host));
|
|
1092
|
+
if (mine.length === 1)
|
|
1093
|
+
return mine[0].id;
|
|
1094
|
+
const listing = (arr) => arr.map((a) => ` - ${a.name ?? a.id} \`${a.id}\``).join('\n');
|
|
1095
|
+
if (mine.length > 1) {
|
|
1096
|
+
throw new Error(`startRun (LOCAL): multiple active agents match this machine (${host}). Pass \`agentId\` ` +
|
|
1097
|
+
`to pick one:\n${listing(mine)}`);
|
|
1098
|
+
}
|
|
1099
|
+
throw new Error(`startRun (LOCAL): could not auto-pick your local agent — no active agent's name matches ` +
|
|
1100
|
+
`this hostname (${host}). Pass \`agentId\` explicitly. Active agents:\n${listing(active)}`);
|
|
1101
|
+
}
|
|
1102
|
+
detectExecutionOS() {
|
|
1103
|
+
if (process.platform === 'win32')
|
|
1104
|
+
return 'WINDOWS';
|
|
1105
|
+
if (process.platform === 'darwin')
|
|
1106
|
+
return 'MAC';
|
|
1107
|
+
return 'LINUX';
|
|
1108
|
+
}
|
|
1109
|
+
buildRunPlatform(tcType, mode, browser, deviceId) {
|
|
1110
|
+
const t = tcType.toUpperCase();
|
|
1111
|
+
if (t === 'WEB') {
|
|
1112
|
+
const b = (browser ?? 'CHROME').toUpperCase();
|
|
1113
|
+
return [{ name: b, id: b, technologyType: 'WEB', type: 'NONE' }];
|
|
1114
|
+
}
|
|
1115
|
+
if (t === 'ANDROID' || t === 'IOS') {
|
|
1116
|
+
// Executor's AndroidSessionFactory.buildLocal calls requireDeviceId — no default. Fail
|
|
1117
|
+
// loud so the caller sees the fix (list devices, pick one) instead of a queued/error hang.
|
|
1118
|
+
if (!deviceId) {
|
|
1119
|
+
throw new Error(`${t} test cases need \`deviceId\`. Call android_list_devices to see the available ` +
|
|
1120
|
+
`devices, then re-run with \`deviceId\` set to the adb serial (LOCAL) or marketplace ` +
|
|
1121
|
+
`device name (CLOUD).`);
|
|
1122
|
+
}
|
|
1123
|
+
// ponytail: CLOUD path still ships `cloudDeviceType: undefined` — the executor needs it
|
|
1124
|
+
// (BROWSER_STACK / SAUCE_LABS / LAMBDA_INTEGRATION / SQA_DEVICE_FARM) to know which farm
|
|
1125
|
+
// to route to. Deferred with the marketplace port; LOCAL runs unaffected.
|
|
1126
|
+
return [{
|
|
1127
|
+
name: deviceId, id: deviceId, technologyType: t,
|
|
1128
|
+
type: mode === 'CLOUD' ? 'CLOUD_DEVICE' : 'NONE',
|
|
1129
|
+
}];
|
|
1130
|
+
}
|
|
1131
|
+
return [{ name: t, id: t, technologyType: t, type: 'NONE' }];
|
|
1132
|
+
}
|
|
1133
|
+
// --- Environments + datasets ------------------------------------------- (fine to hop for now)
|
|
1134
|
+
async listEnvironments() {
|
|
1135
|
+
const h = this.hop();
|
|
1136
|
+
return h ? h.listEnvironments() : [];
|
|
1137
|
+
}
|
|
1138
|
+
async listDatasets(tcId) {
|
|
1139
|
+
const h = this.hop();
|
|
1140
|
+
return h?.listDatasets ? h.listDatasets(tcId) : [];
|
|
1141
|
+
}
|
|
1142
|
+
// --- Journal (recording seed lives on the agent's FS) ------------------
|
|
1143
|
+
async readJournal(tcId) {
|
|
1144
|
+
return this.requireHop('read_journal').readJournal(tcId);
|
|
1145
|
+
}
|
|
1146
|
+
async editJournal(tcId, ops) {
|
|
1147
|
+
return this.requireHop('edit_journal').editJournal(tcId, ops);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
//# sourceMappingURL=adapter.js.map
|