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.
- package/README.md +15 -15
- package/dist/__tests__/regressions.test.js +1133 -1
- package/dist/__tests__/regressions.test.js.map +1 -1
- package/dist/ai/summarizer.d.ts +2 -1
- package/dist/ai/summarizer.js +6 -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-diagnostics.d.ts +66 -0
- package/dist/cli-diagnostics.js +75 -0
- package/dist/cli-diagnostics.js.map +1 -0
- package/dist/cli.js +335 -23
- package/dist/cli.js.map +1 -1
- package/dist/config.js +194 -9
- package/dist/config.js.map +1 -1
- package/dist/extensions.d.ts +8 -0
- package/dist/extensions.js +86 -0
- package/dist/extensions.js.map +1 -0
- 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/sync/cache.d.ts +2 -0
- package/dist/sync/cache.js.map +1 -1
- package/dist/sync/engine.d.ts +29 -2
- package/dist/sync/engine.js +270 -41
- package/dist/sync/engine.js.map +1 -1
- package/dist/sync/publish-results.d.ts +25 -0
- package/dist/sync/publish-results.js +81 -2
- package/dist/sync/publish-results.js.map +1 -1
- package/dist/types.d.ts +98 -2
- package/llms.txt +11 -11
- package/package.json +9 -1
- package/docs/advanced.md +0 -989
- package/docs/agent-setup.md +0 -204
- package/docs/capability-roadmap.md +0 -280
- package/docs/cli.md +0 -614
- 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 -947
- 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
package/dist/sync/engine.js
CHANGED
|
@@ -41,14 +41,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
42
|
exports.failOnParseErrors = failOnParseErrors;
|
|
43
43
|
exports.buildPushDiff = buildPushDiff;
|
|
44
|
+
exports.validatePushModeOptions = validatePushModeOptions;
|
|
44
45
|
exports.push = push;
|
|
45
46
|
exports.pull = pull;
|
|
46
47
|
exports.status = status;
|
|
48
|
+
exports.evaluateClassificationRules = evaluateClassificationRules;
|
|
49
|
+
exports.parseDuration = parseDuration;
|
|
50
|
+
exports.enforceStalenessPolicy = enforceStalenessPolicy;
|
|
47
51
|
exports.detectStaleTestCases = detectStaleTestCases;
|
|
48
52
|
exports.coverageReport = coverageReport;
|
|
49
53
|
const tag_expressions_1 = __importDefault(require("@cucumber/tag-expressions"));
|
|
50
54
|
const fs = __importStar(require("fs"));
|
|
51
55
|
const glob_1 = require("glob");
|
|
56
|
+
const minimatch_1 = require("minimatch");
|
|
52
57
|
const os = __importStar(require("os"));
|
|
53
58
|
const path = __importStar(require("path"));
|
|
54
59
|
const summarizer_1 = require("../ai/summarizer");
|
|
@@ -313,6 +318,45 @@ async function parseLocalFiles(filePaths, config, tagsFilter) {
|
|
|
313
318
|
}
|
|
314
319
|
return { tests: results, failures };
|
|
315
320
|
}
|
|
321
|
+
function validatePushModeOptions(opts) {
|
|
322
|
+
const enabledModes = [
|
|
323
|
+
opts.createOnly && '--create-only',
|
|
324
|
+
opts.linkOnly && '--link-only',
|
|
325
|
+
opts.updateOnly && '--update-only',
|
|
326
|
+
].filter(Boolean);
|
|
327
|
+
if (enabledModes.length > 1) {
|
|
328
|
+
throw new Error(`Only one push mode can be used at a time: ${enabledModes.join(', ')}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function filterDiscoveredFiles(filePaths, sourceFiles, includePatterns, configDir) {
|
|
332
|
+
let filtered = filePaths;
|
|
333
|
+
if (sourceFiles?.length) {
|
|
334
|
+
const requested = new Set(sourceFiles.map((filePath) => path.normalize(path.resolve(filePath))));
|
|
335
|
+
filtered = filtered.filter((filePath) => requested.has(path.normalize(filePath)));
|
|
336
|
+
}
|
|
337
|
+
if (includePatterns?.length && configDir) {
|
|
338
|
+
const resolvedDir = path.resolve(configDir);
|
|
339
|
+
filtered = filtered.filter((filePath) => {
|
|
340
|
+
const relative = path.relative(resolvedDir, filePath);
|
|
341
|
+
return includePatterns.some((pattern) => (0, minimatch_1.minimatch)(relative, pattern, { dot: true }));
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return filtered;
|
|
345
|
+
}
|
|
346
|
+
function formatSuitePreview(pathKey) {
|
|
347
|
+
if (!pathKey)
|
|
348
|
+
return undefined;
|
|
349
|
+
return pathKey.split('/').join(' / ');
|
|
350
|
+
}
|
|
351
|
+
function mapRemoteTestsByTitle(testCases) {
|
|
352
|
+
const byTitle = new Map();
|
|
353
|
+
for (const testCase of testCases) {
|
|
354
|
+
const matches = byTitle.get(testCase.title) ?? [];
|
|
355
|
+
matches.push(testCase);
|
|
356
|
+
byTitle.set(testCase.title, matches);
|
|
357
|
+
}
|
|
358
|
+
return byTitle;
|
|
359
|
+
}
|
|
316
360
|
// ─── Multi-plan helpers ───────────────────────────────────────────────────────
|
|
317
361
|
/**
|
|
318
362
|
* Build an effective config for a single plan entry in testPlans[] mode.
|
|
@@ -330,6 +374,7 @@ function configForPlanEntry(base, entry) {
|
|
|
330
374
|
testPlan: {
|
|
331
375
|
id: entry.id,
|
|
332
376
|
suiteId: entry.suiteId ?? base.testPlan.suiteId,
|
|
377
|
+
hierarchy: entry.hierarchy ?? base.testPlan.hierarchy,
|
|
333
378
|
suiteMapping: entry.suiteMapping ?? base.testPlan.suiteMapping,
|
|
334
379
|
suiteRouting: entry.suiteRouting ?? base.testPlan.suiteRouting,
|
|
335
380
|
},
|
|
@@ -375,6 +420,7 @@ async function resolveTargetSuiteFromRouting(client, config, test, suiteCache) {
|
|
|
375
420
|
}
|
|
376
421
|
// ─── Push ─────────────────────────────────────────────────────────────────────
|
|
377
422
|
async function push(config, configDir, opts = {}) {
|
|
423
|
+
validatePushModeOptions(opts);
|
|
378
424
|
// Multi-plan: run AI summarisation across all plans first so progress totals
|
|
379
425
|
// are correct, then delegate each plan's sync loop (with AI already applied).
|
|
380
426
|
if (config.testPlans?.length) {
|
|
@@ -383,7 +429,7 @@ async function push(config, configDir, opts = {}) {
|
|
|
383
429
|
const parseFailures = [];
|
|
384
430
|
for (const entry of config.testPlans) {
|
|
385
431
|
const entryConfig = configForPlanEntry(config, entry);
|
|
386
|
-
const files = await discoverFiles(entryConfig.local.include, entryConfig.local.exclude, configDir);
|
|
432
|
+
const files = filterDiscoveredFiles(await discoverFiles(entryConfig.local.include, entryConfig.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
|
|
387
433
|
const parsed = await parseLocalFiles(files, entryConfig, opts.tags);
|
|
388
434
|
parseFailures.push(...parsed.failures);
|
|
389
435
|
const tests = parsed.tests;
|
|
@@ -430,7 +476,7 @@ async function pushSingle(config, configDir, opts) {
|
|
|
430
476
|
let resolvedTests = opts._preloadedTests ?? [];
|
|
431
477
|
let parseFailures = [];
|
|
432
478
|
if (!opts._preloadedTests) {
|
|
433
|
-
const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
|
|
479
|
+
const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
|
|
434
480
|
const parsed = await parseLocalFiles(files, config, opts.tags);
|
|
435
481
|
resolvedTests = parsed.tests;
|
|
436
482
|
parseFailures = parsed.failures;
|
|
@@ -515,7 +561,7 @@ async function pushSingle(config, configDir, opts) {
|
|
|
515
561
|
const titleField = config.sync?.titleField ?? 'System.Title';
|
|
516
562
|
const conflictAction = config.sync?.conflictAction ?? 'overwrite';
|
|
517
563
|
const disableLocal = config.sync?.disableLocalChanges ?? false;
|
|
518
|
-
const byFolder =
|
|
564
|
+
const byFolder = (0, test_cases_1.usesGeneratedSuiteHierarchy)(config);
|
|
519
565
|
// Load local cache for conflict detection and skip optimisation
|
|
520
566
|
const cache = (0, cache_1.loadCache)(configDir);
|
|
521
567
|
// G: seed in-memory suite cache from persisted _suites to avoid redundant API calls
|
|
@@ -535,18 +581,24 @@ async function pushSingle(config, configDir, opts) {
|
|
|
535
581
|
const markAutomated = config.sync?.markAutomated ?? false;
|
|
536
582
|
const recoveredIds = new Set(); // "filePath:line" keys
|
|
537
583
|
let preloadedRemoteTcs;
|
|
584
|
+
let remoteByTitle;
|
|
538
585
|
const unlinkedWithAtName = resolvedTests.filter(t => !t.azureId && t.automatedTestName);
|
|
539
|
-
|
|
586
|
+
const shouldPreloadRemoteTcs = (markAutomated && unlinkedWithAtName.length > 0)
|
|
587
|
+
|| (opts.linkOnly && resolvedTests.some((test) => !test.azureId));
|
|
588
|
+
if (shouldPreloadRemoteTcs) {
|
|
540
589
|
try {
|
|
541
590
|
preloadedRemoteTcs = await (0, test_cases_1.getTestCasesInSuite)(client, config);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
591
|
+
remoteByTitle = mapRemoteTestsByTitle(preloadedRemoteTcs);
|
|
592
|
+
if (markAutomated && unlinkedWithAtName.length > 0) {
|
|
593
|
+
const byAtName = new Map(preloadedRemoteTcs
|
|
594
|
+
.filter(tc => tc.automatedTestName)
|
|
595
|
+
.map(tc => [tc.automatedTestName, tc]));
|
|
596
|
+
for (const test of unlinkedWithAtName) {
|
|
597
|
+
const match = byAtName.get(test.automatedTestName);
|
|
598
|
+
if (match) {
|
|
599
|
+
test.azureId = match.id;
|
|
600
|
+
recoveredIds.add(`${test.filePath}:${test.line}`);
|
|
601
|
+
}
|
|
550
602
|
}
|
|
551
603
|
}
|
|
552
604
|
}
|
|
@@ -594,7 +646,19 @@ async function pushSingle(config, configDir, opts) {
|
|
|
594
646
|
const pushWorkers = Array.from({ length: Math.min(PUSH_CONCURRENCY, pushQueue.length) }, async () => {
|
|
595
647
|
while (pushQueue.length > 0) {
|
|
596
648
|
const test = pushQueue.shift();
|
|
649
|
+
const currentHierarchyKey = byFolder ? (0, test_cases_1.getGeneratedSuitePathKeyForTest)(config, test, configDir) : undefined;
|
|
650
|
+
const targetSuitePath = formatSuitePreview(currentHierarchyKey);
|
|
597
651
|
if (test.azureId) {
|
|
652
|
+
if (opts.createOnly || opts.linkOnly) {
|
|
653
|
+
reportProgress({
|
|
654
|
+
action: 'skipped',
|
|
655
|
+
filePath: test.filePath,
|
|
656
|
+
title: test.title,
|
|
657
|
+
azureId: test.azureId,
|
|
658
|
+
detail: opts.createOnly ? 'skipped by create-only mode' : 'already linked; skipped by link-only mode',
|
|
659
|
+
});
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
598
662
|
try {
|
|
599
663
|
const cached = cache[test.azureId];
|
|
600
664
|
// Use pre-fetched TC from cache; fall back to live fetch on miss
|
|
@@ -602,11 +666,21 @@ async function pushSingle(config, configDir, opts) {
|
|
|
602
666
|
? remoteTcCache.get(test.azureId)
|
|
603
667
|
: await (0, test_cases_1.getTestCase)(client, test.azureId, titleField);
|
|
604
668
|
if (!remote) {
|
|
669
|
+
if (opts.updateOnly) {
|
|
670
|
+
reportProgress({
|
|
671
|
+
action: 'skipped',
|
|
672
|
+
filePath: test.filePath,
|
|
673
|
+
title: test.title,
|
|
674
|
+
azureId: test.azureId,
|
|
675
|
+
detail: 'remote test case missing; skipped by update-only mode',
|
|
676
|
+
});
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
605
679
|
// TC was deleted from Azure — re-create it and write back the new ID.
|
|
606
680
|
let newId;
|
|
607
681
|
if (!opts.dryRun) {
|
|
608
682
|
const suiteIdOverride = byFolder
|
|
609
|
-
? await (0, test_cases_1.
|
|
683
|
+
? await (0, test_cases_1.getOrCreateGeneratedSuiteForTest)(client, config, test, configDir, suiteCache)
|
|
610
684
|
: await resolveTargetSuiteFromRouting(client, config, test, suiteCache);
|
|
611
685
|
newId = await (0, test_cases_1.createTestCase)(client, test, config, suiteIdOverride, configDir);
|
|
612
686
|
createdIds.add(newId);
|
|
@@ -616,7 +690,7 @@ async function pushSingle(config, configDir, opts) {
|
|
|
616
690
|
await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, newId, test, conditionSuiteCache);
|
|
617
691
|
const created = await (0, test_cases_1.getTestCase)(client, newId, titleField);
|
|
618
692
|
if (created)
|
|
619
|
-
updateCacheEntry(cache, test, created);
|
|
693
|
+
updateCacheEntry(cache, test, created, currentHierarchyKey);
|
|
620
694
|
}
|
|
621
695
|
reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId });
|
|
622
696
|
continue;
|
|
@@ -625,23 +699,44 @@ async function pushSingle(config, configDir, opts) {
|
|
|
625
699
|
// Normalise local to @param@ before comparing so outlines don't report
|
|
626
700
|
// stepsChanged on every push after the first successful sync.
|
|
627
701
|
const { changedFields, diffDetail } = buildPushDiff(test, remote, config, cached);
|
|
628
|
-
|
|
702
|
+
const previousHierarchyKey = cached?.suitePathKey ?? (cached?.filePath
|
|
703
|
+
? (0, test_cases_1.getGeneratedSuitePathKey)(config, cached.filePath, configDir)
|
|
704
|
+
: undefined);
|
|
705
|
+
const suiteChanged = byFolder &&
|
|
706
|
+
previousHierarchyKey !== undefined &&
|
|
707
|
+
currentHierarchyKey !== undefined &&
|
|
708
|
+
previousHierarchyKey !== currentHierarchyKey;
|
|
709
|
+
const previousSuitePath = formatSuitePreview(previousHierarchyKey);
|
|
710
|
+
const effectiveChangedFields = suiteChanged ? [...changedFields, 'suite'] : changedFields;
|
|
711
|
+
const effectiveDiffDetail = suiteChanged
|
|
712
|
+
? [
|
|
713
|
+
...diffDetail,
|
|
714
|
+
{
|
|
715
|
+
field: 'suite',
|
|
716
|
+
remote: previousHierarchyKey || '(plan root)',
|
|
717
|
+
local: currentHierarchyKey || '(plan root)',
|
|
718
|
+
},
|
|
719
|
+
]
|
|
720
|
+
: diffDetail;
|
|
721
|
+
if (effectiveChangedFields.length === 0) {
|
|
629
722
|
// Update cache entry even on skip (changedDate may differ due to other fields)
|
|
630
|
-
updateCacheEntry(cache, test, remote);
|
|
631
|
-
reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId });
|
|
723
|
+
updateCacheEntry(cache, test, remote, currentHierarchyKey);
|
|
724
|
+
reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId, detail: 'no changes' });
|
|
632
725
|
continue;
|
|
633
726
|
}
|
|
634
727
|
// Conflict detection: remote was changed since last push AND local also differs
|
|
635
|
-
if (cached && remote.changedDate && remote.changedDate !== cached.changedDate) {
|
|
728
|
+
if (changedFields.length > 0 && cached && remote.changedDate && remote.changedDate !== cached.changedDate) {
|
|
636
729
|
const relFile = path.relative(configDir, test.filePath);
|
|
637
730
|
const conflict = {
|
|
638
731
|
action: 'conflict',
|
|
639
732
|
filePath: test.filePath,
|
|
640
733
|
title: test.title,
|
|
641
734
|
azureId: test.azureId,
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
735
|
+
targetSuitePath,
|
|
736
|
+
previousSuitePath,
|
|
737
|
+
changedFields: effectiveChangedFields,
|
|
738
|
+
diffDetail: effectiveDiffDetail,
|
|
739
|
+
detail: `${relFile}:${test.line} — changed fields: ${effectiveChangedFields.join(', ')}`,
|
|
645
740
|
};
|
|
646
741
|
if (conflictAction === 'skip') {
|
|
647
742
|
reportProgress(conflict);
|
|
@@ -655,24 +750,39 @@ async function pushSingle(config, configDir, opts) {
|
|
|
655
750
|
// 'overwrite' — fall through to update
|
|
656
751
|
}
|
|
657
752
|
if (!opts.dryRun) {
|
|
658
|
-
|
|
753
|
+
if (changedFields.length > 0) {
|
|
754
|
+
await (0, test_cases_1.updateTestCase)(client, test.azureId, test, config, configDir);
|
|
755
|
+
}
|
|
659
756
|
// Ensure the TC is in the configured suite (it may not be if the suite was
|
|
660
757
|
// changed in config, or if the TC was imported with an ID but never pushed before).
|
|
661
758
|
const updateSuiteId = byFolder
|
|
662
|
-
? await (0, test_cases_1.
|
|
759
|
+
? await (0, test_cases_1.getOrCreateGeneratedSuiteForTest)(client, config, test, configDir, suiteCache)
|
|
663
760
|
: await resolveTargetSuiteFromRouting(client, config, test, suiteCache) ?? config.testPlan.suiteId;
|
|
664
761
|
if (updateSuiteId) {
|
|
665
762
|
await (0, test_cases_1.addTestCaseToSuite)(client, config, test.azureId, updateSuiteId);
|
|
763
|
+
if (suiteChanged && previousHierarchyKey) {
|
|
764
|
+
const previousSuiteId = cached?.suitePathKey
|
|
765
|
+
? await (0, test_cases_1.resolveExistingSuiteForPathKey)(client, config, previousHierarchyKey, suiteCache)
|
|
766
|
+
: cached?.filePath
|
|
767
|
+
? await (0, test_cases_1.resolveExistingSuiteForFile)(client, config, cached.filePath, configDir, suiteCache)
|
|
768
|
+
: undefined;
|
|
769
|
+
if (previousSuiteId && previousSuiteId !== updateSuiteId) {
|
|
770
|
+
await (0, test_cases_1.removeTestCaseFromSuite)(client, config, test.azureId, previousSuiteId);
|
|
771
|
+
if ((0, test_cases_1.shouldCleanupEmptyGeneratedSuites)(config)) {
|
|
772
|
+
await (0, test_cases_1.pruneEmptyGeneratedSuitesForPathKey)(client, config, previousHierarchyKey, suiteCache);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
666
776
|
}
|
|
667
777
|
else {
|
|
668
778
|
await (0, test_cases_1.addTestCaseToRootSuite)(client, config, test.azureId);
|
|
669
779
|
}
|
|
670
780
|
await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, test.azureId, test, conditionSuiteCache);
|
|
671
|
-
const updated = await (0, test_cases_1.getTestCase)(client, test.azureId, titleField);
|
|
781
|
+
const updated = changedFields.length > 0 ? await (0, test_cases_1.getTestCase)(client, test.azureId, titleField) : remote;
|
|
672
782
|
if (updated)
|
|
673
|
-
updateCacheEntry(cache, test, updated);
|
|
783
|
+
updateCacheEntry(cache, test, updated, currentHierarchyKey);
|
|
674
784
|
}
|
|
675
|
-
reportProgress({ action: 'updated', filePath: test.filePath, title: test.title, azureId: test.azureId, changedFields, diffDetail });
|
|
785
|
+
reportProgress({ action: 'updated', filePath: test.filePath, title: test.title, azureId: test.azureId, targetSuitePath, previousSuitePath, changedFields: effectiveChangedFields, diffDetail: effectiveDiffDetail });
|
|
676
786
|
}
|
|
677
787
|
catch (err) {
|
|
678
788
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -680,11 +790,51 @@ async function pushSingle(config, configDir, opts) {
|
|
|
680
790
|
}
|
|
681
791
|
}
|
|
682
792
|
else {
|
|
793
|
+
if (opts.updateOnly) {
|
|
794
|
+
reportProgress({
|
|
795
|
+
action: 'skipped',
|
|
796
|
+
filePath: test.filePath,
|
|
797
|
+
title: test.title,
|
|
798
|
+
detail: 'unlinked local spec; skipped by update-only mode',
|
|
799
|
+
});
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (opts.linkOnly) {
|
|
803
|
+
try {
|
|
804
|
+
const canonicalTitle = (0, test_cases_1.buildAzureSyncContent)(test, config.sync?.format).title;
|
|
805
|
+
const titleMatches = remoteByTitle?.get(canonicalTitle) ?? [];
|
|
806
|
+
if (titleMatches.length !== 1) {
|
|
807
|
+
const detail = titleMatches.length === 0
|
|
808
|
+
? 'no unique title match found in current remote scope'
|
|
809
|
+
: `multiple title matches found in current remote scope (${titleMatches.length})`;
|
|
810
|
+
reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, detail });
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const matched = titleMatches[0];
|
|
814
|
+
test.azureId = matched.id;
|
|
815
|
+
recoveredIds.add(`${test.filePath}:${test.line}`);
|
|
816
|
+
if (!opts.dryRun && !disableLocal) {
|
|
817
|
+
pendingWritebacks.push({ test, newId: matched.id });
|
|
818
|
+
}
|
|
819
|
+
const detail = opts.dryRun
|
|
820
|
+
? 'would link to existing test case by exact title match'
|
|
821
|
+
: disableLocal
|
|
822
|
+
? 'matched existing test case by exact title match (local writeback skipped)'
|
|
823
|
+
: 'linked to existing test case by exact title match';
|
|
824
|
+
reportProgress({ action: 'linked', filePath: test.filePath, title: test.title, azureId: matched.id, detail });
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
829
|
+
reportProgress({ action: 'error', filePath: test.filePath, title: test.title, detail: msg });
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
683
833
|
try {
|
|
684
834
|
let newId;
|
|
685
835
|
if (!opts.dryRun) {
|
|
686
836
|
const suiteIdOverride = byFolder
|
|
687
|
-
? await (0, test_cases_1.
|
|
837
|
+
? await (0, test_cases_1.getOrCreateGeneratedSuiteForTest)(client, config, test, configDir, suiteCache)
|
|
688
838
|
: await resolveTargetSuiteFromRouting(client, config, test, suiteCache);
|
|
689
839
|
newId = await (0, test_cases_1.createTestCase)(client, test, config, suiteIdOverride, configDir);
|
|
690
840
|
createdIds.add(newId);
|
|
@@ -695,9 +845,9 @@ async function pushSingle(config, configDir, opts) {
|
|
|
695
845
|
// Fetch back to get changedDate for cache
|
|
696
846
|
const created = await (0, test_cases_1.getTestCase)(client, newId, titleField);
|
|
697
847
|
if (created)
|
|
698
|
-
updateCacheEntry(cache, test, created);
|
|
848
|
+
updateCacheEntry(cache, test, created, currentHierarchyKey);
|
|
699
849
|
}
|
|
700
|
-
reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId });
|
|
850
|
+
reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId, targetSuitePath });
|
|
701
851
|
}
|
|
702
852
|
catch (err) {
|
|
703
853
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -754,17 +904,17 @@ async function pushSingle(config, configDir, opts) {
|
|
|
754
904
|
}
|
|
755
905
|
}
|
|
756
906
|
// Removed TC detection is only safe on full-scope runs.
|
|
757
|
-
// Tag-filtered or condition-filtered pushes intentionally operate on a subset.
|
|
758
|
-
const shouldDetectRemoved = !opts.tags && !config.local.condition;
|
|
907
|
+
// Tag-filtered, source-file-filtered, create-only, or condition-filtered pushes intentionally operate on a subset.
|
|
908
|
+
const shouldDetectRemoved = !opts.tags && !opts.sourceFiles?.length && !opts.createOnly && !opts.linkOnly && !opts.updateOnly && !config.local.condition;
|
|
909
|
+
const localIds = new Set([
|
|
910
|
+
...resolvedTests.map((t) => t.azureId).filter(Boolean),
|
|
911
|
+
...createdIds,
|
|
912
|
+
]);
|
|
759
913
|
if (shouldDetectRemoved) {
|
|
760
914
|
try {
|
|
761
915
|
// Reuse the pre-loaded remote TCs if we already fetched them for automatedTestName
|
|
762
916
|
// matching, otherwise fetch now. This avoids a redundant round-trip.
|
|
763
917
|
const remoteTcs = preloadedRemoteTcs ?? await (0, test_cases_1.getTestCasesInSuite)(client, config);
|
|
764
|
-
const localIds = new Set([
|
|
765
|
-
...resolvedTests.map((t) => t.azureId).filter(Boolean),
|
|
766
|
-
...createdIds,
|
|
767
|
-
]);
|
|
768
918
|
for (const remote of remoteTcs) {
|
|
769
919
|
if (!localIds.has(remote.id)) {
|
|
770
920
|
if (!opts.dryRun) {
|
|
@@ -782,6 +932,22 @@ async function pushSingle(config, configDir, opts) {
|
|
|
782
932
|
}
|
|
783
933
|
catch { /* best-effort: don't fail the whole push */ }
|
|
784
934
|
}
|
|
935
|
+
if (!opts.dryRun && (0, test_cases_1.shouldCleanupEmptyGeneratedSuites)(config) && shouldDetectRemoved) {
|
|
936
|
+
const staleHierarchyKeys = [...new Set(Object.entries(cache)
|
|
937
|
+
.filter(([cacheKey]) => cacheKey !== '_suites')
|
|
938
|
+
.map(([cacheKey, entry]) => ({ azureId: Number(cacheKey), entry }))
|
|
939
|
+
.filter(({ azureId, entry }) => Number.isFinite(azureId) && !localIds.has(azureId) && !!entry)
|
|
940
|
+
.map(({ entry }) => entry.suitePathKey ?? (0, test_cases_1.getGeneratedSuitePathKey)(config, entry.filePath, configDir))
|
|
941
|
+
.filter((pathKey) => !!pathKey))];
|
|
942
|
+
for (const staleHierarchyKey of staleHierarchyKeys) {
|
|
943
|
+
try {
|
|
944
|
+
await (0, test_cases_1.pruneEmptyGeneratedSuitesForPathKey)(client, config, staleHierarchyKey, suiteCache);
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
// Best-effort cleanup; do not fail the push when stale branch pruning cannot complete.
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
785
951
|
if (!opts.dryRun) {
|
|
786
952
|
// G: persist suite name→id map so the next push avoids redundant API traversals
|
|
787
953
|
if (suiteCache.size > 0) {
|
|
@@ -803,7 +969,7 @@ async function pull(config, configDir, opts = {}) {
|
|
|
803
969
|
return pullSingle(config, configDir, opts);
|
|
804
970
|
}
|
|
805
971
|
async function pullSingle(config, configDir, opts) {
|
|
806
|
-
const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
|
|
972
|
+
const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
|
|
807
973
|
const parsed = await parseLocalFiles(files, config, opts.tags);
|
|
808
974
|
failOnParseErrors('pull', parsed.failures);
|
|
809
975
|
const tests = parsed.tests;
|
|
@@ -841,7 +1007,7 @@ async function pullSingle(config, configDir, opts) {
|
|
|
841
1007
|
const stepsChanged = remoteStepsText !== localStepsText;
|
|
842
1008
|
const descriptionChanged = (remote.description ?? '') !== (test.description ?? '');
|
|
843
1009
|
if (!titleChanged && !stepsChanged && !descriptionChanged) {
|
|
844
|
-
reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId });
|
|
1010
|
+
reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId, detail: 'no changes' });
|
|
845
1011
|
continue;
|
|
846
1012
|
}
|
|
847
1013
|
if (!opts.dryRun) {
|
|
@@ -904,6 +1070,14 @@ async function pullSingle(config, configDir, opts) {
|
|
|
904
1070
|
detail: `Pull-create is not supported for local.type "${config.local.type}".`,
|
|
905
1071
|
});
|
|
906
1072
|
}
|
|
1073
|
+
else if (opts.sourceFiles?.length) {
|
|
1074
|
+
results.push({
|
|
1075
|
+
action: 'error',
|
|
1076
|
+
filePath: '',
|
|
1077
|
+
title: '',
|
|
1078
|
+
detail: 'Pull-create is not supported together with --source-file because partial runs cannot infer which unlinked remote tests to materialize.',
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
907
1081
|
else {
|
|
908
1082
|
try {
|
|
909
1083
|
const remoteTcs = await (0, test_cases_1.getTestCasesInSuite)(client, config);
|
|
@@ -939,10 +1113,18 @@ async function pullSingle(config, configDir, opts) {
|
|
|
939
1113
|
}
|
|
940
1114
|
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
941
1115
|
async function status(config, configDir, opts = {}) {
|
|
942
|
-
return push(config, configDir, {
|
|
1116
|
+
return push(config, configDir, {
|
|
1117
|
+
dryRun: true,
|
|
1118
|
+
tags: opts.tags,
|
|
1119
|
+
sourceFiles: opts.sourceFiles,
|
|
1120
|
+
includePatterns: opts.includePatterns,
|
|
1121
|
+
onProgress: opts.onProgress,
|
|
1122
|
+
onAiProgress: opts.onAiProgress,
|
|
1123
|
+
aiSummary: opts.aiSummary,
|
|
1124
|
+
});
|
|
943
1125
|
}
|
|
944
1126
|
// ─── Cache helpers ────────────────────────────────────────────────────────────
|
|
945
|
-
function updateCacheEntry(cache, test, remote) {
|
|
1127
|
+
function updateCacheEntry(cache, test, remote, suitePathKey) {
|
|
946
1128
|
if (!remote.changedDate)
|
|
947
1129
|
return;
|
|
948
1130
|
cache[remote.id] = {
|
|
@@ -955,6 +1137,7 @@ function updateCacheEntry(cache, test, remote) {
|
|
|
955
1137
|
remoteDescriptionHash: (0, cache_1.hashString)(remote.description),
|
|
956
1138
|
changedDate: remote.changedDate,
|
|
957
1139
|
filePath: test.filePath,
|
|
1140
|
+
suitePathKey,
|
|
958
1141
|
};
|
|
959
1142
|
}
|
|
960
1143
|
// ─── Apply remote changes to local file ───────────────────────────────────────
|
|
@@ -1173,6 +1356,52 @@ function buildPullMarkdownContent(tc, tagPrefix) {
|
|
|
1173
1356
|
}
|
|
1174
1357
|
return lines.join('\n');
|
|
1175
1358
|
}
|
|
1359
|
+
function evaluateClassificationRules(rules, testTags, testPath) {
|
|
1360
|
+
for (const rule of rules) {
|
|
1361
|
+
if (rule.match.default) {
|
|
1362
|
+
return { classification: rule.classification, matchedRule: rule, destinations: rule.destinations };
|
|
1363
|
+
}
|
|
1364
|
+
if (rule.match.tags?.length) {
|
|
1365
|
+
const normalizedTags = testTags.map((t) => t.startsWith('@') ? t : `@${t}`);
|
|
1366
|
+
const matchTags = rule.match.tags.map((t) => t.startsWith('@') ? t : `@${t}`);
|
|
1367
|
+
if (matchTags.some((mt) => normalizedTags.includes(mt))) {
|
|
1368
|
+
return { classification: rule.classification, matchedRule: rule, destinations: rule.destinations };
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
if (rule.match.path) {
|
|
1372
|
+
if ((0, minimatch_1.minimatch)(testPath, rule.match.path, { dot: true })) {
|
|
1373
|
+
return { classification: rule.classification, matchedRule: rule, destinations: rule.destinations };
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1379
|
+
// ─── Staleness policy helpers ────────────────────────────────────────────────
|
|
1380
|
+
function parseDuration(duration) {
|
|
1381
|
+
const match = duration.match(/^(\d+)\s*(d|h|m|w)$/i);
|
|
1382
|
+
if (!match)
|
|
1383
|
+
throw new Error(`Invalid duration format: "${duration}". Expected format: 30d, 24h, 7w, etc.`);
|
|
1384
|
+
const value = parseInt(match[1], 10);
|
|
1385
|
+
const unit = match[2].toLowerCase();
|
|
1386
|
+
const ms = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
1387
|
+
return value * ms[unit];
|
|
1388
|
+
}
|
|
1389
|
+
function enforceStalenessPolicy(staleCases, policy) {
|
|
1390
|
+
if (!policy)
|
|
1391
|
+
return { prunable: staleCases, exceeded: false };
|
|
1392
|
+
let prunable = staleCases;
|
|
1393
|
+
if (policy.maxAge) {
|
|
1394
|
+
const maxAgeMs = parseDuration(policy.maxAge);
|
|
1395
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
1396
|
+
prunable = prunable.filter((tc) => {
|
|
1397
|
+
if (!tc.lastSyncDate)
|
|
1398
|
+
return true;
|
|
1399
|
+
return new Date(tc.lastSyncDate).getTime() < cutoff;
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
const exceeded = policy.maxPruneCount !== undefined && prunable.length > policy.maxPruneCount;
|
|
1403
|
+
return { prunable, exceeded };
|
|
1404
|
+
}
|
|
1176
1405
|
/**
|
|
1177
1406
|
* Detect Azure DevOps Test Cases that have no corresponding local spec.
|
|
1178
1407
|
* A TC is "stale" when it exists in the plan suite but no local file references it
|
|
@@ -1180,7 +1409,7 @@ function buildPullMarkdownContent(tc, tagPrefix) {
|
|
|
1180
1409
|
* without running push, or when TCs are created directly in Azure without a local spec.
|
|
1181
1410
|
*/
|
|
1182
1411
|
async function detectStaleTestCases(config, configDir, opts = {}) {
|
|
1183
|
-
const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
|
|
1412
|
+
const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
|
|
1184
1413
|
const parsed = await parseLocalFiles(files, config, opts.tags);
|
|
1185
1414
|
failOnParseErrors('stale test case detection', parsed.failures);
|
|
1186
1415
|
const tests = parsed.tests;
|
|
@@ -1211,7 +1440,7 @@ async function detectStaleTestCases(config, configDir, opts = {}) {
|
|
|
1211
1440
|
* 2. Story coverage — % of referenced User Stories that have at least one linked spec
|
|
1212
1441
|
*/
|
|
1213
1442
|
async function coverageReport(config, configDir, opts = {}) {
|
|
1214
|
-
const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
|
|
1443
|
+
const files = filterDiscoveredFiles(await discoverFiles(config.local.include, config.local.exclude, configDir), opts.sourceFiles, opts.includePatterns, configDir);
|
|
1215
1444
|
const parsed = await parseLocalFiles(files, config, opts.tags);
|
|
1216
1445
|
failOnParseErrors('coverage report', parsed.failures);
|
|
1217
1446
|
const tests = parsed.tests;
|