@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.
@@ -18,6 +18,7 @@ const script = `
18
18
  if (options.setupCwd) process.chdir(options.setupCwd);
19
19
  const calls = [];
20
20
  const requests = [];
21
+ const apiOptions = [];
21
22
  const app = {
22
23
  ...sync,
23
24
  args: options.args,
@@ -41,9 +42,10 @@ const script = `
41
42
  compareVersions() { return 0; },
42
43
  async getMultiple() {},
43
44
  die(message) { throw new Error(message); },
44
- async callStandardAPI(method, data) {
45
+ async callStandardAPI(method, data, opts) {
45
46
  calls.push(method);
46
47
  requests.push({ method, data });
48
+ apiOptions.push({ method, opts });
47
49
  if (options.failAPI === method) throw new Error('Fixture API failure');
48
50
  }
49
51
  };
@@ -51,7 +53,7 @@ const script = `
51
53
  console.error(err.message);
52
54
  process.exitCode = 1;
53
55
  }).finally(() => {
54
- console.log(JSON.stringify({ calls, requests, errors: app.errors || [], warnings: app.warnings || [] }));
56
+ console.log(JSON.stringify({ calls, requests, apiOptions, errors: app.errors || [], warnings: app.warnings || [] }));
55
57
  });
56
58
  `;
57
59
 
@@ -225,6 +227,87 @@ test('sync errors exit nonzero even in quiet mode or a dry run', t => {
225
227
  }
226
228
  });
227
229
 
