@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.
- package/README.md +543 -0
- package/docs/help.md +2587 -0
- package/docs/screenshots.md +219 -0
- package/docs/sync.md +920 -0
- package/docs/tutorial.md +204 -0
- package/index.js +444 -0
- package/lib/alerts.js +735 -0
- package/lib/apikey.js +425 -0
- package/lib/buckets.js +607 -0
- package/lib/categories.js +323 -0
- package/lib/channels.js +290 -0
- package/lib/config.js +86 -0
- package/lib/dashboard.js +237 -0
- package/lib/doc.js +131 -0
- package/lib/events.js +1314 -0
- package/lib/groups.js +1038 -0
- package/lib/jobs.js +1254 -0
- package/lib/log.js +125 -0
- package/lib/marketplace.js +386 -0
- package/lib/monitors.js +412 -0
- package/lib/plugins.js +491 -0
- package/lib/secrets.js +441 -0
- package/lib/servers.js +1502 -0
- package/lib/snapshots.js +498 -0
- package/lib/sync.js +963 -0
- package/lib/system.js +612 -0
- package/lib/tags.js +250 -0
- package/lib/tickets.js +681 -0
- package/lib/transfer.js +447 -0
- package/lib/utils.js +2745 -0
- package/lib/webhook.js +414 -0
- package/logo.webp +0 -0
- package/package.json +38 -0
- package/test/README.md +97 -0
- package/test/api-validation.test.js +170 -0
- package/test/categories.test.js +246 -0
- package/test/channels.test.js +209 -0
- package/test/config.test.js +96 -0
- package/test/doc.test.js +84 -0
- package/test/groups.test.js +211 -0
- package/test/help.test.js +26 -0
- package/test/helpers/common.js +127 -0
- package/test/helpers/pagination-preload.cjs +18 -0
- package/test/log.test.js +103 -0
- package/test/markdown.test.js +61 -0
- package/test/marketplace.test.js +229 -0
- package/test/monitors.test.js +227 -0
- package/test/pagination.test.js +138 -0
- package/test/plugins.test.js +351 -0
- package/test/secrets.test.js +271 -0
- package/test/servers.test.js +303 -0
- package/test/snapshots.test.js +167 -0
- package/test/sync-unit.test.js +446 -0
- package/test/sync.test.js +542 -0
- package/test/system.test.js +94 -0
- package/test/tags.test.js +230 -0
- package/test/tickets.test.js +261 -0
- package/test/toast.test.js +73 -0
- package/test/transfer-apikey.test.js +96 -0
- package/test/transfer-workflow.test.js +122 -0
- package/test/transfer.test.js +314 -0
- package/test/webhook.test.js +332 -0
- package/test.js +49 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const cli = require('pixl-cli');
|
|
4
|
+
const utils = require('../lib/utils.js');
|
|
5
|
+
|
|
6
|
+
test('markdown renders inline formatting inside list items', () => {
|
|
7
|
+
const output = utils.markdown([
|
|
8
|
+
'- **Node.js** and `npx`',
|
|
9
|
+
'\t- Nested **bold** and `code`'
|
|
10
|
+
].join('\n'));
|
|
11
|
+
const plain_text = cli.Tools.stripANSI(output);
|
|
12
|
+
|
|
13
|
+
// Test the visible terminal text, regardless of whether the test runner was
|
|
14
|
+
// launched from a color-capable terminal. The important regression check is
|
|
15
|
+
// that Markdown delimiters do not leak through from either list item.
|
|
16
|
+
assert.match(plain_text, /• Node\.js and npx/);
|
|
17
|
+
assert.match(plain_text, /• Nested bold and code/);
|
|
18
|
+
assert.doesNotMatch(plain_text, /\*\*|`/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('markdown uses typographic bullets without changing ordered lists', () => {
|
|
22
|
+
const output = utils.markdown([
|
|
23
|
+
'- First item',
|
|
24
|
+
'- Second item',
|
|
25
|
+
'',
|
|
26
|
+
'1. First numbered item',
|
|
27
|
+
'2. Second numbered item'
|
|
28
|
+
].join('\n'));
|
|
29
|
+
const plain_text = cli.Tools.stripANSI(output);
|
|
30
|
+
|
|
31
|
+
assert.match(plain_text, /• First item/);
|
|
32
|
+
assert.match(plain_text, /• Second item/);
|
|
33
|
+
assert.match(plain_text, /1\. First numbered item/);
|
|
34
|
+
assert.match(plain_text, /2\. Second numbered item/);
|
|
35
|
+
assert.doesNotMatch(plain_text, /^\s*\* /m);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('markdown indents list levels by two spaces', () => {
|
|
39
|
+
const output = utils.markdown([
|
|
40
|
+
'- Parent item',
|
|
41
|
+
' - Nested item'
|
|
42
|
+
].join('\n'));
|
|
43
|
+
const plain_text = cli.Tools.stripANSI(output);
|
|
44
|
+
|
|
45
|
+
// The first space is the global Markdown margin. Each list level then
|
|
46
|
+
// contributes the configured two-space marked-terminal indentation.
|
|
47
|
+
assert.match(plain_text, /^ {3}• Parent item$/m);
|
|
48
|
+
assert.match(plain_text, /^ {5}• Nested item$/m);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('markdown adds a left margin and reserves room for both sides', () => {
|
|
52
|
+
const output = utils.markdown('This is a deliberately long paragraph. '.repeat(12));
|
|
53
|
+
const terminalWidth = Math.min(process.stdout.columns || 80, 120);
|
|
54
|
+
|
|
55
|
+
// Every rendered line receives one visible leading space. marked-terminal
|
|
56
|
+
// reflows against a width reduced by two, leaving the opposite margin free.
|
|
57
|
+
output.split('\n').forEach( line => {
|
|
58
|
+
assert.match(cli.Tools.stripANSI(line), /^ /);
|
|
59
|
+
assert.ok(cli.stringWidth(line) <= terminalWidth - 1);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { loadTestConfig, createCheck, xy, json, call: apiCall } = require('./helpers/common.js');
|
|
4
|
+
|
|
5
|
+
test('marketplace', async t => {
|
|
6
|
+
loadTestConfig();
|
|
7
|
+
const check = createCheck(t);
|
|
8
|
+
const marketplaceID = 'pixlcore/xyplug-ai';
|
|
9
|
+
let original = [];
|
|
10
|
+
let cleanupAllowed = false;
|
|
11
|
+
let pluginID = '';
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
original = (await apiCall('getPlugins')).rows;
|
|
15
|
+
assert.ok(!original.some(plugin => plugin.marketplace && (plugin.marketplace.id == marketplaceID)),
|
|
16
|
+
marketplaceID + ' must be uninstalled before running this suite');
|
|
17
|
+
cleanupAllowed = true;
|
|
18
|
+
|
|
19
|
+
await check('search by positional text', () => {
|
|
20
|
+
const rows = json(['marketplace', 'AI Generation', '--limit', '5']);
|
|
21
|
+
assert.ok(Array.isArray(rows));
|
|
22
|
+
assert.ok(rows.some(product => product.id == marketplaceID));
|
|
23
|
+
assert.ok(rows.every(product => product.type == 'plugin'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await check('search filters and descending sort', () => {
|
|
27
|
+
const rows = json([
|
|
28
|
+
'marketplace', '--query', 'AI', '--plugin_type', 'event',
|
|
29
|
+
'--author', 'pixl core', '--license', 'mit', '--requires', 'npx',
|
|
30
|
+
'--tags', 'AI', '--status', 'not', '--sort_by', 'modified',
|
|
31
|
+
'--sort_dir', 'desc', '--limit', '10'
|
|
32
|
+
]);
|
|
33
|
+
assert.ok(rows.some(product => product.id == marketplaceID));
|
|
34
|
+
assert.ok(rows.every(product => product.plugin_type == 'event'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await check('repeated list filters require all values', () => {
|
|
38
|
+
const rows = json(['marketplace', '--tags', 'AI', '--tags', 'Prompt', '--requires', 'npx']);
|
|
39
|
+
assert.ok(rows.some(product => product.id == marketplaceID));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await check('human search pagination and suggestions', () => {
|
|
43
|
+
const out = xy(['marketplace', '--limit', '1']);
|
|
44
|
+
assert.match(out, /PLUGIN MARKETPLACE/);
|
|
45
|
+
assert.match(out, /page 1 of/i);
|
|
46
|
+
assert.match(out, /Other Commands:/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await check('README image cleanup preserves useful text and links', () => {
|
|
50
|
+
const clean = require('../lib/marketplace.js').cleanMarketplaceReadme([
|
|
51
|
+
'<p align="center"><img src="logo.png" alt="Logo"/></p>',
|
|
52
|
+
'<h1 align="center">Example Plugin</h1>',
|
|
53
|
+
'[](https://example.com/releases)',
|
|
54
|
+
'',
|
|
55
|
+
'\x1b[31mBody\x1b[0m'
|
|
56
|
+
].join('\n'));
|
|
57
|
+
assert.doesNotMatch(clean, /<img|!\[/i);
|
|
58
|
+
assert.doesNotMatch(clean, /\x1b/);
|
|
59
|
+
assert.match(clean, /^# Example Plugin/m);
|
|
60
|
+
assert.match(clean, /\[Version\]\(https:\/\/example\.com\/releases\)/);
|
|
61
|
+
assert.match(clean, /Body/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await check('JSON details include listing, version and original README', () => {
|
|
65
|
+
const detail = json(['marketplace', marketplaceID]);
|
|
66
|
+
assert.equal(detail.item.id, marketplaceID);
|
|
67
|
+
assert.equal(detail.item.type, 'plugin');
|
|
68
|
+
assert.equal(detail.item.plugin_type, 'event');
|
|
69
|
+
assert.equal(detail.version, detail.item.versions[0]);
|
|
70
|
+
assert.match(detail.text, /<img/i);
|
|
71
|
+
assert.match(detail.text, /Plugin Parameters/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await check('human details show summary and terminal-safe full README', () => {
|
|
75
|
+
const out = xy(['marketplace', 'get', marketplaceID]);
|
|
76
|
+
assert.match(out, /MARKETPLACE PLUGIN SUMMARY/);
|
|
77
|
+
assert.match(out, /PLUGIN README/);
|
|
78
|
+
assert.match(out, /AI Generation Plugin/);
|
|
79
|
+
assert.match(out, /Environment Variables/);
|
|
80
|
+
assert.match(out, /Not Installed/);
|
|
81
|
+
assert.doesNotMatch(out, /<img|!\[/i);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const preview = json(['marketplace', 'install', marketplaceID, '--version', 'v1.0.9']);
|
|
85
|
+
pluginID = preview.plugin.id;
|
|
86
|
+
|
|
87
|
+
await check('human install preview focuses on the Plugin definition', () => {
|
|
88
|
+
const out = xy(['marketplace', 'install', marketplaceID, '--version', 'v1.0.9']);
|
|
89
|
+
assert.match(out, /MARKETPLACE PLUGIN INSTALL PREVIEW/);
|
|
90
|
+
assert.match(out, /Plugin Name:\s+AI Generation/);
|
|
91
|
+
assert.match(out, /Author:\s+PixlCore/);
|
|
92
|
+
assert.match(out, /Version:\s+v1\.0\.9/);
|
|
93
|
+
assert.match(out, /This is the Plugin definition that will be created/);
|
|
94
|
+
assert.match(out, new RegExp('"id": "' + pluginID + '"'));
|
|
95
|
+
assert.match(out, /⚠️[\s\S]*Preview only\./);
|
|
96
|
+
assert.doesNotMatch(out, /"product":|"operation":|"preview": true|"warnings":/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await check('scripted preview extracts source without mutating Plugin data', () => {
|
|
100
|
+
const marketplace = require('../lib/marketplace.js');
|
|
101
|
+
const plugin = { id: 'sample', command: 'node', script: 'console.log("Hello");\n' };
|
|
102
|
+
const display = marketplace.prepareMarketplacePreviewPlugin(plugin);
|
|
103
|
+
assert.equal(display.script, plugin.script);
|
|
104
|
+
assert.ok(!('script' in display.plugin));
|
|
105
|
+
assert.equal(plugin.script, 'console.log("Hello");\n');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await check('scripted Marketplace Plugin shows source below preview JSON', () => {
|
|
109
|
+
const out = xy(['marketplace', 'install', 'pixlcore/xyplug-upgrade']);
|
|
110
|
+
const script_pos = out.indexOf('PLUGIN SCRIPT');
|
|
111
|
+
assert.ok(script_pos > 0);
|
|
112
|
+
assert.doesNotMatch(out.slice(0, script_pos), /"script":/);
|
|
113
|
+
assert.match(out.slice(script_pos), /Auto Upgrade Plugin for xyOps/);
|
|
114
|
+
assert.match(out.slice(script_pos), /const fs = require\('fs'\);/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await check('install defaults to a complete create preview', () => {
|
|
118
|
+
assert.equal(preview.preview, true);
|
|
119
|
+
assert.equal(preview.operation, 'create');
|
|
120
|
+
assert.equal(preview.marketplace.id, marketplaceID);
|
|
121
|
+
assert.equal(preview.product.id, marketplaceID);
|
|
122
|
+
assert.equal(preview.version, 'v1.0.9');
|
|
123
|
+
assert.deepEqual(preview.warnings, []);
|
|
124
|
+
assert.deepEqual(preview.plugin.marketplace, { id: marketplaceID, version: 'v1.0.9' });
|
|
125
|
+
assert.equal(preview.plugin.type, 'event');
|
|
126
|
+
assert.ok(!('created' in preview.plugin));
|
|
127
|
+
assert.ok(!('modified' in preview.plugin));
|
|
128
|
+
assert.ok(!('revision' in preview.plugin));
|
|
129
|
+
assert.ok(!('username' in preview.plugin));
|
|
130
|
+
assert.ok(!('uid' in preview.plugin));
|
|
131
|
+
assert.ok(!('gid' in preview.plugin));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await check('preview does not install Plugin', () => {
|
|
135
|
+
assert.deepEqual(json(['plugins', '--id', pluginID]), []);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await check('false confirmation and dry confirmation remain previews', () => {
|
|
139
|
+
assert.equal(json(['marketplace', 'install', marketplaceID, '--confirm', 'false']).preview, true);
|
|
140
|
+
assert.equal(json(['marketplace', 'install', marketplaceID, '--confirm', '--dry']).preview, true);
|
|
141
|
+
assert.deepEqual(json(['plugins', '--id', pluginID]), []);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const rejected = [
|
|
145
|
+
['get', 'invalid-id'],
|
|
146
|
+
['get', marketplaceID, '--unknown', 'x'],
|
|
147
|
+
['install'],
|
|
148
|
+
['install', marketplaceID, '--unknown', 'x'],
|
|
149
|
+
['install', marketplaceID, '--confirm', 'yes'],
|
|
150
|
+
['--plugin_type', 'bogus'],
|
|
151
|
+
['--status', 'outdated'],
|
|
152
|
+
['--sort_by', 'bogus'],
|
|
153
|
+
['--sort_dir', 'sideways'],
|
|
154
|
+
['--query', 'AI', 'extra search']
|
|
155
|
+
];
|
|
156
|
+
for (const args of rejected) {
|
|
157
|
+
await check('reject marketplace ' + args.join(' '), () => xy(['marketplace', ...args], { fail: true }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const installed = json(['marketplace', 'install', marketplaceID, '--version', 'v1.0.9', '--confirm']);
|
|
161
|
+
|
|
162
|
+
await check('confirmed install creates selected Marketplace version', () => {
|
|
163
|
+
assert.equal(installed.operation, 'created');
|
|
164
|
+
assert.equal(installed.plugin.id, pluginID);
|
|
165
|
+
assert.equal(installed.plugin.marketplace.id, marketplaceID);
|
|
166
|
+
assert.equal(installed.plugin.marketplace.version, 'v1.0.9');
|
|
167
|
+
assert.match(installed.plugin.command, /1\.0\.9/);
|
|
168
|
+
assert.ok(!('uid' in installed.plugin));
|
|
169
|
+
assert.ok(!('gid' in installed.plugin));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await check('installed product is reported as outdated', () => {
|
|
173
|
+
const rows = json(['marketplace', '--query', 'AI Generation', '--status', 'installed']);
|
|
174
|
+
assert.ok(rows.some(product => product.id == marketplaceID));
|
|
175
|
+
const out = xy(['marketplace', marketplaceID]);
|
|
176
|
+
assert.match(out, /Outdated/);
|
|
177
|
+
assert.match(out, new RegExp(pluginID));
|
|
178
|
+
assert.match(out, /v1\.0\.9/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const upgradePreview = json(['marketplace', 'install', marketplaceID]);
|
|
182
|
+
await check('latest installation previews an update', () => {
|
|
183
|
+
assert.equal(upgradePreview.operation, 'update');
|
|
184
|
+
assert.equal(upgradePreview.version, upgradePreview.product.versions[0]);
|
|
185
|
+
assert.equal(upgradePreview.plugin.id, pluginID);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const upgraded = json(['marketplace', 'install', marketplaceID, '--confirm']);
|
|
189
|
+
await check('confirmed upgrade updates Plugin and Marketplace version', () => {
|
|
190
|
+
assert.equal(upgraded.operation, 'updated');
|
|
191
|
+
assert.equal(upgraded.plugin.id, pluginID);
|
|
192
|
+
assert.equal(upgraded.plugin.marketplace.version, upgraded.marketplace.version);
|
|
193
|
+
assert.equal(upgraded.plugin.marketplace.version, upgradePreview.product.versions[0]);
|
|
194
|
+
assert.match(upgraded.plugin.command, new RegExp(upgraded.marketplace.version.replace(/^v/, '').replace(/\./g, '\\.')));
|
|
195
|
+
assert.ok(upgraded.plugin.revision > installed.plugin.revision);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await check('latest version is reported as up to date', () => {
|
|
199
|
+
assert.match(xy(['marketplace', marketplaceID]), /Up to Date/);
|
|
200
|
+
assert.match(xy(['plugin', pluginID]), /Marketplace/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
json(['plugin', 'delete', pluginID, '--confirm']);
|
|
204
|
+
|
|
205
|
+
await check('delete returns product to not-installed state', () => {
|
|
206
|
+
assert.deepEqual(json(['plugins', '--id', pluginID]), []);
|
|
207
|
+
const rows = json(['marketplace', '--query', 'AI Generation', '--status', 'not']);
|
|
208
|
+
assert.ok(rows.some(product => product.id == marketplaceID));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
for (const topic of ['marketplace', 'marketplace list', 'marketplace search', 'marketplace get', 'marketplace install']) {
|
|
212
|
+
await check('help ' + topic, () => assert.match(xy(['help', ...topic.split(' ')]), /Marketplace/i));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await check('existing Plugins remain unchanged', async () => {
|
|
216
|
+
assert.deepEqual((await apiCall('getPlugins')).rows, original);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
// Only clean up after confirming this product was absent at suite start.
|
|
221
|
+
// This catches failures after a successful create but before normal delete.
|
|
222
|
+
if (cleanupAllowed) {
|
|
223
|
+
const rows = (await apiCall('getPlugins')).rows.filter(plugin => {
|
|
224
|
+
return plugin.marketplace && (plugin.marketplace.id == marketplaceID);
|
|
225
|
+
});
|
|
226
|
+
for (const plugin of rows) await apiCall('deletePlugin', { id: plugin.id });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const Path = require('node:path');
|
|
5
|
+
const { loadTestConfig, createCheck, createTempDir, xy, json, call, call: apiCall, cleanupFixtures } = require('./helpers/common.js');
|
|
6
|
+
|
|
7
|
+
test('monitors', async t => {
|
|
8
|
+
loadTestConfig();
|
|
9
|
+
const check = createCheck(t);
|
|
10
|
+
|
|
11
|
+
// Disposable live lifecycle. Use constant sources and remove every fixture.
|
|
12
|
+
const id = 'cli_monitor_test_' + Date.now();
|
|
13
|
+
const title = 'CLI Monitor Test ' + id;
|
|
14
|
+
const ids = new Set([id]);
|
|
15
|
+
const temp = createTempDir(t, 'xycli-monitor-test-');
|
|
16
|
+
try {
|
|
17
|
+
const original = (await apiCall('getMonitors')).rows;
|
|
18
|
+
const multi = await apiCall('getMultiple', { lists: 'all', servers: 1 });
|
|
19
|
+
const server = Object.values(multi.servers)[0];
|
|
20
|
+
const group = multi.groups[0];
|
|
21
|
+
assert.ok(server && group, 'Live server and group required');
|
|
22
|
+
|
|
23
|
+
await check('dry create defaults and numeric constant', () => {
|
|
24
|
+
const req = json(['monitor', 'create', '--id', id, '--title', title, '--source', '0', '--dry']);
|
|
25
|
+
assert.equal(req.source, '0');
|
|
26
|
+
assert.equal(req.display, true);
|
|
27
|
+
assert.equal(req.data_type, 'float');
|
|
28
|
+
assert.equal(req.delta_min_value, false);
|
|
29
|
+
assert.deepEqual(req.groups, []);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await check('dry create does not persist', () => assert.deepEqual(json(['monitors', '--id', id]), []));
|
|
33
|
+
|
|
34
|
+
const created = json(['monitor', 'create', '--id', id, '--title', title, '--source', '12.5', '--display', 'false', '--groups', group.id, '--group', group.id, '--delta', 'true', '--divide_by_delta', 'true', '--delta_min_value', '0', '--min_vert_scale', '100', '--suffix', '/sec', '--icon', 'chart-line', '--notes', 'Hello']);
|
|
35
|
+
|
|
36
|
+
await check('create persists configuration and deduplicates groups', () => {
|
|
37
|
+
assert.equal(created.id, id);
|
|
38
|
+
assert.equal(created.source, '12.5');
|
|
39
|
+
assert.equal(created.display, false);
|
|
40
|
+
assert.equal(created.delta, true);
|
|
41
|
+
assert.equal(created.divide_by_delta, true);
|
|
42
|
+
assert.equal(created.delta_min_value, 0);
|
|
43
|
+
assert.equal(created.min_vert_scale, 100);
|
|
44
|
+
assert.equal(created.suffix, '/sec');
|
|
45
|
+
assert.equal(created.notes, 'Hello');
|
|
46
|
+
assert.equal(created.icon, 'chart-line');
|
|
47
|
+
assert.equal(created.revision, 1);
|
|
48
|
+
assert.deepEqual(created.groups, [group.id]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await check('exact ID detail', () => assert.deepEqual(json(['monitor', id]), created));
|
|
52
|
+
|
|
53
|
+
await check('fuzzy title detail', () => assert.equal(json(['monitor', 'get', '--title', title.toLowerCase()]).id, id));
|
|
54
|
+
|
|
55
|
+
await check('named ID detail', () => assert.equal(json(['monitor', 'get', '--id', id]).id, id));
|
|
56
|
+
|
|
57
|
+
await check('combined filters', () => assert.deepEqual(json(['monitors', title, '--display', 'false', '--delta', 'true', '--divide_by_delta', 'true', '--data_type', 'float']), [created]));
|
|
58
|
+
|
|
59
|
+
await check('group title lookup', () => assert.equal(json(['monitors', title, '--group', group.title]).length, 1));
|
|
60
|
+
|
|
61
|
+
await check('JSON list ignores pagination as filters', () => assert.deepEqual(json(['monitor', 'list', '--id', id, '--limit', '1', '--page', '2']), [created]));
|
|
62
|
+
|
|
63
|
+
await check('human pagination and suggestions', () => {
|
|
64
|
+
const out = xy(['monitors', '--limit', '1', '--page', '2']);
|
|
65
|
+
assert.match(out, /page 2 of/);
|
|
66
|
+
assert.match(out, /Other Commands:/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await check('empty filtered table', () => assert.match(xy(['monitors', title, '--display', 'true']), /No filtered monitors found/));
|
|
70
|
+
|
|
71
|
+
await check('human detail includes all settings', () => {
|
|
72
|
+
const out = xy(['monitor', id]);
|
|
73
|
+
fs.writeFileSync(Path.join(temp, 'detail.txt'), out);
|
|
74
|
+
for (const label of ['Monitor Summary', 'Hidden', 'Source', 'Data Match', 'Delta Min', 'Sort Order', 'Revision', 'Monitor Notes', 'Hello']) assert.ok(out.toLowerCase().includes(label.toLowerCase()), label);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await check('human update shows target and parsed data', () => {
|
|
78
|
+
const out = xy(['monitor', 'update', id, '--notes', 'Preview', '--dry']);
|
|
79
|
+
assert.match(out, /UPDATE MONITOR/);
|
|
80
|
+
assert.match(out, new RegExp('Monitor ID:\\s+' + id));
|
|
81
|
+
assert.match(out, /UPDATE DATA[\s\S]*"notes": "Preview"/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await check('sparse dry update', () => assert.deepEqual(json(['monitor', 'update', id, '--notes', 'Preview', '--dry']), { notes: 'Preview', id }));
|
|
85
|
+
|
|
86
|
+
await check('dry update leaves revision', () => assert.equal(json(['monitor', id]).revision, 1));
|
|
87
|
+
|
|
88
|
+
await check('saved test returns absolute value despite delta settings', () => assert.deepEqual(json(['monitor', 'test', id, '--server', server.id]), { code: 0, value: 12.5 }));
|
|
89
|
+
|
|
90
|
+
await check('test fuzzy monitor and server lookup', () => assert.equal(json(['monitor', 'test', title.toLowerCase(), '--server', server.title || server.hostname]).value, 12.5));
|
|
91
|
+
|
|
92
|
+
await check('test zero success', () => assert.deepEqual(json(['monitor', 'test', '--id', id, '--server', server.id, '--source', '0']), { code: 0, value: 0 }));
|
|
93
|
+
|
|
94
|
+
await check('test unresolved expression', () => assert.deepEqual(json(['monitor', 'test', '--server', server.id, '--source', 'missing_monitor_test_field']), { code: 0, fail: true }));
|
|
95
|
+
|
|
96
|
+
await check('human No Value', () => assert.match(xy(['monitor', 'test', '--server', server.id, '--source', 'missing_monitor_test_field']), /No Value/));
|
|
97
|
+
|
|
98
|
+
for (const type of ['integer', 'float', 'bytes', 'seconds', 'milliseconds']) {
|
|
99
|
+
await check('test type conversion ' + type, () => assert.equal(json(['monitor', 'test', '--server', server.id, '--source', '12.75', '--data_type', type]).value, type === 'float' ? 12.75 : 12));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await check('human bytes formatting', () => assert.match(xy(['monitor', 'test', '--server', server.id, '--source', '2048', '--data_type', 'bytes']), /2 K/));
|
|
103
|
+
|
|
104
|
+
await check('regex first capture', () => assert.equal(json(['monitor', 'test', '--server', server.id, '--source', '"42 workers"', '--data_match', '(\\d+)', '--data_type', 'integer']).value, 42));
|
|
105
|
+
|
|
106
|
+
await check('regex full match', () => assert.equal(json(['monitor', 'test', '--server', server.id, '--source', '"42 workers"', '--data_match', '\\d+']).value, 42));
|
|
107
|
+
|
|
108
|
+
await check('test dry request includes only evaluator fields', () => assert.deepEqual(json(['monitor', 'test', id, '--server', server.id, '--dry']), { source: '12.5', data_type: 'float', data_match: '', server: server.id }));
|
|
109
|
+
|
|
110
|
+
await check('test overrides do not change saved definition', () => assert.deepEqual(json(['monitor', id]), created));
|
|
111
|
+
|
|
112
|
+
json(['monitor', 'update', '--id', id, '--json', '@-'], { input: JSON.stringify({ display: true, delta_min_value: false, notes: 'From stdin' }) });
|
|
113
|
+
|
|
114
|
+
await check('stdin sparse update and disabled minimum', () => {
|
|
115
|
+
const row = json(['monitor', id]);
|
|
116
|
+
assert.equal(row.display, true);
|
|
117
|
+
assert.equal(row.delta_min_value, false);
|
|
118
|
+
assert.equal(row.notes, 'From stdin');
|
|
119
|
+
assert.equal(row.source, created.source);
|
|
120
|
+
assert.equal(row.created, created.created);
|
|
121
|
+
assert.equal(row.revision, 2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Group assignments use IDs; extra IDs are only temporary test definitions.
|
|
125
|
+
json(['monitor', 'update', id, '--groups.0', group.id, '--groups', '["first","second"]', '--group', 'third', '--group', group.id]);
|
|
126
|
+
|
|
127
|
+
await check('replacement before indexed edits and append', () => assert.deepEqual(json(['monitor', id]).groups, [group.id, 'second', 'third']));
|
|
128
|
+
|
|
129
|
+
const groupFile = Path.join(temp, 'groups.json');
|
|
130
|
+
fs.writeFileSync(groupFile, JSON.stringify([group.id]));
|
|
131
|
+
json(['monitor', 'update', id, '--groups', '@' + groupFile]);
|
|
132
|
+
|
|
133
|
+
await check('groups from file', () => assert.deepEqual(json(['monitor', id]).groups, [group.id]));
|
|
134
|
+
|
|
135
|
+
json(['monitor', 'update', id, '--groups', '[]', '--suffix', '', '--notes', '', '--data_match', '', '--json', '@-'], { input: JSON.stringify({ delta_min_value: -5, sort_order: -1 }) });
|
|
136
|
+
|
|
137
|
+
await check('clearing settings, numeric minimum and ordering', () => {
|
|
138
|
+
const row = json(['monitor', id]);
|
|
139
|
+
assert.deepEqual(row.groups, []);
|
|
140
|
+
assert.equal(row.suffix, '');
|
|
141
|
+
assert.equal(row.notes, '');
|
|
142
|
+
assert.equal(row.delta_min_value, -5);
|
|
143
|
+
assert.equal(json(['monitors'])[0].id, id);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await check('all-group monitors included in group filter', () => assert.equal(json(['monitors', title, '--group', group.id]).length, 1));
|
|
147
|
+
|
|
148
|
+
const revision = json(['monitor', id]).revision;
|
|
149
|
+
await check('unconfirmed delete shows target and warning toast', () => {
|
|
150
|
+
for (const args of [['delete', id], ['delete', id, '--confirm', 'false']]) {
|
|
151
|
+
const out = xy(['monitor', ...args]);
|
|
152
|
+
assert.match(out, /DELETE MONITOR/);
|
|
153
|
+
assert.match(out, /⚠️[\s\S]*Please confirm the monitor delete/);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
for (const args of [
|
|
158
|
+
['update', id, '--groups.99', group.id], ['update', id, '--groups.__proto__.x', 'bad'],
|
|
159
|
+
['update', id, '--groups', '{}'], ['update', id, '--groups', '[5]'], ['update', id, '--group', ''],
|
|
160
|
+
['update', id, '--title', ' '], ['update', id, '--source', ''], ['update', id, '--source', 'cpu.('],
|
|
161
|
+
['update', id, '--display', 'perhaps'], ['update', id, '--delta_min_value', 'true'],
|
|
162
|
+
['update', id, '--sort_order', '1.5'], ['update', id, '--data_type', 'string'], ['update', id, '--data_match', '['],
|
|
163
|
+
['update', id, '--revision', '99'], ['update', id, '--enabled', 'false'], ['update', id],
|
|
164
|
+
['update', title, '--notes', 'wrong'], ['update', id, '--id', 'cpu_usage', '--notes', 'wrong'],
|
|
165
|
+
['delete', title, '--confirm'],
|
|
166
|
+
['create', '--title', title], ['create', '--title', title, '--source', 'cpu.('],
|
|
167
|
+
['test', id], ['test', '--server', server.id], ['test', id, '--server', 'missing_test_server'],
|
|
168
|
+
['test', id, '--server', server.id, '--source', 'cpu.('],
|
|
169
|
+
['test', id, '--server', server.id, '--data_match', 'never_matches'],
|
|
170
|
+
['test', id, '--server', server.id, '--delta', 'true']
|
|
171
|
+
]) await check('reject ' + args.slice(0,3).join(' '), () => xy(['monitor', ...args], { fail: true }));
|
|
172
|
+
|
|
173
|
+
await check('reject negative chart range', () => xy(['monitor', 'update', id, '--json', '@-'], { input: '{"min_vert_scale":-1}', fail: true }));
|
|
174
|
+
|
|
175
|
+
await check('rejected requests preserve revision', () => assert.equal(json(['monitor', id]).revision, revision));
|
|
176
|
+
|
|
177
|
+
await check('duplicate ID error', () => assert.match(xy(['monitor', 'create', '--id', id, '--title', title, '--source', '0'], { fail: true }), /already exists/));
|
|
178
|
+
|
|
179
|
+
const reqFile = Path.join(temp, 'monitor.json');
|
|
180
|
+
fs.writeFileSync(reqFile, JSON.stringify({ title: title + ' file', source: '0', data_type: 'integer', display: false }));
|
|
181
|
+
const imported = json(['monitor', 'create', '--json', '@' + reqFile]);
|
|
182
|
+
ids.add(imported.id);
|
|
183
|
+
|
|
184
|
+
await check('file create with auto ID and integer defaults', () => {
|
|
185
|
+
assert.match(imported.id, /^m/);
|
|
186
|
+
assert.equal(imported.min_vert_scale, 1);
|
|
187
|
+
assert.equal(imported.display, false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const humanID = id + '_human';
|
|
191
|
+
ids.add(humanID);
|
|
192
|
+
|
|
193
|
+
await check('human creation suggestions', () => assert.match(xy(['monitor', 'create', '--id', humanID, '--title', title, '--source', '0', '--display', 'false']), /Other Commands:/));
|
|
194
|
+
|
|
195
|
+
for (const monitorID of Array.from(ids)) {
|
|
196
|
+
if (monitorID === id) continue;
|
|
197
|
+
json(['monitor', 'delete', monitorID, '--confirm']);
|
|
198
|
+
ids.delete(monitorID);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await check('dry delete request', () => assert.deepEqual(json(['monitor', 'delete', id, '--confirm', '--dry']), { id }));
|
|
202
|
+
|
|
203
|
+
await check('dry delete leaves monitor', () => assert.equal(json(['monitor', id]).id, id));
|
|
204
|
+
|
|
205
|
+
json(['monitor', 'delete', id, '--confirm']);
|
|
206
|
+
ids.delete(id);
|
|
207
|
+
|
|
208
|
+
await check('delete removes monitor', () => assert.deepEqual(json(['monitors', '--id', id]), []));
|
|
209
|
+
|
|
210
|
+
await check('get after delete fails', () => xy(['monitor', id], { fail: true }));
|
|
211
|
+
|
|
212
|
+
for (const topic of ['monitors', 'monitor', 'monitor list', 'monitor get', 'monitor create', 'monitor update', 'monitor test', 'monitor delete']) {
|
|
213
|
+
await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await check('existing monitors remain unchanged', () => {
|
|
217
|
+
const now = json(['monitors']);
|
|
218
|
+
assert.equal(now.length, original.length);
|
|
219
|
+
for (const monitor of original) assert.deepEqual(now.find(item => item.id === monitor.id), monitor);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await cleanupFixtures([
|
|
224
|
+
{ list: 'monitors', method: 'deleteMonitor', match: item => ids.has(item.id) || item.title.startsWith(title) }
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const Path = require('node:path');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { loadTestConfig, createCheck, createTempDir, runCLI } = require('./helpers/common.js');
|
|
6
|
+
|
|
7
|
+
test('pagination', async t => {
|
|
8
|
+
loadTestConfig();
|
|
9
|
+
const check = createCheck(t);
|
|
10
|
+
|
|
11
|
+
// End-to-end, read-only checks for search pagination and mutation dry runs.
|
|
12
|
+
const traceFile = Path.join(createTempDir(t, 'xycli-page-test-'), 'trace.jsonl');
|
|
13
|
+
|
|
14
|
+
function run(args) {
|
|
15
|
+
fs.writeFileSync(traceFile, '');
|
|
16
|
+
const output = runCLI(args, {
|
|
17
|
+
preload: Path.join(__dirname, 'helpers/pagination-preload.cjs'),
|
|
18
|
+
env: { XYOPS_SUGGEST: 'false', XYCLI_PAGINATION_TRACE: traceFile }
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const trace = fs.readFileSync(traceFile, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
22
|
+
return { output, trace };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function json(args) {
|
|
26
|
+
const result = run([...args, '--format', 'jsonc']);
|
|
27
|
+
return JSON.parse(result.output.split('\n').find(line => /^[\[{]/.test(line)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function search(args, method, offset = 2, limit = 2) {
|
|
31
|
+
const result = run([...args, '--limit', String(limit), '--page', '2']);
|
|
32
|
+
const call = result.trace.find(row => row.method === method);
|
|
33
|
+
assert.ok(call, JSON.stringify(result.trace));
|
|
34
|
+
assert.equal(call.offset, offset);
|
|
35
|
+
assert.equal(call.limit, limit);
|
|
36
|
+
assert.ok(!call.query || !/\b(limit|offset|page):/.test(call.query), call.query);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const command of ['categories', 'buckets', 'keys', 'alerts']) {
|
|
41
|
+
await check(command + ' pagination stays out of local filters', () => {
|
|
42
|
+
const base = json([command]);
|
|
43
|
+
assert.deepEqual(json([command, '--limit', '1', '--page', '2']), base);
|
|
44
|
+
const result = run([command, '--limit', '1', '--page', '2']);
|
|
45
|
+
assert.ok(!result.output.includes('FILTERED'), result.output);
|
|
46
|
+
if (base.length > 1) assert.match(result.output, /page 2 of/);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await check('jobs search page arithmetic and query isolation', () => search(['jobs'], 'searchJobs'));
|
|
51
|
+
|
|
52
|
+
await check('jobs explicit offset', () => {
|
|
53
|
+
const result = run(['jobs', '--limit', '3', '--offset', '4']);
|
|
54
|
+
assert.deepEqual(result.trace.find(row => row.method === 'searchJobs'), { method: 'searchJobs', query: '*', offset: 4, limit: 3 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await check('page takes precedence over offset', () => search(['jobs', '--offset', '80'], 'searchJobs'));
|
|
58
|
+
|
|
59
|
+
await check('alert search pagination and query isolation', () => search(['alerts', 'search'], 'searchAlerts'));
|
|
60
|
+
|
|
61
|
+
await check('singular alert search alias', () => search(['alert', 'search'], 'searchAlerts'));
|
|
62
|
+
|
|
63
|
+
const events = json(['events']);
|
|
64
|
+
const event = events.find(event => !event.workflow) || events[0];
|
|
65
|
+
|
|
66
|
+
if (event) {
|
|
67
|
+
await check('event completed variant', () => search(['event', event.id, '--completed'], 'searchJobs'));
|
|
68
|
+
|
|
69
|
+
await check('event queued variant', () => search(['event', event.id, '--queued'], 'getActiveJobs'));
|
|
70
|
+
|
|
71
|
+
await check('event queue alias', () => search(['event', event.id, '--queue'], 'getActiveJobs'));
|
|
72
|
+
}
|
|
73
|
+
else await t.test('event detail pagination', { skip: 'No saved events on this server.' }, () => {});
|
|
74
|
+
|
|
75
|
+
const upcoming = [['dashboard', '--upcoming'], ['dashboard', 'upcoming'], ['upcoming'], ['jobs', '--upcoming'], ['jobs', 'upcoming']];
|
|
76
|
+
if (event) upcoming.push(['event', event.id, '--upcoming']);
|
|
77
|
+
|
|
78
|
+
for (const args of upcoming) {
|
|
79
|
+
await check(args.join(' ') + ' paginates predictions', () => {
|
|
80
|
+
const result = run([...args, '--limit', '1', '--page', '2']);
|
|
81
|
+
assert.match(result.output, /UPCOMING JOBS/);
|
|
82
|
+
const section = result.output.slice(result.output.indexOf('UPCOMING JOBS'));
|
|
83
|
+
if (section.includes('items found')) assert.match(section, /page 2 of/);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const jobRows = (() => {
|
|
88
|
+
const result = run(['api', 'searchJobs', '--query', '*', '--limit', '1', '--offset', '0', '--format', 'jsonc']);
|
|
89
|
+
const lines = result.output.split('\n').filter(line => line.startsWith('{'));
|
|
90
|
+
return JSON.parse(lines[lines.length - 1]).rows;
|
|
91
|
+
})();
|
|
92
|
+
if (jobRows.length) {
|
|
93
|
+
await check('job details paginate every related search', () => {
|
|
94
|
+
const result = run(['job', jobRows[0].id, '--limit', '2', '--page', '2']);
|
|
95
|
+
for (const method of ['searchAlerts', 'searchSnapshots']) {
|
|
96
|
+
const call = result.trace.find(row => row.method === method);
|
|
97
|
+
assert.equal(call.offset, 2);
|
|
98
|
+
assert.equal(call.limit, 2);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else await t.test('job detail pagination', { skip: 'No completed jobs on this server.' }, () => {});
|
|
103
|
+
|
|
104
|
+
const invocations = json(['alerts', 'search', '--limit', '1']);
|
|
105
|
+
if (invocations.length) {
|
|
106
|
+
await check('alert invocation details prepare related-record pagination', () => {
|
|
107
|
+
const result = run(['alert', invocations[0].id, '--limit', '2', '--page', '2']);
|
|
108
|
+
const call = result.trace.find(row => row.method === 'searchSnapshots');
|
|
109
|
+
|
|
110
|
+
// Alert detail intentionally anchors embedded sections at offset zero.
|
|
111
|
+
assert.equal(call.offset, 0);
|
|
112
|
+
assert.equal(call.limit, 2);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
else await t.test('alert invocation pagination', { skip: 'No alert history on this server.' }, () => {});
|
|
116
|
+
|
|
117
|
+
await check('generic API retains raw pagination properties', () => {
|
|
118
|
+
const payload = json(['api', 'searchJobs', '--offset', '7', '--limit', '3', '--page', '4', '--dry']);
|
|
119
|
+
assert.deepEqual(payload, { offset: 7, limit: 3, page: 4 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const resourceLimit = { type: 'time', enabled: true, duration: 300 };
|
|
123
|
+
for (const command of ['category', 'event']) {
|
|
124
|
+
await check(command + ' resource limit survives creation', () => {
|
|
125
|
+
const payload = json([command, 'create', '--title', 'Pagination dry test', '--limit', JSON.stringify(resourceLimit), '--dry']);
|
|
126
|
+
assert.ok(payload.limits.some(item => item.duration === 300));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await check('mutation pagination properties reach CRUD validation', () => {
|
|
131
|
+
for (const [key, value] of [['offset', '7'], ['page', '3']]) {
|
|
132
|
+
const output = runCLI(['event', 'create', '--title', 'Pagination dry test', '--' + key, value, '--dry'], { fail: true });
|
|
133
|
+
assert.match(output, new RegExp('Unsupported property for create_event: "' + key + '"'));
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await check('category color remains a resource property', () => assert.equal(json(['category', 'create', '--title', 'Color dry test', '--color', 'blue', '--dry']).color, 'blue'));
|
|
138
|
+
});
|