ado-sync 0.1.64 → 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 (63) hide show
  1. package/README.md +20 -15
  2. package/dist/__tests__/regressions.test.js +1011 -1
  3. package/dist/__tests__/regressions.test.js.map +1 -1
  4. package/dist/ai/generate-spec.d.ts +1 -1
  5. package/dist/ai/generate-spec.js +23 -0
  6. package/dist/ai/generate-spec.js.map +1 -1
  7. package/dist/ai/summarizer.d.ts +3 -2
  8. package/dist/ai/summarizer.js +50 -1
  9. package/dist/ai/summarizer.js.map +1 -1
  10. package/dist/azure/test-cases.d.ts +11 -1
  11. package/dist/azure/test-cases.js +286 -43
  12. package/dist/azure/test-cases.js.map +1 -1
  13. package/dist/cli.js +91 -14
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.js +74 -1
  16. package/dist/config.js.map +1 -1
  17. package/dist/id-markers.d.ts +1 -0
  18. package/dist/id-markers.js +13 -0
  19. package/dist/id-markers.js.map +1 -1
  20. package/dist/mcp-server.js +1 -1
  21. package/dist/mcp-server.js.map +1 -1
  22. package/dist/sync/cache.d.ts +2 -0
  23. package/dist/sync/cache.js.map +1 -1
  24. package/dist/sync/engine.d.ts +12 -1
  25. package/dist/sync/engine.js +210 -41
  26. package/dist/sync/engine.js.map +1 -1
  27. package/dist/types.d.ts +56 -4
  28. package/llms.txt +12 -11
  29. package/package.json +8 -1
  30. package/docs/advanced.md +0 -988
  31. package/docs/agent-setup.md +0 -204
  32. package/docs/capability-roadmap.md +0 -280
  33. package/docs/cli.md +0 -609
  34. package/docs/configuration.md +0 -322
  35. package/docs/examples/csharp-mstest-local-llm.yaml +0 -35
  36. package/docs/examples/csharp-mstest.yaml +0 -21
  37. package/docs/examples/csharp-nunit.yaml +0 -21
  38. package/docs/examples/csharp-specflow.yaml +0 -16
  39. package/docs/examples/cypress.yaml +0 -21
  40. package/docs/examples/detox-react-native.yaml +0 -21
  41. package/docs/examples/espresso-android.yaml +0 -21
  42. package/docs/examples/flutter-dart.yaml +0 -21
  43. package/docs/examples/java-junit.yaml +0 -21
  44. package/docs/examples/java-testng.yaml +0 -21
  45. package/docs/examples/js-jasmine-wdio.yaml +0 -21
  46. package/docs/examples/js-jest.yaml +0 -21
  47. package/docs/examples/playwright-js.yaml +0 -21
  48. package/docs/examples/playwright-ts.yaml +0 -21
  49. package/docs/examples/puppeteer.yaml +0 -21
  50. package/docs/examples/python-pytest.yaml +0 -21
  51. package/docs/examples/robot-framework.yaml +0 -19
  52. package/docs/examples/testcafe.yaml +0 -21
  53. package/docs/examples/xcuitest-ios.yaml +0 -21
  54. package/docs/mcp-server.md +0 -312
  55. package/docs/publish-test-results.md +0 -939
  56. package/docs/spec-formats.md +0 -1357
  57. package/docs/troubleshooting.md +0 -101
  58. package/docs/vscode-extension.md +0 -139
  59. package/docs/work-item-links.md +0 -115
  60. package/docs/workflows.md +0 -457
  61. package/mkdocs.yml +0 -40
  62. package/requirements-docs.txt +0 -4
  63. package/scripts/build_site.sh +0 -6
@@ -22,7 +22,7 @@ import { AzureClient } from './client';
22
22
  * Process tags for push: apply tag mapping and filter ignored tags.
23
23
  * Returns the transformed tags list ready for Azure DevOps.
24
24
  */
