@virag8/azure-devops-test-publisher 1.0.1 → 1.2.2

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 CHANGED
@@ -5,12 +5,17 @@ Publish automated test results and failure screenshots from WebdriverIO (Mocha o
5
5
  ## Features
6
6
 
7
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
+ - **Only executed tests appear in the run** — the run is created empty and test points are attached as tests finish, so cases in the suite that never ran are not left sitting in an _In progress_ state.
8
9
  - **Mocha support** — extracts the Azure DevOps test case id from a test title (e.g. `C1234 login works`) via the `afterTest` hook.
9
10
  - **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
11
  - **Custom case id pattern** — override the default `C123`/`#123` matcher with your own regex (e.g. `TC-(\d+)`) via `caseIdPattern`.
11
12
  - **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
+ - **Configuration-aware publishing** — scope a publish to a single Azure DevOps configuration (Android vs iOS, Chrome vs Firefox, …) so parallel jobs never overwrite each other's results for the same test case.
14
+ - **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. A run is only created when no run id is supplied — `0` counts as "not supplied".
15
+ - **"Run by" populated** — results are stamped with the identity that owns the PAT instead of showing an empty _Run by_ column.
13
16
  - **Point pre-fetch** — pass already-fetched test points via `PublishOptions.points` to skip a redundant Azure DevOps API call.
17
+ - **Fail-fast option validation** — a missing `orgUrl`, `projectId`, `planId` or `suiteId` throws a typed `AzureDevOpsConfigError` listing every offending key, at construction time rather than mid-run.
18
+ - **Actionable diagnostics** — warnings name the project, plan, suite, configuration and the exact test case ids that could not be matched.
14
19
  - **Resilient publishing** — publish failures are caught and logged so a flaky Azure DevOps API never fails the test run itself.
15
20
  - **Standalone reporter service** — `AzureDevOpsReporterService` for custom/non-service integrations that just need `afterTest` + `onComplete` hooks.
16
21
  - **Quiet by default** — set `debug: true` to log raw Azure DevOps API payloads while troubleshooting.
@@ -41,7 +46,7 @@ exports.config = {
41
46
  {
42
47
  orgUrl: process.env.AZURE_ORG_URL,
43
48
  token: process.env.AZURE_PAT,
44
- projectName: "MyProject", // name or GUID
49
+ projectId: "MyProject", // name or GUID
45
50
  planId: 123,
46
51
  suiteId: 456,
47
52
  screenshotOnFailure: true, // optional, defaults to true
@@ -85,6 +90,97 @@ If no tag is present, the case id is parsed from the scenario name instead.
85
90
 
86
91
  The pattern must contain exactly one capturing group for the numeric id.
87
92
 
93
+ ### Test configurations (Android vs iOS, Chrome vs Firefox, …)
94
+
95
+ If the same test case exists in your suite under several Azure DevOps _configurations_, tell each worker which configuration it represents. Without this, a result published for one configuration can overwrite another configuration's result for the same case.
96
+
97
+ ```js
98
+ {
99
+ configurationId: Number(process.env.ADO_CONFIGURATION_ID), // e.g. 1042 = Android
100
+ }
101
+ ```
102
+
103
+ With `configurationId` set, the service:
104
+
105
+ - attaches only the test point belonging to that configuration to the run, and
106
+ - updates only the result for that configuration, leaving the others untouched.
107
+
108
+ Run one WebdriverIO process per configuration:
109
+
110
+ ```bash
111
+ ADO_CONFIGURATION_ID=1042 npx wdio run wdio.android.conf.ts
112
+ ADO_CONFIGURATION_ID=1043 npx wdio run wdio.ios.conf.ts
113
+ ```
114
+
115
+ Find the id under **Test Plans → Configurations**, or at `https://dev.azure.com/<org>/<project>/_apis/test/configurations`.
116
+
117
+ > While `configurationId` is set, points and results that have no configuration are skipped. Leave it unset for suites that don't use configurations.
118
+
119
+ ### Reusing an existing Test Run
120
+
121
+ A run is created in `onPrepare` **only** when no run id is available. The id is looked up in this order:
122
+
123
+ 1. the `AZURE_DEVOPS_TEST_RUN_ID` environment variable, then
124
+ 2. the `runId` service option.
125
+
126
+ Both `0` and an unset value mean "create a new run". To publish into a run created elsewhere (e.g. by an earlier pipeline stage):
127
+
128
+ ```bash
129
+ # PowerShell
130
+ $env:AZURE_DEVOPS_TEST_RUN_ID = "12345"; npx wdio run wdio.conf.ts
131
+
132
+ # bash
133
+ AZURE_DEVOPS_TEST_RUN_ID=12345 npx wdio run wdio.conf.ts
134
+ ```
135
+
136
+ The variable name is exported as a constant so you don't have to hardcode it:
137
+
138
+ ```ts
139
+ import { RUN_ID_ENV_VAR } from "@virag8/azure-devops-test-publisher";
140
+
141
+ console.log(`Publishing into run ${process.env[RUN_ID_ENV_VAR]}`);
142
+ ```
143
+
144
+ Workers inherit the variable from the launcher process, so it is readable inside specs and hooks. It is cleared again in `onComplete` and does not propagate back to the shell that started WebdriverIO.
145
+
146
+ ### Validating configuration early
147
+
148
+ `orgUrl`, `projectId`, `planId` and `suiteId` are mandatory. If any is missing or malformed, construction throws `AzureDevOpsConfigError` before a single test runs:
149
+
150
+ ```ts
151
+ import {
152
+ AzureDevOpsService,
153
+ AzureDevOpsConfigError,
154
+ } from "@virag8/azure-devops-test-publisher";
155
+
156
+ try {
157
+ new AzureDevOpsService(options);
158
+ } catch (err) {
159
+ if (err instanceof AzureDevOpsConfigError) {
160
+ console.error("Bad Azure DevOps config:", err.missing); // e.g. ["planId", "suiteId"]
161
+ }
162
+ }
163
+ ```
164
+
165
+ ```
166
+ AzureDevOpsConfigError: Missing or invalid Azure DevOps option(s): planId, suiteId.
167
+ Provide them when constructing the service or in the wdio service options.
168
+ ```
169
+
170
+ A missing `token` is **not** an error — it simply disables publishing with a warning, which keeps local runs working without a PAT.
171
+
172
+ ### Troubleshooting unmatched test cases
173
+
174
+ When a case id from a title or tag has no matching test point, the exact ids are logged:
175
+
176
+ ```
177
+ No test point found for test case id(s) 9999 in project "MyProject", plan 123, suite 456, configuration 1042.
178
+ The suite exposes 12 point(s) for case id(s) 1001, 1002, … Check that the case ids in your test
179
+ titles belong to this plan/suite and configuration.
180
+ ```
181
+
182
+ Common causes: the case lives in a different suite, the suite id belongs to another plan, or the worker's `configurationId` doesn't match the point's configuration. Set `debug: true` to also dump the raw API payloads.
183
+
88
184
  ## Usage as a standalone reporter
89
185
 
90
186
  For custom runners that aren't WebdriverIO services, use `AzureDevOpsReporterService` directly:
@@ -95,7 +191,7 @@ import { AzureDevOpsReporterService } from "@virag8/azure-devops-test-publisher"
95
191
  const reporter = new AzureDevOpsReporterService({
96
192
  orgUrl: process.env.AZURE_ORG_URL!,
97
193
  token: process.env.AZURE_PAT!,
98
- projectName: "MyProject",
194
+ projectId: "MyProject",
99
195
  planId: 123,
100
196
  suiteId: 456,
101
197
  });
@@ -118,7 +214,7 @@ import { AzureDevOpsService } from "@virag8/azure-devops-test-publisher";
118
214
  const ado = new AzureDevOpsService({
119
215
  orgUrl: process.env.AZURE_ORG_URL!,
120
216
  token: process.env.AZURE_PAT!,
121
- projectName: "MyProject",
217
+ projectId: "MyProject",
122
218
  planId: 123,
123
219
  suiteId: 456,
124
220
  });
@@ -132,38 +228,62 @@ await ado.publishResults([
132
228
  ]);
133
229
  ```
134
230
 
231
+ Scope a publish to one configuration, and keep the run open for later batches:
232
+
233
+ ```ts
234
+ const runId = await ado.createRun();
235
+
236
+ await ado.publishResults(
237
+ [{ testCaseId: 1234, outcome: "Failed", errorMessage: "boom" }],
238
+ { runId, configurationId: 1042, keepRunOpen: true },
239
+ );
240
+
241
+ await ado.completeRun(runId);
242
+ ```
243
+
244
+ `createRun()` creates an **empty** run; points are attached by `publishResults` as results come in, so cases that never executed stay out of the run.
245
+
135
246
  ## Configuration reference
136
247
 
137
248
  ### `AzureDevOpsOptions`
138
249
 
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`.
250
+ | Option | Type | Description |
251
+ | --------------- | ---------- | ------------------------------------------------------------------------------------ |
252
+ | `orgUrl` | `string` | Azure DevOps organization URL. |
253
+ | `token` | `string` | Personal access token with Test Plan read/write permissions. |
254
+ | `projectId` | `string` | Azure DevOps project **name or id (GUID)** — both are accepted. |
255
+ | `planId` | `number` | Test plan id. |
256
+ | `suiteId` | `number` | Test suite id within the plan. |
257
+ | `runName` | `string?` | Custom name for created runs. |
258
+ | `runId` | `number?` | Reuse this existing run instead of creating a new one. `0` means "create a new run". |
259
+ | `reuseTestRun` | `boolean?` | Keep a single run open across multiple `publishResults` calls. |
260
+ | `caseIdPattern` | `RegExp?` | Custom regex (one capturing group) for extracting the test case id. |
261
+ | `debug` | `boolean?` | Log raw Azure DevOps API payloads. Defaults to `false`. |
262
+
263
+ > **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
264
 
154
265
  ### `AzureDevOpsWdioOptions` (extends `AzureDevOpsOptions`)
155
266
 
156
- | Option | Type | Description |
157
- | --------------------- | ---------- | ------------------------------------------------------------------ |
158
- | `screenshotOnFailure` | `boolean?` | Attach a browser screenshot to failed results. Defaults to `true`. |
267
+ | Option | Type | Description |
268
+ | --------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
269
+ | `screenshotOnFailure` | `boolean?` | Attach a browser screenshot to failed results. Defaults to `true`. |
270
+ | `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
271
 
160
272
  ### `PublishOptions`
161
273
 
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. |
274
+ | Option | Type | Description |
275
+ | ----------------- | -------------- | --------------------------------------------------------------------------------------------------------- |
276
+ | `runId` | `number?` | Publish into this existing run instead of creating a new one. `0` is treated as "not set". |
277
+ | `points` | `TestPoint[]?` | Reuse already-fetched test points instead of calling `getPoints` again. |
278
+ | `configurationId` | `number?` | Only publish to the test point/result of this configuration id; points and results in others are skipped. |
279
+ | `keepRunOpen` | `boolean?` | Leave the run in progress so more results can be added later. |
280
+
281
+ ### `AzureDevOpsConfigError`
282
+
283
+ | Member | Type | Description |
284
+ | --------- | ---------- | ------------------------------------------------------- |
285
+ | `missing` | `string[]` | The mandatory option keys that were missing or invalid. |
286
+ | `message` | `string` | Human readable summary listing every offending key. |
167
287
 
168
288
  ## Development
169
289
 
@@ -173,6 +293,8 @@ npm run test:real # integration tests against a real Azure DevOps org (require
173
293
  npm run build # compile to dist/
174
294
  ```
175
295
 
296
+ `npm run test:real` reads its credentials from a local `.env` file (`AZURE_ORG_URL`, `AZURE_PAT`, `AZURE_PROJECT`, `AZURE_PLAN_ID`, `AZURE_SUITE_ID`, `AZURE_TEST_CASE_ID`) and skips itself when they are absent.
297
+
176
298
  ## License
177
299
 
178
300
  MIT
@@ -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
- /** Creates an empty run covering every point of the configured suite. */
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. */
@@ -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
- /** Creates an empty run covering every point of the configured suite. */
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
- pointIds,
83
- configurationIds,
84
- }, this.config.projectName);
143
+ configurationIds: [],
144
+ }, this.config.projectId);
85
145
  if (!testRun.id) {
86
- throw new Error("Failed to create Test Run in Azure DevOps.");
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.projectName, this.config.planId, this.config.suiteId));
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 && targetCaseIds.has(parseInt(p.testCase.id, 10)));
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
- if (matchedPoints.length === 0) {
103
- console.warn("No matching test points found in Azure DevOps for the given test cases.");
104
- return;
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.projectName, runId);
122
- const existingCaseIds = new Set(existingResults.map((r) => r.testCase?.id));
123
- const missingPoints = matchedPoints.filter((p) => !existingCaseIds.has(p.testCase.id));
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(missingPoints.map((p) => ({
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.projectName, runId);
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.projectName);
221
+ }, this.config.projectId);
144
222
  if (!testRun.id) {
145
- throw new Error("Failed to create Test Run in Azure DevOps.");
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.projectName, runId);
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) => results.some((r) => r.testCaseId.toString() === result.testCase?.id))
234
+ .filter((result) => inTargetConfiguration(result) &&
235
+ results.some((r) => matchesTestCase(result, r)))
156
236
  .map((result) => {
157
- const match = results.find((r) => r.testCaseId.toString() === result.testCase?.id);
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.projectName, runId);
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 results,
171
- // so fall back to the id->caseId mapping we already know from step 4.
172
- const caseIdByResultId = new Map();
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 && result.testCase?.id) {
175
- caseIdByResultId.set(result.id, result.testCase.id);
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 caseId = savedResult.testCase?.id ?? caseIdByResultId.get(savedResult.id);
266
+ const info = resultInfoById.get(savedResult.id);
267
+ const caseId = savedResult.testCase?.id ?? info?.caseId;
183
268
  if (!caseId)
184
269
  continue;
185
- const matchedLocalResult = results.find((r) => r.testCaseId.toString() === caseId);
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.projectName, runId, savedResult.id);
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.projectName, runId);
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
- projectName: string;
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
  }
