ado-sync 0.1.65 → 0.1.67

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 (58) hide show
  1. package/README.md +15 -15
  2. package/dist/__tests__/regressions.test.js +1011 -1
  3. package/dist/__tests__/regressions.test.js.map +1 -1
  4. package/dist/ai/summarizer.d.ts +2 -1
  5. package/dist/ai/summarizer.js +6 -1
  6. package/dist/ai/summarizer.js.map +1 -1
  7. package/dist/azure/test-cases.d.ts +11 -1
  8. package/dist/azure/test-cases.js +286 -43
  9. package/dist/azure/test-cases.js.map +1 -1
  10. package/dist/cli.js +85 -8
  11. package/dist/cli.js.map +1 -1
  12. package/dist/config.js +74 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/id-markers.d.ts +1 -0
  15. package/dist/id-markers.js +13 -0
  16. package/dist/id-markers.js.map +1 -1
  17. package/dist/sync/cache.d.ts +2 -0
  18. package/dist/sync/cache.js.map +1 -1
  19. package/dist/sync/engine.d.ts +12 -1
  20. package/dist/sync/engine.js +210 -41
  21. package/dist/sync/engine.js.map +1 -1
  22. package/dist/types.d.ts +52 -2
  23. package/llms.txt +11 -11
  24. package/package.json +8 -1
  25. package/docs/advanced.md +0 -989
  26. package/docs/agent-setup.md +0 -204
  27. package/docs/capability-roadmap.md +0 -280
  28. package/docs/cli.md +0 -614
  29. package/docs/configuration.md +0 -322
  30. package/docs/examples/csharp-mstest-local-llm.yaml +0 -35
  31. package/docs/examples/csharp-mstest.yaml +0 -21
  32. package/docs/examples/csharp-nunit.yaml +0 -21
  33. package/docs/examples/csharp-specflow.yaml +0 -16
  34. package/docs/examples/cypress.yaml +0 -21
  35. package/docs/examples/detox-react-native.yaml +0 -21
  36. package/docs/examples/espresso-android.yaml +0 -21
  37. package/docs/examples/flutter-dart.yaml +0 -21
  38. package/docs/examples/java-junit.yaml +0 -21
  39. package/docs/examples/java-testng.yaml +0 -21
  40. package/docs/examples/js-jasmine-wdio.yaml +0 -21
  41. package/docs/examples/js-jest.yaml +0 -21
  42. package/docs/examples/playwright-js.yaml +0 -21
  43. package/docs/examples/playwright-ts.yaml +0 -21
  44. package/docs/examples/puppeteer.yaml +0 -21
  45. package/docs/examples/python-pytest.yaml +0 -21
  46. package/docs/examples/robot-framework.yaml +0 -19
  47. package/docs/examples/testcafe.yaml +0 -21
  48. package/docs/examples/xcuitest-ios.yaml +0 -21
  49. package/docs/mcp-server.md +0 -312
  50. package/docs/publish-test-results.md +0 -947
  51. package/docs/spec-formats.md +0 -1357
  52. package/docs/troubleshooting.md +0 -101
  53. package/docs/vscode-extension.md +0 -139
  54. package/docs/work-item-links.md +0 -115
  55. package/docs/workflows.md +0 -457
  56. package/mkdocs.yml +0 -40
  57. package/requirements-docs.txt +0 -4
  58. package/scripts/build_site.sh +0 -6
@@ -41,9 +41,12 @@ const fs = __importStar(require("node:fs"));
41
41
  const os = __importStar(require("node:os"));
42
42
  const path = __importStar(require("node:path"));
43
43
  const node_test_1 = __importDefault(require("node:test"));
44
+ const summarizer_1 = require("../ai/summarizer");
44
45
  const client_1 = require("../azure/client");
45
46
  const test_cases_1 = require("../azure/test-cases");
47
+ const config_1 = require("../config");
46
48
  const id_markers_1 = require("../id-markers");
49
+ const id_markers_2 = require("../id-markers");
47
50
  const gherkin_1 = require("../parsers/gherkin");
48
51
  const javascript_1 = require("../parsers/javascript");
49
52
  const engine_1 = require("../sync/engine");
@@ -71,6 +74,64 @@ function makeParsedTest(overrides = {}) {
71
74
  ...overrides,
72
75
  };
73
76
  }
77
+ function withTemporaryEnv(vars, fn) {
78
+ const previous = {};
79
+ for (const [key, value] of Object.entries(vars)) {
80
+ previous[key] = process.env[key];
81
+ if (value === undefined)
82
+ delete process.env[key];
83
+ else
84
+ process.env[key] = value;
85
+ }
86
+ try {
87
+ fn();
88
+ }
89
+ finally {
90
+ for (const [key, value] of Object.entries(previous)) {
91
+ if (value === undefined)
92
+ delete process.env[key];
93
+ else
94
+ process.env[key] = value;
95
+ }
96
+ }
97
+ }
98
+ function withCleanAiDetectionEnv(vars, fn) {
99
+ withTemporaryEnv({
100
+ ANTHROPIC_API_KEY: undefined,
101
+ OPENAI_API_KEY: undefined,
102
+ GITHUB_TOKEN: undefined,
103
+ CLAUDE_CODE: undefined,
104
+ CLAUDE_CONTEXT: undefined,
105
+ CODEX: undefined,
106
+ OPENAI_CODEX: undefined,
107
+ CODEX_CLI: undefined,
108
+ VISUAL_STUDIO_AGENT_MODE: undefined,
109
+ VISUAL_STUDIO_COPILOT_AGENT_MODE: undefined,
110
+ VS_COPILOT_AGENT_MODE: undefined,
111
+ COPILOT_AGENT_MODE: undefined,
112
+ CURSOR_SESSION_ID: undefined,
113
+ CURSOR_TRACE_ID: undefined,
114
+ WINDSURF_SESSION_ID: undefined,
115
+ CLINE_TASK_ID: undefined,
116
+ CLINE_SESSION_ID: undefined,
117
+ ANTIGRAVITY_SESSION_ID: undefined,
118
+ AIDER: undefined,
119
+ AIDER_SESSION: undefined,
120
+ CONTINUE_SESSION_ID: undefined,
121
+ AUGMENT_SESSION_ID: undefined,
122
+ ROO_CODE_SESSION_ID: undefined,
123
+ TRAE_SESSION_ID: undefined,
124
+ AMAZON_Q_SESSION_ID: undefined,
125
+ AWS_Q_SESSION_ID: undefined,
126
+ AMP_SESSION_ID: undefined,
127
+ TERM_PROGRAM: undefined,
128
+ TERMINAL_EMULATOR: undefined,
129
+ IDEA_INITIAL_DIRECTORY: undefined,
130
+ __INTELLIJ_COMMAND_HISTFILE__: undefined,
131
+ PATH: '/usr/bin:/bin',
132
+ ...vars,
133
+ }, fn);
134
+ }
74
135
  (0, node_test_1.default)('buildPushDiff flags expected-result-only step changes', () => {
75
136
  const config = makeConfig();
76
137
  const local = makeParsedTest();
@@ -95,6 +156,10 @@ function makeParsedTest(overrides = {}) {
95
156
  msg.includes('unexpected token');
96
157
  });
97
158
  });
