docusaurus-plugin-generate-schema-docs 1.8.4 → 1.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +10 -0
  2. package/__tests__/__fixtures__/validateSchemas/schema-with-not-anyof-multi.json +12 -0
  3. package/__tests__/__fixtures__/validateSchemas/schema-with-not-anyof.json +30 -0
  4. package/__tests__/__fixtures__/validateSchemas/schema-with-not-edge-cases.json +24 -0
  5. package/__tests__/__fixtures__/validateSchemas/schema-with-not-non-object.json +15 -0
  6. package/__tests__/generateEventDocs.anchor.test.js +1 -1
  7. package/__tests__/generateEventDocs.nested.test.js +1 -1
  8. package/__tests__/generateEventDocs.partials.test.js +1 -1
  9. package/__tests__/generateEventDocs.test.js +506 -1
  10. package/__tests__/generateEventDocs.versioned.test.js +1 -1
  11. package/__tests__/helpers/buildExampleFromSchema.test.js +240 -0
  12. package/__tests__/helpers/constraintSchemaPaths.test.js +208 -0
  13. package/__tests__/helpers/continuingLinesStyle.test.js +492 -0
  14. package/__tests__/helpers/exampleModel.test.js +209 -0
  15. package/__tests__/helpers/file-system.test.js +73 -1
  16. package/__tests__/helpers/getConstraints.test.js +27 -0
  17. package/__tests__/helpers/mergeSchema.test.js +94 -0
  18. package/__tests__/helpers/processSchema.test.js +291 -1
  19. package/__tests__/helpers/schema-doc-template.test.js +54 -0
  20. package/__tests__/helpers/schema-processing.test.js +122 -2
  21. package/__tests__/helpers/schemaToExamples.test.js +1007 -0
  22. package/__tests__/helpers/schemaToTableData.mutations.test.js +970 -0
  23. package/__tests__/helpers/schemaToTableData.test.js +157 -0
  24. package/__tests__/helpers/snippetTargets.test.js +432 -0
  25. package/__tests__/helpers/trackingTargets.test.js +319 -0
  26. package/__tests__/helpers/validator.test.js +385 -1
  27. package/__tests__/index.test.js +436 -0
  28. package/__tests__/syncGtm.test.js +139 -3
  29. package/__tests__/update-schema-ids.test.js +70 -1
  30. package/__tests__/validateSchemas-integration.test.js +2 -2
  31. package/__tests__/validateSchemas.test.js +142 -1
  32. package/generateEventDocs.js +21 -1
  33. package/helpers/constraintSchemaPaths.js +10 -14
  34. package/helpers/schemaToTableData.js +538 -492
  35. package/helpers/trackingTargets.js +26 -3
  36. package/helpers/validator.js +18 -4
  37. package/index.js +1 -2
  38. package/package.json +1 -1
  39. package/scripts/sync-gtm.js +25 -7
