ado-sync 0.1.24 → 0.1.27

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 (46) hide show
  1. package/README.md +240 -678
  2. package/dist/azure/client.d.ts +3 -0
  3. package/dist/azure/client.js +6 -0
  4. package/dist/azure/client.js.map +1 -1
  5. package/dist/azure/test-cases.d.ts +8 -3
  6. package/dist/azure/test-cases.js +406 -25
  7. package/dist/azure/test-cases.js.map +1 -1
  8. package/dist/cli.js +51 -4
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config.js +111 -5
  11. package/dist/config.js.map +1 -1
  12. package/dist/parsers/csharp.d.ts +30 -0
  13. package/dist/parsers/csharp.js +257 -0
  14. package/dist/parsers/csharp.js.map +1 -0
  15. package/dist/parsers/gherkin.d.ts +4 -1
  16. package/dist/parsers/gherkin.js +19 -4
  17. package/dist/parsers/gherkin.js.map +1 -1
  18. package/dist/parsers/java.d.ts +40 -0
  19. package/dist/parsers/java.js +329 -0
  20. package/dist/parsers/java.js.map +1 -0
  21. package/dist/parsers/javascript.d.ts +33 -0
  22. package/dist/parsers/javascript.js +261 -0
  23. package/dist/parsers/javascript.js.map +1 -0
  24. package/dist/parsers/markdown.d.ts +4 -1
  25. package/dist/parsers/markdown.js +5 -3
  26. package/dist/parsers/markdown.js.map +1 -1
  27. package/dist/parsers/python.d.ts +34 -0
  28. package/dist/parsers/python.js +305 -0
  29. package/dist/parsers/python.js.map +1 -0
  30. package/dist/parsers/shared.d.ts +18 -0
  31. package/dist/parsers/shared.js +40 -0
  32. package/dist/parsers/shared.js.map +1 -1
  33. package/dist/sync/engine.js +114 -5
  34. package/dist/sync/engine.js.map +1 -1
  35. package/dist/sync/publish-results.d.ts +49 -0
  36. package/dist/sync/publish-results.js +476 -0
  37. package/dist/sync/publish-results.js.map +1 -0
  38. package/dist/sync/writeback.d.ts +57 -1
  39. package/dist/sync/writeback.js +243 -0
  40. package/dist/sync/writeback.js.map +1 -1
  41. package/dist/types.d.ts +159 -2
  42. package/docs/advanced.md +350 -0
  43. package/docs/configuration.md +293 -0
  44. package/docs/publish-test-results.md +317 -0
  45. package/docs/spec-formats.md +595 -0
  46. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import * as CoreApi from 'azure-devops-node-api/CoreApi';
2
+ import * as TestApi from 'azure-devops-node-api/TestApi';
2
3
  import * as TestPlanApi from 'azure-devops-node-api/TestPlanApi';
3
4
  import * as WorkItemTrackingApi from 'azure-devops-node-api/WorkItemTrackingApi';
4
5
  import { SyncConfig } from '../types';
@@ -7,11 +8,13 @@ export declare class AzureClient {
7
8
  private connection;
8
9
  private _witApi;
9
10
  private _testPlanApi;
11
+ private _testApi;
10
12
  private _coreApi;
11
13
  private constructor();
12
14
  static create(config: SyncConfig): Promise<AzureClient>;
13
15
  private connect;
14
16
  getWitApi(): Promise<WorkItemTrackingApi.IWorkItemTrackingApi>;
15
17
  getTestPlanApi(): Promise<TestPlanApi.ITestPlanApi>;
18
+ getTestApi(): Promise<TestApi.ITestApi>;
16
19
  getCoreApi(): Promise<CoreApi.ICoreApi>;
17
20
  }
