ado-sync 0.1.65 → 0.1.68

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 (67) hide show
  1. package/README.md +15 -15
  2. package/dist/__tests__/regressions.test.js +1133 -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-diagnostics.d.ts +66 -0
  11. package/dist/cli-diagnostics.js +75 -0
  12. package/dist/cli-diagnostics.js.map +1 -0
  13. package/dist/cli.js +335 -23
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.js +194 -9
  16. package/dist/config.js.map +1 -1
  17. package/dist/extensions.d.ts +8 -0
  18. package/dist/extensions.js +86 -0
  19. package/dist/extensions.js.map +1 -0
  20. package/dist/id-markers.d.ts +1 -0
  21. package/dist/id-markers.js +13 -0
  22. package/dist/id-markers.js.map +1 -1
  23. package/dist/sync/cache.d.ts +2 -0
  24. package/dist/sync/cache.js.map +1 -1
  25. package/dist/sync/engine.d.ts +29 -2
  26. package/dist/sync/engine.js +270 -41
  27. package/dist/sync/engine.js.map +1 -1
  28. package/dist/sync/publish-results.d.ts +25 -0
  29. package/dist/sync/publish-results.js +81 -2
  30. package/dist/sync/publish-results.js.map +1 -1
  31. package/dist/types.d.ts +98 -2
  32. package/llms.txt +11 -11
  33. package/package.json +9 -1
  34. package/docs/advanced.md +0 -989
  35. package/docs/agent-setup.md +0 -204
  36. package/docs/capability-roadmap.md +0 -280
  37. package/docs/cli.md +0 -614
  38. package/docs/configuration.md +0 -322
  39. package/docs/examples/csharp-mstest-local-llm.yaml +0 -35
  40. package/docs/examples/csharp-mstest.yaml +0 -21
  41. package/docs/examples/csharp-nunit.yaml +0 -21
  42. package/docs/examples/csharp-specflow.yaml +0 -16
  43. package/docs/examples/cypress.yaml +0 -21
  44. package/docs/examples/detox-react-native.yaml +0 -21
  45. package/docs/examples/espresso-android.yaml +0 -21
  46. package/docs/examples/flutter-dart.yaml +0 -21
  47. package/docs/examples/java-junit.yaml +0 -21
  48. package/docs/examples/java-testng.yaml +0 -21
  49. package/docs/examples/js-jasmine-wdio.yaml +0 -21
  50. package/docs/examples/js-jest.yaml +0 -21
  51. package/docs/examples/playwright-js.yaml +0 -21
  52. package/docs/examples/playwright-ts.yaml +0 -21
  53. package/docs/examples/puppeteer.yaml +0 -21
  54. package/docs/examples/python-pytest.yaml +0 -21
  55. package/docs/examples/robot-framework.yaml +0 -19
  56. package/docs/examples/testcafe.yaml +0 -21
  57. package/docs/examples/xcuitest-ios.yaml +0 -21
  58. package/docs/mcp-server.md +0 -312
  59. package/docs/publish-test-results.md +0 -947
  60. package/docs/spec-formats.md +0 -1357
  61. package/docs/troubleshooting.md +0 -101
  62. package/docs/vscode-extension.md +0 -139
  63. package/docs/work-item-links.md +0 -115
  64. package/docs/workflows.md +0 -457
  65. package/mkdocs.yml +0 -40
  66. package/requirements-docs.txt +0 -4
  67. package/scripts/build_site.sh +0 -6
@@ -41,9 +41,13 @@ 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 cli_diagnostics_1 = require("../cli-diagnostics");
48
+ const config_1 = require("../config");
46
49
  const id_markers_1 = require("../id-markers");
50
+ const id_markers_2 = require("../id-markers");
47
51
  const gherkin_1 = require("../parsers/gherkin");
48
52
  const javascript_1 = require("../parsers/javascript");
49
53
  const engine_1 = require("../sync/engine");
@@ -71,6 +75,64 @@ function makeParsedTest(overrides = {}) {
71
75
  ...overrides,
72
76
  };
73
77
  }
78
+ function withTemporaryEnv(vars, fn) {
79
+ const previous = {};
80
+ for (const [key, value] of Object.entries(vars)) {
81
+ previous[key] = process.env[key];
82
+ if (value === undefined)
83
+ delete process.env[key];
84
+ else
85
+ process.env[key] = value;
86
+ }
87
+ try {
88
+ fn();
89
+ }
90
+ finally {
91
+ for (const [key, value] of Object.entries(previous)) {
92
+ if (value === undefined)
93
+ delete process.env[key];
94
+ else
95
+ process.env[key] = value;
96
+ }
97
+ }
98
+ }
99
+ function withCleanAiDetectionEnv(vars, fn) {
100
+ withTemporaryEnv({
101
+ ANTHROPIC_API_KEY: undefined,
102
+ OPENAI_API_KEY: undefined,
103
+ GITHUB_TOKEN: undefined,
104
+ CLAUDE_CODE: undefined,
105
+ CLAUDE_CONTEXT: undefined,
106
+ CODEX: undefined,
107
+ OPENAI_CODEX: undefined,
108
+ CODEX_CLI: undefined,
109
+ VISUAL_STUDIO_AGENT_MODE: undefined,
110
+ VISUAL_STUDIO_COPILOT_AGENT_MODE: undefined,
111
+ VS_COPILOT_AGENT_MODE: undefined,
112
+ COPILOT_AGENT_MODE: undefined,
113
+ CURSOR_SESSION_ID: undefined,
114
+ CURSOR_TRACE_ID: undefined,
115
+ WINDSURF_SESSION_ID: undefined,
116
+ CLINE_TASK_ID: undefined,
117
+ CLINE_SESSION_ID: undefined,
118
+ ANTIGRAVITY_SESSION_ID: undefined,
119
+ AIDER: undefined,
120
+ AIDER_SESSION: undefined,
121
+ CONTINUE_SESSION_ID: undefined,
122
+ AUGMENT_SESSION_ID: undefined,
123
+ ROO_CODE_SESSION_ID: undefined,
124
+ TRAE_SESSION_ID: undefined,
125
+ AMAZON_Q_SESSION_ID: undefined,
126
+ AWS_Q_SESSION_ID: undefined,
127
+ AMP_SESSION_ID: undefined,
128
+ TERM_PROGRAM: undefined,
129
+ TERMINAL_EMULATOR: undefined,
130
+ IDEA_INITIAL_DIRECTORY: undefined,
131
+ __INTELLIJ_COMMAND_HISTFILE__: undefined,
132
+ PATH: '/usr/bin:/bin',
133
+ ...vars,
134
+ }, fn);
135
+ }
74
136
  (0, node_test_1.default)('buildPushDiff flags expected-result-only step changes', () => {
75
137
  const config = makeConfig();
76
138
  const local = makeParsedTest();
@@ -95,6 +157,10 @@ function makeParsedTest(overrides = {}) {
95
157
  msg.includes('unexpected token');
96
158
  });
97
159
  });
