@jacebenson/jsn 4.0.4 → 4.2.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 +18 -2
- package/package.json +4 -3
- package/src/app.js +48 -64
- package/src/auth.js +46 -11
- package/src/capabilities.js +128 -0
- package/src/cli.js +129 -65
- package/src/commands/_ticket.js +37 -93
- package/src/commands/approvals.js +350 -0
- package/src/commands/atf.js +635 -0
- package/src/commands/auth.js +93 -13
- package/src/commands/catalog/enrich.js +139 -0
- package/src/commands/catalog.js +25 -81
- package/src/commands/cmdb.js +613 -0
- package/src/commands/codesearch.js +103 -0
- package/src/commands/dev/_generic.js +63 -111
- package/src/commands/dev/_simple.js +16 -12
- package/src/commands/dev/b4rules.js +1 -1
- package/src/commands/dev/domains.js +4 -0
- package/src/commands/dev/eval.js +65 -7
- package/src/commands/dev/flows.js +3 -0
- package/src/commands/dev/logs.js +97 -0
- package/src/commands/dev/rest.js +6 -0
- package/src/commands/dev/restmessage.js +1 -0
- package/src/commands/dev/scopes.js +49 -25
- package/src/commands/dev/scrapi.js +5 -0
- package/src/commands/dev/soapmessages.js +1 -0
- package/src/commands/dev/updatesets.js +145 -15
- package/src/commands/dev.js +3 -2
- package/src/commands/docs/db.js +14 -0
- package/src/commands/docs/docs.js +9 -21
- package/src/commands/docs/search.js +33 -10
- package/src/commands/groups.js +3 -0
- package/src/commands/records.js +274 -12
- package/src/commands/setup.js +5 -0
- package/src/commands/skill.js +3 -0
- package/src/commands/snippets.js +155 -0
- package/src/commands/tickets.js +9 -29
- package/src/commands/users.js +9 -35
- package/src/commands/version.js +3 -0
- package/src/errors.js +107 -1
- package/src/help.js +1 -0
- package/src/helpers.js +131 -15
- package/src/mutations.js +36 -91
- package/src/output.js +195 -64
- package/src/resolve-record.js +71 -0
- package/src/sdk.js +108 -8
- package/src/session.js +214 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
// ATF (Automated Test Framework) commands — top-level `jsn atf`
|
|
2
|
+
//
|
|
3
|
+
// Reference: todo.md item 3. Execution is shipped here; generation
|
|
4
|
+
// (`jsn atf create`) is the greenfield wedge tracked separately.
|
|
5
|
+
//
|
|
6
|
+
// Run endpoints are tried in order (see scheduleTestRun):
|
|
7
|
+
// 1. POST /api/sn_cicd/tests/run_test (modern CICD API — real path per
|
|
8
|
+
// now-sdk cicd/operations.ts: tests.run_test, NOT /test/run)
|
|
9
|
+
// 2. POST /api/sn_atf/rest/test (ATF plugin REST)
|
|
10
|
+
// 3. POST /api/now/atf/test/{id}/run (legacy)
|
|
11
|
+
// 4. POST /api/now/table/sys_atf_test_result (last resort, mutation-gated)
|
|
12
|
+
//
|
|
13
|
+
// Polling (sn_cicd path): dispatch returns result.links.progress.id; poll
|
|
14
|
+
// GET /api/sn_cicd/progress/{id} whose status is 0=pending, 1=running,
|
|
15
|
+
// 2=successful, 3=error, 4=canceled. The progress tracker's successful flag
|
|
16
|
+
// IS the pass/fail source of truth (now-sdk atf-results.ts). On terminal,
|
|
17
|
+
// fetch result.links.results.id for enrichment (test_status / counts).
|
|
18
|
+
//
|
|
19
|
+
// Run is a mutation (it schedules tests that act on records), so it's gated
|
|
20
|
+
// by confirmDelete + registered in MUTATION_COMMANDS for read-only profiles.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
getStringField, interactiveList,
|
|
24
|
+
resolveFieldsParam, assertSafeExactMatch, confirmDelete,
|
|
25
|
+
} from '../helpers.js';
|
|
26
|
+
import { errUsage, errNotFound } from '../errors.js';
|
|
27
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
28
|
+
|
|
29
|
+
// run/run-suite schedule tests that act on records (top-level only).
|
|
30
|
+
declareCapabilities('atf', { mutationSubcommands: ['run', 'run-suite'] });
|
|
31
|
+
|
|
32
|
+
// sn_cicd progress statuses (now-sdk poller.ts)
|
|
33
|
+
const PROGRESS_PENDING = '0';
|
|
34
|
+
const PROGRESS_RUNNING = '1';
|
|
35
|
+
const PROGRESS_SUCCESSFUL = '2';
|
|
36
|
+
const PROGRESS_ERROR = '3';
|
|
37
|
+
const PROGRESS_CANCELED = '4';
|
|
38
|
+
const PROGRESS_TERMINAL = new Set([PROGRESS_SUCCESSFUL, PROGRESS_ERROR, PROGRESS_CANCELED]);
|
|
39
|
+
|
|
40
|
+
// Terminal statuses for sys_atf_test_result (older instances that expose a status field)
|
|
41
|
+
const TERMINAL_TABLE_STATUSES = new Set(['passed', 'failed', 'error', 'canceled', 'cancelled', 'skipped']);
|
|
42
|
+
|
|
43
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True when the API error means "endpoint/plugin missing" — safe to fall
|
|
47
|
+
* through the ladder. Some instances report a missing endpoint as 404,
|
|
48
|
+
* others as 400 with ServiceNow's "Requested URI does not represent any
|
|
49
|
+
* resource" body (e.g. when the sn_cicd API isn't enabled).
|
|
50
|
+
*/
|
|
51
|
+
export function isEndpointMissing(err) {
|
|
52
|
+
if (err?.status === 404 || err?.status === 403) return true;
|
|
53
|
+
if (err?.status === 400 && /Requested URI does not represent any resource/.test(err.message || '')) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract an execution/result id from a run response (legacy/plugin shapes). */
|
|
58
|
+
export function pickExecutionId(resp) {
|
|
59
|
+
if (!resp || typeof resp !== 'object') return '';
|
|
60
|
+
if (typeof resp.result === 'string') return resp.result;
|
|
61
|
+
const inner = resp.result && typeof resp.result === 'object' ? resp.result : null;
|
|
62
|
+
const src = inner || resp;
|
|
63
|
+
for (const key of ['result_id', 'sys_id', 'tracker_id', 'progress_id', 'id']) {
|
|
64
|
+
const v = src?.[key];
|
|
65
|
+
if (v == null) continue;
|
|
66
|
+
if (typeof v === 'object') {
|
|
67
|
+
if (v.value) return String(v.value);
|
|
68
|
+
if (v.display_value) return String(v.display_value);
|
|
69
|
+
} else if (v) {
|
|
70
|
+
return String(v);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Extract result.links.progress.id (sn_cicd dispatch shape). */
|
|
77
|
+
export function pickProgressId(resp) {
|
|
78
|
+
if (!resp || typeof resp !== 'object') return '';
|
|
79
|
+
const inner = resp.result && typeof resp.result === 'object' ? resp.result : null;
|
|
80
|
+
const src = inner || resp;
|
|
81
|
+
return src?.links?.progress?.id || src?.progress_id || src?.tracker_id || '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Extract result.links.results.id (sn_cicd dispatch shape), else legacy ids. */
|
|
85
|
+
export function pickResultId(resp) {
|
|
86
|
+
if (!resp || typeof resp !== 'object') return '';
|
|
87
|
+
if (typeof resp.result === 'string') return resp.result;
|
|
88
|
+
const inner = resp.result && typeof resp.result === 'object' ? resp.result : null;
|
|
89
|
+
const src = inner || resp;
|
|
90
|
+
return src?.links?.results?.id || src?.result_id || src?.sys_id || '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve an ATF test/suite by sys_id or name.
|
|
95
|
+
* @returns {Promise<object>} raw record
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveAtf(app, table, id, label) {
|
|
98
|
+
assertSafeExactMatch(id);
|
|
99
|
+
const isSysID = /^[0-9a-fA-F]{32}$/.test(id);
|
|
100
|
+
const params = new URLSearchParams();
|
|
101
|
+
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
|
|
102
|
+
params.set('sysparm_limit', '1');
|
|
103
|
+
params.set('sysparm_display_value', 'all');
|
|
104
|
+
const records = await app.sdk.list(table, params);
|
|
105
|
+
if (!records || records.length === 0) throw errNotFound(label, id);
|
|
106
|
+
return records[0];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Collect the test sys_ids that belong to a suite (M2M table).
|
|
111
|
+
* Deduped, in suite-member order.
|
|
112
|
+
*/
|
|
113
|
+
export async function suiteTestIds(app, suiteSysID) {
|
|
114
|
+
const params = new URLSearchParams();
|
|
115
|
+
params.set('sysparm_query', `test_suite=${suiteSysID}`);
|
|
116
|
+
params.set('sysparm_limit', '500');
|
|
117
|
+
params.set('sysparm_fields', 'test');
|
|
118
|
+
const m2m = await app.sdk.list('sys_atf_test_suite_test', params);
|
|
119
|
+
return [...new Set(m2m.map((r) => getStringField(r, 'test')).filter(Boolean))];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the encoded query for a suite-filtered test list. Chunks the IN
|
|
124
|
+
* clause (URL-length safety) and ANDs the user's query on top.
|
|
125
|
+
*/
|
|
126
|
+
export function buildSuiteQuery(userQuery, testIds) {
|
|
127
|
+
if (!testIds || testIds.length === 0) return '';
|
|
128
|
+
const chunks = [];
|
|
129
|
+
for (let i = 0; i < testIds.length; i += 150) chunks.push(testIds.slice(i, i + 150).join(','));
|
|
130
|
+
const inQuery = chunks.map((c) => `sys_idIN${c}`).join('^OR');
|
|
131
|
+
return userQuery ? `${userQuery}^${inQuery}` : inQuery;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Schedule a single ATF test run via the endpoint ladder.
|
|
136
|
+
* @returns {Promise<{executionId, progressId, resultId, kind: 'sn_cicd_test'|'table', via}>}
|
|
137
|
+
*/
|
|
138
|
+
export async function scheduleTestRun(app, testSysID) {
|
|
139
|
+
const base = app.sdk.baseURL;
|
|
140
|
+
// 1) sn_cicd (modern) — params are QUERY params per the API spec
|
|
141
|
+
// (openapi/sn_cicd-CICD_ATF_Test_Execution_API.json: test_sys_id in:query)
|
|
142
|
+
try {
|
|
143
|
+
const resp = await app.sdk.request(`${base}/api/sn_cicd/tests/run_test?test_sys_id=${encodeURIComponent(testSysID)}`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
});
|
|
146
|
+
const progressId = pickProgressId(resp);
|
|
147
|
+
const resultId = pickResultId(resp);
|
|
148
|
+
if (!progressId && !resultId) throw errUsage('sn_cicd tests/run_test returned no progress/result link');
|
|
149
|
+
return { executionId: resultId || progressId, progressId, resultId, kind: 'sn_cicd_test', via: 'sn_cicd' };
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (!isEndpointMissing(err)) throw err;
|
|
152
|
+
}
|
|
153
|
+
// 2) sn_atf REST (ATF plugin)
|
|
154
|
+
try {
|
|
155
|
+
const resp = await app.sdk.request(`${base}/api/sn_atf/rest/test`, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
body: JSON.stringify({ test_id: testSysID }),
|
|
158
|
+
});
|
|
159
|
+
const executionId = pickExecutionId(resp);
|
|
160
|
+
if (!executionId) throw errUsage('sn_atf/rest/test returned no result id');
|
|
161
|
+
return { executionId, progressId: '', resultId: executionId, kind: 'table', via: 'sn_atf' };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (!isEndpointMissing(err)) throw err;
|
|
164
|
+
}
|
|
165
|
+
// 3) legacy
|
|
166
|
+
try {
|
|
167
|
+
const resp = await app.sdk.request(`${base}/api/now/atf/test/${testSysID}/run`, { method: 'POST' });
|
|
168
|
+
const executionId = pickExecutionId(resp);
|
|
169
|
+
if (!executionId) throw errUsage('legacy ATF run returned no result id');
|
|
170
|
+
return { executionId, progressId: '', resultId: executionId, kind: 'table', via: 'legacy' };
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (!isEndpointMissing(err)) throw err;
|
|
173
|
+
}
|
|
174
|
+
// 4) table insert — last resort, mutation-gated by the command's confirm
|
|
175
|
+
const created = await app.sdk.create('sys_atf_test_result', { test: testSysID, status: 'scheduled' });
|
|
176
|
+
const executionId = getStringField(created, 'sys_id');
|
|
177
|
+
if (!executionId) throw errUsage('Table-insert fallback returned no sys_id');
|
|
178
|
+
return { executionId, progressId: '', resultId: executionId, kind: 'table', via: 'table_insert' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Schedule an ATF suite run.
|
|
183
|
+
* @returns {Promise<{executionId, progressId, resultId, kind: 'sn_cicd_suite'|'table', via}>}
|
|
184
|
+
*/
|
|
185
|
+
export async function scheduleSuiteRun(app, suiteSysID) {
|
|
186
|
+
const base = app.sdk.baseURL;
|
|
187
|
+
// 1) sn_cicd (modern) — test_suite_sys_id is a QUERY param per the API spec
|
|
188
|
+
// (openapi/sn_cicd-CICD_ATF_Suite_Execution_API.json)
|
|
189
|
+
try {
|
|
190
|
+
const resp = await app.sdk.request(`${base}/api/sn_cicd/testsuite/run?test_suite_sys_id=${encodeURIComponent(suiteSysID)}`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
});
|
|
193
|
+
const progressId = pickProgressId(resp);
|
|
194
|
+
const resultId = pickResultId(resp);
|
|
195
|
+
if (!progressId && !resultId) throw errUsage('sn_cicd testsuite/run returned no progress/result link');
|
|
196
|
+
return { executionId: resultId || progressId, progressId, resultId, kind: 'sn_cicd_suite', via: 'sn_cicd' };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (!isEndpointMissing(err)) throw err;
|
|
199
|
+
}
|
|
200
|
+
// 2) sn_atf REST (ATF plugin)
|
|
201
|
+
try {
|
|
202
|
+
const resp = await app.sdk.request(`${base}/api/sn_atf/rest/suite`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
body: JSON.stringify({ suite_id: suiteSysID }),
|
|
205
|
+
});
|
|
206
|
+
const executionId = pickExecutionId(resp);
|
|
207
|
+
if (!executionId) throw errUsage('sn_atf/rest/suite returned no result id');
|
|
208
|
+
return { executionId, progressId: '', resultId: executionId, kind: 'table', via: 'sn_atf' };
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (!isEndpointMissing(err)) throw err;
|
|
211
|
+
}
|
|
212
|
+
throw errUsage(
|
|
213
|
+
'No ATF run API available (sn_cicd and sn_atf both unreachable).\n' +
|
|
214
|
+
'Verify the ATF plugin is installed and API access is enabled on the instance.'
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Fetch GET /api/sn_cicd/progress/{id}; null when the endpoint is missing. */
|
|
219
|
+
async function fetchCicdProgress(app, progressId) {
|
|
220
|
+
try {
|
|
221
|
+
const resp = await app.sdk.request(`${app.sdk.baseURL}/api/sn_cicd/progress/${progressId}`, { method: 'GET' });
|
|
222
|
+
const r = resp?.result && typeof resp.result === 'object' ? resp.result : (resp ?? {});
|
|
223
|
+
return {
|
|
224
|
+
status: String(r.status ?? ''),
|
|
225
|
+
percent: Number(r.percent_complete ?? 0),
|
|
226
|
+
message: r.status_message ?? '',
|
|
227
|
+
detail: r.status_detail ?? '',
|
|
228
|
+
links: r.links ?? {},
|
|
229
|
+
raw: r,
|
|
230
|
+
};
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (!isEndpointMissing(err)) throw err;
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Map an sn_cicd progress status to a word. */
|
|
238
|
+
function progressStatusWord(status) {
|
|
239
|
+
switch (status) {
|
|
240
|
+
case PROGRESS_PENDING: return 'pending';
|
|
241
|
+
case PROGRESS_RUNNING: return 'running';
|
|
242
|
+
case PROGRESS_SUCCESSFUL: return 'passed';
|
|
243
|
+
case PROGRESS_ERROR: return 'error';
|
|
244
|
+
case PROGRESS_CANCELED: return 'canceled';
|
|
245
|
+
default: return 'unknown';
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Fetch one sn_cicd result record; null when the endpoint is missing.
|
|
251
|
+
* Instances disagree about whether a single test run links to the test or
|
|
252
|
+
* the suite result endpoint (the progress tracker's links.results.url is
|
|
253
|
+
* authoritative), so try both segments on 404.
|
|
254
|
+
*/
|
|
255
|
+
async function fetchCicdResult(app, resultId, kind) {
|
|
256
|
+
const segments = kind === 'sn_cicd_test'
|
|
257
|
+
? ['tests/test/results', 'testsuite/results']
|
|
258
|
+
: ['testsuite/results', 'tests/test/results'];
|
|
259
|
+
let lastErr = null;
|
|
260
|
+
for (const segment of segments) {
|
|
261
|
+
try {
|
|
262
|
+
const resp = await app.sdk.request(`${app.sdk.baseURL}/api/sn_cicd/${segment}/${resultId}`, { method: 'GET' });
|
|
263
|
+
const r = resp?.result && typeof resp.result === 'object' ? resp.result : (resp ?? {});
|
|
264
|
+
const links = r.links ?? {};
|
|
265
|
+
const resultUrl = links.results?.url || '';
|
|
266
|
+
const norm = (s) => String(s || '').toLowerCase().replace('failure', 'failed');
|
|
267
|
+
if (segment === 'testsuite/results') {
|
|
268
|
+
return {
|
|
269
|
+
execution_id: resultId,
|
|
270
|
+
status: norm(r.test_suite_status || r.test_status) || 'complete',
|
|
271
|
+
test_suite_name: r.test_suite_name ?? '',
|
|
272
|
+
passed: Number(r.rolledup_test_success_count ?? 0),
|
|
273
|
+
failed: Number(r.rolledup_test_failure_count ?? 0),
|
|
274
|
+
error_count: Number(r.rolledup_test_error_count ?? 0),
|
|
275
|
+
skipped: Number(r.rolledup_test_skip_count ?? 0),
|
|
276
|
+
output: r.output ?? '',
|
|
277
|
+
url: resultUrl,
|
|
278
|
+
raw: r,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
execution_id: resultId,
|
|
283
|
+
status: norm(r.test_status || r.test_suite_status) || 'unknown',
|
|
284
|
+
test: r.test_name ?? '',
|
|
285
|
+
output: r.output ?? '',
|
|
286
|
+
url: resultUrl,
|
|
287
|
+
raw: r,
|
|
288
|
+
};
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (!isEndpointMissing(err)) throw err;
|
|
291
|
+
lastErr = err;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (lastErr) return null;
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Fetch a result from the sys_atf_test_result tables; null when not found. */
|
|
299
|
+
export async function fetchTableResult(app, executionId) {
|
|
300
|
+
for (const table of ['sys_atf_test_result', 'sys_atf_test_suite_result']) {
|
|
301
|
+
try {
|
|
302
|
+
const rec = await app.sdk.get(table, executionId);
|
|
303
|
+
if (rec) return normalizeTableResult(rec, table);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (!isEndpointMissing(err)) throw err;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Normalize a sys_atf_test_result / sys_atf_test_suite_result row. */
|
|
312
|
+
function normalizeTableResult(rec, table) {
|
|
313
|
+
const statusRaw = String(getStringField(rec, 'status') || '').toLowerCase();
|
|
314
|
+
const endTime = getStringField(rec, 'end_time');
|
|
315
|
+
const output = getStringField(rec, 'output');
|
|
316
|
+
const failing = getStringField(rec, 'first_failing_step');
|
|
317
|
+
let status = statusRaw || 'unknown';
|
|
318
|
+
if (!statusRaw) {
|
|
319
|
+
if (failing) status = 'failed';
|
|
320
|
+
else if (endTime && output) status = 'passed';
|
|
321
|
+
else if (endTime) status = 'complete';
|
|
322
|
+
else if (getStringField(rec, 'start_time')) status = 'running';
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
execution_id: getStringField(rec, 'sys_id'),
|
|
326
|
+
table,
|
|
327
|
+
test: getStringField(rec, 'test') || getStringField(rec, 'test_suite'),
|
|
328
|
+
status,
|
|
329
|
+
started: getStringField(rec, 'sys_created_on') || '',
|
|
330
|
+
updated: getStringField(rec, 'sys_updated_on') || '',
|
|
331
|
+
raw: rec,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isTerminalTable(rec) {
|
|
336
|
+
const status = String(getStringField(rec, 'status') || '').toLowerCase();
|
|
337
|
+
if (TERMINAL_TABLE_STATUSES.has(status)) return true;
|
|
338
|
+
const endTime = getStringField(rec, 'end_time');
|
|
339
|
+
const output = getStringField(rec, 'output');
|
|
340
|
+
const json = getStringField(rec, 'test_result_json');
|
|
341
|
+
const failing = getStringField(rec, 'first_failing_step');
|
|
342
|
+
return !!(endTime && (output || json || failing));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Fetch an execution result by trying every source (used by `results`):
|
|
347
|
+
* sn_cicd test result → sn_cicd suite result → progress tracker (following
|
|
348
|
+
* its links.results.id for enrichment) → tables.
|
|
349
|
+
*/
|
|
350
|
+
export async function fetchAnyResult(app, executionId) {
|
|
351
|
+
for (const kind of ['sn_cicd_test', 'sn_cicd_suite']) {
|
|
352
|
+
const res = await fetchCicdResult(app, executionId, kind);
|
|
353
|
+
if (res) return res;
|
|
354
|
+
}
|
|
355
|
+
const prog = await fetchCicdProgress(app, executionId);
|
|
356
|
+
if (prog) {
|
|
357
|
+
const base = {
|
|
358
|
+
execution_id: executionId,
|
|
359
|
+
status: progressStatusWord(prog.status),
|
|
360
|
+
percent: prog.percent,
|
|
361
|
+
message: prog.message,
|
|
362
|
+
detail: prog.detail,
|
|
363
|
+
raw: prog.raw,
|
|
364
|
+
};
|
|
365
|
+
const linkedResultId = prog.links?.results?.id || '';
|
|
366
|
+
if (linkedResultId) {
|
|
367
|
+
for (const kind of ['sn_cicd_test', 'sn_cicd_suite']) {
|
|
368
|
+
const res = await fetchCicdResult(app, linkedResultId, kind);
|
|
369
|
+
if (res) {
|
|
370
|
+
return { ...base, result_id: linkedResultId, ...res, raw: res.raw };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return base;
|
|
375
|
+
}
|
|
376
|
+
return fetchTableResult(app, executionId);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Poll an execution until it reaches a terminal state.
|
|
381
|
+
* sn_cicd kinds poll the progress tracker, then enrich with the result
|
|
382
|
+
* record; table kinds poll sys_atf_test_result directly.
|
|
383
|
+
* @returns {Promise<object>} normalized result
|
|
384
|
+
*/
|
|
385
|
+
export async function pollExecution(app, { executionId, progressId, resultId, kind }, timeoutMs = 120000, intervalMs = 2000) {
|
|
386
|
+
const start = Date.now();
|
|
387
|
+
if (kind === 'sn_cicd_test' || kind === 'sn_cicd_suite') {
|
|
388
|
+
let last = null;
|
|
389
|
+
while (Date.now() - start < timeoutMs) {
|
|
390
|
+
const prog = progressId ? await fetchCicdProgress(app, progressId) : null;
|
|
391
|
+
if (prog && PROGRESS_TERMINAL.has(prog.status)) {
|
|
392
|
+
const trackerStatus = progressStatusWord(prog.status);
|
|
393
|
+
// The dispatch response often only carries the progress link; the
|
|
394
|
+
// results link appears once the run completes. Follow it when present,
|
|
395
|
+
// then fall back to the dispatch result id.
|
|
396
|
+
let enriched = null;
|
|
397
|
+
const linkedResultId = prog.links?.results?.id || '';
|
|
398
|
+
if (linkedResultId) {
|
|
399
|
+
for (const k of ['sn_cicd_test', 'sn_cicd_suite']) {
|
|
400
|
+
const r = await fetchCicdResult(app, linkedResultId, k);
|
|
401
|
+
if (r) { enriched = r; break; }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (!enriched && resultId && resultId !== progressId) {
|
|
405
|
+
enriched = await fetchCicdResult(app, resultId, kind);
|
|
406
|
+
}
|
|
407
|
+
const status = enriched?.status && enriched.status !== 'unknown' ? enriched.status : trackerStatus;
|
|
408
|
+
return {
|
|
409
|
+
execution_id: resultId || progressId,
|
|
410
|
+
progress_id: progressId,
|
|
411
|
+
...(enriched ? { result_id: enriched.execution_id, ...enriched } : {}),
|
|
412
|
+
status,
|
|
413
|
+
tracker: { status: prog.status, percent: prog.percent, message: prog.message, detail: prog.detail },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
last = prog;
|
|
417
|
+
await sleep(intervalMs);
|
|
418
|
+
}
|
|
419
|
+
throw errUsage(
|
|
420
|
+
`ATF run still ${last?.message || 'running'} after ${Math.round(timeoutMs / 1000)}s. ` +
|
|
421
|
+
`Check later with: jsn atf results ${progressId || executionId}`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Table-based kinds (sn_atf / legacy / table insert)
|
|
426
|
+
while (Date.now() - start < timeoutMs) {
|
|
427
|
+
const rec = await fetchTableResult(app, executionId);
|
|
428
|
+
if (rec && isTerminalTable(rec)) return rec;
|
|
429
|
+
await sleep(intervalMs);
|
|
430
|
+
}
|
|
431
|
+
throw errUsage(
|
|
432
|
+
`ATF run still running after ${Math.round(timeoutMs / 1000)}s. ` +
|
|
433
|
+
`Check later with: jsn atf results ${executionId}`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function listRecords(app, table, columns, query, limit) {
|
|
438
|
+
const params = new URLSearchParams();
|
|
439
|
+
params.set('sysparm_limit', String(limit));
|
|
440
|
+
params.set('sysparm_display_value', 'all');
|
|
441
|
+
const fields = resolveFieldsParam(columns);
|
|
442
|
+
if (fields) params.set('sysparm_fields', fields);
|
|
443
|
+
if (query) params.set('sysparm_query', query);
|
|
444
|
+
return app.sdk.list(table, params);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function outputList(app, table, columns, records, extra = {}) {
|
|
448
|
+
app.ok({
|
|
449
|
+
table,
|
|
450
|
+
count: records.length,
|
|
451
|
+
columns,
|
|
452
|
+
records,
|
|
453
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
454
|
+
...extra,
|
|
455
|
+
}, { summary: `${records.length} record(s)` });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function handleRun(app, argv, { entity, label, scheduled }) {
|
|
459
|
+
if (!argv.wait) {
|
|
460
|
+
app.ok({
|
|
461
|
+
execution_id: scheduled.executionId,
|
|
462
|
+
progress_id: scheduled.progressId,
|
|
463
|
+
entity: label,
|
|
464
|
+
status: 'scheduled',
|
|
465
|
+
via: scheduled.via,
|
|
466
|
+
poll: `jsn atf results ${scheduled.executionId}`,
|
|
467
|
+
}, {
|
|
468
|
+
summary: `Scheduled ATF ${entity}: ${label} (${scheduled.via}). Poll with: jsn atf results ${scheduled.executionId}`,
|
|
469
|
+
breadcrumbs: [
|
|
470
|
+
{ action: 'results', cmd: `jsn atf results ${scheduled.executionId}`, description: 'Fetch the execution result' },
|
|
471
|
+
],
|
|
472
|
+
});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const defaultTimeout = entity === 'suite' ? 300 : 120;
|
|
476
|
+
const timeoutMs = (argv.timeout || defaultTimeout) * 1000;
|
|
477
|
+
const result = await pollExecution(app, scheduled, timeoutMs);
|
|
478
|
+
app.ok({
|
|
479
|
+
execution_id: scheduled.executionId,
|
|
480
|
+
progress_id: scheduled.progressId,
|
|
481
|
+
entity: label,
|
|
482
|
+
via: scheduled.via,
|
|
483
|
+
...result,
|
|
484
|
+
}, { summary: `ATF ${entity} ${label}: ${result.status}` });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function atfCmd(wrap) {
|
|
488
|
+
return {
|
|
489
|
+
command: 'atf [subcommand]',
|
|
490
|
+
describe: 'Automated Test Framework (list, suites, run, results)',
|
|
491
|
+
builder: (yargs) => {
|
|
492
|
+
return yargs
|
|
493
|
+
.command({
|
|
494
|
+
command: 'list',
|
|
495
|
+
aliases: ['ls'],
|
|
496
|
+
describe: 'List ATF tests (optionally filtered by suite)',
|
|
497
|
+
builder: (y) => y
|
|
498
|
+
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKElogin" or "active=true")' })
|
|
499
|
+
.option('suite', { type: 'string', describe: 'Only tests in this suite (name or sys_id)' })
|
|
500
|
+
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns' })
|
|
501
|
+
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
|
|
502
|
+
handler: wrap(async (argv, app) => {
|
|
503
|
+
const columns = argv.columns ? argv.columns.split(',') : ['name', 'sys_class_name', 'active', 'sys_updated_on'];
|
|
504
|
+
|
|
505
|
+
if (argv.suite) {
|
|
506
|
+
app.requireInstance();
|
|
507
|
+
const suite = await resolveAtf(app, 'sys_atf_test_suite', argv.suite, 'ATF suite');
|
|
508
|
+
const suiteID = getStringField(suite, 'sys_id');
|
|
509
|
+
const ids = await suiteTestIds(app, suiteID);
|
|
510
|
+
const query = buildSuiteQuery(argv.query, ids);
|
|
511
|
+
if (!query) {
|
|
512
|
+
outputList(app, 'sys_atf_test', columns, [], { suite: getStringField(suite, 'name') });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const records = await listRecords(app, 'sys_atf_test', columns, query, argv.limit);
|
|
516
|
+
outputList(app, 'sys_atf_test', columns, records, { suite: getStringField(suite, 'name') });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const picked = await interactiveList({
|
|
521
|
+
app, table: 'sys_atf_test', singular: 'ATF test', columns, limit: argv.limit,
|
|
522
|
+
query: argv.query, labelField: 'name',
|
|
523
|
+
formatLabel: (r) => `${getStringField(r, 'name')} [${getStringField(r, 'sys_class_name') || '?'}]`,
|
|
524
|
+
});
|
|
525
|
+
if (picked === undefined) return; // user cancelled
|
|
526
|
+
if (picked) {
|
|
527
|
+
const sysID = getStringField(picked, 'sys_id');
|
|
528
|
+
return app.ok(picked, {
|
|
529
|
+
summary: `ATF test: ${getStringField(picked, 'name')}`,
|
|
530
|
+
breadcrumbs: [
|
|
531
|
+
{ action: 'run', cmd: `jsn atf run ${sysID}`, description: 'Run this test' },
|
|
532
|
+
],
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const records = await listRecords(app, 'sys_atf_test', columns, argv.query, argv.limit);
|
|
537
|
+
outputList(app, 'sys_atf_test', columns, records);
|
|
538
|
+
}),
|
|
539
|
+
})
|
|
540
|
+
.command({
|
|
541
|
+
command: 'suites',
|
|
542
|
+
aliases: ['ls-suite'],
|
|
543
|
+
describe: 'List ATF test suites',
|
|
544
|
+
builder: (y) => y
|
|
545
|
+
.option('query', { type: 'string', describe: 'Encoded query' })
|
|
546
|
+
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns' })
|
|
547
|
+
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
|
|
548
|
+
handler: wrap(async (argv, app) => {
|
|
549
|
+
const columns = argv.columns ? argv.columns.split(',') : ['name', 'description', 'sys_updated_on'];
|
|
550
|
+
const picked = await interactiveList({
|
|
551
|
+
app, table: 'sys_atf_test_suite', singular: 'ATF suite', columns, limit: argv.limit,
|
|
552
|
+
query: argv.query, labelField: 'name',
|
|
553
|
+
formatLabel: (r) => {
|
|
554
|
+
const desc = getStringField(r, 'description');
|
|
555
|
+
return `${getStringField(r, 'name')}${desc ? ` — ${desc}` : ''}`;
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
if (picked === undefined) return; // user cancelled
|
|
559
|
+
if (picked) {
|
|
560
|
+
const sysID = getStringField(picked, 'sys_id');
|
|
561
|
+
let count = 0;
|
|
562
|
+
try { count = (await suiteTestIds(app, sysID)).length; } catch { /* non-critical */ }
|
|
563
|
+
return app.ok(picked, {
|
|
564
|
+
summary: `ATF suite: ${getStringField(picked, 'name')}`,
|
|
565
|
+
breadcrumbs: [
|
|
566
|
+
count > 0 ? { action: 'tests', cmd: `jsn atf list --suite "${getStringField(picked, 'name')}"`, description: `${count} test(s) in suite` } : null,
|
|
567
|
+
{ action: 'run', cmd: `jsn atf run-suite ${sysID}`, description: 'Run the whole suite' },
|
|
568
|
+
].filter(Boolean),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const records = await listRecords(app, 'sys_atf_test_suite', columns, argv.query, argv.limit);
|
|
573
|
+
outputList(app, 'sys_atf_test_suite', columns, records);
|
|
574
|
+
}),
|
|
575
|
+
})
|
|
576
|
+
.command({
|
|
577
|
+
command: 'run <test>',
|
|
578
|
+
describe: 'Run an ATF test (sys_id or name). Mutation — requires confirmation',
|
|
579
|
+
builder: (y) => y
|
|
580
|
+
.positional('test', { describe: 'Test sys_id or name', type: 'string' })
|
|
581
|
+
.option('wait', { type: 'boolean', default: true, describe: 'Wait for completion (--no-wait to schedule and return immediately)' })
|
|
582
|
+
.option('timeout', { type: 'number', describe: 'Poll timeout in seconds (default 120)' })
|
|
583
|
+
.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
584
|
+
handler: wrap(async (argv, app) => {
|
|
585
|
+
app.requireInstance();
|
|
586
|
+
await confirmDelete(app, argv, `Run ATF test "${argv.test}"?`);
|
|
587
|
+
const test = await resolveAtf(app, 'sys_atf_test', argv.test, 'ATF test');
|
|
588
|
+
const scheduled = await scheduleTestRun(app, getStringField(test, 'sys_id'));
|
|
589
|
+
await handleRun(app, argv, { entity: 'test', label: getStringField(test, 'name'), scheduled });
|
|
590
|
+
}),
|
|
591
|
+
})
|
|
592
|
+
.command({
|
|
593
|
+
command: 'run-suite <suite>',
|
|
594
|
+
describe: 'Run an ATF test suite (sys_id or name). Mutation — requires confirmation',
|
|
595
|
+
builder: (y) => y
|
|
596
|
+
.positional('suite', { describe: 'Suite sys_id or name', type: 'string' })
|
|
597
|
+
.option('wait', { type: 'boolean', default: true, describe: 'Wait for completion (--no-wait to schedule and return immediately)' })
|
|
598
|
+
.option('timeout', { type: 'number', describe: 'Poll timeout in seconds (default 300)' })
|
|
599
|
+
.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
600
|
+
handler: wrap(async (argv, app) => {
|
|
601
|
+
app.requireInstance();
|
|
602
|
+
await confirmDelete(app, argv, `Run ATF suite "${argv.suite}"?`);
|
|
603
|
+
const suite = await resolveAtf(app, 'sys_atf_test_suite', argv.suite, 'ATF suite');
|
|
604
|
+
const scheduled = await scheduleSuiteRun(app, getStringField(suite, 'sys_id'));
|
|
605
|
+
await handleRun(app, argv, { entity: 'suite', label: getStringField(suite, 'name'), scheduled });
|
|
606
|
+
}),
|
|
607
|
+
})
|
|
608
|
+
.command({
|
|
609
|
+
command: 'results <id>',
|
|
610
|
+
describe: 'Show an ATF execution result (id from run/run-suite)',
|
|
611
|
+
builder: (y) => y
|
|
612
|
+
.positional('id', { describe: 'Execution/result/progress sys_id', type: 'string' }),
|
|
613
|
+
handler: wrap(async (argv, app) => {
|
|
614
|
+
app.requireInstance();
|
|
615
|
+
const result = await fetchAnyResult(app, argv.id);
|
|
616
|
+
if (!result) throw errNotFound('ATF execution', argv.id);
|
|
617
|
+
app.ok(result, { summary: `ATF result ${argv.id}: ${result.status}` });
|
|
618
|
+
}),
|
|
619
|
+
});
|
|
620
|
+
},
|
|
621
|
+
handler: (argv) => {
|
|
622
|
+
if (argv._[1]) return; // a subcommand ran — its own handler handled it
|
|
623
|
+
console.log('Manage ServiceNow Automated Test Framework (ATF).\n');
|
|
624
|
+
console.log('Commands:');
|
|
625
|
+
console.log(' list List ATF tests (--suite <name> filters)');
|
|
626
|
+
console.log(' suites List ATF test suites');
|
|
627
|
+
console.log(' run <test> Run a test by sys_id or name (polls by default)');
|
|
628
|
+
console.log(' run-suite <suite> Run a suite by sys_id or name');
|
|
629
|
+
console.log(' results <id> Show an execution result');
|
|
630
|
+
console.log('\nRun "jsn atf <command> --help" for details.');
|
|
631
|
+
console.log('\nRun is a mutation: it schedules tests that act on records.');
|
|
632
|
+
console.log(' Confirm when prompted, or pass --force / profile skip_confirmations.');
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|