@@ -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,34 +14,30 @@ 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 declare class AzureDevOpsWdioService {
33
- private options;
21
+ export default class AzureDevOpsWdioService implements Services.ServiceInstance {
22
+ private readonly _options;
34
23
  private results;
35
24
  private service?;
36
- constructor(options: AzureDevOpsWdioOptions);
25
+ constructor(_options: AzureDevOpsWdioOptions);
37
26
  onPrepare(): Promise<void>;
38
27
  onComplete(): Promise<void>;
39
28
  private debug;
40
- afterTest(test: WdioTest, _context: unknown, results: WdioTestResult): Promise<void>;
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: CucumberResult): Promise<void>;
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>;
46
37
  private captureScreenshot;
47
38
  private extractCucumberCaseId;
39
+ /** `0` is treated as "not set", so a fresh run gets created. */
48
40
  private resolveRunId;
49
41
  private getService;
50
42
  }
51
- /** WebdriverIO looks for a `launcher` export to run `onPrepare`/`onComplete`. */
52
- export declare const launcher: typeof AzureDevOpsWdioService;
53
- export default AzureDevOpsWdioService;
43
+ export { AzureDevOpsWdioService };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.launcher = exports.AzureDevOpsWdioService = exports.RUN_ID_ENV_VAR = void 0;
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,14 +10,21 @@ 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
- options;
13
+ _options;
14
14
  results = [];