159
+ (0, node_test_1.default)('validatePushModeOptions rejects incompatible mode combinations', () => {
160
+ strict_1.default.throws(() => (0, engine_1.validatePushModeOptions)({ createOnly: true, linkOnly: true, updateOnly: false }), /Only one push mode can be used at a time/);
161
+ strict_1.default.throws(() => (0, engine_1.validatePushModeOptions)({ createOnly: false, linkOnly: true, updateOnly: true }), /Only one push mode can be used at a time/);
162
+ });
98
163
  (0, node_test_1.default)('parseGherkinFile preserves doc-string block structure in description HTML', () => {
99
164
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-gherkin-docstring-'));
100
165
  const filePath = path.join(tempDir, 'sample.feature');
@@ -147,6 +212,143 @@ function makeParsedTest(overrides = {}) {
147
212
  strict_1.default.ok(updatePatch.some((p) => p.op === 'remove' && p.path === '/fields/Microsoft.VSTS.TCM.Parameters'));
148
213
  strict_1.default.ok(updatePatch.some((p) => p.op === 'remove' && p.path === '/fields/Microsoft.VSTS.TCM.LocalDataSource'));
149
214
  });
215
+ (0, node_test_1.default)('updateTestCase adds deterministic ownership tag for tagged sync targets', async () => {
216
+ let updatePatch;
217
+ const config = makeConfig({
218
+ configurationKey: 'Smoke Suite',
219
+ syncTarget: { mode: 'tagged' },
220
+ });
221
+ const ownershipTag = (0, id_markers_1.getSyncTargetOwnershipTag)(config);
222
+ const wit = {
223
+ getWorkItem: async () => ({
224
+ fields: {
225
+ 'System.Tags': 'smoke',
226
+ },
227
+ }),
228
+ updateWorkItem: async (_doc, patch) => {
229
+ updatePatch = patch;
230
+ return {};
231
+ },
232
+ };
233
+ const client = {
234
+ getWitApi: async () => wit,
235
+ };
236
+ await (0, test_cases_1.updateTestCase)(client, 99, makeParsedTest(), config);
237
+ strict_1.default.ok(updatePatch, 'expected updateWorkItem to be called');
238
+ const tagsPatch = updatePatch.find((patch) => patch.path === '/fields/System.Tags');
239
+ strict_1.default.ok(tagsPatch, 'expected System.Tags to be updated');
240
+ strict_1.default.match(tagsPatch.value, new RegExp(ownershipTag ?? ''));
241
+ });
242
+ (0, node_test_1.default)('loadConfig accepts declarative hierarchy definitions', () => {
243
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-hierarchy-config-'));
244
+ const configPath = path.join(tempDir, 'ado-sync.json');
245
+ fs.writeFileSync(configPath, JSON.stringify({
246
+ orgUrl: 'https://dev.azure.com/example',
247
+ project: 'ExampleProject',
248
+ auth: { type: 'pat', token: 'token' },
249
+ testPlan: {
250
+ id: 1,
251
+ suiteId: 10,
252
+ hierarchy: { mode: 'byFolder', rootSuite: 'Generated Specs' },
253
+ },
254
+ local: { type: 'gherkin', include: 'specs/**/*.feature' },
255
+ }, null, 2));
256
+ try {
257
+ const loaded = (0, config_1.loadConfig)(configPath);
258
+ strict_1.default.deepEqual(loaded.testPlan.hierarchy, { mode: 'byFolder', rootSuite: 'Generated Specs' });
259
+ }
260
+ finally {
261
+ fs.rmSync(tempDir, { recursive: true, force: true });
262
+ }
263
+ });
264
+ (0, node_test_1.default)('loadConfig accepts tag-driven hierarchy definitions', () => {
265
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-hierarchy-by-tag-config-'));
266
+ const configPath = path.join(tempDir, 'ado-sync.json');
267
+ fs.writeFileSync(configPath, JSON.stringify({
268
+ orgUrl: 'https://dev.azure.com/example',
269
+ project: 'ExampleProject',
270
+ auth: { type: 'pat', token: 'token' },
271
+ testPlan: {
272
+ id: 1,
273
+ suiteId: 10,
274
+ hierarchy: { mode: 'byTag', tagPrefix: 'suite', rootSuite: 'Generated Specs' },
275
+ },
276
+ local: { type: 'markdown', include: 'specs/**/*.md' },
277
+ }, null, 2));
278
+ try {
279
+ const loaded = (0, config_1.loadConfig)(configPath);
280
+ strict_1.default.deepEqual(loaded.testPlan.hierarchy, { mode: 'byTag', tagPrefix: 'suite', rootSuite: 'Generated Specs' });
281
+ }
282
+ finally {
283
+ fs.rmSync(tempDir, { recursive: true, force: true });
284
+ }
285
+ });
286
+ (0, node_test_1.default)('loadConfig accepts level-rule hierarchy definitions and diagnostic output', () => {
287
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-hierarchy-by-levels-config-'));
288
+ const configPath = path.join(tempDir, 'ado-sync.json');
289
+ fs.writeFileSync(configPath, JSON.stringify({
290
+ orgUrl: 'https://dev.azure.com/example',
291
+ project: 'ExampleProject',
292
+ auth: { type: 'pat', token: 'token' },
293
+ testPlan: {
294
+ id: 1,
295
+ suiteId: 10,
296
+ hierarchy: {
297
+ mode: 'byLevels',
298
+ rootSuite: 'Generated Specs',
299
+ levels: [
300
+ { source: 'folder', index: 0 },
301
+ { source: 'tag', tagPrefix: 'suite' },
302
+ ],
303
+ },
304
+ },
305
+ local: { type: 'markdown', include: 'specs/**/*.md' },
306
+ toolSettings: { outputLevel: 'diagnostic' },
307
+ }, null, 2));
308
+ try {
309
+ const loaded = (0, config_1.loadConfig)(configPath);
310
+ strict_1.default.deepEqual(loaded.testPlan.hierarchy, {
311
+ mode: 'byLevels',
312
+ rootSuite: 'Generated Specs',
313
+ levels: [
314
+ { source: 'folder', index: 0 },
315
+ { source: 'tag', tagPrefix: 'suite' },
316
+ ],
317
+ });
318
+ strict_1.default.equal(loaded.toolSettings?.outputLevel, 'diagnostic');
319
+ }
320
+ finally {
321
+ fs.rmSync(tempDir, { recursive: true, force: true });
322
+ }
323
+ });
324
+ (0, node_test_1.default)('getOrCreateSuiteForFile anchors generated hierarchy under a named root suite', async () => {
325
+ const createdSuites = [];
326
+ let nextSuiteId = 100;
327
+ const client = {
328
+ getTestPlanApi: async () => ({
329
+ getTestSuitesForPlan: async () => [],
330
+ createTestSuite: async (suite) => {
331
+ createdSuites.push({ name: suite.name, parentSuiteId: suite.parentSuite.id });
332
+ return { id: nextSuiteId++ };
333
+ },
334
+ }),
335
+ };
336
+ const config = makeConfig({
337
+ testPlan: {
338
+ id: 1,
339
+ suiteId: 10,
340
+ hierarchy: { mode: 'byFile', rootSuite: 'Generated Specs' },
341
+ },
342
+ });
343
+ const suiteId = await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, '/repo/specs/auth/login.feature', '/repo', new Map());
344
+ strict_1.default.equal(suiteId, 103);
345
+ strict_1.default.deepEqual(createdSuites, [
346
+ { name: 'Generated Specs', parentSuiteId: 10 },
347
+ { name: 'specs', parentSuiteId: 100 },
348
+ { name: 'auth', parentSuiteId: 101 },
349
+ { name: 'login', parentSuiteId: 102 },
350
+ ]);
351
+ });
150
352
  (0, node_test_1.default)('writebackDocComment preserves user-authored JSDoc and parser ignores ado-sync marker', async () => {
151
353
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-test-'));
152
354
  const filePath = path.join(tempDir, 'sample.test.ts');
@@ -357,6 +559,665 @@ function makeParsedTest(overrides = {}) {
357
559
  fs.rmSync(tempDir, { recursive: true, force: true });
358
560
  }
359
561
  });
