@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,122 @@
1
+ // Verify a shared workflow graph can be exported, deleted, and recreated.
2
+ const { test } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const Path = require('node:path');
5
+ const { loadTestConfig, createCheck, createTempDir, json, call, cleanupFixtures } = require('./helpers/common.js');
6
+
7
+ test('transfer-workflow', async t => {
8
+ loadTestConfig();
9
+ const check = createCheck(t);
10
+ const prefix = 'cli_transfer_graph_' + Date.now() + '_';
11
+ const temp = createTempDir(t, 'xycli-graph-');
12
+
13
+ async function cleanup() {
14
+ // Delete workflow consumers first, followed by their shared dependencies.
15
+ await cleanupFixtures([
16
+ { list: 'events', method: 'deleteEvent' },
17
+ { list: 'plugins', method: 'deletePlugin' },
18
+ { list: 'categories', method: 'deleteCategory' },
19
+ { list: 'groups', method: 'deleteGroup' }
20
+ ].map(resource => ({
21
+ ...resource,
22
+ match: item => item.id.startsWith(prefix),
23
+ sort: (a, b) => Number(b.type === 'workflow') - Number(a.type === 'workflow')
24
+ })));
25
+ }
26
+
27
+ try {
28
+ // All events are disabled and have no triggers. The group matches no
29
+ // hosts, so no fixture can accidentally launch a job on a real server.
30
+ await call('createGroup', {
31
+ id: prefix + 'group',
32
+ title: prefix + 'group',
33
+ hostname_match: '^' + prefix + 'no_hosts$',
34
+ alert_actions: []
35
+ });
36
+
37
+ await call('createCategory', {
38
+ id: prefix + 'category',
39
+ title: prefix + 'category',
40
+ enabled: false,
41
+ actions: [],
42
+ limits: []
43
+ });
44
+
45
+ await call('createPlugin', {
46
+ id: prefix + 'plugin',
47
+ title: prefix + 'plugin',
48
+ type: 'event',
49
+ command: '/usr/bin/true',
50
+ enabled: false,
51
+ params: []
52
+ });
53
+
54
+ await call('createEvent', {
55
+ id: prefix + 'leaf',
56
+ title: prefix + 'leaf',
57
+ enabled: false,
58
+ category: prefix + 'category',
59
+ plugin: prefix + 'plugin',
60
+ targets: [prefix + 'group'],
61
+ algo: 'random',
62
+ triggers: []
63
+ });
64
+
65
+ // Both branches reference the same leaf. Import must create that shared
66
+ // leaf before either consumer, regardless of export traversal order.
67
+ for (const [name, refs] of [['a', ['leaf']], ['b', ['leaf']], ['root', ['a', 'b']]]) {
68
+ await call('createEvent', {
69
+ id: prefix + name,
70
+ title: prefix + name,
71
+ enabled: false,
72
+ type: 'workflow',
73
+ category: prefix + 'category',
74
+ triggers: [],
75
+ workflow: {
76
+ nodes: refs.map((ref, idx) => ({
77
+ id: 'node' + idx,
78
+ type: 'event',
79
+ x: idx * 100,
80
+ y: 0,
81
+ data: { event: prefix + ref, params: {} }
82
+ })),
83
+ connections: []
84
+ }
85
+ });
86
+ }
87
+
88
+ const filename = Path.join(temp, 'shared-workflow.json.gz');
89
+ await check('export includes all seven graph objects', () => {
90
+ assert.equal(json(['event', prefix + 'root', '--export', filename, '--deps', 'all']).count, 7);
91
+ });
92
+
93
+ await cleanup();
94
+
95
+ await check('fresh import orders the shared leaf before both consumers', () => {
96
+ const preview = json(['import', filename]);
97
+ assert.equal(preview.items.length, 7);
98
+ assert.ok(preview.items.every(item => item.operation === 'create'));
99
+
100
+ const order = preview.items.map(item => item.data.id);
101
+ assert.ok(order.indexOf(prefix + 'leaf') < order.indexOf(prefix + 'b'));
102
+ assert.ok(order.indexOf(prefix + 'leaf') < order.indexOf(prefix + 'a'));
103
+ assert.equal(order[order.length - 1], prefix + 'root');
104
+ });
105
+
106
+ await check('confirmed import recreates all seven graph objects', () => {
107
+ const result = json(['import', filename, '--confirm']);
108
+ assert.equal(result.items.length, 7);
109
+ assert.ok(result.items.every(item => item.status === 'created'));
110
+ });
111
+
112
+ await check('restored workflow retains its branches and disabled state', async () => {
113
+ const restored = await call('getMultiple', { lists: 'all' });
114
+ const root = restored.events.find(event => event.id === prefix + 'root');
115
+ assert.equal(root.workflow.nodes.length, 2);
116
+ assert.equal(root.enabled, false);
117
+ });
118
+ }
119
+ finally {
120
+ await cleanup();
121
+ }
122
+ });
@@ -0,0 +1,314 @@
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 zlib = require('node:zlib');
6
+ const utils = require('../lib/utils.js');
7
+ const transfer = require('../lib/transfer.js');
8
+ const { loadTestConfig, createCheck, createTempDir, xy, json, call, cleanupFixtures } = require('./helpers/common.js');
9
+
10
+ test('transfer', async t => {
11
+ loadTestConfig();
12
+ const check = createCheck(t);
13
+
14
+ // Disposable end-to-end XYPDF lifecycle plus dependency graph checks.
15
+ const prefix = 'cli_transfer_' + Date.now() + '_';
16
+ const temp = createTempDir(t, 'xycli-transfer-');
17
+ const types = {
18
+ event: ['event', 'events', 'Event'],
19
+ alert: ['alert', 'alerts', 'Alert'],
20
+ api_key: ['key', 'api_keys', 'ApiKey'],
21
+ bucket: ['bucket', 'buckets', 'Bucket'],
22
+ category: ['category', 'categories', 'Category'],
23
+ channel: ['channel', 'channels', 'Channel'],
24
+ group: ['group', 'groups', 'Group'],
25
+ monitor: ['monitor', 'monitors', 'Monitor'],
26
+ plugin: ['plugin', 'plugins', 'Plugin'],
27
+ role: ['role', 'roles', 'Role'],
28
+ tag: ['tag', 'tags', 'Tag'],
29
+ web_hook: ['webhook', 'web_hooks', 'WebHook']
30
+ };
31
+
32
+ function wrap(items) {
33
+ return { type: 'xypdf', version: '1.0', xyops: '1.0.0', items };
34
+ }
35
+
36
+ function file(name, value) {
37
+ const path = Path.join(temp, name);
38
+ fs.writeFileSync(path, JSON.stringify(value), { mode: 0o600 });
39
+ return path;
40
+ }
41
+
42
+ function fixture(type, data = {}) {
43
+ return { type, data: { id: prefix + type, title: prefix + type, enabled: false, notes: 'Original', ...data, username: 'old_author', created: 1, modified: 2, revision: 99, sort_order: 99 } };
44
+ }
45
+
46
+ const original = await call('getMultiple', { lists: 'all' });
47
+ try {
48
+ // Seed an existing API key to cover updates. The separate API key suite
49
+ // covers deletion, recreation, and authentication with the original secret.
50
+ const seedKey = (await call('createApiKey', { title: prefix + 'api_key', active: true, privileges: {}, roles: [] })).api_key;
51
+
52
+ // Unit graph deliberately has a cycle, shared dependencies and physical
53
+ // server targets. Do not install the cyclic workflow on the live server.
54
+ const ctx = { ...utils, ...transfer, die: message => { throw new Error(message); } };
55
+ for (const [type, meta] of Object.entries(types)) ctx[meta[1]] = [];
56
+ const a = { id: 'a', title: 'A', category: 'general', targets: ['server_id'], workflow: { nodes: [{ type: 'event', data: { event: 'b' } }] } };
57
+ const b = { id: 'b', title: 'B', workflow: { nodes: [{ type: 'event', data: { event: 'a' } }, { type: 'job', data: { plugin: 'custom' } }] }, actions: [{ type: 'plugin', plugin_id: 'custom' }] };
58
+ ctx.events = [a,b];
59
+ ctx.plugins = [{ id: 'custom', title: 'Custom', script: 'literal code' }];
60
+
61
+ await check('cycle-safe deduplication', () => {
62
+ const warnings = [];
63
+ assert.deepEqual(ctx.collectExportItems('event', a, ctx.parseExportDependencies('all'), warnings).map(item => item.data.id), ['a','b','custom']);
64
+ assert.deepEqual(warnings, []);
65
+ assert.equal(a.category, 'general');
66
+ });
67
+
68
+ await check('unselected subevents are not traversed', () => assert.equal(ctx.collectExportItems('event', a, ctx.parseExportDependencies('plugins'), []).length, 1));
69
+
70
+ await check('trigger and action dependency discovery', () => {
71
+ ctx.web_hooks = [{ id: 'hook', title: 'Hook' }];
72
+ ctx.buckets = [{ id: 'bucket', title: 'Bucket' }];
73
+ ctx.tags = [{ id: 'tag', title: 'Tag' }];
74
+ const event = { id: 'c', title: 'C', plugin: 'stock', triggers: [{ type: 'plugin', plugin_id: 'custom' }],
75
+ actions: [{ type: 'web_hook', web_hook: 'hook' }, { type: 'fetch', bucket_id: 'bucket' }, { type: 'tag', tag_id: 'tag' }] };
76
+ ctx.plugins.push({ id: 'stock', title: 'Stock', stock: true });
77
+ assert.deepEqual(ctx.collectExportItems('event', event, ctx.parseExportDependencies('all'), []).map(item => item.data.id), ['c','custom','hook','bucket','tag']);
78
+ });
79
+
80
+ await check('missing dependency warning', () => {
81
+ const warnings = [];
82
+ ctx.collectExportItems('event', { id:'c', plugin:'missing' }, ctx.parseExportDependencies('plugins'), warnings);
83
+ assert.equal(warnings.length, 1);
84
+ });
85
+
86
+ await check('semver release and prerelease ordering', () => {
87
+ assert.equal(ctx.compareVersions('1.2.3', '1.2.3+build'), 0);
88
+ assert.equal(ctx.compareVersions('1.2.3-beta.2', '1.2.3-beta.10'), -1);
89
+ assert.equal(ctx.compareVersions('1.2.3', '1.2.3-beta'), 1);
90
+ });
91
+
92
+ await check('shared new dependencies precede every consumer', () => {
93
+ const node = id => ({ type: 'event', data: { event: id } });
94
+ const make = (id, refs) => ({ type: 'event', operation: 'create', data: { id, workflow: { nodes: refs.map(node) } } });
95
+ const plan = [make('b', ['c']), make('c', []), make('a', ['c']), make('root', ['a','b'])];
96
+ const ids = ctx.orderTransferPlan(plan).map(item => item.data.id);
97
+ assert.ok(ids.indexOf('c') < ids.indexOf('b') && ids.indexOf('c') < ids.indexOf('a'));
98
+ assert.equal(ids[ids.length - 1], 'root');
99
+ assert.throws(() => ctx.orderTransferPlan([make('a', ['b']), make('b', ['a'])]), /Circular dependency/);
100
+ const existing = make('b', ['a']);
101
+ existing.operation = 'update';
102
+ assert.equal(ctx.orderTransferPlan([make('a', ['b']), existing]).length, 2);
103
+ });
104
+
105
+ // Import must forward stored credentials unchanged through the generic API:
106
+ // no API-key-specific replacement or follow-up requests.
107
+ require('pixl-cli').global();
108
+ let received, contractResult;
109
+ const keyData = { id: 'contract_key', title: 'Contract Key', key: 'stored_hash', mask: 'test****mask', active: false };
110
+ ctx.args = { other: [file('key-contract.json', wrap([{ type: 'api_key', data: keyData }]))], confirm: true };
111
+ ctx.xyopsVersion = '1.0.96';
112
+ ctx.format = 'jsonc';
113
+ ctx.getMultiple = async () => {};
114
+ ctx.jsonOutput = value => { contractResult = value; };
115
+ ctx.api = { createApiKey: async params => { received = params;
116
+ return { data: { code: 0, api_key: params } }; } };
117
+ await ctx.cmd_import();
118
+
119
+ await check('new API key import uses generic pass-through contract', () => {
120
+ assert.deepEqual(received, keyData);
121
+ assert.equal(contractResult.items[0].id, keyData.id);
122
+ assert.equal(contractResult.code, 0);
123
+ });
124
+
125
+ const items = [
126
+ fixture('event', { id: prefix + 'workflow', title: prefix + 'workflow', type: 'workflow', category: prefix + 'category', triggers: [], workflow: { nodes: [{ id: 'node1', type: 'event', x: 0, y: 0, data: { event: prefix + 'event', params: {} } }],
127
+ connections: [] } }),
128
+ fixture('event', { category: prefix + 'category', plugin: prefix + 'plugin', targets: [prefix + 'group'], algo: 'random', params: {}, triggers: [],
129
+ tags: [prefix + 'tag'],
130
+ actions: [] }),
131
+ fixture('alert', { expression: '0', message: 'Never triggered', actions: [],
132
+ groups: [] }),
133
+ { type: 'api_key', data: seedKey },
134
+ fixture('bucket'), fixture('category', { actions: [],
135
+ limits: [] }), fixture('channel', { users: [] }),
136
+ fixture('group', { hostname_match: '^' + prefix + 'no_hosts$', alert_actions: [] }),
137
+ fixture('monitor', { source: '0', data_type: 'float', display: false, groups: [] }),
138
+ fixture('plugin', { type: 'event', command: '/usr/bin/true', script: '', params: [] }),
139
+ fixture('role', { privileges: {} }), fixture('tag'), fixture('web_hook', { method: 'POST', url: 'https://example.invalid', headers: [] })
140
+ ];
141
+ const bundle = file('all.json', wrap(items));
142
+
143
+ await check('preview all types, dependencies first, key fields preserved', () => {
144
+ const plan = json(['import', bundle]);
145
+ assert.equal(plan.preview, true);
146
+ assert.equal(plan.items.length, 13);
147
+ assert.equal(plan.items[0].type, 'web_hook');
148
+ assert.ok(plan.items.every(item => item.operation === (item.type === 'api_key' ? 'update' : 'create') && !('revision' in item.data) && !('username' in item.data)));
149
+ const keyData = plan.items.find(item => item.type === 'api_key').data;
150
+ assert.ok(keyData.key === seedKey.key, 'Stored credential hash preserved');
151
+ assert.equal(keyData.mask, seedKey.mask);
152
+ });
153
+
154
+ await check('human preview has warning toast and confirmation suggestion', () => {
155
+ const out = xy(['import', bundle]);
156
+ assert.match(out, /⚠️[\s\S]*Preview only\./);
157
+ assert.match(out, /Preview only[\s\S]*--confirm/);
158
+ });
159
+
160
+ await check('confirm plus dry remains preview', () => assert.equal(json(['import', bundle, '--confirm', '--dry']).preview, true));
161
+
162
+ const before = await call('getMultiple', { lists: 'all' });
163
+
164
+ await check('previews do not create anything', () => {
165
+ for (const [,list] of Object.values(types)) assert.ok(JSON.stringify(before[list].filter(item => item.id !== seedKey.id)) === JSON.stringify(original[list]), list + ' unchanged by preview');
166
+ });
167
+
168
+ const imported = json(['import', bundle, '--confirm']);
169
+
170
+ await check('create all other types plus workflow and update API key', () => assert.ok(imported.items.length === 13 && imported.items.every(item => item.status === (item.type === 'api_key' ? 'updated' : 'created'))));
171
+
172
+ const keyResult = imported.items.find(item => item.type === 'api_key');
173
+
174
+ await check('API key import retains ID', () => assert.equal(keyResult.id, seedKey.id));
175
+
176
+ const state = await call('getMultiple', { lists: 'all' });
177
+ const key = state.api_keys.find(item => item.id === keyResult.id);
178
+
179
+ await check('API key import retains credential hash and mask', () => {
180
+ assert.ok(key.key === seedKey.key, 'Stored credential hash preserved');
181
+ assert.equal(key.mask, seedKey.mask);
182
+ });
183
+
184
+ for (const [type, [command, list]] of Object.entries(types)) {
185
+ const obj = state[list].find(obj => obj.title === prefix + type);
186
+ assert.ok(obj, type);
187
+ const path = Path.join(temp, type + '.json');
188
+
189
+ await check('export ' + type, () => {
190
+ const result = json([command, obj.id, '--export', path]);
191
+ assert.equal(result.count, 1);
192
+ const payload = JSON.parse(fs.readFileSync(path));
193
+ assert.equal(payload.type, 'xypdf');
194
+ assert.equal(payload.version, '1.0');
195
+ assert.equal(payload.items[0].type, type);
196
+ assert.equal(payload.items[0].data.id, obj.id);
197
+ for (const name of ['created','modified','revision','sort_order','username']) assert.ok(!(name in payload.items[0].data));
198
+ assert.equal(fs.statSync(path).mode & 0o777, 0o600);
199
+
200
+ if (type === 'api_key') {
201
+ assert.ok(payload.items[0].data.key === seedKey.key, 'Stored credential hash preserved');
202
+ assert.equal(payload.items[0].data.mask, seedKey.mask);
203
+ }
204
+ });
205
+
206
+ await check('round-trip update ' + type, () => {
207
+ assert.equal(json(['import', path]).items[0].operation, 'update');
208
+ const result = json(['import', path, '--confirm']);
209
+ assert.equal(result.items[0].status, 'updated');
210
+ assert.ok(!('plain_key' in result.items[0]));
211
+ });
212
+ }
213
+
214
+ const bucketID = prefix + 'bucket';
215
+ await call('writeBucketData', { id: bucketID, data: { sentinel: 'Keep me' } });
216
+ const bucketPath = Path.join(temp, 'bucket-with-data.json');
217
+ json(['bucket', bucketID, '--export', bucketPath]);
218
+
219
+ await check('bucket export contains metadata only', () => assert.ok(!fs.readFileSync(bucketPath, 'utf8').includes('Keep me')));
220
+
221
+ json(['import', bucketPath, '--confirm']);
222
+ const bucket = await call('getBucket', { id: bucketID });
223
+
224
+ await check('bucket import preserves stored data', () => assert.equal(bucket.data.sentinel, 'Keep me'));
225
+
226
+ const workflowPath = Path.join(temp, "workflow's export.json.gz");
227
+
228
+ await check('workflow dependencies and gzip', () => {
229
+ const result = json(['event', prefix + 'workflow', '--export', workflowPath, '--deps', 'all']);
230
+ assert.equal(result.count, 6);
231
+ const payload = JSON.parse(zlib.gunzipSync(fs.readFileSync(workflowPath)));
232
+ assert.equal(payload.items[0].data.id, prefix + 'workflow');
233
+ assert.equal(json(['import', workflowPath]).items.length, 6);
234
+ });
235
+
236
+ await check('gzip confirmed import', () => assert.ok(json(['import', workflowPath, '--confirm']).items.every(item => item.status === 'updated')));
237
+
238
+ await check('export refuses overwrite and leaves no temp file', () => {
239
+ const originalBytes = fs.readFileSync(workflowPath);
240
+ xy(['event', prefix + 'workflow', '--export', workflowPath], true);
241
+ assert.deepEqual(fs.readFileSync(workflowPath), originalBytes);
242
+ assert.ok(!fs.readdirSync(temp).some(name => name.startsWith('.xycli-export-')));
243
+ });
244
+
245
+ await check('explicit overwrite', () => assert.equal(json(['event', prefix + 'workflow', '--export', workflowPath, '--overwrite']).count, 1));
246
+
247
+ const dryPath = Path.join(temp, 'dry.json');
248
+
249
+ await check('dry export and named fuzzy selector', () => {
250
+ assert.equal(json(['monitor', 'get', '--title', prefix + 'monitor', '--export', dryPath, '--dry']).payload.items[0].data.id, prefix + 'monitor');
251
+ assert.ok(!fs.existsSync(dryPath));
252
+ });
253
+
254
+ for (const args of [
255
+ ['events', '--export', dryPath],
256
+ ['event', 'update', prefix + 'event', '--export', dryPath],
257
+ ['monitor', prefix + 'monitor', '--export', dryPath, '--deps', 'all'],
258
+ ['event', prefix + 'event', '--export', dryPath, '--deps', 'invalid'],
259
+ ['event', prefix + 'event', '--export']
260
+ ]) {
261
+ await check('reject invalid export arguments ' + args.slice(0, 2).join(' '), () => xy(args, true));
262
+ }
263
+
264
+ const invalidFiles = [
265
+ ['missing wrapper', {}],
266
+ ['empty items', wrap([])],
267
+ ['unsupported format', { ...wrap([items[1]]), version: '2.0' }],
268
+ ['newer server required', { ...wrap([items[1]]), xyops: '999.0.0' }],
269
+ ['unsupported object type', wrap([{ type: 'job', data: { title: 'Bad' } }])],
270
+ ['duplicate IDs', wrap([items[1], items[1]])],
271
+ ['invalid ID', wrap([{ type: 'monitor', data: { id: '../bad', title: 'Bad' } }])],
272
+ ['unsafe property', JSON.parse('{"type":"xypdf","version":"1.0","items":[{"type":"monitor","data":{"title":"Bad","params":{"__proto__":{"polluted":true}}}}]}')]
273
+ ];
274
+
275
+ for (const [name, payload] of invalidFiles) {
276
+ await check('reject invalid file: ' + name, () => xy(['import', file('invalid.json', payload), '--confirm'], true));
277
+ }
278
+
279
+ const malformed = Path.join(temp, 'broken.json.gz');
280
+ fs.writeFileSync(malformed, 'broken');
281
+
282
+ await check('malformed gzip', () => xy(['import', malformed], true));
283
+
284
+ const active = fixture('event', { triggers: [{ type: 'schedule', enabled: true }] });
285
+
286
+ await check('preview flags active triggers', () => assert.ok(json(['import', file('active.json', wrap([active]))]).warnings.some(text => /active triggers/.test(text))));
287
+
288
+ const partial = wrap([fixture('tag', { id: prefix + 'pending' }), fixture('monitor', { id: prefix + 'bad_monitor', source: 'cpu.(', data_type: 'float' }), fixture('tag', { id: prefix + 'completed' })]);
289
+
290
+ await check('partial failure reports completed, failed and pending', () => {
291
+ const result = json(['import', file('partial.json', partial), '--confirm'], true);
292
+ assert.deepEqual(result.items.map(item => item.status), ['created','failed','pending']);
293
+ });
294
+
295
+ await check('human success output', () => assert.match(xy(['import', Path.join(temp, 'monitor.json'), '--confirm']), /IMPORT RESULTS[\s\S]*updated/));
296
+
297
+ for (const topic of ['export','import']) await check('help ' + topic, () => assert.match(xy(['help', topic]), /XYPDF/));
298
+ const now = await call('getMultiple', { lists: 'all' });
299
+
300
+ await check('existing definitions unchanged', () => {
301
+ for (const [,list] of Object.values(types)) for (const obj of original[list]) assert.ok(JSON.stringify(now[list].find(item => item.id === obj.id)) === JSON.stringify(obj), list + ' original definition unchanged');
302
+ });
303
+ }
304
+ finally {
305
+ // Consumers precede dependencies in this registry. Match titles as well
306
+ // as IDs to catch server-generated IDs when creation partially succeeds.
307
+ await cleanupFixtures(Object.values(types).map(([, list, suffix]) => ({
308
+ list,
309
+ method: 'delete' + suffix,
310
+ match: item => item.id.startsWith(prefix) || (item.title || '').startsWith(prefix),
311
+ sort: (a, b) => Number(b.type === 'workflow') - Number(a.type === 'workflow')
312
+ })));
313
+ }
314
+ });