230
+ test('sync ignore-missing skips only absent remote IDs and continues updates', t => {
231
+ const remote = { id: 'present', title: 'Present', notes: 'Remote' };
232
+ const local = { ...remote, notes: 'Local' };
233
+ const missing = { id: 'gone', title: 'Gone', notes: 'Keep this file' };
234
+ const result = runSync(t, {
235
+ categories: [remote],
236
+ existingSources: [
237
+ { path: 'Gone.json', type: 'category', data: missing },
238
+ { path: 'Present.json', type: 'category', data: local }
239
+ ],
240
+ args: { up: 'categories', down: 'categories', delete: false, ignore_missing: true }
241
+ });
242
+
243
+ assert.equal(result.status, 0);
244
+ assert.deepEqual(result.report.warnings, []);
245
+ assert.equal(result.report.calls.includes('update_category'), true);
246
+ assert.equal(fs.existsSync(Path.join(result.dir, 'Gone.json')), true);
247
+ assert.equal(JSON.parse(fs.readFileSync(Path.join(result.dir, 'Gone.json'), 'utf8')).items[0].data.notes, 'Keep this file');
248
+ });
249
+
250
+ test('sync ignore-missing does not suppress malformed source warnings', t => {
251
+ const result = runSync(t, {
252
+ source: { id: 'missing', title: 'Missing' },
253
+ emptyItems: true,
254
+ args: { up: 'categories', ignore_missing: true }
255
+ });
256
+
257
+ assert.equal(result.status, 1);
258
+ assert.match(result.report.warnings[0], /empty, malformed or missing items array/);
259
+ assert.deepEqual(result.report.calls, []);
260
+ });
261
+
262
+ test('sync ignore-missing still validates neighbors of missing sources', t => {
263
+ const result = runSync(t, {
264
+ source: { id: 'missing', title: 'Missing' },
265
+ invalidNeighbor: true,
266
+ args: { up: 'categories', ignore_missing: true }
267
+ });
268
+
269
+ assert.equal(result.status, 1);
270
+ assert.match(result.report.warnings[0], /Neighbor file does not match/);
271
+ assert.deepEqual(result.report.calls, []);
272
+ });
273
+
274
+ test('sync ignore-missing still rejects duplicate missing sources', t => {
275
+ const result = runSync(t, {
276
+ source: { id: 'missing', title: 'Missing' },
277
+ duplicateSource: true,
278
+ args: { up: 'categories', ignore_missing: true }
279
+ });
280
+
281
+ assert.equal(result.status, 1);
282
+ assert.match(result.report.warnings[0], /Duplicate source item found/);
283
+ assert.deepEqual(result.report.calls, []);
284
+ });
285
+
286
+ test('sync ignore-missing can be disabled over a saved setting', t => {
287
+ const result = runSync(t, {
288
+ source: { id: 'missing', title: 'Missing' },
289
+ config: { up: 'categories', ignore_missing: true },
290
+ args: { ignore_missing: false }
291
+ });
292
+
293
+ assert.equal(result.status, 1);
294
+ assert.match(result.report.warnings[0], /Cannot find category in xyOps/);
295
+ });
296
+
297
+ test('sync sends existing Plugin properties through the sync API path', t => {
298
+ const remote = { id: 'legacy', title: 'Legacy', type: 'event', command: 'node', cwd: '/opt/legacy', custom_field: { keep: true }, notes: 'Remote' };
299
+ const local = { ...remote, notes: 'Local' };
300
+ const result = runSync(t, {
301
+ plugins: [remote],
302
+ existingSources: [{ path: 'Legacy.json', type: 'plugin', data: local }],
303
+ args: { up: 'plugins' }
304
+ });
305
+
306
+ assert.equal(result.status, 0);
307
+ assert.deepEqual(result.report.requests.find(request => request.method === 'update_plugin').data, local);
308
+ assert.equal(result.report.apiOptions.find(call => call.method === 'update_plugin').opts.sync, true);
309
+ });
310
+
228
311
  test('sync API errors preserve failure status while still sending both notifications', t => {
229
312
  const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
230
313
  for (const failAPI of ['update_category', 'update_global_state', 'delete_category']) {
@@ -297,6 +380,39 @@ test('sync setup new exports only remote definitions missing from the local tree
297
380
  assert.equal(fs.existsSync(Path.join(result.dir, 'custom', 'layout', 'Renamed-Existing.json')), true);
298
381
  });
299
382
 
383
+ test('sync setup new ignore-missing leaves stale files and exports new definitions', t => {
384
+ const missing = { id: 'gone', title: 'Gone' };
385
+ const added = { id: 'added', title: 'Added Plugin', command: 'node' };
386
+ const result = runSync(t, {
387
+ setup: ['plugins'],
388
+ plugins: [added],
389
+ existingSources: [{ path: 'old/Gone.json', type: 'plugin', data: missing }],
390
+ args: { new: true, ignore_missing: true }
391
+ });
392
+
393
+ assert.equal(result.status, 0);
394
+ assert.deepEqual(result.report.warnings, []);
395
+ assert.equal(fs.existsSync(Path.join(result.dir, 'old', 'Gone.json')), true);
396
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin.json')), true);
397
+ });
398
+
399
+ test('sync setup new ignore-missing still rejects duplicate missing sources', t => {
400
+ const missing = { id: 'gone', title: 'Gone' };
401
+ const result = runSync(t, {
402
+ setup: ['plugins'],
403
+ plugins: [{ id: 'added', title: 'Added Plugin', command: 'node' }],
404
+ existingSources: [
405
+ { path: 'old/Gone.json', type: 'plugin', data: missing },
406
+ { path: 'other/Gone.json', type: 'plugin', data: missing }
407
+ ],
408
+ args: { new: true, ignore_missing: true }
409
+ });
410
+
411
+ assert.equal(result.status, 1);
412
+ assert.match(result.stderr, /Duplicate source item found/);
413
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin.json')), false);
414
+ });
415
+
300
416
  test('sync setup runs down_cmd after writing files from the setup directory', t => {
301
417
  const marker = 'setup-complete.txt';
302
418
  const result = runSync(t, {
@@ -7,14 +7,14 @@ cli.global();
7
7
 
8
8
  test('toast wraps within the terminal width without ellipsis', () => {
9
9
  const originalWidth = cli.width;
10
- const originalWrite = process.stdout.write;
10
+ const originalWrite = process.stderr.write;
11
11
  const originalColor = cli.chalk.enabled;
12
12
  let output = '';
13
13
 
14
14
  try {
15
15
  cli.width = function() { return 40; };
16
16
  cli.chalk.enabled = false;
17
- process.stdout.write = function(chunk) {
17
+ process.stderr.write = function(chunk) {
18
18
  output += chunk;
19
19
  return true;
20
20
  };
@@ -27,7 +27,7 @@ test('toast wraps within the terminal width without ellipsis', () => {
27
27
  finally {
28
28
  cli.width = originalWidth;
29
29
  cli.chalk.enabled = originalColor;
30
- process.stdout.write = originalWrite;
30
+ process.stderr.write = originalWrite;
31
31
  }
32
32
 
33
33
  const box_lines = output.split('\n').filter( line => line.match(/[┌│└]/) );
@@ -1,7 +1,10 @@
1
1
  const { test } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
+ const cli = require('pixl-cli');
3
4
  const utils = require('../lib/utils.js');
4
5
 
6
+ cli.global();
7
+
5
8
  test('executable language detection accepts Unix and Windows command forms', () => {
6
9
  // Plugin definitions are portable, so exercise both path styles regardless of
7
10
  // the operating system running this test.
@@ -17,3 +20,50 @@ test('executable language detection accepts Unix and Windows command forms', ()
17
20
  assert.equal(utils.getLangFromBinary('unknown-binary.exe'), null);
18
21
  assert.equal(utils.getLangFromBinary(null), null);
19
22
  });
23
+
24
+ test('sync API updates preserve stored properties while manual updates retain typo checks', async () => {
25
+ const calls = [];
26
+ const context = {
27
+ ...utils,
28
+ dry: false,
29
+ verbose: false,
30
+ api: {
31
+ async update_plugin(request, opts) {
32
+ calls.push({ method: 'update_plugin', request, opts });
33
+ return { err: null, data: { code: 0 } };
34
+ },
35
+ async update_event(request, opts) {
36
+ calls.push({ method: 'update_event', request, opts });
37
+ return { err: null, data: { code: 0 } };
38
+ }
39
+ },
40
+ die(message) { throw new Error(message); }
41
+ };
42
+
43
+ for (const [method, request] of [
44
+ ['update_plugin', { id: 'legacy_plugin', cwd: '/tmp/legacy' }],
45
+ ['update_event', { id: 'legacy_event', custom_field: { enabled: true } }]
46
+ ]) {
47
+ // A hand-typed update still rejects an unknown top-level property.
48
+ const prior_calls = calls.length;
49
+ await assert.rejects(context.callStandardAPI(method, request), /Unsupported property/);
50
+ assert.equal(calls.length, prior_calls);
51
+
52
+ // Sync forwards the complete stored object to xyOps for its own validation.
53
+ await context.callStandardAPI(method, request, { sync: true });
54
+ assert.equal(calls.length, prior_calls + 1);
55
+ assert.deepEqual(calls.at(-1), { method, request, opts: {} });
56
+ }
57
+
58
+ // The original report failed during dry sync, before any xyOps request.
59
+ // Preview the same Plugin payload and verify that it makes no SDK call.
60
+ const plugin_request = { id: 'legacy_plugin', cwd: '/tmp/legacy' };
61
+ const previews = [];
62
+ const prior_calls = calls.length;
63
+ context.dry = true;
64
+ context.color = () => cli.chalk.white;
65
+ context.jsonOutput = request => { previews.push(request); };
66
+ await context.callStandardAPI('update_plugin', plugin_request, { sync: true });
67
+ assert.deepEqual(previews, [plugin_request]);
68
+ assert.equal(calls.length, prior_calls);
69
+ });