@pixlcore/xycli 1.0.6 → 1.0.8

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.
@@ -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
+ });
@@ -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,20 @@ test('sync errors exit nonzero even in quiet mode or a dry run', t => {
225
227
  }
226
228
  });
227
229
 
230
+ test('sync sends existing Plugin properties through the sync API path', t => {
231
+ const remote = { id: 'legacy', title: 'Legacy', type: 'event', command: 'node', cwd: '/opt/legacy', custom_field: { keep: true }, notes: 'Remote' };
232
+ const local = { ...remote, notes: 'Local' };
233
+ const result = runSync(t, {
234
+ plugins: [remote],
235
+ existingSources: [{ path: 'Legacy.json', type: 'plugin', data: local }],
236
+ args: { up: 'plugins' }
237
+ });
238
+
239
+ assert.equal(result.status, 0);
240
+ assert.deepEqual(result.report.requests.find(request => request.method === 'update_plugin').data, local);
241
+ assert.equal(result.report.apiOptions.find(call => call.method === 'update_plugin').opts.sync, true);
242
+ });
243
+
228
244
  test('sync API errors preserve failure status while still sending both notifications', t => {
229
245
  const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
230
246
  for (const failAPI of ['update_category', 'update_global_state', 'delete_category']) {
@@ -432,6 +448,61 @@ test('sync setup groups Events and workflows into Category folders', t => {
432
448
  assert.equal(result.stdout.indexOf('events/Operations-Team/', operationsHeader + 1), -1);
433
449
  });
434
450
 
451
+ test('sync setup externalizes each matching workflow node parameter', t => {
452
+ const shellScript = '#!/bin/sh\necho first\n';
453
+ const nodeScript = '#!/usr/bin/env node\nconsole.log("second");\n';
454
+ const workflow = {
455
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
456
+ workflow: { nodes: [
457
+ { id: 'ntrigger1', type: 'trigger' },
458
+ { id: 'nfirst01', type: 'event', data: { params: { script: shellScript } } },
459
+ { id: 'nsecond1', type: 'job', data: { params: { script: nodeScript } } },
460
+ { id: 'nempty001', type: 'job', data: { params: { script: '' } } }
461
+ ] }
462
+ };
463
+ const result = runSync(t, {
464
+ setup: ['events'],
465
+ categories: [{ id: 'operations', title: 'Operations' }],
466
+ events: [workflow],
467
+ args: { file_props: 'params.script' }
468
+ });
469
+ const dir = Path.join(result.dir, 'workflows', 'Operations');
470
+ const source = JSON.parse(fs.readFileSync(Path.join(dir, 'Flow.json'), 'utf8')).items[0].data;
471
+
472
+ assert.equal(result.status, 0);
473
+ assert.deepEqual(result.report.errors, []);
474
+ assert.equal(fs.readFileSync(Path.join(dir, 'Flow-workflow.nfirst01.params.script.sh'), 'utf8'), shellScript);
475
+ assert.equal(fs.readFileSync(Path.join(dir, 'Flow-workflow.nsecond1.params.script.js'), 'utf8'), nodeScript);
476
+ assert.equal(source.workflow.nodes[1].data.params.script, '(External)');
477
+ assert.equal(source.workflow.nodes[2].data.params.script, '(External)');
478
+ assert.equal(source.workflow.nodes[3].data.params.script, '');
479
+ assert.equal(fs.readdirSync(dir).length, 3, 'Empty and trigger nodes have no neighbor files');
480
+ });
481
+
482
+ test('sync setup accepts a shorthand path for one workflow node', t => {
483
+ const workflow = {
484
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
485
+ workflow: { nodes: [
486
+ { id: 'nfirst01', type: 'job', data: { params: { script: 'First\n' } } },
487
+ { id: 'nsecond1', type: 'job', data: { params: { script: 'Second\n' } } }
488
+ ] }
489
+ };
490
+ const result = runSync(t, {
491
+ setup: ['events'],
492
+ categories: [{ id: 'operations', title: 'Operations' }],
493
+ events: [workflow],
494
+ args: { file_props: 'workflow.nfirst01.params.script' }
495
+ });
496
+ const dir = Path.join(result.dir, 'workflows', 'Operations');
497
+ const source = JSON.parse(fs.readFileSync(Path.join(dir, 'Flow.json'), 'utf8')).items[0].data;
498
+
499
+ assert.equal(result.status, 0);
500
+ assert.equal(fs.readFileSync(Path.join(dir, 'Flow-workflow.nfirst01.params.script.txt'), 'utf8'), 'First\n');
501
+ assert.equal(source.workflow.nodes[0].data.params.script, '(External)');
502
+ assert.equal(source.workflow.nodes[1].data.params.script, 'Second\n');
503
+ assert.equal(fs.readdirSync(dir).length, 2);
504
+ });
505
+
435
506
  test('sync setup validates all Event Categories before writing any files', t => {
436
507
  const result = runSync(t, {
437
508
  setup: ['plugins', 'events'],
@@ -655,6 +726,112 @@ test('property neighbor matching requires an exact source basename boundary', t
655
726
  assert.equal(result.report.requests.some(request => request.method === 'update_event'), false);
656
727
  });
657
728
 
729
+ test('workflow node neighbors replace placeholders before upload', t => {
730
+ const shellScript = '#!/bin/sh\necho local\n';
731
+ const nodeScript = '#!/usr/bin/env node\nconsole.log("local");\n';
732
+ const remote = {
733
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
734
+ workflow: { nodes: [
735
+ { id: 'nfirst01', type: 'event', data: { params: { script: 'Remote shell\n' } } },
736
+ { id: 'nsecond1', type: 'job', data: { params: { script: 'Remote node\n' } } }
737
+ ] }
738
+ };
739
+ const local = JSON.parse(JSON.stringify(remote));
740
+ local.workflow.nodes.forEach(node => { node.data.params.script = '(External)'; });
741
+ const result = runSync(t, {
742
+ events: [remote],
743
+ existingSources: [
744
+ { path: 'Flow.json', type: 'event', data: local },
745
+ { path: 'Flow-workflow.nfirst01.params.script.sh', contents: shellScript },
746
+ { path: 'Flow-workflow.nsecond1.params.script.js', contents: nodeScript }
747
+ ],
748
+ args: { up: 'events' }
749
+ });
750
+ const updates = result.report.requests.filter(request => request.method === 'update_event');
751
+
752
+ assert.equal(result.status, 0);
753
+ assert.deepEqual(result.report.warnings, []);
754
+ assert.deepEqual(result.report.errors, []);
755
+ assert.equal(updates.length, 1);
756
+ assert.equal(updates[0].data.workflow.nodes[0].data.params.script, shellScript);
757
+ assert.equal(updates[0].data.workflow.nodes[1].data.params.script, nodeScript);
758
+ const source = JSON.parse(fs.readFileSync(Path.join(result.dir, 'Flow.json'), 'utf8')).items[0].data;
759
+ assert.equal(source.workflow.nodes[0].data.params.script, '(External)');
760
+ assert.equal(source.workflow.nodes[1].data.params.script, '(External)');
761
+ });
762
+
763
+ test('workflow node neighbors receive pulled values while JSON keeps placeholders', t => {
764
+ const shellScript = '#!/bin/sh\necho remote\n';
765
+ const nodeScript = '#!/usr/bin/env node\nconsole.log("remote");\n';
766
+ const remote = {
767
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
768
+ workflow: { nodes: [
769
+ { id: 'nfirst01', type: 'event', data: { params: { script: shellScript } } },
770
+ { id: 'nsecond1', type: 'job', data: { params: { script: nodeScript } } }
771
+ ] }
772
+ };
773
+ const local = JSON.parse(JSON.stringify(remote));
774
+ local.workflow.nodes.forEach(node => { node.data.params.script = '(External)'; });
775
+ const result = runSync(t, {
776
+ events: [remote],
777
+ existingSources: [
778
+ { path: 'Flow.json', type: 'event', data: local },
779
+ { path: 'Flow-workflow.nfirst01.params.script.sh', contents: 'Old shell\n' },
780
+ { path: 'Flow-workflow.nsecond1.params.script.js', contents: 'Old node\n' }
781
+ ],
782
+ args: { down: 'events' }
783
+ });
784
+ const source = JSON.parse(fs.readFileSync(Path.join(result.dir, 'Flow.json'), 'utf8')).items[0].data;
785
+
786
+ assert.equal(result.status, 0);
787
+ assert.deepEqual(result.report.warnings, []);
788
+ assert.deepEqual(result.report.errors, []);
789
+ assert.equal(fs.readFileSync(Path.join(result.dir, 'Flow-workflow.nfirst01.params.script.sh'), 'utf8'), shellScript);
790
+ assert.equal(fs.readFileSync(Path.join(result.dir, 'Flow-workflow.nsecond1.params.script.js'), 'utf8'), nodeScript);
791
+ assert.equal(source.workflow.nodes[0].data.params.script, '(External)');
792
+ assert.equal(source.workflow.nodes[1].data.params.script, '(External)');
793
+ });
794
+
795
+ test('workflow node shorthand preserves ordinary indexed dot paths', () => {
796
+ const sync = require('../lib/sync.js');
797
+ const data = { workflow: { nodes: [
798
+ { id: 'nfirst01', data: { params: { script: 'Original\n' } } }
799
+ ] } };
800
+
801
+ // The literal "nodes" segment must not be mistaken for a workflow node ID.
802
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nfirst01.params.script'), 'Original\n');
803
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nodes.0.data.params.script'), 'Original\n');
804
+ assert.equal(sync.setSyncPropByPath(data, 'workflow.nodes.0.data.params.script', 'Indexed\n'), true);
805
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nfirst01.params.script'), 'Indexed\n');
806
+ assert.equal(sync.setSyncPropByPath(data, 'workflow.nfirst01.params.script', 'Shorthand\n'), true);
807
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nodes.0.data.params.script'), 'Shorthand\n');
808
+ assert.equal(sync.getSyncPropByPath(data, 'workflow.nmissing1.params.script'), undefined);
809
+ assert.equal(sync.setSyncPropByPath(data, 'workflow.nmissing1.params.script', 'Missing\n'), undefined);
810
+ assert.equal(data.workflow.nodes.length, 1);
811
+ });
812
+
813
+ test('a workflow neighbor for a missing node stops sync before upload', t => {
814
+ const workflow = {
815
+ id: 'flow', title: 'Flow', type: 'workflow', category: 'operations',
816
+ workflow: { nodes: [
817
+ { id: 'nfirst01', type: 'job', data: { params: { script: 'Inline\n' } } }
818
+ ] }
819
+ };
820
+ const result = runSync(t, {
821
+ events: [workflow],
822
+ existingSources: [
823
+ { path: 'Flow.json', type: 'event', data: workflow },
824
+ { path: 'Flow-workflow.nmissing1.params.script.sh', contents: 'Orphan\n' }
825
+ ],
826
+ args: { up: 'events' }
827
+ });
828
+
829
+ assert.equal(result.status, 1);
830
+ assert.match(result.report.warnings[0], /does not match an existing string property/);
831
+ assert.deepEqual(result.report.errors, []);
832
+ assert.equal(result.report.requests.some(request => request.method === 'update_event'), false);
833
+ });
834
+
658
835
  test('a property neighbor must resolve to an existing string property', t => {
659
836
  const category = { id: 'fixture', title: 'Fixture', notes: '' };
660
837
  const result = runSync(t, {
@@ -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
+ });