@@ -42,6 +42,7 @@ class AzureClient {
42
42
  connection;
43
43
  _witApi;
44
44
  _testPlanApi;
45
+ _testApi;
45
46
  _coreApi;
46
47
  constructor(config) {
47
48
  this.config = config;
@@ -78,6 +79,11 @@ class AzureClient {
78
79
  this._testPlanApi = await this.connection.getTestPlanApi();
79
80
  return this._testPlanApi;
80
81
  }
82
+ async getTestApi() {
83
+ if (!this._testApi)
84
+ this._testApi = await this.connection.getTestApi();
85
+ return this._testApi;
86
+ }
81
87
  async getCoreApi() {
82
88
  if (!this._coreApi)
83
89
  this._coreApi = await this.connection.getCoreApi();
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/azure/client.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8CAAyD;AACzD,6DAA+C;AAC/C,iEAA+C;AAQ/C,MAAa,WAAW;IAMM;IALpB,UAAU,CAAU;IACpB,OAAO,CAA4C;IACnD,YAAY,CAA4B;IACxC,QAAQ,CAAoB;IAEpC,YAA4B,MAAkB;QAAlB,WAAM,GAAN,MAAM,CAAY;IAAG,CAAC;IAElD,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAkB;QACpC,MAAM,MAAM,GAAG,IAAI,WAAW,CAAC,MAAM,CAAC,CAAC;QACvC,MAAM,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,OAAO,MAAM,CAAC;IAChB,CAAC;IAEO,KAAK,CAAC,OAAO;QACnB,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC;QAErC,IAAI,WAA4B,CAAC;QAEjC,IAAI,IAAI,CAAC,IAAI,KAAK,iBAAiB,EAAE,CAAC;YACpC,MAAM,UAAU,GAAG,IAAI,iCAAsB,EAAE,CAAC;YAChD,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,gBAAiB,CAAC,CAAC;YAChE,WAAW,GAAG,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACpD,CAAC;aAAM,IAAI,IAAI,CAAC,IAAI,KAAK,aAAa,EAAE,CAAC;YACvC,WAAW,GAAG,KAAK,CAAC,gBAAgB,CAAC,IAAI,CAAC,KAAM,CAAC,CAAC;QACpD,CAAC;aAAM,CAAC;YACN,MAAM;YACN,WAAW,GAAG,KAAK,CAAC,6BAA6B,CAAC,IAAI,CAAC,KAAM,CAAC,CAAC;QACjE,CAAC;QAED,IAAI,CAAC,UAAU,GAAG,IAAI,8BAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,SAAS;QACb,IAAI,CAAC,IAAI,CAAC,OAAO;YAAE,IAAI,CAAC,OAAO,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,sBAAsB,EAAE,CAAC;QACjF,OAAO,IAAI,CAAC,OAAO,CAAC;IACtB,CAAC;IAED,KAAK,CAAC,cAAc;QAClB,IAAI,CAAC,IAAI,CAAC,YAAY;YAAE,IAAI,CAAC,YAAY,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,cAAc,EAAE,CAAC;QACnF,OAAO,IAAI,CAAC,YAAY,CAAC;IAC3B,CAAC;IAED,KAAK,CAAC,UAAU;QACd,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,IAAI,CAAC,QAAQ,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,UAAU,EAAE,CAAC;QACvE,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;CACF;AA/CD,kCA+CC"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/azure/client.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8CAAyD;AACzD,6DAA+C;AAC/C,iEAA+C;AAS/C,MAAa,WAAW;IAOM;IANpB,UAAU,CAAU;IACpB,OAAO,CAA4C;IACnD,YAAY,CAA4B;IACxC,QAAQ,CAAoB;IAC5B,QAAQ,CAAoB;IAEpC,YAA4B,MAAkB;QAAlB,WAAM,GAAN,MAAM,CAAY;IAAG,CAAC;IAElD,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAkB;QACpC,MAAM,MAAM,GAAG,IAAI,WAAW,CAAC,MAAM,CAAC,CAAC;QACvC,MAAM,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,OAAO,MAAM,CAAC;IAChB,CAAC;IAEO,KAAK,CAAC,OAAO;QACnB,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC;QAErC,IAAI,WAA4B,CAAC;QAEjC,IAAI,IAAI,CAAC,IAAI,KAAK,iBAAiB,EAAE,CAAC;YACpC,MAAM,UAAU,GAAG,IAAI,iCAAsB,EAAE,CAAC;YAChD,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,gBAAiB,CAAC,CAAC;YAChE,WAAW,GAAG,KAAK,CAAC,gBAAgB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACpD,CAAC;aAAM,IAAI,IAAI,CAAC,IAAI,KAAK,aAAa,EAAE,CAAC;YACvC,WAAW,GAAG,KAAK,CAAC,gBAAgB,CAAC,IAAI,CAAC,KAAM,CAAC,CAAC;QACpD,CAAC;aAAM,CAAC;YACN,MAAM;YACN,WAAW,GAAG,KAAK,CAAC,6BAA6B,CAAC,IAAI,CAAC,KAAM,CAAC,CAAC;QACjE,CAAC;QAED,IAAI,CAAC,UAAU,GAAG,IAAI,8BAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,SAAS;QACb,IAAI,CAAC,IAAI,CAAC,OAAO;YAAE,IAAI,CAAC,OAAO,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,sBAAsB,EAAE,CAAC;QACjF,OAAO,IAAI,CAAC,OAAO,CAAC;IACtB,CAAC;IAED,KAAK,CAAC,cAAc;QAClB,IAAI,CAAC,IAAI,CAAC,YAAY;YAAE,IAAI,CAAC,YAAY,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,cAAc,EAAE,CAAC;QACnF,OAAO,IAAI,CAAC,YAAY,CAAC;IAC3B,CAAC;IAED,KAAK,CAAC,UAAU;QACd,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,IAAI,CAAC,QAAQ,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,UAAU,EAAE,CAAC;QACvE,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,KAAK,CAAC,UAAU;QACd,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,IAAI,CAAC,QAAQ,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,UAAU,EAAE,CAAC;QACvE,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;CACF;AArDD,kCAqDC"}
@@ -16,16 +16,21 @@
16
16
  * For Scenario Outlines, parameter data is stored in:
17
17
  * Microsoft.VSTS.TCM.LocalDataSource (NewDataSet XML)
18
18
  */
19
- import { AzureTestCase, ParsedTest, SyncConfig } from '../types';
19
+ import { AzureTestCase, CustomizationsConfig, ParsedTest, SyncConfig } from '../types';
20
20
  import { AzureClient } from './client';
21
+ /**
22
+ * Process tags for push: apply tag mapping and filter ignored tags.
23
+ * Returns the transformed tags list ready for Azure DevOps.
24
+ */
25
+ export declare function processTagsForPush(tags: string[], tagPrefix: string, customizations?: CustomizationsConfig): string[];
21
26
  /**
22
27
  * Get or create a nested suite matching the folder path of the given file.
23
28
  * Uses suiteCache (Map<relPath, suiteId>) to avoid redundant API calls.
24
29
  */
25
30
  export declare function getOrCreateSuiteForFile(client: AzureClient, config: SyncConfig, filePath: string, configDir: string, suiteCache: Map<string, number>): Promise<number>;
26
31
  export declare function getTestCase(client: AzureClient, id: number, titleField?: string): Promise<AzureTestCase | null>;
27
- export declare function createTestCase(client: AzureClient, test: ParsedTest, config: SyncConfig, suiteIdOverride?: number): Promise<number>;
28
- export declare function updateTestCase(client: AzureClient, id: number, test: ParsedTest, config: SyncConfig): Promise<void>;
32
+ export declare function createTestCase(client: AzureClient, test: ParsedTest, config: SyncConfig, suiteIdOverride?: number, configDir?: string): Promise<number>;
33
+ export declare function updateTestCase(client: AzureClient, id: number, test: ParsedTest, config: SyncConfig, configDir?: string): Promise<void>;
29
34
  export declare function updateLocalFromAzure(client: AzureClient, id: number, titleField?: string): Promise<AzureTestCase | null>;
30
35
  /**
31
36
  * Tag an orphaned Azure TC with 'ado-sync:removed' so teams can review and
@@ -54,6 +54,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
54
54
  return (mod && mod.__esModule) ? mod : { "default": mod };
55
55
  };
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.processTagsForPush = processTagsForPush;
57
58
  exports.getOrCreateSuiteForFile = getOrCreateSuiteForFile;
58
59
  exports.getTestCase = getTestCase;
59
60
  exports.createTestCase = createTestCase;
@@ -67,6 +68,8 @@ exports.getTestCasesInSuite = getTestCasesInSuite;
67
68
  const tag_expressions_1 = __importDefault(require("@cucumber/tag-expressions"));
68
69
  const crypto = __importStar(require("crypto"));
69
70
  const fast_xml_parser_1 = require("fast-xml-parser");
71
+ const fs = __importStar(require("fs"));
72
+ const glob_1 = require("glob");
70
73
  const path = __importStar(require("path"));
71
74
  // ─── XML helpers ─────────────────────────────────────────────────────────────
72
75
  function escapeHtml(str) {
@@ -223,7 +226,9 @@ function buildAutomationPatches(test, config, op) {
223
226
  const ext = path.extname(test.filePath);
224
227
  const fileBase = sanitizeTestName(path.basename(test.filePath, ext));
225
228
  const scenarioName = sanitizeTestName(test.title);
226
- const automatedTestName = `${fileBase}.${scenarioName}`;
229
+ // For C# tests, automatedTestName is the FQMN (Namespace.Class.Method) provided by the parser.
230
+ // For other types it falls back to the file-basename.scenario-title convention.
231
+ const automatedTestName = test.automatedTestName ?? `${fileBase}.${scenarioName}`;
227
232
  const automatedTestStorage = path.basename(test.filePath);
228
233
  const automatedTestId = deterministicGuid(`${test.filePath}::${test.title}`);
229
234
  return [
@@ -243,6 +248,354 @@ function tagsFromString(raw) {
243
248
  .map((t) => t.trim())
244
249
  .filter(Boolean);
245
250
  }
251
+ // ─── Tag transformation helpers ──────────────────────────────────────────────
252
+ /**
253
+ * Apply tag text map transformation (character/substring replacements).
254
+ * e.g. textMap { "_": " " } transforms "my_tag" → "my tag"
255
+ */
256
+ function applyTagTextMap(tags, textMap) {
257
+ return tags.map((t) => {
258
+ let result = t;
259
+ for (const [from, to] of Object.entries(textMap)) {
260
+ result = result.split(from).join(to);
261
+ }
262
+ return result;
263
+ });
264
+ }
265
+ /**
266
+ * Filter tags that should be ignored from removal during push.
267
+ * Patterns support trailing wildcard (e.g. "ado-tag*").
268
+ */
269
+ function isIgnoredTag(tag, ignorePatterns) {
270
+ for (const pattern of ignorePatterns) {
271
+ if (pattern.endsWith('*')) {
272
+ if (tag.startsWith(pattern.slice(0, -1)))
273
+ return true;
274
+ }
275
+ else if (tag === pattern) {
276
+ return true;
277
+ }
278
+ }
279
+ return false;
280
+ }
281
+ /**
282
+ * Process tags for push: apply tag mapping and filter ignored tags.
283
+ * Returns the transformed tags list ready for Azure DevOps.
284
+ */
285
+ function processTagsForPush(tags, tagPrefix, customizations) {
286
+ let processed = tags.filter((t) => !t.startsWith(tagPrefix + ':'));
287
+ // Apply tag text map transformation
288
+ if (customizations?.tagTextMapTransformation?.enabled && customizations.tagTextMapTransformation.textMap) {
289
+ processed = applyTagTextMap(processed, customizations.tagTextMapTransformation.textMap);
290
+ }
291
+ return processed;
292
+ }
293
+ // ─── State change helpers ────────────────────────────────────────────────────
294
+ /**
295
+ * Build the PATCH operation to set the TC state when the scenario has changed.
296
+ * Only applies when state.setValueOnChangeTo is configured.
297
+ * Respects an optional state.condition tag expression.
298
+ */
299
+ function buildStateChangePatches(test, stateConfig, op) {
300
+ if (!stateConfig?.setValueOnChangeTo)
301
+ return [];
302
+ // Check condition: if specified, only apply state change when tags match
303
+ if (stateConfig.condition) {
304
+ const tagsWithAt = test.tags.map((t) => (t.startsWith('@') ? t : `@${t}`));
305
+ const node = (0, tag_expressions_1.default)(stateConfig.condition);
306
+ if (!node.evaluate(tagsWithAt))
307
+ return [];
308
+ }
309
+ return [
310
+ { op, path: '/fields/System.State', value: stateConfig.setValueOnChangeTo },
311
+ ];
312
+ }
313
+ // ─── Field update helpers ────────────────────────────────────────────────────
314
+ /**
315
+ * Expand placeholders in a field update value string.
316
+ * Supported placeholders:
317
+ * {scenario-name} → test title
318
+ * {feature-name} → file basename without extension
319
+ * {feature-file} → file basename
320
+ * {scenario-description} → test description
321
+ * {1}, {2}, etc. → wildcard captures from condition
322
+ */
323
+ function expandFieldPlaceholders(value, test, wildcardCaptures = []) {
324
+ let result = value;
325
+ result = result.replace(/\{scenario-name\}/g, test.title);
326
+ result = result.replace(/\{feature-name\}/g, path.basename(test.filePath, path.extname(test.filePath)));
327
+ result = result.replace(/\{feature-file\}/g, path.basename(test.filePath));
328
+ result = result.replace(/\{scenario-description\}/g, test.description ?? '');
329
+ // Replace numbered captures {1}, {2}, ...
330
+ for (let i = 0; i < wildcardCaptures.length; i++) {
331
+ result = result.replace(new RegExp(`\\{${i + 1}\\}`, 'g'), wildcardCaptures[i]);
332
+ }
333
+ return result;
334
+ }
335
+ /**
336
+ * Evaluate a tag condition with wildcard support.
337
+ * e.g. condition "@priority:*" with tags ["priority:high"] → captures: ["high"]
338
+ * Returns null if no match, or the array of wildcard captures if matched.
339
+ */
340
+ function evaluateWildcardCondition(condition, tags) {
341
+ const tagsWithAt = tags.map((t) => (t.startsWith('@') ? t : `@${t}`));
342
+ // Check for wildcard patterns in the condition
343
+ if (!condition.includes('*')) {
344
+ // Simple tag expression evaluation
345
+ const node = (0, tag_expressions_1.default)(condition);
346
+ return node.evaluate(tagsWithAt) ? [] : null;
347
+ }
348
+ // Extract wildcard tag patterns from condition
349
+ const tagPatterns = condition.match(/@[\w-]+(?::[\w-]*\*[\w-]*)+|@[\w-]*\*[\w-]*/g) ?? [];
350
+ const captures = [];
351
+ for (const pattern of tagPatterns) {
352
+ const cleanPattern = pattern.startsWith('@') ? pattern.slice(1) : pattern;
353
+ // Convert wildcard pattern to regex: @priority:* → priority:(.+)
354
+ const regexStr = '^' + cleanPattern.replace(/\*/g, '(.+)') + '$';
355
+ const regex = new RegExp(regexStr);
356
+ let matched = false;
357
+ for (const tag of tags) {
358
+ const m = tag.match(regex);
359
+ if (m) {
360
+ captures.push(...m.slice(1));
361
+ matched = true;
362
+ break;
363
+ }
364
+ }
365
+ if (!matched)
366
+ return null;
367
+ }
368
+ // Also evaluate the non-wildcard part of the expression
369
+ // Replace wildcard tags with a dummy tag name for expression evaluation
370
+ let evalExpr = condition;
371
+ for (const pattern of tagPatterns) {
372
+ evalExpr = evalExpr.replace(pattern, '@__wildcard_matched__');
373
+ }
374
+ // Add the dummy tag so it evaluates the rest of the expression correctly
375
+ const evalTags = [...tagsWithAt, '@__wildcard_matched__'];
376
+ try {
377
+ const node = (0, tag_expressions_1.default)(evalExpr);
378
+ if (!node.evaluate(evalTags))
379
+ return null;
380
+ }
381
+ catch {
382
+ // If the expression can't be parsed after replacement, trust the wildcard match
383
+ }
384
+ return captures;
385
+ }
386
+ /**
387
+ * Build PATCH operations for field updates.
388
+ * Handles simple values, conditional values, wildcard tag matches, and update events.
389
+ */
390
+ function buildFieldUpdatePatches(test, fieldUpdates, isCreate, op) {
391
+ if (!fieldUpdates)
392
+ return [];
393
+ const patches = [];
394
+ for (const [field, spec] of Object.entries(fieldUpdates)) {
395
+ // Normalize field reference: if it doesn't contain a dot, assume it's a display name
396
+ const fieldPath = field.includes('.') ? field : field;
397
+ if (typeof spec === 'string') {
398
+ // Simple value — always update
399
+ const value = expandFieldPlaceholders(spec, test);
400
+ patches.push({ op, path: `/fields/${fieldPath}`, value });
401
+ continue;
402
+ }
403
+ const update = spec;
404
+ // Check update event
405
+ if (update.update === 'onCreate' && !isCreate)
406
+ continue;
407
+ if (update.update === 'onChange' && isCreate)
408
+ continue;
409
+ // Handle conditionalValue (switch-style)
410
+ if (update.conditionalValue) {
411
+ let resolved = false;
412
+ for (const [condExpr, condValue] of Object.entries(update.conditionalValue)) {
413
+ if (condExpr === 'otherwise')
414
+ continue;
415
+ const captures = evaluateWildcardCondition(condExpr, test.tags);
416
+ if (captures !== null) {
417
+ const value = expandFieldPlaceholders(condValue, test, captures);
418
+ patches.push({ op, path: `/fields/${fieldPath}`, value });
419
+ resolved = true;
420
+ break;
421
+ }
422
+ }
423
+ if (!resolved && update.conditionalValue['otherwise'] !== undefined) {
424
+ const value = expandFieldPlaceholders(update.conditionalValue['otherwise'], test);
425
+ patches.push({ op, path: `/fields/${fieldPath}`, value });
426
+ }
427
+ continue;
428
+ }
429
+ // Handle single value with optional condition
430
+ if (update.condition) {
431
+ const captures = evaluateWildcardCondition(update.condition, test.tags);
432
+ if (captures === null)
433
+ continue;
434
+ if (update.value !== undefined) {
435
+ const value = expandFieldPlaceholders(update.value, test, captures);
436
+ patches.push({ op, path: `/fields/${fieldPath}`, value });
437
+ }
438
+ }
439
+ else if (update.value !== undefined) {
440
+ const value = expandFieldPlaceholders(update.value, test);
441
+ patches.push({ op, path: `/fields/${fieldPath}`, value });
442
+ }
443
+ }
444
+ return patches;
445
+ }
446
+ /**
447
+ * Build PATCH operations for field defaults (applied on create only).
448
+ */
449
+ function buildFieldDefaultPatches(customizations) {
450
+ if (!customizations?.fieldDefaults?.enabled)
451
+ return [];
452
+ const patches = [];
453
+ for (const [field, value] of Object.entries(customizations.fieldDefaults.defaultValues)) {
454
+ patches.push({ op: 'add', path: `/fields/${field}`, value });
455
+ }
456
+ return patches;
457
+ }
458
+ // ─── Format helpers ──────────────────────────────────────────────────────────
459
+ /**
460
+ * Apply format configuration to step conversion.
461
+ * Handles useExpectedResult, prefixBackgroundSteps, syncDataTableAsText, emptyActionValue, etc.
462
+ */
463
+ function applyFormatToSteps(steps, formatConfig) {
464
+ const useExpected = formatConfig?.useExpectedResult ?? false;
465
+ const emptyAction = formatConfig?.emptyActionValue;
466
+ const emptyExpected = formatConfig?.emptyExpectedResultValue;
467
+ const prefixBg = formatConfig?.prefixBackgroundSteps ?? true;
468
+ const dataTableAsText = formatConfig?.syncDataTableAsText ?? false;
469
+ return steps
470
+ .filter((s) => {
471
+ // When prefixBackgroundSteps is false, exclude background steps entirely
472
+ if (s.isBackground && !prefixBg)
473
+ return false;
474
+ return true;
475
+ })
476
+ .map((s) => {
477
+ const bgPrefix = s.isBackground ? 'Background: ' : '';
478
+ const rawAction = `${bgPrefix}${s.keyword} ${s.text}`.trim();
479
+ let action = rawAction || emptyAction || '';
480
+ let expected = s.expected ?? '';
481
+ // When useExpectedResult is true, Then/Verify steps go to expected column
482
+ // (background steps are not subject to this transformation)
483
+ if (useExpected && !s.isBackground && (s.keyword === 'Then' || s.keyword === 'Verify')) {
484
+ expected = s.text;
485
+ action = emptyAction || '';
486
+ }
487
+ if (!expected && emptyExpected) {
488
+ expected = emptyExpected;
489
+ }
490
+ // Append data table rows as plain text when syncDataTableAsText is enabled
491
+ if (dataTableAsText && s.dataTable?.length) {
492
+ const tableText = s.dataTable.map((row) => `| ${row.join(' | ')} |`).join('\n');
493
+ action = action ? `${action}\n${tableText}` : tableText;
494
+ }
495
+ return { action, expected };
496
+ });
497
+ }
498
+ /**
499
+ * Optionally append a "Parameters: @p1@, @p2@, ..." step for parametrized TCs.
500
+ * The step is appended in-place to the steps array.
501
+ *
502
+ * - 'always' → always append
503
+ * - 'never' → never append
504
+ * - 'whenUnusedParameters' → append only when at least one header has no @param@ reference in any step (default)
505
+ */
506
+ function applyShowParameterListStep(steps, outlineParameters, formatConfig) {
507
+ if (!outlineParameters?.headers.length)
508
+ return;
509
+ const mode = formatConfig?.showParameterListStep ?? 'whenUnusedParameters';
510
+ if (mode === 'never')
511
+ return;
512
+ const { headers } = outlineParameters;
513
+ const shouldAppend = mode === 'always' ||
514
+ headers.some((h) => !steps.some((s) => s.action.includes(`@${h}@`)));
515
+ if (shouldAppend) {
516
+ const paramsList = headers.map((h) => `@${h}@`).join(', ');
517
+ steps.push({ action: `Parameters: ${paramsList}`, expected: '' });
518
+ }
519
+ }
520
+ /**
521
+ * Apply prefixTitle format config to the test case title.
522
+ */
523
+ function formatTitle(title, test, formatConfig) {
524
+ if (formatConfig?.prefixTitle === false)
525
+ return title;
526
+ // Don't double-prefix if title already has the prefix
527
+ if (/^Scenario(?:\s+Outline)?:\s+/i.test(title))
528
+ return title;
529
+ const isOutline = !!test.outlineParameters?.headers.length;
530
+ const prefix = isOutline ? 'Scenario Outline: ' : 'Scenario: ';
531
+ return prefix + title;
532
+ }
533
+ // ─── Attachment helpers ──────────────────────────────────────────────────────
534
+ /**
535
+ * Upload file attachments to a test case work item.
536
+ * Resolves file paths relative to the feature file or the configured baseFolder.
537
+ */
538
+ async function syncAttachments(client, tcId, test, config, configDir) {
539
+ const attachConfig = config.sync?.attachments;
540
+ if (!attachConfig?.enabled)
541
+ return;
542
+ if (!test.attachmentRefs?.length)
543
+ return;
544
+ const wit = await client.getWitApi();
545
+ const baseFolder = attachConfig.baseFolder
546
+ ? path.resolve(configDir, attachConfig.baseFolder)
547
+ : path.dirname(test.filePath);
548
+ // Fetch existing attachments on the TC
549
+ let existingAttachments = [];
550
+ try {
551
+ const wi = await wit.getWorkItem(tcId, undefined, undefined, 4 /* WorkItemExpand.Relations */);
552
+ if (wi?.relations) {
553
+ existingAttachments = wi.relations
554
+ .filter((r) => r.rel === 'AttachedFile')
555
+ .map((r) => ({
556
+ name: r.attributes?.name ?? '',
557
+ url: r.url ?? '',
558
+ }));
559
+ }
560
+ }
561
+ catch { /* continue without existing attachment info */ }
562
+ const existingNames = new Set(existingAttachments.map((a) => a.name));
563
+ for (const ref of test.attachmentRefs) {
564
+ // Resolve glob patterns for file paths
565
+ const resolvedPaths = await (0, glob_1.glob)(ref.filePath, {
566
+ cwd: baseFolder,
567
+ absolute: true,
568
+ });
569
+ for (const filePath of resolvedPaths) {
570
+ const fileName = path.basename(filePath);
571
+ if (existingNames.has(fileName))
572
+ continue; // Already attached
573
+ if (!fs.existsSync(filePath)) {
574
+ console.warn(` [warn] Attachment file not found: ${filePath}`);
575
+ continue;
576
+ }
577
+ const content = fs.readFileSync(filePath);
578
+ const stream = Buffer.from(content);
579
+ try {
580
+ const attachment = await wit.createAttachment({}, stream, fileName);
581
+ if (attachment?.url) {
582
+ await wit.updateWorkItem({}, [{
583
+ op: 'add',
584
+ path: '/relations/-',
585
+ value: {
586
+ rel: 'AttachedFile',
587
+ url: attachment.url,
588
+ attributes: { comment: `ado-sync:${ref.prefix}:${ref.filePath}` },
589
+ },
590
+ }], tcId);
591
+ }
592
+ }
593
+ catch (err) {
594
+ console.warn(` [warn] Failed to attach ${fileName} to TC #${tcId}: ${err.message}`);
595
+ }
596
+ }
597
+ }
598
+ }
246
599
  // ─── Work item relation helpers ───────────────────────────────────────────────
247
600
  function workItemUrl(orgUrl, id) {
248
601
  return `${orgUrl.replace(/\/$/, '')}/_apis/wit/workItems/${id}`;
@@ -422,26 +775,33 @@ async function getTestCase(client, id, titleField = 'System.Title') {
422
775
  iterationPath: f['System.IterationPath'],
423
776
  };
424
777
  }
425
- async function createTestCase(client, test, config, suiteIdOverride) {
778
+ async function createTestCase(client, test, config, suiteIdOverride, configDir) {
426
779
  const wit = await client.getWitApi();
427
780
  const syncCfg = config.sync ?? {};
428
781
  const titleField = syncCfg.titleField ?? 'System.Title';
782
+ const formatConfig = syncCfg.format;
429
783
  const isParametrized = !!test.outlineParameters?.headers.length;
430
- const steps = test.steps.map((s) => {
431
- const rawAction = `${s.keyword} ${s.text}`.trim();
432
- return {
433
- action: isParametrized ? gherkinParamsToAzure(rawAction) : rawAction,
434
- expected: s.expected ?? '',
435
- };
436
- });
784
+ const steps = applyFormatToSteps(test.steps, formatConfig).map((s) => ({
785
+ action: isParametrized ? gherkinParamsToAzure(s.action) : s.action,
786
+ expected: s.expected,
787
+ }));
788
+ applyShowParameterListStep(steps, test.outlineParameters, formatConfig);
789
+ // Apply format prefixTitle
790
+ const title = formatTitle(test.title, test, formatConfig);
437
791
  const patchDoc = [
438
- { op: 'add', path: `/fields/${titleField}`, value: test.title },
792
+ { op: 'add', path: `/fields/${titleField}`, value: title },
439
793
  { op: 'add', path: '/fields/Microsoft.VSTS.TCM.Steps', value: buildStepsXml(steps) },
440
794
  ];
441
795
  if (test.description) {
442
796
  patchDoc.push({ op: 'add', path: '/fields/System.Description', value: test.description });
443
797
  }
444
798
  patchDoc.push(...buildAutomationPatches(test, config, 'add'));
799
+ // State change on create
800
+ patchDoc.push(...buildStateChangePatches(test, syncCfg.state, 'add'));
801
+ // Field defaults (customizations) — only on create
802
+ patchDoc.push(...buildFieldDefaultPatches(config.customizations));
803
+ // Field updates
804
+ patchDoc.push(...buildFieldUpdatePatches(test, syncCfg.fieldUpdates, true, 'add'));
445
805
  if (test.outlineParameters) {
446
806
  const { headers, rows } = test.outlineParameters;
447
807
  const namesXml = buildParameterNamesXml(headers);
@@ -453,9 +813,9 @@ async function createTestCase(client, test, config, suiteIdOverride) {
453
813
  patchDoc.push({ op: 'add', path: '/fields/Microsoft.VSTS.TCM.LocalDataSource', value: dataXml });
454
814
  }
455
815
  }
456
- const filteredTags = test.tags
457
- .filter((t) => !t.startsWith(syncCfg.tagPrefix + ':'))
458
- .join('; ');
816
+ // Process tags: apply tag text map transformation
817
+ const processedTags = processTagsForPush(test.tags, syncCfg.tagPrefix ?? 'tc', config.customizations);
818
+ const filteredTags = processedTags.join('; ');
459
819
  if (filteredTags) {
460
820
  patchDoc.push({ op: 'add', path: '/fields/System.Tags', value: filteredTags });
461
821
  }
@@ -477,28 +837,41 @@ async function createTestCase(client, test, config, suiteIdOverride) {
477
837
  else {
478
838
  await addTestCaseToRootSuite(client, config, wi.id);
479
839
  }
840
+ // Sync attachments
841
+ if (configDir) {
842
+ await syncAttachments(client, wi.id, test, config, configDir);
843
+ }
480
844
  return wi.id;
481
845
  }
482
- async function updateTestCase(client, id, test, config) {
846
+ async function updateTestCase(client, id, test, config, configDir) {
483
847
  const wit = await client.getWitApi();
484
848
  const syncCfg = config.sync ?? {};
485
849
  const titleField = syncCfg.titleField ?? 'System.Title';
850
+ const formatConfig = syncCfg.format;
486
851
  const isParametrized = !!test.outlineParameters?.headers.length;
487
- const steps = test.steps.map((s) => {
488
- const rawAction = `${s.keyword} ${s.text}`.trim();
489
- return {
490
- action: isParametrized ? gherkinParamsToAzure(rawAction) : rawAction,
491
- expected: s.expected ?? '',
492
- };
493
- });
494
- const localTags = test.tags.filter((t) => !t.startsWith(syncCfg.tagPrefix + ':'));
495
- // Fetch existing Azure tags and merge: preserve any Azure-only tags, add new local tags
852
+ const steps = applyFormatToSteps(test.steps, formatConfig).map((s) => ({
853
+ action: isParametrized ? gherkinParamsToAzure(s.action) : s.action,
854
+ expected: s.expected,
855
+ }));
856
+ applyShowParameterListStep(steps, test.outlineParameters, formatConfig);
857
+ // Apply format prefixTitle
858
+ const title = formatTitle(test.title, test, formatConfig);
859
+ // Process tags with transformations
860
+ const processedLocalTags = processTagsForPush(test.tags, syncCfg.tagPrefix ?? 'tc', config.customizations);
861
+ // Fetch existing Azure tags and merge
496
862
  const wi = await wit.getWorkItem(id, ['System.Tags']);
497
863
  const existingAzureTags = tagsFromString(wi?.fields?.['System.Tags'] ?? '');
498
- const mergedTags = [...new Set([...existingAzureTags, ...localTags])];
864
+ // Filter ignored tags from removal — they should be preserved in Azure
865
+ const ignorePatterns = config.customizations?.ignoreTestCaseTags?.enabled
866
+ ? config.customizations.ignoreTestCaseTags.tags
867
+ : [];
868
+ const mergedTags = [...new Set([
869
+ ...existingAzureTags.filter((t) => isIgnoredTag(t, ignorePatterns)),
870
+ ...processedLocalTags,
871
+ ])];
499
872
  const mergedTagsValue = mergedTags.join('; ');
500
873
  const patchDoc = [
501
- { op: 'replace', path: `/fields/${titleField}`, value: test.title },
874
+ { op: 'replace', path: `/fields/${titleField}`, value: title },
502
875
  { op: 'replace', path: '/fields/Microsoft.VSTS.TCM.Steps', value: buildStepsXml(steps) },
503
876
  { op: 'replace', path: '/fields/System.Tags', value: mergedTagsValue },
504
877
  ];
@@ -506,6 +879,10 @@ async function updateTestCase(client, id, test, config) {
506
879
  patchDoc.push({ op: 'replace', path: '/fields/System.Description', value: test.description });
507
880
  }
508
881
  patchDoc.push(...buildAutomationPatches(test, config, 'replace'));
882
+ // State change on update
883
+ patchDoc.push(...buildStateChangePatches(test, syncCfg.state, 'replace'));
884
+ // Field updates
885
+ patchDoc.push(...buildFieldUpdatePatches(test, syncCfg.fieldUpdates, false, 'replace'));
509
886
  if (test.outlineParameters) {
510
887
  const { headers, rows } = test.outlineParameters;
511
888
  const namesXml = buildParameterNamesXml(headers);
@@ -521,6 +898,10 @@ async function updateTestCase(client, id, test, config) {
521
898
  const relationPatches = await applyLinkRelations(client, id, test, config, false);
522
899
  patchDoc.push(...relationPatches);
523
900
  await wit.updateWorkItem({}, patchDoc, id);
901
+ // Sync attachments
902
+ if (configDir) {
903
+ await syncAttachments(client, id, test, config, configDir);
904
+ }
524
905
  }
525
906
  async function updateLocalFromAzure(client, id, titleField) {
526
907
  return getTestCase(client, id, titleField);