@piwitests/reporter 0.14.0 → 0.15.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/README.md CHANGED
@@ -92,6 +92,7 @@ export default defineConfig({
92
92
  | `collectScmInfo` | boolean | `true` | Auto-collect git commit, branch, author |
93
93
  | `collectCiInfo` | boolean | `true` | Auto-collect CI environment info |
94
94
  | `collectPerformanceMetrics` | boolean | `true` | Collect step timings, network requests and web vitals from the fixture |
95
+ | `outputFile` | string | — | Write a JSON file with the run URL/id/status so CI can consume it (see below) |
95
96
  | `apiKey` | string | — | API key for authentication (preferred for CI) |
96
97
  | `username` | string | — | Username for dashboard login (use `apiKey` instead when possible) |
97
98
  | `password` | string | — | Password for dashboard login (used with `username`) |
@@ -209,6 +210,51 @@ When `collectCiInfo` is enabled (default), the reporter auto-detects:
209
210
  - **Travis CI** — build number/URL, job number
210
211
  - **Azure Pipelines** — build number, build ID/URL, job name
211
212
 
213
+ ## Publishing the run URL to CI
214
+
215
+ After a run is submitted, the reporter surfaces the dashboard run URL so a later
216
+ CI step (a custom email, a Slack message, a deploy gate) can pick it up without
217
+ scraping the log. The URL is always printed as `View run: <url>`, and in
218
+ addition:
219
+
220
+ - **Any CI — JSON output file.** Set `outputFile` (or `PIWI_OUTPUT_FILE`) and the
221
+ reporter writes a small JSON file when the run lands:
222
+
223
+ ```json
224
+ { "runUrl": "https://piwi.example.com/test-runs/1234", "runId": 1234, "projectId": 5, "projectName": "checkout", "status": "passed", "ciBuildUrl": "https://ci.example.com/build/9" }
225
+ ```
226
+
227
+ Read it from any pipeline, e.g. `node -e "console.log(require('./piwi-run.json').runUrl)"`
228
+ (portable) or `cat piwi-run.json` and parse it in your email step. In Jenkins,
229
+ `def run = readJSON file: 'piwi-run.json'` then use `run.runUrl`.
230
+
231
+ - **GitHub Actions (automatic).** When `GITHUB_ACTIONS` is set, the reporter
232
+ appends step outputs to `$GITHUB_OUTPUT` (`piwi_run_url`, `piwi_run_id`,
233
+ `piwi_project_id`, `piwi_run_status`), writes a markdown link to the job
234
+ summary, and prints a `::notice::` annotation. Give the test step an `id` and a
235
+ downstream step can read it:
236
+
237
+ ```yaml
238
+ - id: tests
239
+ run: npx playwright test
240
+ - run: echo "Results at ${{ steps.tests.outputs.piwi_run_url }}"
241
+ ```
242
+
243
+ - **GitLab CI (automatic).** When `GITLAB_CI` is set, the reporter writes a
244
+ dotenv report (`piwi.env` by default, override with `PIWI_DOTENV_FILE`).
245
+ Declare it so later jobs inherit `$PIWI_RUN_URL`:
246
+
247
+ ```yaml
248
+ test:
249
+ script: npx playwright test
250
+ artifacts:
251
+ reports:
252
+ dotenv: piwi.env
253
+ email:
254
+ needs: [test]
255
+ script: ./send-email.sh "$PIWI_RUN_URL"
256
+ ```
257
+
212
258
  ## How It Works
213
259
 
214
260
  1. When tests start, the reporter creates a run on the server (streaming mode) or collects results locally (batch mode)
@@ -84,7 +84,8 @@ var PIWI_ENV_KEYS = {
84
84
  captureLocators: "PIWI_CAPTURE_LOCATORS",
85
85
  capturePageState: "PIWI_CAPTURE_PAGE_STATE",
86
86
  inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
87
- pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL"
87
+ pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
88
+ outputFile: "PIWI_OUTPUT_FILE"
88
89
  };
89
90
  function readBool(val) {
90
91
  if (val === void 0) return void 0;
@@ -108,7 +109,8 @@ var ENV_FALLBACK_SPECS = [
108
109
  { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
109
110
  { option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
110
111
  { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
111
- { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" }
112
+ { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
113
+ { option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
112
114
  ];
113
115
  function resolveOptions(raw) {
114
116
  const env = process.env;
package/dist/index.d.ts CHANGED
@@ -97,6 +97,15 @@ interface PiwiDashboardOptions extends PlaywrightTestConfig {
97
97
  tags?: string[];
98
98
  /** Additional custom metadata as key-value pairs */
99
99
  customData?: Record<string, unknown>;
100
+ /**
101
+ * Write a JSON file with the submitted run's dashboard URL, id, project id and
102
+ * status after the run lands, so a CI pipeline can consume it (e.g. feed the
103
+ * run URL into a custom email step). Any CI can read the file. GitHub Actions
104
+ * step outputs / job summary and GitLab dotenv reports are emitted
105
+ * automatically when running under those systems, regardless of this option.
106
+ * Can also be set with `PIWI_OUTPUT_FILE`.
107
+ */
108
+ outputFile?: string;
100
109
  /** Enable verbose logging for debugging. Defaults to `false`. */
101
110
  verbose?: boolean;
102
111
  }
package/dist/index.js CHANGED
@@ -40,7 +40,7 @@ __export(index_exports, {
40
40
  module.exports = __toCommonJS(index_exports);
41
41
 
42
42
  // src/public/reporter.ts
43
- var path12 = __toESM(require("path"));
43
+ var path13 = __toESM(require("path"));
44
44
 
45
45
  // src/internal/config/env.ts
46
46
  var DEFAULTS = {
@@ -80,7 +80,8 @@ var PIWI_ENV_KEYS = {
80
80
  captureLocators: "PIWI_CAPTURE_LOCATORS",
81
81
  capturePageState: "PIWI_CAPTURE_PAGE_STATE",
82
82
  inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
83
- pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL"
83
+ pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
84
+ outputFile: "PIWI_OUTPUT_FILE"
84
85
  };
85
86
  function readBool(val) {
86
87
  if (val === void 0) return void 0;
@@ -104,7 +105,8 @@ var ENV_FALLBACK_SPECS = [
104
105
  { option: "captureLocators", env: PIWI_ENV_KEYS.captureLocators, kind: "bool" },
105
106
  { option: "capturePageState", env: PIWI_ENV_KEYS.capturePageState, kind: "bool" },
106
107
  { option: "inspectOnFailure", env: PIWI_ENV_KEYS.inspectOnFailure, kind: "bool" },
107
- { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" }
108
+ { option: "pickLocatorOnFailure", env: PIWI_ENV_KEYS.pickLocatorOnFailure, kind: "bool" },
109
+ { option: "outputFile", env: PIWI_ENV_KEYS.outputFile, kind: "string" }
108
110
  ];
109
111
  function resolveOptions(raw) {
110
112
  const env = process.env;
@@ -407,11 +409,6 @@ function serializeRun(payload, opts) {
407
409
  return body;
408
410
  }
409
411
 
410
- // src/internal/support/run-url.ts
411
- function runUrl(serverUrl, runId) {
412
- return `${serverUrl.replace(/\/+$/, "")}/test-runs/${runId}`;
413
- }
414
-
415
412
  // src/internal/submit/uploader.ts
416
413
  var Uploader = class {
417
414
  /**
@@ -434,7 +431,6 @@ var Uploader = class {
434
431
  this.logger.info(`Successfully uploaded test results`);
435
432
  if (response.testRunId) {
436
433
  this.logger.info(`Test Run ID: ${response.testRunId}, Project ID: ${response.projectId}`);
437
- this.logger.info(`View run: ${runUrl(this.httpClient.baseUrl, response.testRunId)}`);
438
434
  }
439
435
  return response;
440
436
  }
@@ -450,7 +446,6 @@ var Uploader = class {
450
446
  this.logger.info(`Successfully uploaded test results with files`);
451
447
  if (response.testRunId) {
452
448
  this.logger.info(`Test Run ID: ${response.testRunId}, Project ID: ${response.projectId}`);
453
- this.logger.info(`View run: ${runUrl(this.httpClient.baseUrl, response.testRunId)}`);
454
449
  }
455
450
  if (response.reports) {
456
451
  for (const r of response.reports) this.logger.info(`${r.label}: ${r.path}`);
@@ -1157,6 +1152,11 @@ function readSetupInfo(projectName) {
1157
1152
  return null;
1158
1153
  }
1159
1154
 
1155
+ // src/internal/support/run-url.ts
1156
+ function runUrl(serverUrl, runId) {
1157
+ return `${serverUrl.replace(/\/+$/, "")}/test-runs/${runId}`;
1158
+ }
1159
+
1160
1160
  // src/internal/streaming/stream-manager.ts
1161
1161
  var StreamManager = class {
1162
1162
  /**
@@ -2001,6 +2001,90 @@ function buildErrorText(result) {
2001
2001
  return text;
2002
2002
  }
2003
2003
 
2004
+ // src/internal/support/ci-output.ts
2005
+ var fs12 = __toESM(require("fs"));
2006
+ var path12 = __toESM(require("path"));
2007
+ function emitRunOutputs(output, logger, outputFile, env = process.env) {
2008
+ logger.info(`View run: ${output.runUrl}`);
2009
+ if (outputFile) writeOutputFile(outputFile, output, logger);
2010
+ if (env.GITHUB_ACTIONS) emitGitHubActions(output, env, logger);
2011
+ if (env.GITLAB_CI) emitGitLabDotenv(output, env, logger);
2012
+ }
2013
+ function writeOutputFile(file, output, logger) {
2014
+ try {
2015
+ const dir = path12.dirname(file);
2016
+ if (dir && dir !== ".") fs12.mkdirSync(dir, { recursive: true });
2017
+ fs12.writeFileSync(
2018
+ file,
2019
+ JSON.stringify(
2020
+ {
2021
+ runUrl: output.runUrl,
2022
+ runId: output.runId,
2023
+ projectId: output.projectId ?? null,
2024
+ projectName: output.projectName,
2025
+ status: output.status,
2026
+ ciBuildUrl: output.ciBuildUrl ?? null
2027
+ },
2028
+ null,
2029
+ 2
2030
+ ) + "\n"
2031
+ );
2032
+ logger.info(`Wrote run output to ${file}`);
2033
+ } catch (error) {
2034
+ logger.warn(`Failed to write run output file '${file}': ${errorMessage(error)}`);
2035
+ }
2036
+ }
2037
+ function emitGitHubActions(output, env, logger) {
2038
+ const pairs = [
2039
+ ["piwi_run_url", output.runUrl],
2040
+ ["piwi_run_id", String(output.runId)],
2041
+ ["piwi_run_status", output.status]
2042
+ ];
2043
+ if (output.projectId != null) pairs.push(["piwi_project_id", String(output.projectId)]);
2044
+ if (env.GITHUB_OUTPUT) {
2045
+ appendFileLines(
2046
+ env.GITHUB_OUTPUT,
2047
+ pairs.map(([k, v]) => `${k}=${v}`),
2048
+ logger,
2049
+ "step output"
2050
+ );
2051
+ }
2052
+ if (env.GITHUB_STEP_SUMMARY) {
2053
+ appendFileLines(
2054
+ env.GITHUB_STEP_SUMMARY,
2055
+ ["### Piwi test run", "", `[View run](${output.runUrl}) \u2014 **${output.status}**`, ""],
2056
+ logger,
2057
+ "step summary"
2058
+ );
2059
+ }
2060
+ process.stdout.write(`::notice title=Piwi test run::${output.runUrl}
2061
+ `);
2062
+ }
2063
+ function emitGitLabDotenv(output, env, logger) {
2064
+ const file = env.PIWI_DOTENV_FILE || "piwi.env";
2065
+ const lines = [`PIWI_RUN_URL=${output.runUrl}`, `PIWI_RUN_ID=${output.runId}`, `PIWI_RUN_STATUS=${output.status}`];
2066
+ if (output.projectId != null) lines.push(`PIWI_PROJECT_ID=${output.projectId}`);
2067
+ if (output.ciBuildUrl) lines.push(`PIWI_CI_BUILD_URL=${output.ciBuildUrl}`);
2068
+ try {
2069
+ fs12.writeFileSync(file, lines.join("\n") + "\n");
2070
+ logger.info(`Wrote GitLab dotenv report to ${file} (declare it as artifacts:reports:dotenv)`);
2071
+ } catch (error) {
2072
+ logger.warn(`Failed to write GitLab dotenv file '${file}': ${errorMessage(error)}`);
2073
+ }
2074
+ }
2075
+ function appendFileLines(file, lines, logger, label) {
2076
+ try {
2077
+ fs12.appendFileSync(file, lines.join("\n") + "\n");
2078
+ } catch (error) {
2079
+ logger.warn(`Failed to write GitHub Actions ${label}: ${errorMessage(error)}`);
2080
+ }
2081
+ }
2082
+ function ciBuildUrlFromMetadata(metadata) {
2083
+ const ci = metadata?.ci;
2084
+ if (!ci) return void 0;
2085
+ return ci.buildUrl || ci.pipelineUrl || ci.jobUrl || void 0;
2086
+ }
2087
+
2004
2088
  // src/internal/submit/run-submitter.ts
2005
2089
  var RunSubmitter = class {
2006
2090
  /**
@@ -2045,13 +2129,29 @@ var RunSubmitter = class {
2045
2129
  this.logger.error(`Authentication failed: ${errorMessage(error)}`);
2046
2130
  throw error;
2047
2131
  }
2132
+ let outcome = { done: false, output: null };
2048
2133
  if (sm?.enabled && sm?.runId != null) {
2049
- if (await this.tryFinishStreaming(run, overallStatus, duration, auth)) return;
2134
+ outcome = await this.tryFinishStreaming(run, overallStatus, duration, auth);
2135
+ }
2136
+ if (!outcome.done && (this.hasReports(run) || run.options.uploadTraces)) {
2137
+ outcome = await this.tryUploadWithFiles(run, overallStatus, duration, auth);
2050
2138
  }
2051
- if (this.hasReports(run) || run.options.uploadTraces) {
2052
- if (await this.tryUploadWithFiles(run, overallStatus, duration, auth)) return;
2139
+ if (!outcome.done) {
2140
+ outcome = await this.tryUploadJSON(run, overallStatus, duration, auth);
2053
2141
  }
2054
- await this.tryUploadJSON(run, overallStatus, duration, auth);
2142
+ if (outcome.output) emitRunOutputs(outcome.output, this.logger, run.options.outputFile);
2143
+ }
2144
+ /** Assemble a CI-facing run output, or `null` when the server returned no run id. */
2145
+ buildOutput(runId, projectId, run, status) {
2146
+ if (runId == null) return null;
2147
+ return {
2148
+ runUrl: runUrl(this.httpClient.baseUrl, runId),
2149
+ runId,
2150
+ projectId,
2151
+ projectName: run.options.projectName,
2152
+ status,
2153
+ ciBuildUrl: ciBuildUrlFromMetadata(run.metadata)
2154
+ };
2055
2155
  }
2056
2156
  hasReports(run) {
2057
2157
  return !!run.options.uploadReport || (run.options.reports?.length ?? 0) > 0;
@@ -2122,9 +2222,6 @@ var RunSubmitter = class {
2122
2222
  }
2123
2223
  await this.httpClient.postJSON(`/api/test-runs/${sm.runId}/finish`, finishBody, auth);
2124
2224
  this.logger.info(`Successfully finalized streaming run #${sm.runId}`);
2125
- if (run.options.serverUrl) {
2126
- this.logger.info(`View run: ${runUrl(run.options.serverUrl, sm.runId)}`);
2127
- }
2128
2225
  this.recovery.clear();
2129
2226
  if (this.hasReports(run)) {
2130
2227
  try {
@@ -2139,22 +2236,22 @@ var RunSubmitter = class {
2139
2236
  this.logger.warn(`Failed to upload reports for streaming run: ${errorMessage(error)}`);
2140
2237
  }
2141
2238
  }
2142
- return true;
2239
+ return { done: true, output: this.buildOutput(sm.runId, void 0, run, overallStatus) };
2143
2240
  } catch (error) {
2144
2241
  this.logger.warn(`Failed to finalize streaming run: ${errorMessage(error)}`);
2145
2242
  this.logger.info("Falling back to batch upload...");
2146
- return false;
2243
+ return { done: false, output: null };
2147
2244
  }
2148
2245
  }
2149
2246
  async tryUploadWithFiles(run, overallStatus, duration, auth) {
2150
2247
  try {
2151
- await this.uploader.uploadWithFiles(
2248
+ const response = await this.uploader.uploadWithFiles(
2152
2249
  this.buildRunPayload(run, overallStatus, duration),
2153
2250
  this.reportOptions(run),
2154
2251
  auth
2155
2252
  );
2156
2253
  this.recovery.clear();
2157
- return true;
2254
+ return { done: true, output: this.buildOutput(response?.testRunId, response?.projectId, run, overallStatus) };
2158
2255
  } catch (error) {
2159
2256
  if (error instanceof HttpError && error.status === 401 && !auth) {
2160
2257
  this.logAuthRequired(run.options.serverUrl);
@@ -2162,14 +2259,15 @@ var RunSubmitter = class {
2162
2259
  }
2163
2260
  this.logger.warn(`Failed to upload with files: ${errorMessage(error)}`);
2164
2261
  this.logger.info("Falling back to JSON upload...");
2165
- return false;
2262
+ return { done: false, output: null };
2166
2263
  }
2167
2264
  }
2168
2265
  async tryUploadJSON(run, overallStatus, duration, auth) {
2169
2266
  const payload = this.buildRunPayload(run, overallStatus, duration);
2170
2267
  try {
2171
- await this.uploader.uploadJSON(payload, auth);
2268
+ const response = await this.uploader.uploadJSON(payload, auth);
2172
2269
  this.recovery.clear();
2270
+ return { done: true, output: this.buildOutput(response?.testRunId, response?.projectId, run, overallStatus) };
2173
2271
  } catch (error) {
2174
2272
  if (error instanceof HttpError && error.status === 401 && !auth) {
2175
2273
  this.logAuthRequired(run.options.serverUrl);
@@ -2180,6 +2278,7 @@ var RunSubmitter = class {
2180
2278
  `Saved a local recovery copy \u2014 it will be uploaded automatically on your next test run. If this keeps happening, check that serverUrl (${run.options.serverUrl ?? "not set"}) is correct and reachable.`
2181
2279
  );
2182
2280
  this.recovery.save(serializeRun(payload, { includeTestCases: true }));
2281
+ return { done: true, output: null };
2183
2282
  }
2184
2283
  }
2185
2284
  /** Log one actionable line explaining how to fix a 401 caused by a missing credential. */
@@ -2192,7 +2291,7 @@ var RunSubmitter = class {
2192
2291
 
2193
2292
  // src/public/reporter.ts
2194
2293
  function testLocation(test) {
2195
- const relativeFilePath = path12.relative(process.cwd(), test.location.file).split(path12.sep).join("/");
2294
+ const relativeFilePath = path13.relative(process.cwd(), test.location.file).split(path13.sep).join("/");
2196
2295
  return `${relativeFilePath}:${test.location.line}:${test.location.column}`;
2197
2296
  }
2198
2297
  var PiwiDashboardReporter = class {
@@ -2500,7 +2599,7 @@ var PiwiDashboardReporter = class {
2500
2599
  var import_node_zlib = require("zlib");
2501
2600
 
2502
2601
  // src/internal/capture/locator-healing.ts
2503
- var path13 = __toESM(require("path"));
2602
+ var path14 = __toESM(require("path"));
2504
2603
 
2505
2604
  // ../packages/core/src/locator-fingerprint.ts
2506
2605
  var PRESENT_SIMILARITY = 0.8;
@@ -3057,10 +3156,10 @@ function captureCallerLocation(stack = new Error().stack ?? "") {
3057
3156
  }
3058
3157
  let rel = file;
3059
3158
  try {
3060
- rel = path13.relative(process.cwd(), file);
3159
+ rel = path14.relative(process.cwd(), file);
3061
3160
  } catch {
3062
3161
  }
3063
- rel = rel.split(path13.sep).join("/");
3162
+ rel = rel.split(path14.sep).join("/");
3064
3163
  if (rel.startsWith("./")) rel = rel.slice(2);
3065
3164
  return `${rel}:${m[3]}:${m[4]}`;
3066
3165
  }
@@ -3103,7 +3202,7 @@ function inspectionGateFromTestInfo(testInfo, enabled = process.env.PIWI_INSPECT
3103
3202
  }
3104
3203
 
3105
3204
  // src/internal/capture/pick-on-failure.ts
3106
- var path14 = __toESM(require("path"));
3205
+ var path15 = __toESM(require("path"));
3107
3206
  var ANSI_RE = /\[[0-9;]*m/g;
3108
3207
  function endOfString(s, start) {
3109
3208
  const q = s[start];
@@ -3197,7 +3296,7 @@ ${err.stack ?? ""}`.replace(ANSI_RE, "");
3197
3296
  const parsed = parseLeafLocatorExpression(line[1].trim());
3198
3297
  if (!parsed) continue;
3199
3298
  const loc = err.location;
3200
- const location = loc ? `${path14.relative(process.cwd(), loc.file).split(path14.sep).join("/")}:${loc.line}:${loc.column}` : null;
3299
+ const location = loc ? `${path15.relative(process.cwd(), loc.file).split(path15.sep).join("/")}:${loc.line}:${loc.column}` : null;
3201
3300
  return { method: parsed.method, args: parsed.args, location };
3202
3301
  }
3203
3302
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piwitests/reporter",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Playwright reporter for sending test results to Piwi Dashboard",
5
5
  "url": "https://github.com/PiwiTests/platform",
6
6
  "homepage": "https://piwitests.github.io",