@@ -0,0 +1,436 @@
1
+ /**
2
+ * @jest-environment @stryker-mutator/jest-runner/jest-env/node
3
+ */
4
+
5
+ import createPlugin from '../index.js';
6
+
7
+ jest.mock('url', () => ({
8
+ fileURLToPath: jest.fn(
9
+ () => '/mocked/packages/docusaurus-plugin-generate-schema-docs/index.js',
10
+ ),
11
+ pathToFileURL: jest.fn((p) => ({ href: 'file://' + p })),
12
+ }));
13
+
14
+ jest.mock('../generateEventDocs.js', () => jest.fn().mockResolvedValue());
15
+ jest.mock('../validateSchemas.js', () => jest.fn().mockResolvedValue(true));
16
+ jest.mock('../helpers/update-schema-ids.js', () => jest.fn());
17
+ jest.mock('child_process', () => ({ execSync: jest.fn() }));
18
+ jest.mock('../helpers/path-helpers.js', () => ({
19
+ getPathsForVersion: jest
20
+ .fn()
21
+ .mockReturnValue({ schemaDir: '/site/static/schemas/next' }),
22
+ }));
23
+
24
+ jest.mock('fs', () => ({
25
+ existsSync: jest.fn(),
26
+ readFileSync: jest.fn(),
27
+ cpSync: jest.fn(),
28
+ }));
29
+
30
+ import generateEventDocs from '../generateEventDocs.js';
31
+ import validateSchemas from '../validateSchemas.js';
32
+ import updateSchemaIds from '../helpers/update-schema-ids.js';
33
+ import { getPathsForVersion } from '../helpers/path-helpers.js';
34
+ import fs from 'fs';
35
+ import { execSync } from 'child_process';
36
+
37
+ const makeContext = (overrides = {}) => ({
38
+ siteDir: '/site',
39
+ siteConfig: {
40
+ organizationName: 'org',
41
+ projectName: 'proj',
42
+ url: 'https://example.com',
43
+ },
44
+ ...overrides,
45
+ });
46
+
47
+ const makeOptions = () => ({ dataLayerName: 'dataLayer' });
48
+
49
+ beforeEach(() => {
50
+ jest.clearAllMocks();
51
+ fs.existsSync.mockReturnValue(false);
52
+ });
53
+
54
+ describe('loadContent', () => {
55
+ it('calls generateEventDocs once without version when not versioned', async () => {
56
+ const plugin = await createPlugin(makeContext(), makeOptions());
57
+ await plugin.loadContent();
58
+
59
+ expect(generateEventDocs).toHaveBeenCalledTimes(1);
60
+ expect(generateEventDocs).toHaveBeenCalledWith(
61
+ expect.not.objectContaining({ version: expect.anything() }),
62
+ );
63
+ });
64
+
65
+ it('calls generateEventDocs for each version plus current when versioned', async () => {
66
+ fs.existsSync.mockReturnValue(true);
67
+ fs.readFileSync.mockReturnValue(JSON.stringify(['1.0', '2.0']));
68
+
69
+ const plugin = await createPlugin(makeContext(), makeOptions());
70
+ await plugin.loadContent();
71
+
72
+ expect(fs.readFileSync).toHaveBeenCalledWith('/site/versions.json', 'utf8');
73
+ expect(generateEventDocs).toHaveBeenCalledTimes(3);
74
+ expect(generateEventDocs).toHaveBeenCalledWith(
75
+ expect.objectContaining({ version: '1.0' }),
76
+ );
77
+ expect(generateEventDocs).toHaveBeenCalledWith(
78
+ expect.objectContaining({ version: '2.0' }),
79
+ );
80
+ expect(generateEventDocs).toHaveBeenCalledWith(
81
+ expect.objectContaining({ version: 'current' }),
82
+ );
83
+ });
84
+ });
85
+
86
+ describe('extendCli - validate-schemas', () => {
87
+ const makeCli = (targetCommand = 'validate-schemas [version]') => {
88
+ const action = { fn: null };
89
+ const cli = {
90
+ command: jest.fn((name) => {
91
+ const cmd = {
92
+ description: jest.fn().mockReturnThis(),
93
+ option: jest.fn().mockReturnThis(),
94
+ action: jest.fn((fn) => {
95
+ if (name === targetCommand) action.fn = fn;
96
+ return cmd;
97
+ }),
98
+ };
99
+ return cmd;
100
+ }),
101
+ };
102
+ return { cli, action };
103
+ };
104
+
105
+ it('calls validateSchemas with schemaDir for given version', async () => {
106
+ const { cli, action } = makeCli();
107
+ const plugin = await createPlugin(makeContext(), makeOptions());
108
+ plugin.extendCli(cli);
109
+
110
+ // first command registered is validate-schemas
111
+ expect(cli.command).toHaveBeenCalledWith('validate-schemas [version]');
112
+ await action.fn('next');
113
+
114
+ expect(validateSchemas).toHaveBeenCalledWith('/site/static/schemas/next');
115
+ });
116
+
117
+ it('defaults to "next" version when no version argument given', async () => {
118
+ const { cli, action } = makeCli();
119
+ const plugin = await createPlugin(makeContext(), makeOptions());
120
+ plugin.extendCli(cli);
121
+
122
+ await action.fn(undefined);
123
+ expect(getPathsForVersion).toHaveBeenCalledWith('next', '/site');
124
+ expect(validateSchemas).toHaveBeenCalledWith('/site/static/schemas/next');
125
+ });
126
+
127
+ it('exits with code 1 when validation fails', async () => {
128
+ validateSchemas.mockResolvedValue(false);
129
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
130
+ jest.spyOn(console, 'error').mockImplementation(() => {});
131
+
132
+ const { cli, action } = makeCli();
133
+ const plugin = await createPlugin(makeContext(), makeOptions());
134
+ plugin.extendCli(cli);
135
+
136
+ await action.fn('next');
137
+ expect(exitSpy).toHaveBeenCalledWith(1);
138
+
139
+ exitSpy.mockRestore();
140
+ });
141
+ });
142
+
143
+ describe('plugin structure', () => {
144
+ it('returns plugin with name docusaurus-plugin-generate-schema-docs', async () => {
145
+ const plugin = await createPlugin(makeContext(), makeOptions());
146
+ expect(plugin.name).toBe('docusaurus-plugin-generate-schema-docs');
147
+ });
148
+
149
+ it('passes all pluginOptions properties to generateEventDocs', async () => {
150
+ const plugin = await createPlugin(makeContext(), makeOptions());
151
+ await plugin.loadContent();
152
+
153
+ expect(generateEventDocs).toHaveBeenCalledWith(
154
+ expect.objectContaining({
155
+ organizationName: 'org',
156
+ projectName: 'proj',
157
+ siteDir: '/site',
158
+ url: 'https://example.com',
159
+ dataLayerName: 'dataLayer',
160
+ }),
161
+ );
162
+ });
163
+
164
+ it('checks for versions.json inside siteDir', async () => {
165
+ await createPlugin(makeContext(), makeOptions());
166
+ expect(fs.existsSync).toHaveBeenCalledWith('/site/versions.json');
167
+ });
168
+
169
+ it('getPathsToWatch returns static/schemas/next path when versioned', async () => {
170
+ fs.existsSync.mockReturnValue(true);
171
+ fs.readFileSync.mockReturnValue(JSON.stringify(['1.0']));
172
+
173
+ const plugin = await createPlugin(makeContext(), makeOptions());
174
+ const paths = plugin.getPathsToWatch();
175
+ expect(paths).toContain('/site/static/schemas/next');
176
+ });
177
+
178
+ it('getPathsToWatch returns static/schemas path when not versioned', async () => {
179
+ const plugin = await createPlugin(makeContext(), makeOptions());
180
+ const paths = plugin.getPathsToWatch();
181
+ expect(paths).toContain('/site/static/schemas');
182
+ expect(paths).not.toContain('/site/static/schemas/next');
183
+ });
184
+
185
+ it('has a getThemePath method that returns ./components', async () => {
186
+ const plugin = await createPlugin(makeContext(), makeOptions());
187
+ expect(typeof plugin.getThemePath).toBe('function');
188
+ expect(plugin.getThemePath()).toBe('./components');
189
+ });
190
+ });
191
+
192
+ describe('extendCli - generate-schema-docs', () => {
193
+ const getActionForCommand = async (commandName) => {
194
+ let capturedAction = null;
195
+ const cli = {
196
+ command: jest.fn((name) => {
197
+ const cmd = {
198
+ description: jest.fn().mockReturnThis(),
199
+ option: jest.fn().mockReturnThis(),
200
+ action: jest.fn((fn) => {
201
+ if (name === commandName) capturedAction = fn;
202
+ return cmd;
203
+ }),
204
+ };
205
+
206
+ return cmd;
207
+ }),
208
+ };
209
+ const plugin = await createPlugin(makeContext(), makeOptions());
210
+ plugin.extendCli(cli);
211
+ return capturedAction;
212
+ };
213
+
214
+ it('calls generateEventDocs once when not versioned', async () => {
215
+ const action = await getActionForCommand('generate-schema-docs');
216
+ await action();
217
+ expect(generateEventDocs).toHaveBeenCalledTimes(1);
218
+ });
219
+
220
+ it('calls generateEventDocs for each version plus current when versioned', async () => {
221
+ fs.existsSync.mockReturnValue(true);
222
+ fs.readFileSync.mockReturnValue(JSON.stringify(['1.0']));
223
+
224
+ const action = await getActionForCommand('generate-schema-docs');
225
+ await action();
226
+
227
+ expect(fs.readFileSync).toHaveBeenCalledWith('/site/versions.json', 'utf8');
228
+ expect(generateEventDocs).toHaveBeenCalledTimes(2);
229
+ expect(generateEventDocs).toHaveBeenCalledWith(
230
+ expect.objectContaining({ version: '1.0' }),
231
+ );
232
+ expect(generateEventDocs).toHaveBeenCalledWith(
233
+ expect.objectContaining({ version: 'current' }),
234
+ );
235
+ });
236
+ });
237
+
238
+ describe('extendCli - update-schema-ids', () => {
239
+ const getAction = async () => {
240
+ let captured = null;
241
+ const cli = {
242
+ command: jest.fn((name) => {
243
+ const cmd = {
244
+ description: jest.fn().mockReturnThis(),
245
+ option: jest.fn().mockReturnThis(),
246
+ action: jest.fn((fn) => {
247
+ if (name === 'update-schema-ids [version]') captured = fn;
248
+ return cmd;
249
+ }),
250
+ };
251
+ return cmd;
252
+ }),
253
+ };
254
+ const plugin = await createPlugin(makeContext(), makeOptions());
255
+ plugin.extendCli(cli);
256
+ return captured;
257
+ };
258
+
259
+ it('calls updateSchemaIds with siteDir, url, and version', async () => {
260
+ const action = await getAction();
261
+ action('1.0.0');
262
+ expect(updateSchemaIds).toHaveBeenCalledWith(
263
+ '/site',
264
+ 'https://example.com',
265
+ '1.0.0',
266
+ );
267
+ });
268
+ });
269
+
270
+ describe('extendCli - sync-gtm', () => {
271
+ const getAction = async () => {
272
+ let captured = null;
273
+ const cli = {
274
+ command: jest.fn((name) => {
275
+ const cmd = {
276
+ description: jest.fn().mockReturnThis(),
277
+ option: jest.fn().mockReturnThis(),
278
+ action: jest.fn((fn) => {
279
+ if (name === 'sync-gtm') captured = fn;
280
+ return cmd;
281
+ }),
282
+ };
283
+ return cmd;
284
+ }),
285
+ };
286
+ const plugin = await createPlugin(makeContext(), makeOptions());
287
+ plugin.extendCli(cli);
288
+ return captured;
289
+ };
290
+
291
+ it('runs the sync-gtm script with the default path', async () => {
292
+ const action = await getAction();
293
+ action({ path: '/site' });
294
+ const cmd = execSync.mock.calls[0][0];
295
+ expect(cmd).toContain('scripts/sync-gtm.js');
296
+ expect(cmd).toContain('--path=/site');
297
+ expect(execSync).toHaveBeenCalledWith(cmd, {
298
+ cwd: '/site',
299
+ stdio: 'inherit',
300
+ });
301
+ });
302
+
303
+ it('joins multiple args with spaces', async () => {
304
+ const action = await getAction();
305
+ action({ path: '/site', json: true, quiet: true });
306
+ const cmd = execSync.mock.calls[0][0];
307
+ expect(cmd).toContain('--path=/site --json --quiet');
308
+ });
309
+
310
+ it('appends --json flag when json option is set', async () => {
311
+ const action = await getAction();
312
+ action({ path: '/site', json: true });
313
+ expect(execSync).toHaveBeenCalledWith(
314
+ expect.stringContaining('--json'),
315
+ expect.any(Object),
316
+ );
317
+ });
318
+
319
+ it('appends --quiet flag when quiet option is set', async () => {
320
+ const action = await getAction();
321
+ action({ path: '/site', quiet: true });
322
+ expect(execSync).toHaveBeenCalledWith(
323
+ expect.stringContaining('--quiet'),
324
+ expect.any(Object),
325
+ );
326
+ });
327
+
328
+ it('appends --skip-array-sub-properties flag when option is set', async () => {
329
+ const action = await getAction();
330
+ action({ path: '/site', skipArraySubProperties: true });
331
+ expect(execSync).toHaveBeenCalledWith(
332
+ expect.stringContaining('--skip-array-sub-properties'),
333
+ expect.any(Object),
334
+ );
335
+ });
336
+
337
+ it('does not append optional flags when options are falsy', async () => {
338
+ const action = await getAction();
339
+ action({ path: '/site' });
340
+ const cmd = execSync.mock.calls[0][0];
341
+ expect(cmd).not.toContain('--json');
342
+ expect(cmd).not.toContain('--quiet');
343
+ expect(cmd).not.toContain('--skip-array-sub-properties');
344
+ });
345
+ });
346
+
347
+ describe('extendCli - version-with-schemas', () => {
348
+ const getAction = async () => {
349
+ let captured = null;
350
+ const cli = {
351
+ command: jest.fn((name) => {
352
+ const cmd = {
353
+ description: jest.fn().mockReturnThis(),
354
+ option: jest.fn().mockReturnThis(),
355
+ action: jest.fn((fn) => {
356
+ if (name === 'version-with-schemas <version>') captured = fn;
357
+ return cmd;
358
+ }),
359
+ };
360
+ return cmd;
361
+ }),
362
+ };
363
+ const plugin = await createPlugin(makeContext(), makeOptions());
364
+ plugin.extendCli(cli);
365
+ return captured;
366
+ };
367
+
368
+ beforeEach(() => {
369
+ jest.spyOn(console, 'log').mockImplementation(() => {});
370
+ jest.spyOn(console, 'error').mockImplementation(() => {});
371
+ // versions.json absent (not versioned); nextSchemasDir present
372
+ fs.existsSync.mockImplementation((p) => !p.includes('versions.json'));
373
+ });
374
+
375
+ it('creates version, copies schemas, updates IDs and generates docs on success', async () => {
376
+ const action = await getAction();
377
+ await action('1.2.0');
378
+
379
+ expect(execSync).toHaveBeenCalledWith(
380
+ expect.stringContaining('docs:version 1.2.0'),
381
+ { cwd: '/site', stdio: 'inherit' },
382
+ );
383
+ expect(fs.cpSync).toHaveBeenCalledWith(
384
+ '/site/static/schemas/next',
385
+ '/site/static/schemas/1.2.0',
386
+ { recursive: true },
387
+ );
388
+ expect(updateSchemaIds).toHaveBeenCalledWith(
389
+ '/site',
390
+ 'https://example.com',
391
+ '1.2.0',
392
+ );
393
+ expect(generateEventDocs).toHaveBeenCalledWith(
394
+ expect.objectContaining({ version: '1.2.0' }),
395
+ );
396
+ });
397
+
398
+ it('exits with code 1 when docusaurus docs:version fails', async () => {
399
+ execSync.mockImplementation(() => {
400
+ throw new Error('cmd failed');
401
+ });
402
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
403
+
404
+ const action = await getAction();
405
+ await action('1.2.0');
406
+
407
+ expect(exitSpy).toHaveBeenCalledWith(1);
408
+ exitSpy.mockRestore();
409
+ });
410
+
411
+ it('exits with code 1 when next schemas directory does not exist', async () => {
412
+ execSync.mockImplementation(() => {});
413
+ fs.existsSync.mockReturnValue(false); // nextSchemasDir also missing
414
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
415
+
416
+ const action = await getAction();
417
+ await action('1.2.0');
418
+
419
+ expect(exitSpy).toHaveBeenCalledWith(1);
420
+ exitSpy.mockRestore();
421
+ });
422
+
423
+ it('exits with code 1 when schema copy fails', async () => {
424
+ execSync.mockImplementation(() => {});
425
+ fs.cpSync.mockImplementation(() => {
426
+ throw new Error('copy failed');
427
+ });
428
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {});
429
+
430
+ const action = await getAction();
431
+ await action('1.2.0');
432
+
433
+ expect(exitSpy).toHaveBeenCalledWith(1);
434
+ exitSpy.mockRestore();
435
+ });
436
+ });
@@ -1,6 +1,6 @@
1
1
  /* eslint-env jest */
