@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 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
- projectName: "MyProject", // name or GUID
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
- projectName: "MyProject",
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
- projectName: "MyProject",
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
- | `projectName` | `string` | Azure DevOps project **name or id (GUID)** — both are accepted. |
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 `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`.
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
 
@@ -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,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 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>;
@@ -48,6 +39,4 @@ export declare class AzureDevOpsWdioService {
48
39
  private resolveRunId;
49
40
  private getService;
50
41
  }
51
- /** WebdriverIO looks for a `launcher` export to run `onPrepare`/`onComplete`. */
52
- export declare const launcher: typeof AzureDevOpsWdioService;
53
- export default AzureDevOpsWdioService;
42
+ 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,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
- 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() {
@@ -34,15 +35,15 @@ class AzureDevOpsWdioService {
34
35
  }
35
36
  // --- worker process hooks ---
36
37
  debug(message, payload) {
37
- if (!this.options.debug)
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.options.caseIdPattern);
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.options.caseIdPattern ?? "default C123/#123"),
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?.message,
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.options.screenshotOnFailure === false)
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.options.caseIdPattern;
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.options.runId : parsed;
146
+ return Number.isNaN(parsed) ? this._options.runId : parsed;
137
147
  }
138
148
  getService() {
139
- this.service ??= new azureService_1.AzureDevOpsService(this.options);
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.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
- }
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
+ }