@pixlcore/xycli 1.0.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 (63) hide show
  1. package/README.md +543 -0
  2. package/docs/help.md +2587 -0
  3. package/docs/screenshots.md +219 -0
  4. package/docs/sync.md +920 -0
  5. package/docs/tutorial.md +204 -0
  6. package/index.js +444 -0
  7. package/lib/alerts.js +735 -0
  8. package/lib/apikey.js +425 -0
  9. package/lib/buckets.js +607 -0
  10. package/lib/categories.js +323 -0
  11. package/lib/channels.js +290 -0
  12. package/lib/config.js +86 -0
  13. package/lib/dashboard.js +237 -0
  14. package/lib/doc.js +131 -0
  15. package/lib/events.js +1314 -0
  16. package/lib/groups.js +1038 -0
  17. package/lib/jobs.js +1254 -0
  18. package/lib/log.js +125 -0
  19. package/lib/marketplace.js +386 -0
  20. package/lib/monitors.js +412 -0
  21. package/lib/plugins.js +491 -0
  22. package/lib/secrets.js +441 -0
  23. package/lib/servers.js +1502 -0
  24. package/lib/snapshots.js +498 -0
  25. package/lib/sync.js +963 -0
  26. package/lib/system.js +612 -0
  27. package/lib/tags.js +250 -0
  28. package/lib/tickets.js +681 -0
  29. package/lib/transfer.js +447 -0
  30. package/lib/utils.js +2745 -0
  31. package/lib/webhook.js +414 -0
  32. package/logo.webp +0 -0
  33. package/package.json +38 -0
  34. package/test/README.md +97 -0
  35. package/test/api-validation.test.js +170 -0
  36. package/test/categories.test.js +246 -0
  37. package/test/channels.test.js +209 -0
  38. package/test/config.test.js +96 -0
  39. package/test/doc.test.js +84 -0
  40. package/test/groups.test.js +211 -0
  41. package/test/help.test.js +26 -0
  42. package/test/helpers/common.js +127 -0
  43. package/test/helpers/pagination-preload.cjs +18 -0
  44. package/test/log.test.js +103 -0
  45. package/test/markdown.test.js +61 -0
  46. package/test/marketplace.test.js +229 -0
  47. package/test/monitors.test.js +227 -0
  48. package/test/pagination.test.js +138 -0
  49. package/test/plugins.test.js +351 -0
  50. package/test/secrets.test.js +271 -0
  51. package/test/servers.test.js +303 -0
  52. package/test/snapshots.test.js +167 -0
  53. package/test/sync-unit.test.js +446 -0
  54. package/test/sync.test.js +542 -0
  55. package/test/system.test.js +94 -0
  56. package/test/tags.test.js +230 -0
  57. package/test/tickets.test.js +261 -0
  58. package/test/toast.test.js +73 -0
  59. package/test/transfer-apikey.test.js +96 -0
  60. package/test/transfer-workflow.test.js +122 -0
  61. package/test/transfer.test.js +314 -0
  62. package/test/webhook.test.js +332 -0
  63. package/test.js +49 -0