2
2
  /**
3
- * @jest-environment node
3
+ * @jest-environment @stryker-mutator/jest-runner/jest-env/node
4
4
  */
5
5
  const fs = require('fs');
6
6
  const path = require('path');
@@ -168,6 +168,31 @@ describe('getVariablesFromSchemas', () => {
168
168
  screen_name: { type: 'string', description: 'Screen name.' },
169
169
  },
170
170
  };
171
+ const mobileConditionalSchema = {
172
+ title: 'Mobile Conditional Event',
173
+ 'x-tracking-targets': ['android-firebase-kotlin-sdk'],
174
+ type: 'object',
175
+ allOf: [
176
+ {
177
+ if: {
178
+ properties: {
179
+ mobile_platform_hint: { const: 'ios' },
180
+ },
181
+ required: ['mobile_platform_hint'],
182
+ },
183
+ then: {
184
+ required: ['att_status'],
185
+ },
186
+ else: {
187
+ required: ['ad_personalization_enabled'],
188
+ },
189
+ },
190
+ ],
191
+ properties: {
192
+ event: { type: 'string', const: 'screen_view' },
193
+ mobile_platform_hint: { type: 'string' },
194
+ },
195
+ };
171
196
  const untaggedEventSchema = {
172
197
  title: 'Untagged Event',
173
198
  type: 'object',
@@ -309,6 +334,52 @@ describe('getVariablesFromSchemas', () => {
309
334
  );
310
335
  });
