@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,542 @@
|
|
|
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, call, cleanupFixtures } = require('./helpers/common.js');
|
|
6
|
+
|
|
7
|
+
test('sync', async t => {
|
|
8
|
+
loadTestConfig();
|
|
9
|
+
const check = createCheck(t);
|
|
10
|
+
|
|
11
|
+
// Use unique, disabled definitions with no active triggers. The Plugin and
|
|
12
|
+
// Event source code is stored but never executed by this suite.
|
|
13
|
+
const stamp = Date.now();
|
|
14
|
+
const prefix = 'cli_sync_test_' + stamp + '_';
|
|
15
|
+
const categoryID = prefix + 'category';
|
|
16
|
+
const deleteCategoryID = prefix + 'delete_category';
|
|
17
|
+
const pluginID = prefix + 'plugin';
|
|
18
|
+
const eventID = prefix + 'event';
|
|
19
|
+
const categoryTitle = 'CLI Sync Test ' + stamp + ' Category';
|
|
20
|
+
const deleteCategoryTitle = 'CLI Sync Test ' + stamp + ' Delete Category';
|
|
21
|
+
const pluginTitle = 'CLI Sync Test ' + stamp + ' Plugin';
|
|
22
|
+
const eventTitle = 'CLI Sync Test ' + stamp + ' Event';
|
|
23
|
+
const temp = createTempDir(t, 'xycli-sync-test-');
|
|
24
|
+
const syncRoot = Path.join(temp, 'active-sync-tree');
|
|
25
|
+
const deleteSetupRoot = Path.join(temp, 'delete-setup');
|
|
26
|
+
|
|
27
|
+
const originalCategoryNotes = 'Original category notes';
|
|
28
|
+
const originalPluginNotes = 'Original plugin notes';
|
|
29
|
+
const originalEventNotes = 'Original event notes';
|
|
30
|
+
const originalPluginScript = '#!/usr/bin/env node\nconsole.log("original plugin");\n';
|
|
31
|
+
const originalEventScript = '#!/bin/bash\necho "original event"\n';
|
|
32
|
+
const updatedCategoryNotes = 'Updated by filesystem sync';
|
|
33
|
+
const updatedPluginNotes = 'Updated Plugin metadata from XYPDF';
|
|
34
|
+
const updatedEventNotes = 'Updated Event metadata from XYPDF';
|
|
35
|
+
const updatedPluginScript = '#!/usr/bin/env node\nconsole.log("updated plugin");\n';
|
|
36
|
+
const updatedEventScript = '#!/bin/bash\necho "updated event"\n';
|
|
37
|
+
const downCategoryNotes = 'Updated remotely for downsync';
|
|
38
|
+
const downPluginNotes = 'Remote Plugin metadata for downsync';
|
|
39
|
+
const downEventNotes = 'Remote Event metadata for downsync';
|
|
40
|
+
const downPluginScript = '#!/usr/bin/env node\nconsole.log("remote plugin");\n';
|
|
41
|
+
const downEventScript = '#!/bin/bash\necho "remote event"\n';
|
|
42
|
+
const governedState = {
|
|
43
|
+
['category-' + categoryID]: true,
|
|
44
|
+
['plugin-' + pluginID]: true,
|
|
45
|
+
['event-' + eventID]: true
|
|
46
|
+
};
|
|
47
|
+
const mixedState = {
|
|
48
|
+
['category-' + categoryID]: true
|
|
49
|
+
};
|
|
50
|
+
let originalSyncState = {};
|
|
51
|
+
let capturedSyncState = false;
|
|
52
|
+
|
|
53
|
+
function slugify(title) {
|
|
54
|
+
// Match setupSync() exactly so the test locates its own generated files
|
|
55
|
+
// without depending on directory enumeration order.
|
|
56
|
+
return title.replace(/\W+/g, '-').replace(/\-+$/, '').replace(/^\-+/, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readXYPDF(file) {
|
|
60
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeXYPDF(file, data) {
|
|
64
|
+
fs.writeFileSync(file, JSON.stringify(data, null, '\t') + '\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function copyIntoSyncTree(file) {
|
|
68
|
+
// setup exports every object in each selected list. Copy only this suite's
|
|
69
|
+
// files into the directory used for the actual sync, so unrelated server
|
|
70
|
+
// definitions are never candidates for updates.
|
|
71
|
+
const destination = Path.join(syncRoot, Path.basename(file));
|
|
72
|
+
fs.copyFileSync(file, destination);
|
|
73
|
+
return destination;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function getFixtures() {
|
|
77
|
+
const data = await call('getMultiple', { lists: 'categories,plugins,events', state: 1 });
|
|
78
|
+
return {
|
|
79
|
+
category: data.categories.find(item => item.id === categoryID),
|
|
80
|
+
deleteCategory: data.categories.find(item => item.id === deleteCategoryID),
|
|
81
|
+
plugin: data.plugins.find(item => item.id === pluginID),
|
|
82
|
+
event: data.events.find(item => item.id === eventID),
|
|
83
|
+
syncState: (data.state && data.state.sync) || {}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
let group;
|
|
89
|
+
|
|
90
|
+
await check('local server has a target group and preserves existing sync state', async () => {
|
|
91
|
+
const data = await call('getMultiple', { lists: 'groups', state: 1 });
|
|
92
|
+
group = data.groups[0];
|
|
93
|
+
assert.ok(group, 'At least one server group is required');
|
|
94
|
+
|
|
95
|
+
// The sync map is a single authoritative global object. Preserve any map
|
|
96
|
+
// already present on the developer's server and restore it in finally.
|
|
97
|
+
originalSyncState = JSON.parse(JSON.stringify((data.state && data.state.sync) || {}));
|
|
98
|
+
capturedSyncState = true;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await check('create disposable sync definitions', async () => {
|
|
102
|
+
await call('createCategory', {
|
|
103
|
+
id: categoryID,
|
|
104
|
+
title: categoryTitle,
|
|
105
|
+
enabled: false,
|
|
106
|
+
color: 'blue',
|
|
107
|
+
icon: 'folder-outline',
|
|
108
|
+
notes: originalCategoryNotes,
|
|
109
|
+
actions: [],
|
|
110
|
+
limits: []
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// This unreferenced Category is reserved for the delete-mode lifecycle.
|
|
114
|
+
// Keeping it separate lets the main Category remain attached to the Event.
|
|
115
|
+
await call('createCategory', {
|
|
116
|
+
id: deleteCategoryID,
|
|
117
|
+
title: deleteCategoryTitle,
|
|
118
|
+
enabled: false,
|
|
119
|
+
color: 'gray',
|
|
120
|
+
icon: 'trash-can-outline',
|
|
121
|
+
notes: 'Disposable delete-mode fixture',
|
|
122
|
+
actions: [],
|
|
123
|
+
limits: []
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await call('createPlugin', {
|
|
127
|
+
id: pluginID,
|
|
128
|
+
title: pluginTitle,
|
|
129
|
+
enabled: false,
|
|
130
|
+
type: 'event',
|
|
131
|
+
command: '/usr/bin/node',
|
|
132
|
+
script: originalPluginScript,
|
|
133
|
+
params: [{ id: 'script', title: 'Script', type: 'code', value: '', required: false }],
|
|
134
|
+
groups: [],
|
|
135
|
+
format: '',
|
|
136
|
+
uid: '',
|
|
137
|
+
gid: '',
|
|
138
|
+
kill: 'parent',
|
|
139
|
+
runner: false,
|
|
140
|
+
notes: originalPluginNotes
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await call('createEvent', {
|
|
144
|
+
id: eventID,
|
|
145
|
+
title: eventTitle,
|
|
146
|
+
enabled: false,
|
|
147
|
+
category: categoryID,
|
|
148
|
+
plugin: pluginID,
|
|
149
|
+
params: { script: originalEventScript },
|
|
150
|
+
fields: [],
|
|
151
|
+
tags: [],
|
|
152
|
+
targets: [group.id],
|
|
153
|
+
algo: 'random',
|
|
154
|
+
notes: originalEventNotes,
|
|
155
|
+
actions: [],
|
|
156
|
+
limits: [],
|
|
157
|
+
triggers: []
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await check('sync setup exports selected resource types', () => {
|
|
162
|
+
const output = xy([
|
|
163
|
+
'sync', 'setup', 'categories', 'plugins', 'events',
|
|
164
|
+
'--file_props', 'script,params.script'
|
|
165
|
+
], { cwd: temp });
|
|
166
|
+
|
|
167
|
+
assert.match(output, /SYNC SETUP/);
|
|
168
|
+
assert.match(output, /Setup complete!/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const categoryExport = Path.join(temp, 'categories', slugify(categoryTitle) + '.json');
|
|
172
|
+
const pluginExport = Path.join(temp, 'plugins', slugify(pluginTitle) + '.json');
|
|
173
|
+
const pluginScriptExport = Path.join(temp, 'plugins', slugify(pluginTitle) + '-script.js');
|
|
174
|
+
const eventExport = Path.join(temp, 'events', slugify(eventTitle) + '.json');
|
|
175
|
+
const eventScriptExport = Path.join(temp, 'events', slugify(eventTitle) + '-params.script.sh');
|
|
176
|
+
|
|
177
|
+
await check('setup writes valid XYPDF and external property files', () => {
|
|
178
|
+
const category = readXYPDF(categoryExport);
|
|
179
|
+
const plugin = readXYPDF(pluginExport);
|
|
180
|
+
const event = readXYPDF(eventExport);
|
|
181
|
+
|
|
182
|
+
assert.equal(category.type, 'xypdf');
|
|
183
|
+
assert.equal(category.version, '1.0');
|
|
184
|
+
assert.equal(category.items.length, 1);
|
|
185
|
+
assert.equal(category.items[0].type, 'category');
|
|
186
|
+
assert.equal(category.items[0].data.id, categoryID);
|
|
187
|
+
assert.equal(plugin.items[0].data.id, pluginID);
|
|
188
|
+
assert.equal(plugin.items[0].data.script, '(External)');
|
|
189
|
+
assert.equal(event.items[0].data.id, eventID);
|
|
190
|
+
assert.equal(event.items[0].data.params.script, '(External)');
|
|
191
|
+
|
|
192
|
+
for (const payload of [category, plugin, event]) {
|
|
193
|
+
for (const key of ['created', 'modified', 'revision', 'sort_order', 'username']) {
|
|
194
|
+
assert.ok(!(key in payload.items[0].data), 'Export omits ' + key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
assert.equal(fs.readFileSync(pluginScriptExport, 'utf8'), originalPluginScript);
|
|
199
|
+
assert.equal(fs.readFileSync(eventScriptExport, 'utf8'), originalEventScript);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
let categoryFile, pluginFile, pluginScriptFile, eventFile, eventScriptFile;
|
|
203
|
+
|
|
204
|
+
await check('prepare isolated arbitrary-layout sync tree', () => {
|
|
205
|
+
fs.mkdirSync(syncRoot, { recursive: true });
|
|
206
|
+
categoryFile = copyIntoSyncTree(categoryExport);
|
|
207
|
+
pluginFile = copyIntoSyncTree(pluginExport);
|
|
208
|
+
pluginScriptFile = copyIntoSyncTree(pluginScriptExport);
|
|
209
|
+
eventFile = copyIntoSyncTree(eventExport);
|
|
210
|
+
eventScriptFile = copyIntoSyncTree(eventScriptExport);
|
|
211
|
+
assert.equal(fs.readdirSync(syncRoot).length, 5);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await check('edit XYPDF metadata and external source files', () => {
|
|
215
|
+
const category = readXYPDF(categoryFile);
|
|
216
|
+
const plugin = readXYPDF(pluginFile);
|
|
217
|
+
const event = readXYPDF(eventFile);
|
|
218
|
+
|
|
219
|
+
category.items[0].data.notes = updatedCategoryNotes;
|
|
220
|
+
plugin.items[0].data.notes = updatedPluginNotes;
|
|
221
|
+
event.items[0].data.notes = updatedEventNotes;
|
|
222
|
+
|
|
223
|
+
writeXYPDF(categoryFile, category);
|
|
224
|
+
writeXYPDF(pluginFile, plugin);
|
|
225
|
+
writeXYPDF(eventFile, event);
|
|
226
|
+
fs.writeFileSync(pluginScriptFile, updatedPluginScript);
|
|
227
|
+
fs.writeFileSync(eventScriptFile, updatedEventScript);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await check('dry upsync previews every update without changing xyOps', async () => {
|
|
231
|
+
const output = xy([
|
|
232
|
+
'sync', syncRoot, '--up', 'categories,plugins,events', '--dry'
|
|
233
|
+
], { cwd: temp });
|
|
234
|
+
const fixtures = await getFixtures();
|
|
235
|
+
|
|
236
|
+
assert.match(output, /DRY RUN/);
|
|
237
|
+
assert.match(output, /API REQUEST PREVIEW: update_category/);
|
|
238
|
+
assert.match(output, /API REQUEST PREVIEW: update_plugin/);
|
|
239
|
+
assert.match(output, /API REQUEST PREVIEW: update_event/);
|
|
240
|
+
assert.match(output, /API REQUEST PREVIEW: update_global_state/);
|
|
241
|
+
assert.equal(fixtures.category.notes, originalCategoryNotes);
|
|
242
|
+
assert.equal(fixtures.plugin.notes, originalPluginNotes);
|
|
243
|
+
assert.equal(fixtures.plugin.script, originalPluginScript);
|
|
244
|
+
assert.equal(fixtures.event.notes, originalEventNotes);
|
|
245
|
+
assert.equal(fixtures.event.params.script, originalEventScript);
|
|
246
|
+
assert.deepEqual(fixtures.syncState, originalSyncState);
|
|
247
|
+
for (const item of [fixtures.category, fixtures.plugin, fixtures.event]) {
|
|
248
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(item, 'sync'), 'Dry upsync leaves resources free of sync metadata');
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await check('upsync applies XYPDF and external property changes', async () => {
|
|
253
|
+
const output = xy([
|
|
254
|
+
'sync', syncRoot, '--up', 'categories,plugins,events'
|
|
255
|
+
], { cwd: temp });
|
|
256
|
+
const fixtures = await getFixtures();
|
|
257
|
+
|
|
258
|
+
assert.match(output, /Updating category/);
|
|
259
|
+
assert.match(output, /Updating plugin/);
|
|
260
|
+
assert.match(output, /Updating event/);
|
|
261
|
+
assert.equal(fixtures.category.notes, updatedCategoryNotes);
|
|
262
|
+
assert.equal(fixtures.plugin.notes, updatedPluginNotes);
|
|
263
|
+
assert.equal(fixtures.plugin.script, updatedPluginScript);
|
|
264
|
+
assert.equal(fixtures.event.notes, updatedEventNotes);
|
|
265
|
+
assert.equal(fixtures.event.params.script, updatedEventScript);
|
|
266
|
+
assert.deepEqual(fixtures.syncState, governedState);
|
|
267
|
+
for (const item of [fixtures.category, fixtures.plugin, fixtures.event]) {
|
|
268
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(item, 'sync'), 'Upsync leaves resources free of sync metadata');
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await check('update commands protect definitions under up-only sync management', async () => {
|
|
273
|
+
const blockedNotes = 'Blocked direct update';
|
|
274
|
+
const blocked = xy([
|
|
275
|
+
'category', 'update', categoryID, '--notes', blockedNotes
|
|
276
|
+
]);
|
|
277
|
+
const afterBlocked = await getFixtures();
|
|
278
|
+
|
|
279
|
+
assert.match(blocked, /under remote management/);
|
|
280
|
+
assert.match(blocked, /--confirm/);
|
|
281
|
+
assert.equal(afterBlocked.category.notes, updatedCategoryNotes);
|
|
282
|
+
|
|
283
|
+
const confirmedDryRun = xy([
|
|
284
|
+
'category', 'update', categoryID, '--notes', blockedNotes, '--confirm', '--dry'
|
|
285
|
+
]);
|
|
286
|
+
const afterDryRun = await getFixtures();
|
|
287
|
+
|
|
288
|
+
assert.doesNotMatch(confirmedDryRun, /under remote management/);
|
|
289
|
+
assert.match(confirmedDryRun, /API REQUEST PREVIEW: updateCategory/);
|
|
290
|
+
assert.equal(afterDryRun.category.notes, updatedCategoryNotes);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await check('System reset clears sync governance only after confirmation', async () => {
|
|
294
|
+
const unconfirmed = xy(['system', 'reset', 'sync']);
|
|
295
|
+
const afterUnconfirmed = await getFixtures();
|
|
296
|
+
|
|
297
|
+
assert.match(unconfirmed, /clear all sync state flags/);
|
|
298
|
+
assert.match(unconfirmed, /--confirm/);
|
|
299
|
+
assert.deepEqual(afterUnconfirmed.syncState, governedState);
|
|
300
|
+
|
|
301
|
+
const dryRun = xy(['system', 'reset', 'sync', '--confirm', '--dry']);
|
|
302
|
+
const afterDryRun = await getFixtures();
|
|
303
|
+
|
|
304
|
+
assert.match(dryRun, /API REQUEST PREVIEW: update_global_state/);
|
|
305
|
+
assert.deepEqual(afterDryRun.syncState, governedState);
|
|
306
|
+
|
|
307
|
+
const confirmed = xy(['system', 'reset', 'sync', '--confirm']);
|
|
308
|
+
const afterConfirmed = await getFixtures();
|
|
309
|
+
|
|
310
|
+
assert.match(confirmed, /sync state flags have been reset/i);
|
|
311
|
+
assert.deepEqual(afterConfirmed.syncState, {});
|
|
312
|
+
assert.equal(afterConfirmed.category.notes, updatedCategoryNotes);
|
|
313
|
+
assert.equal(afterConfirmed.plugin.notes, updatedPluginNotes);
|
|
314
|
+
assert.equal(afterConfirmed.event.notes, updatedEventNotes);
|
|
315
|
+
|
|
316
|
+
// An unchanged up-only sync must republish its complete governance map.
|
|
317
|
+
xy(['sync', syncRoot, '--up', 'categories,plugins,events'], { cwd: temp });
|
|
318
|
+
assert.deepEqual((await getFixtures()).syncState, governedState);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await check('unchanged upsync is a no-op', async () => {
|
|
322
|
+
const before = await getFixtures();
|
|
323
|
+
const output = xy([
|
|
324
|
+
'sync', syncRoot, '--up', 'categories,plugins,events', '--verbose'
|
|
325
|
+
], { cwd: temp });
|
|
326
|
+
const after = await getFixtures();
|
|
327
|
+
|
|
328
|
+
assert.doesNotMatch(output, /Updating (category|plugin|event)/);
|
|
329
|
+
assert.doesNotMatch(output, /API REQUEST: update_global_state/);
|
|
330
|
+
assert.equal(after.category.revision, before.category.revision);
|
|
331
|
+
assert.equal(after.plugin.revision, before.plugin.revision);
|
|
332
|
+
assert.equal(after.event.revision, before.event.revision);
|
|
333
|
+
assert.deepEqual(after.syncState, governedState);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await check('change xyOps resources for downsync', async () => {
|
|
337
|
+
await call('updateCategory', { id: categoryID, notes: downCategoryNotes });
|
|
338
|
+
await call('updatePlugin', { id: pluginID, notes: downPluginNotes, script: downPluginScript });
|
|
339
|
+
await call('updateEvent', { id: eventID, notes: downEventNotes, params: { script: downEventScript } });
|
|
340
|
+
|
|
341
|
+
const fixtures = await getFixtures();
|
|
342
|
+
assert.equal(fixtures.category.notes, downCategoryNotes);
|
|
343
|
+
assert.equal(fixtures.plugin.notes, downPluginNotes);
|
|
344
|
+
assert.equal(fixtures.plugin.script, downPluginScript);
|
|
345
|
+
assert.equal(fixtures.event.notes, downEventNotes);
|
|
346
|
+
assert.equal(fixtures.event.params.script, downEventScript);
|
|
347
|
+
assert.deepEqual(fixtures.syncState, governedState);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
await check('dry downsync previews writes without changing files or state', async () => {
|
|
351
|
+
const before = [categoryFile, pluginFile, pluginScriptFile, eventFile, eventScriptFile].map(file => fs.readFileSync(file, 'utf8'));
|
|
352
|
+
const output = xy([
|
|
353
|
+
'sync', syncRoot, '--down', 'categories,plugins,events', '--dry'
|
|
354
|
+
], { cwd: temp });
|
|
355
|
+
const after = [categoryFile, pluginFile, pluginScriptFile, eventFile, eventScriptFile].map(file => fs.readFileSync(file, 'utf8'));
|
|
356
|
+
const fixtures = await getFixtures();
|
|
357
|
+
|
|
358
|
+
assert.match(output, /DRY RUN/);
|
|
359
|
+
assert.equal((output.match(/WRITING FILE:/g) || []).length, 3);
|
|
360
|
+
assert.match(output, /API REQUEST PREVIEW: update_global_state/);
|
|
361
|
+
assert.deepEqual(after, before);
|
|
362
|
+
assert.deepEqual(fixtures.syncState, governedState);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await check('downsync writes files and clears global sync governance', async () => {
|
|
366
|
+
const output = xy([
|
|
367
|
+
'sync', syncRoot, '--down', 'categories,plugins,events'
|
|
368
|
+
], { cwd: temp });
|
|
369
|
+
const category = readXYPDF(categoryFile);
|
|
370
|
+
const plugin = readXYPDF(pluginFile);
|
|
371
|
+
const event = readXYPDF(eventFile);
|
|
372
|
+
const fixtures = await getFixtures();
|
|
373
|
+
|
|
374
|
+
assert.match(output, /Updating category/);
|
|
375
|
+
assert.match(output, /Updating plugin/);
|
|
376
|
+
assert.match(output, /Updating event/);
|
|
377
|
+
assert.equal(category.items[0].data.notes, downCategoryNotes);
|
|
378
|
+
assert.equal(plugin.items[0].data.notes, downPluginNotes);
|
|
379
|
+
assert.equal(plugin.items[0].data.script, '(External)');
|
|
380
|
+
assert.equal(event.items[0].data.notes, downEventNotes);
|
|
381
|
+
assert.equal(event.items[0].data.params.script, '(External)');
|
|
382
|
+
assert.equal(fs.readFileSync(pluginScriptFile, 'utf8'), downPluginScript);
|
|
383
|
+
assert.equal(fs.readFileSync(eventScriptFile, 'utf8'), downEventScript);
|
|
384
|
+
assert.deepEqual(fixtures.syncState, {});
|
|
385
|
+
for (const item of [fixtures.category, fixtures.plugin, fixtures.event]) {
|
|
386
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(item, 'sync'), 'Downsync leaves resources free of sync metadata');
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
await check('unchanged downsync and state are a no-op', async () => {
|
|
391
|
+
const files = [categoryFile, pluginFile, pluginScriptFile, eventFile, eventScriptFile];
|
|
392
|
+
const before = files.map(file => fs.readFileSync(file, 'utf8'));
|
|
393
|
+
const output = xy([
|
|
394
|
+
'sync', syncRoot, '--down', 'categories,plugins,events', '--verbose'
|
|
395
|
+
], { cwd: temp });
|
|
396
|
+
const after = files.map(file => fs.readFileSync(file, 'utf8'));
|
|
397
|
+
const fixtures = await getFixtures();
|
|
398
|
+
|
|
399
|
+
assert.doesNotMatch(output, /Updating (category|plugin|event)/);
|
|
400
|
+
assert.doesNotMatch(output, /API REQUEST: update_global_state/);
|
|
401
|
+
assert.deepEqual(after, before);
|
|
402
|
+
assert.deepEqual(fixtures.syncState, {});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
await check('mixed directions govern only up-only resource types', async () => {
|
|
406
|
+
const output = xy([
|
|
407
|
+
'sync', syncRoot,
|
|
408
|
+
'--up', 'categories,plugins',
|
|
409
|
+
'--down', 'plugins,events'
|
|
410
|
+
], { cwd: temp });
|
|
411
|
+
const fixtures = await getFixtures();
|
|
412
|
+
|
|
413
|
+
assert.doesNotMatch(output, /Updating (category|plugin|event)/);
|
|
414
|
+
assert.deepEqual(fixtures.syncState, mixedState);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
let deleteCategoryFile;
|
|
418
|
+
let categoriesBeforeDelete;
|
|
419
|
+
let deleteSyncState;
|
|
420
|
+
|
|
421
|
+
await check('prepare and verify a complete Category delete inventory', async () => {
|
|
422
|
+
fs.mkdirSync(deleteSetupRoot, { recursive: true });
|
|
423
|
+
xy(['sync', 'setup', 'categories'], { cwd: deleteSetupRoot });
|
|
424
|
+
|
|
425
|
+
const deleteSyncRoot = Path.join(deleteSetupRoot, 'categories');
|
|
426
|
+
const sourceFiles = fs.readdirSync(deleteSyncRoot)
|
|
427
|
+
.filter(file => file.match(/\.json$/i))
|
|
428
|
+
.map(file => Path.join(deleteSyncRoot, file));
|
|
429
|
+
const sourceByID = new Map();
|
|
430
|
+
|
|
431
|
+
for (const file of sourceFiles) {
|
|
432
|
+
const payload = readXYPDF(file);
|
|
433
|
+
const id = payload.items && payload.items[0] && payload.items[0].data && payload.items[0].data.id;
|
|
434
|
+
assert.ok(id, 'Every Category source has an ID');
|
|
435
|
+
assert.ok(!sourceByID.has(id), 'Category ID is exported exactly once: ' + id);
|
|
436
|
+
sourceByID.set(id, file);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
categoriesBeforeDelete = (await call('getMultiple', { lists: 'categories' })).categories;
|
|
440
|
+
const remoteIDs = new Set(categoriesBeforeDelete.map(category => category.id));
|
|
441
|
+
for (const id of sourceByID.keys()) {
|
|
442
|
+
assert.ok(remoteIDs.has(id), 'Export contains an existing Category: ' + id);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Stock and Marketplace definitions need no local source to be protected.
|
|
446
|
+
for (const category of categoriesBeforeDelete) {
|
|
447
|
+
if (Object.hasOwn(category, 'stock') || Object.hasOwn(category, 'marketplace')) continue;
|
|
448
|
+
assert.ok(sourceByID.has(category.id), 'Export contains Category: ' + category.id);
|
|
449
|
+
}
|
|
450
|
+
deleteSyncState = Object.fromEntries([...sourceByID.keys()]
|
|
451
|
+
.filter(id => id !== deleteCategoryID).map(id => ['category-' + id, true]));
|
|
452
|
+
|
|
453
|
+
deleteCategoryFile = sourceByID.get(deleteCategoryID);
|
|
454
|
+
assert.ok(deleteCategoryFile, 'Disposable delete Category was exported');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await check('malformed XYPDF aborts delete mode', async () => {
|
|
458
|
+
const validSource = fs.readFileSync(deleteCategoryFile, 'utf8');
|
|
459
|
+
writeXYPDF(deleteCategoryFile, { type: 'xypdf', version: '1.0', items: [] });
|
|
460
|
+
|
|
461
|
+
const output = xy([
|
|
462
|
+
'sync', Path.dirname(deleteCategoryFile), '--up', 'categories', '--down', 'false', '--delete', 'categories'
|
|
463
|
+
], { cwd: temp, fail: true });
|
|
464
|
+
const fixtures = await getFixtures();
|
|
465
|
+
|
|
466
|
+
assert.match(output, /empty, malformed or missing items array/);
|
|
467
|
+
assert.doesNotMatch(output, /Deleting category/);
|
|
468
|
+
assert.ok(fixtures.deleteCategory, 'Malformed source did not delete its Category');
|
|
469
|
+
assert.deepEqual(fixtures.syncState, mixedState, 'Malformed input leaves global sync governance untouched');
|
|
470
|
+
fs.writeFileSync(deleteCategoryFile, validSource);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
await check('dry delete previews removal without changing xyOps', async () => {
|
|
474
|
+
fs.unlinkSync(deleteCategoryFile);
|
|
475
|
+
const output = xy([
|
|
476
|
+
'sync', Path.dirname(deleteCategoryFile), '--up', 'categories', '--down', 'false', '--delete', 'categories', '--dry'
|
|
477
|
+
], { cwd: temp });
|
|
478
|
+
const fixtures = await getFixtures();
|
|
479
|
+
|
|
480
|
+
assert.match(output, /DRY RUN/);
|
|
481
|
+
assert.match(output, new RegExp('Deleting category.*' + deleteCategoryTitle));
|
|
482
|
+
assert.match(output, /API REQUEST PREVIEW: delete_category/);
|
|
483
|
+
assert.match(output, /API REQUEST PREVIEW: update_global_state/);
|
|
484
|
+
assert.ok(fixtures.deleteCategory, 'Dry delete preserved its Category');
|
|
485
|
+
assert.deepEqual(fixtures.syncState, mixedState, 'Dry delete preserves global sync governance');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await check('delete mode removes only the missing disposable Category', async () => {
|
|
489
|
+
// Recheck the live inventory immediately before the destructive command.
|
|
490
|
+
// If another process added or removed a Category after setup, stop here.
|
|
491
|
+
const categoriesImmediatelyBefore = (await call('getMultiple', { lists: 'categories' })).categories;
|
|
492
|
+
assert.deepEqual(
|
|
493
|
+
categoriesImmediatelyBefore.map(item => item.id).sort(),
|
|
494
|
+
categoriesBeforeDelete.map(item => item.id).sort(),
|
|
495
|
+
'Live Category inventory is unchanged since the verified export'
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
const output = xy([
|
|
499
|
+
'sync', Path.dirname(deleteCategoryFile), '--up', 'categories', '--down', 'false', '--delete', 'categories'
|
|
500
|
+
], { cwd: temp });
|
|
501
|
+
const fixtures = await getFixtures();
|
|
502
|
+
const categoriesAfterDelete = (await call('getMultiple', { lists: 'categories' })).categories;
|
|
503
|
+
const expectedIDs = categoriesBeforeDelete.map(item => item.id).filter(id => id !== deleteCategoryID).sort();
|
|
504
|
+
const actualIDs = categoriesAfterDelete.map(item => item.id).sort();
|
|
505
|
+
|
|
506
|
+
assert.match(output, new RegExp('Deleting category.*' + deleteCategoryTitle));
|
|
507
|
+
assert.equal(fixtures.deleteCategory, undefined);
|
|
508
|
+
assert.deepEqual(actualIDs, expectedIDs, 'No other Categories were deleted');
|
|
509
|
+
assert.ok(fixtures.category && fixtures.plugin && fixtures.event, 'Resources outside the missing fixture remain');
|
|
510
|
+
assert.deepEqual(fixtures.syncState, deleteSyncState, 'Up-only delete mode tracks the remaining Category sources');
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
const cleanupErrors = [];
|
|
515
|
+
|
|
516
|
+
// Restore the developer's complete pre-test governance map. Do this even
|
|
517
|
+
// when a lifecycle assertion fails after an up-only sync changed the map.
|
|
518
|
+
if (capturedSyncState) {
|
|
519
|
+
try {
|
|
520
|
+
await call('updateGlobalState', { sync: originalSyncState });
|
|
521
|
+
const restored = await call('getMultiple', { lists: 'categories', state: 1 });
|
|
522
|
+
assert.deepEqual((restored.state && restored.state.sync) || {}, originalSyncState);
|
|
523
|
+
}
|
|
524
|
+
catch (error) { cleanupErrors.push(error); }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Consumers must be deleted before the Plugin and Category they reference.
|
|
528
|
+
// Prefix matching also catches a partial create before its response returned.
|
|
529
|
+
try {
|
|
530
|
+
await cleanupFixtures([
|
|
531
|
+
{ list: 'events', method: 'deleteEvent', match: item => item.id === eventID || (item.title || '').startsWith('CLI Sync Test ' + stamp) },
|
|
532
|
+
{ list: 'plugins', method: 'deletePlugin', match: item => item.id === pluginID || (item.title || '').startsWith('CLI Sync Test ' + stamp) },
|
|
533
|
+
{ list: 'categories', method: 'deleteCategory', match: item => [categoryID, deleteCategoryID].includes(item.id) || (item.title || '').startsWith('CLI Sync Test ' + stamp) }
|
|
534
|
+
]);
|
|
535
|
+
}
|
|
536
|
+
catch (error) { cleanupErrors.push(error); }
|
|
537
|
+
|
|
538
|
+
if (cleanupErrors.length) {
|
|
539
|
+
throw new AggregateError(cleanupErrors, 'Sync fixture or global-state cleanup failed. Check the local server before rerunning.');
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { loadTestConfig, createCheck, createTempDir, xy, json, call: apiCall } = require('./helpers/common.js');
|
|
7
|
+
|
|
8
|
+
test('system', async t => {
|
|
9
|
+
loadTestConfig();
|
|
10
|
+
const check = createCheck(t);
|
|
11
|
+
const temp = createTempDir(t, 'xycli-system-test-');
|
|
12
|
+
|
|
13
|
+
async function waitForInternalJob(id) {
|
|
14
|
+
// Maintenance APIs return as soon as the background job starts. Wait for
|
|
15
|
+
// each approved test operation to finish before starting the next one.
|
|
16
|
+
const timeout = Date.now() + 120000;
|
|
17
|
+
while (Date.now() < timeout) {
|
|
18
|
+
const data = await apiCall('getMultiple', { lists: ['tags'], jobs: 1 });
|
|
19
|
+
if (!data.internalJobs || !data.internalJobs[id]) return;
|
|
20
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
21
|
+
}
|
|
22
|
+
throw new Error('Timed out waiting for System job: ' + id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await check('JSON dashboard contains system, conductor, job, and user data', () => {
|
|
26
|
+
const data = json(['system']);
|
|
27
|
+
assert.ok(data.stats && data.stats.db && data.stats.db.records);
|
|
28
|
+
assert.ok(data.masters && typeof(data.masters) === 'object');
|
|
29
|
+
assert.ok(data.internalJobs && typeof(data.internalJobs) === 'object');
|
|
30
|
+
assert.ok(Array.isArray(data.users));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await check('human dashboard combines statistics, conductors, and connected users', () => {
|
|
34
|
+
const out = xy(['system']);
|
|
35
|
+
for (const label of ['System Status & Maintenance', 'Process CPU', 'DB Disk Size', 'All Conductors', 'All Connected Users', 'Other Commands']) {
|
|
36
|
+
assert.match(out, new RegExp(label.replace(/[&]/g, '\\&'), 'i'), label);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await check('diagnostic command returns a structured JSON snapshot', () => {
|
|
41
|
+
const data = json(['system', 'diagnostic']);
|
|
42
|
+
assert.equal(typeof(data.generated), 'number');
|
|
43
|
+
assert.ok(data.system && data.system.db);
|
|
44
|
+
assert.ok(data.conductors && typeof(data.conductors) === 'object');
|
|
45
|
+
assert.ok(data.workers && typeof(data.workers) === 'object');
|
|
46
|
+
assert.ok(data.state && typeof(data.state) === 'object');
|
|
47
|
+
assert.ok(data.stats && typeof(data.stats) === 'object');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await check('bulk export downloads a complete private gzip archive', () => {
|
|
51
|
+
const file = Path.join(temp, 'tags-export.json.gz');
|
|
52
|
+
const result = json(['system', 'export', file, '--lists', 'tags']);
|
|
53
|
+
assert.equal(result.code, 0);
|
|
54
|
+
assert.equal(result.file, file);
|
|
55
|
+
assert.ok(result.size > 0);
|
|
56
|
+
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
|
57
|
+
|
|
58
|
+
const body = zlib.gunzipSync(fs.readFileSync(file)).toString('utf8');
|
|
59
|
+
assert.match(body, /^# xyOps Data Export v1\.0/m);
|
|
60
|
+
assert.match(body, /^# List: global\/tags$/m);
|
|
61
|
+
assert.match(body, /^# End of file$/m);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await check('broadcast sends an informational message', () => {
|
|
65
|
+
const message = 'xycli System test ' + Date.now();
|
|
66
|
+
const result = json(['system', 'broadcast', message, '--type', 'info']);
|
|
67
|
+
assert.equal(result.code, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await check('rate-limit reset completes successfully', () => {
|
|
71
|
+
const result = json(['system', 'reset', 'rates']);
|
|
72
|
+
assert.equal(result.code, 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await check('database optimization starts and completes', async () => {
|
|
76
|
+
const result = json(['system', 'optimize']);
|
|
77
|
+
assert.equal(result.code, 0);
|
|
78
|
+
assert.match(result.id, /^i\w+$/);
|
|
79
|
+
await waitForInternalJob(result.id);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await check('maintenance starts and completes', async () => {
|
|
83
|
+
const result = json(['system', 'maintenance']);
|
|
84
|
+
assert.equal(result.code, 0);
|
|
85
|
+
assert.match(result.id, /^i\w+$/);
|
|
86
|
+
await waitForInternalJob(result.id);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await check('System help chapters render', () => {
|
|
90
|
+
for (const section of ['system', 'system export', 'system maintenance', 'system optimize', 'system reset', 'system diagnostic', 'system broadcast']) {
|
|
91
|
+
assert.match(xy(['help', ...section.split(' ')]), new RegExp('HELP: ' + section.toUpperCase()));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|