562
+ (0, node_test_1.default)('push limits processing to requested source files and skips removed-case detection', async () => {
563
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-source-file-'));
564
+ const selectedFilePath = path.join(tempDir, 'selected.md');
565
+ const skippedFilePath = path.join(tempDir, 'skipped.md');
566
+ fs.writeFileSync(selectedFilePath, ['### Selected case', '', 'Steps:', '1. Open the app', ''].join('\n'));
567
+ fs.writeFileSync(skippedFilePath, ['### Skipped case', '', 'Steps:', '1. Do not sync me', ''].join('\n'));
568
+ const config = makeConfig({
569
+ local: { type: 'markdown', include: '*.md' },
570
+ });
571
+ let suiteFetchCount = 0;
572
+ const originalCreate = client_1.AzureClient.create;
573
+ client_1.AzureClient.create = async () => ({
574
+ getTestPlanApi: async () => ({
575
+ getTestCaseList: async () => {
576
+ suiteFetchCount++;
577
+ return [{ workItem: { id: 77 } }];
578
+ },
579
+ getTestPlanById: async () => ({ rootSuite: { id: 10 } }),
580
+ }),
581
+ getWitApi: async () => ({
582
+ getWorkItems: async () => [{ id: 77, fields: { 'System.Title': 'Unrelated remote case', 'System.Tags': '' } }],
583
+ }),
584
+ });
585
+ try {
586
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, sourceFiles: [selectedFilePath] });
587
+ strict_1.default.equal(suiteFetchCount, 0);
588
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.filePath === selectedFilePath));
589
+ strict_1.default.ok(!results.some((result) => result.filePath === skippedFilePath));
590
+ strict_1.default.ok(!results.some((result) => result.action === 'removed'));
591
+ }
592
+ finally {
593
+ client_1.AzureClient.create = originalCreate;
594
+ fs.rmSync(tempDir, { recursive: true, force: true });
595
+ }
596
+ });
597
+ (0, node_test_1.default)('push create-only creates unlinked cases and skips linked updates and removed detection', async () => {
598
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-create-only-'));
599
+ const filePath = path.join(tempDir, 'spec.md');
600
+ fs.writeFileSync(filePath, [
601
+ '### Existing case',
602
+ '@tc:55',
603
+ '',
604
+ 'Steps:',
605
+ '1. Existing step',
606
+ '',
607
+ '---',
608
+ '',
609
+ '### New case',
610
+ '',
611
+ 'Steps:',
612
+ '1. New step',
613
+ '',
614
+ ].join('\n'));
615
+ const config = makeConfig({
616
+ local: { type: 'markdown', include: '*.md' },
617
+ });
618
+ let suiteFetchCount = 0;
619
+ const originalCreate = client_1.AzureClient.create;
620
+ client_1.AzureClient.create = async () => ({
621
+ getTestPlanApi: async () => ({
622
+ getTestCaseList: async () => {
623
+ suiteFetchCount++;
624
+ return [{ workItem: { id: 77 } }];
625
+ },
626
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
627
+ }),
628
+ getWitApi: async () => ({
629
+ getWorkItems: async () => [{ id: 77, fields: { 'System.Title': 'Unrelated remote case', 'System.Tags': '' } }],
630
+ }),
631
+ });
632
+ try {
633
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, createOnly: true });
634
+ strict_1.default.equal(suiteFetchCount, 0);
635
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.title === 'New case'));
636
+ strict_1.default.ok(results.some((result) => result.action === 'skipped' && result.azureId === 55 && /create-only/.test(result.detail ?? '')));
637
+ strict_1.default.ok(!results.some((result) => result.action === 'removed'));
638
+ strict_1.default.ok(!results.some((result) => result.action === 'updated'));
639
+ }
640
+ finally {
641
+ client_1.AzureClient.create = originalCreate;
642
+ fs.rmSync(tempDir, { recursive: true, force: true });
643
+ }
644
+ });
645
+ (0, node_test_1.default)('push link-only links unlinked cases by unique exact title match without creating or updating', async () => {
646
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-link-only-'));
647
+ const filePath = path.join(tempDir, 'spec.md');
648
+ fs.writeFileSync(filePath, [
649
+ '### Already linked',
650
+ '@tc:55',
651
+ '',
652
+ 'Steps:',
653
+ '1. Existing step',
654
+ '',
655
+ '---',
656
+ '',
657
+ '### Match me',
658
+ '',
659
+ 'Steps:',
660
+ '1. Same title as remote',
661
+ '',
662
+ ].join('\n'));
663
+ const config = makeConfig({
664
+ local: { type: 'markdown', include: '*.md' },
665
+ });
666
+ let suiteFetchCount = 0;
667
+ const originalCreate = client_1.AzureClient.create;
668
+ client_1.AzureClient.create = async () => ({
669
+ getTestPlanApi: async () => ({
670
+ getTestCaseList: async () => {
671
+ suiteFetchCount++;
672
+ return [{ workItem: { id: 88 } }, { workItem: { id: 99 } }];
673
+ },
674
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
675
+ }),
676
+ getWitApi: async () => ({
677
+ getWorkItems: async () => [
678
+ { id: 88, fields: { 'System.Title': 'Match me', 'System.Tags': '' } },
679
+ { id: 99, fields: { 'System.Title': 'Unrelated remote case', 'System.Tags': '' } },
680
+ ],
681
+ }),
682
+ });
683
+ try {
684
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, linkOnly: true });
685
+ strict_1.default.equal(suiteFetchCount, 1);
686
+ strict_1.default.ok(results.some((result) => result.action === 'linked' && result.azureId === 88 && result.title === 'Match me'));
687
+ strict_1.default.ok(results.some((result) => result.action === 'skipped' && result.azureId === 55 && /link-only/.test(result.detail ?? '')));
688
+ strict_1.default.ok(!results.some((result) => result.action === 'created'));
689
+ strict_1.default.ok(!results.some((result) => result.action === 'updated'));
690
+ strict_1.default.ok(!results.some((result) => result.action === 'removed'));
691
+ }
692
+ finally {
693
+ client_1.AzureClient.create = originalCreate;
694
+ fs.rmSync(tempDir, { recursive: true, force: true });
695
+ }
696
+ });
697
+ (0, node_test_1.default)('push link-only skips ambiguous exact title matches', async () => {
698
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-link-ambiguous-'));
699
+ const filePath = path.join(tempDir, 'spec.md');
700
+ fs.writeFileSync(filePath, ['### Duplicate title', '', 'Steps:', '1. Step', ''].join('\n'));
701
+ const config = makeConfig({
702
+ local: { type: 'markdown', include: '*.md' },
703
+ });
704
+ const originalCreate = client_1.AzureClient.create;
705
+ client_1.AzureClient.create = async () => ({
706
+ getTestPlanApi: async () => ({
707
+ getTestCaseList: async () => [{ workItem: { id: 90 } }, { workItem: { id: 91 } }],
708
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
709
+ }),
710
+ getWitApi: async () => ({
711
+ getWorkItems: async () => [
712
+ { id: 90, fields: { 'System.Title': 'Duplicate title', 'System.Tags': '' } },
713
+ { id: 91, fields: { 'System.Title': 'Duplicate title', 'System.Tags': '' } },
714
+ ],
715
+ }),
716
+ });
717
+ try {
718
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, linkOnly: true });
719
+ strict_1.default.ok(results.some((result) => result.action === 'skipped' && /multiple title matches/.test(result.detail ?? '')));
720
+ strict_1.default.ok(!results.some((result) => result.action === 'linked'));
721
+ strict_1.default.ok(!results.some((result) => result.action === 'created'));
722
+ }
723
+ finally {
724
+ client_1.AzureClient.create = originalCreate;
725
+ fs.rmSync(tempDir, { recursive: true, force: true });
726
+ }
727
+ });
728
+ (0, node_test_1.default)('push update-only updates linked cases and skips unlinked cases and removed detection', async () => {
729
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-update-only-'));
730
+ const filePath = path.join(tempDir, 'spec.md');
731
+ fs.writeFileSync(filePath, [
732
+ '### Existing case',
733
+ '@tc:55',
734
+ '',
735
+ 'Steps:',
736
+ '1. Updated local step',
737
+ '',
738
+ '---',
739
+ '',
740
+ '### New case',
741
+ '',
742
+ 'Steps:',
743
+ '1. New step',
744
+ '',
745
+ ].join('\n'));
746
+ const config = makeConfig({
747
+ local: { type: 'markdown', include: '*.md' },
748
+ });
749
+ let suiteFetchCount = 0;
750
+ const originalCreate = client_1.AzureClient.create;
751
+ client_1.AzureClient.create = async () => ({
752
+ getTestPlanApi: async () => ({
753
+ getTestCaseList: async () => {
754
+ suiteFetchCount++;
755
+ return [{ workItem: { id: 77 } }];
756
+ },
757
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
758
+ addTestCasesToSuite: async () => undefined,
759
+ }),
760
+ getWitApi: async () => ({
761
+ getWorkItems: async () => [{
762
+ id: 77,
763
+ fields: {
764
+ 'System.Title': 'Unrelated remote case',
765
+ 'System.Tags': '',
766
+ },
767
+ }],
768
+ getWorkItem: async (id) => ({
769
+ id,
770
+ fields: {
771
+ 'System.Title': 'Existing case',
772
+ 'System.Description': '',
773
+ 'Microsoft.VSTS.TCM.Steps': '<steps id="0" last="2"><step id="2" type="ValidateStep"><parameterizedString isformatted="true">&lt;DIV&gt;&lt;P&gt;Step Existing local step&lt;/P&gt;&lt;/DIV&gt;</parameterizedString><parameterizedString isformatted="true"></parameterizedString><description/></step></steps>',
774
+ 'System.Tags': '',
775
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
776
+ },
777
+ }),
778
+ updateWorkItem: async () => ({}),
779
+ }),
780
+ });
781
+ try {
782
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, updateOnly: true });
783
+ strict_1.default.equal(suiteFetchCount, 0);
784
+ strict_1.default.ok(results.some((result) => result.action === 'updated' && result.azureId === 55));
785
+ strict_1.default.ok(results.some((result) => result.action === 'skipped' && result.title === 'New case' && /update-only/.test(result.detail ?? '')));
786
+ strict_1.default.ok(!results.some((result) => result.action === 'created'));
787
+ strict_1.default.ok(!results.some((result) => result.action === 'removed'));
788
+ }
789
+ finally {
790
+ client_1.AzureClient.create = originalCreate;
791
+ fs.rmSync(tempDir, { recursive: true, force: true });
792
+ }
793
+ });
794
+ (0, node_test_1.default)('push moves linked hierarchy-managed cases to the new generated suite when file path changes', async () => {
795
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-hierarchy-move-'));
796
+ const oldRelativePath = 'specs/old/login.md';
797
+ const newRelativePath = 'specs/new/login.md';
798
+ const filePath = path.join(tempDir, newRelativePath);
799
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
800
+ fs.writeFileSync(filePath, ['### Existing case', '@tc:55', '', 'Steps:', '1. Existing step', ''].join('\n'));
801
+ fs.writeFileSync(path.join(tempDir, '.ado-sync-state.json'), JSON.stringify({
802
+ 55: {
803
+ title: 'Existing case',
804
+ stepsHash: 'cached-steps',
805
+ descriptionHash: 'cached-description',
806
+ remoteDescriptionHash: 'cached-remote-description',
807
+ changedDate: '2026-05-01T00:00:00Z',
808
+ filePath: path.join(tempDir, oldRelativePath),
809
+ },
810
+ }, null, 2));
811
+ const config = makeConfig({
812
+ local: { type: 'markdown', include: 'specs/**/*.md' },
813
+ testPlan: {
814
+ id: 1,
815
+ suiteId: 10,
816
+ hierarchy: { mode: 'byFolder' },
817
+ },
818
+ });
819
+ const addedToSuites = [];
820
+ const removedFromSuites = [];
821
+ const suites = [
822
+ { id: 101, name: 'specs', parentSuite: { id: 10 } },
823
+ { id: 102, name: 'old', parentSuite: { id: 101 } },
824
+ ];
825
+ let nextSuiteId = 103;
826
+ const originalCreate = client_1.AzureClient.create;
827
+ client_1.AzureClient.create = async () => ({
828
+ getTestPlanApi: async () => ({
829
+ getTestCaseList: async () => [],
830
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
831
+ getTestSuitesForPlan: async () => suites,
832
+ createTestSuite: async (suite) => {
833
+ const created = { id: nextSuiteId++, name: suite.name, parentSuite: { id: suite.parentSuite.id } };
834
+ suites.push(created);
835
+ return created;
836
+ },
837
+ addTestCasesToSuite: async (_entries, _project, _planId, suiteId) => {
838
+ addedToSuites.push(suiteId);
839
+ },
840
+ removeTestCasesFromSuite: async (_project, _planId, suiteId, _testCaseIds) => {
841
+ removedFromSuites.push(suiteId);
842
+ },
843
+ }),
844
+ getWitApi: async () => ({
845
+ getWorkItem: async (id) => ({
846
+ id,
847
+ fields: {
848
+ 'System.Title': 'Existing case',
849
+ 'System.Description': '',
850
+ 'Microsoft.VSTS.TCM.Steps': '<steps id="0" last="2"><step id="2" type="ValidateStep"><parameterizedString isformatted="true">&lt;DIV&gt;&lt;P&gt;Step Existing step&lt;/P&gt;&lt;/DIV&gt;</parameterizedString><parameterizedString isformatted="true"></parameterizedString><description/></step></steps>',
851
+ 'System.Tags': '',
852
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
853
+ },
854
+ }),
855
+ updateWorkItem: async () => ({})
856
+ }),
857
+ });
858
+ try {
859
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: false });
860
+ strict_1.default.ok(results.some((result) => result.action === 'updated' && result.azureId === 55 && result.changedFields?.includes('suite') && result.targetSuitePath === 'specs / new' && result.previousSuitePath === 'specs / old'));
861
+ strict_1.default.deepEqual(addedToSuites, [103]);
862
+ strict_1.default.deepEqual(removedFromSuites, [102]);
863
+ }
864
+ finally {
865
+ client_1.AzureClient.create = originalCreate;
866
+ fs.rmSync(tempDir, { recursive: true, force: true });
867
+ }
868
+ });
869
+ (0, node_test_1.default)('push moves linked hierarchy-managed cases when the tag-driven suite path changes', async () => {
870
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-hierarchy-by-tag-move-'));
871
+ const filePath = path.join(tempDir, 'specs', 'login.md');
872
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
873
+ fs.writeFileSync(filePath, ['### Existing case', '@tc:55 @suite:new/path', '', 'Steps:', '1. Existing step', ''].join('\n'));
874
+ fs.writeFileSync(path.join(tempDir, '.ado-sync-state.json'), JSON.stringify({
875
+ 55: {
876
+ title: 'Existing case',
877
+ stepsHash: 'cached-steps',
878
+ descriptionHash: 'cached-description',
879
+ remoteDescriptionHash: 'cached-remote-description',
880
+ changedDate: '2026-05-01T00:00:00Z',
881
+ filePath,
882
+ suitePathKey: 'old/path',
883
+ },
884
+ }, null, 2));
885
+ const config = makeConfig({
886
+ local: { type: 'markdown', include: 'specs/**/*.md' },
887
+ testPlan: {
888
+ id: 1,
889
+ suiteId: 10,
890
+ hierarchy: { mode: 'byTag', tagPrefix: 'suite' },
891
+ },
892
+ });
893
+ const addedToSuites = [];
894
+ const removedFromSuites = [];
895
+ const suites = [
896
+ { id: 101, name: 'old', parentSuite: { id: 10 } },
897
+ { id: 102, name: 'path', parentSuite: { id: 101 } },
898
+ ];
899
+ let nextSuiteId = 103;
900
+ const originalCreate = client_1.AzureClient.create;
901
+ client_1.AzureClient.create = async () => ({
902
+ getTestPlanApi: async () => ({
903
+ getTestCaseList: async () => [],
904
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
905
+ getTestSuitesForPlan: async () => suites,
906
+ createTestSuite: async (suite) => {
907
+ const created = { id: nextSuiteId++, name: suite.name, parentSuite: { id: suite.parentSuite.id } };
908
+ suites.push(created);
909
+ return created;
910
+ },
911
+ addTestCasesToSuite: async (_entries, _project, _planId, suiteId) => {
912
+ addedToSuites.push(suiteId);
913
+ },
914
+ removeTestCasesFromSuite: async (_project, _planId, suiteId) => {
915
+ removedFromSuites.push(suiteId);
916
+ },
917
+ }),
918
+ getWitApi: async () => ({
919
+ getWorkItem: async (id) => ({
920
+ id,
921
+ fields: {
922
+ 'System.Title': 'Existing case',
923
+ 'System.Description': '',
924
+ 'Microsoft.VSTS.TCM.Steps': '<steps id="0" last="2"><step id="2" type="ValidateStep"><parameterizedString isformatted="true">&lt;DIV&gt;&lt;P&gt;Step Existing step&lt;/P&gt;&lt;/DIV&gt;</parameterizedString><parameterizedString isformatted="true"></parameterizedString><description/></step></steps>',
925
+ 'System.Tags': '',
926
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
927
+ },
928
+ }),
929
+ updateWorkItem: async () => ({}),
930
+ }),
931
+ });
932
+ try {
933
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: false });
934
+ strict_1.default.ok(results.some((result) => result.action === 'updated' && result.azureId === 55 && result.changedFields?.includes('suite') && result.targetSuitePath === 'new / path' && result.previousSuitePath === 'old / path'));
935
+ strict_1.default.deepEqual(addedToSuites, [104]);
936
+ strict_1.default.deepEqual(removedFromSuites, [102]);
937
+ }
938
+ finally {
939
+ client_1.AzureClient.create = originalCreate;
940
+ fs.rmSync(tempDir, { recursive: true, force: true });
941
+ }
942
+ });
943
+ (0, node_test_1.default)('status previews generated suite targets for hierarchy-managed creates', async () => {
944
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-status-hierarchy-target-'));
945
+ const filePath = path.join(tempDir, 'specs', 'auth', 'login.md');
946
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
947
+ fs.writeFileSync(filePath, ['### Login case', '', 'Steps:', '1. Open app', ''].join('\n'));
948
+ const config = makeConfig({
949
+ local: { type: 'markdown', include: 'specs/**/*.md' },
950
+ testPlan: {
951
+ id: 1,
952
+ suiteId: 10,
953
+ hierarchy: { mode: 'byFolder', rootSuite: 'Generated Specs' },
954
+ },
955
+ });
956
+ const originalCreate = client_1.AzureClient.create;
957
+ client_1.AzureClient.create = async () => ({
958
+ getTestPlanApi: async () => ({
959
+ getTestCaseList: async () => [],
960
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
961
+ }),
962
+ getWitApi: async () => ({
963
+ getWorkItems: async () => [],
964
+ }),
965
+ });
966
+ try {
967
+ const results = await (0, engine_1.status)(config, tempDir);
968
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.targetSuitePath === 'Generated Specs / specs / auth'));
969
+ }
970
+ finally {
971
+ client_1.AzureClient.create = originalCreate;
972
+ fs.rmSync(tempDir, { recursive: true, force: true });
973
+ }
974
+ });
975
+ (0, node_test_1.default)('status previews tag-driven hierarchy targets for creates', async () => {
976
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-status-hierarchy-by-tag-target-'));
977
+ const filePath = path.join(tempDir, 'specs', 'auth', 'login.md');
978
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
979
+ fs.writeFileSync(filePath, ['### Login case', '<!-- tags: @suite:mobile/auth -->', '', 'Steps:', '1. Open app', ''].join('\n'));
980
+ const config = makeConfig({
981
+ local: { type: 'markdown', include: 'specs/**/*.md' },
982
+ testPlan: {
983
+ id: 1,
984
+ suiteId: 10,
985
+ hierarchy: { mode: 'byTag', tagPrefix: 'suite', rootSuite: 'Generated Specs' },
986
+ },
987
+ });
988
+ const originalCreate = client_1.AzureClient.create;
989
+ client_1.AzureClient.create = async () => ({
990
+ getTestPlanApi: async () => ({
991
+ getTestCaseList: async () => [],
992
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
993
+ }),
994
+ getWitApi: async () => ({
995
+ getWorkItems: async () => [],
996
+ }),
997
+ });
998
+ try {
999
+ const results = await (0, engine_1.status)(config, tempDir);
1000
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.targetSuitePath === 'Generated Specs / mobile / auth'));
1001
+ }
1002
+ finally {
1003
+ client_1.AzureClient.create = originalCreate;
1004
+ fs.rmSync(tempDir, { recursive: true, force: true });
1005
+ }
1006
+ });
1007
+ (0, node_test_1.default)('status previews level-rule hierarchy targets for creates', async () => {
1008
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-status-hierarchy-by-levels-target-'));
1009
+ const filePath = path.join(tempDir, 'specs', 'auth', 'login.md');
1010
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1011
+ fs.writeFileSync(filePath, ['### Login case', '<!-- tags: @suite:mobile -->', '', 'Steps:', '1. Open app', ''].join('\n'));
1012
+ const config = makeConfig({
1013
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1014
+ testPlan: {
1015
+ id: 1,
1016
+ suiteId: 10,
1017
+ hierarchy: {
1018
+ mode: 'byLevels',
1019
+ rootSuite: 'Generated Specs',
1020
+ levels: [
1021
+ { source: 'folder', index: 1 },
1022
+ { source: 'tag', tagPrefix: 'suite' },
1023
+ ],
1024
+ },
1025
+ },
1026
+ });
1027
+ const originalCreate = client_1.AzureClient.create;
1028
+ client_1.AzureClient.create = async () => ({
1029
+ getTestPlanApi: async () => ({
1030
+ getTestCaseList: async () => [],
1031
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
1032
+ }),
1033
+ getWitApi: async () => ({
1034
+ getWorkItems: async () => [],
1035
+ }),
1036
+ });
1037
+ try {
1038
+ const results = await (0, engine_1.status)(config, tempDir);
1039
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.targetSuitePath === 'Generated Specs / auth / mobile'));
1040
+ }
1041
+ finally {
1042
+ client_1.AzureClient.create = originalCreate;
1043
+ fs.rmSync(tempDir, { recursive: true, force: true });
1044
+ }
1045
+ });
1046
+ (0, node_test_1.default)('push can prune empty generated suites after a hierarchy-managed move', async () => {
1047
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-hierarchy-prune-'));
1048
+ const oldRelativePath = 'specs/old/login.md';
1049
+ const newRelativePath = 'specs/new/login.md';
1050
+ const filePath = path.join(tempDir, newRelativePath);
1051
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1052
+ fs.writeFileSync(filePath, ['### Existing case', '@tc:55', '', 'Steps:', '1. Existing step', ''].join('\n'));
1053
+ fs.writeFileSync(path.join(tempDir, '.ado-sync-state.json'), JSON.stringify({
1054
+ 55: {
1055
+ title: 'Existing case',
1056
+ stepsHash: 'cached-steps',
1057
+ descriptionHash: 'cached-description',
1058
+ remoteDescriptionHash: 'cached-remote-description',
1059
+ changedDate: '2026-05-01T00:00:00Z',
1060
+ filePath: path.join(tempDir, oldRelativePath),
1061
+ },
1062
+ }, null, 2));
1063
+ const config = makeConfig({
1064
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1065
+ testPlan: {
1066
+ id: 1,
1067
+ suiteId: 10,
1068
+ hierarchy: { mode: 'byFolder', cleanupEmptySuites: true },
1069
+ },
1070
+ });
1071
+ const deletedSuites = [];
1072
+ const suites = [
1073
+ { id: 101, name: 'specs', parentSuite: { id: 10 } },
1074
+ { id: 102, name: 'old', parentSuite: { id: 101 } },
1075
+ ];
1076
+ let nextSuiteId = 103;
1077
+ const originalCreate = client_1.AzureClient.create;
1078
+ client_1.AzureClient.create = async () => ({
1079
+ getTestPlanApi: async () => ({
1080
+ getTestCaseList: async (_project, _planId, suiteId) => {
1081
+ if (suiteId === 10)
1082
+ return [{ testCase: { id: 77 } }];
1083
+ if (suiteId === 102 || suiteId === 101)
1084
+ return [];
1085
+ return [];
1086
+ },
1087
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
1088
+ getTestSuitesForPlan: async () => suites,
1089
+ createTestSuite: async (suite) => {
1090
+ const created = { id: nextSuiteId++, name: suite.name, parentSuite: { id: suite.parentSuite.id } };
1091
+ suites.push(created);
1092
+ return created;
1093
+ },
1094
+ addTestCasesToSuite: async () => undefined,
1095
+ removeTestCasesFromSuite: async () => undefined,
1096
+ deleteTestSuite: async (_project, _planId, suiteId) => {
1097
+ deletedSuites.push(suiteId);
1098
+ const index = suites.findIndex((suite) => suite.id === suiteId);
1099
+ if (index >= 0)
1100
+ suites.splice(index, 1);
1101
+ },
1102
+ }),
1103
+ getWitApi: async () => ({
1104
+ getWorkItem: async (id) => ({
1105
+ id,
1106
+ fields: {
1107
+ 'System.Title': 'Existing case',
1108
+ 'System.Description': '',
1109
+ 'Microsoft.VSTS.TCM.Steps': '<steps id="0" last="2"><step id="2" type="ValidateStep"><parameterizedString isformatted="true">&lt;DIV&gt;&lt;P&gt;Step Existing step&lt;/P&gt;&lt;/DIV&gt;</parameterizedString><parameterizedString isformatted="true"></parameterizedString><description/></step></steps>',
1110
+ 'System.Tags': '',
1111
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
1112
+ },
1113
+ }),
1114
+ updateWorkItem: async () => ({})
1115
+ }),
1116
+ });
1117
+ try {
1118
+ await (0, engine_1.push)(config, tempDir, { dryRun: false });
1119
+ strict_1.default.deepEqual(deletedSuites, [102]);
1120
+ }
1121
+ finally {
1122
+ client_1.AzureClient.create = originalCreate;
1123
+ fs.rmSync(tempDir, { recursive: true, force: true });
1124
+ }
1125
+ });
1126
+ (0, node_test_1.default)('push can prune empty stale generated suites for removed local specs on full-scope runs', async () => {
1127
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-hierarchy-stale-prune-'));
1128
+ const filePath = path.join(tempDir, 'specs', 'active', 'login.md');
1129
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1130
+ fs.writeFileSync(filePath, ['### Active case', '@tc:55', '', 'Steps:', '1. Existing step', ''].join('\n'));
1131
+ fs.writeFileSync(path.join(tempDir, '.ado-sync-state.json'), JSON.stringify({
1132
+ 55: {
1133
+ title: 'Active case',
1134
+ stepsHash: 'cached-steps',
1135
+ descriptionHash: 'cached-description',
1136
+ remoteDescriptionHash: 'cached-remote-description',
1137
+ changedDate: '2026-05-01T00:00:00Z',
1138
+ filePath,
1139
+ },
1140
+ 77: {
1141
+ title: 'Removed local case',
1142
+ stepsHash: 'stale-steps',
1143
+ descriptionHash: 'stale-description',
1144
+ remoteDescriptionHash: 'stale-remote-description',
1145
+ changedDate: '2026-05-01T00:00:00Z',
1146
+ filePath: path.join(tempDir, 'specs', 'legacy', 'old.md'),
1147
+ suitePathKey: 'specs/legacy',
1148
+ },
1149
+ }, null, 2));
1150
+ const config = makeConfig({
1151
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1152
+ testPlan: {
1153
+ id: 1,
1154
+ suiteId: 10,
1155
+ hierarchy: { mode: 'byFolder', cleanupEmptySuites: true },
1156
+ },
1157
+ });
1158
+ const deletedSuites = [];
1159
+ const suites = [
1160
+ { id: 101, name: 'specs', parentSuite: { id: 10 } },
1161
+ { id: 102, name: 'legacy', parentSuite: { id: 101 } },
1162
+ { id: 103, name: 'active', parentSuite: { id: 101 } },
1163
+ ];
1164
+ const originalCreate = client_1.AzureClient.create;
1165
+ client_1.AzureClient.create = async () => ({
1166
+ getTestPlanApi: async () => ({
1167
+ getTestCaseList: async (_project, _planId, suiteId) => {
1168
+ if (suiteId === 10)
1169
+ return [{ workItem: { id: 77 } }];
1170
+ if (suiteId === 103)
1171
+ return [{ workItem: { id: 55 } }];
1172
+ if (suiteId === 102 || suiteId === 101)
1173
+ return [];
1174
+ return [];
1175
+ },
1176
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
1177
+ getTestSuitesForPlan: async () => suites,
1178
+ addTestCasesToSuite: async () => undefined,
1179
+ removeTestCasesFromSuite: async () => undefined,
1180
+ deleteTestSuite: async (_project, _planId, suiteId) => {
1181
+ deletedSuites.push(suiteId);
1182
+ const index = suites.findIndex((suite) => suite.id === suiteId);
1183
+ if (index >= 0)
1184
+ suites.splice(index, 1);
1185
+ },
1186
+ }),
1187
+ getWitApi: async () => ({
1188
+ getWorkItem: async (id) => ({
1189
+ id,
1190
+ fields: {
1191
+ 'System.Title': id === 55 ? 'Active case' : 'Removed local case',
1192
+ 'System.Description': '',
1193
+ 'Microsoft.VSTS.TCM.Steps': '<steps id="0" last="2"><step id="2" type="ValidateStep"><parameterizedString isformatted="true">&lt;DIV&gt;&lt;P&gt;Step Existing step&lt;/P&gt;&lt;/DIV&gt;</parameterizedString><parameterizedString isformatted="true"></parameterizedString><description/></step></steps>',
1194
+ 'System.Tags': '',
1195
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
1196
+ },
1197
+ }),
1198
+ getWorkItems: async () => [{
1199
+ id: 77,
1200
+ fields: {
1201
+ 'System.WorkItemType': 'Test Case',
1202
+ 'System.Title': 'Removed local case',
1203
+ 'System.Description': '',
1204
+ 'Microsoft.VSTS.TCM.Steps': '<steps id="0" last="2"><step id="2" type="ValidateStep"><parameterizedString isformatted="true">&lt;DIV&gt;&lt;P&gt;Step Existing step&lt;/P&gt;&lt;/DIV&gt;</parameterizedString><parameterizedString isformatted="true"></parameterizedString><description/></step></steps>',
1205
+ 'System.Tags': '',
1206
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
1207
+ },
1208
+ }],
1209
+ updateWorkItem: async () => ({}),
1210
+ }),
1211
+ });
1212
+ try {
1213
+ await (0, engine_1.push)(config, tempDir, { dryRun: false });
1214
+ strict_1.default.deepEqual(deletedSuites, [102]);
1215
+ }
1216
+ finally {
1217
+ client_1.AzureClient.create = originalCreate;
1218
+ fs.rmSync(tempDir, { recursive: true, force: true });
1219
+ }
1220
+ });
360
1221
  (0, node_test_1.default)('pull-create errors for unsupported local types instead of creating markdown files', async () => {
361
1222
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-pull-create-'));
362
1223
  const filePath = path.join(tempDir, 'sample.test.ts');
@@ -392,11 +1253,40 @@ function makeParsedTest(overrides = {}) {
392
1253
  fs.rmSync(tempDir, { recursive: true, force: true });
393
1254
  }
394
1255
  });
