@pixlcore/xycli 1.0.7 → 1.0.9
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 +2 -2
- package/docs/help.md +22 -3
- package/docs/sync.md +71 -9
- package/docs/two-way-git-tutorial.md +19 -3
- package/index.js +32 -5
- package/lib/apikey.js +1 -1
- package/lib/buckets.js +1 -5
- package/lib/marketplace.js +1 -1
- package/lib/sync-buckets.js +315 -0
- package/lib/sync-syshook.js +207 -0
- package/lib/sync.js +128 -43
- package/lib/transfer.js +3 -3
- package/lib/utils.js +8 -3
- package/package.json +2 -2
- package/test/README.md +5 -3
- package/test/help.test.js +56 -0
- package/test/sync-buckets-unit.test.js +188 -0
- package/test/sync-buckets.test.js +133 -0
- package/test/sync-syshook-unit.test.js +231 -0
- package/test/sync-unit.test.js +118 -2
- package/test/toast.test.js +3 -3
- package/test/utils-unit.test.js +50 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const Path = require('node:path');
|
|
6
|
+
const bucketSync = require('../lib/sync-buckets.js');
|
|
7
|
+
const sync = require('../lib/sync.js');
|
|
8
|
+
const utils = require('../lib/utils.js');
|
|
9
|
+
|
|
10
|
+
// The bucket pass uses the ordinary sync logging functions, whose output is
|
|
11
|
+
// provided by pixl-cli in production. Keep the test output quiet and local.
|
|
12
|
+
global.println = () => {};
|
|
13
|
+
|
|
14
|
+
function fixture(t, options = {}) {
|
|
15
|
+
const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-bucket-sync-unit-'));
|
|
16
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
17
|
+
const item = { type: 'bucket', data: { id: 'bucket_unit', title: 'Changed Title' }, file: Path.join(dir, 'custom', 'Original-Name.json') };
|
|
18
|
+
const paths = bucketSync.getSyncBucketPaths(item);
|
|
19
|
+
fs.mkdirSync(paths.files, { recursive: true });
|
|
20
|
+
fs.writeFileSync(item.file, '{}');
|
|
21
|
+
fs.writeFileSync(paths.data, JSON.stringify(options.localData || { keep: true, removed: true }));
|
|
22
|
+
fs.utimesSync(paths.data, 100, options.localDataTime || 100);
|
|
23
|
+
|
|
24
|
+
const remote = {
|
|
25
|
+
bucket: { id: item.data.id, title: item.data.title },
|
|
26
|
+
data: options.remoteData || { keep: false, server: true },
|
|
27
|
+
meta: { mod: options.remoteDataTime || 90 },
|
|
28
|
+
files: options.remoteFiles || []
|
|
29
|
+
};
|
|
30
|
+
const bytes = options.remoteBytes || {};
|
|
31
|
+
const calls = [];
|
|
32
|
+
const app = {
|
|
33
|
+
...bucketSync,
|
|
34
|
+
...sync,
|
|
35
|
+
compareVersions: utils.compareVersions,
|
|
36
|
+
xyopsVersion: options.version || '1.1.2',
|
|
37
|
+
dry: !!options.dry,
|
|
38
|
+
errors: [],
|
|
39
|
+
warnings: [],
|
|
40
|
+
api: {
|
|
41
|
+
async getBucket() { return { data: structuredClone(remote) }; },
|
|
42
|
+
async file(request, settings) {
|
|
43
|
+
const path = 'files/' + request.path;
|
|
44
|
+
if (!Object.hasOwn(bytes, path)) return { err: new Error('Missing remote bytes') };
|
|
45
|
+
fs.writeFileSync(settings.download, bytes[path]);
|
|
46
|
+
return { data: {} };
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async callStandardAPI(method, request, settings = {}) {
|
|
50
|
+
calls.push({ method, request, settings });
|
|
51
|
+
if (method == 'writeBucketData') {
|
|
52
|
+
remote.data = structuredClone(request.data);
|
|
53
|
+
remote.meta.mod = 120;
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
if (method == 'uploadBucketFiles') {
|
|
57
|
+
settings.files.forEach(path => {
|
|
58
|
+
const name = app.cleanSyncBucketFilename(Path.basename(path));
|
|
59
|
+
let file = remote.files.find(entry => entry.filename == name);
|
|
60
|
+
if (!file) {
|
|
61
|
+
file = { filename: name, path: 'files/bucket/bucket_unit/new/' + name };
|
|
62
|
+
remote.files.push(file);
|
|
63
|
+
}
|
|
64
|
+
file.size = fs.statSync(path).size;
|
|
65
|
+
file.date = 130;
|
|
66
|
+
});
|
|
67
|
+
return { files: structuredClone(remote.files) };
|
|
68
|
+
}
|
|
69
|
+
if (method == 'deleteBucketFile') {
|
|
70
|
+
remote.files = remote.files.filter(file => file.path != request.path);
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
throw new Error('Unexpected API: ' + method);
|
|
74
|
+
},
|
|
75
|
+
logSyncError(message) { this.errors.push(message); },
|
|
76
|
+
logSyncWarning(message) { this.warnings.push(message); },
|
|
77
|
+
markdown(text) { return text; },
|
|
78
|
+
die(message) { throw new Error(message); }
|
|
79
|
+
};
|
|
80
|
+
return { dir, item, paths, remote, calls, app };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('bucket sync rejects xyOps versions older than 1.1.2', t => {
|
|
84
|
+
const current = fixture(t);
|
|
85
|
+
assert.doesNotThrow(() => current.app.requireBucketSyncVersion());
|
|
86
|
+
current.app.xyopsVersion = '1.1.1';
|
|
87
|
+
assert.throws(() => current.app.requireBucketSyncVersion(), /v1\.1\.2 or later/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('bucket setup writes data and binary files with server mtimes beside the XYPDF source', async t => {
|
|
91
|
+
const file = { filename: 'Sample.JSON', path: 'files/bucket/bucket_unit/key/Sample.JSON', size: 8, date: 80 };
|
|
92
|
+
const env = fixture(t, { remoteFiles: [file], remoteBytes: { [file.path]: 'not json' } });
|
|
93
|
+
fs.rmSync(env.paths.dir, { recursive: true, force: true });
|
|
94
|
+
await env.app.setupBucketContents([{ id: env.item.data.id, file: env.item.file }], { dry: false, force: false });
|
|
95
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(env.paths.data, 'utf8')), env.remote.data);
|
|
96
|
+
assert.equal(fs.readFileSync(Path.join(env.paths.files, 'Sample.JSON'), 'utf8'), 'not json');
|
|
97
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 90);
|
|
98
|
+
assert.equal(fs.statSync(Path.join(env.paths.files, 'Sample.JSON')).mtimeMs / 1000, 80);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('upsync replaces data, normalizes manifest names, batches uploads, and deletes missing files last', async t => {
|
|
102
|
+
const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
|
|
103
|
+
const env = fixture(t, { remoteFiles: [removed] });
|
|
104
|
+
// Semicolon needs server filename normalization but remains a regular file on Windows.
|
|
105
|
+
const upload = Path.join(env.paths.files, 'New; File.txt');
|
|
106
|
+
fs.writeFileSync(upload, 'new');
|
|
107
|
+
fs.utimesSync(upload, 110, 110);
|
|
108
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
|
|
109
|
+
assert.deepEqual(env.calls.map(call => call.method), ['writeBucketData', 'uploadBucketFiles', 'deleteBucketFile']);
|
|
110
|
+
assert.equal(env.calls[0].request.replace, true);
|
|
111
|
+
assert.deepEqual(env.remote.data, { keep: true, removed: true });
|
|
112
|
+
assert.equal(env.remote.files[0].filename, 'New_ File.txt');
|
|
113
|
+
assert.equal(fs.statSync(upload).mtimeMs / 1000, 130);
|
|
114
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 120);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('downsync replaces changed files and preserves the server timestamp', async t => {
|
|
118
|
+
const file = { filename: 'same.txt', path: 'files/bucket/bucket_unit/key/same.txt', size: 6, date: 140 };
|
|
119
|
+
const env = fixture(t, { remoteFiles: [file], remoteBytes: { [file.path]: 'remote' }, remoteDataTime: 150 });
|
|
120
|
+
const localFile = Path.join(env.paths.files, file.filename);
|
|
121
|
+
fs.writeFileSync(localFile, 'old');
|
|
122
|
+
fs.utimesSync(localFile, 100, 100);
|
|
123
|
+
await env.app.syncBucketContents([env.item], { down: ['buckets'] });
|
|
124
|
+
assert.equal(fs.readFileSync(localFile, 'utf8'), 'remote');
|
|
125
|
+
assert.equal(fs.statSync(localFile).mtimeMs / 1000, 140);
|
|
126
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(env.paths.data, 'utf8')), env.remote.data);
|
|
127
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 150);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('two-way sync chooses the newer data and file copies independently', async t => {
|
|
131
|
+
const old = { filename: 'remote.txt', path: 'files/bucket/bucket_unit/key/remote.txt', size: 6, date: 140 };
|
|
132
|
+
const env = fixture(t, {
|
|
133
|
+
localDataTime: 160,
|
|
134
|
+
remoteDataTime: 150,
|
|
135
|
+
remoteFiles: [old],
|
|
136
|
+
remoteBytes: { [old.path]: 'remote'
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const localFile = Path.join(env.paths.files, old.filename);
|
|
140
|
+
fs.writeFileSync(localFile, 'stale');
|
|
141
|
+
fs.utimesSync(localFile, 100, 100);
|
|
142
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], down: ['buckets'] });
|
|
143
|
+
assert.deepEqual(env.calls.map(call => call.method), ['writeBucketData']);
|
|
144
|
+
assert.equal(fs.readFileSync(localFile, 'utf8'), 'remote');
|
|
145
|
+
assert.equal(fs.statSync(localFile).mtimeMs / 1000, 140);
|
|
146
|
+
assert.deepEqual(env.remote.data, env.app.readSyncBucketLocal(env.item).data);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('two-way sync reports equal-time content conflicts instead of guessing', async t => {
|
|
150
|
+
const env = fixture(t, { remoteDataTime: 100 });
|
|
151
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], down: ['buckets'] });
|
|
152
|
+
assert.equal(env.app.errors.length, 1);
|
|
153
|
+
assert.match(env.app.errors[0], /equal timestamps/);
|
|
154
|
+
assert.deepEqual(env.calls, []);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('invalid local data blocks every bucket mutation including file deletion', async t => {
|
|
158
|
+
const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
|
|
159
|
+
const env = fixture(t, { remoteFiles: [removed] });
|
|
160
|
+
fs.writeFileSync(env.paths.data, '{invalid');
|
|
161
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
|
|
162
|
+
assert.equal(env.app.errors.length, 1);
|
|
163
|
+
assert.deepEqual(env.calls, []);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('bucket dry runs leave data, files, and server records unchanged', async t => {
|
|
167
|
+
const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
|
|
168
|
+
const env = fixture(t, { remoteFiles: [removed], dry: true });
|
|
169
|
+
const added = Path.join(env.paths.files, 'new.txt');
|
|
170
|
+
fs.writeFileSync(added, 'new');
|
|
171
|
+
const originalTime = fs.statSync(env.paths.data).mtimeMs;
|
|
172
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
|
|
173
|
+
assert.deepEqual(env.calls, []);
|
|
174
|
+
assert.equal(env.remote.files.length, 1);
|
|
175
|
+
assert.deepEqual(env.remote.data, { keep: false, server: true });
|
|
176
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs, originalTime);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a failed download keeps the previous local file intact', async t => {
|
|
180
|
+
const file = { filename: 'same.txt', path: 'files/bucket/bucket_unit/key/same.txt', size: 6, date: 140 };
|
|
181
|
+
const env = fixture(t, { remoteFiles: [file] });
|
|
182
|
+
const localFile = Path.join(env.paths.files, file.filename);
|
|
183
|
+
fs.writeFileSync(localFile, 'before');
|
|
184
|
+
fs.utimesSync(localFile, 100, 100);
|
|
185
|
+
await env.app.syncBucketContents([env.item], { down: ['buckets'] });
|
|
186
|
+
assert.equal(env.app.errors.length, 1);
|
|
187
|
+
assert.equal(fs.readFileSync(localFile, 'utf8'), 'before');
|
|
188
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
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 { api } = require('@pixlcore/xyops-sdk');
|
|
6
|
+
const { loadTestConfig, createCheck, createTempDir, xy, call } = require('./helpers/common.js');
|
|
7
|
+
|
|
8
|
+
test('bucket filesystem sync', async t => {
|
|
9
|
+
loadTestConfig();
|
|
10
|
+
const check = createCheck(t);
|
|
11
|
+
const temp = createTempDir(t, 'xycli-bucket-sync-live-');
|
|
12
|
+
const setupRoot = Path.join(temp, 'setup');
|
|
13
|
+
const activeRoot = Path.join(temp, 'active');
|
|
14
|
+
const stamp = Date.now();
|
|
15
|
+
const id = 'cli_bucket_sync_test_' + stamp;
|
|
16
|
+
const title = 'CLI Bucket Sync Test ' + stamp;
|
|
17
|
+
const slug = title.replace(/\W+/g, '-').replace(/^-|-$/g, '');
|
|
18
|
+
const seed = Path.join(temp, 'seed.json');
|
|
19
|
+
let originalState = null;
|
|
20
|
+
let created = false;
|
|
21
|
+
|
|
22
|
+
fs.mkdirSync(setupRoot, { recursive: true });
|
|
23
|
+
fs.mkdirSync(activeRoot, { recursive: true });
|
|
24
|
+
fs.writeFileSync(seed, 'seed');
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await check('create an isolated Bucket fixture', async () => {
|
|
28
|
+
const before = await call('getMultiple', { lists: 'buckets', state: 1 });
|
|
29
|
+
originalState = structuredClone((before.state && before.state.sync) || {});
|
|
30
|
+
|
|
31
|
+
// --new will skip existing Buckets without downloading their content.
|
|
32
|
+
for (const bucket of before.buckets) {
|
|
33
|
+
fs.writeFileSync(Path.join(setupRoot, 'skip-' + bucket.id + '.json'), JSON.stringify({
|
|
34
|
+
type: 'xypdf', version: '1.0', items: [{ type: 'bucket', data: bucket }]
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
await call('createBucket', { id, title, enabled: false, data: { keep: true, remove: true } });
|
|
38
|
+
created = true;
|
|
39
|
+
const uploaded = await api.uploadBucketFiles({ id }, { files: [seed] });
|
|
40
|
+
assert.ok(!uploaded.err && uploaded.data && uploaded.data.code === 0, 'Seed upload succeeded');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await check('setup exports Bucket data and files', async () => {
|
|
44
|
+
const output = xy(['sync', 'setup', 'buckets', '--new'], { cwd: setupRoot });
|
|
45
|
+
assert.match(output, /🪣.*\/\s*\n/);
|
|
46
|
+
assert.match(output, /📄.*data\.json/);
|
|
47
|
+
assert.match(output, /📂.*files\//);
|
|
48
|
+
assert.match(output, /📎.*seed\.json/);
|
|
49
|
+
assert.doesNotMatch(output, /Bucket content:/);
|
|
50
|
+
const source = Path.join(setupRoot, 'buckets', slug);
|
|
51
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(Path.join(source, 'data.json'), 'utf8')), { keep: true, remove: true });
|
|
52
|
+
assert.equal(fs.readFileSync(Path.join(source, 'files', 'seed.json'), 'utf8'), 'seed');
|
|
53
|
+
fs.mkdirSync(Path.join(activeRoot, 'buckets'), { recursive: true });
|
|
54
|
+
fs.cpSync(source + '.json', Path.join(activeRoot, 'buckets', slug + '.json'));
|
|
55
|
+
fs.cpSync(source, Path.join(activeRoot, 'buckets', slug), { recursive: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await check('upsync replaces data and uploads a new file', async () => {
|
|
59
|
+
const content = Path.join(activeRoot, 'buckets', slug);
|
|
60
|
+
// Semicolon exercises filename normalization on Unix and Windows alike.
|
|
61
|
+
const added = Path.join(content, 'files', 'New; File.txt');
|
|
62
|
+
const source = Path.join(activeRoot, 'buckets', slug + '.json');
|
|
63
|
+
const xypdf = JSON.parse(fs.readFileSync(source, 'utf8'));
|
|
64
|
+
xypdf.items[0].data.notes = 'Local Bucket notes';
|
|
65
|
+
fs.writeFileSync(source, JSON.stringify(xypdf));
|
|
66
|
+
fs.writeFileSync(Path.join(content, 'data.json'), JSON.stringify({ keep: 'local' }));
|
|
67
|
+
fs.writeFileSync(added, 'new content');
|
|
68
|
+
const output = xy(['sync', activeRoot, '--up', 'buckets', '--down', 'false', '--delete', 'false']);
|
|
69
|
+
assert.match(output, new RegExp('Updating bucket: ' + title + ' \\(' + id + '\\)'));
|
|
70
|
+
assert.match(output, new RegExp('\\n\\n ⬆️ Updating bucket data: ' + title + ' \\(' + id + '\\)'));
|
|
71
|
+
assert.match(output, /-.*"keep": true/);
|
|
72
|
+
assert.match(output, /\+.*"keep": "local"/);
|
|
73
|
+
assert.match(output, new RegExp('\\n\\n ⬆️ Uploading \\d+ bucket files?: ' + title + ' \\(' + id + '\\)'));
|
|
74
|
+
assert.match(output, /📎.*New; File\.txt/);
|
|
75
|
+
assert.ok(output.indexOf('Updating bucket data:') > output.indexOf('Updating bucket:'), 'Bucket data has its own section after the definition');
|
|
76
|
+
const bucket = await call('getBucket', { id });
|
|
77
|
+
assert.equal(bucket.bucket.notes, 'Local Bucket notes');
|
|
78
|
+
assert.deepEqual(bucket.data, { keep: 'local' });
|
|
79
|
+
const file = bucket.files.find(entry => entry.filename === 'New_ File.txt');
|
|
80
|
+
assert.ok(file, 'Normalized upload is in the manifest');
|
|
81
|
+
assert.equal(Math.floor(fs.statSync(added).mtimeMs / 1000), file.date);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await check('downsync pulls remote data and file changes', async () => {
|
|
85
|
+
await call('updateBucket', { id, notes: 'Remote Bucket notes' });
|
|
86
|
+
await call('writeBucketData', { id, replace: true, data: { keep: 'remote' } });
|
|
87
|
+
fs.writeFileSync(seed, 'remote replacement');
|
|
88
|
+
const uploaded = await api.uploadBucketFiles({ id }, { files: [seed] });
|
|
89
|
+
assert.ok(!uploaded.err && uploaded.data && uploaded.data.code === 0, 'Replacement upload succeeded');
|
|
90
|
+
xy(['sync', activeRoot, '--up', 'false', '--down', 'buckets', '--delete', 'false']);
|
|
91
|
+
const content = Path.join(activeRoot, 'buckets', slug);
|
|
92
|
+
const xypdf = JSON.parse(fs.readFileSync(content + '.json', 'utf8'));
|
|
93
|
+
assert.equal(xypdf.items[0].data.notes, 'Remote Bucket notes');
|
|
94
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(Path.join(content, 'data.json'), 'utf8')), { keep: 'remote' });
|
|
95
|
+
assert.equal(fs.readFileSync(Path.join(content, 'files', 'seed.json'), 'utf8'), 'remote replacement');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await check('two-way sync pushes newer local content and aligns timestamps', async () => {
|
|
99
|
+
const content = Path.join(activeRoot, 'buckets', slug);
|
|
100
|
+
const dataFile = Path.join(content, 'data.json');
|
|
101
|
+
const file = Path.join(content, 'files', 'seed.json');
|
|
102
|
+
const newer = Math.ceil(Date.now() / 1000) + 5;
|
|
103
|
+
fs.writeFileSync(dataFile, JSON.stringify({ keep: 'two-way' }));
|
|
104
|
+
fs.writeFileSync(file, 'two-way file');
|
|
105
|
+
fs.utimesSync(dataFile, newer, newer);
|
|
106
|
+
fs.utimesSync(file, newer, newer);
|
|
107
|
+
const output = xy(['sync', activeRoot, '--up', 'buckets', '--down', 'buckets', '--delete', 'false']);
|
|
108
|
+
assert.match(output, new RegExp('\\n\\n ⬆️ Updating bucket data: ' + title + ' \\(' + id + '\\)'));
|
|
109
|
+
assert.match(output, /-.*"keep": "remote"/);
|
|
110
|
+
assert.match(output, /\+.*"keep": "two-way"/);
|
|
111
|
+
const bucket = await call('getBucket', { id });
|
|
112
|
+
assert.deepEqual(bucket.data, { keep: 'two-way' });
|
|
113
|
+
const remoteFile = bucket.files.find(entry => entry.filename === 'seed.json');
|
|
114
|
+
assert.equal(remoteFile.size, Buffer.byteLength('two-way file'));
|
|
115
|
+
assert.equal(Math.floor(fs.statSync(file).mtimeMs / 1000), remoteFile.date);
|
|
116
|
+
assert.equal(Math.floor(fs.statSync(dataFile).mtimeMs / 1000), bucket.meta.mod);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
// Ordinary sync writes a shared management map. Restore it exactly, then
|
|
121
|
+
// delete only this uniquely named Bucket and its content.
|
|
122
|
+
const cleanupErrors = [];
|
|
123
|
+
if (originalState !== null) {
|
|
124
|
+
try { await call('update_global_state', { sync: originalState }); }
|
|
125
|
+
catch (error) { cleanupErrors.push(error); }
|
|
126
|
+
}
|
|
127
|
+
if (created) {
|
|
128
|
+
try { await call('deleteBucket', { id }); }
|
|
129
|
+
catch (error) { cleanupErrors.push(error); }
|
|
130
|
+
}
|
|
131
|
+
if (cleanupErrors.length) throw new AggregateError(cleanupErrors, 'Bucket sync fixture cleanup failed');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const Path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
function makeTree(t) {
|
|
9
|
+
var dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-syshook-test-'));
|
|
10
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function writeSource(dir, name, type, data) {
|
|
15
|
+
var file = Path.join(dir, name);
|
|
16
|
+
fs.mkdirSync(Path.dirname(file), { recursive: true });
|
|
17
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
18
|
+
type: 'xypdf', version: '1.0', items: [{ type, data }]
|
|
19
|
+
}));
|
|
20
|
+
return file;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function runHook(dir, action, payload, extra = [], use_default_lock = false) {
|
|
24
|
+
// Strip xyOps connection variables to match the conductor's shell-hook
|
|
25
|
+
// environment. The hook must complete without credentials or API access.
|
|
26
|
+
var env = { ...process.env, HOME: dir };
|
|
27
|
+
for (const key of Object.keys(env)) {
|
|
28
|
+
if (key.startsWith('XYOPS_')) delete env[key];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var args = [Path.join(__dirname, '..', 'index.js'), 'sync', dir, '--syshook', action];
|
|
32
|
+
if (!use_default_lock) args.push('--lock_file', Path.join(dir, '.sync-lock'));
|
|
33
|
+
args.push(...extra);
|
|
34
|
+
|
|
35
|
+
return spawnSync(process.execPath, args, {
|
|
36
|
+
input: payload === null ? '' : JSON.stringify(payload) + '\n',
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
timeout: 10000,
|
|
39
|
+
env
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function markerCommand(name) {
|
|
44
|
+
var code = `require('node:fs').writeFileSync(${JSON.stringify(name)}, process.cwd())`;
|
|
45
|
+
return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(code)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('syshook deletes only the exact Event and its owned neighbors', t => {
|
|
49
|
+
var dir = makeTree(t);
|
|
50
|
+
var event = writeSource(dir, 'events/Ops/Nightly.json', 'event', {
|
|
51
|
+
id: 'evt_one', title: 'Nightly', params: { script: '(External)' }
|
|
52
|
+
});
|
|
53
|
+
var neighbor = Path.join(dir, 'events/Ops/Nightly-params.script.sh');
|
|
54
|
+
fs.writeFileSync(neighbor, 'echo one\n');
|
|
55
|
+
var other = writeSource(dir, 'events/Ops/Nightly-V2.json', 'event', {
|
|
56
|
+
id: 'evt_two', title: 'Nightly V2', params: { script: '(External)' }
|
|
57
|
+
});
|
|
58
|
+
var other_neighbor = Path.join(dir, 'events/Ops/Nightly-V2-params.script.sh');
|
|
59
|
+
fs.writeFileSync(other_neighbor, 'echo two\n');
|
|
60
|
+
var new_event = writeSource(dir, 'events/Ops/New.json', 'event', { id: 'evt_new', title: 'New' });
|
|
61
|
+
var same_id_plugin = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'evt_one', title: 'Shared' });
|
|
62
|
+
var marker = Path.join(dir, 'completed.txt');
|
|
63
|
+
|
|
64
|
+
var result = runHook(dir, 'event_delete', {
|
|
65
|
+
xy: 1, action: 'event_delete', event: { id: 'evt_one', title: 'Nightly' }
|
|
66
|
+
}, ['--down_cmd', markerCommand('completed.txt')]);
|
|
67
|
+
|
|
68
|
+
assert.ifError(result.error);
|
|
69
|
+
assert.equal(result.status, 0, result.stderr);
|
|
70
|
+
assert.equal(fs.existsSync(event), false);
|
|
71
|
+
assert.equal(fs.existsSync(neighbor), false);
|
|
72
|
+
assert.equal(fs.existsSync(other), true);
|
|
73
|
+
assert.equal(fs.existsSync(other_neighbor), true);
|
|
74
|
+
assert.equal(fs.existsSync(new_event), true);
|
|
75
|
+
assert.equal(fs.existsSync(same_id_plugin), true);
|
|
76
|
+
assert.equal(fs.readFileSync(marker, 'utf8'), fs.realpathSync(dir));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('syshook uses its default lock without a configured xyOps URL or API Key', t => {
|
|
80
|
+
var dir = makeTree(t);
|
|
81
|
+
var event = writeSource(dir, 'events/Delete-Me.json', 'event', { id: 'evt_no_config' });
|
|
82
|
+
var result = runHook(dir, 'event_delete', {
|
|
83
|
+
xy: 1, action: 'event_delete', event: { id: 'evt_no_config' }
|
|
84
|
+
}, [], true);
|
|
85
|
+
|
|
86
|
+
assert.equal(result.status, 0, result.stderr);
|
|
87
|
+
assert.equal(fs.existsSync(event), false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('syshook rejects a mismatched action without deleting files', t => {
|
|
91
|
+
var dir = makeTree(t);
|
|
92
|
+
var file = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
|
|
93
|
+
var result = runHook(dir, 'plugin_delete', {
|
|
94
|
+
xy: 1, action: 'event_delete', plugin: { id: 'plugin_one' }
|
|
95
|
+
}, ['--down_cmd', markerCommand('completed.txt')]);
|
|
96
|
+
|
|
97
|
+
assert.equal(result.status, 1);
|
|
98
|
+
assert.match(result.stderr, /does not match --syshook plugin_delete/);
|
|
99
|
+
assert.equal(fs.existsSync(file), true);
|
|
100
|
+
assert.equal(fs.existsSync(Path.join(dir, 'completed.txt')), false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('syshook deletes a matching Plugin without changing another Plugin', t => {
|
|
104
|
+
var dir = makeTree(t);
|
|
105
|
+
var target = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
|
|
106
|
+
var other = writeSource(dir, 'plugins/Other.json', 'plugin', { id: 'plugin_two', title: 'Other' });
|
|
107
|
+
var result = runHook(dir, 'plugin_delete', {
|
|
108
|
+
xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
assert.equal(result.status, 0, result.stderr);
|
|
112
|
+
assert.equal(fs.existsSync(target), false);
|
|
113
|
+
assert.equal(fs.existsSync(other), true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Each remaining standard sync type uses the same exact type-and-ID lookup.
|
|
117
|
+
// Include another Event with the target ID to catch an accidental ID-only
|
|
118
|
+
// match, especially for the API Key action's different spelling.
|
|
119
|
+
for (const [action, type] of [
|
|
120
|
+
['alert_delete', 'alert'],
|
|
121
|
+
['apikey_delete', 'api_key'],
|
|
122
|
+
['category_delete', 'category'],
|
|
123
|
+
['channel_delete', 'channel'],
|
|
124
|
+
['group_delete', 'group'],
|
|
125
|
+
['monitor_delete', 'monitor'],
|
|
126
|
+
['tag_delete', 'tag'],
|
|
127
|
+
['web_hook_delete', 'web_hook']
|
|
128
|
+
]) {
|
|
129
|
+
test('syshook deletes one ' + type + ' for ' + action, t => {
|
|
130
|
+
var dir = makeTree(t);
|
|
131
|
+
var target = writeSource(dir, type + '/Target.json', type, { id: 'shared_id' });
|
|
132
|
+
var other = writeSource(dir, type + '/Other.json', type, { id: 'other_id' });
|
|
133
|
+
var event = writeSource(dir, 'events/Shared.json', 'event', { id: 'shared_id' });
|
|
134
|
+
var result = runHook(dir, action, {
|
|
135
|
+
xy: 1, action, [type]: { id: 'shared_id' }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
assert.equal(result.status, 0, result.stderr);
|
|
139
|
+
assert.equal(fs.existsSync(target), false);
|
|
140
|
+
assert.equal(fs.existsSync(other), true);
|
|
141
|
+
assert.equal(fs.existsSync(event), true);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
test('syshook rejects bucket deletion and preserves its source', t => {
|
|
146
|
+
var dir = makeTree(t);
|
|
147
|
+
var bucket = writeSource(dir, 'buckets/Shared.json', 'bucket', { id: 'bucket_one' });
|
|
148
|
+
var result = runHook(dir, 'bucket_delete', {
|
|
149
|
+
xy: 1, action: 'bucket_delete', bucket: { id: 'bucket_one' }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
assert.equal(result.status, 1);
|
|
153
|
+
assert.match(result.stderr, /Invalid --syshook action/);
|
|
154
|
+
assert.equal(fs.existsSync(bucket), true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('syshook checks neighboring files before deleting the source', t => {
|
|
158
|
+
var dir = makeTree(t);
|
|
159
|
+
var target = writeSource(dir, 'events/Ops/Nightly.json', 'event', { id: 'evt_one', title: 'Nightly' });
|
|
160
|
+
var unrelated = Path.join(dir, 'events/Ops/Nightly-other.txt');
|
|
161
|
+
fs.writeFileSync(unrelated, 'keep me\n');
|
|
162
|
+
var result = runHook(dir, 'event_delete', {
|
|
163
|
+
xy: 1, action: 'event_delete', event: { id: 'evt_one' }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
assert.equal(result.status, 1);
|
|
167
|
+
assert.match(result.stderr, /Neighbor file does not match a string property/);
|
|
168
|
+
assert.equal(fs.existsSync(target), true);
|
|
169
|
+
assert.equal(fs.existsSync(unrelated), true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('syshook stops on malformed JSON before deleting a matching source', t => {
|
|
173
|
+
var dir = makeTree(t);
|
|
174
|
+
var target = writeSource(dir, 'events/Ops/Nightly.json', 'event', { id: 'evt_one', title: 'Nightly' });
|
|
175
|
+
fs.writeFileSync(Path.join(dir, 'events/Ops/Broken.json'), '{bad json');
|
|
176
|
+
var result = runHook(dir, 'event_delete', {
|
|
177
|
+
xy: 1, action: 'event_delete', event: { id: 'evt_one' }
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
assert.equal(result.status, 1);
|
|
181
|
+
assert.match(result.stderr, /Failed to parse JSON file/);
|
|
182
|
+
assert.equal(fs.existsSync(target), true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('syshook refuses duplicate XYPDF sources before changing either file', t => {
|
|
186
|
+
var dir = makeTree(t);
|
|
187
|
+
var data = { id: 'plugin_one', title: 'Shared', script: '(External)' };
|
|
188
|
+
var first = writeSource(dir, 'plugins/First.json', 'plugin', data);
|
|
189
|
+
var second = writeSource(dir, 'plugins/Second.json', 'plugin', data);
|
|
190
|
+
var result = runHook(dir, 'plugin_delete', {
|
|
191
|
+
xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' }
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
assert.equal(result.status, 1);
|
|
195
|
+
assert.match(result.stderr, /Duplicate XYPDF sources/);
|
|
196
|
+
assert.equal(fs.existsSync(first), true);
|
|
197
|
+
assert.equal(fs.existsSync(second), true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('syshook dry run and absent source never run down_cmd', t => {
|
|
201
|
+
var dir = makeTree(t);
|
|
202
|
+
var file = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
|
|
203
|
+
var command = markerCommand('completed.txt');
|
|
204
|
+
var payload = { xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' } };
|
|
205
|
+
var preview = runHook(dir, 'plugin_delete', payload, ['--dry', '--down_cmd', command]);
|
|
206
|
+
|
|
207
|
+
assert.equal(preview.status, 0, preview.stderr);
|
|
208
|
+
assert.match(preview.stdout, /Would delete:/);
|
|
209
|
+
assert.equal(fs.existsSync(file), true);
|
|
210
|
+
assert.equal(fs.existsSync(Path.join(dir, 'completed.txt')), false);
|
|
211
|
+
|
|
212
|
+
var absent = runHook(dir, 'plugin_delete', {
|
|
213
|
+
xy: 1, action: 'plugin_delete', plugin: { id: 'other' }
|
|
214
|
+
}, ['--down_cmd', command]);
|
|
215
|
+
assert.equal(absent.status, 0, absent.stderr);
|
|
216
|
+
assert.equal(fs.existsSync(file), true);
|
|
217
|
+
assert.equal(fs.existsSync(Path.join(dir, 'completed.txt')), false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('syshook reports a failed down_cmd after deleting the matched source', t => {
|
|
221
|
+
var dir = makeTree(t);
|
|
222
|
+
var file = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
|
|
223
|
+
var command = `${JSON.stringify(process.execPath)} -e "process.exit(2)"`;
|
|
224
|
+
var result = runHook(dir, 'plugin_delete', {
|
|
225
|
+
xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' }
|
|
226
|
+
}, ['--down_cmd', command]);
|
|
227
|
+
|
|
228
|
+
assert.equal(result.status, 1);
|
|
229
|
+
assert.match(result.stderr, /Command failed/);
|
|
230
|
+
assert.equal(fs.existsSync(file), false);
|
|
231
|
+
});
|