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.
- package/README.md +20 -15
- package/dist/__tests__/regressions.test.js +1011 -1
- package/dist/__tests__/regressions.test.js.map +1 -1
- package/dist/ai/generate-spec.d.ts +1 -1
- package/dist/ai/generate-spec.js +23 -0
- package/dist/ai/generate-spec.js.map +1 -1
- package/dist/ai/summarizer.d.ts +3 -2
- package/dist/ai/summarizer.js +50 -1
- package/dist/ai/summarizer.js.map +1 -1
- package/dist/azure/test-cases.d.ts +11 -1
- package/dist/azure/test-cases.js +286 -43
- package/dist/azure/test-cases.js.map +1 -1
- package/dist/cli.js +91 -14
- package/dist/cli.js.map +1 -1
- package/dist/config.js +74 -1
- package/dist/config.js.map +1 -1
- package/dist/id-markers.d.ts +1 -0
- package/dist/id-markers.js +13 -0
- package/dist/id-markers.js.map +1 -1
- package/dist/mcp-server.js +1 -1
- package/dist/mcp-server.js.map +1 -1
- package/dist/sync/cache.d.ts +2 -0
- package/dist/sync/cache.js.map +1 -1
- package/dist/sync/engine.d.ts +12 -1
- package/dist/sync/engine.js +210 -41
- package/dist/sync/engine.js.map +1 -1
- package/dist/types.d.ts +56 -4
- package/llms.txt +12 -11
- package/package.json +8 -1
- package/docs/advanced.md +0 -988
- package/docs/agent-setup.md +0 -204
- package/docs/capability-roadmap.md +0 -280
- package/docs/cli.md +0 -609
- package/docs/configuration.md +0 -322
- package/docs/examples/csharp-mstest-local-llm.yaml +0 -35
- package/docs/examples/csharp-mstest.yaml +0 -21
- package/docs/examples/csharp-nunit.yaml +0 -21
- package/docs/examples/csharp-specflow.yaml +0 -16
- package/docs/examples/cypress.yaml +0 -21
- package/docs/examples/detox-react-native.yaml +0 -21
- package/docs/examples/espresso-android.yaml +0 -21
- package/docs/examples/flutter-dart.yaml +0 -21
- package/docs/examples/java-junit.yaml +0 -21
- package/docs/examples/java-testng.yaml +0 -21
- package/docs/examples/js-jasmine-wdio.yaml +0 -21
- package/docs/examples/js-jest.yaml +0 -21
- package/docs/examples/playwright-js.yaml +0 -21
- package/docs/examples/playwright-ts.yaml +0 -21
- package/docs/examples/puppeteer.yaml +0 -21
- package/docs/examples/python-pytest.yaml +0 -21
- package/docs/examples/robot-framework.yaml +0 -19
- package/docs/examples/testcafe.yaml +0 -21
- package/docs/examples/xcuitest-ios.yaml +0 -21
- package/docs/mcp-server.md +0 -312
- package/docs/publish-test-results.md +0 -939
- package/docs/spec-formats.md +0 -1357
- package/docs/troubleshooting.md +0 -101
- package/docs/vscode-extension.md +0 -139
- package/docs/work-item-links.md +0 -115
- package/docs/workflows.md +0 -457
- package/mkdocs.yml +0 -40
- package/requirements-docs.txt +0 -4
- 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.
|
package/dist/azure/test-cases.js
CHANGED
|
@@ -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
|
|
831
|
-
|
|
832
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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((
|
|
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
|