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.
- package/README.md +240 -678
- package/dist/azure/client.d.ts +3 -0
- package/dist/azure/client.js +6 -0
- package/dist/azure/client.js.map +1 -1
- package/dist/azure/test-cases.d.ts +8 -3
- package/dist/azure/test-cases.js +406 -25
- package/dist/azure/test-cases.js.map +1 -1
- package/dist/cli.js +51 -4
- package/dist/cli.js.map +1 -1
- package/dist/config.js +111 -5
- package/dist/config.js.map +1 -1
- package/dist/parsers/csharp.d.ts +30 -0
- package/dist/parsers/csharp.js +257 -0
- package/dist/parsers/csharp.js.map +1 -0
- package/dist/parsers/gherkin.d.ts +4 -1
- package/dist/parsers/gherkin.js +19 -4
- package/dist/parsers/gherkin.js.map +1 -1
- package/dist/parsers/java.d.ts +40 -0
- package/dist/parsers/java.js +329 -0
- package/dist/parsers/java.js.map +1 -0
- package/dist/parsers/javascript.d.ts +33 -0
- package/dist/parsers/javascript.js +261 -0
- package/dist/parsers/javascript.js.map +1 -0
- package/dist/parsers/markdown.d.ts +4 -1
- package/dist/parsers/markdown.js +5 -3
- package/dist/parsers/markdown.js.map +1 -1
- package/dist/parsers/python.d.ts +34 -0
- package/dist/parsers/python.js +305 -0
- package/dist/parsers/python.js.map +1 -0
- package/dist/parsers/shared.d.ts +18 -0
- package/dist/parsers/shared.js +40 -0
- package/dist/parsers/shared.js.map +1 -1
- package/dist/sync/engine.js +114 -5
- package/dist/sync/engine.js.map +1 -1
- package/dist/sync/publish-results.d.ts +49 -0
- package/dist/sync/publish-results.js +476 -0
- package/dist/sync/publish-results.js.map +1 -0
- package/dist/sync/writeback.d.ts +57 -1
- package/dist/sync/writeback.js +243 -0
- package/dist/sync/writeback.js.map +1 -1
- package/dist/types.d.ts +159 -2
- package/docs/advanced.md +350 -0
- package/docs/configuration.md +293 -0
- package/docs/publish-test-results.md +317 -0
- package/docs/spec-formats.md +595 -0
- package/package.json +1 -1
package/dist/azure/client.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/azure/client.js
CHANGED
|
@@ -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();
|
package/dist/azure/client.js.map
CHANGED
|
@@ -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;
|
|
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
|
package/dist/azure/test-cases.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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:
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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:
|
|
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);
|