ado-sync 0.1.56 → 0.1.58
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/dist/__tests__/regressions.test.d.ts +1 -0
- package/dist/__tests__/regressions.test.js +140 -0
- package/dist/__tests__/regressions.test.js.map +1 -0
- package/dist/ai/generate-spec.d.ts +3 -3
- package/dist/ai/generate-spec.js +4 -1
- package/dist/ai/generate-spec.js.map +1 -1
- package/dist/ai/summarizer.d.ts +4 -4
- package/dist/ai/summarizer.js +4 -4
- package/dist/ai/summarizer.js.map +1 -1
- package/dist/azure/test-cases.d.ts +10 -1
- package/dist/azure/test-cases.js +61 -25
- package/dist/azure/test-cases.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.js +6 -1
- package/dist/config.js.map +1 -1
- package/dist/issues/ado-bugs.js +7 -3
- package/dist/issues/ado-bugs.js.map +1 -1
- package/dist/mcp-server.d.ts +34 -0
- package/dist/mcp-server.js +85 -62
- package/dist/mcp-server.js.map +1 -1
- package/dist/parsers/javascript.js +2 -1
- package/dist/parsers/javascript.js.map +1 -1
- package/dist/sync/cache.js +3 -1
- package/dist/sync/cache.js.map +1 -1
- package/dist/sync/engine.d.ts +12 -1
- package/dist/sync/engine.js +226 -165
- package/dist/sync/engine.js.map +1 -1
- package/dist/sync/publish-results.js +64 -21
- package/dist/sync/publish-results.js.map +1 -1
- package/dist/sync/writeback.js +22 -5
- package/dist/sync/writeback.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/docs/advanced.md +17 -18
- package/docs/publish-test-results.md +3 -3
- package/mkdocs.yml +39 -0
- package/package.json +17 -16
- package/requirements-docs.txt +4 -0
- package/scripts/build_site.sh +6 -0
package/dist/sync/engine.js
CHANGED
|
@@ -39,6 +39,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
39
39
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
40
40
|
};
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.failOnParseErrors = failOnParseErrors;
|
|
43
|
+
exports.buildPushDiff = buildPushDiff;
|
|
42
44
|
exports.push = push;
|
|
43
45
|
exports.pull = pull;
|
|
44
46
|
exports.status = status;
|
|
@@ -76,6 +78,9 @@ function matchesTags(test, expression) {
|
|
|
76
78
|
const tagsWithAt = test.tags.map((t) => (t.startsWith('@') ? t : `@${t}`));
|
|
77
79
|
return node.evaluate(tagsWithAt);
|
|
78
80
|
}
|
|
81
|
+
// ─── Code-type detection ──────────────────────────────────────────────────────
|
|
82
|
+
/** Local types whose test bodies are executable code and may benefit from AI summarisation. */
|
|
83
|
+
const CODE_TYPES = new Set(['javascript', 'playwright', 'puppeteer', 'cypress', 'testcafe', 'detox', 'espresso', 'xcuitest', 'flutter', 'java', 'csharp', 'python']);
|
|
79
84
|
// ─── File discovery ───────────────────────────────────────────────────────────
|
|
80
85
|
async function discoverFiles(include, exclude, configDir) {
|
|
81
86
|
const patterns = Array.isArray(include) ? include : [include];
|
|
@@ -98,7 +103,57 @@ async function discoverFiles(include, exclude, configDir) {
|
|
|
98
103
|
}
|
|
99
104
|
return [...new Set(all)].sort();
|
|
100
105
|
}
|
|
101
|
-
|
|
106
|
+
function failOnParseErrors(operation, failures) {
|
|
107
|
+
if (!failures.length)
|
|
108
|
+
return;
|
|
109
|
+
const details = failures
|
|
110
|
+
.map(({ filePath, message }) => ` - ${filePath}: ${message}`)
|
|
111
|
+
.join('\n');
|
|
112
|
+
throw new Error(`Aborting ${operation}: ${failures.length} file(s) could not be parsed.\n` +
|
|
113
|
+
`Fix the parse errors and rerun to avoid partial sync decisions.\n${details}`);
|
|
114
|
+
}
|
|
115
|
+
function supportsPullWriteback(localType) {
|
|
116
|
+
return localType === 'gherkin' || localType === 'reqnroll' || localType === 'markdown' || localType === 'csv';
|
|
117
|
+
}
|
|
118
|
+
function buildPushDiff(test, remote, config, cached) {
|
|
119
|
+
const tagPrefix = config.sync?.tagPrefix ?? 'tc';
|
|
120
|
+
const desiredAzure = (0, test_cases_1.buildAzureSyncContent)(test, config.sync?.format);
|
|
121
|
+
const localStepsText = desiredAzure.steps.map((s) => `${s.action}|${s.expected ?? ''}`).join('\n');
|
|
122
|
+
const remoteStepsText = remote.steps.map((s) => `${s.action}|${s.expected ?? ''}`).join('\n');
|
|
123
|
+
const titleChanged = remote.title !== desiredAzure.title;
|
|
124
|
+
const stepsChanged = localStepsText !== remoteStepsText;
|
|
125
|
+
const localTags = new Set(test.tags.filter((t) => !t.startsWith(tagPrefix + ':')));
|
|
126
|
+
const remoteTags = new Set(remote.tags);
|
|
127
|
+
const tagsChanged = [...localTags].some((t) => !remoteTags.has(t));
|
|
128
|
+
const localDescHash = (0, cache_1.hashString)(test.description);
|
|
129
|
+
const cachedDescHash = cached?.descriptionHash ?? '';
|
|
130
|
+
const descriptionChanged = localDescHash !== cachedDescHash;
|
|
131
|
+
const remoteDescHash = (0, cache_1.hashString)(remote.description);
|
|
132
|
+
const cachedRemoteDescHash = cached?.remoteDescriptionHash ?? '';
|
|
133
|
+
const remoteDescriptionChanged = cachedRemoteDescHash !== '' && remoteDescHash !== cachedRemoteDescHash;
|
|
134
|
+
const changedFields = [];
|
|
135
|
+
if (titleChanged)
|
|
136
|
+
changedFields.push('title');
|
|
137
|
+
if (stepsChanged)
|
|
138
|
+
changedFields.push('steps');
|
|
139
|
+
if (tagsChanged)
|
|
140
|
+
changedFields.push('tags');
|
|
141
|
+
if (descriptionChanged || remoteDescriptionChanged)
|
|
142
|
+
changedFields.push('description');
|
|
143
|
+
const diffDetail = [];
|
|
144
|
+
if (titleChanged)
|
|
145
|
+
diffDetail.push({ field: 'title', local: desiredAzure.title, remote: remote.title });
|
|
146
|
+
if (stepsChanged)
|
|
147
|
+
diffDetail.push({ field: 'steps', local: localStepsText, remote: remoteStepsText });
|
|
148
|
+
if (tagsChanged) {
|
|
149
|
+
const addedTags = [...localTags].filter((t) => !remoteTags.has(t));
|
|
150
|
+
diffDetail.push({ field: 'tags', local: addedTags.join(', '), remote: remote.tags.join(', ') });
|
|
151
|
+
}
|
|
152
|
+
if (descriptionChanged || remoteDescriptionChanged) {
|
|
153
|
+
diffDetail.push({ field: 'description', local: test.description ?? '', remote: remote.description ?? '' });
|
|
154
|
+
}
|
|
155
|
+
return { changedFields, diffDetail };
|
|
156
|
+
}
|
|
102
157
|
async function parseLocalFiles(filePaths, config, tagsFilter) {
|
|
103
158
|
const tagPrefix = config.sync?.tagPrefix ?? 'tc';
|
|
104
159
|
const linkConfigs = config.sync?.links;
|
|
@@ -106,6 +161,7 @@ async function parseLocalFiles(filePaths, config, tagsFilter) {
|
|
|
106
161
|
const attachmentsConfig = config.sync?.attachments;
|
|
107
162
|
const localCondition = config.local.condition;
|
|
108
163
|
const results = [];
|
|
164
|
+
const failures = [];
|
|
109
165
|
for (const fp of filePaths) {
|
|
110
166
|
try {
|
|
111
167
|
let tests;
|
|
@@ -210,9 +266,10 @@ async function parseLocalFiles(filePaths, config, tagsFilter) {
|
|
|
210
266
|
catch (err) {
|
|
211
267
|
const msg = err instanceof Error ? err.message : String(err);
|
|
212
268
|
console.warn(` [warn] Failed to parse ${fp}: ${msg}`);
|
|
269
|
+
failures.push({ filePath: fp, message: msg });
|
|
213
270
|
}
|
|
214
271
|
}
|
|
215
|
-
return results;
|
|
272
|
+
return { tests: results, failures };
|
|
216
273
|
}
|
|
217
274
|
// ─── Multi-plan helpers ───────────────────────────────────────────────────────
|
|
218
275
|
/**
|
|
@@ -281,14 +338,17 @@ async function push(config, configDir, opts = {}) {
|
|
|
281
338
|
if (config.testPlans?.length) {
|
|
282
339
|
// Collect all tests across plans and run AI in one pass
|
|
283
340
|
const planTests = [];
|
|
341
|
+
const parseFailures = [];
|
|
284
342
|
for (const entry of config.testPlans) {
|
|
285
343
|
const entryConfig = configForPlanEntry(config, entry);
|
|
286
344
|
const files = await discoverFiles(entryConfig.local.include, entryConfig.local.exclude, configDir);
|
|
287
|
-
const
|
|
345
|
+
const parsed = await parseLocalFiles(files, entryConfig, opts.tags);
|
|
346
|
+
parseFailures.push(...parsed.failures);
|
|
347
|
+
const tests = parsed.tests;
|
|
288
348
|
planTests.push({ entryConfig, tests });
|
|
289
349
|
}
|
|
350
|
+
failOnParseErrors('push', parseFailures);
|
|
290
351
|
if (opts.aiSummary) {
|
|
291
|
-
const CODE_TYPES = new Set(['javascript', 'playwright', 'puppeteer', 'cypress', 'testcafe', 'detox', 'espresso', 'xcuitest', 'flutter', 'java', 'csharp', 'python']);
|
|
292
352
|
const allTargets = planTests.flatMap(({ entryConfig, tests }) => CODE_TYPES.has(entryConfig.local.type) ? tests.filter(t => t.steps.length === 0 || !t.description) : []);
|
|
293
353
|
let aiDone = 0;
|
|
294
354
|
for (const { entryConfig, tests } of planTests) {
|
|
@@ -325,22 +385,25 @@ async function push(config, configDir, opts = {}) {
|
|
|
325
385
|
return pushSingle(config, configDir, opts);
|
|
326
386
|
}
|
|
327
387
|
async function pushSingle(config, configDir, opts) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
388
|
+
let resolvedTests = opts._preloadedTests ?? [];
|
|
389
|
+
let parseFailures = [];
|
|
390
|
+
if (!opts._preloadedTests) {
|
|
391
|
+
const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
|
|
392
|
+
const parsed = await parseLocalFiles(files, config, opts.tags);
|
|
393
|
+
resolvedTests = parsed.tests;
|
|
394
|
+
parseFailures = parsed.failures;
|
|
395
|
+
}
|
|
396
|
+
failOnParseErrors('push', parseFailures);
|
|
333
397
|
// AI auto-summary: for code-based local types, default to the local node-llama-cpp
|
|
334
398
|
// provider (with heuristic fallback) when no explicit aiSummary opts are provided.
|
|
335
399
|
// If no GGUF model path is set, the local provider transparently falls back to
|
|
336
400
|
// heuristic mode so the push always succeeds even without a model installed.
|
|
337
|
-
const CODE_TYPES = new Set(['javascript', 'playwright', 'puppeteer', 'cypress', 'testcafe', 'detox', 'espresso', 'xcuitest', 'flutter', 'java', 'csharp', 'python']);
|
|
338
401
|
const effectiveAiOpts = opts._preloadedTests ? undefined // AI already applied in multi-plan pre-pass
|
|
339
402
|
: opts.aiSummary ?? (CODE_TYPES.has(config.local.type) ? { provider: 'local', heuristicFallback: true } : undefined);
|
|
340
403
|
if (effectiveAiOpts) {
|
|
341
|
-
const aiTargets =
|
|
404
|
+
const aiTargets = resolvedTests.filter(t => t.steps.length === 0 || !t.description);
|
|
342
405
|
let aiDone = 0;
|
|
343
|
-
for (const test of
|
|
406
|
+
for (const test of resolvedTests) {
|
|
344
407
|
const needsSteps = test.steps.length === 0;
|
|
345
408
|
const needsDescription = !test.description;
|
|
346
409
|
if (needsSteps || needsDescription) {
|
|
@@ -424,7 +487,7 @@ async function pushSingle(config, configDir, opts) {
|
|
|
424
487
|
const markAutomated = config.sync?.markAutomated ?? false;
|
|
425
488
|
const recoveredIds = new Set(); // "filePath:line" keys
|
|
426
489
|
let preloadedRemoteTcs;
|
|
427
|
-
const unlinkedWithAtName =
|
|
490
|
+
const unlinkedWithAtName = resolvedTests.filter(t => !t.azureId && t.automatedTestName);
|
|
428
491
|
if (markAutomated && unlinkedWithAtName.length > 0) {
|
|
429
492
|
try {
|
|
430
493
|
preloadedRemoteTcs = await (0, test_cases_1.getTestCasesInSuite)(client, config);
|
|
@@ -445,7 +508,7 @@ async function pushSingle(config, configDir, opts) {
|
|
|
445
508
|
// For suites with many linked tests, fetching each TC individually is slow.
|
|
446
509
|
// Pre-fetch all linked IDs concurrently (up to 8 in-flight at once) and store
|
|
447
510
|
// them in a Map so the sync loop can look them up without extra round-trips.
|
|
448
|
-
const linkedTests =
|
|
511
|
+
const linkedTests = resolvedTests.filter((t) => t.azureId !== undefined);
|
|
449
512
|
const remoteTcCache = new Map();
|
|
450
513
|
if (linkedTests.length > 0) {
|
|
451
514
|
const CONCURRENCY = 8;
|
|
@@ -471,18 +534,100 @@ async function pushSingle(config, configDir, opts) {
|
|
|
471
534
|
let done = 0;
|
|
472
535
|
const reportProgress = (result) => {
|
|
473
536
|
results.push(result);
|
|
474
|
-
opts.onProgress?.(++done,
|
|
537
|
+
opts.onProgress?.(++done, resolvedTests.length, result);
|
|
475
538
|
};
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
539
|
+
const PUSH_CONCURRENCY = 4; // Or 8, but we don't want to overwhelm ADO with concurrent creates/updates
|
|
540
|
+
const pushQueue = [...resolvedTests];
|
|
541
|
+
const pushWorkers = Array.from({ length: Math.min(PUSH_CONCURRENCY, pushQueue.length) }, async () => {
|
|
542
|
+
while (pushQueue.length > 0) {
|
|
543
|
+
const test = pushQueue.shift();
|
|
544
|
+
if (test.azureId) {
|
|
545
|
+
try {
|
|
546
|
+
const cached = cache[test.azureId];
|
|
547
|
+
// Use pre-fetched TC from cache; fall back to live fetch on miss
|
|
548
|
+
const remote = remoteTcCache.has(test.azureId)
|
|
549
|
+
? remoteTcCache.get(test.azureId)
|
|
550
|
+
: await (0, test_cases_1.getTestCase)(client, test.azureId, titleField);
|
|
551
|
+
if (!remote) {
|
|
552
|
+
// TC was deleted from Azure — re-create it and write back the new ID.
|
|
553
|
+
let newId;
|
|
554
|
+
if (!opts.dryRun) {
|
|
555
|
+
const suiteIdOverride = byFolder
|
|
556
|
+
? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
|
|
557
|
+
: await resolveTargetSuiteFromRouting(client, config, test, suiteCache);
|
|
558
|
+
newId = await (0, test_cases_1.createTestCase)(client, test, config, suiteIdOverride, configDir);
|
|
559
|
+
createdIds.add(newId);
|
|
560
|
+
if (!disableLocal) {
|
|
561
|
+
pendingWritebacks.push({ test, newId });
|
|
562
|
+
}
|
|
563
|
+
await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, newId, test, conditionSuiteCache);
|
|
564
|
+
const created = await (0, test_cases_1.getTestCase)(client, newId, titleField);
|
|
565
|
+
if (created)
|
|
566
|
+
updateCacheEntry(cache, test, created);
|
|
567
|
+
}
|
|
568
|
+
reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId });
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
// For Scenario Outlines, local steps use <param> but Azure stores @param@.
|
|
572
|
+
// Normalise local to @param@ before comparing so outlines don't report
|
|
573
|
+
// stepsChanged on every push after the first successful sync.
|
|
574
|
+
const { changedFields, diffDetail } = buildPushDiff(test, remote, config, cached);
|
|
575
|
+
if (changedFields.length === 0) {
|
|
576
|
+
// Update cache entry even on skip (changedDate may differ due to other fields)
|
|
577
|
+
updateCacheEntry(cache, test, remote);
|
|
578
|
+
reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
// Conflict detection: remote was changed since last push AND local also differs
|
|
582
|
+
if (cached && remote.changedDate && remote.changedDate !== cached.changedDate) {
|
|
583
|
+
const relFile = path.relative(configDir, test.filePath);
|
|
584
|
+
const conflict = {
|
|
585
|
+
action: 'conflict',
|
|
586
|
+
filePath: test.filePath,
|
|
587
|
+
title: test.title,
|
|
588
|
+
azureId: test.azureId,
|
|
589
|
+
changedFields,
|
|
590
|
+
diffDetail,
|
|
591
|
+
detail: `${relFile}:${test.line} — changed fields: ${changedFields.join(', ')}`,
|
|
592
|
+
};
|
|
593
|
+
if (conflictAction === 'skip') {
|
|
594
|
+
reportProgress(conflict);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (conflictAction === 'fail') {
|
|
598
|
+
conflicts.push(conflict);
|
|
599
|
+
done++;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
// 'overwrite' — fall through to update
|
|
603
|
+
}
|
|
604
|
+
if (!opts.dryRun) {
|
|
605
|
+
await (0, test_cases_1.updateTestCase)(client, test.azureId, test, config, configDir);
|
|
606
|
+
// Ensure the TC is in the configured suite (it may not be if the suite was
|
|
607
|
+
// changed in config, or if the TC was imported with an ID but never pushed before).
|
|
608
|
+
const updateSuiteId = byFolder
|
|
609
|
+
? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
|
|
610
|
+
: await resolveTargetSuiteFromRouting(client, config, test, suiteCache) ?? config.testPlan.suiteId;
|
|
611
|
+
if (updateSuiteId) {
|
|
612
|
+
await (0, test_cases_1.addTestCaseToSuite)(client, config, test.azureId, updateSuiteId);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
await (0, test_cases_1.addTestCaseToRootSuite)(client, config, test.azureId);
|
|
616
|
+
}
|
|
617
|
+
await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, test.azureId, test, conditionSuiteCache);
|
|
618
|
+
const updated = await (0, test_cases_1.getTestCase)(client, test.azureId, titleField);
|
|
619
|
+
if (updated)
|
|
620
|
+
updateCacheEntry(cache, test, updated);
|
|
621
|
+
}
|
|
622
|
+
reportProgress({ action: 'updated', filePath: test.filePath, title: test.title, azureId: test.azureId, changedFields, diffDetail });
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
626
|
+
reportProgress({ action: 'error', filePath: test.filePath, title: test.title, azureId: test.azureId, detail: msg });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
try {
|
|
486
631
|
let newId;
|
|
487
632
|
if (!opts.dryRun) {
|
|
488
633
|
const suiteIdOverride = byFolder
|
|
@@ -494,141 +639,21 @@ async function pushSingle(config, configDir, opts) {
|
|
|
494
639
|
pendingWritebacks.push({ test, newId });
|
|
495
640
|
}
|
|
496
641
|
await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, newId, test, conditionSuiteCache);
|
|
642
|
+
// Fetch back to get changedDate for cache
|
|
497
643
|
const created = await (0, test_cases_1.getTestCase)(client, newId, titleField);
|
|
498
644
|
if (created)
|
|
499
645
|
updateCacheEntry(cache, test, created);
|
|
500
646
|
}
|
|
501
647
|
reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId });
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
// For Scenario Outlines, local steps use <param> but Azure stores @param@.
|
|
505
|
-
// Normalise local to @param@ before comparing so outlines don't report
|
|
506
|
-
// stepsChanged on every push after the first successful sync.
|
|
507
|
-
const isOutline = !!test.outlineParameters?.headers.length;
|
|
508
|
-
const localStepsText = test.steps
|
|
509
|
-
.map((s) => {
|
|
510
|
-
const raw = `${s.keyword} ${s.text}`;
|
|
511
|
-
return isOutline ? raw.replace(/<([^>]+)>/g, '@$1@') : raw;
|
|
512
|
-
})
|
|
513
|
-
.join('\n');
|
|
514
|
-
const remoteStepsText = remote.steps.map((s) => s.action).join('\n');
|
|
515
|
-
const titleChanged = remote.title !== test.title;
|
|
516
|
-
const stepsChanged = localStepsText !== remoteStepsText;
|
|
517
|
-
// For tags: push is additive (merges local into Azure), so only flag a change
|
|
518
|
-
// when the local file has tags that are NOT yet present in Azure.
|
|
519
|
-
const localTags = new Set(test.tags.filter((t) => !t.startsWith(tagPrefix + ':')));
|
|
520
|
-
const remoteTags = new Set(remote.tags);
|
|
521
|
-
const tagsChanged = [...localTags].some((t) => !remoteTags.has(t));
|
|
522
|
-
// Description: compare local hash against what we last pushed (cache), not what
|
|
523
|
-
// Azure returns (which may have been reformatted by Azure's rich-text editor).
|
|
524
|
-
const localDescHash = (0, cache_1.hashString)(test.description);
|
|
525
|
-
const cachedDescHash = cached?.descriptionHash ?? '';
|
|
526
|
-
const descriptionChanged = localDescHash !== cachedDescHash;
|
|
527
|
-
// E: also detect when the remote description changed since we last synced
|
|
528
|
-
const remoteDescHash = (0, cache_1.hashString)(remote.description);
|
|
529
|
-
const cachedRemoteDescHash = cached?.remoteDescriptionHash ?? '';
|
|
530
|
-
const remoteDescriptionChanged = cachedRemoteDescHash !== '' && remoteDescHash !== cachedRemoteDescHash;
|
|
531
|
-
// Collect which fields changed for richer reporting (D)
|
|
532
|
-
const changedFields = [];
|
|
533
|
-
if (titleChanged)
|
|
534
|
-
changedFields.push('title');
|
|
535
|
-
if (stepsChanged)
|
|
536
|
-
changedFields.push('steps');
|
|
537
|
-
if (tagsChanged)
|
|
538
|
-
changedFields.push('tags');
|
|
539
|
-
if (descriptionChanged || remoteDescriptionChanged)
|
|
540
|
-
changedFields.push('description');
|
|
541
|
-
// Build per-field diff details for structured JSON output
|
|
542
|
-
const diffDetail = [];
|
|
543
|
-
if (titleChanged)
|
|
544
|
-
diffDetail.push({ field: 'title', local: test.title, remote: remote.title });
|
|
545
|
-
if (stepsChanged)
|
|
546
|
-
diffDetail.push({ field: 'steps', local: localStepsText, remote: remoteStepsText });
|
|
547
|
-
if (tagsChanged) {
|
|
548
|
-
const addedTags = [...localTags].filter((t) => !remoteTags.has(t));
|
|
549
|
-
diffDetail.push({ field: 'tags', local: addedTags.join(', '), remote: remote.tags.join(', ') });
|
|
550
|
-
}
|
|
551
|
-
if (descriptionChanged || remoteDescriptionChanged) {
|
|
552
|
-
diffDetail.push({ field: 'description', local: test.description ?? '', remote: remote.description ?? '' });
|
|
553
648
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
reportProgress({ action: 'skipped', filePath: test.filePath, title: test.title, azureId: test.azureId });
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
// Conflict detection: remote was changed since last push AND local also differs
|
|
561
|
-
if (cached && remote.changedDate && remote.changedDate !== cached.changedDate) {
|
|
562
|
-
const relFile = path.relative(configDir, test.filePath);
|
|
563
|
-
const conflict = {
|
|
564
|
-
action: 'conflict',
|
|
565
|
-
filePath: test.filePath,
|
|
566
|
-
title: test.title,
|
|
567
|
-
azureId: test.azureId,
|
|
568
|
-
changedFields,
|
|
569
|
-
diffDetail,
|
|
570
|
-
detail: `${relFile}:${test.line} — changed fields: ${changedFields.join(', ')}`,
|
|
571
|
-
};
|
|
572
|
-
if (conflictAction === 'skip') {
|
|
573
|
-
reportProgress(conflict);
|
|
574
|
-
continue;
|
|
575
|
-
}
|
|
576
|
-
if (conflictAction === 'fail') {
|
|
577
|
-
conflicts.push(conflict);
|
|
578
|
-
done++;
|
|
579
|
-
continue;
|
|
580
|
-
}
|
|
581
|
-
// 'overwrite' — fall through to update
|
|
582
|
-
}
|
|
583
|
-
if (!opts.dryRun) {
|
|
584
|
-
await (0, test_cases_1.updateTestCase)(client, test.azureId, test, config, configDir);
|
|
585
|
-
// Ensure the TC is in the configured suite (it may not be if the suite was
|
|
586
|
-
// changed in config, or if the TC was imported with an ID but never pushed before).
|
|
587
|
-
const updateSuiteId = byFolder
|
|
588
|
-
? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
|
|
589
|
-
: await resolveTargetSuiteFromRouting(client, config, test, suiteCache) ?? config.testPlan.suiteId;
|
|
590
|
-
if (updateSuiteId) {
|
|
591
|
-
await (0, test_cases_1.addTestCaseToSuite)(client, config, test.azureId, updateSuiteId);
|
|
592
|
-
}
|
|
593
|
-
else {
|
|
594
|
-
await (0, test_cases_1.addTestCaseToRootSuite)(client, config, test.azureId);
|
|
595
|
-
}
|
|
596
|
-
await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, test.azureId, test, conditionSuiteCache);
|
|
597
|
-
updateCacheEntry(cache, test, remote);
|
|
598
|
-
}
|
|
599
|
-
reportProgress({ action: 'updated', filePath: test.filePath, title: test.title, azureId: test.azureId, changedFields, diffDetail });
|
|
600
|
-
}
|
|
601
|
-
catch (err) {
|
|
602
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
603
|
-
reportProgress({ action: 'error', filePath: test.filePath, title: test.title, azureId: test.azureId, detail: msg });
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
else {
|
|
607
|
-
try {
|
|
608
|
-
let newId;
|
|
609
|
-
if (!opts.dryRun) {
|
|
610
|
-
const suiteIdOverride = byFolder
|
|
611
|
-
? await (0, test_cases_1.getOrCreateSuiteForFile)(client, config, test.filePath, configDir, suiteCache)
|
|
612
|
-
: await resolveTargetSuiteFromRouting(client, config, test, suiteCache);
|
|
613
|
-
newId = await (0, test_cases_1.createTestCase)(client, test, config, suiteIdOverride, configDir);
|
|
614
|
-
createdIds.add(newId);
|
|
615
|
-
if (!disableLocal) {
|
|
616
|
-
pendingWritebacks.push({ test, newId });
|
|
617
|
-
}
|
|
618
|
-
await (0, test_cases_1.addTestCaseToConditionSuites)(client, config, newId, test, conditionSuiteCache);
|
|
619
|
-
// Fetch back to get changedDate for cache
|
|
620
|
-
const created = await (0, test_cases_1.getTestCase)(client, newId, titleField);
|
|
621
|
-
if (created)
|
|
622
|
-
updateCacheEntry(cache, test, created);
|
|
649
|
+
catch (err) {
|
|
650
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
651
|
+
reportProgress({ action: 'error', filePath: test.filePath, title: test.title, detail: msg });
|
|
623
652
|
}
|
|
624
|
-
reportProgress({ action: 'created', filePath: test.filePath, title: test.title, azureId: newId });
|
|
625
|
-
}
|
|
626
|
-
catch (err) {
|
|
627
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
628
|
-
reportProgress({ action: 'error', filePath: test.filePath, title: test.title, detail: msg });
|
|
629
653
|
}
|
|
630
654
|
}
|
|
631
|
-
}
|
|
655
|
+
});
|
|
656
|
+
await Promise.all(pushWorkers);
|
|
632
657
|
if (conflicts.length) {
|
|
633
658
|
const lines = conflicts.map((c) => {
|
|
634
659
|
const fields = c.changedFields?.length ? `\n Changed fields: ${c.changedFields.join(', ')}` : '';
|
|
@@ -640,7 +665,7 @@ async function pushSingle(config, configDir, opts) {
|
|
|
640
665
|
// For all other framework types this is a no-op.
|
|
641
666
|
if (!opts.dryRun && !disableLocal && config.local.type === 'playwright') {
|
|
642
667
|
const alreadyQueued = new Set(pendingWritebacks.map((wb) => `${wb.test.filePath}:${wb.test.line}`));
|
|
643
|
-
for (const test of
|
|
668
|
+
for (const test of resolvedTests) {
|
|
644
669
|
if (test.azureId && !alreadyQueued.has(`${test.filePath}:${test.line}`)) {
|
|
645
670
|
pendingWritebacks.push({ test, newId: test.azureId });
|
|
646
671
|
}
|
|
@@ -651,7 +676,7 @@ async function pushSingle(config, configDir, opts) {
|
|
|
651
676
|
// (no new TC was created). Queue them so the source annotation is restored.
|
|
652
677
|
if (!opts.dryRun && !disableLocal && recoveredIds.size > 0) {
|
|
653
678
|
const alreadyQueued = new Set(pendingWritebacks.map((wb) => `${wb.test.filePath}:${wb.test.line}`));
|
|
654
|
-
for (const test of
|
|
679
|
+
for (const test of resolvedTests) {
|
|
655
680
|
const key = `${test.filePath}:${test.line}`;
|
|
656
681
|
if (test.azureId && recoveredIds.has(key) && !alreadyQueued.has(key)) {
|
|
657
682
|
pendingWritebacks.push({ test, newId: test.azureId });
|
|
@@ -682,7 +707,7 @@ async function pushSingle(config, configDir, opts) {
|
|
|
682
707
|
// matching, otherwise fetch now. This avoids a redundant round-trip.
|
|
683
708
|
const remoteTcs = preloadedRemoteTcs ?? await (0, test_cases_1.getTestCasesInSuite)(client, config);
|
|
684
709
|
const localIds = new Set([
|
|
685
|
-
...
|
|
710
|
+
...resolvedTests.map((t) => t.azureId).filter(Boolean),
|
|
686
711
|
...createdIds,
|
|
687
712
|
]);
|
|
688
713
|
for (const remote of remoteTcs) {
|
|
@@ -724,7 +749,9 @@ async function pull(config, configDir, opts = {}) {
|
|
|
724
749
|
}
|
|
725
750
|
async function pullSingle(config, configDir, opts) {
|
|
726
751
|
const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
|
|
727
|
-
const
|
|
752
|
+
const parsed = await parseLocalFiles(files, config, opts.tags);
|
|
753
|
+
failOnParseErrors('pull', parsed.failures);
|
|
754
|
+
const tests = parsed.tests;
|
|
728
755
|
const client = await client_1.AzureClient.create(config);
|
|
729
756
|
const titleField = config.sync?.titleField ?? 'System.Title';
|
|
730
757
|
const tagPrefix = config.sync?.tagPrefix ?? 'tc';
|
|
@@ -737,6 +764,7 @@ async function pullSingle(config, configDir, opts) {
|
|
|
737
764
|
results.push(result);
|
|
738
765
|
opts.onProgress?.(++done, linked.length, result);
|
|
739
766
|
};
|
|
767
|
+
const canWriteToLocal = supportsPullWriteback(config.local.type);
|
|
740
768
|
for (const test of linked) {
|
|
741
769
|
try {
|
|
742
770
|
const remote = await (0, test_cases_1.getTestCase)(client, test.azureId, titleField);
|
|
@@ -751,9 +779,10 @@ async function pullSingle(config, configDir, opts) {
|
|
|
751
779
|
});
|
|
752
780
|
continue;
|
|
753
781
|
}
|
|
754
|
-
const
|
|
755
|
-
const
|
|
756
|
-
const
|
|
782
|
+
const desiredAzure = (0, test_cases_1.buildAzureSyncContent)(test, config.sync?.format);
|
|
783
|
+
const titleChanged = remote.title !== desiredAzure.title;
|
|
784
|
+
const remoteStepsText = remote.steps.map((s) => `${s.action}|${s.expected ?? ''}`).join('\n');
|
|
785
|
+
const localStepsText = desiredAzure.steps.map((s) => `${s.action}|${s.expected ?? ''}`).join('\n');
|
|
757
786
|
const stepsChanged = remoteStepsText !== localStepsText;
|
|
758
787
|
const descriptionChanged = (remote.description ?? '') !== (test.description ?? '');
|
|
759
788
|
if (!titleChanged && !stepsChanged && !descriptionChanged) {
|
|
@@ -761,10 +790,37 @@ async function pullSingle(config, configDir, opts) {
|
|
|
761
790
|
continue;
|
|
762
791
|
}
|
|
763
792
|
if (!opts.dryRun) {
|
|
764
|
-
if (
|
|
765
|
-
|
|
793
|
+
if (disableLocal) {
|
|
794
|
+
reportProgress({
|
|
795
|
+
action: 'skipped',
|
|
796
|
+
filePath: test.filePath,
|
|
797
|
+
title: remote.title,
|
|
798
|
+
azureId: test.azureId,
|
|
799
|
+
detail: [
|
|
800
|
+
titleChanged && 'title',
|
|
801
|
+
stepsChanged && 'steps',
|
|
802
|
+
descriptionChanged && 'description',
|
|
803
|
+
].filter(Boolean).join(', ') + ' changed (local changes skipped)',
|
|
804
|
+
});
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (!canWriteToLocal) {
|
|
808
|
+
reportProgress({
|
|
809
|
+
action: 'error',
|
|
810
|
+
filePath: test.filePath,
|
|
811
|
+
title: test.title,
|
|
812
|
+
azureId: test.azureId,
|
|
813
|
+
detail: `Pull is not supported for local.type "${config.local.type}".`,
|
|
814
|
+
});
|
|
815
|
+
continue;
|
|
766
816
|
}
|
|
767
|
-
|
|
817
|
+
applyRemoteToLocal(test, remote.title, remote.steps.map((s) => ({ keyword: 'Step', text: s.action, expected: s.expected })), remote.description, config.local.type, tagPrefix);
|
|
818
|
+
updateCacheEntry(cache, {
|
|
819
|
+
...test,
|
|
820
|
+
title: remote.title,
|
|
821
|
+
description: remote.description,
|
|
822
|
+
steps: remote.steps.map((s) => ({ keyword: 'Step', text: s.action, expected: s.expected })),
|
|
823
|
+
}, remote);
|
|
768
824
|
}
|
|
769
825
|
reportProgress({
|
|
770
826
|
action: 'pulled',
|
|
@@ -875,8 +931,9 @@ function stripHtml(html) {
|
|
|
875
931
|
.replace(/<\/p>/gi, '\n')
|
|
876
932
|
.replace(/<[^>]+>/g, '')
|
|
877
933
|
.replace(/&/g, '&')
|
|
878
|
-
|
|
879
|
-
.replace(/&
|
|
934
|
+
// Do not decode < or > to avoid reintroducing HTML tag delimiters such as <script>.
|
|
935
|
+
// .replace(/</g, '<')
|
|
936
|
+
// .replace(/>/g, '>')
|
|
880
937
|
.replace(/"/g, '"')
|
|
881
938
|
.replace(/ /g, ' ')
|
|
882
939
|
.replace(/\n{3,}/g, '\n\n')
|
|
@@ -1071,7 +1128,9 @@ function buildPullMarkdownContent(tc, tagPrefix) {
|
|
|
1071
1128
|
*/
|
|
1072
1129
|
async function detectStaleTestCases(config, configDir, opts = {}) {
|
|
1073
1130
|
const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
|
|
1074
|
-
const
|
|
1131
|
+
const parsed = await parseLocalFiles(files, config, opts.tags);
|
|
1132
|
+
failOnParseErrors('stale test case detection', parsed.failures);
|
|
1133
|
+
const tests = parsed.tests;
|
|
1075
1134
|
const localIds = new Set(tests.map((t) => t.azureId).filter(Boolean));
|
|
1076
1135
|
const client = await client_1.AzureClient.create(config);
|
|
1077
1136
|
const plans = config.testPlans?.length ? config.testPlans : [config.testPlan];
|
|
@@ -1100,7 +1159,9 @@ async function detectStaleTestCases(config, configDir, opts = {}) {
|
|
|
1100
1159
|
*/
|
|
1101
1160
|
async function coverageReport(config, configDir, opts = {}) {
|
|
1102
1161
|
const files = await discoverFiles(config.local.include, config.local.exclude, configDir);
|
|
1103
|
-
const
|
|
1162
|
+
const parsed = await parseLocalFiles(files, config, opts.tags);
|
|
1163
|
+
failOnParseErrors('coverage report', parsed.failures);
|
|
1164
|
+
const tests = parsed.tests;
|
|
1104
1165
|
const linked = tests.filter((t) => t.azureId !== undefined);
|
|
1105
1166
|
const unlinked = tests.filter((t) => t.azureId === undefined);
|
|
1106
1167
|
// Find story link prefix from sync.links config; default to 'story'
|