160
+ (0, node_test_1.default)('validatePushModeOptions rejects incompatible mode combinations', () => {
161
+ strict_1.default.throws(() => (0, engine_1.validatePushModeOptions)({ createOnly: true, linkOnly: true, updateOnly: false }), /Only one push mode can be used at a time/);
162
+ strict_1.default.throws(() => (0, engine_1.validatePushModeOptions)({ createOnly: false, linkOnly: true, updateOnly: true }), /Only one push mode can be used at a time/);
163
+ });
98
164
  (0, node_test_1.default)('parseGherkinFile preserves doc-string block structure in description HTML', () => {
99
165
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-gherkin-docstring-'));
100
166
  const filePath = path.join(tempDir, 'sample.feature');
@@ -147,6 +213,261 @@ function makeParsedTest(overrides = {}) {
147
213
  strict_1.default.ok(updatePatch.some((p) => p.op === 'remove' && p.path === '/fields/Microsoft.VSTS.TCM.Parameters'));
148
214
  strict_1.default.ok(updatePatch.some((p) => p.op === 'remove' && p.path === '/fields/Microsoft.VSTS.TCM.LocalDataSource'));
149
215
  });
216
+ (0, node_test_1.default)('updateTestCase adds deterministic ownership tag for tagged sync targets', async () => {
217
+ let updatePatch;
218
+ const config = makeConfig({
219
+ configurationKey: 'Smoke Suite',
220
+ syncTarget: { mode: 'tagged' },
221
+ });
222
+ const ownershipTag = (0, id_markers_1.getSyncTargetOwnershipTag)(config);
223
+ const wit = {
224
+ getWorkItem: async () => ({
225
+ fields: {
226
+ 'System.Tags': 'smoke',
227
+ },
228
+ }),
229
+ updateWorkItem: async (_doc, patch) => {
230
+ updatePatch = patch;
231
+ return {};
232
+ },
233
+ };
234
+ const client = {
235
+ getWitApi: async () => wit,
236
+ };
237
+ await (0, test_cases_1.updateTestCase)(client, 99, makeParsedTest(), config);
238
+ strict_1.default.ok(updatePatch, 'expected updateWorkItem to be called');
239
+ const tagsPatch = updatePatch.find((patch) => patch.path === '/fields/System.Tags');
240
+ strict_1.default.ok(tagsPatch, 'expected System.Tags to be updated');
241
+ strict_1.default.match(tagsPatch.value, new RegExp(ownershipTag ?? ''));
242
+ });
243
+ (0, node_test_1.default)('loadConfig accepts declarative hierarchy definitions', () => {
244
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-hierarchy-config-'));
245
+ const configPath = path.join(tempDir, 'ado-sync.json');
246
+ fs.writeFileSync(configPath, JSON.stringify({
247
+ orgUrl: 'https://dev.azure.com/example',
248
+ project: 'ExampleProject',
249
+ auth: { type: 'pat', token: 'token' },
250
+ testPlan: {
251
+ id: 1,
252
+ suiteId: 10,
253
+ hierarchy: { mode: 'byFolder', rootSuite: 'Generated Specs' },
254
+ },
255
+ local: { type: 'gherkin', include: 'specs/**/*.feature' },
256
+ }, null, 2));
257
+ try {
258
+ const loaded = (0, config_1.loadConfig)(configPath);
259
+ strict_1.default.deepEqual(loaded.testPlan.hierarchy, { mode: 'byFolder', rootSuite: 'Generated Specs' });
260
+ }
261
+ finally {
262
+ fs.rmSync(tempDir, { recursive: true, force: true });
263
+ }
264
+ });
265
+ (0, node_test_1.default)('loadConfig accepts tag-driven hierarchy definitions', () => {
266
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-hierarchy-by-tag-config-'));
267
+ const configPath = path.join(tempDir, 'ado-sync.json');
268
+ fs.writeFileSync(configPath, JSON.stringify({
269
+ orgUrl: 'https://dev.azure.com/example',
270
+ project: 'ExampleProject',
271
+ auth: { type: 'pat', token: 'token' },
272
+ testPlan: {
273
+ id: 1,
274
+ suiteId: 10,
275
+ hierarchy: { mode: 'byTag', tagPrefix: 'suite', rootSuite: 'Generated Specs' },
276
+ },
277
+ local: { type: 'markdown', include: 'specs/**/*.md' },
278
+ }, null, 2));
279
+ try {
280
+ const loaded = (0, config_1.loadConfig)(configPath);
281
+ strict_1.default.deepEqual(loaded.testPlan.hierarchy, { mode: 'byTag', tagPrefix: 'suite', rootSuite: 'Generated Specs' });
282
+ }
283
+ finally {
284
+ fs.rmSync(tempDir, { recursive: true, force: true });
285
+ }
286
+ });
287
+ (0, node_test_1.default)('loadConfig accepts level-rule hierarchy definitions and diagnostic output', () => {
288
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-hierarchy-by-levels-config-'));
289
+ const configPath = path.join(tempDir, 'ado-sync.json');
290
+ fs.writeFileSync(configPath, JSON.stringify({
291
+ orgUrl: 'https://dev.azure.com/example',
292
+ project: 'ExampleProject',
293
+ auth: { type: 'pat', token: 'token' },
294
+ testPlan: {
295
+ id: 1,
296
+ suiteId: 10,
297
+ hierarchy: {
298
+ mode: 'byLevels',
299
+ rootSuite: 'Generated Specs',
300
+ levels: [
301
+ { source: 'folder', index: 0 },
302
+ { source: 'tag', tagPrefix: 'suite' },
303
+ ],
304
+ },
305
+ },
306
+ local: { type: 'markdown', include: 'specs/**/*.md' },
307
+ toolSettings: { outputLevel: 'diagnostic' },
308
+ }, null, 2));
309
+ try {
310
+ const loaded = (0, config_1.loadConfig)(configPath);
311
+ strict_1.default.deepEqual(loaded.testPlan.hierarchy, {
312
+ mode: 'byLevels',
313
+ rootSuite: 'Generated Specs',
314
+ levels: [
315
+ { source: 'folder', index: 0 },
316
+ { source: 'tag', tagPrefix: 'suite' },
317
+ ],
318
+ });
319
+ strict_1.default.equal(loaded.toolSettings?.outputLevel, 'diagnostic');
320
+ }
321
+ finally {
322
+ fs.rmSync(tempDir, { recursive: true, force: true });
323
+ }
324
+ });
325
+ (0, node_test_1.default)('getValidateDiagnosticItems summarizes effective validate context', () => {
326
+ strict_1.default.deepEqual((0, cli_diagnostics_1.getValidateDiagnosticItems)({
327
+ authType: 'pat',
328
+ localType: 'gherkin',
329
+ syncTargetMode: 'query',
330
+ planIds: [7, 9],
331
+ overrideCount: 2,
332
+ }), [
333
+ { label: 'Auth type', value: 'pat' },
334
+ { label: 'Local type', value: 'gherkin' },
335
+ { label: 'Sync target', value: 'query' },
336
+ { label: 'Plan IDs', value: '7, 9' },
337
+ { label: 'Overrides', value: '2' },
338
+ ]);
339
+ });
340
+ (0, node_test_1.default)('getStaleDiagnosticItems summarizes stale detection context', () => {
341
+ strict_1.default.deepEqual((0, cli_diagnostics_1.getStaleDiagnosticItems)({
342
+ syncTargetMode: 'tagged',
343
+ planIds: [5],
344
+ markerPrefix: 'tc',
345
+ ownershipTag: 'ado-sync:smoke-suite',
346
+ tagExpression: '@smoke',
347
+ staleCount: 3,
348
+ retireState: 'Closed',
349
+ dryRun: true,
350
+ overrideCount: 1,
351
+ }), [
352
+ { label: 'Sync target', value: 'tagged' },
353
+ { label: 'Plan ID', value: '5' },
354
+ { label: 'Marker prefix', value: 'tc' },
355
+ { label: 'Ownership tag', value: 'ado-sync:smoke-suite' },
356
+ { label: 'Tag filter', value: '@smoke' },
357
+ { label: 'Stale candidates', value: '3' },
358
+ { label: 'Retire state', value: 'Closed' },
359
+ { label: 'Dry run', value: 'yes' },
360
+ { label: 'Overrides', value: '1' },
361
+ ]);
362
+ });
363
+ (0, node_test_1.default)('getCoverageDiagnosticItems summarizes coverage context', () => {
364
+ strict_1.default.deepEqual((0, cli_diagnostics_1.getCoverageDiagnosticItems)({
365
+ localType: 'markdown',
366
+ syncTargetMode: 'suite',
367
+ tagExpression: '@smoke',
368
+ totalLocalSpecs: 12,
369
+ linkedSpecs: 9,
370
+ unlinkedSpecs: 3,
371
+ storiesReferenced: 5,
372
+ storiesCovered: 4,
373
+ storyPrefix: 'story',
374
+ failBelow: 80,
375
+ overrideCount: 1,
376
+ }), [
377
+ { label: 'Local type', value: 'markdown' },
378
+ { label: 'Sync target', value: 'suite' },
379
+ { label: 'Tag filter', value: '@smoke' },
380
+ { label: 'Total specs', value: '12' },
381
+ { label: 'Linked specs', value: '9' },
382
+ { label: 'Unlinked specs', value: '3' },
383
+ { label: 'Story prefix', value: 'story' },
384
+ { label: 'Stories referenced', value: '5' },
385
+ { label: 'Stories covered', value: '4' },
386
+ { label: 'Fail-below gate', value: '80%' },
387
+ { label: 'Overrides', value: '1' },
388
+ ]);
389
+ });
390
+ (0, node_test_1.default)('getTrendDiagnosticItems summarizes trend context', () => {
391
+ strict_1.default.deepEqual((0, cli_diagnostics_1.getTrendDiagnosticItems)({
392
+ days: 14,
393
+ maxRuns: 25,
394
+ topN: 7,
395
+ runNameFilter: 'nightly',
396
+ webhookType: 'teams',
397
+ failOnFlaky: true,
398
+ failBelow: 85,
399
+ runsAnalyzed: 12,
400
+ totalResults: 240,
401
+ flakyCount: 3,
402
+ failingCount: 7,
403
+ overrideCount: 2,
404
+ }), [
405
+ { label: 'Days', value: '14' },
406
+ { label: 'Max runs', value: '25' },
407
+ { label: 'Top-N', value: '7' },
408
+ { label: 'Run-name filter', value: 'nightly' },
409
+ { label: 'Webhook', value: 'teams' },
410
+ { label: 'Fail on flaky', value: 'yes' },
411
+ { label: 'Fail-below gate', value: '85%' },
412
+ { label: 'Runs analyzed', value: '12' },
413
+ { label: 'Results analyzed', value: '240' },
414
+ { label: 'Flaky tests', value: '3' },
415
+ { label: 'Top failing tests', value: '7' },
416
+ { label: 'Overrides', value: '2' },
417
+ ]);
418
+ });
419
+ (0, node_test_1.default)('getAcGateDiagnosticItems summarizes ac-gate context', () => {
420
+ strict_1.default.deepEqual((0, cli_diagnostics_1.getAcGateDiagnosticItems)({
421
+ selectorMode: 'area-path',
422
+ selectorValue: 'Project\\QA',
423
+ failMode: 'no-ac-only',
424
+ totalStories: 9,
425
+ passed: 6,
426
+ failed: 3,
427
+ noAc: 2,
428
+ noTc: 1,
429
+ overrideCount: 1,
430
+ }), [
431
+ { label: 'Selector mode', value: 'area-path' },
432
+ { label: 'Selector value', value: 'Project\\QA' },
433
+ { label: 'States', value: 'n/a' },
434
+ { label: 'Fail mode', value: 'no-ac-only' },
435
+ { label: 'Stories selected', value: '9' },
436
+ { label: 'Passed', value: '6' },
437
+ { label: 'Failed', value: '3' },
438
+ { label: 'Missing AC', value: '2' },
439
+ { label: 'Missing TCs', value: '1' },
440
+ { label: 'Overrides', value: '1' },
441
+ ]);
442
+ });
443
+ (0, node_test_1.default)('getOrCreateSuiteForFile anchors generated hierarchy under a named root suite', async () => {
444
+ const createdSuites = [];
445
+ let nextSuiteId = 100;
446
+ const client = {
447
+ getTestPlanApi: async () => ({
448
+ getTestSuitesForPlan: async () => [],
449
+ createTestSuite: async (suite) => {
450
+ createdSuites.push({ name: suite.name, parentSuiteId: suite.parentSuite.id });
451
+ return { id: nextSuiteId++ };
452
+ },
453
+ }),
454
+ };
455
+ const config = makeConfig({
456
+ testPlan: {
457
+ id: 1,
458
+ suiteId: 10,
459
+ hierarchy: { mode: 'byFile', rootSuite: 'Generated Specs' },
460
+ },
461
+ });
462
+ const suiteId = await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, '/repo/specs/auth/login.feature', '/repo', new Map());
463
+ strict_1.default.equal(suiteId, 103);
464
+ strict_1.default.deepEqual(createdSuites, [
465
+ { name: 'Generated Specs', parentSuiteId: 10 },
466
+ { name: 'specs', parentSuiteId: 100 },
467
+ { name: 'auth', parentSuiteId: 101 },
468
+ { name: 'login', parentSuiteId: 102 },
469
+ ]);
470
+ });
150
471
  (0, node_test_1.default)('writebackDocComment preserves user-authored JSDoc and parser ignores ado-sync marker', async () => {
151
472
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-test-'));
152
473
  const filePath = path.join(tempDir, 'sample.test.ts');
@@ -211,6 +532,8 @@ function makeParsedTest(overrides = {}) {
211
532
  const summary = await (0, publish_results_1.publishTestResults)(config, tempDir);
212
533
  strict_1.default.equal(summary.runId, 77);
213
534
  strict_1.default.deepEqual(createRunModel.configurationIds, [9]);
535
+ strict_1.default.equal(summary.diagnostics?.configurationId, 9);
536
+ strict_1.default.equal(summary.diagnostics?.sources[0]?.format, 'junit');
214
537
  }
215
538
  finally {
216
539
  client_1.AzureClient.create = originalCreate;
@@ -271,6 +594,7 @@ function makeParsedTest(overrides = {}) {
271
594
  strict_1.default.equal(addedResultsPayload[0].testPlan.id, '12');
272
595
  strict_1.default.equal(addedResultsPayload[0].testCaseRevision, 7);
273
596
  strict_1.default.equal(addedResultsPayload[0].configuration.id, '9');
597
+ strict_1.default.deepEqual(summary.diagnostics?.plannedRun, { planId: 12, suiteId: 34, pointCount: 1 });
274
598
  }
275
599
  finally {
276
600
  client_1.AzureClient.create = originalCreate;
@@ -357,6 +681,665 @@ function makeParsedTest(overrides = {}) {
357
681
  fs.rmSync(tempDir, { recursive: true, force: true });
358
682
  }
359
683
  });
684
+ (0, node_test_1.default)('push limits processing to requested source files and skips removed-case detection', async () => {
685
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-source-file-'));
686
+ const selectedFilePath = path.join(tempDir, 'selected.md');
687
+ const skippedFilePath = path.join(tempDir, 'skipped.md');
688
+ fs.writeFileSync(selectedFilePath, ['### Selected case', '', 'Steps:', '1. Open the app', ''].join('\n'));
689
+ fs.writeFileSync(skippedFilePath, ['### Skipped case', '', 'Steps:', '1. Do not sync me', ''].join('\n'));
690
+ const config = makeConfig({
691
+ local: { type: 'markdown', include: '*.md' },
692
+ });
693
+ let suiteFetchCount = 0;
694
+ const originalCreate = client_1.AzureClient.create;
695
+ client_1.AzureClient.create = async () => ({
696
+ getTestPlanApi: async () => ({
697
+ getTestCaseList: async () => {
698
+ suiteFetchCount++;
699
+ return [{ workItem: { id: 77 } }];
700
+ },
701
+ getTestPlanById: async () => ({ rootSuite: { id: 10 } }),
702
+ }),
703
+ getWitApi: async () => ({
704
+ getWorkItems: async () => [{ id: 77, fields: { 'System.Title': 'Unrelated remote case', 'System.Tags': '' } }],
705
+ }),
706
+ });
707
+ try {
708
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, sourceFiles: [selectedFilePath] });
709
+ strict_1.default.equal(suiteFetchCount, 0);
710
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.filePath === selectedFilePath));
711
+ strict_1.default.ok(!results.some((result) => result.filePath === skippedFilePath));
712
+ strict_1.default.ok(!results.some((result) => result.action === 'removed'));
713
+ }
714
+ finally {
715
+ client_1.AzureClient.create = originalCreate;
716
+ fs.rmSync(tempDir, { recursive: true, force: true });
717
+ }
718
+ });
719
+ (0, node_test_1.default)('push create-only creates unlinked cases and skips linked updates and removed detection', async () => {
720
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-create-only-'));
721
+ const filePath = path.join(tempDir, 'spec.md');
722
+ fs.writeFileSync(filePath, [
723
+ '### Existing case',
724
+ '@tc:55',
725
+ '',
726
+ 'Steps:',
727
+ '1. Existing step',
728
+ '',
729
+ '---',
730
+ '',
731
+ '### New case',
732
+ '',
733
+ 'Steps:',
734
+ '1. New step',
735
+ '',
736
+ ].join('\n'));
737
+ const config = makeConfig({
738
+ local: { type: 'markdown', include: '*.md' },
739
+ });
740
+ let suiteFetchCount = 0;
741
+ const originalCreate = client_1.AzureClient.create;
742
+ client_1.AzureClient.create = async () => ({
743
+ getTestPlanApi: async () => ({
744
+ getTestCaseList: async () => {
745
+ suiteFetchCount++;
746
+ return [{ workItem: { id: 77 } }];
747
+ },
748
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
749
+ }),
750
+ getWitApi: async () => ({
751
+ getWorkItems: async () => [{ id: 77, fields: { 'System.Title': 'Unrelated remote case', 'System.Tags': '' } }],
752
+ }),
753
+ });
754
+ try {
755
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, createOnly: true });
756
+ strict_1.default.equal(suiteFetchCount, 0);
757
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.title === 'New case'));
758
+ strict_1.default.ok(results.some((result) => result.action === 'skipped' && result.azureId === 55 && /create-only/.test(result.detail ?? '')));
759
+ strict_1.default.ok(!results.some((result) => result.action === 'removed'));
760
+ strict_1.default.ok(!results.some((result) => result.action === 'updated'));
761
+ }
762
+ finally {
763
+ client_1.AzureClient.create = originalCreate;
764
+ fs.rmSync(tempDir, { recursive: true, force: true });
765
+ }
766
+ });
767
+ (0, node_test_1.default)('push link-only links unlinked cases by unique exact title match without creating or updating', async () => {
768
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-link-only-'));
769
+ const filePath = path.join(tempDir, 'spec.md');
770
+ fs.writeFileSync(filePath, [
771
+ '### Already linked',
772
+ '@tc:55',
773
+ '',
774
+ 'Steps:',
775
+ '1. Existing step',
776
+ '',
777
+ '---',
778
+ '',
779
+ '### Match me',
780
+ '',
781
+ 'Steps:',
782
+ '1. Same title as remote',
783
+ '',
784
+ ].join('\n'));
785
+ const config = makeConfig({
786
+ local: { type: 'markdown', include: '*.md' },
787
+ });
788
+ let suiteFetchCount = 0;
789
+ const originalCreate = client_1.AzureClient.create;
790
+ client_1.AzureClient.create = async () => ({
791
+ getTestPlanApi: async () => ({
792
+ getTestCaseList: async () => {
793
+ suiteFetchCount++;
794
+ return [{ workItem: { id: 88 } }, { workItem: { id: 99 } }];
795
+ },
796
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
797
+ }),
798
+ getWitApi: async () => ({
799
+ getWorkItems: async () => [
800
+ { id: 88, fields: { 'System.Title': 'Match me', 'System.Tags': '' } },
801
+ { id: 99, fields: { 'System.Title': 'Unrelated remote case', 'System.Tags': '' } },
802
+ ],
803
+ }),
804
+ });
805
+ try {
806
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, linkOnly: true });
807
+ strict_1.default.equal(suiteFetchCount, 1);
808
+ strict_1.default.ok(results.some((result) => result.action === 'linked' && result.azureId === 88 && result.title === 'Match me'));
809
+ strict_1.default.ok(results.some((result) => result.action === 'skipped' && result.azureId === 55 && /link-only/.test(result.detail ?? '')));
810
+ strict_1.default.ok(!results.some((result) => result.action === 'created'));
811
+ strict_1.default.ok(!results.some((result) => result.action === 'updated'));
812
+ strict_1.default.ok(!results.some((result) => result.action === 'removed'));
813
+ }
814
+ finally {
815
+ client_1.AzureClient.create = originalCreate;
816
+ fs.rmSync(tempDir, { recursive: true, force: true });
817
+ }
818
+ });
819
+ (0, node_test_1.default)('push link-only skips ambiguous exact title matches', async () => {
820
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-link-ambiguous-'));
821
+ const filePath = path.join(tempDir, 'spec.md');
822
+ fs.writeFileSync(filePath, ['### Duplicate title', '', 'Steps:', '1. Step', ''].join('\n'));
823
+ const config = makeConfig({
824
+ local: { type: 'markdown', include: '*.md' },
825
+ });
826
+ const originalCreate = client_1.AzureClient.create;
827
+ client_1.AzureClient.create = async () => ({
828
+ getTestPlanApi: async () => ({
829
+ getTestCaseList: async () => [{ workItem: { id: 90 } }, { workItem: { id: 91 } }],
830
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
831
+ }),
832
+ getWitApi: async () => ({
833
+ getWorkItems: async () => [
834
+ { id: 90, fields: { 'System.Title': 'Duplicate title', 'System.Tags': '' } },
835
+ { id: 91, fields: { 'System.Title': 'Duplicate title', 'System.Tags': '' } },
836
+ ],
837
+ }),
838
+ });
839
+ try {
840
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, linkOnly: true });
841
+ strict_1.default.ok(results.some((result) => result.action === 'skipped' && /multiple title matches/.test(result.detail ?? '')));
842
+ strict_1.default.ok(!results.some((result) => result.action === 'linked'));
843
+ strict_1.default.ok(!results.some((result) => result.action === 'created'));
844
+ }
845
+ finally {
846
+ client_1.AzureClient.create = originalCreate;
847
+ fs.rmSync(tempDir, { recursive: true, force: true });
848
+ }
849
+ });
850
+ (0, node_test_1.default)('push update-only updates linked cases and skips unlinked cases and removed detection', async () => {
851
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-update-only-'));
852
+ const filePath = path.join(tempDir, 'spec.md');
853
+ fs.writeFileSync(filePath, [
854
+ '### Existing case',
855
+ '@tc:55',
856
+ '',
857
+ 'Steps:',
858
+ '1. Updated local step',
859
+ '',
860
+ '---',
861
+ '',
862
+ '### New case',
863
+ '',
864
+ 'Steps:',
865
+ '1. New step',
866
+ '',
867
+ ].join('\n'));
868
+ const config = makeConfig({
869
+ local: { type: 'markdown', include: '*.md' },
870
+ });
871
+ let suiteFetchCount = 0;
872
+ const originalCreate = client_1.AzureClient.create;
873
+ client_1.AzureClient.create = async () => ({
874
+ getTestPlanApi: async () => ({
875
+ getTestCaseList: async () => {
876
+ suiteFetchCount++;
877
+ return [{ workItem: { id: 77 } }];
878
+ },
879
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
880
+ addTestCasesToSuite: async () => undefined,
881
+ }),
882
+ getWitApi: async () => ({
883
+ getWorkItems: async () => [{
884
+ id: 77,
885
+ fields: {
886
+ 'System.Title': 'Unrelated remote case',
887
+ 'System.Tags': '',
888
+ },
889
+ }],
890
+ getWorkItem: async (id) => ({
891
+ id,
892
+ fields: {
893
+ 'System.Title': 'Existing case',
894
+ 'System.Description': '',
895
+ '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>',
896
+ 'System.Tags': '',
897
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
898
+ },
899
+ }),
900
+ updateWorkItem: async () => ({}),
901
+ }),
902
+ });
903
+ try {
904
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: true, updateOnly: true });
905
+ strict_1.default.equal(suiteFetchCount, 0);
906
+ strict_1.default.ok(results.some((result) => result.action === 'updated' && result.azureId === 55));
907
+ strict_1.default.ok(results.some((result) => result.action === 'skipped' && result.title === 'New case' && /update-only/.test(result.detail ?? '')));
908
+ strict_1.default.ok(!results.some((result) => result.action === 'created'));
909
+ strict_1.default.ok(!results.some((result) => result.action === 'removed'));
910
+ }
911
+ finally {
912
+ client_1.AzureClient.create = originalCreate;
913
+ fs.rmSync(tempDir, { recursive: true, force: true });
914
+ }
915
+ });
916
+ (0, node_test_1.default)('push moves linked hierarchy-managed cases to the new generated suite when file path changes', async () => {
917
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-hierarchy-move-'));
918
+ const oldRelativePath = 'specs/old/login.md';
919
+ const newRelativePath = 'specs/new/login.md';
920
+ const filePath = path.join(tempDir, newRelativePath);
921
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
922
+ fs.writeFileSync(filePath, ['### Existing case', '@tc:55', '', 'Steps:', '1. Existing step', ''].join('\n'));
923
+ fs.writeFileSync(path.join(tempDir, '.ado-sync-state.json'), JSON.stringify({
924
+ 55: {
925
+ title: 'Existing case',
926
+ stepsHash: 'cached-steps',
927
+ descriptionHash: 'cached-description',
928
+ remoteDescriptionHash: 'cached-remote-description',
929
+ changedDate: '2026-05-01T00:00:00Z',
930
+ filePath: path.join(tempDir, oldRelativePath),
931
+ },
932
+ }, null, 2));
933
+ const config = makeConfig({
934
+ local: { type: 'markdown', include: 'specs/**/*.md' },
935
+ testPlan: {
936
+ id: 1,
937
+ suiteId: 10,
938
+ hierarchy: { mode: 'byFolder' },
939
+ },
940
+ });
941
+ const addedToSuites = [];
942
+ const removedFromSuites = [];
943
+ const suites = [
944
+ { id: 101, name: 'specs', parentSuite: { id: 10 } },
945
+ { id: 102, name: 'old', parentSuite: { id: 101 } },
946
+ ];
947
+ let nextSuiteId = 103;
948
+ const originalCreate = client_1.AzureClient.create;
949
+ client_1.AzureClient.create = async () => ({
950
+ getTestPlanApi: async () => ({
951
+ getTestCaseList: async () => [],
952
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
953
+ getTestSuitesForPlan: async () => suites,
954
+ createTestSuite: async (suite) => {
955
+ const created = { id: nextSuiteId++, name: suite.name, parentSuite: { id: suite.parentSuite.id } };
956
+ suites.push(created);
957
+ return created;
958
+ },
959
+ addTestCasesToSuite: async (_entries, _project, _planId, suiteId) => {
960
+ addedToSuites.push(suiteId);
961
+ },
962
+ removeTestCasesFromSuite: async (_project, _planId, suiteId, _testCaseIds) => {
963
+ removedFromSuites.push(suiteId);
964
+ },
965
+ }),
966
+ getWitApi: async () => ({
967
+ getWorkItem: async (id) => ({
968
+ id,
969
+ fields: {
970
+ 'System.Title': 'Existing case',
971
+ 'System.Description': '',
972
+ '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>',
973
+ 'System.Tags': '',
974
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
975
+ },
976
+ }),
977
+ updateWorkItem: async () => ({})
978
+ }),
979
+ });
980
+ try {
981
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: false });
982
+ 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'));
983
+ strict_1.default.deepEqual(addedToSuites, [103]);
984
+ strict_1.default.deepEqual(removedFromSuites, [102]);
985
+ }
986
+ finally {
987
+ client_1.AzureClient.create = originalCreate;
988
+ fs.rmSync(tempDir, { recursive: true, force: true });
989
+ }
990
+ });
991
+ (0, node_test_1.default)('push moves linked hierarchy-managed cases when the tag-driven suite path changes', async () => {
992
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-hierarchy-by-tag-move-'));
993
+ const filePath = path.join(tempDir, 'specs', 'login.md');
994
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
995
+ fs.writeFileSync(filePath, ['### Existing case', '@tc:55 @suite:new/path', '', 'Steps:', '1. Existing step', ''].join('\n'));
996
+ fs.writeFileSync(path.join(tempDir, '.ado-sync-state.json'), JSON.stringify({
997
+ 55: {
998
+ title: 'Existing case',
999
+ stepsHash: 'cached-steps',
1000
+ descriptionHash: 'cached-description',
1001
+ remoteDescriptionHash: 'cached-remote-description',
1002
+ changedDate: '2026-05-01T00:00:00Z',
1003
+ filePath,
1004
+ suitePathKey: 'old/path',
1005
+ },
1006
+ }, null, 2));
1007
+ const config = makeConfig({
1008
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1009
+ testPlan: {
1010
+ id: 1,
1011
+ suiteId: 10,
1012
+ hierarchy: { mode: 'byTag', tagPrefix: 'suite' },
1013
+ },
1014
+ });
1015
+ const addedToSuites = [];
1016
+ const removedFromSuites = [];
1017
+ const suites = [
1018
+ { id: 101, name: 'old', parentSuite: { id: 10 } },
1019
+ { id: 102, name: 'path', parentSuite: { id: 101 } },
1020
+ ];
1021
+ let nextSuiteId = 103;
1022
+ const originalCreate = client_1.AzureClient.create;
1023
+ client_1.AzureClient.create = async () => ({
1024
+ getTestPlanApi: async () => ({
1025
+ getTestCaseList: async () => [],
1026
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
1027
+ getTestSuitesForPlan: async () => suites,
1028
+ createTestSuite: async (suite) => {
1029
+ const created = { id: nextSuiteId++, name: suite.name, parentSuite: { id: suite.parentSuite.id } };
1030
+ suites.push(created);
1031
+ return created;
1032
+ },
1033
+ addTestCasesToSuite: async (_entries, _project, _planId, suiteId) => {
1034
+ addedToSuites.push(suiteId);
1035
+ },
1036
+ removeTestCasesFromSuite: async (_project, _planId, suiteId) => {
1037
+ removedFromSuites.push(suiteId);
1038
+ },
1039
+ }),
1040
+ getWitApi: async () => ({
1041
+ getWorkItem: async (id) => ({
1042
+ id,
1043
+ fields: {
1044
+ 'System.Title': 'Existing case',
1045
+ 'System.Description': '',
1046
+ '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>',
1047
+ 'System.Tags': '',
1048
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
1049
+ },
1050
+ }),
1051
+ updateWorkItem: async () => ({}),
1052
+ }),
1053
+ });
1054
+ try {
1055
+ const results = await (0, engine_1.push)(config, tempDir, { dryRun: false });
1056
+ 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'));
1057
+ strict_1.default.deepEqual(addedToSuites, [104]);
1058
+ strict_1.default.deepEqual(removedFromSuites, [102]);
1059
+ }
1060
+ finally {
1061
+ client_1.AzureClient.create = originalCreate;
1062
+ fs.rmSync(tempDir, { recursive: true, force: true });
1063
+ }
1064
+ });
1065
+ (0, node_test_1.default)('status previews generated suite targets for hierarchy-managed creates', async () => {
1066
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-status-hierarchy-target-'));
1067
+ const filePath = path.join(tempDir, 'specs', 'auth', 'login.md');
1068
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1069
+ fs.writeFileSync(filePath, ['### Login case', '', 'Steps:', '1. Open app', ''].join('\n'));
1070
+ const config = makeConfig({
1071
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1072
+ testPlan: {
1073
+ id: 1,
1074
+ suiteId: 10,
1075
+ hierarchy: { mode: 'byFolder', rootSuite: 'Generated Specs' },
1076
+ },
1077
+ });
1078
+ const originalCreate = client_1.AzureClient.create;
1079
+ client_1.AzureClient.create = async () => ({
1080
+ getTestPlanApi: async () => ({
1081
+ getTestCaseList: async () => [],
1082
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
1083
+ }),
1084
+ getWitApi: async () => ({
1085
+ getWorkItems: async () => [],
1086
+ }),
1087
+ });
1088
+ try {
1089
+ const results = await (0, engine_1.status)(config, tempDir);
1090
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.targetSuitePath === 'Generated Specs / specs / auth'));
1091
+ }
1092
+ finally {
1093
+ client_1.AzureClient.create = originalCreate;
1094
+ fs.rmSync(tempDir, { recursive: true, force: true });
1095
+ }
1096
+ });
1097
+ (0, node_test_1.default)('status previews tag-driven hierarchy targets for creates', async () => {
1098
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-status-hierarchy-by-tag-target-'));
1099
+ const filePath = path.join(tempDir, 'specs', 'auth', 'login.md');
1100
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1101
+ fs.writeFileSync(filePath, ['### Login case', '<!-- tags: @suite:mobile/auth -->', '', 'Steps:', '1. Open app', ''].join('\n'));
1102
+ const config = makeConfig({
1103
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1104
+ testPlan: {
1105
+ id: 1,
1106
+ suiteId: 10,
1107
+ hierarchy: { mode: 'byTag', tagPrefix: 'suite', rootSuite: 'Generated Specs' },
1108
+ },
1109
+ });
1110
+ const originalCreate = client_1.AzureClient.create;
1111
+ client_1.AzureClient.create = async () => ({
1112
+ getTestPlanApi: async () => ({
1113
+ getTestCaseList: async () => [],
1114
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
1115
+ }),
1116
+ getWitApi: async () => ({
1117
+ getWorkItems: async () => [],
1118
+ }),
1119
+ });
1120
+ try {
1121
+ const results = await (0, engine_1.status)(config, tempDir);
1122
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.targetSuitePath === 'Generated Specs / mobile / auth'));
1123
+ }
1124
+ finally {
1125
+ client_1.AzureClient.create = originalCreate;
1126
+ fs.rmSync(tempDir, { recursive: true, force: true });
1127
+ }
1128
+ });
1129
+ (0, node_test_1.default)('status previews level-rule hierarchy targets for creates', async () => {
1130
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-status-hierarchy-by-levels-target-'));
1131
+ const filePath = path.join(tempDir, 'specs', 'auth', 'login.md');
1132
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1133
+ fs.writeFileSync(filePath, ['### Login case', '<!-- tags: @suite:mobile -->', '', 'Steps:', '1. Open app', ''].join('\n'));
1134
+ const config = makeConfig({
1135
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1136
+ testPlan: {
1137
+ id: 1,
1138
+ suiteId: 10,
1139
+ hierarchy: {
1140
+ mode: 'byLevels',
1141
+ rootSuite: 'Generated Specs',
1142
+ levels: [
1143
+ { source: 'folder', index: 1 },
1144
+ { source: 'tag', tagPrefix: 'suite' },
1145
+ ],
1146
+ },
1147
+ },
1148
+ });
1149
+ const originalCreate = client_1.AzureClient.create;
1150
+ client_1.AzureClient.create = async () => ({
1151
+ getTestPlanApi: async () => ({
1152
+ getTestCaseList: async () => [],
1153
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
1154
+ }),
1155
+ getWitApi: async () => ({
1156
+ getWorkItems: async () => [],
1157
+ }),
1158
+ });
1159
+ try {
1160
+ const results = await (0, engine_1.status)(config, tempDir);
1161
+ strict_1.default.ok(results.some((result) => result.action === 'created' && result.targetSuitePath === 'Generated Specs / auth / mobile'));
1162
+ }
1163
+ finally {
1164
+ client_1.AzureClient.create = originalCreate;
1165
+ fs.rmSync(tempDir, { recursive: true, force: true });
1166
+ }
1167
+ });
1168
+ (0, node_test_1.default)('push can prune empty generated suites after a hierarchy-managed move', async () => {
1169
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-hierarchy-prune-'));
1170
+ const oldRelativePath = 'specs/old/login.md';
1171
+ const newRelativePath = 'specs/new/login.md';
1172
+ const filePath = path.join(tempDir, newRelativePath);
1173
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1174
+ fs.writeFileSync(filePath, ['### Existing case', '@tc:55', '', 'Steps:', '1. Existing step', ''].join('\n'));
1175
+ fs.writeFileSync(path.join(tempDir, '.ado-sync-state.json'), JSON.stringify({
1176
+ 55: {
1177
+ title: 'Existing case',
1178
+ stepsHash: 'cached-steps',
1179
+ descriptionHash: 'cached-description',
1180
+ remoteDescriptionHash: 'cached-remote-description',
1181
+ changedDate: '2026-05-01T00:00:00Z',
1182
+ filePath: path.join(tempDir, oldRelativePath),
1183
+ },
1184
+ }, null, 2));
1185
+ const config = makeConfig({
1186
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1187
+ testPlan: {
1188
+ id: 1,
1189
+ suiteId: 10,
1190
+ hierarchy: { mode: 'byFolder', cleanupEmptySuites: true },
1191
+ },
1192
+ });
1193
+ const deletedSuites = [];
1194
+ const suites = [
1195
+ { id: 101, name: 'specs', parentSuite: { id: 10 } },
1196
+ { id: 102, name: 'old', parentSuite: { id: 101 } },
1197
+ ];
1198
+ let nextSuiteId = 103;
1199
+ const originalCreate = client_1.AzureClient.create;
1200
+ client_1.AzureClient.create = async () => ({
1201
+ getTestPlanApi: async () => ({
1202
+ getTestCaseList: async (_project, _planId, suiteId) => {
1203
+ if (suiteId === 10)
1204
+ return [{ testCase: { id: 77 } }];
1205
+ if (suiteId === 102 || suiteId === 101)
1206
+ return [];
1207
+ return [];
1208
+ },
1209
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
1210
+ getTestSuitesForPlan: async () => suites,
1211
+ createTestSuite: async (suite) => {
1212
+ const created = { id: nextSuiteId++, name: suite.name, parentSuite: { id: suite.parentSuite.id } };
1213
+ suites.push(created);
1214
+ return created;
1215
+ },
1216
+ addTestCasesToSuite: async () => undefined,
1217
+ removeTestCasesFromSuite: async () => undefined,
1218
+ deleteTestSuite: async (_project, _planId, suiteId) => {
1219
+ deletedSuites.push(suiteId);
1220
+ const index = suites.findIndex((suite) => suite.id === suiteId);
1221
+ if (index >= 0)
1222
+ suites.splice(index, 1);
1223
+ },
1224
+ }),
1225
+ getWitApi: async () => ({
1226
+ getWorkItem: async (id) => ({
1227
+ id,
1228
+ fields: {
1229
+ 'System.Title': 'Existing case',
1230
+ 'System.Description': '',
1231
+ '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>',
1232
+ 'System.Tags': '',
1233
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
1234
+ },
1235
+ }),
1236
+ updateWorkItem: async () => ({})
1237
+ }),
1238
+ });
1239
+ try {
1240
+ await (0, engine_1.push)(config, tempDir, { dryRun: false });
1241
+ strict_1.default.deepEqual(deletedSuites, [102]);
1242
+ }
1243
+ finally {
1244
+ client_1.AzureClient.create = originalCreate;
1245
+ fs.rmSync(tempDir, { recursive: true, force: true });
1246
+ }
1247
+ });
1248
+ (0, node_test_1.default)('push can prune empty stale generated suites for removed local specs on full-scope runs', async () => {
1249
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-push-hierarchy-stale-prune-'));
1250
+ const filePath = path.join(tempDir, 'specs', 'active', 'login.md');
1251
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1252
+ fs.writeFileSync(filePath, ['### Active case', '@tc:55', '', 'Steps:', '1. Existing step', ''].join('\n'));
1253
+ fs.writeFileSync(path.join(tempDir, '.ado-sync-state.json'), JSON.stringify({
1254
+ 55: {
1255
+ title: 'Active case',
1256
+ stepsHash: 'cached-steps',
1257
+ descriptionHash: 'cached-description',
1258
+ remoteDescriptionHash: 'cached-remote-description',
1259
+ changedDate: '2026-05-01T00:00:00Z',
1260
+ filePath,
1261
+ },
1262
+ 77: {
1263
+ title: 'Removed local case',
1264
+ stepsHash: 'stale-steps',
1265
+ descriptionHash: 'stale-description',
1266
+ remoteDescriptionHash: 'stale-remote-description',
1267
+ changedDate: '2026-05-01T00:00:00Z',
1268
+ filePath: path.join(tempDir, 'specs', 'legacy', 'old.md'),
1269
+ suitePathKey: 'specs/legacy',
1270
+ },
1271
+ }, null, 2));
1272
+ const config = makeConfig({
1273
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1274
+ testPlan: {
1275
+ id: 1,
1276
+ suiteId: 10,
1277
+ hierarchy: { mode: 'byFolder', cleanupEmptySuites: true },
1278
+ },
1279
+ });
1280
+ const deletedSuites = [];
1281
+ const suites = [
1282
+ { id: 101, name: 'specs', parentSuite: { id: 10 } },
1283
+ { id: 102, name: 'legacy', parentSuite: { id: 101 } },
1284
+ { id: 103, name: 'active', parentSuite: { id: 101 } },
1285
+ ];
1286
+ const originalCreate = client_1.AzureClient.create;
1287
+ client_1.AzureClient.create = async () => ({
1288
+ getTestPlanApi: async () => ({
1289
+ getTestCaseList: async (_project, _planId, suiteId) => {
1290
+ if (suiteId === 10)
1291
+ return [{ workItem: { id: 77 } }];
1292
+ if (suiteId === 103)
1293
+ return [{ workItem: { id: 55 } }];
1294
+ if (suiteId === 102 || suiteId === 101)
1295
+ return [];
1296
+ return [];
1297
+ },
1298
+ getTestPlanById: async () => ({ id: 1, rootSuite: { id: 10 } }),
1299
+ getTestSuitesForPlan: async () => suites,
1300
+ addTestCasesToSuite: async () => undefined,
1301
+ removeTestCasesFromSuite: async () => undefined,
1302
+ deleteTestSuite: async (_project, _planId, suiteId) => {
1303
+ deletedSuites.push(suiteId);
1304
+ const index = suites.findIndex((suite) => suite.id === suiteId);
1305
+ if (index >= 0)
1306
+ suites.splice(index, 1);
1307
+ },
1308
+ }),
1309
+ getWitApi: async () => ({
1310
+ getWorkItem: async (id) => ({
1311
+ id,
1312
+ fields: {
1313
+ 'System.Title': id === 55 ? 'Active case' : 'Removed local case',
1314
+ 'System.Description': '',
1315
+ '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>',
1316
+ 'System.Tags': '',
1317
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
1318
+ },
1319
+ }),
1320
+ getWorkItems: async () => [{
1321
+ id: 77,
1322
+ fields: {
1323
+ 'System.WorkItemType': 'Test Case',
1324
+ 'System.Title': 'Removed local case',
1325
+ 'System.Description': '',
1326
+ '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>',
1327
+ 'System.Tags': '',
1328
+ 'System.ChangedDate': '2026-05-01T00:00:00Z',
1329
+ },
1330
+ }],
1331
+ updateWorkItem: async () => ({}),
1332
+ }),
1333
+ });
1334
+ try {
1335
+ await (0, engine_1.push)(config, tempDir, { dryRun: false });
1336
+ strict_1.default.deepEqual(deletedSuites, [102]);
1337
+ }
1338
+ finally {
1339
+ client_1.AzureClient.create = originalCreate;
1340
+ fs.rmSync(tempDir, { recursive: true, force: true });
1341
+ }
1342
+ });
360
1343
  (0, node_test_1.default)('pull-create errors for unsupported local types instead of creating markdown files', async () => {
361
1344
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-pull-create-'));
362
1345
  const filePath = path.join(tempDir, 'sample.test.ts');
@@ -392,11 +1375,40 @@ function makeParsedTest(overrides = {}) {
392
1375
  fs.rmSync(tempDir, { recursive: true, force: true });
393
1376
  }
394
1377
  });