311
336
 
337
+ it('should skip non-web schemas before mergeAllOf can fail on unsupported keywords', async () => {
338
+ const mobileConditionalPath = path.join(
339
+ SCHEMA_PATH,
340
+ 'mobile-conditional-event.json',
341
+ );
342
+ mockFiles[SCHEMA_PATH].push('mobile-conditional-event.json');
343
+ mockFileContents[mobileConditionalPath] = JSON.stringify(
344
+ mobileConditionalSchema,
345
+ );
346
+
347
+ const bundledWebSchema = JSON.parse(JSON.stringify(complexEventSchema));
348
+ bundledWebSchema.properties.user_data.properties.addresses.items =
349
+ addressSchema;
350
+ const loggerErrorSpy = jest
351
+ .spyOn(console, 'error')
352
+ .mockImplementation(() => {});
353
+
354
+ RefParser.bundle.mockImplementation(async (filePath) => {
355
+ if (filePath.endsWith('complex-event.json')) {
356
+ return bundledWebSchema;
357
+ }
358
+ if (filePath.endsWith('mobile-event.json')) {
359
+ return mobileEventSchema;
360
+ }
361
+ if (filePath.endsWith('mobile-conditional-event.json')) {
362
+ return mobileConditionalSchema;
363
+ }
364
+ if (filePath.endsWith('address.json')) {
365
+ return addressSchema;
366
+ }
367
+ throw new Error(`Unexpected schema file: ${filePath}`);
368
+ });
369
+
370
+ const result = await gtmScript.getVariablesFromSchemas(SCHEMA_PATH, {});
371
+
372
+ expect(result.map((variable) => variable.name)).not.toContain(
373
+ 'mobile_platform_hint',
374
+ );
375
+ expect(loggerErrorSpy).not.toHaveBeenCalledWith(
376
+ expect.stringContaining(
377
+ `Error processing schema ${mobileConditionalPath}`,
378
+ ),
379
+ expect.any(Error),
380
+ );
381
+ });
382
+
312
383
  it('should include root tracking schemas based on content instead of path names', async () => {
313
384
  const nestedSchemaDir = path.join(SCHEMA_PATH, 'event-components-demo');
314
385
  const nestedSchemaPath = path.join(nestedSchemaDir, 'checkout-event.json');
@@ -494,18 +565,61 @@ describe('GTM Synchronization Logic', () => {
494
565
  });
495
566
 
496
567
  describe('deleteGtmVariables', () => {
497
- it('should call execSync to delete variables and return their names', () => {
568
+ it('should delete variables and return their names', () => {
498
569
  const toDelete = gtmScript.getVariablesToDelete(
499
570
  schemaVariables,
500
571
  gtmVariables,
501
572
  );
502
- const deleted = gtmScript.deleteGtmVariables(toDelete);
573
+ const { deleted, failedDeletes } = gtmScript.deleteGtmVariables(toDelete);
503
574
  expect(execSync).toHaveBeenCalledTimes(1);
504
575
  expect(execSync).toHaveBeenCalledWith(
505
576
  'gtm variables delete --variable-id 123 --force --quiet',
506
577
  { stdio: 'inherit' },
507
578
  );
508
579
  expect(deleted).toEqual(['old_variable']);
580
+ expect(failedDeletes).toEqual([]);
581
+ });
582
+
583
+ it('should capture failed deletes when GTM rejects the deletion', () => {
584
+ execSync.mockImplementation(() => {
585
+ throw new Error('Returned an error response for your request.');
586
+ });
587
+
588
+ const toDelete = gtmScript.getVariablesToDelete(
589
+ schemaVariables,
590
+ gtmVariables,
591
+ );
592
+ const { deleted, failedDeletes } = gtmScript.deleteGtmVariables(toDelete);
593
+
594
+ expect(deleted).toEqual([]);
595
+ expect(failedDeletes).toEqual([
596
+ { name: 'old_variable', variableId: '123' },
597
+ ]);
598
+ });
599
+ });
600
+
601
+ describe('syncGtmVariables', () => {
602
+ it('should report failed deletes when GTM rejects a deletion', async () => {
603
+ const syncedSchemaVariables = [
604
+ { name: 'event', description: 'The event name.' },
605
+ ];
606
+
607
+ execSync.mockImplementation((command) => {
608
+ if (command === 'gtm variables list -o json --quiet') {
609
+ return Buffer.from(JSON.stringify(gtmVariables));
610
+ }
611
+ if (command.startsWith('gtm variables delete')) {
612
+ throw new Error('Returned an error response for your request.');
613
+ }
614
+ throw new Error(`Unexpected command: ${command}`);
615
+ });
616
+
617
+ const summary = await gtmScript.syncGtmVariables(syncedSchemaVariables);
618
+
619
+ expect(summary.deleted).toEqual([]);
620
+ expect(summary.failedDeletes).toEqual([
621
+ { name: 'old_variable', variableId: '123' },
622
+ ]);
509
623
  });
510
624
  });
511
625
  });
@@ -525,6 +639,12 @@ describe('main function', () => {
525
639
  syncGtmVariables: jest.fn().mockResolvedValue({
526
640
  created: ['var1'],
527
641
  deleted: ['var2'],
642
+ failedDeletes: [
643
+ {
644
+ name: 'legacy_field',
645
+ variableId: '88',
646
+ },
647
+ ],
528
648
  inSync: ['var3'],
529
649
  }),
530
650
  getVariablesFromSchemas: jest
@@ -558,6 +678,12 @@ describe('main function', () => {
558
678
  workspace: { workspaceName: 'test-workspace', workspaceId: '123' },
559
679
  created: ['var1'],
560
680
  deleted: ['var2'],
681
+ failedDeletes: [
682
+ {
683
+ name: 'legacy_field',
684
+ variableId: '88',
685
+ },
686
+ ],
561
687
  inSync: ['var3'],
562
688
  });
563
689
  });
@@ -567,4 +693,14 @@ describe('main function', () => {
567
693
  await gtmScript.main(argv, mockDeps);
568
694
  expect(mockDeps.getLatestSchemaPath).toHaveBeenCalledWith('./my-demo');
569
695
  });
696
+
697
+ it('should report failed deletions in human-readable output', async () => {
698
+ const argv = ['node', 'script.js'];
699
+ await gtmScript.main(argv, mockDeps);
700
+
701
+ expect(logSpy).toHaveBeenCalledWith(
702
+ 'Skipped deleting 1 GTM variables (GTM rejected the delete, they may still be referenced):',
703
+ );
704
+ expect(logSpy).toHaveBeenCalledWith('- legacy_field (ID: 88)');
705
+ });
570
706
  });