15
15
  service;
16
- constructor(options) {
17
- this.options = options;
16
+ constructor(_options) {
17
+ this._options = _options;
18
+ (0, azureService_1.assertRequiredOptions)(_options);
18
19
  }
19
20
  // --- launcher process hooks ---
20
21
  async onPrepare() {
22
+ const existingRunId = this.resolveRunId();
23
+ if (existingRunId) {
24
+ process.env[exports.RUN_ID_ENV_VAR] = existingRunId.toString();
25
+ console.log(`Reusing Azure DevOps test run: ${existingRunId}`);
26
+ return;
27
+ }
21
28
  const runId = await this.getService().createRun();
22
29
  if (runId === undefined)
23
30
  return;
@@ -34,15 +41,15 @@ class AzureDevOpsWdioService {
34
41
  }
35
42
  // --- worker process hooks ---
36
43
  debug(message, payload) {
37
- if (!this.options.debug)
44
+ if (!this._options.debug)
38
45
  return;
39
46
  console.log(message, payload);
40
47
  }
41
48
  async afterTest(test, _context, results) {
42
- const caseId = (0, utils_1.extractTestCaseId)(test.title, this.options.caseIdPattern);
49
+ const caseId = (0, utils_1.extractTestCaseId)(test.title, this._options.caseIdPattern);
43
50
  this.debug("Extracted case id from test title:", {
44
51
  title: test.title,
45
- pattern: String(this.options.caseIdPattern ?? "default C123/#123"),
52
+ pattern: String(this._options.caseIdPattern ?? "default C123/#123"),
46
53
  caseId,
47
54
  });
48
55
  if (!caseId)
@@ -53,6 +60,7 @@ class AzureDevOpsWdioService {
53
60
  errorMessage: results.error?.message,
54
61
  durationInMs: results.duration ?? 0,
55
62
  attachments: await this.captureScreenshot(caseId, test, results),
63
+ configurationId: this._options.configurationId,
56
64
  });
57
65
  }
58
66
  /** Cucumber hook for BDD feature files; reads the case id from a `@C123` tag or the scenario name. */
@@ -63,11 +71,18 @@ class AzureDevOpsWdioService {
63
71
  this.results.push({
64
72
  testCaseId: caseId,
65
73
  outcome: result.passed ? "Passed" : "Failed",
66
- errorMessage: result.error?.message,
74
+ errorMessage: this.stringifyError(result.error),
67
75
  durationInMs: result.duration ?? 0,
68
76
  attachments: await this.captureScreenshot(caseId, { title: world.pickle.name }, result),
77
+ configurationId: this._options.configurationId,
69
78
  });
70
79
  }
80
+ /** Cucumber's `error` is typed as a string, but some frameworks still pass a raw `Error`. */
81
+ stringifyError(error) {
82
+ if (!error)
83
+ return undefined;
84
+ return error instanceof Error ? error.message : String(error);
85
+ }
71
86
  async after() {
72
87
  await this.publish();
73
88
  }
@@ -81,6 +96,7 @@ class AzureDevOpsWdioService {
81
96
  await this.getService().publishResults(pending, {
82
97
  runId: this.resolveRunId(),
83
98
  keepRunOpen: true,
99
+ configurationId: this._options.configurationId,
84
100
  });
85
101
  }
86
102
  catch (err) {
@@ -88,7 +104,7 @@ class AzureDevOpsWdioService {
88
104
  }
89
105
  }
90
106
  async captureScreenshot(caseId, test, results) {
91
- if (results.passed || this.options.screenshotOnFailure === false)
107
+ if (results.passed || this._options.screenshotOnFailure === false)
92
108
  return [];
93
109
  const browser = globalThis
94
110
  .browser;
@@ -109,7 +125,7 @@ class AzureDevOpsWdioService {
109
125
  }
110
126
  }
111
127
  extractCucumberCaseId(world) {
112
- const pattern = this.options.caseIdPattern;
128
+ const pattern = this._options.caseIdPattern;
113
129
  const tags = world.pickle.tags ?? [];
114
130
  this.debug("Scanning scenario tags for case id:", {
115
131
  scenario: world.pickle.name,
@@ -130,17 +146,17 @@ class AzureDevOpsWdioService {
130
146
  });
131
147
  return fromName;
132
148
  }
149
+ /** `0` is treated as "not set", so a fresh run gets created. */
133
150
  resolveRunId() {
134
151
  const fromEnv = process.env[exports.RUN_ID_ENV_VAR];
135
152
  const parsed = fromEnv ? parseInt(fromEnv, 10) : NaN;
136
- return Number.isNaN(parsed) ? this.options.runId : parsed;
153
+ const runId = Number.isNaN(parsed) ? this._options.runId : parsed;
154
+ return runId || undefined;
137
155
  }
138
156
  getService() {
139
- this.service ??= new azureService_1.AzureDevOpsService(this.options);
157
+ this.service ??= new azureService_1.AzureDevOpsService(this._options);
140
158
  return this.service;
141
159
  }
142
160
  }
143
- exports.AzureDevOpsWdioService = AzureDevOpsWdioService;
144
- /** WebdriverIO looks for a `launcher` export to run `onPrepare`/`onComplete`. */
145
- exports.launcher = AzureDevOpsWdioService;
146
161
  exports.default = AzureDevOpsWdioService;
162
+ exports.AzureDevOpsWdioService = AzureDevOpsWdioService;
package/package.json CHANGED
@@ -1,62 +1,64 @@
1
- {
2
- "name": "@virag8/azure-devops-test-publisher",
3
- "version": "1.0.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
- },
50
- "dependencies": {
51
- "azure-devops-node-api": "^12.0.0"
52
- },
53
- "devDependencies": {
54
- "@types/jest": "^30.0.0",
55
- "@types/node": "^20.0.0",
56
- "dotenv": "^17.4.2",
57
- "jest": "^30.5.1",
58
- "rimraf": "^5.0.0",
59
- "ts-jest": "^29.4.12",
60
- "typescript": "^5.0.0"
61
- }
62
- }
1
+ {
2
+ "name": "@virag8/azure-devops-test-publisher",
3
+ "version": "1.2.2",
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
+ }