1378
+ (0, node_test_1.default)('pull-create is blocked on source-file-filtered runs', async () => {
1379
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-pull-source-file-'));
1380
+ const filePath = path.join(tempDir, 'sample.feature');
1381
+ fs.writeFileSync(filePath, ['Feature: Sample', '', ' Scenario: existing test', ' Given a step', ''].join('\n'));
1382
+ const config = makeConfig({
1383
+ local: { type: 'gherkin', include: '*.feature' },
1384
+ sync: {
1385
+ tagPrefix: 'tc',
1386
+ pull: { enableCreatingNewLocalTestCases: true },
1387
+ },
1388
+ });
1389
+ const originalCreate = client_1.AzureClient.create;
1390
+ client_1.AzureClient.create = async () => ({
1391
+ getTestApi: async () => ({}),
1392
+ getTestPlanApi: async () => ({
1393
+ getTestCaseList: async () => [],
1394
+ getTestPlanById: async () => ({ rootSuite: { id: 10 } }),
1395
+ }),
1396
+ getWitApi: async () => ({ getWorkItems: async () => [] }),
1397
+ });
1398
+ try {
1399
+ const results = await (0, engine_1.pull)(config, tempDir, { sourceFiles: [filePath] });
1400
+ strict_1.default.ok(results.some((result) => result.action === 'error' && /--source-file/.test(result.detail ?? '')));
1401
+ }
1402
+ finally {
1403
+ client_1.AzureClient.create = originalCreate;
1404
+ fs.rmSync(tempDir, { recursive: true, force: true });
1405
+ }
1406
+ });
395
1407
  (0, node_test_1.default)('configurationKey marker prefixes parse namespaced and legacy JavaScript IDs', () => {
396
1408
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-config-key-'));
397
1409
  const filePath = path.join(tempDir, 'sample.test.ts');
398
1410
  const config = makeConfig({ configurationKey: 'Smoke Suite' });
399
- const markerPrefix = (0, id_markers_1.getPreferredMarkerTagPrefix)(config);
1411
+ const markerPrefix = (0, id_markers_2.getPreferredMarkerTagPrefix)(config);
400
1412
  try {
401
1413
  fs.writeFileSync(filePath, [`// @${markerPrefix}:123`, "test('namespaced id', () => {});", ''].join('\n'));
402
1414
  const namespaced = (0, javascript_1.parseJavaScriptFile)(filePath, [markerPrefix, 'tc']);
@@ -409,4 +1421,124 @@ function makeParsedTest(overrides = {}) {
409
1421
  fs.rmSync(tempDir, { recursive: true, force: true });
410
1422
  }
411
1423
  });
1424
+ (0, node_test_1.default)('getTestCasesInSuite filters remote inventory to tagged ownership scope', async () => {
1425
+ const config = makeConfig({
1426
+ configurationKey: 'Smoke Suite',
1427
+ syncTarget: { mode: 'tagged' },
1428
+ });
1429
+ const ownershipTag = (0, id_markers_1.getSyncTargetOwnershipTag)(config);
1430
+ const client = {
1431
+ getTestPlanApi: async () => ({
1432
+ getTestCaseList: async () => [
1433
+ { workItem: { id: 101 } },
1434
+ { workItem: { id: 102 } },
1435
+ ],
1436
+ getTestPlanById: async () => ({ rootSuite: { id: 10 } }),
1437
+ }),
1438
+ getWitApi: async () => ({
1439
+ getWorkItems: async () => [
1440
+ { id: 101, fields: { 'System.Title': 'Owned case', 'System.Tags': ownershipTag } },
1441
+ { id: 102, fields: { 'System.Title': 'Other case', 'System.Tags': 'other-owner' } },
1442
+ ],
1443
+ }),
1444
+ };
1445
+ const testCases = await (0, test_cases_1.getTestCasesInSuite)(client, config);
1446
+ strict_1.default.deepEqual(testCases.map((testCase) => testCase.id), [101]);
1447
+ });
1448
+ (0, node_test_1.default)('getTestCasesInSuite resolves query-based sync targets via WIQL', async () => {
1449
+ const config = makeConfig({
1450
+ syncTarget: {
1451
+ mode: 'query',
1452
+ wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'Test Case'",
1453
+ },
1454
+ });
1455
+ let suiteFetchCount = 0;
1456
+ const client = {
1457
+ getTestPlanApi: async () => ({
1458
+ getTestCaseList: async () => {
1459
+ suiteFetchCount++;
1460
+ return [];
1461
+ },
1462
+ }),
1463
+ getWitApi: async () => ({
1464
+ queryByWiql: async () => ({ workItems: [{ id: 77 }] }),
1465
+ getWorkItems: async () => [{
1466
+ id: 77,
1467
+ fields: {
1468
+ 'System.WorkItemType': 'Test Case',
1469
+ 'System.Title': 'Query-owned case',
1470
+ 'System.Tags': '',
1471
+ },
1472
+ }],
1473
+ }),
1474
+ };
1475
+ const testCases = await (0, test_cases_1.getTestCasesInSuite)(client, config);
1476
+ strict_1.default.equal(suiteFetchCount, 0);
1477
+ strict_1.default.deepEqual(testCases.map((testCase) => testCase.id), [77]);
1478
+ });
1479
+ (0, node_test_1.default)('status supports query-based sync targets with testPlans', async () => {
1480
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ado-sync-status-query-testplans-'));
1481
+ const smokePath = path.join(tempDir, 'specs', 'smoke', 'login.md');
1482
+ const regressionPath = path.join(tempDir, 'specs', 'regression', 'checkout.md');
1483
+ fs.mkdirSync(path.dirname(smokePath), { recursive: true });
1484
+ fs.mkdirSync(path.dirname(regressionPath), { recursive: true });
1485
+ fs.writeFileSync(smokePath, ['### Login case', '', 'Steps:', '1. Open app', ''].join('\n'));
1486
+ fs.writeFileSync(regressionPath, ['### Checkout case', '', 'Steps:', '1. Add item', ''].join('\n'));
1487
+ const config = makeConfig({
1488
+ local: { type: 'markdown', include: 'specs/**/*.md' },
1489
+ syncTarget: {
1490
+ mode: 'query',
1491
+ wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'Test Case'",
1492
+ },
1493
+ testPlans: [
1494
+ { id: 11, include: 'specs/smoke/**/*.md' },
1495
+ { id: 22, include: 'specs/regression/**/*.md' },
1496
+ ],
1497
+ });
1498
+ let suiteFetchCount = 0;
1499
+ let queryCount = 0;
1500
+ const originalCreate = client_1.AzureClient.create;
1501
+ client_1.AzureClient.create = async () => ({
1502
+ getTestPlanApi: async () => ({
1503
+ getTestCaseList: async () => {
1504
+ suiteFetchCount++;
1505
+ return [];
1506
+ },
1507
+ getTestPlanById: async (_project, planId) => ({ id: planId, rootSuite: { id: planId * 10 } }),
1508
+ }),
1509
+ getWitApi: async () => ({
1510
+ queryByWiql: async () => {
1511
+ queryCount++;
1512
+ return { workItems: [] };
1513
+ },
1514
+ getWorkItems: async () => [],
1515
+ }),
1516
+ });
1517
+ try {
1518
+ const results = await (0, engine_1.status)(config, tempDir);
1519
+ strict_1.default.equal(queryCount, 2);
1520
+ strict_1.default.equal(suiteFetchCount, 0);
1521
+ strict_1.default.equal(results.filter((result) => result.action === 'created').length, 2);
1522
+ }
1523
+ finally {
1524
+ client_1.AzureClient.create = originalCreate;
1525
+ fs.rmSync(tempDir, { recursive: true, force: true });
1526
+ }
1527
+ });
1528
+ (0, node_test_1.default)('detectAiEnvironment returns heuristic when Visual Studio Agent mode env is set', () => {
1529
+ withCleanAiDetectionEnv({
1530
+ VISUAL_STUDIO_AGENT_MODE: '1',
1531
+ }, () => {
1532
+ const detected = (0, summarizer_1.detectAiEnvironment)();
1533
+ strict_1.default.equal(detected?.provider, 'heuristic');
1534
+ });
1535
+ });
1536
+ (0, node_test_1.default)('detectAiEnvironment returns heuristic when Codex env signal is set', () => {
1537
+ withCleanAiDetectionEnv({
1538
+ CODEX_CLI: '1',
1539
+ }, () => {
1540
+ const detected = (0, summarizer_1.detectAiEnvironment)();
1541
+ strict_1.default.equal(detected?.provider, 'heuristic');
1542
+ });
1543
+ });
412
1544
  //# sourceMappingURL=regressions.test.js.map