ado-sync 0.1.56 → 0.1.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/__tests__/regressions.test.d.ts +1 -0
  2. package/dist/__tests__/regressions.test.js +140 -0
  3. package/dist/__tests__/regressions.test.js.map +1 -0
  4. package/dist/ai/generate-spec.d.ts +3 -3
  5. package/dist/ai/generate-spec.js +4 -1
  6. package/dist/ai/generate-spec.js.map +1 -1
  7. package/dist/ai/summarizer.d.ts +4 -4
  8. package/dist/ai/summarizer.js +4 -4
  9. package/dist/ai/summarizer.js.map +1 -1
  10. package/dist/azure/test-cases.d.ts +10 -1
  11. package/dist/azure/test-cases.js +61 -25
  12. package/dist/azure/test-cases.js.map +1 -1
  13. package/dist/cli.js +1 -1
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.js +6 -1
  16. package/dist/config.js.map +1 -1
  17. package/dist/issues/ado-bugs.js +7 -3
  18. package/dist/issues/ado-bugs.js.map +1 -1
  19. package/dist/mcp-server.d.ts +34 -0
  20. package/dist/mcp-server.js +85 -62
  21. package/dist/mcp-server.js.map +1 -1
  22. package/dist/parsers/javascript.js +2 -1
  23. package/dist/parsers/javascript.js.map +1 -1
  24. package/dist/sync/cache.js +3 -1
  25. package/dist/sync/cache.js.map +1 -1
  26. package/dist/sync/engine.d.ts +12 -1
  27. package/dist/sync/engine.js +226 -165
  28. package/dist/sync/engine.js.map +1 -1
  29. package/dist/sync/publish-results.js +64 -21
  30. package/dist/sync/publish-results.js.map +1 -1
  31. package/dist/sync/writeback.js +22 -5
  32. package/dist/sync/writeback.js.map +1 -1
  33. package/dist/types.d.ts +2 -2
  34. package/docs/advanced.md +17 -18
  35. package/docs/publish-test-results.md +3 -3
  36. package/mkdocs.yml +39 -0
  37. package/package.json +17 -16
  38. package/requirements-docs.txt +4 -0
  39. package/scripts/build_site.sh +6 -0
@@ -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
- // ─── Local file parsing ───────────────────────────────────────────────────────
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 tests = await parseLocalFiles(files, entryConfig, opts.tags);
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
- const files = opts._preloadedTests
329
- ? []
330
- : await discoverFiles(config.local.include, config.local.exclude, configDir);
331
- const tests = opts._preloadedTests
332
- ?? await parseLocalFiles(files, config, opts.tags);
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 = tests.filter(t => t.steps.length === 0 || !t.description);
404
+ const aiTargets = resolvedTests.filter(t => t.steps.length === 0 || !t.description);
342
405
  let aiDone = 0;
343
- for (const test of tests) {
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 = tests.filter(t => !t.azureId && t.automatedTestName);
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 = tests.filter((t) => t.azureId !== undefined);
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, tests.length, result);
537
+ opts.onProgress?.(++done, resolvedTests.length, result);
475
538
  };
476
- for (const test of tests) {
477
- if (test.azureId) {
478
- try {
479
- const cached = cache[test.azureId];
480
- // Use pre-fetched TC from cache; fall back to live fetch on miss
481
- const remote = remoteTcCache.has(test.azureId)
482
- ? remoteTcCache.get(test.azureId)
483
- : await (0, test_cases_1.getTestCase)(client, test.azureId, titleField);
484
- if (!remote) {
485
- // TC was deleted from Azure — re-create it and write back the new ID.
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
- if (changedFields.length === 0) {
555
- // Update cache entry even on skip (changedDate may differ due to other fields)
556
- updateCacheEntry(cache, test, remote);
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 tests) {
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 tests) {
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
- ...tests.map((t) => t.azureId).filter(Boolean),
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 tests = await parseLocalFiles(files, config, opts.tags);
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 titleChanged = remote.title !== test.title;
755
- const remoteStepsText = remote.steps.map((s) => s.action + '|' + s.expected).join('\n');
756
- const localStepsText = test.steps.map((s) => s.keyword + ' ' + s.text + '|' + (s.expected ?? '')).join('\n');
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 (!disableLocal) {
765
- applyRemoteToLocal(test, remote.title, remote.steps.map((s) => ({ keyword: 'Step', text: s.action, expected: s.expected })), remote.description, config.local.type, tagPrefix);
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
- updateCacheEntry(cache, test, remote);
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(/&amp;/g, '&')
878
- .replace(/&lt;/g, '<')
879
- .replace(/&gt;/g, '>')
934
+ // Do not decode &lt; or &gt; to avoid reintroducing HTML tag delimiters such as <script>.
935
+ // .replace(/&lt;/g, '<')
936
+ // .replace(/&gt;/g, '>')
880
937
  .replace(/&quot;/g, '"')
881
938
  .replace(/&nbsp;/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 tests = await parseLocalFiles(files, config, opts.tags);
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 tests = await parseLocalFiles(files, config, opts.tags);
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'