@testmo/testmo-link 1.0.0-beta.0

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.
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright (c) Testmo GmbH (Berlin, Germany)
4
+ * All rights reserved.
5
+ * contact@testmo.com - www.testmo.com
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.PatternMatcher = void 0;
9
+ const logger_1 = require("../lib/logger");
10
+ const errors_1 = require("../lib/errors");
11
+ const retry_1 = require("../lib/retry");
12
+ /**
13
+ * Matches test names to repository case names using configurable regex rules.
14
+ */
15
+ class PatternMatcher {
16
+ constructor(rules) {
17
+ this.cases = [];
18
+ this.casesByNormalizedName = new Map();
19
+ this.compiledRules = [];
20
+ this.folderIdByNormalizedName = new Map();
21
+ this.loaded = false;
22
+ for (const rule of rules) {
23
+ if (rule.type !== 'name_match') {
24
+ logger_1.logger.warn(`Unknown pattern rule type "${rule.type}" — skipping`);
25
+ continue;
26
+ }
27
+ // Validate regex compiles
28
+ try {
29
+ // Case-insensitive matching
30
+ this.compiledRules.push({
31
+ source: rule.pattern,
32
+ regex: new RegExp(rule.pattern, 'i'),
33
+ folder: typeof rule.folder === 'string' ? rule.folder : undefined,
34
+ });
35
+ }
36
+ catch (e) {
37
+ throw new errors_1.PatternError(rule.pattern, e.message);
38
+ }
39
+ }
40
+ }
41
+ /**
42
+ * Fetch all repository cases (id, name, folder_id) and, when any rule
43
+ * uses folder scoping, fetch folder name→id mappings too.
44
+ */
45
+ async loadCases(client, projectId) {
46
+ var _a, _b;
47
+ if (this.loaded)
48
+ return;
49
+ let page = 1;
50
+ const perPage = 1000;
51
+ while (true) {
52
+ const result = await (0, retry_1.withRetry)(() => client.callApi('/api/v1/projects/{project_id}/cases/names', 'GET', { project_id: projectId }, { page, per_page: perPage }, {}, {}, null, ['bearerAuth'], ['application/json'], ['application/json'], Object, null));
53
+ const data = result.data;
54
+ const pageCases = (_a = data === null || data === void 0 ? void 0 : data.result) !== null && _a !== void 0 ? _a : [];
55
+ for (const c of pageCases) {
56
+ if (c.id && c.name) {
57
+ const record = {
58
+ id: c.id,
59
+ name: c.name,
60
+ folderId: (_b = c.folder_id) !== null && _b !== void 0 ? _b : null,
61
+ };
62
+ this.cases.push(record);
63
+ const key = this.normalize(c.name);
64
+ const existing = this.casesByNormalizedName.get(key);
65
+ if (existing) {
66
+ existing.push(record);
67
+ }
68
+ else {
69
+ this.casesByNormalizedName.set(key, [record]);
70
+ }
71
+ }
72
+ }
73
+ if (!(data === null || data === void 0 ? void 0 : data.next_page))
74
+ break;
75
+ page = data.next_page;
76
+ }
77
+ // Load folder name→id mappings when at least one rule uses folder scoping
78
+ const needsFolders = this.compiledRules.some(r => r.folder !== undefined);
79
+ if (needsFolders) {
80
+ await this.loadFolders(client, projectId);
81
+ }
82
+ this.loaded = true;
83
+ logger_1.logger.debug(`Loaded ${this.cases.length} repository case(s) for pattern matching`);
84
+ }
85
+ async loadFolders(client, projectId) {
86
+ var _a;
87
+ let page = 1;
88
+ const perPage = 100;
89
+ while (true) {
90
+ const result = await (0, retry_1.withRetry)(() => client.callApi('/api/v1/projects/{project_id}/folders', 'GET', { project_id: projectId }, { page, per_page: perPage }, {}, {}, null, ['bearerAuth'], ['application/json'], ['application/json'], Object, null));
91
+ const data = result.data;
92
+ const folders = (_a = data === null || data === void 0 ? void 0 : data.result) !== null && _a !== void 0 ? _a : [];
93
+ for (const f of folders) {
94
+ if (f.id && f.name) {
95
+ this.folderIdByNormalizedName.set(this.normalize(f.name), f.id);
96
+ }
97
+ }
98
+ if (!(data === null || data === void 0 ? void 0 : data.next_page))
99
+ break;
100
+ page = data.next_page;
101
+ }
102
+ logger_1.logger.debug(`Loaded ${this.folderIdByNormalizedName.size} folder(s) for pattern matching`);
103
+ }
104
+ /**
105
+ * Match a test name to a repository case.
106
+ *
107
+ * When linkedCaseIds is provided, already-linked cases are filtered out
108
+ * before the ambiguity check so that duplicate case names across folders
109
+ * don't prevent unlinked cases from being matched.
110
+ *
111
+ * Returns the case ID if exactly one unlinked match is found; undefined otherwise.
112
+ */
113
+ match(testName, linkedCaseIds) {
114
+ if (this.cases.length === 0)
115
+ return undefined;
116
+ const matchedCases = this.findMatchingCases(testName);
117
+ if (matchedCases.length === 0)
118
+ return undefined;
119
+ // Filter out cases that are already linked elsewhere in this run
120
+ const candidates = linkedCaseIds && linkedCaseIds.size > 0
121
+ ? matchedCases.filter(c => !linkedCaseIds.has(c.id))
122
+ : matchedCases;
123
+ if (candidates.length === 1) {
124
+ return candidates[0].id;
125
+ }
126
+ // Ambiguous — warn and skip
127
+ if (candidates.length > 1) {
128
+ logger_1.logger.warn(`Ambiguous pattern match for "${testName}": ` +
129
+ `${candidates.length} cases matched (IDs: ${candidates.map(c => c.id).join(', ')}) — skipping`);
130
+ }
131
+ return undefined;
132
+ }
133
+ findMatchingCases(testName) {
134
+ var _a, _b;
135
+ // If rules are configured, apply them: regex transforms test name → search term
136
+ if (this.compiledRules.length > 0) {
137
+ for (const { regex, folder } of this.compiledRules) {
138
+ const m = testName.match(regex);
139
+ if (!m)
140
+ continue;
141
+ // Use first captured group as search term, or full match if no groups
142
+ const searchTerm = m[1] !== undefined ? m[1] : m[0];
143
+ const normalized = this.normalize(searchTerm);
144
+ let hits = (_a = this.casesByNormalizedName.get(normalized)) !== null && _a !== void 0 ? _a : [];
145
+ // Apply folder scoping when the rule specifies a folder
146
+ if (folder !== undefined && hits.length > 0) {
147
+ const folderId = this.folderIdByNormalizedName.get(this.normalize(folder));
148
+ if (folderId !== undefined) {
149
+ hits = hits.filter(c => c.folderId === folderId);
150
+ }
151
+ else {
152
+ logger_1.logger.warn(`Pattern rule specifies unknown folder "${folder}" — ` +
153
+ `folder filter skipped for "${testName}"`);
154
+ }
155
+ }
156
+ if (hits.length > 0)
157
+ return hits;
158
+ }
159
+ return [];
160
+ }
161
+ // No rules — direct case-insensitive name match
162
+ const normalized = this.normalize(testName);
163
+ return (_b = this.casesByNormalizedName.get(normalized)) !== null && _b !== void 0 ? _b : [];
164
+ }
165
+ /** Normalize for comparison: lowercase, collapse whitespace/underscores/dashes. */
166
+ normalize(s) {
167
+ return s.toLowerCase().replace(/[\s_-]+/g, ' ').trim();
168
+ }
169
+ getCaseCount() {
170
+ return this.cases.length;
171
+ }
172
+ }
173
+ exports.PatternMatcher = PatternMatcher;
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@testmo/testmo-link",
3
+ "version": "1.0.0-beta.0",
4
+ "description": "Testmo automation linking tool — link automation test results to repository test cases",
5
+ "author": "Testmo GmbH",
6
+ "homepage": "https://www.testmo.com/",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "testmo-link": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist/**/*.js"
13
+ ],
14
+ "scripts": {
15
+ "lint": "npm run compile && npx eslint \"src/**/*.ts\"",
16
+ "compile": "tsc -p tsconfig.json",
17
+ "test": "TS_NODE_PROJECT=tsconfig.mocha.json node_modules/mocha/bin/mocha.js",
18
+ "test-ci": "TS_NODE_PROJECT=tsconfig.mocha.json node_modules/mocha/bin/mocha.js --reporter node_modules/mocha-junit-reporter --reporter-options jenkinsMode=1,mochaFile=results/results.xml",
19
+ "clean": "rm -rf dist/ && rm -f testmo-link*.tgz",
20
+ "prepack": "npm run clean && npm run compile"
21
+ },
22
+ "devDependencies": {
23
+ "@types/chai": "^5.0.1",
24
+ "@types/chai-as-promised": "^7.1.8",
25
+ "@types/js-yaml": "^4.0.9",
26
+ "@types/lodash": "^4.17.14",
27
+ "@types/mocha": "^10.0.10",
28
+ "@types/node": "^22.10.7",
29
+ "@types/sinon": "^17.0.3",
30
+ "@types/validator": "^13.12.2",
31
+ "@typescript-eslint/eslint-plugin": "^8.20.0",
32
+ "@typescript-eslint/parser": "^8.20.0",
33
+ "chai": "^4.5.0",
34
+ "chai-as-promised": "^7.1.2",
35
+ "eslint": "^8.57.1",
36
+ "mocha": "^11.0.1",
37
+ "mocha-junit-reporter": "^2.2.1",
38
+ "sinon": "^19.0.2",
39
+ "ts-node": "^10.9.2",
40
+ "typescript": "^5.7.3"
41
+ },
42
+ "dependencies": {
43
+ "@testmo/testmo-api": "2.7.0-beta.0",
44
+ "chalk": "^4.1.2",
45
+ "commander": "^8.3.0",
46
+ "glob": "^11.0.1",
47
+ "js-yaml": "^4.1.0",
48
+ "lodash": "^4.17.21",
49
+ "validator": "^13.11.0"
50
+ }
51
+ }