@virag8/azure-devops-test-publisher 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 viragkumar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # azure-devops-test-publisher
2
+
3
+ Publish automated test results and failure screenshots from WebdriverIO (Mocha or Cucumber/BDD) — or any custom TypeScript test runner — directly to **Azure DevOps Test Plans**.
4
+
5
+ ## Features
6
+
7
+ - **Single shared Test Run** — creates one Azure DevOps Test Run for the whole suite in the launcher process and shares its id with all parallel workers via an environment variable, so results from every spec land in the same run.
8
+ - **Mocha support** — extracts the Azure DevOps test case id from a test title (e.g. `C1234 login works`) via the `afterTest` hook.
9
+ - **Cucumber / BDD support** — extracts the test case id from a scenario's `@C1234` tag, falling back to the scenario name, via the `afterScenario` hook.
10
+ - **Custom case id pattern** — override the default `C123`/`#123` matcher with your own regex (e.g. `TC-(\d+)`) via `caseIdPattern`.
11
+ - **Automatic failure screenshots** — captures a browser screenshot and attaches it to the Azure DevOps result whenever a test/scenario fails (toggle with `screenshotOnFailure`).
12
+ - **Run reuse** — publish into an existing run (`runId`), or keep a single run open across multiple publishes (`reuseTestRun` / `keepRunOpen`) instead of creating a new run every time.
13
+ - **Point pre-fetch** — pass already-fetched test points via `PublishOptions.points` to skip a redundant Azure DevOps API call.
14
+ - **Resilient publishing** — publish failures are caught and logged so a flaky Azure DevOps API never fails the test run itself.
15
+ - **Standalone reporter service** — `AzureDevOpsReporterService` for custom/non-service integrations that just need `afterTest` + `onComplete` hooks.
16
+ - **Quiet by default** — set `debug: true` to log raw Azure DevOps API payloads while troubleshooting.
17
+ - **Fully typed** — ships with TypeScript declarations for all public options and result types.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install --save-dev @virag8/azure-devops-test-publisher
23
+ ```
24
+
25
+ Requires Node.js 18 or newer.
26
+
27
+ ## Usage with WebdriverIO
28
+
29
+ Register the service in `wdio.conf.js` / `wdio.conf.ts`:
30
+
31
+ ```js
32
+ const {
33
+ AzureDevOpsWdioService,
34
+ } = require("@virag8/azure-devops-test-publisher");
35
+
36
+ exports.config = {
37
+ // ...
38
+ services: [
39
+ [
40
+ AzureDevOpsWdioService,
41
+ {
42
+ orgUrl: process.env.AZURE_ORG_URL,
43
+ token: process.env.AZURE_PAT,
44
+ projectName: "MyProject", // name or GUID
45
+ planId: 123,
46
+ suiteId: 456,
47
+ screenshotOnFailure: true, // optional, defaults to true
48
+ },
49
+ ],
50
+ ],
51
+ };
52
+ ```
53
+
54
+ The service creates the Test Run in `onPrepare`, collects results from every worker via `afterTest`/`afterScenario`, publishes them in `after`, and completes the run in `onComplete`.
55
+
56
+ ### Mocha specs
57
+
58
+ Tag the test title with the Azure DevOps test case id:
59
+
60
+ ```js
61
+ it("C1234 login", async () => { ... });
62
+ ```
63
+
64
+ ### Cucumber / BDD feature files
65
+
66
+ Tag the scenario with `@C<testCaseId>`:
67
+
68
+ ```gherkin
69
+ @C1234
70
+ Scenario: User can log in
71
+ Given the user is on the login page
72
+ When they submit valid credentials
73
+ Then they should see the dashboard
74
+ ```
75
+
76
+ If no tag is present, the case id is parsed from the scenario name instead.
77
+
78
+ ### Custom case id pattern
79
+
80
+ ```js
81
+ {
82
+ caseIdPattern: /TC-(\d+)/, // matches "TC-1234" in titles or tags
83
+ }
84
+ ```
85
+
86
+ The pattern must contain exactly one capturing group for the numeric id.
87
+
88
+ ## Usage as a standalone reporter
89
+
90
+ For custom runners that aren't WebdriverIO services, use `AzureDevOpsReporterService` directly:
91
+
92
+ ```ts
93
+ import { AzureDevOpsReporterService } from "@virag8/azure-devops-test-publisher";
94
+
95
+ const reporter = new AzureDevOpsReporterService({
96
+ orgUrl: process.env.AZURE_ORG_URL!,
97
+ token: process.env.AZURE_PAT!,
98
+ projectName: "MyProject",
99
+ planId: 123,
100
+ suiteId: 456,
101
+ });
102
+
103
+ await reporter.afterTest(
104
+ { title: "C1234 login", duration: 250 },
105
+ {},
106
+ { passed: true },
107
+ );
108
+ await reporter.onComplete();
109
+ ```
110
+
111
+ ## Using the low-level `AzureDevOpsService`
112
+
113
+ Both the WDIO service and the reporter are built on `AzureDevOpsService`, which you can use directly for full control over run creation and result publishing:
114
+
115
+ ```ts
116
+ import { AzureDevOpsService } from "@virag8/azure-devops-test-publisher";
117
+
118
+ const ado = new AzureDevOpsService({
119
+ orgUrl: process.env.AZURE_ORG_URL!,
120
+ token: process.env.AZURE_PAT!,
121
+ projectName: "MyProject",
122
+ planId: 123,
123
+ suiteId: 456,
124
+ });
125
+
126
+ await ado.publishResults([
127
+ {
128
+ testCaseId: 1234,
129
+ outcome: "Passed",
130
+ durationInMs: 250,
131
+ },
132
+ ]);
133
+ ```
134
+
135
+ ## Configuration reference
136
+
137
+ ### `AzureDevOpsOptions`
138
+
139
+ | Option | Type | Description |
140
+ | --------------- | ---------- | ------------------------------------------------------------------- |
141
+ | `orgUrl` | `string` | Azure DevOps organization URL. |
142
+ | `token` | `string` | Personal access token with Test Plan read/write permissions. |
143
+ | `projectName` | `string` | Azure DevOps project **name or id (GUID)** — both are accepted. |
144
+ | `planId` | `number` | Test plan id. |
145
+ | `suiteId` | `number` | Test suite id within the plan. |
146
+ | `runName` | `string?` | Custom name for created runs. |
147
+ | `runId` | `number?` | Reuse this existing run instead of creating a new one. |
148
+ | `reuseTestRun` | `boolean?` | Keep a single run open across multiple `publishResults` calls. |
149
+ | `caseIdPattern` | `RegExp?` | Custom regex (one capturing group) for extracting the test case id. |
150
+ | `debug` | `boolean?` | Log raw Azure DevOps API payloads. Defaults to `false`. |
151
+
152
+ > **Note on `projectName`** — 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
+
154
+ ### `AzureDevOpsWdioOptions` (extends `AzureDevOpsOptions`)
155
+
156
+ | Option | Type | Description |
157
+ | --------------------- | ---------- | ------------------------------------------------------------------ |
158
+ | `screenshotOnFailure` | `boolean?` | Attach a browser screenshot to failed results. Defaults to `true`. |
159
+
160
+ ### `PublishOptions`
161
+
162
+ | Option | Type | Description |
163
+ | ------------- | -------------- | ----------------------------------------------------------------------- |
164
+ | `runId` | `number?` | Publish into this existing run instead of creating a new one. |
165
+ | `points` | `TestPoint[]?` | Reuse already-fetched test points instead of calling `getPoints` again. |
166
+ | `keepRunOpen` | `boolean?` | Leave the run in progress so more results can be added later. |
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ npm run test:unit # mocked unit tests
172
+ npm run test:real # integration tests against a real Azure DevOps org (requires env vars)
173
+ npm run build # compile to dist/
174
+ ```
175
+
176
+ ## License
177
+
178
+ MIT
@@ -0,0 +1,17 @@
1
+ import { AzureDevOpsOptions, PublishOptions, TestResultItem } from "./types";
2
+ export declare class AzureDevOpsService {
3
+ private testApiPromise?;
4
+ private config;
5
+ private currentRunId?;
6
+ /** Whether a PAT was provided; when false, every public method is a no-op. */
7
+ private readonly enabled;
8
+ constructor(config: AzureDevOpsOptions);
9
+ /** Id of the run currently being published to, if any. */
10
+ get runId(): number | undefined;
11
+ private debug;
12
+ /** Creates an empty run covering every point of the configured suite. */
13
+ createRun(): Promise<number | undefined>;
14
+ publishResults(results: TestResultItem[], options?: PublishOptions): Promise<number | undefined>;
15
+ /** Marks a run as completed. Defaults to the run used by the last publish. */
16
+ completeRun(runId?: number | undefined): Promise<void>;
17
+ }
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AzureDevOpsService = void 0;
37
+ const azdev = __importStar(require("azure-devops-node-api"));
38
+ class AzureDevOpsService {
39
+ testApiPromise;
40
+ config;
41
+ currentRunId;
42
+ /** Whether a PAT was provided; when false, every public method is a no-op. */
43
+ enabled;
44
+ constructor(config) {
45
+ this.config = config;
46
+ this.currentRunId = config.runId;
47
+ this.enabled = Boolean(config.token);
48
+ if (!this.enabled) {
49
+ console.warn("Azure DevOps PAT (token) not provided; Azure DevOps test result publishing is disabled.");
50
+ return;
51
+ }
52
+ const authHandler = azdev.getPersonalAccessTokenHandler(config.token);
53
+ const connection = new azdev.WebApi(config.orgUrl, authHandler);
54
+ this.testApiPromise = connection.getTestApi();
55
+ }
56
+ /** Id of the run currently being published to, if any. */
57
+ get runId() {
58
+ return this.currentRunId;
59
+ }
60
+ debug(message, payload) {
61
+ if (!this.config.debug)
62
+ return;
63
+ console.log(message, payload);
64
+ }
65
+ /** Creates an empty run covering every point of the configured suite. */
66
+ async createRun() {
67
+ if (!this.enabled)
68
+ return undefined;
69
+ 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
+ const testRun = await testApi.createTestRun({
78
+ name: this.config.runName ||
79
+ `Automated Test Run - ${new Date().toISOString()}`,
80
+ automated: true,
81
+ plan: { id: this.config.planId.toString() },
82
+ pointIds,
83
+ configurationIds,
84
+ }, this.config.projectName);
85
+ if (!testRun.id) {
86
+ throw new Error("Failed to create Test Run in Azure DevOps.");
87
+ }
88
+ this.currentRunId = testRun.id;
89
+ return testRun.id;
90
+ }
91
+ async publishResults(results, options = {}) {
92
+ if (!this.enabled)
93
+ return undefined;
94
+ const testApi = await this.testApiPromise;
95
+ // 1. Get test points matching the local test cases
96
+ const points = options.points ??
97
+ (await testApi.getPoints(this.config.projectName, this.config.planId, this.config.suiteId));
98
+ this.debug("Fetched test points:", points);
99
+ const targetCaseIds = new Set(results.map((r) => r.testCaseId));
100
+ const matchedPoints = points.filter((p) => p.testCase?.id && targetCaseIds.has(parseInt(p.testCase.id, 10)));
101
+ this.debug("Matched test points:", matchedPoints);
102
+ if (matchedPoints.length === 0) {
103
+ console.warn("No matching test points found in Azure DevOps for the given test cases.");
104
+ return;
105
+ }
106
+ const pointIds = matchedPoints.map((p) => p.id);
107
+ this.debug("Point IDs for the test run:", pointIds);
108
+ // Extract unique configuration IDs from matched points (or fallback to empty array/default)
109
+ const configurationIds = Array.from(new Set(matchedPoints
110
+ .map((p) => p.configuration?.id ? parseInt(p.configuration.id, 10) : null)
111
+ .filter((id) => id !== null)));
112
+ this.debug("Configuration IDs for the test run:", configurationIds);
113
+ // 2. Reuse the existing Test Run when asked, otherwise create a new one
114
+ const reusedRunId = options.runId ??
115
+ (this.config.reuseTestRun ? this.currentRunId : undefined) ??
116
+ this.config.runId;
117
+ let runId;
118
+ if (reusedRunId) {
119
+ runId = reusedRunId;
120
+ // Only add points that the run does not already hold a result for
121
+ const existingResults = await testApi.getTestResults(this.config.projectName, runId);
122
+ const existingCaseIds = new Set(existingResults.map((r) => r.testCase?.id));
123
+ const missingPoints = matchedPoints.filter((p) => !existingCaseIds.has(p.testCase.id));
124
+ if (missingPoints.length > 0) {
125
+ await testApi.addTestResultsToTestRun(missingPoints.map((p) => ({
126
+ testPoint: { id: p.id.toString() },
127
+ testCase: { id: p.testCase.id },
128
+ configuration: p.configuration?.id
129
+ ? { id: p.configuration.id }
130
+ : undefined,
131
+ })), this.config.projectName, runId);
132
+ }
133
+ }
134
+ else {
135
+ const runName = this.config.runName ||
136
+ `Automated Test Run - ${new Date().toISOString()}`;
137
+ const testRun = await testApi.createTestRun({
138
+ name: runName,
139
+ automated: true,
140
+ plan: { id: this.config.planId.toString() },
141
+ pointIds: pointIds,
142
+ configurationIds: configurationIds,
143
+ }, this.config.projectName);
144
+ if (!testRun.id) {
145
+ throw new Error("Failed to create Test Run in Azure DevOps.");
146
+ }
147
+ runId = testRun.id;
148
+ }
149
+ this.currentRunId = runId;
150
+ // 3. Fetch automatically created results for the run
151
+ const runResults = await testApi.getTestResults(this.config.projectName, runId);
152
+ // 4. Map outcomes and error messages
153
+ this.debug("Run results fetched from Azure DevOps:", runResults);
154
+ const updatedResults = runResults
155
+ .filter((result) => results.some((r) => r.testCaseId.toString() === result.testCase?.id))
156
+ .map((result) => {
157
+ const match = results.find((r) => r.testCaseId.toString() === result.testCase?.id);
158
+ return {
159
+ ...result,
160
+ outcome: match ? match.outcome : "Inconclusive",
161
+ errorMessage: match?.errorMessage || "",
162
+ state: "Completed",
163
+ durationInMs: match?.durationInMs || 0,
164
+ };
165
+ });
166
+ // 5. Update test results in ADO
167
+ this.debug("Updating test results in Azure DevOps:", updatedResults);
168
+ const savedResults = await testApi.updateTestResults(updatedResults, this.config.projectName, runId);
169
+ this.debug("Saved test results:", savedResults);
170
+ // The real API doesn't always echo `testCase` back on the updated results,
171
+ // so fall back to the id->caseId mapping we already know from step 4.
172
+ const caseIdByResultId = new Map();
173
+ for (const result of updatedResults) {
174
+ if (result.id && result.testCase?.id) {
175
+ caseIdByResultId.set(result.id, result.testCase.id);
176
+ }
177
+ }
178
+ // 6. Upload Attachments
179
+ for (const savedResult of savedResults) {
180
+ if (!savedResult.id)
181
+ continue;
182
+ const caseId = savedResult.testCase?.id ?? caseIdByResultId.get(savedResult.id);
183
+ if (!caseId)
184
+ continue;
185
+ const matchedLocalResult = results.find((r) => r.testCaseId.toString() === caseId);
186
+ if (matchedLocalResult?.attachments &&
187
+ matchedLocalResult.attachments.length > 0) {
188
+ for (const attachment of matchedLocalResult.attachments) {
189
+ const attachmentModel = {
190
+ fileName: attachment.fileName,
191
+ stream: attachment.base64Content,
192
+ comment: attachment.comment || "Automated execution screenshot",
193
+ attachmentType: attachment.attachmentType || "GeneralAttachment",
194
+ };
195
+ // Log metadata only; the base64 stream is far too large to print.
196
+ this.debug("Uploading attachment:", {
197
+ resultId: savedResult.id,
198
+ caseId,
199
+ fileName: attachmentModel.fileName,
200
+ attachmentType: attachmentModel.attachmentType,
201
+ base64Length: attachment.base64Content?.length ?? 0,
202
+ });
203
+ const savedAttachment = await testApi.createTestResultAttachment(attachmentModel, this.config.projectName, runId, savedResult.id);
204
+ this.debug("Attachment uploaded:", savedAttachment);
205
+ }
206
+ }
207
+ else {
208
+ this.debug("No attachments to upload for case:", caseId);
209
+ }
210
+ }
211
+ // 7. Complete the Test Run unless more results are still to come
212
+ const keepRunOpen = options.keepRunOpen ?? this.config.reuseTestRun ?? false;
213
+ if (!keepRunOpen) {
214
+ await this.completeRun(runId);
215
+ }
216
+ return runId;
217
+ }
218
+ /** Marks a run as completed. Defaults to the run used by the last publish. */
219
+ async completeRun(runId = this.currentRunId) {
220
+ if (!this.enabled || !runId)
221
+ return;
222
+ const testApi = await this.testApiPromise;
223
+ await testApi.updateTestRun({ state: "Completed" }, this.config.projectName, runId);
224
+ if (runId === this.currentRunId) {
225
+ this.currentRunId = undefined;
226
+ }
227
+ }
228
+ }
229
+ exports.AzureDevOpsService = AzureDevOpsService;
@@ -0,0 +1,23 @@
1
+ import { AzureDevOpsWdioOptions } from "./types";
2
+ export declare class AzureDevOpsReporterService {
3
+ private options;
4
+ private results;
5
+ private ado?;
6
+ constructor(options: AzureDevOpsWdioOptions);
7
+ afterTest(test: {
8
+ title: string;
9
+ duration: number;
10
+ }, _context: unknown, results: {
11
+ passed: boolean;
12
+ error?: Error;
13
+ }, browserInstance?: {
14
+ takeScreenshot: () => Promise<string>;
15
+ }): Promise<void>;
16
+ onComplete(): Promise<void>;
17
+ /** Completes the shared test run when `reuseTestRun` keeps it open. */
18
+ completeRun(): Promise<void>;
19
+ private extractTestCaseId;
20
+ }
21
+ export * from "./types";
22
+ export * from "./azureService";
23
+ export * from "./wdioService";
package/dist/index.js ADDED
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.AzureDevOpsReporterService = void 0;
18
+ const azureService_1 = require("./azureService");
19
+ const utils_1 = require("./utils");
20
+ class AzureDevOpsReporterService {
21
+ options;
22
+ results = [];
23
+ ado;
24
+ constructor(options) {
25
+ this.options = options;
26
+ }
27
+ async afterTest(test, _context, results, browserInstance) {
28
+ const caseId = this.extractTestCaseId(test.title);
29
+ if (!caseId)
30
+ return;
31
+ const attachments = [];
32
+ if (!results.passed &&
33
+ browserInstance &&
34
+ this.options.screenshotOnFailure !== false) {
35
+ try {
36
+ const base64Png = await browserInstance.takeScreenshot();
37
+ attachments.push({
38
+ fileName: `failure-C${caseId}.png`,
39
+ base64Content: base64Png,
40
+ comment: `Failure screenshot for test: ${test.title}`,
41
+ });
42
+ }
43
+ catch (err) {
44
+ console.error("Failed to capture browser screenshot:", err);
45
+ }
46
+ }
47
+ this.results.push({
48
+ testCaseId: caseId,
49
+ outcome: results.passed ? "Passed" : "Failed",
50
+ errorMessage: results.error?.message,
51
+ durationInMs: test.duration,
52
+ attachments,
53
+ });
54
+ }
55
+ async onComplete() {
56
+ if (this.results.length === 0)
57
+ return;
58
+ this.ado ??= new azureService_1.AzureDevOpsService(this.options);
59
+ await this.ado.publishResults(this.results);
60
+ this.results = [];
61
+ }
62
+ /** Completes the shared test run when `reuseTestRun` keeps it open. */
63
+ async completeRun() {
64
+ await this.ado?.completeRun();
65
+ }
66
+ extractTestCaseId(title) {
67
+ const caseId = (0, utils_1.extractTestCaseId)(title, this.options.caseIdPattern);
68
+ if (this.options.debug) {
69
+ console.log("Extracted case id from test title:", {
70
+ title,
71
+ pattern: String(this.options.caseIdPattern ?? "default C123/#123"),
72
+ caseId,
73
+ });
74
+ }
75
+ return caseId;
76
+ }
77
+ }
78
+ exports.AzureDevOpsReporterService = AzureDevOpsReporterService;
79
+ __exportStar(require("./types"), exports);
80
+ __exportStar(require("./azureService"), exports);
81
+ __exportStar(require("./wdioService"), exports);
@@ -0,0 +1,43 @@
1
+ import { TestPoint } from "azure-devops-node-api/interfaces/TestInterfaces";
2
+ export interface AzureDevOpsOptions {
3
+ orgUrl: string;
4
+ token: string;
5
+ /** Project display name or its GUID; Azure DevOps accepts either. */
6
+ projectName: string;
7
+ planId: number;
8
+ suiteId: number;
9
+ runName?: string;
10
+ /** Reuse this already existing test run instead of creating a new one. */
11
+ runId?: number;
12
+ /** Publish every batch into a single run, created on the first publish. */
13
+ reuseTestRun?: boolean;
14
+ /** Custom regex (with a capturing group for the numeric id) used instead of the default `C123`/`#123` matcher. */
15
+ caseIdPattern?: RegExp;
16
+ /** Log Azure DevOps API payloads to the console. Off by default. */
17
+ debug?: boolean;
18
+ }
19
+ export interface PublishOptions {
20
+ /** Publish into this existing run instead of creating a new one. */
21
+ runId?: number;
22
+ /** Reuse already-fetched test points instead of calling `getPoints` again. */
23
+ points?: TestPoint[];
24
+ /** Leave the run in progress so more results can be added later. */
25
+ keepRunOpen?: boolean;
26
+ }
27
+ export interface AzureDevOpsWdioOptions extends AzureDevOpsOptions {
28
+ /** Attach a browser screenshot to failed results. Defaults to true. */
29
+ screenshotOnFailure?: boolean;
30
+ }
31
+ export interface TestAttachment {
32
+ fileName: string;
33
+ base64Content: string;
34
+ comment?: string;
35
+ attachmentType?: string;
36
+ }
37
+ export interface TestResultItem {
38
+ testCaseId: number;
39
+ outcome: "Passed" | "Failed" | "Inconclusive";
40
+ errorMessage?: string;
41
+ durationInMs?: number;
42
+ attachments?: TestAttachment[];
43
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Reads the Azure DevOps case id out of a test title tagged with `C123` or `#123`.
3
+ * Pass a custom `pattern` (with a capturing group for the numeric id) to override the default.
4
+ */
5
+ export declare function extractTestCaseId(title: string, pattern?: RegExp): number | null;
package/dist/utils.js ADDED
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractTestCaseId = extractTestCaseId;
4
+ /**
5
+ * Reads the Azure DevOps case id out of a test title tagged with `C123` or `#123`.
6
+ * Pass a custom `pattern` (with a capturing group for the numeric id) to override the default.
7
+ */
8
+ function extractTestCaseId(title, pattern) {
9
+ const match = pattern
10
+ ? title.match(pattern)
11
+ : title.match(/C(\d+)/i) || title.match(/#(\d+)/);
12
+ if (!match?.[1])
13
+ return null;
14
+ const caseId = parseInt(match[1], 10);
15
+ return Number.isNaN(caseId) ? null : caseId;
16
+ }
@@ -0,0 +1,53 @@
1
+ import { AzureDevOpsWdioOptions } from "./types";
2
+ /** Shares the run id created in the launcher process with the worker processes. */
3
+ 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
+ interface CucumberPickleTag {
14
+ name: string;
15
+ }
16
+ /** Shape of the `world` argument WebdriverIO's Cucumber framework passes to `afterScenario`. */
17
+ interface CucumberWorld {
18
+ pickle: {
19
+ name: string;
20
+ tags?: CucumberPickleTag[];
21
+ };
22
+ }
23
+ interface CucumberResult {
24
+ passed: boolean;
25
+ duration?: number;
26
+ error?: Error;
27
+ }
28
+ /**
29
+ * WebdriverIO service that creates a single Test Run in `onPrepare`, pushes every
30
+ * spec's results into that run, and completes it in `onComplete`.
31
+ */
32
+ export declare class AzureDevOpsWdioService {
33
+ private options;
34
+ private results;
35
+ private service?;
36
+ constructor(options: AzureDevOpsWdioOptions);
37
+ onPrepare(): Promise<void>;
38
+ onComplete(): Promise<void>;
39
+ private debug;
40
+ afterTest(test: WdioTest, _context: unknown, results: WdioTestResult): Promise<void>;
41
+ /** Cucumber hook for BDD feature files; reads the case id from a `@C123` tag or the scenario name. */
42
+ afterScenario(world: CucumberWorld, result: CucumberResult): Promise<void>;
43
+ after(): Promise<void>;
44
+ /** Publishes everything collected so far into the shared run. */
45
+ publish(): Promise<void>;
46
+ private captureScreenshot;
47
+ private extractCucumberCaseId;
48
+ private resolveRunId;
49
+ private getService;
50
+ }
51
+ /** WebdriverIO looks for a `launcher` export to run `onPrepare`/`onComplete`. */
52
+ export declare const launcher: typeof AzureDevOpsWdioService;
53
+ export default AzureDevOpsWdioService;
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.launcher = exports.AzureDevOpsWdioService = exports.RUN_ID_ENV_VAR = void 0;
4
+ const azureService_1 = require("./azureService");
5
+ const utils_1 = require("./utils");
6
+ /** Shares the run id created in the launcher process with the worker processes. */
7
+ exports.RUN_ID_ENV_VAR = "AZURE_DEVOPS_TEST_RUN_ID";
8
+ /**
9
+ * WebdriverIO service that creates a single Test Run in `onPrepare`, pushes every
10
+ * spec's results into that run, and completes it in `onComplete`.
11
+ */
12
+ class AzureDevOpsWdioService {
13
+ options;
14
+ results = [];
15
+ service;
16
+ constructor(options) {
17
+ this.options = options;
18
+ }
19
+ // --- launcher process hooks ---
20
+ async onPrepare() {
21
+ const runId = await this.getService().createRun();
22
+ if (runId === undefined)
23
+ return;
24
+ process.env[exports.RUN_ID_ENV_VAR] = runId.toString();
25
+ console.log(`Azure DevOps test run created: ${runId}`);
26
+ }
27
+ async onComplete() {
28
+ const runId = this.resolveRunId();
29
+ if (!runId)
30
+ return;
31
+ await this.getService().completeRun(runId);
32
+ delete process.env[exports.RUN_ID_ENV_VAR];
33
+ console.log(`Azure DevOps test run completed: ${runId}`);
34
+ }
35
+ // --- worker process hooks ---
36
+ debug(message, payload) {
37
+ if (!this.options.debug)
38
+ return;
39
+ console.log(message, payload);
40
+ }
41
+ async afterTest(test, _context, results) {
42
+ const caseId = (0, utils_1.extractTestCaseId)(test.title, this.options.caseIdPattern);
43
+ this.debug("Extracted case id from test title:", {
44
+ title: test.title,
45
+ pattern: String(this.options.caseIdPattern ?? "default C123/#123"),
46
+ caseId,
47
+ });
48
+ if (!caseId)
49
+ return;
50
+ this.results.push({
51
+ testCaseId: caseId,
52
+ outcome: results.passed ? "Passed" : "Failed",
53
+ errorMessage: results.error?.message,
54
+ durationInMs: results.duration ?? 0,
55
+ attachments: await this.captureScreenshot(caseId, test, results),
56
+ });
57
+ }
58
+ /** Cucumber hook for BDD feature files; reads the case id from a `@C123` tag or the scenario name. */
59
+ async afterScenario(world, result) {
60
+ const caseId = this.extractCucumberCaseId(world);
61
+ if (!caseId)
62
+ return;
63
+ this.results.push({
64
+ testCaseId: caseId,
65
+ outcome: result.passed ? "Passed" : "Failed",
66
+ errorMessage: result.error?.message,
67
+ durationInMs: result.duration ?? 0,
68
+ attachments: await this.captureScreenshot(caseId, { title: world.pickle.name }, result),
69
+ });
70
+ }
71
+ async after() {
72
+ await this.publish();
73
+ }
74
+ /** Publishes everything collected so far into the shared run. */
75
+ async publish() {
76
+ if (this.results.length === 0)
77
+ return;
78
+ const pending = this.results;
79
+ this.results = [];
80
+ try {
81
+ await this.getService().publishResults(pending, {
82
+ runId: this.resolveRunId(),
83
+ keepRunOpen: true,
84
+ });
85
+ }
86
+ catch (err) {
87
+ console.error("Failed to publish results to Azure DevOps:", err);
88
+ }
89
+ }
90
+ async captureScreenshot(caseId, test, results) {
91
+ if (results.passed || this.options.screenshotOnFailure === false)
92
+ return [];
93
+ const browser = globalThis
94
+ .browser;
95
+ if (!browser?.takeScreenshot)
96
+ return [];
97
+ try {
98
+ return [
99
+ {
100
+ fileName: `failure-C${caseId}.png`,
101
+ base64Content: await browser.takeScreenshot(),
102
+ comment: `Failure screenshot for test: ${test.fullTitle || test.title}`,
103
+ },
104
+ ];
105
+ }
106
+ catch (err) {
107
+ console.error("Failed to capture browser screenshot:", err);
108
+ return [];
109
+ }
110
+ }
111
+ extractCucumberCaseId(world) {
112
+ const pattern = this.options.caseIdPattern;
113
+ const tags = world.pickle.tags ?? [];
114
+ this.debug("Scanning scenario tags for case id:", {
115
+ scenario: world.pickle.name,
116
+ tags: tags.map((t) => t.name),
117
+ pattern: String(pattern ?? "default C123/#123"),
118
+ });
119
+ for (const tag of tags) {
120
+ const caseId = (0, utils_1.extractTestCaseId)(tag.name, pattern);
121
+ if (caseId) {
122
+ this.debug("Matched case id from tag:", { tag: tag.name, caseId });
123
+ return caseId;
124
+ }
125
+ }
126
+ const fromName = (0, utils_1.extractTestCaseId)(world.pickle.name, pattern);
127
+ this.debug("No tag matched; fell back to scenario name:", {
128
+ scenario: world.pickle.name,
129
+ caseId: fromName,
130
+ });
131
+ return fromName;
132
+ }
133
+ resolveRunId() {
134
+ const fromEnv = process.env[exports.RUN_ID_ENV_VAR];
135
+ const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;
136
+ return Number.isNaN(parsed) ? this.options.runId : parsed;
137
+ }
138
+ getService() {
139
+ this.service ??= new azureService_1.AzureDevOpsService(this.options);
140
+ return this.service;
141
+ }
142
+ }
143
+ exports.AzureDevOpsWdioService = AzureDevOpsWdioService;
144
+ /** WebdriverIO looks for a `launcher` export to run `onPrepare`/`onComplete`. */
145
+ exports.launcher = AzureDevOpsWdioService;
146
+ exports.default = AzureDevOpsWdioService;
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@virag8/azure-devops-test-publisher",
3
+ "version": "1.0.0",
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
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "build": "rimraf dist && tsc",
43
+ "prepublishOnly": "npm run test:unit && npm run build",
44
+ "test:unit": "jest --config tests/jest.config.js",
45
+ "test:real": "jest --config tests/jest.real.config.js",
46
+ "test": "npm run test:unit"
47
+ },
48
+ "dependencies": {
49
+ "azure-devops-node-api": "^12.0.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/jest": "^30.0.0",
53
+ "@types/node": "^20.0.0",
54
+ "dotenv": "^17.4.2",
55
+ "jest": "^30.5.1",
56
+ "rimraf": "^5.0.0",
57
+ "ts-jest": "^29.4.12",
58
+ "typescript": "^5.0.0"
59
+ }
60
+ }