common-qa-mcp 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/adapters/factory.d.ts +17 -7
- package/dist/adapters/factory.d.ts.map +1 -1
- package/dist/adapters/factory.js +44 -7
- package/dist/adapters/factory.js.map +1 -1
- package/dist/adapters/http.d.ts +50 -0
- package/dist/adapters/http.d.ts.map +1 -0
- package/dist/adapters/http.js +108 -0
- package/dist/adapters/http.js.map +1 -0
- package/dist/adapters/remoteMcp/adapter.d.ts +16 -2
- package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
- package/dist/adapters/remoteMcp/adapter.js +34 -5
- package/dist/adapters/remoteMcp/adapter.js.map +1 -1
- package/dist/adapters/simplifyqa/adapter.d.ts +437 -0
- package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/adapter.js +3097 -0
- package/dist/adapters/simplifyqa/adapter.js.map +1 -0
- package/dist/adapters/simplifyqa/mapper.d.ts +95 -0
- package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -0
- package/dist/adapters/simplifyqa/mapper.js +858 -0
- package/dist/adapters/simplifyqa/mapper.js.map +1 -0
- package/dist/adapters/types.d.ts +168 -5
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/adapters/walnut/adapter.d.ts +235 -0
- package/dist/adapters/walnut/adapter.d.ts.map +1 -0
- package/dist/adapters/walnut/adapter.js +1114 -0
- package/dist/adapters/walnut/adapter.js.map +1 -0
- package/dist/adapters/walnut/mapper.d.ts +30 -9
- package/dist/adapters/walnut/mapper.d.ts.map +1 -1
- package/dist/adapters/walnut/mapper.js +149 -24
- package/dist/adapters/walnut/mapper.js.map +1 -1
- package/dist/config.d.ts +38 -14
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +73 -19
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +3 -2
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +44 -15
- package/dist/connection.js.map +1 -1
- package/dist/domain/types.d.ts +680 -5
- package/dist/domain/types.d.ts.map +1 -1
- package/dist/drafts/store.d.ts +4 -1
- package/dist/drafts/store.d.ts.map +1 -1
- package/dist/drafts/store.js +7 -2
- package/dist/drafts/store.js.map +1 -1
- package/dist/initAgents.d.ts.map +1 -1
- package/dist/initAgents.js +48 -18
- package/dist/initAgents.js.map +1 -1
- package/dist/requestConnection.d.ts +5 -3
- package/dist/requestConnection.d.ts.map +1 -1
- package/dist/requestConnection.js +11 -5
- package/dist/requestConnection.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +47 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/backlog.tools.d.ts +13 -0
- package/dist/tools/backlog.tools.d.ts.map +1 -0
- package/dist/tools/backlog.tools.js +399 -0
- package/dist/tools/backlog.tools.js.map +1 -0
- package/dist/tools/connection.tools.js +5 -5
- package/dist/tools/connection.tools.js.map +1 -1
- package/dist/tools/defects.tools.d.ts +7 -0
- package/dist/tools/defects.tools.d.ts.map +1 -0
- package/dist/tools/defects.tools.js +82 -0
- package/dist/tools/defects.tools.js.map +1 -0
- package/dist/tools/discovery.tools.d.ts.map +1 -1
- package/dist/tools/discovery.tools.js +951 -43
- package/dist/tools/discovery.tools.js.map +1 -1
- package/dist/tools/formulas.tools.d.ts +7 -0
- package/dist/tools/formulas.tools.d.ts.map +1 -0
- package/dist/tools/formulas.tools.js +56 -0
- package/dist/tools/formulas.tools.js.map +1 -0
- package/dist/tools/heal.tools.d.ts +17 -0
- package/dist/tools/heal.tools.d.ts.map +1 -0
- package/dist/tools/heal.tools.js +76 -0
- package/dist/tools/heal.tools.js.map +1 -0
- package/dist/tools/markdown.d.ts +3 -3
- package/dist/tools/markdown.d.ts.map +1 -1
- package/dist/tools/markdown.js +89 -18
- package/dist/tools/markdown.js.map +1 -1
- package/dist/tools/objects.tools.d.ts +7 -0
- package/dist/tools/objects.tools.d.ts.map +1 -0
- package/dist/tools/objects.tools.js +201 -0
- package/dist/tools/objects.tools.js.map +1 -0
- package/dist/tools/recording.tools.d.ts.map +1 -1
- package/dist/tools/recording.tools.js +92 -12
- package/dist/tools/recording.tools.js.map +1 -1
- package/dist/tools/run.tools.d.ts.map +1 -1
- package/dist/tools/run.tools.js +464 -39
- package/dist/tools/run.tools.js.map +1 -1
- package/dist/tools/schemas.d.ts +21 -4
- package/dist/tools/schemas.d.ts.map +1 -1
- package/dist/tools/schemas.js +14 -1
- package/dist/tools/schemas.js.map +1 -1
- package/dist/tools/storySync.tools.d.ts +66 -0
- package/dist/tools/storySync.tools.d.ts.map +1 -0
- package/dist/tools/storySync.tools.js +213 -0
- package/dist/tools/storySync.tools.js.map +1 -0
- package/dist/tools/tasks.tools.d.ts +7 -0
- package/dist/tools/tasks.tools.d.ts.map +1 -0
- package/dist/tools/tasks.tools.js +75 -0
- package/dist/tools/tasks.tools.js.map +1 -0
- package/dist/tools/testcase.tools.d.ts.map +1 -1
- package/dist/tools/testcase.tools.js +162 -101
- package/dist/tools/testcase.tools.js.map +1 -1
- package/dist/tools/testdata.tools.d.ts +7 -0
- package/dist/tools/testdata.tools.d.ts.map +1 -0
- package/dist/tools/testdata.tools.js +249 -0
- package/dist/tools/testdata.tools.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
import { ApiError, makeClient } from '../http.js';
|
|
2
|
+
import { WALNUT_ACTIONS, actionDefsFrom } from './actionTypes.js';
|
|
3
|
+
import { deriveTestData, fromWalnutObject, fromWalnutStory, fromWalnutStorySummary, fromWalnutSuite, fromWalnutSuiteSummary, fromWalnutTc, fromWalnutTcSummary, toWalnutStep, } from './mapper.js';
|
|
4
|
+
import { RemoteMcpAdapter } from '../remoteMcp/adapter.js';
|
|
5
|
+
/** Datarow `values` come back as a `[{name,value}]` array (prepareRowValuesForDb) or, for legacy
|
|
6
|
+
* rows, a flat object. Flatten either to `{ paramName: value }` so new values can be merged in. */
|
|
7
|
+
function flattenRowValues(raw) {
|
|
8
|
+
const out = {};
|
|
9
|
+
if (Array.isArray(raw)) {
|
|
10
|
+
for (const item of raw) {
|
|
11
|
+
const name = item?.name;
|
|
12
|
+
if (typeof name === 'string' && name)
|
|
13
|
+
out[name] = String(item.value ?? '');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
else if (raw && typeof raw === 'object') {
|
|
17
|
+
for (const [k, v] of Object.entries(raw))
|
|
18
|
+
out[k] = v == null ? '' : String(v);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
/** Neutral step technology -> Object Repository `object_type` (schemas.ts enum, UPPERCASE). Only
|
|
23
|
+
* element-bearing technologies get an object; api/database/control_flow/function never do. */
|
|
24
|
+
const OBJECT_TYPE_FOR_STEP = {
|
|
25
|
+
web: 'WEB',
|
|
26
|
+
android: 'ANDROID',
|
|
27
|
+
mainframe: 'MAINFRAME',
|
|
28
|
+
};
|
|
29
|
+
/** The user id (`id` claim) from a Walnut JWT's payload, for the `x-user-id` header the execution
|
|
30
|
+
* routes demand. Pure base64url decode — no signature check (the backend re-verifies the token). */
|
|
31
|
+
function userIdFromJwt(token) {
|
|
32
|
+
const seg = token.split('.')[1];
|
|
33
|
+
if (!seg)
|
|
34
|
+
return undefined;
|
|
35
|
+
try {
|
|
36
|
+
let b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
|
|
37
|
+
b64 += '='.repeat((4 - (b64.length % 4)) % 4);
|
|
38
|
+
const claims = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
|
39
|
+
return typeof claims.id === 'string' ? claims.id : undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Walnut execution `status` (schemas.ts:3315) → neutral RunStatus.state. `blocked` = couldn't run
|
|
46
|
+
* (setup/dependency) → surfaced as 'error'; `terminated`/`cancelled` = user/system stop → 'stopped'. */
|
|
47
|
+
function mapRunState(status) {
|
|
48
|
+
switch (status) {
|
|
49
|
+
case 'not_started': return 'queued';
|
|
50
|
+
case 'in_progress': return 'running';
|
|
51
|
+
case 'passed': return 'passed';
|
|
52
|
+
case 'failed': return 'failed';
|
|
53
|
+
case 'terminated':
|
|
54
|
+
case 'cancelled': return 'stopped';
|
|
55
|
+
case 'blocked': return 'error';
|
|
56
|
+
case 'skipped': return 'stopped';
|
|
57
|
+
default: return 'queued';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Reserve/trigger errors come back as coded JSON (no_cloud_runner_available, cloud_runner_offline,
|
|
61
|
+
* cloud_runner_not_eligible, enqueue_failed, …). Unwrap ApiError.body to the human message so the
|
|
62
|
+
* client sees "…is offline. Choose an online runner" instead of "HTTP 409". */
|
|
63
|
+
function unwrapApiError(e) {
|
|
64
|
+
if (e instanceof ApiError) {
|
|
65
|
+
try {
|
|
66
|
+
const j = JSON.parse(e.body);
|
|
67
|
+
const msg = j.message || j.error || j.code;
|
|
68
|
+
if (msg)
|
|
69
|
+
return new Error(msg);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// body wasn't JSON — fall through to the snippet
|
|
73
|
+
}
|
|
74
|
+
return new Error(e.body || `HTTP ${e.status}`);
|
|
75
|
+
}
|
|
76
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The backend's requireProjectAccess org-scopes the project lookup: if the CONNECTED project isn't in
|
|
80
|
+
* the TOKEN's organization, EVERY org-scoped call (tc_list / tc_get / local_agents / runs / …) fails
|
|
81
|
+
* with a bare `404 {"error":"Project not found"}` — which reads like a missing route, not a wrong
|
|
82
|
+
* project. (Hit live: a persisted last-project from org A while the token is org B.) Translate ONLY
|
|
83
|
+
* that case into an actionable hint; every other error (a genuine 404, 409, 503, …) is rethrown
|
|
84
|
+
* UNCHANGED, so status-based handling and real "not found" messages are untouched.
|
|
85
|
+
*/
|
|
86
|
+
const PROJECT_SCOPE_HINT = "The connected project isn't accessible with this token — it's almost certainly not in your token's " +
|
|
87
|
+
'organization (the project may be pinned from a different org). Run list_projects to see the projects ' +
|
|
88
|
+
'this token can access, then qa_switch_project to switch to one.';
|
|
89
|
+
function isProjectNotFound(e) {
|
|
90
|
+
return e instanceof ApiError && e.status === 404 && /project not found/i.test(e.body || '');
|
|
91
|
+
}
|
|
92
|
+
/** Wrap a QaHttp so a project-scope 404 surfaces PROJECT_SCOPE_HINT instead of a bare "HTTP 404 …".
|
|
93
|
+
* Applied once to WalnutAdapter.http, so it covers every direct org-scoped call in one place. */
|
|
94
|
+
function withProjectScopeHint(http) {
|
|
95
|
+
const hint = (e) => {
|
|
96
|
+
if (isProjectNotFound(e))
|
|
97
|
+
throw new Error(PROJECT_SCOPE_HINT);
|
|
98
|
+
throw e;
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
async getJson(path) { try {
|
|
102
|
+
return await http.getJson(path);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return hint(e);
|
|
106
|
+
} },
|
|
107
|
+
async postJson(path, body) { try {
|
|
108
|
+
return await http.postJson(path, body);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
return hint(e);
|
|
112
|
+
} },
|
|
113
|
+
async putJson(path, body) { try {
|
|
114
|
+
return await http.putJson(path, body);
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return hint(e);
|
|
118
|
+
} },
|
|
119
|
+
async patchJson(path, body) { try {
|
|
120
|
+
return await http.patchJson(path, body);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
return hint(e);
|
|
124
|
+
} },
|
|
125
|
+
async del(path) { try {
|
|
126
|
+
await http.del(path);
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
hint(e);
|
|
130
|
+
} },
|
|
131
|
+
async postMultipart(path, partName, json) { try {
|
|
132
|
+
return await http.postMultipart(path, partName, json);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
return hint(e);
|
|
136
|
+
} },
|
|
137
|
+
async putMultipart(path, partName, json) { try {
|
|
138
|
+
return await http.putMultipart(path, partName, json);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
return hint(e);
|
|
142
|
+
} },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Suite-run header status ('passed'|'failed'|'in_progress'|'cancelled') → neutral RunState. */
|
|
146
|
+
function mapSuiteRunState(status) {
|
|
147
|
+
switch (String(status ?? '').toLowerCase()) {
|
|
148
|
+
case 'passed': return 'passed';
|
|
149
|
+
case 'failed': return 'failed';
|
|
150
|
+
case 'in_progress': return 'running';
|
|
151
|
+
case 'cancelled':
|
|
152
|
+
case 'canceled': return 'stopped';
|
|
153
|
+
default: return 'queued';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const PRODUCT_URL_SEGMENT = {
|
|
157
|
+
testcase: 'test-cases',
|
|
158
|
+
suite: 'test-suites',
|
|
159
|
+
// The execution REPORT page in walnut-frontend is `/reports/:id` (App.tsx) — NOT `/executions/:id`
|
|
160
|
+
// (that route 404s). `:id` is the execution Mongo _id, which is what getRun uses as runId.
|
|
161
|
+
run: 'reports',
|
|
162
|
+
// ponytail: walnut has no reusable-function library — segment unused. Kept to satisfy the union.
|
|
163
|
+
function: 'functions',
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* WalnutAdapter — DIRECT-to-backend REST driver for Walnut (design note §3/§4). Every data op
|
|
167
|
+
* (discover / author / save / run-dispatch / triage) hits the Walnut backend REST directly via a
|
|
168
|
+
* `QaHttp` bound to `cfg.baseUrl` (the BACKEND base, `QA_BASE_URL`) — so a cloud editor with no local
|
|
169
|
+
* agent can drive the whole lifecycle. The ONLY agent-bound surface is recording + save-with-editor,
|
|
170
|
+
* which hops to the LOCAL AGENT's `/mcp` (`cfg.url`, `QA_MCP_URL`) via a lazily-built RemoteMcpAdapter
|
|
171
|
+
* (see `callRaw`/`listAgentTools`); when `cfg.url` is unset, recording is gracefully unavailable.
|
|
172
|
+
*
|
|
173
|
+
* This class deliberately does not `implements ProviderAdapter` — it ports only the direct-REST subset
|
|
174
|
+
* (journal, objects, environments, suite create/delete are out of scope here); the factory casts it,
|
|
175
|
+
* and no registered tool calls the unported methods.
|
|
176
|
+
*/
|
|
177
|
+
export class WalnutAdapter {
|
|
178
|
+
constructor(cfg, transport) {
|
|
179
|
+
this.cfg = cfg;
|
|
180
|
+
/** Lazily-built hop to the LOCAL AGENT's /mcp (cfg.url) for the agent-bound recording surface. Only
|
|
181
|
+
* ever built when cfg.url is set; direct REST never uses it. */
|
|
182
|
+
this.agentHop = null;
|
|
183
|
+
// REST base is the BACKEND (cfg.baseUrl / QA_BASE_URL), NOT cfg.url (the agent /mcp endpoint). The
|
|
184
|
+
// factory only builds this adapter when baseUrl is set, so it is present at runtime.
|
|
185
|
+
this.http = withProjectScopeHint(makeClient({ baseUrl: cfg.baseUrl ?? '', headers: () => this.headers(), ...transport }));
|
|
186
|
+
}
|
|
187
|
+
// Auth headers: jwt mode sends the bearer token PLUS x-project-id (the token alone carries no
|
|
188
|
+
// project context) and x-user-id (the execution routes read the acting user from THIS header — see
|
|
189
|
+
// below); key mode sends only the bearer token, because an MCP API key already carries project
|
|
190
|
+
// context server-side. Sending x-project-id in key mode, or omitting it in jwt mode, breaks real
|
|
191
|
+
// requests against walnut-backend.
|
|
192
|
+
//
|
|
193
|
+
// x-user-id (jwt mode): the execution routes (POST /executions/reserve, /:id/stop, …) read the
|
|
194
|
+
// acting user from THIS header, not just from the token — omitting it 400s with "Project ID and
|
|
195
|
+
// User ID are required". It is derived from the token's own `id` claim, so it always matches the
|
|
196
|
+
// authenticated user; harmless on the routes that ignore it.
|
|
197
|
+
//
|
|
198
|
+
// NOTE (common-mcp adaptation): projectId is OPTIONAL here (a session can be connected before a
|
|
199
|
+
// project is chosen), so x-project-id is spread in only when present — mirroring RemoteMcpAdapter.
|
|
200
|
+
headers() {
|
|
201
|
+
if (this.cfg.walnutAuth !== 'jwt') {
|
|
202
|
+
return { Authorization: `Bearer ${this.cfg.token}`, 'Content-Type': 'application/json' };
|
|
203
|
+
}
|
|
204
|
+
const uid = userIdFromJwt(this.cfg.token);
|
|
205
|
+
return {
|
|
206
|
+
Authorization: `Bearer ${this.cfg.token}`,
|
|
207
|
+
...(this.cfg.projectId ? { 'x-project-id': this.cfg.projectId } : {}),
|
|
208
|
+
...(uid ? { 'x-user-id': uid } : {}),
|
|
209
|
+
'Content-Type': 'application/json',
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** Validates baseUrl+token+project (`GET /api/projects/:id`). */
|
|
213
|
+
async info() {
|
|
214
|
+
const res = await this.http.getJson(`/api/projects/${this.cfg.projectId ?? ''}`);
|
|
215
|
+
const { project } = res;
|
|
216
|
+
const stats = project.statistics;
|
|
217
|
+
return {
|
|
218
|
+
provider: 'walnut',
|
|
219
|
+
projectId: project._id != null ? String(project._id) : (this.cfg.projectId ?? ''),
|
|
220
|
+
projectName: project.name,
|
|
221
|
+
counts: stats
|
|
222
|
+
? {
|
|
223
|
+
stories: stats.stories_count ?? 0,
|
|
224
|
+
members: stats.members_count ?? 0,
|
|
225
|
+
testCases: stats.test_cases_count ?? 0,
|
|
226
|
+
defects: stats.defects_count ?? 0,
|
|
227
|
+
}
|
|
228
|
+
: undefined,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Projects visible to the token (`GET /api/projects`). Tolerant of both an array body and a
|
|
232
|
+
* `{ projects: [...] }` wrapper. */
|
|
233
|
+
async listProjects() {
|
|
234
|
+
const res = await this.http.getJson('/api/projects');
|
|
235
|
+
const rows = Array.isArray(res) ? res : (res?.projects ?? []);
|
|
236
|
+
return rows.map((row) => {
|
|
237
|
+
const p = row;
|
|
238
|
+
const stats = (p['statistics'] ?? {});
|
|
239
|
+
const num = (v) => (typeof v === 'number' ? v : 0);
|
|
240
|
+
return {
|
|
241
|
+
id: String(p['id'] ?? p['_id'] ?? ''),
|
|
242
|
+
name: String(p['name'] ?? ''),
|
|
243
|
+
counts: {
|
|
244
|
+
stories: num(stats['stories_count']),
|
|
245
|
+
members: num(stats['members_count']),
|
|
246
|
+
testCases: num(stats['test_cases_count']),
|
|
247
|
+
defects: num(stats['defects_count']),
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async listTestCases(q) {
|
|
253
|
+
const params = new URLSearchParams();
|
|
254
|
+
if (q.search)
|
|
255
|
+
params.set('search', q.search);
|
|
256
|
+
if (q.limit != null)
|
|
257
|
+
params.set('limit', String(q.limit));
|
|
258
|
+
if (q.offset != null)
|
|
259
|
+
params.set('offset', String(q.offset));
|
|
260
|
+
const qs = params.toString();
|
|
261
|
+
const res = await this.http.getJson(`/api/test-cases${qs ? `?${qs}` : ''}`);
|
|
262
|
+
return (res.testCases ?? []).map((row) => fromWalnutTcSummary(row));
|
|
263
|
+
}
|
|
264
|
+
/** Neutral, steps included. `id` accepts either a Mongo ObjectId or a `TC_xxx` business key. */
|
|
265
|
+
// ponytail: Walnut has no version model — versionId is accepted for interface parity and ignored.
|
|
266
|
+
async getTestCase(id, _versionId) {
|
|
267
|
+
// Passed through untouched -- never validated/transformed. The backend itself
|
|
268
|
+
// resolves by shape (walnut-backend test-cases.ts:5156: 24-hex-char -> _id,
|
|
269
|
+
// else -> test_case_id).
|
|
270
|
+
const doc = await this.http.getJson(`/api/test-cases/${id}`);
|
|
271
|
+
return fromWalnutTc(doc);
|
|
272
|
+
}
|
|
273
|
+
/** List the project's test suites (`GET /api/test-suites`). `skip_filters=true` skips the faceted
|
|
274
|
+
* filter-metadata queries we don't need. Excludes ad-hoc suites server-side. `id` is the Mongo
|
|
275
|
+
* `_id` (what getSuite resolves by). */
|
|
276
|
+
async listSuites() {
|
|
277
|
+
const res = await this.http.getJson('/api/test-suites?skip_filters=true');
|
|
278
|
+
return (res.suites ?? []).map((row) => fromWalnutSuiteSummary(row));
|
|
279
|
+
}
|
|
280
|
+
/** Read one suite with its ordered members (`GET /api/test-suites/:id`). `id` is the Mongo `_id`
|
|
281
|
+
* (the route does NOT accept the `SU_` business key). */
|
|
282
|
+
async getSuite(id) {
|
|
283
|
+
const doc = await this.http.getJson(`/api/test-suites/${id}`);
|
|
284
|
+
return fromWalnutSuite(doc);
|
|
285
|
+
}
|
|
286
|
+
/** List the project's USER STORIES (`GET /api/stories?type=story`), for the story-driven authoring
|
|
287
|
+
* flow. `search` is a case-insensitive regex over key/title/description/AC (backend SEC-07 escaped).
|
|
288
|
+
* `id` on each row is the Mongo `_id` (what a test case links to via story_id). */
|
|
289
|
+
async listStories(q) {
|
|
290
|
+
const params = new URLSearchParams({ type: 'story' });
|
|
291
|
+
if (q.search)
|
|
292
|
+
params.set('search', q.search);
|
|
293
|
+
if (q.status)
|
|
294
|
+
params.set('status', q.status);
|
|
295
|
+
if (q.limit != null)
|
|
296
|
+
params.set('limit', String(q.limit));
|
|
297
|
+
if (q.offset != null)
|
|
298
|
+
params.set('offset', String(q.offset));
|
|
299
|
+
const res = await this.http.getJson(`/api/stories?${params.toString()}`);
|
|
300
|
+
return (res.stories ?? []).map((row) => fromWalnutStorySummary(row));
|
|
301
|
+
}
|
|
302
|
+
/** Read one user story with its acceptance criteria. `GET /api/stories/:id` resolves by Mongo `_id`
|
|
303
|
+
* ONLY (no business-key route), so a `US_123`/Jira key is resolved first via the list `search`
|
|
304
|
+
* endpoint, matching key/walnut_key. */
|
|
305
|
+
async getStory(id) {
|
|
306
|
+
const mongoId = await this.resolveStoryMongoId(id);
|
|
307
|
+
const doc = await this.http.getJson(`/api/stories/${mongoId}`);
|
|
308
|
+
return fromWalnutStory((doc.story ?? doc));
|
|
309
|
+
}
|
|
310
|
+
/** Resolve a story reference to its Mongo `_id`. A 24-hex id passes straight through; a business key
|
|
311
|
+
* (US_123 / an external Jira key) is looked up via `GET /api/stories?search=` and matched on
|
|
312
|
+
* key/walnut_key (there is no GET-by-key route). Throws a clear error when nothing matches. */
|
|
313
|
+
async resolveStoryMongoId(ref) {
|
|
314
|
+
if (/^[0-9a-f]{24}$/i.test(ref))
|
|
315
|
+
return ref;
|
|
316
|
+
// No GET-by-key route; and the list `search` regex covers key/title/description/AC but NOT the minted
|
|
317
|
+
// walnut_key (US_001). Native stories have an EMPTY `key` (only walnut_key), so a keyword search on
|
|
318
|
+
// "US_002" returns nothing. Scan the story list and match the business key (key / walnut_key /
|
|
319
|
+
// raw_key) client-side instead.
|
|
320
|
+
const params = new URLSearchParams({ type: 'story', limit: '200' });
|
|
321
|
+
const res = await this.http.getJson(`/api/stories?${params.toString()}`);
|
|
322
|
+
const rows = res.stories ?? [];
|
|
323
|
+
const norm = (v) => String(v ?? '').trim().toLowerCase();
|
|
324
|
+
const target = norm(ref);
|
|
325
|
+
const hit = rows.find((r) => norm(r['key']) === target || norm(r['walnut_key']) === target || norm(r['raw_key']) === target);
|
|
326
|
+
const id = hit?.['_id'] ?? hit?.['id'];
|
|
327
|
+
if (!id)
|
|
328
|
+
throw new Error(`No user story found for "${ref}" — check the story key (e.g. US_001) or pass its id.`);
|
|
329
|
+
return String(id);
|
|
330
|
+
}
|
|
331
|
+
async searchObjects(q) {
|
|
332
|
+
const params = new URLSearchParams();
|
|
333
|
+
if (q.query)
|
|
334
|
+
params.set('search', q.query);
|
|
335
|
+
if (q.pageName)
|
|
336
|
+
params.set('pageName', q.pageName);
|
|
337
|
+
if (q.limit != null)
|
|
338
|
+
params.set('limit', String(q.limit));
|
|
339
|
+
if (q.offset != null)
|
|
340
|
+
params.set('offset', String(q.offset));
|
|
341
|
+
const qs = params.toString();
|
|
342
|
+
const res = await this.http.getJson(`/api/objects${qs ? `?${qs}` : ''}`);
|
|
343
|
+
return (res.objects ?? []).map((row) => fromWalnutObject(row));
|
|
344
|
+
}
|
|
345
|
+
/** Read one object with its full locator bag (`GET /api/objects/:id`). */
|
|
346
|
+
async getObject(id) {
|
|
347
|
+
const doc = await this.http.getJson(`/api/objects/${id}`);
|
|
348
|
+
return fromWalnutObject(doc);
|
|
349
|
+
}
|
|
350
|
+
async deleteObject(id) {
|
|
351
|
+
await this.http.del(`/api/objects/${id}`);
|
|
352
|
+
}
|
|
353
|
+
/** Delete a test case (`DELETE /api/test-cases/:id`; recoverable via the product recycle bin). Like
|
|
354
|
+
* the update PUT, the route resolves ONLY by Mongo _id, so a business key is resolved first. */
|
|
355
|
+
async deleteTestCase(id) {
|
|
356
|
+
const mongoId = await this.resolveTestcaseMongoId(id);
|
|
357
|
+
try {
|
|
358
|
+
await this.http.del(`/api/test-cases/${mongoId}`);
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
throw unwrapApiError(e);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Add / remove suite members (`PATCH /api/test-suites/:id/test-cases`, one action per call). The
|
|
366
|
+
* neutral op names a `tcId`, but the two backend actions speak different keys:
|
|
367
|
+
* • add → `testCaseIds[]` of MONGO _ids (the route casts them to ObjectId), so each tcId is
|
|
368
|
+
* resolved from a business key when needed. The backend appends with fresh instanceIds
|
|
369
|
+
* and rejects mixing test-case categories (manual vs automated) in one suite (409).
|
|
370
|
+
* • remove → a single `instanceId`. The suite stores instanceIds, not tcIds, so we map each
|
|
371
|
+
* requested tcId (business key OR Mongo _id) to the instanceId(s) it occupies in the
|
|
372
|
+
* CURRENT membership and remove each. Removing a tcId drops every instance of it.
|
|
373
|
+
* Returns the resulting neutral Suite. (reorder ops aren't surfaced as a tool; ignored here.)
|
|
374
|
+
*/
|
|
375
|
+
async updateSuiteMembers(id, ops) {
|
|
376
|
+
const removes = ops.filter((o) => o.op === 'remove');
|
|
377
|
+
const adds = ops.filter((o) => o.op === 'add');
|
|
378
|
+
if (removes.length) {
|
|
379
|
+
const doc = await this.http.getJson(`/api/test-suites/${id}`);
|
|
380
|
+
const wanted = new Set(removes.map((o) => String(o.tcId)));
|
|
381
|
+
const instanceIds = [];
|
|
382
|
+
for (const m of doc.testCases ?? []) {
|
|
383
|
+
const tc = m.testCaseId;
|
|
384
|
+
const businessKey = typeof tc === 'object' && tc ? String(tc.test_case_id ?? '') : '';
|
|
385
|
+
const mongoId = typeof tc === 'object' && tc ? String(tc._id ?? '') : String(tc ?? '');
|
|
386
|
+
const inst = String(m.instanceId ?? '');
|
|
387
|
+
if (inst && (wanted.has(businessKey) || wanted.has(mongoId)))
|
|
388
|
+
instanceIds.push(inst);
|
|
389
|
+
}
|
|
390
|
+
if (!instanceIds.length) {
|
|
391
|
+
throw new Error(`None of the requested test cases are members of suite ${id}.`);
|
|
392
|
+
}
|
|
393
|
+
for (const instanceId of instanceIds) {
|
|
394
|
+
try {
|
|
395
|
+
await this.http.patchJson(`/api/test-suites/${id}/test-cases`, { action: 'remove', instanceId });
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
throw unwrapApiError(e);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (adds.length) {
|
|
403
|
+
const testCaseIds = await Promise.all(adds.map((o) => this.resolveTestcaseMongoId(String(o.tcId))));
|
|
404
|
+
try {
|
|
405
|
+
await this.http.patchJson(`/api/test-suites/${id}/test-cases`, { action: 'add', testCaseIds });
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
throw unwrapApiError(e);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return this.getSuite(id);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Persist a NEW test case (`POST /api/test-cases`). The body mirrors the product's create schema:
|
|
415
|
+
* the category field is `test_category` (NOT `category`); `status` defaults to 'draft' server-side
|
|
416
|
+
* so we omit it; steps go through `toWalnutStep`. project comes from the x-project-id header and
|
|
417
|
+
* the creator from the JWT, so neither is in the body.
|
|
418
|
+
*
|
|
419
|
+
* Two-step, exactly like the UI: create the test case (the backend auto-creates a dataset with a
|
|
420
|
+
* column per `${param}` token and an EMPTY datarow), then fill that datarow via the SAME
|
|
421
|
+
* /api/test-data endpoint the Test Data tab uses. This keeps web value-handling in the MCP (no
|
|
422
|
+
* backend change) — walnut stores web values as `${param}` + datarow, never inline on the step.
|
|
423
|
+
*/
|
|
424
|
+
async createTestCase(tc) {
|
|
425
|
+
// Prefer the agent hop when a QA_MCP_URL is configured (priority 1). The agent's save is the FULL
|
|
426
|
+
// flow — it opens the live editor on the saved TC and (with the journal work) re-keys the recording
|
|
427
|
+
// seed onto the new TC — neither of which the direct-REST path below does. Recording is always
|
|
428
|
+
// agent-hosted, so a recorded draft's seed lives on the agent; saving through the SAME agent keeps
|
|
429
|
+
// it journaled and shows the user the saved TC in the editor. Fall back to direct REST only when
|
|
430
|
+
// there is NO agent hop (a cloud editor with QA_BASE_URL only).
|
|
431
|
+
const saveHop = this.hop();
|
|
432
|
+
if (saveHop)
|
|
433
|
+
return saveHop.createTestCase(tc);
|
|
434
|
+
await this.ensureRecordedObjects(tc.steps); // mint + link objects so recorded steps are executable
|
|
435
|
+
// Link to a user story when supplied (manual-for-story authoring). The create route auto-derives
|
|
436
|
+
// feature_id/epic_id from the story; story_id must be the Mongo _id, so resolve a US_/Jira key first.
|
|
437
|
+
const storyId = tc.storyRef ? await this.resolveStoryMongoId(tc.storyRef) : undefined;
|
|
438
|
+
const body = {
|
|
439
|
+
title: tc.title,
|
|
440
|
+
test_category: tc.category,
|
|
441
|
+
priority: tc.priority,
|
|
442
|
+
test_steps: tc.steps.map(toWalnutStep),
|
|
443
|
+
...(storyId ? { story_id: storyId } : {}),
|
|
444
|
+
...(tc.description ? { description: tc.description } : {}),
|
|
445
|
+
...(tc.objective ? { objective: tc.objective } : {}),
|
|
446
|
+
...(tc.preconditions ? { preconditions: tc.preconditions } : {}),
|
|
447
|
+
...(tc.expectedOutcome ? { expected_outcome: tc.expectedOutcome } : {}),
|
|
448
|
+
...(tc.tags && tc.tags.length ? { tags: tc.tags } : {}),
|
|
449
|
+
};
|
|
450
|
+
const res = await this.http.postJson('/api/test-cases', body);
|
|
451
|
+
const saved = this.savedRefFrom(res, true);
|
|
452
|
+
// Fill the auto-created datarow with the ${param} values (derived per step, explicit tc.data
|
|
453
|
+
// winning). A failure here is surfaced, not swallowed — the whole point is to stop values from
|
|
454
|
+
// silently vanishing; the test case itself already exists, so the message says so.
|
|
455
|
+
const testData = { ...deriveTestData(tc.steps), ...(tc.data ?? {}) };
|
|
456
|
+
if (saved.id && Object.keys(testData).length) {
|
|
457
|
+
try {
|
|
458
|
+
await this.writeDefaultTestData(saved.id, testData);
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
462
|
+
throw new Error(`Test case ${saved.id} was created, but writing its default test data failed: ${msg}. ` +
|
|
463
|
+
'Open it and fill the Test Data tab manually.');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return saved;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Populate the datarow(s) of the dataset Walnut auto-creates from a test case's `${param}` tokens
|
|
470
|
+
* — the same second step the UI's Test Data tab performs (create leaves the columns empty). Values
|
|
471
|
+
* are keyed by PARAM NAME (the `${...}` token), which is how walnut stores them
|
|
472
|
+
* (prepareRowValuesForDb). No dataset linked (the test case had no `${param}` tokens) → no-op.
|
|
473
|
+
* Writes every seeded row (the create route makes one per project environment), merging over
|
|
474
|
+
* whatever the row already holds so nothing existing is clobbered.
|
|
475
|
+
*/
|
|
476
|
+
async writeDefaultTestData(tcId, values) {
|
|
477
|
+
const doc = await this.http.getJson(`/api/test-cases/${tcId}`);
|
|
478
|
+
const datasetId = doc.linked_datasets?.[0]?.dataset_id;
|
|
479
|
+
if (!datasetId)
|
|
480
|
+
return; // no ${param} tokens → nothing was auto-created to fill
|
|
481
|
+
const rowsRes = await this.http.getJson(`/api/test-data/datasets/${datasetId}/rows`);
|
|
482
|
+
for (const row of rowsRes.rows ?? []) {
|
|
483
|
+
if (!row._id)
|
|
484
|
+
continue;
|
|
485
|
+
const merged = { ...flattenRowValues(row.values), ...values };
|
|
486
|
+
await this.http.putJson(`/api/test-data/rows/${row._id}`, { values: merged });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Update an EXISTING test case, full-document (`PUT /api/test-cases/:id`). Sends the neutral
|
|
490
|
+
* `version` for optimistic concurrency when present. */
|
|
491
|
+
async updateTestCase(id, tc) {
|
|
492
|
+
// Prefer the agent hop when configured (priority 1) — same rationale as createTestCase: the agent
|
|
493
|
+
// save opens the editor and (with the journal work) keeps the seed in sync. Direct REST only when
|
|
494
|
+
// there is no agent hop.
|
|
495
|
+
const saveHop = this.hop();
|
|
496
|
+
if (saveHop)
|
|
497
|
+
return saveHop.updateTestCase(id, tc);
|
|
498
|
+
await this.ensureRecordedObjects(tc.steps); // same object-minting as create, for edited/recorded steps
|
|
499
|
+
// Preserve/attach the story link on update too (the manual→automated promote keeps it).
|
|
500
|
+
const storyId = tc.storyRef ? await this.resolveStoryMongoId(tc.storyRef) : undefined;
|
|
501
|
+
const body = {
|
|
502
|
+
title: tc.title,
|
|
503
|
+
test_category: tc.category,
|
|
504
|
+
priority: tc.priority,
|
|
505
|
+
test_steps: tc.steps.map(toWalnutStep),
|
|
506
|
+
...(storyId ? { story_id: storyId } : {}),
|
|
507
|
+
// Full-document semantics: this PUT carries the COMPLETE step list, so removed steps must be
|
|
508
|
+
// deleted, not left lingering. The backend only reconciles deletions behind this opt-in flag
|
|
509
|
+
// (test-cases.ts PUT :6132) — without it, a step dropped from the payload survives (each kept
|
|
510
|
+
// step is matched by its _id, emitted below via toWalnutStep). Together = a clean replace.
|
|
511
|
+
reconcile_step_deletions: true,
|
|
512
|
+
...(tc.status ? { status: tc.status } : {}),
|
|
513
|
+
...(tc.description ? { description: tc.description } : {}),
|
|
514
|
+
...(tc.version != null ? { version: tc.version } : {}),
|
|
515
|
+
};
|
|
516
|
+
// PUT /api/test-cases/:id resolves ONLY by Mongo _id (findOne({_id:id})) — a business key like
|
|
517
|
+
// TC_123 makes Mongo throw a CastError → generic 500. GET tolerates both; PUT does not. Resolve
|
|
518
|
+
// to the _id first (24-hex passes straight through).
|
|
519
|
+
const mongoId = await this.resolveTestcaseMongoId(id);
|
|
520
|
+
const res = await this.http.putJson(`/api/test-cases/${mongoId}`, body);
|
|
521
|
+
return this.savedRefFrom(res, false);
|
|
522
|
+
}
|
|
523
|
+
/** Neutral SavedRef from a create/update response, tolerant of the doc being bare or wrapped
|
|
524
|
+
* (`{ testCase }` / `{ data }`). Prefers the business id (`test_case_id`) for the deep link. */
|
|
525
|
+
savedRefFrom(res, created) {
|
|
526
|
+
const r = (res ?? {});
|
|
527
|
+
const doc = ((r['testCase'] ?? r['data'] ?? r) ?? {});
|
|
528
|
+
const businessId = doc['test_case_id'] != null ? String(doc['test_case_id']) : '';
|
|
529
|
+
const mongoId = doc['_id'] != null ? String(doc['_id']) : '';
|
|
530
|
+
const id = businessId || mongoId;
|
|
531
|
+
return {
|
|
532
|
+
id,
|
|
533
|
+
created,
|
|
534
|
+
...(id ? { productUrl: this.productUrlFor({ kind: 'testcase', id }) } : {}),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* The credential of the ONLINE local agent for this project — the SAME one the UI's Run modal
|
|
539
|
+
* dispatches to. Sourced from the live roster (`GET /api/agent-credentials/project-agents`). The
|
|
540
|
+
* roster reflects real Redis/heartbeat presence, so `is_online` always targets a live poller.
|
|
541
|
+
* Returns null when the acting user has no online agent, OR when the roster endpoint is unreachable
|
|
542
|
+
* (e.g. the `ai-agents` feature flag is off) — the caller then surfaces a clear "no local agent"
|
|
543
|
+
* error. (The agent's cached-credential fallback is agent-only and intentionally not ported here.)
|
|
544
|
+
*/
|
|
545
|
+
async resolveLocalAgentCredential() {
|
|
546
|
+
try {
|
|
547
|
+
const res = await this.http.getJson('/api/agent-credentials/project-agents');
|
|
548
|
+
const agents = res.agents ?? [];
|
|
549
|
+
// A SHARED project lists EVERY member's agent. A local run must go to THE ACTING USER'S OWN
|
|
550
|
+
// agent — NOT the first online one in the roster, which is very likely a teammate's machine.
|
|
551
|
+
// Dispatching there sends the run to someone else's box (where it "passes in the UI but crashes
|
|
552
|
+
// via MCP", because the UI runs on the user's own agent) and is an isolation problem besides.
|
|
553
|
+
// Match the user from the JWT `id` claim, preferring their most-recently-seen online agent.
|
|
554
|
+
const uid = userIdFromJwt(this.cfg.token);
|
|
555
|
+
if (uid) {
|
|
556
|
+
const mine = agents
|
|
557
|
+
.filter((a) => a.is_online && a.credential_id
|
|
558
|
+
&& (a.owner_user_id === uid || a.agent_user_id === uid))
|
|
559
|
+
.sort((a, b) => (b.last_seen_at ?? '').localeCompare(a.last_seen_at ?? ''));
|
|
560
|
+
return mine[0]?.credential_id ?? null; // null = the acting user has no online agent
|
|
561
|
+
}
|
|
562
|
+
// Raw API key (no user identity in the token): can't match an owner — fall back to first online.
|
|
563
|
+
return agents.find((a) => a.is_online && a.credential_id)?.credential_id ?? null;
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return null; // roster unreachable → caller surfaces a clear "no local agent" error
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Persist recorded elements into the Object Repository so a saved test case is EXECUTABLE.
|
|
571
|
+
*
|
|
572
|
+
* A recorded step carries `objectRef.locators` (real captured selectors) but no `objectRef.id` —
|
|
573
|
+
* it's a NEW object not yet in the repository. Walnut binds a step to its element via `object_id`
|
|
574
|
+
* (a repository row); it does NOT store locators inline on the step. So without this, the save
|
|
575
|
+
* drops every locator (object_id stays null) and the test case can't find its elements at run time.
|
|
576
|
+
*
|
|
577
|
+
* For each such step we POST /api/objects (description = the element name, attributes = the recorded
|
|
578
|
+
* locators verbatim — the executor reads them by name: `playwright` primary, `xpath_*` fallbacks)
|
|
579
|
+
* and stamp the returned `_id` onto `objectRef.id` so `toWalnutStep` then emits `object_id`. Runs
|
|
580
|
+
* BEFORE the test-case create, so a failure aborts cleanly with nothing half-saved (any objects
|
|
581
|
+
* already minted are just unlinked repository rows).
|
|
582
|
+
*/
|
|
583
|
+
async ensureRecordedObjects(steps) {
|
|
584
|
+
for (const s of steps) {
|
|
585
|
+
const ref = s.objectRef;
|
|
586
|
+
if (!ref || ref.id || !ref.locators?.length)
|
|
587
|
+
continue; // already linked, or nothing captured
|
|
588
|
+
const objectType = OBJECT_TYPE_FOR_STEP[s.stepType];
|
|
589
|
+
if (!objectType)
|
|
590
|
+
continue; // api/db/control-flow/function steps have no UI object
|
|
591
|
+
const body = {
|
|
592
|
+
description: ref.name ?? s.description ?? 'Recorded element',
|
|
593
|
+
object_type: objectType,
|
|
594
|
+
attributes: ref.locators.map((l) => ({ name: l.name, value: l.value })),
|
|
595
|
+
};
|
|
596
|
+
let res;
|
|
597
|
+
try {
|
|
598
|
+
res = await this.http.postJson('/api/objects', body);
|
|
599
|
+
}
|
|
600
|
+
catch (e) {
|
|
601
|
+
throw new Error(`Failed to create the Object Repository entry for step ${s.n} ("${body.description}"): ` +
|
|
602
|
+
`${e instanceof Error ? e.message : String(e)}. The test case was not saved.`);
|
|
603
|
+
}
|
|
604
|
+
if (!res?._id)
|
|
605
|
+
throw new Error(`Object create for step ${s.n} returned no id; the test case was not saved.`);
|
|
606
|
+
ref.id = String(res._id); // link → toWalnutStep now emits object_id → executable
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** A neutral tcId may be a business key (`TC_123`) or a Mongo `_id`; the executions API wants the
|
|
610
|
+
* `_id`. 24-hex → pass through; otherwise resolve via the test-case read. */
|
|
611
|
+
async resolveTestcaseMongoId(tcId) {
|
|
612
|
+
if (/^[0-9a-f]{24}$/i.test(tcId))
|
|
613
|
+
return tcId;
|
|
614
|
+
const doc = await this.http.getJson(`/api/test-cases/${tcId}`);
|
|
615
|
+
if (!doc?._id)
|
|
616
|
+
throw new Error(`Could not resolve test case "${tcId}" to an id.`);
|
|
617
|
+
return String(doc._id);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Start a run of ONE test case (`POST /api/executions/reserve`; reserve-first is the only path for
|
|
621
|
+
* cloud, and the modern path for local). mode:'local' dispatches to THIS agent's own queue (its
|
|
622
|
+
* cached agent credential); mode:'cloud' dispatches to the chosen online cloud runner. The backend
|
|
623
|
+
* mints the execution + enqueues; per-step driving happens on the agent/runner. `environment` is
|
|
624
|
+
* used only when it is already a Mongo id — otherwise the backend applies the project default env.
|
|
625
|
+
*/
|
|
626
|
+
async startRun(target, opts) {
|
|
627
|
+
if (target.suiteId)
|
|
628
|
+
return this.startSuiteRun(target.suiteId, opts);
|
|
629
|
+
if (!target.tcId)
|
|
630
|
+
throw new Error('startRun: a test case id (tcId) or a suite id (suiteId) is required.');
|
|
631
|
+
const isCloud = opts.mode === 'cloud';
|
|
632
|
+
const body = {
|
|
633
|
+
testcase_id: await this.resolveTestcaseMongoId(target.tcId),
|
|
634
|
+
execution_type: 'automated',
|
|
635
|
+
execution_target: isCloud ? 'cloud_runner' : 'local',
|
|
636
|
+
// Default to bundled 'chromium', NOT 'chrome': the 'chrome' channel launches the user's real
|
|
637
|
+
// installed Google Chrome, which exits within ~2s ("Browser disconnected UNEXPECTEDLY / external
|
|
638
|
+
// close") when Chrome is already open (profile/singleton conflict) or version-mismatched with the
|
|
639
|
+
// agent's Playwright. 'chromium' ships with the agent and runs an isolated profile — the same
|
|
640
|
+
// stable browser a UI run uses. Callers can still pass browser:'chrome'/'msedge'/'firefox'/'webkit'.
|
|
641
|
+
browser: opts.browser ?? 'chromium',
|
|
642
|
+
headless: opts.headless ?? isCloud, // cloud VMs run headless; local shows the browser by default
|
|
643
|
+
...(opts.environment && /^[0-9a-f]{24}$/i.test(opts.environment) ? { environment_id: opts.environment } : {}),
|
|
644
|
+
};
|
|
645
|
+
if (isCloud) {
|
|
646
|
+
if (opts.cloudRunnerId)
|
|
647
|
+
body['cloud_runner_id'] = opts.cloudRunnerId; // else backend auto-picks
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
// A caller-picked agent (opts.agentId, from listLocalAgents) dispatches to THAT agent; otherwise
|
|
651
|
+
// auto-pick the acting user's own online agent (the roster-backed resolveLocalAgentCredential).
|
|
652
|
+
const cred = opts.agentId || (await this.resolveLocalAgentCredential());
|
|
653
|
+
if (!cred) {
|
|
654
|
+
throw new Error('No local agent registered to YOUR account is online for this project. Open YOUR Walnut ' +
|
|
655
|
+
'agent and make sure it shows connected/verified (its task poller must be running), or run ' +
|
|
656
|
+
'on cloud. (A local run only ever dispatches to your own agent, never a teammate\'s.)');
|
|
657
|
+
}
|
|
658
|
+
body['agent_credential_id'] = cred;
|
|
659
|
+
}
|
|
660
|
+
let res;
|
|
661
|
+
try {
|
|
662
|
+
res = await this.http.postJson('/api/executions/reserve', body);
|
|
663
|
+
}
|
|
664
|
+
catch (e) {
|
|
665
|
+
throw unwrapApiError(e);
|
|
666
|
+
}
|
|
667
|
+
const runId = res.execution_mongo_id ?? res._id ?? res.execution_id;
|
|
668
|
+
if (!runId)
|
|
669
|
+
throw new Error('Run was triggered but the backend returned no execution id.');
|
|
670
|
+
return { runId: String(runId) };
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Start a SUITE run (`POST /api/executions/reserve` in suite mode — `testcase_ids[]` present).
|
|
674
|
+
* Fetches the suite's ordered members to build the parallel `testcase_ids`/`instance_ids` the
|
|
675
|
+
* reserve expects, then dispatches: mode:'local' → THIS agent's own queue (its online credential,
|
|
676
|
+
* which the backend requires for a local suite); mode:'cloud' → the chosen/auto-picked online
|
|
677
|
+
* runner. Returns the suite-run id (`SR_xxx`) as runId — getRun/stopRun route it back to the suite
|
|
678
|
+
* endpoints by that prefix.
|
|
679
|
+
*/
|
|
680
|
+
async startSuiteRun(suiteId, opts) {
|
|
681
|
+
const doc = await this.http.getJson(`/api/test-suites/${suiteId}`);
|
|
682
|
+
const members = (doc.testCases ?? [])
|
|
683
|
+
.filter((m) => m && m.testCaseId)
|
|
684
|
+
.slice()
|
|
685
|
+
.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0));
|
|
686
|
+
if (!members.length)
|
|
687
|
+
throw new Error(`Suite ${suiteId} has no runnable test cases.`);
|
|
688
|
+
const testcaseIds = members.map((m) => {
|
|
689
|
+
const tc = m.testCaseId;
|
|
690
|
+
return String(typeof tc === 'object' && tc ? (tc._id ?? '') : (tc ?? ''));
|
|
691
|
+
});
|
|
692
|
+
const instanceIds = members.map((m) => String(m.instanceId ?? ''));
|
|
693
|
+
const isCloud = opts.mode === 'cloud';
|
|
694
|
+
const body = {
|
|
695
|
+
testcase_ids: testcaseIds,
|
|
696
|
+
instance_ids: instanceIds,
|
|
697
|
+
// Local is always serial (the backend forces it); cloud honours the requested strategy.
|
|
698
|
+
strategy: isCloud ? (opts.strategy ?? 'serial') : 'serial',
|
|
699
|
+
execution_type: 'automated',
|
|
700
|
+
execution_target: isCloud ? 'cloud_runner' : 'local',
|
|
701
|
+
suite_id: suiteId,
|
|
702
|
+
browser: opts.browser ?? 'chromium',
|
|
703
|
+
headless: opts.headless ?? isCloud,
|
|
704
|
+
...(opts.environment && /^[0-9a-f]{24}$/i.test(opts.environment) ? { environment_id: opts.environment } : {}),
|
|
705
|
+
...(opts.terminateOnFailure !== undefined ? { terminate_on_failure: opts.terminateOnFailure } : {}),
|
|
706
|
+
...(isCloud && opts.maxConcurrent ? { max_concurrent: opts.maxConcurrent } : {}),
|
|
707
|
+
};
|
|
708
|
+
if (isCloud) {
|
|
709
|
+
if (opts.cloudRunnerId)
|
|
710
|
+
body['cloud_runner_id'] = opts.cloudRunnerId; // else backend auto-picks
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
// A caller-picked agent (opts.agentId, from listLocalAgents) dispatches to THAT agent; otherwise
|
|
714
|
+
// auto-pick the acting user's own online agent.
|
|
715
|
+
const cred = opts.agentId || (await this.resolveLocalAgentCredential());
|
|
716
|
+
if (!cred) {
|
|
717
|
+
throw new Error('No local agent registered to YOUR account is online for this project. Open YOUR Walnut ' +
|
|
718
|
+
'agent and make sure it shows connected/verified, or run the suite on cloud.');
|
|
719
|
+
}
|
|
720
|
+
body['agent_credential_id'] = cred;
|
|
721
|
+
}
|
|
722
|
+
let res;
|
|
723
|
+
try {
|
|
724
|
+
res = await this.http.postJson('/api/executions/reserve', body);
|
|
725
|
+
}
|
|
726
|
+
catch (e) {
|
|
727
|
+
throw unwrapApiError(e);
|
|
728
|
+
}
|
|
729
|
+
if (!res.suite_execution_id) {
|
|
730
|
+
throw new Error('Suite run was triggered but the backend returned no suite execution id.');
|
|
731
|
+
}
|
|
732
|
+
return { runId: String(res.suite_execution_id) };
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Create a SCHEDULE — a deferred / recurring run — instead of dispatching now (`POST /api/schedules`,
|
|
736
|
+
* the same route the Scheduler UI uses). Maps the neutral target + ScheduleOptions to the backend's
|
|
737
|
+
* createScheduleSchema: schedule_target ('test_case' when a tcId is given, else 'suite'); tc_id /
|
|
738
|
+
* suite_id (the backend resolves a tc_id business key OR _id, but a suite_id must be a Mongo _id);
|
|
739
|
+
* execution_target ('local' | 'cloud_runner' from opts.mode); assigned_agent_credential_id (the
|
|
740
|
+
* picked local agent for local, or the picked cloud runner for cloud — omit to let the backend
|
|
741
|
+
* auto-pick a VM); scheduled_for (REQUIRED ISO); schedule_type (default 'once'); end_date; timezone
|
|
742
|
+
* (default 'UTC'); environment_id (only when a 24-hex id, mirroring startRun). MANUAL test cases are
|
|
743
|
+
* rejected server-side (MANUAL_NOT_SCHEDULABLE_CODE); unwrapApiError surfaces that message verbatim.
|
|
744
|
+
*/
|
|
745
|
+
async createSchedule(target, opts) {
|
|
746
|
+
if (!target.tcId && !target.suiteId) {
|
|
747
|
+
throw new Error('createSchedule: a test case id (tcId) or a suite id (suiteId) is required.');
|
|
748
|
+
}
|
|
749
|
+
if (!opts.scheduledFor) {
|
|
750
|
+
throw new Error('createSchedule: scheduledFor (an ISO datetime) is required for a schedule.');
|
|
751
|
+
}
|
|
752
|
+
const isCloud = opts.mode === 'cloud';
|
|
753
|
+
// A schedule fires detached from any live session, so a LOCAL schedule MUST target a specific
|
|
754
|
+
// agent — there is no "caller's own agent" at fire time. The backend only requires+honors the
|
|
755
|
+
// pin in execution_mode:'manual' (needsAssignedAgent), so we must send both.
|
|
756
|
+
if (!isCloud && !opts.agentId) {
|
|
757
|
+
throw new Error('createSchedule: a LOCAL schedule must target a specific agent (agentId — a credential_id ' +
|
|
758
|
+
'from listLocalAgents). It runs detached from any live session, so there is no default agent.');
|
|
759
|
+
}
|
|
760
|
+
const body = {
|
|
761
|
+
schedule_target: target.tcId ? 'test_case' : 'suite',
|
|
762
|
+
...(target.tcId ? { tc_id: target.tcId } : {}),
|
|
763
|
+
...(target.suiteId ? { suite_id: target.suiteId } : {}),
|
|
764
|
+
schedule_type: opts.scheduleType ?? 'once',
|
|
765
|
+
execution_target: isCloud ? 'cloud_runner' : 'local',
|
|
766
|
+
// LOCAL: pin the picked agent. execution_mode:'manual' is what makes the backend REQUIRE and
|
|
767
|
+
// honor assigned_agent_credential_id (needsAssignedAgent); without it the pin is ignored and a
|
|
768
|
+
// detached schedule has no agent to run on. (A local agent credential that is actually a
|
|
769
|
+
// test_runner_vm cred is auto-promoted to cloud_runner by the backend — see schedules.ts.)
|
|
770
|
+
// CLOUD: the backend forces execution_mode='manual' itself and takes assigned_agent_credential_id
|
|
771
|
+
// as an OPTIONAL pin — the runner's test_runner_vm CREDENTIAL id (CloudRunner.credentialId, a
|
|
772
|
+
// DIFFERENT id-space from cloudRunnerId/compute_configs._id). Omit it to auto-pick a live VM at
|
|
773
|
+
// fire time. A pinned VM that is offline at create time is rejected by the backend.
|
|
774
|
+
...(isCloud
|
|
775
|
+
? (opts.cloudRunnerCredentialId ? { assigned_agent_credential_id: opts.cloudRunnerCredentialId } : {})
|
|
776
|
+
: { execution_mode: 'manual', assigned_agent_credential_id: opts.agentId }),
|
|
777
|
+
// Absent browser is intentional: the backend then falls back to each test case's own saved
|
|
778
|
+
// browser instead of forcing one (createScheduleSchema.browser has no default).
|
|
779
|
+
...(opts.browser ? { browser: opts.browser } : {}),
|
|
780
|
+
scheduled_for: opts.scheduledFor,
|
|
781
|
+
...(opts.endDate ? { end_date: opts.endDate } : {}),
|
|
782
|
+
timezone: opts.timezone ?? 'UTC',
|
|
783
|
+
...(opts.environmentId && /^[0-9a-f]{24}$/i.test(opts.environmentId) ? { environment_id: opts.environmentId } : {}),
|
|
784
|
+
};
|
|
785
|
+
let res;
|
|
786
|
+
try {
|
|
787
|
+
res = await this.http.postJson('/api/schedules', body);
|
|
788
|
+
}
|
|
789
|
+
catch (e) {
|
|
790
|
+
throw unwrapApiError(e); // surfaces MANUAL_NOT_SCHEDULABLE + coded schedule errors as the human message
|
|
791
|
+
}
|
|
792
|
+
const doc = ((res?.data ?? res) ?? {});
|
|
793
|
+
const id = doc['_id'] != null ? String(doc['_id']) : '';
|
|
794
|
+
if (!id)
|
|
795
|
+
throw new Error('Schedule was created but the backend returned no schedule id.');
|
|
796
|
+
return {
|
|
797
|
+
id,
|
|
798
|
+
...(typeof doc['status'] === 'string' ? { status: doc['status'] } : {}),
|
|
799
|
+
...(doc['next_run_at'] != null ? { nextRunAt: String(doc['next_run_at']) } : {}),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/** Map one raw schedule row (`GET /api/schedules`) to a neutral ScheduleInfo — human target + cadence
|
|
803
|
+
* + status + pinned agent NAME (never the credential_id) + the update_version an edit must echo. */
|
|
804
|
+
toScheduleInfo(r) {
|
|
805
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
806
|
+
const suiteId = str(r['suite_id']);
|
|
807
|
+
const suiteName = str(r['suite_name']);
|
|
808
|
+
const tcKey = str(r['tc_identifier']) ?? str(r['tc_id']);
|
|
809
|
+
const tcName = str(r['tc_name']);
|
|
810
|
+
const target = suiteId
|
|
811
|
+
? `Suite${suiteName ? `: ${suiteName}` : ` ${suiteId}`}`
|
|
812
|
+
: `${tcKey ?? '(test case)'}${tcName ? ` — ${tcName}` : ''}`;
|
|
813
|
+
const et = str(r['execution_target']);
|
|
814
|
+
const agent = str(r['assigned_agent_owner_name']);
|
|
815
|
+
const browser = str(r['browser']);
|
|
816
|
+
const tz = str(r['timezone']);
|
|
817
|
+
return {
|
|
818
|
+
id: String(r['_id'] ?? ''),
|
|
819
|
+
target,
|
|
820
|
+
scheduleType: str(r['schedule_type']) ?? 'once',
|
|
821
|
+
status: str(r['status']) ?? 'scheduled',
|
|
822
|
+
...(typeof r['is_active'] === 'boolean' ? { active: r['is_active'] } : {}),
|
|
823
|
+
...(et === 'local' || et === 'cloud_runner' ? { executionTarget: et } : {}),
|
|
824
|
+
...(agent ? { agent } : {}),
|
|
825
|
+
...(browser ? { browser } : {}),
|
|
826
|
+
...(r['next_run_at'] != null ? { nextRunAt: String(r['next_run_at']) } : {}),
|
|
827
|
+
...(r['scheduled_for'] != null ? { scheduledFor: String(r['scheduled_for']) } : {}),
|
|
828
|
+
...(tz ? { timezone: tz } : {}),
|
|
829
|
+
...(typeof r['update_version'] === 'number' ? { updateVersion: r['update_version'] } : {}),
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
/** List the project's schedules for management (`GET /api/schedules`). */
|
|
833
|
+
async listSchedules() {
|
|
834
|
+
let res;
|
|
835
|
+
try {
|
|
836
|
+
res = await this.http.getJson('/api/schedules');
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
throw unwrapApiError(e);
|
|
840
|
+
}
|
|
841
|
+
const rows = (res.schedules ?? (Array.isArray(res.data) ? res.data : []));
|
|
842
|
+
return rows.map((r) => this.toScheduleInfo(r));
|
|
843
|
+
}
|
|
844
|
+
/** Fetch one raw schedule row (via the list) — needed for its update_version before an edit. */
|
|
845
|
+
async fetchScheduleRaw(scheduleId) {
|
|
846
|
+
let res;
|
|
847
|
+
try {
|
|
848
|
+
res = await this.http.getJson('/api/schedules');
|
|
849
|
+
}
|
|
850
|
+
catch (e) {
|
|
851
|
+
throw unwrapApiError(e);
|
|
852
|
+
}
|
|
853
|
+
const found = (res.schedules ?? []).find((r) => String(r['_id']) === scheduleId);
|
|
854
|
+
if (!found)
|
|
855
|
+
throw new Error(`Schedule ${scheduleId} was not found in this project.`);
|
|
856
|
+
return found;
|
|
857
|
+
}
|
|
858
|
+
/** Edit a schedule (`PUT /api/schedules/:id`). Reads the current update_version itself (optimistic
|
|
859
|
+
* concurrency) so the caller passes only what changes. Re-pins via mode+agentId (local → also sets
|
|
860
|
+
* execution_mode:'manual') or mode+cloudRunnerCredentialId (cloud), mirroring createSchedule. */
|
|
861
|
+
async updateSchedule(scheduleId, changes) {
|
|
862
|
+
const current = await this.fetchScheduleRaw(scheduleId);
|
|
863
|
+
const uv = current['update_version'];
|
|
864
|
+
const body = {
|
|
865
|
+
update_version: typeof uv === 'number' ? uv : 0,
|
|
866
|
+
...(changes.scheduledFor ? { scheduled_for: changes.scheduledFor } : {}),
|
|
867
|
+
...(changes.scheduleType ? { schedule_type: changes.scheduleType } : {}),
|
|
868
|
+
...(changes.endDate ? { end_date: changes.endDate } : {}),
|
|
869
|
+
...(changes.timezone ? { timezone: changes.timezone } : {}),
|
|
870
|
+
...(changes.browser ? { browser: changes.browser } : {}),
|
|
871
|
+
...(changes.environmentId && /^[0-9a-f]{24}$/i.test(changes.environmentId) ? { environment_id: changes.environmentId } : {}),
|
|
872
|
+
};
|
|
873
|
+
if (changes.mode === 'local' && changes.agentId) {
|
|
874
|
+
body['execution_target'] = 'local';
|
|
875
|
+
body['execution_mode'] = 'manual';
|
|
876
|
+
body['assigned_agent_credential_id'] = changes.agentId;
|
|
877
|
+
}
|
|
878
|
+
else if (changes.mode === 'cloud' && changes.cloudRunnerCredentialId) {
|
|
879
|
+
body['execution_target'] = 'cloud_runner';
|
|
880
|
+
body['assigned_agent_credential_id'] = changes.cloudRunnerCredentialId;
|
|
881
|
+
}
|
|
882
|
+
let res;
|
|
883
|
+
try {
|
|
884
|
+
res = await this.http.putJson(`/api/schedules/${scheduleId}`, body);
|
|
885
|
+
}
|
|
886
|
+
catch (e) {
|
|
887
|
+
throw unwrapApiError(e);
|
|
888
|
+
}
|
|
889
|
+
const doc = ((res?.data ?? res) ?? {});
|
|
890
|
+
return {
|
|
891
|
+
id: doc['_id'] != null ? String(doc['_id']) : scheduleId,
|
|
892
|
+
...(typeof doc['status'] === 'string' ? { status: doc['status'] } : {}),
|
|
893
|
+
...(doc['next_run_at'] != null ? { nextRunAt: String(doc['next_run_at']) } : {}),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
/** Pause / resume / cancel a schedule. pause→`POST /:id/pause`, resume→`POST /:id/resume`,
|
|
897
|
+
* cancel→`PATCH /:id/cancel` (all bodyless). */
|
|
898
|
+
async controlSchedule(scheduleId, action) {
|
|
899
|
+
try {
|
|
900
|
+
const res = action === 'cancel'
|
|
901
|
+
? await this.http.patchJson(`/api/schedules/${scheduleId}/cancel`, {})
|
|
902
|
+
: await this.http.postJson(`/api/schedules/${scheduleId}/${action}`, {});
|
|
903
|
+
const doc = ((res?.data ?? res) ?? {});
|
|
904
|
+
return {
|
|
905
|
+
id: doc['_id'] != null ? String(doc['_id']) : scheduleId,
|
|
906
|
+
...(typeof doc['status'] === 'string' ? { status: doc['status'] } : {}),
|
|
907
|
+
...(doc['next_run_at'] != null ? { nextRunAt: String(doc['next_run_at']) } : {}),
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
catch (e) {
|
|
911
|
+
throw unwrapApiError(e);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/** Delete a schedule permanently (`DELETE /api/schedules/:id`; the backend also tears down any live run). */
|
|
915
|
+
async deleteSchedule(scheduleId) {
|
|
916
|
+
try {
|
|
917
|
+
await this.http.del(`/api/schedules/${scheduleId}`);
|
|
918
|
+
}
|
|
919
|
+
catch (e) {
|
|
920
|
+
throw unwrapApiError(e);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/** Neutral status of a run (`GET /api/executions/:id`, id = Mongo `_id`). Progress is derived from
|
|
924
|
+
* the per-status step counters; a UI deep link stands in for a report URL. */
|
|
925
|
+
async getRun(runId) {
|
|
926
|
+
if (/^SR_/i.test(runId))
|
|
927
|
+
return this.getSuiteRun(runId);
|
|
928
|
+
const doc = await this.http.getJson(`/api/executions/${runId}`);
|
|
929
|
+
const num = (v) => (typeof v === 'number' ? v : 0);
|
|
930
|
+
const done = num(doc['passed_steps']) + num(doc['failed_steps']) + num(doc['skipped_steps']) + num(doc['blocked_steps']);
|
|
931
|
+
const total = num(doc['total_steps']);
|
|
932
|
+
return {
|
|
933
|
+
runId,
|
|
934
|
+
state: mapRunState(doc['status']),
|
|
935
|
+
...(total > 0 ? { progress: { done, total } } : {}),
|
|
936
|
+
reportUrl: this.productUrlFor({ kind: 'run', id: runId }),
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
/** Neutral status of a SUITE run (`GET /api/executions/suite/:suiteRunId/summary`). `progress`
|
|
940
|
+
* counts TEST CASES (not steps); `children` is the per-testcase rollup. A 404 right after reserve
|
|
941
|
+
* (child execution docs not materialized yet) reads as 'queued', not an error. No reportUrl: the
|
|
942
|
+
* `/reports/:id` deep link is keyed by a single execution's Mongo id, not a suite-run `SR_` id. */
|
|
943
|
+
async getSuiteRun(suiteRunId) {
|
|
944
|
+
let doc;
|
|
945
|
+
try {
|
|
946
|
+
doc = await this.http.getJson(`/api/executions/suite/${suiteRunId}/summary`);
|
|
947
|
+
}
|
|
948
|
+
catch (e) {
|
|
949
|
+
if (e instanceof ApiError && e.status === 404)
|
|
950
|
+
return { runId: suiteRunId, state: 'queued' };
|
|
951
|
+
throw unwrapApiError(e);
|
|
952
|
+
}
|
|
953
|
+
const num = (v) => (typeof v === 'number' ? v : 0);
|
|
954
|
+
const t = doc.totals ?? {};
|
|
955
|
+
const total = num(t['total']);
|
|
956
|
+
const done = num(t['passed']) + num(t['failed']) + num(t['skipped']) + num(t['blocked']) + num(t['terminated']);
|
|
957
|
+
const children = (doc.testcases ?? []).map((row) => ({
|
|
958
|
+
id: String(row['testcase_identifier'] ?? row['testcase_id'] ?? ''),
|
|
959
|
+
...(typeof row['testcase_title'] === 'string' ? { title: row['testcase_title'] } : {}),
|
|
960
|
+
state: mapRunState(row['status']),
|
|
961
|
+
}));
|
|
962
|
+
return {
|
|
963
|
+
runId: suiteRunId,
|
|
964
|
+
state: mapSuiteRunState(doc.suite?.status),
|
|
965
|
+
...(total > 0 ? { progress: { done, total } } : {}),
|
|
966
|
+
...(children.length ? { children } : {}),
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
/** Signal a running execution to stop. Suite runs (`SR_...`) stop via the suite endpoint
|
|
970
|
+
* (`POST /api/executions/suite/:id/stop`); single executions via `POST /api/executions/:id/stop`.
|
|
971
|
+
* No body either way. */
|
|
972
|
+
async stopRun(runId) {
|
|
973
|
+
const path = /^SR_/i.test(runId)
|
|
974
|
+
? `/api/executions/suite/${runId}/stop`
|
|
975
|
+
: `/api/executions/${runId}/stop`;
|
|
976
|
+
try {
|
|
977
|
+
await this.http.postJson(path, {});
|
|
978
|
+
}
|
|
979
|
+
catch (e) {
|
|
980
|
+
throw unwrapApiError(e);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/** Cloud runner VMs registered to the project + live online status
|
|
984
|
+
* (`GET /api/compute-configs/runners?live=1`). `online` already folds status online|busy. */
|
|
985
|
+
async listCloudRunners() {
|
|
986
|
+
const res = await this.http.getJson('/api/compute-configs/runners?live=1');
|
|
987
|
+
return (res.runners ?? []).map((r) => ({
|
|
988
|
+
id: String(r['id'] ?? ''),
|
|
989
|
+
name: String(r['name'] ?? ''),
|
|
990
|
+
online: r['online'] === true,
|
|
991
|
+
...(typeof r['status'] === 'string' ? { status: r['status'] } : {}),
|
|
992
|
+
...(typeof r['host'] === 'string' ? { os: r['host'] } : {}),
|
|
993
|
+
// The linked test_runner_vm agent credential — what a SCHEDULE pins as
|
|
994
|
+
// assigned_agent_credential_id (a different id-space from `id` = compute_configs._id).
|
|
995
|
+
...(typeof r['credential_id'] === 'string' && r['credential_id'] ? { credentialId: r['credential_id'] } : {}),
|
|
996
|
+
}));
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* LOCAL agents registered to the project + live online status
|
|
1000
|
+
* (`GET /api/agent-credentials/project-agents`) — the same live roster resolveLocalAgentCredential
|
|
1001
|
+
* reads, exposed so a client can list + pick an agent (`id` = credential_id) before a LOCAL run.
|
|
1002
|
+
* A shared project lists EVERY member's agent; `owner`/`online` let the user pick the right one.
|
|
1003
|
+
*/
|
|
1004
|
+
async listLocalAgents() {
|
|
1005
|
+
const res = await this.http.getJson('/api/agent-credentials/project-agents');
|
|
1006
|
+
return (res.agents ?? [])
|
|
1007
|
+
.filter((a) => a.credential_id)
|
|
1008
|
+
.map((a) => {
|
|
1009
|
+
const name = a.owner_name || a.host_name;
|
|
1010
|
+
return {
|
|
1011
|
+
id: String(a.credential_id),
|
|
1012
|
+
online: a.is_online === true,
|
|
1013
|
+
...(name ? { name } : {}),
|
|
1014
|
+
// Machine hostname — kept ALONGSIDE the owner so the UI/list can disambiguate several
|
|
1015
|
+
// agents owned by the same person (e.g. two "rishi" agents on different machines).
|
|
1016
|
+
...(a.host_name ? { host: a.host_name } : {}),
|
|
1017
|
+
...(typeof a.agent_status === 'string' ? { status: a.agent_status } : {}),
|
|
1018
|
+
...(a.owner_name ? { owner: a.owner_name } : {}),
|
|
1019
|
+
};
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Valid action vocabulary per technology. android/api/database/mainframe always
|
|
1024
|
+
* come from the static `WALNUT_ACTIONS` (there is no live/per-org source for
|
|
1025
|
+
* them); `web` is replaced by the live per-org `GET /api/actions` catalog when
|
|
1026
|
+
* that call succeeds and returns at least one action, else falls back to the
|
|
1027
|
+
* static list. No retry, no caching.
|
|
1028
|
+
*/
|
|
1029
|
+
async actionsCatalog(platform) {
|
|
1030
|
+
const catalog = { ...WALNUT_ACTIONS };
|
|
1031
|
+
try {
|
|
1032
|
+
const res = await this.http.getJson('/api/actions');
|
|
1033
|
+
const live = (res.actions ?? [])
|
|
1034
|
+
.map((a) => a.action_type ?? a.name)
|
|
1035
|
+
.filter((v) => typeof v === 'string' && v.length > 0);
|
|
1036
|
+
if (live.length)
|
|
1037
|
+
catalog.web = live;
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
// GET /api/actions unavailable (network error, 404, 500, ...) -- keep the
|
|
1041
|
+
// static WALNUT_ACTIONS.web fallback.
|
|
1042
|
+
}
|
|
1043
|
+
const all = actionDefsFrom(catalog);
|
|
1044
|
+
return platform ? all.filter((a) => a.platform === platform) : all;
|
|
1045
|
+
}
|
|
1046
|
+
// --- Agent-bound recording surface (composite delegation) -----------------
|
|
1047
|
+
// Recording (+ save-with-editor) is the ONLY surface that needs a local runtime, so it is NOT done
|
|
1048
|
+
// over direct REST: it hops to the LOCAL AGENT's /mcp (cfg.url) via an internal RemoteMcpAdapter.
|
|
1049
|
+
// The recording passthrough (tools/recording.tools.ts) drives it through `listAgentTools`/`callRaw`,
|
|
1050
|
+
// and tc_automate (tools/testcase.tools.ts) calls `callRaw('web'/'web_status')`. When no agent /mcp
|
|
1051
|
+
// is configured (a cloud editor), recording is gracefully unavailable.
|
|
1052
|
+
/** The internal hop to the local agent's /mcp, built lazily and ONLY when cfg.url is set. */
|
|
1053
|
+
hop() {
|
|
1054
|
+
if (!this.cfg.url)
|
|
1055
|
+
return null;
|
|
1056
|
+
if (!this.agentHop)
|
|
1057
|
+
this.agentHop = new RemoteMcpAdapter(this.cfg);
|
|
1058
|
+
return this.agentHop;
|
|
1059
|
+
}
|
|
1060
|
+
/** The local agent's advertised tools (web/mainframe/…), mirrored so the recording passthrough can
|
|
1061
|
+
* register them. No agent configured (cloud editor) → `[]`, so no recording tools get registered
|
|
1062
|
+
* and recording is gracefully unavailable. */
|
|
1063
|
+
async listAgentTools() {
|
|
1064
|
+
const hop = this.hop();
|
|
1065
|
+
return hop ? hop.listAgentTools() : [];
|
|
1066
|
+
}
|
|
1067
|
+
/** Forward an agent-bound tool call (recording) to the local agent's /mcp. Without a configured
|
|
1068
|
+
* agent /mcp (cloud editor) there is nothing to forward to, so this fails with a clear instruction. */
|
|
1069
|
+
async callRaw(name, args) {
|
|
1070
|
+
const hop = this.hop();
|
|
1071
|
+
if (!hop) {
|
|
1072
|
+
throw new Error("Recording needs a local Walnut agent — set QA_MCP_URL to the agent's /mcp endpoint.");
|
|
1073
|
+
}
|
|
1074
|
+
return hop.callRaw(name, args);
|
|
1075
|
+
}
|
|
1076
|
+
// Recording JOURNAL (read/edit) is agent-bound too — it derives from a local recording seed the
|
|
1077
|
+
// agent owns (backend journal persistence is the deferred Item 3b). So journal_grep/journal_modify
|
|
1078
|
+
// DELEGATE to the local agent's /mcp, exactly like recording. No agent (a cloud editor) → a clear
|
|
1079
|
+
// "needs a local agent" error. The methods are always PRESENT so discovery.tools' `!adapter.readJournal`
|
|
1080
|
+
// guard doesn't short-circuit to "no journal" on the direct path — the requirement is the hop, not absence.
|
|
1081
|
+
async readJournal(tcId) {
|
|
1082
|
+
const hop = this.hop();
|
|
1083
|
+
if (!hop)
|
|
1084
|
+
throw new Error("Reading a test case's recording journal needs a local product agent — set QA_MCP_URL to the agent's /mcp endpoint.");
|
|
1085
|
+
return hop.readJournal(tcId);
|
|
1086
|
+
}
|
|
1087
|
+
async editJournal(tcId, ops) {
|
|
1088
|
+
const hop = this.hop();
|
|
1089
|
+
if (!hop)
|
|
1090
|
+
throw new Error("Editing a test case's recording journal (journal_modify) needs a local product agent — set QA_MCP_URL to the agent's /mcp endpoint.");
|
|
1091
|
+
return hop.editJournal(tcId, ops);
|
|
1092
|
+
}
|
|
1093
|
+
/** Deep link into the product UI (e.g. `/test-cases/:id`, `/reports/:id`). These routes are served
|
|
1094
|
+
* by walnut-frontend, NOT the API host, so this must use the FRONTEND origin. */
|
|
1095
|
+
productUrlFor(entity) {
|
|
1096
|
+
return `${this.resolveUiOrigin()}/${PRODUCT_URL_SEGMENT[entity.kind]}/${entity.id}`;
|
|
1097
|
+
}
|
|
1098
|
+
/** Frontend origin for deep links, resolved (and trailing-slash-trimmed) as:
|
|
1099
|
+
* 1. `walnutUiOrigin` (env `QA_MCP_WALNUT_UI`) -- the explicit override; point it at the local
|
|
1100
|
+
* frontend dev server now, and the deployed frontend once shipped.
|
|
1101
|
+
* 2. else the BACKEND base (`cfg.baseUrl` / `QA_BASE_URL`) with a trailing `/api` stripped --
|
|
1102
|
+
* correct for a single-host deploy (https://app.walnutai.ai/api -> https://app.walnutai.ai), so
|
|
1103
|
+
* production Just Works with no override. A split local dev host (backend :8080, Vite :5173)
|
|
1104
|
+
* can't be derived, so it needs (1). NOTE: derived from `baseUrl`, NOT `cfg.url` — `cfg.url` is
|
|
1105
|
+
* now the agent /mcp endpoint, not a web origin. */
|
|
1106
|
+
resolveUiOrigin() {
|
|
1107
|
+
const strip = (s) => s.replace(/\/+$/, '');
|
|
1108
|
+
const override = this.cfg.walnutUiOrigin?.trim();
|
|
1109
|
+
if (override)
|
|
1110
|
+
return strip(override);
|
|
1111
|
+
return strip(this.cfg.baseUrl ?? '').replace(/\/api$/, '');
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
//# sourceMappingURL=adapter.js.map
|