@virag8/azure-devops-test-publisher 1.0.0 → 1.2.1
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 +9 -8
- package/dist/azureService.d.ts +13 -1
- package/dist/azureService.js +131 -40
- package/dist/index.js +2 -0
- package/dist/types.d.ts +7 -1
- package/dist/wdioService.d.ts +11 -22
- package/dist/wdioService.js +23 -15
- package/package.json +64 -60
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ exports.config = {
|
|
|
41
41
|
{
|
|
42
42
|
orgUrl: process.env.AZURE_ORG_URL,
|
|
43
43
|
token: process.env.AZURE_PAT,
|
|
44
|
-
|
|
44
|
+
projectId: "MyProject", // name or GUID
|
|
45
45
|
planId: 123,
|
|
46
46
|
suiteId: 456,
|
|
47
47
|
screenshotOnFailure: true, // optional, defaults to true
|
|
@@ -95,7 +95,7 @@ import { AzureDevOpsReporterService } from "@virag8/azure-devops-test-publisher"
|
|
|
95
95
|
const reporter = new AzureDevOpsReporterService({
|
|
96
96
|
orgUrl: process.env.AZURE_ORG_URL!,
|
|
97
97
|
token: process.env.AZURE_PAT!,
|
|
98
|
-
|
|
98
|
+
projectId: "MyProject",
|
|
99
99
|
planId: 123,
|
|
100
100
|
suiteId: 456,
|
|
101
101
|
});
|
|
@@ -118,7 +118,7 @@ import { AzureDevOpsService } from "@virag8/azure-devops-test-publisher";
|
|
|
118
118
|
const ado = new AzureDevOpsService({
|
|
119
119
|
orgUrl: process.env.AZURE_ORG_URL!,
|
|
120
120
|
token: process.env.AZURE_PAT!,
|
|
121
|
-
|
|
121
|
+
projectId: "MyProject",
|
|
122
122
|
planId: 123,
|
|
123
123
|
suiteId: 456,
|
|
124
124
|
});
|
|
@@ -140,7 +140,7 @@ await ado.publishResults([
|
|
|
140
140
|
| --------------- | ---------- | ------------------------------------------------------------------- |
|
|
141
141
|
| `orgUrl` | `string` | Azure DevOps organization URL. |
|
|
142
142
|
| `token` | `string` | Personal access token with Test Plan read/write permissions. |
|
|
143
|
-
| `
|
|
143
|
+
| `projectId` | `string` | Azure DevOps project **name or id (GUID)** — both are accepted. |
|
|
144
144
|
| `planId` | `number` | Test plan id. |
|
|
145
145
|
| `suiteId` | `number` | Test suite id within the plan. |
|
|
146
146
|
| `runName` | `string?` | Custom name for created runs. |
|
|
@@ -149,13 +149,14 @@ await ado.publishResults([
|
|
|
149
149
|
| `caseIdPattern` | `RegExp?` | Custom regex (one capturing group) for extracting the test case id. |
|
|
150
150
|
| `debug` | `boolean?` | Log raw Azure DevOps API payloads. Defaults to `false`. |
|
|
151
151
|
|
|
152
|
-
> **Note on `
|
|
152
|
+
> **Note on `projectId`** — despite the name, this accepts either the project's display name (`"MyProject"`) or its GUID (`"b9e8c7cb-..."`). Prefer the GUID: it stays stable if the project is ever renamed, and it avoids URL-encoding issues with names that contain spaces. You can find it at `https://dev.azure.com/<org>/_apis/projects`.
|
|
153
153
|
|
|
154
154
|
### `AzureDevOpsWdioOptions` (extends `AzureDevOpsOptions`)
|
|
155
155
|
|
|
156
|
-
| Option | Type | Description
|
|
157
|
-
| --------------------- | ---------- |
|
|
158
|
-
| `screenshotOnFailure` | `boolean?` | Attach a browser screenshot to failed results. Defaults to `true`.
|
|
156
|
+
| Option | Type | Description |
|
|
157
|
+
| --------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
158
|
+
| `screenshotOnFailure` | `boolean?` | Attach a browser screenshot to failed results. Defaults to `true`. |
|
|
159
|
+
| `configurationId` | `number?` | Azure DevOps test configuration id for this worker (e.g. Android vs iOS). Set this when the same test case is configured for multiple configurations in your suite, otherwise a result published for one configuration can overwrite another configuration's result for the same case. |
|
|
159
160
|
|
|
160
161
|
### `PublishOptions`
|
|
161
162
|
|
package/dist/azureService.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { AzureDevOpsOptions, PublishOptions, TestResultItem } from "./types";
|
|
2
|
+
/** Thrown when mandatory Azure DevOps options are missing or malformed. */
|
|
3
|
+
export declare class AzureDevOpsConfigError extends Error {
|
|
4
|
+
readonly missing: string[];
|
|
5
|
+
constructor(missing: string[]);
|
|
6
|
+
}
|
|
7
|
+
export declare function assertRequiredOptions(config: AzureDevOpsOptions): void;
|
|
2
8
|
export declare class AzureDevOpsService {
|
|
3
9
|
private testApiPromise?;
|
|
10
|
+
private connection?;
|
|
11
|
+
private runByPromise?;
|
|
4
12
|
private config;
|
|
5
13
|
private currentRunId?;
|
|
6
14
|
/** Whether a PAT was provided; when false, every public method is a no-op. */
|
|
@@ -8,8 +16,12 @@ export declare class AzureDevOpsService {
|
|
|
8
16
|
constructor(config: AzureDevOpsOptions);
|
|
9
17
|
/** Id of the run currently being published to, if any. */
|
|
10
18
|
get runId(): number | undefined;
|
|
19
|
+
/** PAT owner, surfaced as "Run by" on the result; Azure leaves the field blank otherwise. */
|
|
20
|
+
private getRunBy;
|
|
11
21
|
private debug;
|
|
12
|
-
/**
|
|
22
|
+
/** Human readable target used in warnings and errors. */
|
|
23
|
+
private describeTarget;
|
|
24
|
+
/** Creates an empty run; points are added by `publishResults` as tests finish, so unexecuted cases are never marked in progress. */
|
|
13
25
|
createRun(): Promise<number | undefined>;
|
|
14
26
|
publishResults(results: TestResultItem[], options?: PublishOptions): Promise<number | undefined>;
|
|
15
27
|
/** Marks a run as completed. Defaults to the run used by the last publish. */
|
package/dist/azureService.js
CHANGED
|
@@ -33,15 +33,56 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.AzureDevOpsService = void 0;
|
|
36
|
+
exports.AzureDevOpsService = exports.AzureDevOpsConfigError = void 0;
|
|
37
|
+
exports.assertRequiredOptions = assertRequiredOptions;
|
|
37
38
|
const azdev = __importStar(require("azure-devops-node-api"));
|
|
39
|
+
/** True if `point`/`result` belongs to the same test case as `item`, and the same configuration when `item.configurationId` is set. */
|
|
40
|
+
function matchesTestCase(point, item) {
|
|
41
|
+
if (!point.testCase?.id ||
|
|
42
|
+
parseInt(point.testCase.id, 10) !== item.testCaseId) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (item.configurationId == null)
|
|
46
|
+
return true;
|
|
47
|
+
const pointConfigId = point.configuration?.id
|
|
48
|
+
? parseInt(point.configuration.id, 10)
|
|
49
|
+
: undefined;
|
|
50
|
+
return pointConfigId === item.configurationId;
|
|
51
|
+
}
|
|
52
|
+
/** Thrown when mandatory Azure DevOps options are missing or malformed. */
|
|
53
|
+
class AzureDevOpsConfigError extends Error {
|
|
54
|
+
missing;
|
|
55
|
+
constructor(missing) {
|
|
56
|
+
super(`Missing or invalid Azure DevOps option(s): ${missing.join(", ")}. ` +
|
|
57
|
+
"Provide them when constructing the service or in the wdio service options.");
|
|
58
|
+
this.missing = missing;
|
|
59
|
+
this.name = "AzureDevOpsConfigError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.AzureDevOpsConfigError = AzureDevOpsConfigError;
|
|
63
|
+
function assertRequiredOptions(config) {
|
|
64
|
+
const missing = [];
|
|
65
|
+
if (!config?.orgUrl?.trim())
|
|
66
|
+
missing.push("orgUrl");
|
|
67
|
+
if (!config?.projectId?.toString().trim())
|
|
68
|
+
missing.push("projectId");
|
|
69
|
+
if (!Number.isInteger(config?.planId))
|
|
70
|
+
missing.push("planId");
|
|
71
|
+
if (!Number.isInteger(config?.suiteId))
|
|
72
|
+
missing.push("suiteId");
|
|
73
|
+
if (missing.length > 0)
|
|
74
|
+
throw new AzureDevOpsConfigError(missing);
|
|
75
|
+
}
|
|
38
76
|
class AzureDevOpsService {
|
|
39
77
|
testApiPromise;
|
|
78
|
+
connection;
|
|
79
|
+
runByPromise;
|
|
40
80
|
config;
|
|
41
81
|
currentRunId;
|
|
42
82
|
/** Whether a PAT was provided; when false, every public method is a no-op. */
|
|
43
83
|
enabled;
|
|
44
84
|
constructor(config) {
|
|
85
|
+
assertRequiredOptions(config);
|
|
45
86
|
this.config = config;
|
|
46
87
|
this.currentRunId = config.runId;
|
|
47
88
|
this.enabled = Boolean(config.token);
|
|
@@ -51,39 +92,58 @@ class AzureDevOpsService {
|
|
|
51
92
|
}
|
|
52
93
|
const authHandler = azdev.getPersonalAccessTokenHandler(config.token);
|
|
53
94
|
const connection = new azdev.WebApi(config.orgUrl, authHandler);
|
|
95
|
+
this.connection = connection;
|
|
54
96
|
this.testApiPromise = connection.getTestApi();
|
|
55
97
|
}
|
|
56
98
|
/** Id of the run currently being published to, if any. */
|
|
57
99
|
get runId() {
|
|
58
100
|
return this.currentRunId;
|
|
59
101
|
}
|
|
102
|
+
/** PAT owner, surfaced as "Run by" on the result; Azure leaves the field blank otherwise. */
|
|
103
|
+
async getRunBy() {
|
|
104
|
+
try {
|
|
105
|
+
this.runByPromise ??= this.connection.connect().then((data) => data.authenticatedUser && {
|
|
106
|
+
id: data.authenticatedUser.id,
|
|
107
|
+
displayName: data.authenticatedUser.customDisplayName ||
|
|
108
|
+
data.authenticatedUser.providerDisplayName,
|
|
109
|
+
});
|
|
110
|
+
return await this.runByPromise;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
this.debug("Failed to resolve the Run by identity:", err);
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
60
117
|
debug(message, payload) {
|
|
61
118
|
if (!this.config.debug)
|
|
62
119
|
return;
|
|
63
120
|
console.log(message, payload);
|
|
64
121
|
}
|
|
65
|
-
/**
|
|
122
|
+
/** Human readable target used in warnings and errors. */
|
|
123
|
+
describeTarget(configurationId) {
|
|
124
|
+
const parts = [
|
|
125
|
+
`project "${this.config.projectId}"`,
|
|
126
|
+
`plan ${this.config.planId}`,
|
|
127
|
+
`suite ${this.config.suiteId}`,
|
|
128
|
+
];
|
|
129
|
+
if (configurationId != null)
|
|
130
|
+
parts.push(`configuration ${configurationId}`);
|
|
131
|
+
return parts.join(", ");
|
|
132
|
+
}
|
|
133
|
+
/** Creates an empty run; points are added by `publishResults` as tests finish, so unexecuted cases are never marked in progress. */
|
|
66
134
|
async createRun() {
|
|
67
135
|
if (!this.enabled)
|
|
68
136
|
return undefined;
|
|
69
137
|
const testApi = await this.testApiPromise;
|
|
70
|
-
const points = await testApi.getPoints(this.config.projectName, this.config.planId, this.config.suiteId);
|
|
71
|
-
const pointIds = points
|
|
72
|
-
.map((p) => p.id)
|
|
73
|
-
.filter((id) => typeof id === "number");
|
|
74
|
-
const configurationIds = Array.from(new Set(points
|
|
75
|
-
.map((p) => p.configuration?.id ? parseInt(p.configuration.id, 10) : null)
|
|
76
|
-
.filter((id) => id !== null)));
|
|
77
138
|
const testRun = await testApi.createTestRun({
|
|
78
139
|
name: this.config.runName ||
|
|
79
140
|
`Automated Test Run - ${new Date().toISOString()}`,
|
|
80
141
|
automated: true,
|
|
81
142
|
plan: { id: this.config.planId.toString() },
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}, this.config.projectName);
|
|
143
|
+
configurationIds: [],
|
|
144
|
+
}, this.config.projectId);
|
|
85
145
|
if (!testRun.id) {
|
|
86
|
-
throw new Error(
|
|
146
|
+
throw new Error(`Failed to create Test Run in Azure DevOps for ${this.describeTarget()}; the API returned a run without an id.`);
|
|
87
147
|
}
|
|
88
148
|
this.currentRunId = testRun.id;
|
|
89
149
|
return testRun.id;
|
|
@@ -94,15 +154,29 @@ class AzureDevOpsService {
|
|
|
94
154
|
const testApi = await this.testApiPromise;
|
|
95
155
|
// 1. Get test points matching the local test cases
|
|
96
156
|
const points = options.points ??
|
|
97
|
-
(await testApi.getPoints(this.config.
|
|
98
|
-
this.debug("Fetched test points:", points);
|
|
157
|
+
(await testApi.getPoints(this.config.projectId, this.config.planId, this.config.suiteId));
|
|
158
|
+
this.debug("Fetched test points:", points.length);
|
|
159
|
+
const inTargetConfiguration = (item) => options.configurationId == null ||
|
|
160
|
+
(item.configuration?.id != null &&
|
|
161
|
+
parseInt(item.configuration.id, 10) === options.configurationId);
|
|
99
162
|
const targetCaseIds = new Set(results.map((r) => r.testCaseId));
|
|
100
|
-
const matchedPoints = points.filter((p) => p.testCase?.id &&
|
|
163
|
+
const matchedPoints = points.filter((p) => p.testCase?.id &&
|
|
164
|
+
targetCaseIds.has(parseInt(p.testCase.id, 10)) &&
|
|
165
|
+
inTargetConfiguration(p) &&
|
|
166
|
+
results.some((r) => matchesTestCase(p, r)));
|
|
101
167
|
this.debug("Matched test points:", matchedPoints);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
168
|
+
const matchedCaseIds = new Set(matchedPoints.map((p) => parseInt(p.testCase.id, 10)));
|
|
169
|
+
const unmatchedCaseIds = [...targetCaseIds].filter((id) => !matchedCaseIds.has(id));
|
|
170
|
+
if (unmatchedCaseIds.length > 0) {
|
|
171
|
+
console.warn(`No test point found for test case id(s) ${unmatchedCaseIds.join(", ")} in ${this.describeTarget(options.configurationId)}. ` +
|
|
172
|
+
`The suite exposes ${points.length} point(s) for case id(s) ${points
|
|
173
|
+
.map((p) => p.testCase?.id)
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.join(", ") || "none"}. ` +
|
|
176
|
+
"Check that the case ids in your test titles belong to this plan/suite and configuration.");
|
|
105
177
|
}
|
|
178
|
+
if (matchedPoints.length === 0)
|
|
179
|
+
return;
|
|
106
180
|
const pointIds = matchedPoints.map((p) => p.id);
|
|
107
181
|
this.debug("Point IDs for the test run:", pointIds);
|
|
108
182
|
// Extract unique configuration IDs from matched points (or fallback to empty array/default)
|
|
@@ -117,18 +191,22 @@ class AzureDevOpsService {
|
|
|
117
191
|
let runId;
|
|
118
192
|
if (reusedRunId) {
|
|
119
193
|
runId = reusedRunId;
|
|
120
|
-
// Only add points that the run does not already hold a result for
|
|
121
|
-
const existingResults = await testApi.getTestResults(this.config.
|
|
122
|
-
const
|
|
123
|
-
const missingPoints = matchedPoints.filter((p) => !
|
|
194
|
+
// Only add points that the run does not already hold a result for (per case+configuration pair)
|
|
195
|
+
const existingResults = await testApi.getTestResults(this.config.projectId, runId);
|
|
196
|
+
const existingPointKeys = new Set(existingResults.map((r) => `${r.testCase?.id}:${r.configuration?.id ?? ""}`));
|
|
197
|
+
const missingPoints = matchedPoints.filter((p) => !existingPointKeys.has(`${p.testCase?.id}:${p.configuration?.id ?? ""}`));
|
|
124
198
|
if (missingPoints.length > 0) {
|
|
125
|
-
await testApi.addTestResultsToTestRun(
|
|
199
|
+
await testApi.addTestResultsToTestRun(
|
|
200
|
+
// Planned results are rejected unless point id, case id, revision and title are all present.
|
|
201
|
+
missingPoints.map((p) => ({
|
|
126
202
|
testPoint: { id: p.id.toString() },
|
|
127
203
|
testCase: { id: p.testCase.id },
|
|
204
|
+
testCaseRevision: 1,
|
|
205
|
+
testCaseTitle: p.testCase?.name || `Test case ${p.testCase.id}`,
|
|
128
206
|
configuration: p.configuration?.id
|
|
129
207
|
? { id: p.configuration.id }
|
|
130
208
|
: undefined,
|
|
131
|
-
})), this.config.
|
|
209
|
+
})), this.config.projectId, runId);
|
|
132
210
|
}
|
|
133
211
|
}
|
|
134
212
|
else {
|
|
@@ -140,49 +218,62 @@ class AzureDevOpsService {
|
|
|
140
218
|
plan: { id: this.config.planId.toString() },
|
|
141
219
|
pointIds: pointIds,
|
|
142
220
|
configurationIds: configurationIds,
|
|
143
|
-
}, this.config.
|
|
221
|
+
}, this.config.projectId);
|
|
144
222
|
if (!testRun.id) {
|
|
145
|
-
throw new Error(
|
|
223
|
+
throw new Error(`Failed to create Test Run in Azure DevOps for ${this.describeTarget(options.configurationId)} with point id(s) ${pointIds.join(", ")}.`);
|
|
146
224
|
}
|
|
147
225
|
runId = testRun.id;
|
|
148
226
|
}
|
|
149
227
|
this.currentRunId = runId;
|
|
150
228
|
// 3. Fetch automatically created results for the run
|
|
151
|
-
const runResults = await testApi.getTestResults(this.config.
|
|
229
|
+
const runResults = await testApi.getTestResults(this.config.projectId, runId);
|
|
152
230
|
// 4. Map outcomes and error messages
|
|
153
231
|
this.debug("Run results fetched from Azure DevOps:", runResults);
|
|
232
|
+
const runBy = await this.getRunBy();
|
|
154
233
|
const updatedResults = runResults
|
|
155
|
-
.filter((result) =>
|
|
234
|
+
.filter((result) => inTargetConfiguration(result) &&
|
|
235
|
+
results.some((r) => matchesTestCase(result, r)))
|
|
156
236
|
.map((result) => {
|
|
157
|
-
const match = results.find((r) =>
|
|
237
|
+
const match = results.find((r) => matchesTestCase(result, r));
|
|
158
238
|
return {
|
|
159
239
|
...result,
|
|
160
240
|
outcome: match ? match.outcome : "Inconclusive",
|
|
161
241
|
errorMessage: match?.errorMessage || "",
|
|
162
242
|
state: "Completed",
|
|
163
243
|
durationInMs: match?.durationInMs || 0,
|
|
244
|
+
runBy: result.runBy ?? runBy,
|
|
164
245
|
};
|
|
165
246
|
});
|
|
166
247
|
// 5. Update test results in ADO
|
|
167
248
|
this.debug("Updating test results in Azure DevOps:", updatedResults);
|
|
168
|
-
const savedResults = await testApi.updateTestResults(updatedResults, this.config.
|
|
249
|
+
const savedResults = await testApi.updateTestResults(updatedResults, this.config.projectId, runId);
|
|
169
250
|
this.debug("Saved test results:", savedResults);
|
|
170
|
-
// The real API doesn't always echo `testCase` back on the updated
|
|
171
|
-
// so fall back to
|
|
172
|
-
const
|
|
251
|
+
// The real API doesn't always echo `testCase`/`configuration` back on the updated
|
|
252
|
+
// results, so fall back to what we already know from step 4.
|
|
253
|
+
const resultInfoById = new Map();
|
|
173
254
|
for (const result of updatedResults) {
|
|
174
|
-
if (result.id
|
|
175
|
-
|
|
255
|
+
if (result.id) {
|
|
256
|
+
resultInfoById.set(result.id, {
|
|
257
|
+
caseId: result.testCase?.id,
|
|
258
|
+
configurationId: result.configuration?.id,
|
|
259
|
+
});
|
|
176
260
|
}
|
|
177
261
|
}
|
|
178
262
|
// 6. Upload Attachments
|
|
179
263
|
for (const savedResult of savedResults) {
|
|
180
264
|
if (!savedResult.id)
|
|
181
265
|
continue;
|
|
182
|
-
const
|
|
266
|
+
const info = resultInfoById.get(savedResult.id);
|
|
267
|
+
const caseId = savedResult.testCase?.id ?? info?.caseId;
|
|
183
268
|
if (!caseId)
|
|
184
269
|
continue;
|
|
185
|
-
const
|
|
270
|
+
const configurationId = savedResult.configuration?.id ?? info?.configurationId;
|
|
271
|
+
const matchedLocalResult = results.find((r) => matchesTestCase({
|
|
272
|
+
testCase: { id: caseId },
|
|
273
|
+
configuration: configurationId
|
|
274
|
+
? { id: configurationId }
|
|
275
|
+
: undefined,
|
|
276
|
+
}, r));
|
|
186
277
|
if (matchedLocalResult?.attachments &&
|
|
187
278
|
matchedLocalResult.attachments.length > 0) {
|
|
188
279
|
for (const attachment of matchedLocalResult.attachments) {
|
|
@@ -200,7 +291,7 @@ class AzureDevOpsService {
|
|
|
200
291
|
attachmentType: attachmentModel.attachmentType,
|
|
201
292
|
base64Length: attachment.base64Content?.length ?? 0,
|
|
202
293
|
});
|
|
203
|
-
const savedAttachment = await testApi.createTestResultAttachment(attachmentModel, this.config.
|
|
294
|
+
const savedAttachment = await testApi.createTestResultAttachment(attachmentModel, this.config.projectId, runId, savedResult.id);
|
|
204
295
|
this.debug("Attachment uploaded:", savedAttachment);
|
|
205
296
|
}
|
|
206
297
|
}
|
|
@@ -220,7 +311,7 @@ class AzureDevOpsService {
|
|
|
220
311
|
if (!this.enabled || !runId)
|
|
221
312
|
return;
|
|
222
313
|
const testApi = await this.testApiPromise;
|
|
223
|
-
await testApi.updateTestRun({ state: "Completed" }, this.config.
|
|
314
|
+
await testApi.updateTestRun({ state: "Completed" }, this.config.projectId, runId);
|
|
224
315
|
if (runId === this.currentRunId) {
|
|
225
316
|
this.currentRunId = undefined;
|
|
226
317
|
}
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ class AzureDevOpsReporterService {
|
|
|
22
22
|
results = [];
|
|
23
23
|
ado;
|
|
24
24
|
constructor(options) {
|
|
25
|
+
(0, azureService_1.assertRequiredOptions)(options);
|
|
25
26
|
this.options = options;
|
|
26
27
|
}
|
|
27
28
|
async afterTest(test, _context, results, browserInstance) {
|
|
@@ -50,6 +51,7 @@ class AzureDevOpsReporterService {
|
|
|
50
51
|
errorMessage: results.error?.message,
|
|
51
52
|
durationInMs: test.duration,
|
|
52
53
|
attachments,
|
|
54
|
+
configurationId: this.options.configurationId,
|
|
53
55
|
});
|
|
54
56
|
}
|
|
55
57
|
async onComplete() {
|
package/dist/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export interface AzureDevOpsOptions {
|
|
|
3
3
|
orgUrl: string;
|
|
4
4
|
token: string;
|
|
5
5
|
/** Project display name or its GUID; Azure DevOps accepts either. */
|
|
6
|
-
|
|
6
|
+
projectId: string;
|
|
7
7
|
planId: number;
|
|
8
8
|
suiteId: number;
|
|
9
9
|
runName?: string;
|
|
@@ -21,12 +21,16 @@ export interface PublishOptions {
|
|
|
21
21
|
runId?: number;
|
|
22
22
|
/** Reuse already-fetched test points instead of calling `getPoints` again. */
|
|
23
23
|
points?: TestPoint[];
|
|
24
|
+
/** Only publish to the test point/result of this Azure DevOps configuration id; others are left untouched. */
|
|
25
|
+
configurationId?: number;
|
|
24
26
|
/** Leave the run in progress so more results can be added later. */
|
|
25
27
|
keepRunOpen?: boolean;
|
|
26
28
|
}
|
|
27
29
|
export interface AzureDevOpsWdioOptions extends AzureDevOpsOptions {
|
|
28
30
|
/** Attach a browser screenshot to failed results. Defaults to true. */
|
|
29
31
|
screenshotOnFailure?: boolean;
|
|
32
|
+
/** Azure DevOps test configuration id (e.g. Android vs iOS) this worker's results belong to. Required when the same test case is configured for multiple configurations, otherwise results can bleed across configurations. */
|
|
33
|
+
configurationId?: number;
|
|
30
34
|
}
|
|
31
35
|
export interface TestAttachment {
|
|
32
36
|
fileName: string;
|
|
@@ -40,4 +44,6 @@ export interface TestResultItem {
|
|
|
40
44
|
errorMessage?: string;
|
|
41
45
|
durationInMs?: number;
|
|
42
46
|
attachments?: TestAttachment[];
|
|
47
|
+
/** Azure DevOps test configuration id; disambiguates test points/results that share a case id across configurations (e.g. Android vs iOS). */
|
|
48
|
+
configurationId?: number;
|
|
43
49
|
}
|
package/dist/wdioService.d.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
+
import type { Frameworks, Services } from "@wdio/types" with {
|
|
2
|
+
"resolution-mode": "import"
|
|
3
|
+
};
|
|
1
4
|
import { AzureDevOpsWdioOptions } from "./types";
|
|
2
5
|
/** Shares the run id created in the launcher process with the worker processes. */
|
|
3
6
|
export declare const RUN_ID_ENV_VAR = "AZURE_DEVOPS_TEST_RUN_ID";
|
|
4
|
-
interface WdioTest {
|
|
5
|
-
title: string;
|
|
6
|
-
fullTitle?: string;
|
|
7
|
-
}
|
|
8
|
-
interface WdioTestResult {
|
|
9
|
-
passed: boolean;
|
|
10
|
-
duration?: number;
|
|
11
|
-
error?: Error;
|
|
12
|
-
}
|
|
13
7
|
interface CucumberPickleTag {
|
|
14
8
|
name: string;
|
|
15
9
|
}
|
|
@@ -20,26 +14,23 @@ interface CucumberWorld {
|
|
|
20
14
|
tags?: CucumberPickleTag[];
|
|
21
15
|
};
|
|
22
16
|
}
|
|
23
|
-
interface CucumberResult {
|
|
24
|
-
passed: boolean;
|
|
25
|
-
duration?: number;
|
|
26
|
-
error?: Error;
|
|
27
|
-
}
|
|
28
17
|
/**
|
|
29
18
|
* WebdriverIO service that creates a single Test Run in `onPrepare`, pushes every
|
|
30
19
|
* spec's results into that run, and completes it in `onComplete`.
|
|
31
20
|
*/
|
|
32
|
-
export
|
|
33
|
-
private
|
|
21
|
+
export default class AzureDevOpsWdioService implements Services.ServiceInstance {
|
|
22
|
+
private readonly _options;
|
|
34
23
|
private results;
|
|
35
24
|
private service?;
|
|
36
|
-
constructor(
|
|
25
|
+
constructor(_options: AzureDevOpsWdioOptions);
|
|
37
26
|
onPrepare(): Promise<void>;
|
|
38
27
|
onComplete(): Promise<void>;
|
|
39
28
|
private debug;
|
|
40
|
-
afterTest(test:
|
|
29
|
+
afterTest(test: Frameworks.Test, _context: unknown, results: Frameworks.TestResult): Promise<void>;
|
|
41
30
|
/** Cucumber hook for BDD feature files; reads the case id from a `@C123` tag or the scenario name. */
|
|
42
|
-
afterScenario(world: CucumberWorld, result:
|
|
31
|
+
afterScenario(world: CucumberWorld, result: Frameworks.PickleResult): Promise<void>;
|
|
32
|
+
/** Cucumber's `error` is typed as a string, but some frameworks still pass a raw `Error`. */
|
|
33
|
+
private stringifyError;
|
|
43
34
|
after(): Promise<void>;
|
|
44
35
|
/** Publishes everything collected so far into the shared run. */
|
|
45
36
|
publish(): Promise<void>;
|
|
@@ -48,6 +39,4 @@ export declare class AzureDevOpsWdioService {
|
|
|
48
39
|
private resolveRunId;
|
|
49
40
|
private getService;
|
|
50
41
|
}
|
|
51
|
-
|
|
52
|
-
export declare const launcher: typeof AzureDevOpsWdioService;
|
|
53
|
-
export default AzureDevOpsWdioService;
|
|
42
|
+
export { AzureDevOpsWdioService };
|
package/dist/wdioService.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.AzureDevOpsWdioService = exports.RUN_ID_ENV_VAR = void 0;
|
|
4
4
|
const azureService_1 = require("./azureService");
|
|
5
5
|
const utils_1 = require("./utils");
|
|
6
6
|
/** Shares the run id created in the launcher process with the worker processes. */
|
|
@@ -10,11 +10,12 @@ exports.RUN_ID_ENV_VAR = "AZURE_DEVOPS_TEST_RUN_ID";
|
|
|
10
10
|
* spec's results into that run, and completes it in `onComplete`.
|
|
11
11
|
*/
|
|
12
12
|
class AzureDevOpsWdioService {
|
|
13
|
-
|
|
13
|
+
_options;
|
|
14
14
|
results = [];
|
|
15
15
|
service;
|
|
16
|
-
constructor(
|
|
17
|
-
this.
|
|
16
|
+
constructor(_options) {
|
|
17
|
+
this._options = _options;
|
|
18
|
+
(0, azureService_1.assertRequiredOptions)(_options);
|
|
18
19
|
}
|
|
19
20
|
// --- launcher process hooks ---
|
|
20
21
|
async onPrepare() {
|
|
@@ -34,15 +35,15 @@ class AzureDevOpsWdioService {
|
|
|
34
35
|
}
|
|
35
36
|
// --- worker process hooks ---
|
|
36
37
|
debug(message, payload) {
|
|
37
|
-
if (!this.
|
|
38
|
+
if (!this._options.debug)
|
|
38
39
|
return;
|
|
39
40
|
console.log(message, payload);
|
|
40
41
|
}
|
|
41
42
|
async afterTest(test, _context, results) {
|
|
42
|
-
const caseId = (0, utils_1.extractTestCaseId)(test.title, this.
|
|
43
|
+
const caseId = (0, utils_1.extractTestCaseId)(test.title, this._options.caseIdPattern);
|
|
43
44
|
this.debug("Extracted case id from test title:", {
|
|
44
45
|
title: test.title,
|
|
45
|
-
pattern: String(this.
|
|
46
|
+
pattern: String(this._options.caseIdPattern ?? "default C123/#123"),
|
|
46
47
|
caseId,
|
|
47
48
|
});
|
|
48
49
|
if (!caseId)
|
|
@@ -53,6 +54,7 @@ class AzureDevOpsWdioService {
|
|
|
53
54
|
errorMessage: results.error?.message,
|
|
54
55
|
durationInMs: results.duration ?? 0,
|
|
55
56
|
attachments: await this.captureScreenshot(caseId, test, results),
|
|
57
|
+
configurationId: this._options.configurationId,
|
|
56
58
|
});
|
|
57
59
|
}
|
|
58
60
|
/** Cucumber hook for BDD feature files; reads the case id from a `@C123` tag or the scenario name. */
|
|
@@ -63,11 +65,18 @@ class AzureDevOpsWdioService {
|
|
|
63
65
|
this.results.push({
|
|
64
66
|
testCaseId: caseId,
|
|
65
67
|
outcome: result.passed ? "Passed" : "Failed",
|
|
66
|
-
errorMessage: result.error
|
|
68
|
+
errorMessage: this.stringifyError(result.error),
|
|
67
69
|
durationInMs: result.duration ?? 0,
|
|
68
70
|
attachments: await this.captureScreenshot(caseId, { title: world.pickle.name }, result),
|
|
71
|
+
configurationId: this._options.configurationId,
|
|
69
72
|
});
|
|
70
73
|
}
|
|
74
|
+
/** Cucumber's `error` is typed as a string, but some frameworks still pass a raw `Error`. */
|
|
75
|
+
stringifyError(error) {
|
|
76
|
+
if (!error)
|
|
77
|
+
return undefined;
|
|
78
|
+
return error instanceof Error ? error.message : String(error);
|
|
79
|
+
}
|
|
71
80
|
async after() {
|
|
72
81
|
await this.publish();
|
|
73
82
|
}
|
|
@@ -81,6 +90,7 @@ class AzureDevOpsWdioService {
|
|
|
81
90
|
await this.getService().publishResults(pending, {
|
|
82
91
|
runId: this.resolveRunId(),
|
|
83
92
|
keepRunOpen: true,
|
|
93
|
+
configurationId: this._options.configurationId,
|
|
84
94
|
});
|
|
85
95
|
}
|
|
86
96
|
catch (err) {
|
|
@@ -88,7 +98,7 @@ class AzureDevOpsWdioService {
|
|
|
88
98
|
}
|
|
89
99
|
}
|
|
90
100
|
async captureScreenshot(caseId, test, results) {
|
|
91
|
-
if (results.passed || this.
|
|
101
|
+
if (results.passed || this._options.screenshotOnFailure === false)
|
|
92
102
|
return [];
|
|
93
103
|
const browser = globalThis
|
|
94
104
|
.browser;
|
|
@@ -109,7 +119,7 @@ class AzureDevOpsWdioService {
|
|
|
109
119
|
}
|
|
110
120
|
}
|
|
111
121
|
extractCucumberCaseId(world) {
|
|
112
|
-
const pattern = this.
|
|
122
|
+
const pattern = this._options.caseIdPattern;
|
|
113
123
|
const tags = world.pickle.tags ?? [];
|
|
114
124
|
this.debug("Scanning scenario tags for case id:", {
|
|
115
125
|
scenario: world.pickle.name,
|
|
@@ -133,14 +143,12 @@ class AzureDevOpsWdioService {
|
|
|
133
143
|
resolveRunId() {
|
|
134
144
|
const fromEnv = process.env[exports.RUN_ID_ENV_VAR];
|
|
135
145
|
const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;
|
|
136
|
-
return Number.isNaN(parsed) ? this.
|
|
146
|
+
return Number.isNaN(parsed) ? this._options.runId : parsed;
|
|
137
147
|
}
|
|
138
148
|
getService() {
|
|
139
|
-
this.service ??= new azureService_1.AzureDevOpsService(this.
|
|
149
|
+
this.service ??= new azureService_1.AzureDevOpsService(this._options);
|
|
140
150
|
return this.service;
|
|
141
151
|
}
|
|
142
152
|
}
|
|
143
|
-
exports.AzureDevOpsWdioService = AzureDevOpsWdioService;
|
|
144
|
-
/** WebdriverIO looks for a `launcher` export to run `onPrepare`/`onComplete`. */
|
|
145
|
-
exports.launcher = AzureDevOpsWdioService;
|
|
146
153
|
exports.default = AzureDevOpsWdioService;
|
|
154
|
+
exports.AzureDevOpsWdioService = AzureDevOpsWdioService;
|
package/package.json
CHANGED
|
@@ -1,60 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@virag8/azure-devops-test-publisher",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Upload test results and screenshots to Azure DevOps Test Plans from WebdriverIO or custom TS runners",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"types": "dist/index.d.ts",
|
|
7
|
-
"license": "MIT",
|
|
8
|
-
"author": "virag <viragkumar58@gmail.com>",
|
|
9
|
-
"homepage": "https://github.com/viragkumar/azure-devops-test-publisher#readme",
|
|
10
|
-
"repository": {
|
|
11
|
-
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/viragkumar/azure-devops-test-publisher.git"
|
|
13
|
-
},
|
|
14
|
-
"bugs": {
|
|
15
|
-
"url": "https://github.com/viragkumar/azure-devops-test-publisher/issues"
|
|
16
|
-
},
|
|
17
|
-
"keywords": [
|
|
18
|
-
"webdriverio",
|
|
19
|
-
"wdio",
|
|
20
|
-
"wdio-service",
|
|
21
|
-
"ado",
|
|
22
|
-
"azure-devops",
|
|
23
|
-
"azure-test-plans",
|
|
24
|
-
"test-results",
|
|
25
|
-
"reporter",
|
|
26
|
-
"cucumber",
|
|
27
|
-
"mocha",
|
|
28
|
-
"typescript"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"test": "
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
},
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"jest": "^30.
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@virag8/azure-devops-test-publisher",
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Upload test results and screenshots to Azure DevOps Test Plans from WebdriverIO or custom TS runners",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "virag <viragkumar58@gmail.com>",
|
|
9
|
+
"homepage": "https://github.com/viragkumar/azure-devops-test-publisher#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/viragkumar/azure-devops-test-publisher.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/viragkumar/azure-devops-test-publisher/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"webdriverio",
|
|
19
|
+
"wdio",
|
|
20
|
+
"wdio-service",
|
|
21
|
+
"ado",
|
|
22
|
+
"azure-devops",
|
|
23
|
+
"azure-test-plans",
|
|
24
|
+
"test-results",
|
|
25
|
+
"reporter",
|
|
26
|
+
"cucumber",
|
|
27
|
+
"mocha",
|
|
28
|
+
"typescript",
|
|
29
|
+
"azure-devops-test-publisher",
|
|
30
|
+
"test-publisher"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "rimraf dist && tsc",
|
|
45
|
+
"prepublishOnly": "npm run test:unit && npm run build",
|
|
46
|
+
"test:unit": "jest --config tests/jest.config.js",
|
|
47
|
+
"test:real": "jest --config tests/jest.real.config.js",
|
|
48
|
+
"test": "npm run test:unit",
|
|
49
|
+
"pack:local": "npm run build && npm pack"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"azure-devops-node-api": "^12.0.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/jest": "^30.0.0",
|
|
56
|
+
"@types/node": "^20.0.0",
|
|
57
|
+
"@wdio/types": "^9.31.2",
|
|
58
|
+
"dotenv": "^17.4.2",
|
|
59
|
+
"jest": "^30.5.1",
|
|
60
|
+
"rimraf": "^5.0.0",
|
|
61
|
+
"ts-jest": "^29.4.12",
|
|
62
|
+
"typescript": "^5.0.0"
|
|
63
|
+
}
|
|
64
|
+
}
|