@@ -0,0 +1,96 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const Path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ function createConfigFixture(t, initial) {
9
+ // Give each CLI process a disposable home directory. Config commands exit
10
+ // before contacting xyOps, so these checks need no development server.
11
+ const home_dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-config-'));
12
+ t.after(() => fs.rmSync(home_dir, { recursive: true, force: true }));
13
+ const file = Path.join(home_dir, '.config', 'xyops', 'cli.json');
14
+
15
+ if (initial) {
16
+ fs.mkdirSync(Path.dirname(file), { recursive: true });
17
+ fs.writeFileSync(file, JSON.stringify(initial, null, '\t') + '\n');
18
+ }
19
+
20
+ return {
21
+ file,
22
+ run(args) {
23
+ const result = spawnSync(process.execPath, [Path.join(__dirname, '..', 'index.js'), 'config', ...args], {
24
+ encoding: 'utf8',
25
+ timeout: 10000,
26
+ env: {
27
+ ...process.env,
28
+ HOME: home_dir,
29
+ XYOPS_API_KEY: 'fixture-key',
30
+ XYOPS_BASE_URL: 'https://fixture.invalid',
31
+ XYOPS_ITEMS_PER_PAGE: '99',
32
+ XYOPS_COLOR: 'false'
33
+ }
34
+ });
35
+
36
+ // Keep captured CLI output out of assertion messages, since a config
37
+ // command may display settings inherited from the real system file.
38
+ assert.ifError(result.error);
39
+ assert.equal(result.status, 0, 'Config command should succeed');
40
+ return result.stdout;
41
+ },
42
+ read() { return JSON.parse(fs.readFileSync(file, 'utf8')); }
43
+ };
44
+ }
45
+
46
+ test('config updates only requested user settings without saving inherited overrides', t => {
47
+ const fixture = createConfigFixture(t, {
48
+ items_per_page: 10,
49
+ base_url: 'https://saved.invalid',
50
+ suggest: true,
51
+ sync: { up: ['events'], down: false }
52
+ });
53
+ fixture.run(['--items_per_page', '25']);
54
+
55
+ // The environment deliberately conflicts with saved values. Saving one
56
+ // preference must preserve the original user layer and exclude inherited
57
+ // credentials, display settings, and any machine-wide configuration.
58
+ assert.deepEqual(fixture.read(), {
59
+ items_per_page: 25,
60
+ base_url: 'https://saved.invalid',
61
+ suggest: true,
62
+ sync: { up: ['events'], down: false }
63
+ });
64
+ assert.equal(fs.statSync(fixture.file).mode & 0o777, 0o600);
65
+ });
66
+
67
+ test('config creates a missing user file with only supplied settings', t => {
68
+ const fixture = createConfigFixture(t);
69
+ fixture.run(['--items_per_page', '25']);
70
+
71
+ assert.deepEqual(fixture.read(), { items_per_page: 25 });
72
+ assert.equal(fs.statSync(fixture.file).mode & 0o777, 0o600);
73
+ });
74
+
75
+ test('config dotted updates preserve sibling user settings and explicit false or zero values', t => {
76
+ const fixture = createConfigFixture(t, {
77
+ sync: { up: ['events'], down: ['plugins'], error_email: 'ops@example.com' },
78
+ suggest: true
79
+ });
80
+ fixture.run(['--sync.down', 'false', '--sync.cmd_timeout', '0', '--suggest', 'false']);
81
+
82
+ assert.deepEqual(fixture.read(), {
83
+ sync: { up: ['events'], down: false, error_email: 'ops@example.com', cmd_timeout: 0 },
84
+ suggest: false
85
+ });
86
+ });
87
+
88
+ test('config display shows effective environment overrides without writing the user file', t => {
89
+ const fixture = createConfigFixture(t, { items_per_page: 10 });
90
+ const original = fs.readFileSync(fixture.file, 'utf8');
91
+ const output = fixture.run([]);
92
+
93
+ assert.match(output, /"items_per_page": 99/);
94
+ assert.doesNotMatch(output, /fixture-key/);
95
+ assert.equal(fs.readFileSync(fixture.file, 'utf8'), original);
96
+ });
@@ -0,0 +1,84 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const doc = require('../lib/doc.js');
4
+ const utils = require('../lib/utils.js');
5
+ const { loadTestConfig, xy } = require('./helpers/common.js');
6
+
7
+ function createDocContext() {
8
+ return {
9
+ ...utils,
10
+ ...doc,
11
+ die(message) { throw new Error(message); }
12
+ };
13
+ }
14
+
15
+ test('documentation chapter extraction includes descendants and ignores code fences', () => {
16
+ const context = createDocContext();
17
+ const markdown = [
18
+ '# Example',
19
+ '## Selected Chapter',
20
+ 'Chapter introduction.',
21
+ '### Child Section',
22
+ 'Child text.',
23
+ '```md',
24
+ '## Not A Real Heading',
25
+ '```',
26
+ '#### Grandchild Section',
27
+ 'Grandchild text.',
28
+ '## Next Chapter',
29
+ 'Excluded text.'
30
+ ].join('\n');
31
+ const selected = context.extractDocChapter(markdown, 'example', 'selected-chapter');
32
+
33
+ assert.match(selected, /^## Selected Chapter/);
34
+ assert.match(selected, /### Child Section/);
35
+ assert.match(selected, /## Not A Real Heading/);
36
+ assert.match(selected, /#### Grandchild Section/);
37
+ assert.doesNotMatch(selected, /## Next Chapter|Excluded text/);
38
+ });
39
+
40
+ test('internal documentation links become copyable doc commands', () => {
41
+ const context = createDocContext();
42
+ const markdown = [
43
+ '[Plugins](plugins.md), [Output](#output-data), and [Website](https://example.com).',
44
+ '```md',
45
+ '[Example Link](example.md)',
46
+ '```'
47
+ ].join('\n');
48
+ const prepared = context.prepareDocMarkdown(markdown, 'events');
49
+
50
+ assert.match(prepared, /Plugins \(`xy doc plugins`\)/);
51
+ assert.match(prepared, /Output \(`xy doc events\/output-data`\)/);
52
+ assert.match(prepared, /\[Website\]\(https:\/\/example\.com\)/);
53
+ assert.match(prepared, /\[Example Link\]\(example\.md\)/);
54
+ });
55
+
56
+ test('doc command renders the index, full documents, and slash or space chapters', () => {
57
+ loadTestConfig();
58
+ const index = xy(['doc']);
59
+ const full = xy(['doc', 'plugins']);
60
+ const slash = xy(['doc', 'plugins/output-data']);
61
+ const space = xy(['doc', 'plugins', 'output-data']);
62
+
63
+ assert.match(index, /Documentation Index/i);
64
+ assert.match(index, /xy doc plugins/);
65
+ assert.match(full, /Plugins/i);
66
+ assert.match(full, /Plugin Types/i);
67
+ assert.match(slash, /Output Data/i);
68
+ assert.match(slash, /arbitrary data output/i);
69
+ assert.doesNotMatch(slash, /Output Files/i);
70
+ assert.match(space, /Output Data/i);
71
+ assert.match(space, /arbitrary data output/i);
72
+ });
73
+
74
+ test('doc command rejects unsafe names, missing chapters, and search', () => {
75
+ loadTestConfig();
76
+ assert.match(xy(['doc', '../plugins'], { fail: true }), /Invalid documentation name/);
77
+ assert.match(xy(['doc', 'plugins/not-a-real-chapter'], { fail: true }), /Could not find documentation chapter/);
78
+ assert.match(xy(['doc', 'search'], { fail: true }), /Documentation search is not supported/);
79
+ });
80
+
81
+ test('doc help chapter renders', () => {
82
+ loadTestConfig();
83
+ assert.match(xy(['help', 'doc']), /HELP: DOC/);
84
+ });
@@ -0,0 +1,211 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { loadTestConfig, createCheck, xy, json, call, cleanupFixtures } = require('./helpers/common.js');
4
+
5
+ test('groups', async t => {
6
+ loadTestConfig();
7
+ const check = createCheck(t);
8
+ const id = 'cli_group_test_' + Date.now();
9
+ const title = 'CLI Group Test ' + id;
10
+ const groups = new Set([id]);
11
+
12
+ try {
13
+ const action = {
14
+ type: 'web_hook',
15
+ enabled: true,
16
+ condition: 'alert_new',
17
+ web_hook: 'example_hook',
18
+ text: ''
19
+ };
20
+ let preview = json(['group', 'create', '--id', id, '--title', title, '--max_jobs_per_server', '2', '--action', JSON.stringify(action), '--dry']);
21
+
22
+ await check('dry create applies defaults and action syntax', () => {
23
+ assert.equal(preview.id, id);
24
+ assert.equal(preview.hostname_match, '(?!)');
25
+ assert.equal(preview.max_jobs_per_server, 2);
26
+ assert.deepEqual(preview.alert_actions, [action]);
27
+ });
28
+
29
+ await check('dry create makes no Group', () => assert.equal(json(['groups', '--id', id]).length, 0));
30
+
31
+ const created = json([
32
+ 'group', 'create', '--id', id, '--title', title,
33
+ '--hostname_match', '^cli-group-no-server$', '--icon', 'mdi-server-network',
34
+ '--notes', 'Hello Group', '--max_jobs_per_server', '3', '--action', JSON.stringify(action)
35
+ ]);
36
+
37
+ await check('create persists the complete Group definition', () => {
38
+ assert.equal(created.id, id);
39
+ assert.equal(created.title, title);
40
+ assert.equal(created.hostname_match, '^cli-group-no-server$');
41
+ assert.equal(created.icon, 'server-network');
42
+ assert.equal(created.notes, 'Hello Group');
43
+ assert.equal(created.max_jobs_per_server, 3);
44
+ assert.deepEqual(created.alert_actions, [action]);
45
+ });
46
+
47
+ await check('list supports positional and named filters', () => {
48
+ assert.equal(json(['groups', title]).length, 1);
49
+ assert.equal(json(['groups', '--hostname_match', 'no-server']).length, 1);
50
+ assert.equal(json(['group', 'list', '--id', id]).length, 1);
51
+ });
52
+
53
+ await check('detail accepts an exact ID and fuzzy title', () => {
54
+ assert.equal(json(['group', id]).group.id, id);
55
+ assert.equal(json(['group', 'get', title.toLowerCase()]).group.id, id);
56
+ });
57
+
58
+ await check('human detail renders the stable sections and expansion hints', () => {
59
+ const output = xy(['group', id]);
60
+ assert.match(output, /LIVE GROUP VIEW - Real-time/);
61
+ assert.match(output, /GROUP SUMMARY/);
62
+ assert.match(output, /GROUP NOTES[\s\S]*Hello Group/);
63
+ assert.match(output, /GROUP SERVERS/);
64
+ assert.match(output, /GROUP JOBS/);
65
+ assert.match(output, /GROUP MONITORS\n \(Shown in verbose mode, or add --monitors\.\)/);
66
+ assert.match(output, /GROUP PROCESSES\n \(Shown in verbose mode, or add --processes\.\)/);
67
+ assert.match(output, /GROUP NETWORK CONNECTIONS\n \(Shown in verbose mode, or add --connections\.\)/);
68
+ });
69
+
70
+ json(['group', 'update', id, '--title', title + ' Updated', '--hostname_match', '', '--alert_actions.0.enabled', 'false', '--max_jobs_per_server', '4']);
71
+ let updated = (await call('getGroup', { id })).group;
72
+
73
+ await check('update preserves untouched fields and supports dotted actions', () => {
74
+ assert.equal(updated.title, title + ' Updated');
75
+ assert.equal(updated.hostname_match, '(?!)');
76
+ assert.equal(updated.max_jobs_per_server, 4);
77
+ assert.equal(updated.alert_actions[0].enabled, false);
78
+ assert.equal(updated.notes, 'Hello Group');
79
+ });
80
+
81
+ json(['group', 'update', id, '--action', JSON.stringify({ ...action, enabled: false })]);
82
+ updated = (await call('getGroup', { id })).group;
83
+
84
+ await check('singular action appends to the saved array', () => assert.equal(updated.alert_actions.length, 2));
85
+
86
+ for (const args of [
87
+ ['update', id, '--alert_actions.99.enabled', 'true'],
88
+ ['update', id, '--alert_actions.0.__proto__.polluted', 'true'],
89
+ ['update', id, '--hostname_match', '['],
90
+ ['update', id, '--max_jobs_per_server', '-1'],
91
+ ['update', id, '--alert_actions', '{}'],
92
+ ['update', id, '--unknown', 'value'],
93
+ ['update', title, '--notes', 'Wrong target'],
94
+ ['update', id],
95
+ ['delete', title, '--confirm']
96
+ ]) {
97
+ await check('reject ' + args.slice(0, 3).join(' '), () => xy(['group', ...args], { fail: true }));
98
+ }
99
+
100
+ await check('unconfirmed deletion shows the selected Group', () => {
101
+ const output = xy(['group', 'delete', id]);
102
+ assert.match(output, /DELETE SERVER GROUP/);
103
+ assert.match(output, new RegExp('Group ID:\\s+' + id));
104
+ assert.match(output, /Please confirm the server group delete/);
105
+ });
106
+
107
+ json(['group', 'delete', id, '--confirm', '--dry']);
108
+ await check('dry deletion preserves the Group', async () => assert.equal((await call('getGroup', { id })).group.id, id));
109
+
110
+ json(['group', 'delete', id, '--confirm']);
111
+ groups.delete(id);
112
+ await check('confirmed deletion removes the Group', () => assert.equal(json(['groups', '--id', id]).length, 0));
113
+
114
+ // The built-in main Group exercises the combined monitoring views without
115
+ // mutating any Server assignments or monitoring records.
116
+ const multiple = await call('getMultiple', {
117
+ lists: ['groups', 'monitors'],
118
+ jobs: 1,
119
+ alerts: 1,
120
+ servers: 1,
121
+ serverCache: 1
122
+ });
123
+ const group = multiple.groups.find(item => item.id == 'main') || multiple.groups.find(item => {
124
+ return Object.values(multiple.servers || {}).some(server => (server.groups || []).includes(item.id));
125
+ });
126
+ assert.ok(group, 'Group tests require a Group with connected Servers');
127
+ const active = Object.values(multiple.servers || {}).filter(server => (server.groups || []).includes(group.id));
128
+ assert.ok(active.length, 'Group tests require connected Group members');
129
+
130
+ await check('live view loads one snapshot and timeline per selected Server', () => {
131
+ const result = json(['group', group.id]);
132
+ assert.equal(result.group.id, group.id);
133
+ assert.equal(result.merge, 'average');
134
+ assert.equal(result.snapshots.length, result.servers.length);
135
+ assert.ok(result.snapshots.length >= active.length);
136
+ assert.ok(result.snapshots.every(item => item.data && item.data.memory && Array.isArray(item.rows)));
137
+ });
138
+
139
+ await check('live view applies Server filters before loading and normalizes merge aliases', () => {
140
+ const server = active[0];
141
+ const ip = String((server.info && server.info.ip) || server.ip || '');
142
+ const partial = ip.slice(0, Math.max(1, Math.floor(ip.length / 2)));
143
+ const result = json(['group', group.id, '--ip', partial, '--merge', 'max']);
144
+ assert.equal(result.merge, 'maximum');
145
+ assert.ok(result.servers.length);
146
+ assert.ok(result.servers.every(item => String((item.info && item.info.ip) || item.ip || '').includes(partial)));
147
+ assert.equal(result.snapshots.length, result.servers.length);
148
+ });
149
+
150
+ await check('live human view renders merged charts, detail tables, and opt-in sections', () => {
151
+ const output = xy(['group', group.id, '--monitors', '--processes', '--connections', '--merge', 'avg', '--limit', '1']);
152
+ assert.match(output, /GROUP SERVERS/);
153
+ assert.match(output, /GROUP QUICK LOOK - LAST MINUTE/);
154
+ assert.match(output, /Average CPU Load/);
155
+ assert.match(output, /AVERAGE GROUP MEMORY DETAILS/);
156
+ assert.match(output, /AVERAGE GROUP CPU DETAILS/);
157
+ assert.match(output, /GROUP MONITORS - LAST HOUR/);
158
+ assert.match(output, /GROUP PROCESSES/);
159
+ assert.match(output, /GROUP NETWORK CONNECTIONS/);
160
+ assert.doesNotMatch(output, /Shown in verbose mode/);
161
+ });
162
+
163
+ await check('live view rejects invalid merge modes and misspelled options', () => {
164
+ assert.match(xy(['group', group.id, '--merge', 'median'], { fail: true }), /Group --merge must be one of/);
165
+ const option = xy(['group', group.id, '--proceses'], { fail: true });
166
+ assert.match(option, /Unsupported group view option: "--proceses"/);
167
+ assert.match(option, /Did you mean "--processes"\?/);
168
+ });
169
+
170
+ const latest = await call('getLatestMonitorData', { server: active[0].id, sys: 'hourly', limit: 1 });
171
+ assert.ok(latest.rows && latest.rows.length, 'Group history tests require recent monitoring data');
172
+ const history_date = new Date(latest.rows[latest.rows.length - 1].date * 1000);
173
+ const pad = number => String(number).padStart(2, '0');
174
+ const history_spec = history_date.getFullYear() + '/' + pad(history_date.getMonth() + 1) + '/' + pad(history_date.getDate()) + '/' + pad(history_date.getHours());
175
+
176
+ await check('historical view reconstructs membership and loads every Monitor timeline', () => {
177
+ const result = json(['group', 'history', group.id, history_spec, '--merge', 'total', '--limit', '1']);
178
+ assert.equal(result.group.id, group.id);
179
+ assert.equal(result.merge, 'total');
180
+ assert.equal(result.range.mode, 'hourly');
181
+ assert.ok(result.servers.length >= active.length);
182
+ assert.equal(result.monitors.length, result.servers.length);
183
+ assert.ok(result.monitors.every(item => Array.isArray(item.rows)));
184
+ assert.ok(Array.isArray(result.alerts.rows));
185
+ assert.ok(Array.isArray(result.jobs.rows));
186
+ assert.ok(result.alerts.rows.length <= 1);
187
+ assert.ok(result.jobs.rows.length <= 1);
188
+ });
189
+
190
+ await check('historical human view renders all required sections in order', () => {
191
+ const output = xy(['group', 'history', group.id, history_spec, '--merge', 'min', '--limit', '1']);
192
+ assert.match(output, /HOURLY GROUP HISTORY/);
193
+ assert.ok(output.indexOf('GROUP SUMMARY') < output.indexOf('GROUP SERVERS'));
194
+ assert.ok(output.indexOf('GROUP SERVERS') < output.indexOf('GROUP MONITORS'));
195
+ assert.ok(output.indexOf('GROUP MONITORS') < output.indexOf('GROUP ALERTS'));
196
+ assert.ok(output.indexOf('GROUP ALERTS') < output.indexOf('GROUP JOBS'));
197
+ assert.match(output, /Minimum CPU Load/);
198
+ });
199
+
200
+ await check('Group help chapters render', () => {
201
+ for (const section of ['groups', 'group', 'group list', 'group get', 'group history', 'group create', 'group update', 'group delete']) {
202
+ assert.match(xy(['help', ...section.split(' ')]), new RegExp('HELP: ' + section.toUpperCase()));
203
+ }
204
+ });
205
+ }
206
+ finally {
207
+ await cleanupFixtures([
208
+ { list: 'groups', method: 'deleteGroup', match: item => groups.has(item.id) || item.title.startsWith(title) }
209
+ ]);
210
+ }
211
+ });
@@ -0,0 +1,26 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { xy } = require('./helpers/common.js');
4
+
5
+ test('help suggests the closest chapter for a typo', () => {
6
+ const output = xy(['help', 'event', 'udpate'], { fail: true });
7
+ assert.match(output, /Could not find help chapter for "event udpate"\. Did you mean "event update"\?/);
8
+ });
9
+
10
+ test('help does not suggest an unrelated chapter', () => {
11
+ const output = xy(['help', 'completely', 'unrelated', 'words'], { fail: true });
12
+ assert.match(output, /Could not find help chapter for "completely unrelated words"\./);
13
+ assert.doesNotMatch(output, /Did you mean/);
14
+ });
15
+
16
+ test('unknown top-level command suggests the closest command', () => {
17
+ const output = xy(['evnets'], { fail: true });
18
+ assert.match(output, /Unknown command: evnets\. Did you mean "events"\?/);
19
+ assert.match(output, /Available Commands:.*events/);
20
+ });
21
+
22
+ test('unknown top-level command does not suggest an unrelated command', () => {
23
+ const output = xy(['completely_unrelated'], { fail: true });
24
+ assert.match(output, /Unknown command: completely_unrelated/);
25
+ assert.doesNotMatch(output, /Did you mean/);
26
+ });
@@ -0,0 +1,127 @@
1
+ // Shared setup for the live CLI integration tests. No third-party test framework.
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const Path = require('node:path');
6
+ const cp = require('node:child_process');
7
+ const root = Path.resolve(__dirname, '../..');
8
+
9
+ function loadTestConfig() {
10
+ // Use the same config files and environment overrides as the CLI. Every
11
+ // suite checks the destination, including when run directly with node --test.
12
+ const app = {};
13
+ require('../../lib/config.js').loadConfig.call(app);
14
+
15
+ assert.ok(/^http:\/\/localhost:5522\/?$/.test(app.config.base_url || ''),
16
+ 'Tests require XYOPS_BASE_URL=http://localhost:5522 (or the equivalent CLI config).');
17
+ assert.ok(app.config.api_key, 'Tests require an administrator API key in the CLI config or XYOPS_API_KEY.');
18
+
19
+ return app.config;
20
+ }
21
+
22
+ function createCheck(t) {
23
+ // Each check gets its own native test result. Lifecycle steps depend on
24
+ // earlier steps, so stop this suite on failure and let its cleanup run.
25
+ return async function check(name, callback) {
26
+ let passed = false;
27
+
28
+ await t.test(name, async () => {
29
+ await callback();
30
+ passed = true;
31
+ });
32
+
33
+ if (!passed) throw new Error('Lifecycle stopped after failed check: ' + name);
34
+ };
35
+ }
36
+
37
+ function createTempDir(t, prefix) {
38
+ // Register immediately so local files are removed even if setup or server
39
+ // cleanup fails. Each suite has a private directory outside the repository.
40
+ const dir = fs.mkdtempSync(Path.join(os.tmpdir(), prefix));
41
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
42
+
43
+ return dir;
44
+ }
45
+
46
+ function runCLI(args, options = {}) {
47
+ // These tests deliberately exercise the public executable in fresh processes.
48
+ // Captured output can contain API secrets, so keep it out of failure reports.
49
+ const result = cp.spawnSync(process.execPath, [
50
+ ...(options.preload ? ['--require', options.preload] : []),
51
+ Path.join(root, 'index.js'),
52
+ ...args
53
+ ], {
54
+ // Most suites run from the project root. Filesystem-oriented commands can
55
+ // override this with a private temporary directory while still invoking the
56
+ // real CLI executable by its absolute path.
57
+ cwd: options.cwd || root,
58
+ encoding: 'utf8',
59
+ timeout: 30000,
60
+ maxBuffer: 16 * 1024 * 1024,
61
+ input: options.input,
62
+ env: {
63
+ ...process.env,
64
+ XYOPS_COLOR: 'false',
65
+ XYOPS_SUGGEST: 'true',
66
+ XYOPS_DEBUG: 'false',
67
+ XYOPS_VERBOSE: 'false',
68
+ ...options.env
69
+ }
70
+ });
71
+
72
+ assert.ok(!result.error && result.signal === null, 'CLI process completed: ' + args[0]);
73
+ assert.equal(result.status === 0, !options.fail, 'Unexpected CLI exit status: ' + args.slice(0, 2).join(' '));
74
+
75
+ return result.stdout + result.stderr;
76
+ }
77
+
78
+ function xy(args, options = {}) {
79
+ // Accept the boolean failure shorthand used by the export and log suites.
80
+ return runCLI(args, typeof options === 'boolean' ? { fail: options } : options);
81
+ }
82
+
83
+ function json(args, options) {
84
+ const output = xy([...args, '--format', 'jsonc'], options);
85
+ const line = output.split('\n').find(line => /^[\[{]/.test(line));
86
+ assert.ok(line, 'CLI returned JSON: ' + args[0]);
87
+
88
+ return JSON.parse(line);
89
+ }
90
+
91
+ async function call(method, params) {
92
+ const { api } = require('@pixlcore/xyops-sdk');
93
+ const result = await api[method](params);
94
+ assert.ok(!result.err && result.data && result.data.code === 0, 'API request succeeded: ' + method);
95
+
96
+ return result.data;
97
+ }
98
+
99
+ async function cleanupFixtures(resources) {
100
+ // Match only this run's unique IDs/titles. Continue deleting other fixtures
101
+ // if one deletion fails, then report cleanup failures without response data.
102
+ const data = await call('getMultiple', { lists: 'all' });
103
+ const errors = [];
104
+
105
+ for (const resource of resources) {
106
+ const objects = data[resource.list].filter(resource.match);
107
+ if (resource.sort) objects.sort(resource.sort);
108
+
109
+ for (const object of objects) {
110
+ try { await call(resource.method, { id: object.id }); }
111
+ catch (error) { errors.push(error); }
112
+ }
113
+ }
114
+
115
+ // Verify deletion even when an API returned success. Only names of resource
116
+ // lists appear in failures; never dump complete definitions or credentials.
117
+ const remaining = await call('getMultiple', { lists: 'all' });
118
+ for (const resource of resources) {
119
+ if (remaining[resource.list].some(resource.match)) {
120
+ errors.push(new Error('Disposable fixtures remain in ' + resource.list));
121
+ }
122
+ }
123
+
124
+ if (errors.length) throw new AggregateError(errors, 'Fixture cleanup failed. Check the local server before rerunning.');
125
+ }
126
+
127
+ module.exports = { loadTestConfig, createCheck, createTempDir, runCLI, xy, json, call, cleanupFixtures };
@@ -0,0 +1,18 @@
1
+ // Record pagination at the SDK boundary without logging authentication or data.
2
+ const fs = require('node:fs');
3
+ const sdk = require('@pixlcore/xyops-sdk');
4
+ const original = sdk.api;
5
+
6
+ sdk.api = new Proxy({}, {
7
+ get(target, method) {
8
+ return async function(params, ...rest) {
9
+ const trace = { method };
10
+ for (const key of ['query', 'offset', 'limit', 'page']) {
11
+ if (params && (key in params)) trace[key] = params[key];
12
+ }
13
+
14
+ fs.appendFileSync(process.env.XYCLI_PAGINATION_TRACE, JSON.stringify(trace) + '\n');
15
+ return original[method](params, ...rest);
16
+ };
17
+ }
18
+ });
@@ -0,0 +1,103 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { loadTestConfig, createCheck, xy, json } = require('./helpers/common.js');
4
+
5
+ test('log', async t => {
6
+ loadTestConfig();
7
+ const check = createCheck(t);
8
+
9
+ // Read-only integration checks for the system log search CLI.
10
+
11
+ await check('default request selects xyOps and 100 rows', () => assert.deepEqual(json(['log', '--dry']), { log: 'xyOps', rows: 100 }));
12
+
13
+ await check('positional name and explicit row limit', () => assert.deepEqual(json(['log', 'xyOps', '--rows', '1000', '--dry']), { log: 'xyOps', rows: 1000 }));
14
+
15
+ await check('named log selector', () => assert.deepEqual(json(['log', '--log', 'xyOps', '--rows', '1', '--dry']), { log: 'xyOps', rows: 1 }));
16
+
17
+ await check('numeric match becomes text', () => assert.equal(json(['log', '--match', '123', '--dry']).match, '123'));
18
+
19
+ await check('boolean flags serialize correctly', () => {
20
+ const req = json(['log', '--match', 'error', '--regex', 'false', '--case', 'true', '--dry']);
21
+ assert.equal(req.regex, false);
22
+ assert.equal(req.case, true);
23
+ });
24
+
25
+ await check('display sort stays out of API request', () => assert.deepEqual(json(['log', '--sort', 'date_desc', '--dry']), { log: 'xyOps', rows: 100 }));
26
+
27
+ await check('default live search returns at most 100 rows', () => {
28
+ const rows = json(['log']);
29
+ assert.ok(rows.length > 0 && rows.length <= 100);
30
+ for (const key of ['hires_epoch', 'category', 'code', 'msg', 'data']) assert.ok(key in rows[0]);
31
+ });
32
+
33
+ await check('explicit limit returns latest subset', () => assert.equal(json(['log', 'xyOps', '--rows', '3']).length, 3));
34
+
35
+ await check('ascending file order', () => {
36
+ const rows = json(['log', '--rows', '5', '--cols', 'hires_epoch']);
37
+ assert.ok(rows.every((row, idx) => !idx || row.hires_epoch >= rows[idx - 1].hires_epoch));
38
+ });
39
+
40
+ await check('descending display order', () => {
41
+ const rows = json(['log', '--rows', '5', '--cols', 'hires_epoch', '--sort', 'date_desc']);
42
+ assert.ok(rows.every((row, idx) => !idx || row.hires_epoch <= rows[idx - 1].hires_epoch));
43
+ });
44
+
45
+ await check('CSV columns project JSON output', () => {
46
+ const rows = json(['log', '--rows', '2', '--cols', 'category,code']);
47
+ assert.equal(rows.length, 2);
48
+ assert.deepEqual(Object.keys(rows[0]), ['category', 'code']);
49
+ });
50
+
51
+ await check('JSON column array', () => {
52
+ const rows = json(['log', '--rows', '1', '--cols', '["msg","data"]']);
53
+ assert.deepEqual(Object.keys(rows[0]), ['msg', 'data']);
54
+ assert.equal(typeof(rows[0].data), 'string');
55
+ });
56
+
57
+ await check('matching searches fields omitted from output', () => {
58
+ const rows = json(['log', '--rows', '2', '--match', 'debug', '--cols', 'hires_epoch']);
59
+ assert.ok(rows.length);
60
+ assert.deepEqual(Object.keys(rows[0]), ['hires_epoch']);
61
+ });
62
+
63
+ await check('case insensitive regex match', () => {
64
+ const rows = json(['log', '--rows', '2', '--match', '\\]\\[DeBuG\\]\\[', '--regex', '--cols', 'category']);
65
+ assert.ok(rows.length);
66
+ assert.ok(rows.every(row => row.category === 'debug'));
67
+ });
68
+
69
+ await check('case sensitive regex match', () => assert.deepEqual(json(['log', '--match', '\\]\\[DeBuG\\]\\[', '--regex', '--case']), []));
70
+
71
+ await check('literal matching escapes regex syntax', () => assert.deepEqual(json(['log', '--match', '^.*$']), []));
72
+
73
+ await check('regex matching supports syntax', () => assert.equal(json(['log', '--rows', '2', '--match', '^.*$', '--regex']).length, 2));
74
+
75
+ await check('missing archive returns an empty array', () => assert.deepEqual(json(['log', '--date', '1900-01-01']), []));
76
+
77
+ await check('missing log returns an empty array', () => assert.deepEqual(json(['log', 'xycli_nonexistent_log_test']), []));
78
+
79
+ await check('native log lines, total line count, and suggestions', () => {
80
+ const output = xy(['log', '--rows', '2', '--cols', 'category,code']);
81
+ assert.match(output, /2 rows returned, [\d,]+ total log rows/);
82
+ assert.match(output, /Other Commands:/);
83
+ assert.ok(!output.includes('page 2'));
84
+ assert.equal(output.split('\n').filter(line => /^\[[^\]]*\]\[[^\]]*\]$/.test(line)).length, 2);
85
+ });
86
+
87
+ await check('empty human output', () => assert.match(xy(['log', '--date', '1900-01-01']), /0 rows returned/));
88
+
89
+ for (const args of [
90
+ ['--rows', '0'], ['--rows', '1001'], ['--rows', '1.5'], ['--rows', 'nope'],
91
+ ['--match', '(', '--regex'], ['--regex', 'maybe'], ['--case', 'maybe'],
92
+ ['--cols', 'bad_column'], ['--cols', '[]'], ['--cols', '{}'],
93
+ ['--date', '2026-02-30'], ['--date', 'yesterday'], ['--date', '2026-13-01'],
94
+ ['--sort', 'wrong'], ['--limit', '5'], ['--offset', '1'], ['--page', '2'],
95
+ ['../xyOps'], ['xyOps.log'], ['xyOps', 'extra'], ['xyOps', '--log', 'other'], ['--match', '{}']
96
+ ]) await check('reject ' + args.join(' '), () => xy(['log', ...args], true));
97
+
98
+ await check('help documents default of 100 rows', () => {
99
+ const output = xy(['help', 'log']);
100
+ assert.match(output, /defaults to 100/);
101
+ assert.match(output, /--rows/);
102
+ });
103
+ });