25
- export declare function processTagsForPush(tags: string[], tagPrefix: string | string[], customizations?: CustomizationsConfig): string[];
25
+ export declare function processTagsForPush(tags: string[], tagPrefix: string | string[], customizations?: CustomizationsConfig, config?: SyncConfig): string[];
26
26
  /**
27
27
  * Build the Azure-facing title and steps for comparison/update logic.
28
28
  * This mirrors the transformation used by createTestCase/updateTestCase so
@@ -32,6 +32,14 @@ export declare function buildAzureSyncContent(test: ParsedTest, formatConfig: Fo
32
32
  title: string;
33
33
  steps: AzureStep[];
34
34
  };
35
+ export declare function shouldCleanupEmptyGeneratedSuites(config: SyncConfig): boolean;
36
+ export declare function usesGeneratedSuiteHierarchy(config: SyncConfig): boolean;
37
+ export declare function getGeneratedSuitePathKey(config: SyncConfig, filePath: string, configDir: string): string | undefined;
38
+ export declare function getGeneratedSuitePathKeyForTest(config: SyncConfig, test: ParsedTest, configDir: string): string | undefined;
39
+ export declare function resolveExistingSuiteForFile(client: AzureClient, config: SyncConfig, filePath: string, configDir: string, suiteCache: Map<string, number>): Promise<number | undefined>;
40
+ export declare function resolveExistingSuiteForPathKey(client: AzureClient, config: SyncConfig, pathKey: string, suiteCache: Map<string, number>): Promise<number | undefined>;
41
+ export declare function pruneEmptyGeneratedSuitesForFile(client: AzureClient, config: SyncConfig, filePath: string, configDir: string, suiteCache: Map<string, number>): Promise<void>;
42
+ export declare function pruneEmptyGeneratedSuitesForPathKey(client: AzureClient, config: SyncConfig, pathKey: string, suiteCache: Map<string, number>): Promise<void>;
35
43
  /**
36
44
  * Get or create a named static suite directly under the plan's root suite.
37
45
  * Used by suiteRouting to resolve named route targets.
@@ -47,6 +55,7 @@ export declare function getOrCreateNamedSuite(client: AzureClient, config: SyncC
47
55
  * Uses suiteCache (Map<relPath, suiteId>) to avoid redundant API calls.
48
56
  */
49
57
  export declare function getOrCreateSuiteForFile(client: AzureClient, config: SyncConfig, filePath: string, configDir: string, suiteCache: Map<string, number>): Promise<number>;
58
+ export declare function getOrCreateGeneratedSuiteForTest(client: AzureClient, config: SyncConfig, test: ParsedTest, configDir: string, suiteCache: Map<string, number>): Promise<number>;
50
59
  export declare function getTestCase(client: AzureClient, id: number, titleField?: string): Promise<AzureTestCase | null>;
51
60
  export declare function createTestCase(client: AzureClient, test: ParsedTest, config: SyncConfig, suiteIdOverride?: number, configDir?: string): Promise<number>;
52
61
  export declare function updateTestCase(client: AzureClient, id: number, test: ParsedTest, config: SyncConfig, configDir?: string): Promise<void>;