1256
+ (0, node_test_1.default)('pull-create is blocked on source-file-filtered runs', async () => {
1257
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-pull-source-file-'));
1258
+ const filePath = path.join(tempDir, 'sample.feature');
1259
+ fs.writeFileSync(filePath, ['Feature: Sample', '', ' Scenario: existing test', ' Given a step', ''].join('\n'));
1260
+ const config = makeConfig({
1261
+ local: { type: 'gherkin', include: '*.feature' },
1262
+ sync: {
1263
+ tagPrefix: 'tc',
1264
+ pull: { enableCreatingNewLocalTestCases: true },
1265
+ },
1266
+ });
1267
+ const originalCreate = client_1.AzureClient.create;
1268
+ client_1.AzureClient.create = async () => ({
1269
+ getTestApi: async () => ({}),
1270
+ getTestPlanApi: async () => ({
1271
+ getTestCaseList: async () => [],
1272
+ getTestPlanById: async () => ({ rootSuite: { id: 10 } }),
1273
+ }),
1274
+ getWitApi: async () => ({ getWorkItems: async () => [] }),
1275
+ });
1276
+ try {
1277
+ const results = await (0, engine_1.pull)(config, tempDir, { sourceFiles: [filePath] });
1278
+ strict_1.default.ok(results.some((result) => result.action === 'error' && /--source-file/.test(result.detail ?? '')));
1279
+ }
1280
+ finally {
1281
+ client_1.AzureClient.create = originalCreate;
1282
+ fs.rmSync(tempDir, { recursive: true, force: true });
1283
+ }
1284
+ });
395
1285
  (0, node_test_1.default)('configurationKey marker prefixes parse namespaced and legacy JavaScript IDs', () => {
396
1286
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-config-key-'));
397
1287
  const filePath = path.join(tempDir, 'sample.test.ts');
398
1288
  const config = makeConfig({ configurationKey: 'Smoke Suite' });
399
- const markerPrefix = (0, id_markers_1.getPreferredMarkerTagPrefix)(config);
1289
+ const markerPrefix = (0, id_markers_2.getPreferredMarkerTagPrefix)(config);
400
1290
  try {
401
1291
  fs.writeFileSync(filePath, [`// @${markerPrefix}:123`, "test('namespaced id', () => {});", ''].join('\n'));
402
1292
  const namespaced = (0, javascript_1.parseJavaScriptFile)(filePath, [markerPrefix, 'tc']);
@@ -409,4 +1299,124 @@ function makeParsedTest(overrides = {}) {
409
1299
  fs.rmSync(tempDir, { recursive: true, force: true });
410
1300
  }
411
1301
  });
1302
+ (0, node_test_1.default)('getTestCasesInSuite filters remote inventory to tagged ownership scope', async () => {
1303
+ const config = makeConfig({
1304
+ configurationKey: 'Smoke Suite',
1305
+ syncTarget: { mode: 'tagged' },
1306
+ });
1307
+ const ownershipTag = (0, id_markers_1.getSyncTargetOwnershipTag)(config);
1308
+ const client = {
1309
+ getTestPlanApi: async () => ({
1310
+ getTestCaseList: async () => [
1311
+ { workItem: { id: 101 } },
1312
+ { workItem: { id: 102 } },
1313
+ ],
1314
+ getTestPlanById: async () => ({ rootSuite: { id: 10 } }),
1315
+ }),
1316
+ getWitApi: async () => ({
1317
+ getWorkItems: async () => [
1318
+ { id: 101, fields: { 'System.Title': 'Owned case', 'System.Tags': ownershipTag } },
1319
+ { id: 102, fields: { 'System.Title': 'Other case', 'System.Tags': 'other-owner' } },
1320
+ ],
1321
+ }),
1322
+ };
1323
+ const testCases = await (0, test_cases_1.getTestCasesInSuite)(client, config);
1324
+ strict_1.default.deepEqual(testCases.map((testCase) => testCase.id), [101]);
1325
+ });
1326
+ (0, node_test_1.default)('getTestCasesInSuite resolves query-based sync targets via WIQL', async () => {
1327
+ const config = makeConfig({
1328
+ syncTarget: {
1329
+ mode: 'query',
1330
+ wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'Test Case'",
1331
+ },
1332
+ });
1333
+ let suiteFetchCount = 0;
1334
+ const client = {
1335
+ getTestPlanApi: async () => ({
1336
+ getTestCaseList: async () => {
1337
+ suiteFetchCount++;
1338
+ return [];
1339
+ },
1340
+ }),
1341
+ getWitApi: async () => ({
1342
+ queryByWiql: async () => ({ workItems: [{ id: 77 }] }),
1343
+ getWorkItems: async () => [{
1344
+ id: 77,
1345
+ fields: {
1346
+ 'System.WorkItemType': 'Test Case',
1347
+ 'System.Title': 'Query-owned case',
1348
+ 'System.Tags': '',
1349
+ },
1350
+ }],
1351
+ }),
1352
+ };
1353
+ const testCases = await (0, test_cases_1.getTestCasesInSuite)(client, config);
1354
+ strict_1.default.equal(suiteFetchCount, 0);
1355
+ strict_1.default.deepEqual(testCases.map((testCase) => testCase.id), [77]);
1356
+ });
1357
+ (0, node_test_1.default)('status supports query-based sync targets with testPlans', async () => {
1358
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-status-query-testplans-'));
1359
+ const smokePath = path.join(tempDir, 'specs', 'smoke', 'login.md');
1360
+ const regressionPath = path.join(tempDir, 'specs', 'regression', 'checkout.md');
1361
+ fs.mkdirSync(path.dirname(smokePath), { recursive: true });
1362
+ fs.mkdirSync(path.dirname(regressionPath), { recursive: true });
1363
+ fs.writeFileSync(smokePath, ['### Login case', '', 'Steps:', '1. Open app', ''].join('\n'));
1364
+ fs.writeFileSync(regressionPath, ['### Checkout case', '', 'Steps:', '1. Add item', ''].join('\n'));
1365
+ const config = makeConfig({
1366
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1367
+ syncTarget: {
1368
+ mode: 'query',
1369
+ wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'Test Case'",
1370
+ },
1371
+ testPlans: [
1372
+ { id: 11, include: 'specs/smoke/**/*.md' },
1373
+ { id: 22, include: 'specs/regression/**/*.md' },
1374
+ ],
1375
+ });
1376
+ let suiteFetchCount = 0;
1377
+ let queryCount = 0;
1378
+ const originalCreate = client_1.AzureClient.create;
1379
+ client_1.AzureClient.create = async () => ({
1380
+ getTestPlanApi: async () => ({
1381
+ getTestCaseList: async () => {
1382
+ suiteFetchCount++;
1383
+ return [];
1384
+ },
1385
+ getTestPlanById: async (_project, planId) => ({ id: planId, rootSuite: { id: planId * 10 } }),
1386
+ }),
1387
+ getWitApi: async () => ({
1388
+ queryByWiql: async () => {
1389
+ queryCount++;
1390
+ return { workItems: [] };
1391
+ },
1392
+ getWorkItems: async () => [],
1393
+ }),
1394
+ });
1395
+ try {
1396
+ const results = await (0, engine_1.status)(config, tempDir);
1397
+ strict_1.default.equal(queryCount, 2);
1398
+ strict_1.default.equal(suiteFetchCount, 0);
1399
+ strict_1.default.equal(results.filter((result) => result.action === 'created').length, 2);
1400
+ }
1401
+ finally {
1402
+ client_1.AzureClient.create = originalCreate;
1403
+ fs.rmSync(tempDir, { recursive: true, force: true });
1404
+ }
1405
+ });
1406
+ (0, node_test_1.default)('detectAiEnvironment returns heuristic when Visual Studio Agent mode env is set', () => {
1407
+ withCleanAiDetectionEnv({
1408
+ VISUAL_STUDIO_AGENT_MODE: '1',
1409
+ }, () => {
1410
+ const detected = (0, summarizer_1.detectAiEnvironment)();
1411
+ strict_1.default.equal(detected?.provider, 'heuristic');
1412
+ });
1413
+ });
1414
+ (0, node_test_1.default)('detectAiEnvironment returns heuristic when Codex env signal is set', () => {
1415
+ withCleanAiDetectionEnv({
1416
+ CODEX_CLI: '1',
1417
+ }, () => {
1418
+ const detected = (0, summarizer_1.detectAiEnvironment)();
1419
+ strict_1.default.equal(detected?.provider, 'heuristic');
1420
+ });
1421
+ });
412
1422
  //# sourceMappingURL=regressions.test.js.map