@jacebenson/jsn 4.1.0 → 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.
Files changed (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -1
  3. package/src/app.js +48 -64
  4. package/src/auth.js +46 -11
  5. package/src/capabilities.js +128 -0
  6. package/src/cli.js +129 -79
  7. package/src/commands/_ticket.js +36 -92
  8. package/src/commands/approvals.js +350 -0
  9. package/src/commands/atf.js +635 -0
  10. package/src/commands/auth.js +93 -13
  11. package/src/commands/catalog/enrich.js +139 -0
  12. package/src/commands/catalog.js +15 -133
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +62 -110
  16. package/src/commands/dev/_simple.js +16 -12
  17. package/src/commands/dev/b4rules.js +1 -1
  18. package/src/commands/dev/domains.js +4 -0
  19. package/src/commands/dev/eval.js +40 -4
  20. package/src/commands/dev/flows.js +3 -0
  21. package/src/commands/dev/logs.js +97 -0
  22. package/src/commands/dev/rest.js +6 -0
  23. package/src/commands/dev/restmessage.js +1 -0
  24. package/src/commands/dev/scopes.js +49 -25
  25. package/src/commands/dev/scrapi.js +5 -0
  26. package/src/commands/dev/soapmessages.js +1 -0
  27. package/src/commands/dev/updatesets.js +145 -15
  28. package/src/commands/dev.js +3 -2
  29. package/src/commands/docs/db.js +14 -0
  30. package/src/commands/docs/docs.js +9 -21
  31. package/src/commands/docs/search.js +33 -10
  32. package/src/commands/groups.js +3 -0
  33. package/src/commands/records.js +254 -9
  34. package/src/commands/setup.js +5 -0
  35. package/src/commands/skill.js +3 -0
  36. package/src/commands/snippets.js +155 -0
  37. package/src/commands/tickets.js +9 -29
  38. package/src/commands/users.js +9 -35
  39. package/src/commands/version.js +3 -0
  40. package/src/errors.js +92 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +22 -11
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +187 -61
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. 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
+ }