@@ -63,6 +72,7 @@ export declare function tagTestCaseAsRemoved(client: AzureClient, id: number, re
63
72
  */
64
73
  export declare function retireTestCase(client: AzureClient, id: number, targetState?: string): Promise<void>;
65
74
  export declare function addTestCaseToSuite(client: AzureClient, config: SyncConfig, testCaseId: number, suiteId: number): Promise<void>;
75
+ export declare function removeTestCaseFromSuite(client: AzureClient, config: SyncConfig, testCaseId: number, suiteId: number): Promise<void>;
66
76
  export declare function addTestCaseToRootSuite(client: AzureClient, config: SyncConfig, testCaseId: number): Promise<void>;
67
77
  /**
68
78
  * Add a test case to every suite whose condition matches the given test.
@@ -56,8 +56,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
57
  exports.processTagsForPush = processTagsForPush;
58
58
  exports.buildAzureSyncContent = buildAzureSyncContent;
59
+ exports.shouldCleanupEmptyGeneratedSuites = shouldCleanupEmptyGeneratedSuites;
60
+ exports.usesGeneratedSuiteHierarchy = usesGeneratedSuiteHierarchy;
61
+ exports.getGeneratedSuitePathKey = getGeneratedSuitePathKey;
62
+ exports.getGeneratedSuitePathKeyForTest = getGeneratedSuitePathKeyForTest;
63
+ exports.resolveExistingSuiteForFile = resolveExistingSuiteForFile;
64
+ exports.resolveExistingSuiteForPathKey = resolveExistingSuiteForPathKey;
65
+ exports.pruneEmptyGeneratedSuitesForFile = pruneEmptyGeneratedSuitesForFile;
66
+ exports.pruneEmptyGeneratedSuitesForPathKey = pruneEmptyGeneratedSuitesForPathKey;
59
67
  exports.getOrCreateNamedSuite = getOrCreateNamedSuite;
60
68
  exports.getOrCreateSuiteForFile = getOrCreateSuiteForFile;
69
+ exports.getOrCreateGeneratedSuiteForTest = getOrCreateGeneratedSuiteForTest;
61
70
  exports.getTestCase = getTestCase;
62
71
  exports.createTestCase = createTestCase;
63
72
  exports.updateTestCase = updateTestCase;
@@ -65,6 +74,7 @@ exports.updateLocalFromAzure = updateLocalFromAzure;
65
74
  exports.tagTestCaseAsRemoved = tagTestCaseAsRemoved;
66
75
  exports.retireTestCase = retireTestCase;
67
76
  exports.addTestCaseToSuite = addTestCaseToSuite;
77
+ exports.removeTestCaseFromSuite = removeTestCaseFromSuite;
68
78
  exports.addTestCaseToRootSuite = addTestCaseToRootSuite;
69
79
  exports.addTestCaseToConditionSuites = addTestCaseToConditionSuites;
70
80
  exports.getTestCasesInSuite = getTestCasesInSuite;
@@ -347,12 +357,16 @@ function isIgnoredTag(tag, ignorePatterns) {
347
357
  * Process tags for push: apply tag mapping and filter ignored tags.
348
358
  * Returns the transformed tags list ready for Azure DevOps.
349
359
  */
350
- function processTagsForPush(tags, tagPrefix, customizations) {
360
+ function processTagsForPush(tags, tagPrefix, customizations, config) {
351
361
  let processed = tags.filter((t) => !(0, id_markers_1.isMarkerTag)(t, tagPrefix));
352
362
  // Apply tag text map transformation
353
363
  if (customizations?.tagTextMapTransformation?.enabled && customizations.tagTextMapTransformation.textMap) {
354
364
  processed = applyTagTextMap(processed, customizations.tagTextMapTransformation.textMap);
355
365
  }
366
+ const ownershipTag = config ? (0, id_markers_1.getSyncTargetOwnershipTag)(config) : undefined;
367
+ if (ownershipTag && !processed.includes(ownershipTag)) {
368
+ processed = [...processed, ownershipTag];
369
+ }
356
370
  return processed;
357
371
  }
358
372
  // ─── State change helpers ────────────────────────────────────────────────────
@@ -790,12 +804,65 @@ async function applyLinkRelations(client, tcId, test, config, isCreate) {
790
804
  }
791
805
  // ─── Suite hierarchy helpers ──────────────────────────────────────────────────
792
806
  async function resolveRootSuiteId(client, config) {
807
+ const syncTargetSuiteId = config.syncTarget?.mode === 'query' ? undefined : config.syncTarget?.suiteId;
808
+ if (syncTargetSuiteId)
809
+ return syncTargetSuiteId;
793
810
  if (config.testPlan.suiteId)
794
811
  return config.testPlan.suiteId;
795
812
  const api = await client.getTestPlanApi();
796
813
  const plan = await api.getTestPlanById(config.project, config.testPlan.id);
797
814
  return plan?.rootSuite?.id ?? 0;
798
815
  }
816
+ function buildAzureTestCaseFromWorkItem(workItem, titleField) {
817
+ const fields = workItem?.fields ?? {};
818
+ return {
819
+ id: workItem.id,
820
+ title: fields[titleField] ?? '',
821
+ description: fields['System.Description'] ?? '',
822
+ steps: parseStepsXml(fields['Microsoft.VSTS.TCM.Steps'] ?? ''),
823
+ tags: tagsFromString(fields['System.Tags']),
824
+ changedDate: fields['System.ChangedDate'],
825
+ areaPath: fields['System.AreaPath'],
826
+ iterationPath: fields['System.IterationPath'],
827
+ automatedTestName: fields['Microsoft.VSTS.TCM.AutomatedTestName'] || undefined,
828
+ };
829
+ }
830
+ function getRemoteScopeSuiteId(config, suiteId) {
831
+ if (suiteId)
832
+ return suiteId;
833
+ if (config.syncTarget?.mode !== 'query' && config.syncTarget?.suiteId)
834
+ return config.syncTarget.suiteId;
835
+ return config.testPlan.suiteId;
836
+ }
837
+ function filterScopedTestCases(testCases, config) {
838
+ const ownershipTag = (0, id_markers_1.getSyncTargetOwnershipTag)(config);
839
+ if (!ownershipTag)
840
+ return testCases;
841
+ const normalizedOwnershipTag = ownershipTag.toLowerCase();
842
+ return testCases.filter((testCase) => testCase.tags.some((tag) => tag.toLowerCase() === normalizedOwnershipTag));
843
+ }
844
+ async function getTestCasesByWiql(client, config, titleField, wiql) {
845
+ const wit = await client.getWitApi();
846
+ const queryResult = await withRetry(() => wit.queryByWiql({ query: wiql }, { project: config.project }));
847
+ const ids = (queryResult?.workItems ?? []).map((ref) => ref.id).filter(Boolean);
848
+ if (!ids.length)
849
+ return [];
850
+ const fields = [
851
+ titleField,
852
+ 'System.WorkItemType',
853
+ 'System.Description',
854
+ 'Microsoft.VSTS.TCM.Steps',
855
+ 'System.Tags',
856
+ 'System.ChangedDate',
857
+ 'System.AreaPath',
858
+ 'System.IterationPath',
859
+ 'Microsoft.VSTS.TCM.AutomatedTestName',
860
+ ];
861
+ const workItems = await withRetry(() => wit.getWorkItems(ids, fields));
862
+ return (workItems ?? [])
863
+ .filter((workItem) => (workItem?.fields?.['System.WorkItemType'] ?? '') === 'Test Case')
864
+ .map((workItem) => buildAzureTestCaseFromWorkItem(workItem, titleField));
865
+ }
799
866
  async function getOrCreateChildSuite(client, config, parentSuiteId, suiteName) {
800
867
  const api = await client.getTestPlanApi();
801
868
  const suites = await api.getTestSuitesForPlan(config.project, config.testPlan.id);
@@ -809,6 +876,192 @@ async function getOrCreateChildSuite(client, config, parentSuiteId, suiteName) {
809
876
  }, config.project, config.testPlan.id);
810
877
  return created?.id ?? parentSuiteId;
811
878
  }
879
+ function getSuiteHierarchy(config) {
880
+ if (config.testPlan.hierarchy)
881
+ return config.testPlan.hierarchy;
882
+ if (config.testPlan.suiteMapping === 'byFolder' || config.testPlan.suiteMapping === 'byFile') {
883
+ return { mode: config.testPlan.suiteMapping };
884
+ }
885
+ return undefined;
886
+ }
887
+ function shouldCleanupEmptyGeneratedSuites(config) {
888
+ return getSuiteHierarchy(config)?.cleanupEmptySuites === true;
889
+ }
890
+ function getGeneratedSuiteSegments(config, filePath, configDir) {
891
+ const hierarchy = getSuiteHierarchy(config);
892
+ if (!hierarchy)
893
+ return undefined;
894
+ const relFile = path.relative(configDir, filePath);
895
+ const allSegments = relFile.split(path.sep);
896
+ const dirSegments = allSegments.slice(0, -1);
897
+ const fileName = path.basename(filePath, path.extname(filePath));
898
+ const rawSegments = hierarchy.mode === 'byFile' ? [...dirSegments, fileName] : dirSegments;
899
+ const cleanSegments = rawSegments.map((segment) => segment.replace(/^@/, '')).filter(Boolean);
900
+ return hierarchy.rootSuite ? [hierarchy.rootSuite, ...cleanSegments] : cleanSegments;
901
+ }
902
+ function getGeneratedSuiteSegmentsForTagHierarchy(hierarchy, test) {
903
+ return getTagHierarchySegments(test, hierarchy.tagPrefix, hierarchy.valueSeparator);
904
+ }
905
+ function getTagHierarchySegments(test, tagPrefix, valueSeparator) {
906
+ const normalizedPrefix = tagPrefix.trim().replace(/^@/, '').toLowerCase();
907
+ const separator = valueSeparator ?? '/';
908
+ const matchingTag = test.tags.find((tag) => {
909
+ const normalizedTag = tag.replace(/^@/, '');
910
+ return normalizedTag.toLowerCase().startsWith(`${normalizedPrefix}:`);
911
+ });
912
+ if (!matchingTag) {
913
+ return [];
914
+ }
915
+ const normalizedTag = matchingTag.replace(/^@/, '');
916
+ const rawValue = normalizedTag.slice(normalizedPrefix.length + 1);
917
+ return rawValue
918
+ .split(separator)
919
+ .map((segment) => segment.trim().replace(/^@/, ''))
920
+ .filter(Boolean);
921
+ }
922
+ function getGeneratedSuiteSegmentsForLevelsHierarchy(hierarchy, test, configDir) {
923
+ const relFile = path.relative(configDir, test.filePath);
924
+ const allSegments = relFile.split(path.sep);
925
+ const dirSegments = allSegments.slice(0, -1);
926
+ const fileName = path.basename(test.filePath, path.extname(test.filePath));
927
+ const resolvedSegments = [];
928
+ for (const level of hierarchy.levels ?? []) {
929
+ if (level.source === 'folder') {
930
+ const segment = dirSegments[level.index];
931
+ if (segment) {
932
+ resolvedSegments.push(segment.replace(/^@/, ''));
933
+ }
934
+ continue;
935
+ }
936
+ if (level.source === 'file') {
937
+ resolvedSegments.push(fileName.replace(/^@/, ''));
938
+ continue;
939
+ }
940
+ resolvedSegments.push(...getTagHierarchySegments(test, level.tagPrefix, level.valueSeparator));
941
+ }
942
+ const cleanSegments = resolvedSegments.filter(Boolean);
943
+ return hierarchy.rootSuite ? [hierarchy.rootSuite, ...cleanSegments] : cleanSegments;
944
+ }
945
+ function getGeneratedSuiteSegmentsForTest(config, test, configDir) {
946
+ const hierarchy = getSuiteHierarchy(config);
947
+ if (!hierarchy)
948
+ return undefined;
949
+ if (hierarchy.mode === 'byTag') {
950
+ const tagSegments = getGeneratedSuiteSegmentsForTagHierarchy(hierarchy, test);
951
+ return hierarchy.rootSuite ? [hierarchy.rootSuite, ...tagSegments] : tagSegments;
952
+ }
953
+ if (hierarchy.mode === 'byLevels') {
954
+ return getGeneratedSuiteSegmentsForLevelsHierarchy(hierarchy, test, configDir);
955
+ }
956
+ return getGeneratedSuiteSegments(config, test.filePath, configDir);
957
+ }
958
+ function usesGeneratedSuiteHierarchy(config) {
959
+ return getSuiteHierarchy(config) !== undefined;
960
+ }
961
+ function getGeneratedSuitePathKey(config, filePath, configDir) {
962
+ const segments = getGeneratedSuiteSegments(config, filePath, configDir);
963
+ return segments ? segments.join('/') : undefined;
964
+ }
965
+ function getGeneratedSuitePathKeyForTest(config, test, configDir) {
966
+ const segments = getGeneratedSuiteSegmentsForTest(config, test, configDir);
967
+ return segments ? segments.join('/') : undefined;
968
+ }
969
+ async function resolveSuiteForSegments(client, config, segments, suiteCache, createMissing) {
970
+ const baseRootSuiteId = await resolveRootSuiteId(client, config);
971
+ if (!segments.length)
972
+ return baseRootSuiteId;
973
+ const api = await client.getTestPlanApi();
974
+ const suites = await api.getTestSuitesForPlan(config.project, config.testPlan.id);
975
+ let parentId = baseRootSuiteId;
976
+ let cacheKey = '';
977
+ for (const seg of segments) {
978
+ cacheKey = cacheKey ? `${cacheKey}/${seg}` : seg;
979
+ if (suiteCache.has(cacheKey)) {
980
+ parentId = suiteCache.get(cacheKey);
981
+ continue;
982
+ }
983
+ const existing = (suites ?? []).find((suite) => suite.parentSuite?.id === parentId && suite.name === seg);
984
+ if (existing?.id) {
985
+ parentId = existing.id;
986
+ suiteCache.set(cacheKey, parentId);
987
+ continue;
988
+ }
989
+ if (!createMissing)
990
+ return undefined;
991
+ parentId = await getOrCreateChildSuite(client, config, parentId, seg);
992
+ suiteCache.set(cacheKey, parentId);
993
+ }
994
+ return parentId;
995
+ }
996
+ function dropSuiteCacheBranch(suiteCache, pathKey) {
997
+ for (const key of [...suiteCache.keys()]) {
998
+ if (key === pathKey || key.startsWith(`${pathKey}/`)) {
999
+ suiteCache.delete(key);
1000
+ }
1001
+ }
1002
+ }
1003
+ async function resolveExistingSuiteForFile(client, config, filePath, configDir, suiteCache) {
1004
+ const segments = getGeneratedSuiteSegments(config, filePath, configDir);
1005
+ if (!segments)
1006
+ return undefined;
1007
+ return resolveSuiteForSegments(client, config, segments, suiteCache, false);
1008
+ }
1009
+ async function resolveExistingSuiteForPathKey(client, config, pathKey, suiteCache) {
1010
+ const segments = pathKey.split('/').filter(Boolean);
1011
+ if (!segments.length)
1012
+ return undefined;
1013
+ return resolveSuiteForSegments(client, config, segments, suiteCache, false);
1014
+ }
1015
+ async function pruneEmptyGeneratedSuitesForFile(client, config, filePath, configDir, suiteCache) {
1016
+ const hierarchy = getSuiteHierarchy(config);
1017
+ const segments = getGeneratedSuiteSegments(config, filePath, configDir);
1018
+ if (!hierarchy || !segments?.length)
1019
+ return;
1020
+ const api = await client.getTestPlanApi();
1021
+ const rootSuiteId = await resolveRootSuiteId(client, config);
1022
+ const protectedDepth = hierarchy.rootSuite ? 1 : 0;
1023
+ for (let depth = segments.length; depth > protectedDepth; depth--) {
1024
+ const pathSegments = segments.slice(0, depth);
1025
+ const pathKey = pathSegments.join('/');
1026
+ const suiteId = await resolveSuiteForSegments(client, config, pathSegments, suiteCache, false);
1027
+ if (!suiteId || suiteId === rootSuiteId)
1028
+ continue;
1029
+ const suites = await api.getTestSuitesForPlan(config.project, config.testPlan.id);
1030
+ const childSuites = (suites ?? []).filter((suite) => suite.parentSuite?.id === suiteId);
1031
+ if (childSuites.length > 0)
1032
+ break;
1033
+ const testCases = await api.getTestCaseList(config.project, config.testPlan.id, suiteId);
1034
+ if ((testCases?.length ?? 0) > 0)
1035
+ break;
1036
+ await api.deleteTestSuite(config.project, config.testPlan.id, suiteId);
1037
+ dropSuiteCacheBranch(suiteCache, pathKey);
1038
+ }
1039
+ }
1040
+ async function pruneEmptyGeneratedSuitesForPathKey(client, config, pathKey, suiteCache) {
1041
+ const hierarchy = getSuiteHierarchy(config);
1042
+ const segments = pathKey.split('/').filter(Boolean);
1043
+ if (!hierarchy || !segments.length)
1044
+ return;
1045
+ const api = await client.getTestPlanApi();
1046
+ const rootSuiteId = await resolveRootSuiteId(client, config);
1047
+ const protectedDepth = hierarchy.rootSuite ? 1 : 0;
1048
+ for (let depth = segments.length; depth > protectedDepth; depth--) {
1049
+ const pathSegments = segments.slice(0, depth);
1050
+ const currentPathKey = pathSegments.join('/');
1051
+ const suiteId = await resolveSuiteForSegments(client, config, pathSegments, suiteCache, false);
1052
+ if (!suiteId || suiteId === rootSuiteId)
1053
+ continue;
1054
+ const suites = await api.getTestSuitesForPlan(config.project, config.testPlan.id);
1055
+ const childSuites = (suites ?? []).filter((suite) => suite.parentSuite?.id === suiteId);
1056
+ if (childSuites.length > 0)
1057
+ break;
1058
+ const testCases = await api.getTestCaseList(config.project, config.testPlan.id, suiteId);
1059
+ if ((testCases?.length ?? 0) > 0)
1060
+ break;
1061
+ await api.deleteTestSuite(config.project, config.testPlan.id, suiteId);
1062
+ dropSuiteCacheBranch(suiteCache, currentPathKey);
1063
+ }
1064
+ }
812
1065
  /**
813
1066
  * Get or create a named static suite directly under the plan's root suite.
814
1067
  * Used by suiteRouting to resolve named route targets.
@@ -827,30 +1080,18 @@ async function getOrCreateNamedSuite(client, config, suiteName) {
827
1080
  * Uses suiteCache (Map<relPath, suiteId>) to avoid redundant API calls.
828
1081
  */
829
1082
  async function getOrCreateSuiteForFile(client, config, filePath, configDir, suiteCache) {
830
- const rootSuiteId = await resolveRootSuiteId(client, config);
831
- const relFile = path.relative(configDir, filePath);
832
- const allSegments = relFile.split(path.sep);
833
- // byFile: include the filename (without extension) as the deepest suite segment
834
- const byFile = config.testPlan.suiteMapping === 'byFile';
835
- const dirSegments = allSegments.slice(0, -1);
836
- const fileName = path.basename(filePath, path.extname(filePath));
837
- const rawSegments = byFile ? [...dirSegments, fileName] : dirSegments;
838
- const cleanSegments = rawSegments.map((s) => s.replace(/^@/, '')).filter(Boolean);
839
- if (!cleanSegments.length)
840
- return rootSuiteId;
841
- let parentId = rootSuiteId;
842
- let cacheKey = '';
843
- for (const seg of cleanSegments) {
844
- cacheKey = cacheKey ? `${cacheKey}/${seg}` : seg;
845
- if (suiteCache.has(cacheKey)) {
846
- parentId = suiteCache.get(cacheKey);
847
- }
848
- else {
849
- parentId = await getOrCreateChildSuite(client, config, parentId, seg);
850
- suiteCache.set(cacheKey, parentId);
851
- }
1083
+ const segments = getGeneratedSuiteSegments(config, filePath, configDir);
1084
+ if (!segments) {
1085
+ return resolveRootSuiteId(client, config);
852
1086
  }
853
- return parentId;
1087
+ return (await resolveSuiteForSegments(client, config, segments, suiteCache, true)) ?? await resolveRootSuiteId(client, config);
1088
+ }
1089
+ async function getOrCreateGeneratedSuiteForTest(client, config, test, configDir, suiteCache) {
1090
+ const segments = getGeneratedSuiteSegmentsForTest(config, test, configDir);
1091
+ if (!segments) {
1092
+ return resolveRootSuiteId(client, config);
1093
+ }
1094
+ return (await resolveSuiteForSegments(client, config, segments, suiteCache, true)) ?? await resolveRootSuiteId(client, config);
854
1095
  }
855
1096
  // ─── API layer ───────────────────────────────────────────────────────────────
856
1097
  async function getTestCase(client, id, titleField = 'System.Title') {
@@ -916,7 +1157,7 @@ async function createTestCase(client, test, config, suiteIdOverride, configDir)
916
1157
  }
917
1158
  }
918
1159
  // Process tags: apply tag text map transformation
919
- const processedTags = processTagsForPush(test.tags, syncCfg.tagPrefix ?? 'tc', config.customizations);
1160
+ const processedTags = processTagsForPush(test.tags, syncCfg.tagPrefix ?? 'tc', config.customizations, config);
920
1161
  const filteredTags = processedTags.join('; ');
921
1162
  if (filteredTags) {
922
1163
  patchDoc.push({ op: 'add', path: '/fields/System.Tags', value: filteredTags });
@@ -941,7 +1182,7 @@ async function createTestCase(client, test, config, suiteIdOverride, configDir)
941
1182
  }
942
1183
  if (!wi?.id)
943
1184
  throw new Error(`Failed to create test case for: ${test.title} — Azure returned no work item ID. Check that the project name, plan ID, and PAT permissions (Test Management: Write) are correct.`);
944
- const resolvedSuiteId = suiteIdOverride ?? config.testPlan.suiteId;
1185
+ const resolvedSuiteId = suiteIdOverride ?? getRemoteScopeSuiteId(config);
945
1186
  if (resolvedSuiteId) {
946
1187
  await addTestCaseToSuite(client, config, wi.id, resolvedSuiteId);
947
1188
  }
@@ -960,7 +1201,7 @@ async function updateTestCase(client, id, test, config, configDir) {
960
1201
  const titleField = syncCfg.titleField ?? 'System.Title';
961
1202
  const { title, steps } = buildAzureSyncContent(test, syncCfg.format);
962
1203
  // Process tags with transformations
963
- const processedLocalTags = processTagsForPush(test.tags, syncCfg.tagPrefix ?? 'tc', config.customizations);
1204
+ const processedLocalTags = processTagsForPush(test.tags, syncCfg.tagPrefix ?? 'tc', config.customizations, config);
964
1205
  // Fetch existing Azure tags and parameter fields so updates can merge/clear correctly.
965
1206
  const wi = await withRetry(() => wit.getWorkItem(id, [
966
1207
  'System.Tags',
@@ -1081,6 +1322,18 @@ async function addTestCaseToSuite(client, config, testCaseId, suiteId) {
1081
1322
  throw err;
1082
1323
  }
1083
1324
  }
1325
+ async function removeTestCaseFromSuite(client, config, testCaseId, suiteId) {
1326
+ const api = await client.getTestPlanApi();
1327
+ try {
1328
+ await api.removeTestCasesFromSuite(config.project, config.testPlan.id, suiteId, String(testCaseId));
1329
+ }
1330
+ catch (err) {
1331
+ const msg = err instanceof Error ? err.message : String(err);
1332
+ if (/not found|does not exist|invalid/i.test(msg))
1333
+ return;
1334
+ throw err;
1335
+ }
1336
+ }
1084
1337
  async function addTestCaseToRootSuite(client, config, testCaseId) {
1085
1338
  const api = await client.getTestPlanApi();
1086
1339
  const plan = await api.getTestPlanById(config.project, config.testPlan.id);
@@ -1130,10 +1383,13 @@ async function addTestCaseToConditionSuites(client, config, testCaseId, test, co
1130
1383
  }
1131
1384
  }
1132
1385
  async function getTestCasesInSuite(client, config, suiteId) {
1386
+ const titleField = config.sync?.titleField ?? 'System.Title';
1387
+ if (config.syncTarget?.mode === 'query') {
1388
+ return filterScopedTestCases(await getTestCasesByWiql(client, config, titleField, config.syncTarget.wiql), config);
1389
+ }
1133
1390
  const api = await client.getTestPlanApi();
1134
1391
  const wit = await client.getWitApi();
1135
- const titleField = config.sync?.titleField ?? 'System.Title';
1136
- let resolvedSuiteId = suiteId ?? config.testPlan.suiteId;
1392
+ let resolvedSuiteId = getRemoteScopeSuiteId(config, suiteId);
1137
1393
  if (!resolvedSuiteId) {
1138
1394
  const plan = await api.getTestPlanById(config.project, config.testPlan.id);
1139
1395
  resolvedSuiteId = plan?.rootSuite?.id;
@@ -1157,19 +1413,6 @@ async function getTestCasesInSuite(client, config, suiteId) {
1157
1413
  if (!ids.length)
1158
1414
  return [];
1159
1415
  const workItems = await withRetry(() => wit.getWorkItems(ids, fields));
1160
- return (workItems ?? []).map((wi) => {
1161
- const f = wi.fields ?? {};
1162
- return {
1163
- id: wi.id,
1164
- title: f[titleField] ?? '',
1165
- description: f['System.Description'] ?? '',
1166
- steps: parseStepsXml(f['Microsoft.VSTS.TCM.Steps'] ?? ''),
1167
- tags: tagsFromString(f['System.Tags']),
1168
- changedDate: f['System.ChangedDate'],
1169
- areaPath: f['System.AreaPath'],
1170
- iterationPath: f['System.IterationPath'],
1171
- automatedTestName: f['Microsoft.VSTS.TCM.AutomatedTestName'] || undefined,
1172
- };
1173
- });
1416
+ return filterScopedTestCases((workItems ?? []).map((workItem) => buildAzureTestCaseFromWorkItem(workItem, titleField)), config);
1174
1417
  }
1175
1418
  //# sourceMappingURL=test-cases.js.map