@allurereport/service 3.17.0 → 3.19.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/dist/history.d.ts CHANGED
@@ -13,6 +13,7 @@ export declare class AllureRemoteHistory implements AllureHistory {
13
13
  repo?: string;
14
14
  branch?: string;
15
15
  });
16
+ resolveTestResultUrl(historyUrl: string, pluginId: string, historicalResultId: string): string;
16
17
  readHistory(params?: {
17
18
  repo?: string;
18
19
  branch?: string;
package/dist/history.js CHANGED
@@ -4,6 +4,15 @@ export class AllureRemoteHistory {
4
4
  constructor(params) {
5
5
  this.params = params;
6
6
  }
7
+ resolveTestResultUrl(historyUrl, pluginId, historicalResultId) {
8
+ if (!historyUrl) {
9
+ return "";
10
+ }
11
+ const { origin, pathname } = new URL(historyUrl);
12
+ const navigateUrl = new URL([pathname, pluginId].join("/"), origin);
13
+ navigateUrl.hash = historicalResultId;
14
+ return navigateUrl.toString();
15
+ }
7
16
  async readHistory(params) {
8
17
  const { limit } = this.params;
9
18
  try {
package/dist/model.d.ts CHANGED
@@ -16,6 +16,7 @@ export type UploadReportPayload = {
16
16
  files: Record<string, string> | Record<string, string>[];
17
17
  onProgress?: (uploadedFilesDelta?: number) => void;
18
18
  uploadBatchMaxBytes?: number;
19
+ signal?: AbortSignal;
19
20
  };
20
21
  export type UploadReportFilePayload = {
21
22
  filename: string;
package/dist/service.js CHANGED
@@ -9,21 +9,34 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _AllureServiceClient_client, _AllureServiceClient_url;
12
+ var _AllureServiceClient_instances, _AllureServiceClient_client, _AllureServiceClient_url, _AllureServiceClient_uploadedAssets, _AllureServiceClient_uploadRawFile, _AllureServiceClient_uploadPreparedFile;
13
+ import { createReadStream } from "node:fs";
14
+ import { stat } from "node:fs/promises";
13
15
  import { join as joinPosix } from "node:path/posix";
16
+ import { parseIntegerConfigValue } from "@allurereport/core-api";
14
17
  import { ALLURE_SERVICE_STORAGE_PREFIX, } from "./model.js";
15
- import { createServiceHttpClient, uploadReport } from "./utils/http.js";
18
+ import { createServiceHttpClient } from "./utils/http.js";
19
+ import { encodeUploadPath, normalizeUploadPath, uploadStorageReport } from "./utils/storage.js";
16
20
  import { parseServiceToken } from "./utils/token.js";
17
21
  import { createUploadForm } from "./utils/upload.js";
18
22
  const UPLOAD_CONTENT_TYPE = "application/octet-stream";
19
23
  const createUploadBlob = (content) => new Blob([content], { type: UPLOAD_CONTENT_TYPE });
20
24
  const createReportUrl = (baseUrl, reportUuid) => `${baseUrl}/${reportUuid}`;
21
25
  const createReportFileUrl = (baseUrl, reportUuid, reportFilename) => `${baseUrl}/${joinPosix(reportUuid, reportFilename)}`;
26
+ const resolveUploadConcurrency = (value) => {
27
+ const concurrency = parseIntegerConfigValue(value) ?? 10;
28
+ if (concurrency <= 0) {
29
+ throw new Error(`Allure service upload concurrency must resolve to an integer greater than 0; received ${String(value)}`);
30
+ }
31
+ return concurrency;
32
+ };
22
33
  export class AllureServiceClient {
23
34
  constructor(config) {
35
+ _AllureServiceClient_instances.add(this);
24
36
  this.config = config;
25
37
  _AllureServiceClient_client.set(this, void 0);
26
38
  _AllureServiceClient_url.set(this, void 0);
39
+ _AllureServiceClient_uploadedAssets.set(this, new Map());
27
40
  if (!config?.accessToken) {
28
41
  throw new Error("Allure service access token is required");
29
42
  }
@@ -65,19 +78,29 @@ export class AllureServiceClient {
65
78
  ...historyPoint,
66
79
  url: createReportUrl(__classPrivateFieldGet(this, _AllureServiceClient_url, "f"), reportUuid),
67
80
  };
68
- return __classPrivateFieldGet(this, _AllureServiceClient_client, "f").post(`/api/reports/${reportUuid}/complete`, {
69
- body: {
70
- historyPoint: completedHistoryPoint,
71
- },
72
- });
81
+ try {
82
+ return await __classPrivateFieldGet(this, _AllureServiceClient_client, "f").post(`/api/reports/${reportUuid}/complete`, {
83
+ body: {
84
+ historyPoint: completedHistoryPoint,
85
+ },
86
+ });
87
+ }
88
+ finally {
89
+ __classPrivateFieldGet(this, _AllureServiceClient_uploadedAssets, "f").delete(reportUuid);
90
+ }
73
91
  }
74
92
  async deleteReport(payload) {
75
93
  const { reportUuid, pluginId = "" } = payload;
76
- return __classPrivateFieldGet(this, _AllureServiceClient_client, "f").post(`/api/report/${reportUuid}/delete`, {
77
- body: {
78
- pluginId,
79
- },
80
- });
94
+ try {
95
+ return await __classPrivateFieldGet(this, _AllureServiceClient_client, "f").post(`/api/report/${reportUuid}/delete`, {
96
+ body: {
97
+ pluginId,
98
+ },
99
+ });
100
+ }
101
+ finally {
102
+ __classPrivateFieldGet(this, _AllureServiceClient_uploadedAssets, "f").delete(reportUuid);
103
+ }
81
104
  }
82
105
  async addReportAssets(payload) {
83
106
  const { files, signal } = payload;
@@ -94,7 +117,12 @@ export class AllureServiceClient {
94
117
  });
95
118
  }
96
119
  async addReportAsset(payload) {
97
- return this.addReportAssets({ files: [payload], ...(payload.signal ? { signal: payload.signal } : {}) });
120
+ const remotePath = normalizeUploadPath(payload.filename);
121
+ return __classPrivateFieldGet(this, _AllureServiceClient_instances, "m", _AllureServiceClient_uploadRawFile).call(this, {
122
+ ...payload,
123
+ endpoint: "/api/assets",
124
+ remotePath,
125
+ });
98
126
  }
99
127
  async addReportFiles(payload) {
100
128
  const { reportUuid, pluginId, files, signal } = payload;
@@ -115,26 +143,59 @@ export class AllureServiceClient {
115
143
  ]));
116
144
  }
117
145
  async addReportFile(payload) {
118
- const result = await this.addReportFiles({
119
- reportUuid: payload.reportUuid,
120
- pluginId: payload.pluginId,
121
- files: [payload],
122
- ...(payload.signal ? { signal: payload.signal } : {}),
146
+ const filename = normalizeUploadPath(payload.filename);
147
+ const remotePath = payload.pluginId ? `${normalizeUploadPath(payload.pluginId)}/${filename}` : filename;
148
+ await __classPrivateFieldGet(this, _AllureServiceClient_instances, "m", _AllureServiceClient_uploadRawFile).call(this, {
149
+ ...payload,
150
+ endpoint: `/api/reports/${payload.reportUuid}/files`,
151
+ remotePath,
123
152
  });
124
- return result[payload.filename];
153
+ return createReportFileUrl(__classPrivateFieldGet(this, _AllureServiceClient_url, "f"), payload.reportUuid, remotePath);
125
154
  }
126
155
  async uploadReport(payload) {
127
- const { uploadBatchMaxBytes: _uploadBatchMaxBytes, ...uploadPayload } = payload;
128
- return uploadReport({
129
- ...uploadPayload,
130
- uploadConcurrency: this.config.uploadConcurrency,
156
+ const uploadedAssets = __classPrivateFieldGet(this, _AllureServiceClient_uploadedAssets, "f").get(payload.reportUuid) ?? new Set();
157
+ __classPrivateFieldGet(this, _AllureServiceClient_uploadedAssets, "f").set(payload.reportUuid, uploadedAssets);
158
+ return uploadStorageReport({
159
+ ...payload,
160
+ uploadConcurrency: resolveUploadConcurrency(this.config.uploadConcurrency),
131
161
  uploadMaxAttempts: this.config.uploadMaxAttempts,
132
162
  uploadMaxSimultaneousFailures: this.config.uploadMaxSimultaneousFailures,
133
- addReportAsset: this.addReportAsset.bind(this),
134
- addReportAssets: this.addReportAssets.bind(this),
135
- addReportFile: this.addReportFile.bind(this),
136
- addReportFiles: this.addReportFiles.bind(this),
163
+ uploadedAssets,
164
+ upload: (file, signal) => __classPrivateFieldGet(this, _AllureServiceClient_instances, "m", _AllureServiceClient_uploadPreparedFile).call(this, payload.reportUuid, file, signal),
137
165
  });
138
166
  }
139
167
  }
140
- _AllureServiceClient_client = new WeakMap(), _AllureServiceClient_url = new WeakMap();
168
+ _AllureServiceClient_client = new WeakMap(), _AllureServiceClient_url = new WeakMap(), _AllureServiceClient_uploadedAssets = new WeakMap(), _AllureServiceClient_instances = new WeakSet(), _AllureServiceClient_uploadRawFile = async function _AllureServiceClient_uploadRawFile(payload) {
169
+ const { endpoint, remotePath, file, filepath, signal } = payload;
170
+ if (file === undefined && !filepath) {
171
+ throw new Error("File or filepath is required");
172
+ }
173
+ const size = payload.size ?? (file !== undefined ? file.length : (await stat(filepath)).size);
174
+ const body = file ?? createReadStream(filepath, { signal });
175
+ try {
176
+ return await __classPrivateFieldGet(this, _AllureServiceClient_client, "f").put(`${endpoint}?path=${encodeUploadPath(remotePath)}`, {
177
+ body,
178
+ headers: {
179
+ "Content-Length": size,
180
+ "Content-Type": UPLOAD_CONTENT_TYPE,
181
+ },
182
+ maxBodyLength: Number.POSITIVE_INFINITY,
183
+ maxContentLength: Number.POSITIVE_INFINITY,
184
+ ...(signal ? { signal } : {}),
185
+ });
186
+ }
187
+ finally {
188
+ if (!Buffer.isBuffer(body)) {
189
+ body.destroy();
190
+ }
191
+ }
192
+ }, _AllureServiceClient_uploadPreparedFile = async function _AllureServiceClient_uploadPreparedFile(reportUuid, file, signal) {
193
+ const endpoint = file.kind === "report" ? `/api/reports/${reportUuid}/files` : "/api/assets";
194
+ await __classPrivateFieldGet(this, _AllureServiceClient_instances, "m", _AllureServiceClient_uploadRawFile).call(this, {
195
+ ...file,
196
+ endpoint,
197
+ remotePath: file.remotePath,
198
+ signal,
199
+ });
200
+ return file.kind === "report" ? createReportFileUrl(__classPrivateFieldGet(this, _AllureServiceClient_url, "f"), reportUuid, file.remotePath) : undefined;
201
+ };
@@ -2,12 +2,29 @@ import { type AxiosRequestConfig } from "axios";
2
2
  import type { UploadReportConfig, UploadReportFilePayload, UploadReportFilesPayload, UploadReportPayload, UploadReportResult } from "../model.js";
3
3
  export declare class KnownError extends Error {
4
4
  status?: number;
5
- constructor(message: string, status?: number, options?: ErrorOptions);
5
+ details?: ServiceHttpErrorDetails;
6
+ constructor(message: string, status?: number, options?: ErrorOptions & {
7
+ details?: ServiceHttpErrorDetails;
8
+ });
6
9
  }
7
10
  export declare class UnknownError extends Error {
8
11
  stack?: string;
9
- constructor(message: string, stack?: string, options?: ErrorOptions);
12
+ details?: ServiceHttpErrorDetails;
13
+ constructor(message: string, stack?: string, options?: ErrorOptions & {
14
+ details?: ServiceHttpErrorDetails;
15
+ });
10
16
  }
17
+ export type ServiceHttpErrorDetails = {
18
+ method: string;
19
+ endpoint: string;
20
+ status?: number;
21
+ statusText?: string;
22
+ responseMessage?: string;
23
+ rayId?: string;
24
+ mitigated?: string;
25
+ retryAfter?: string;
26
+ networkCode?: string;
27
+ };
11
28
  export declare const formatResponseErrorData: (data: unknown) => string | undefined;
12
29
  export declare const formatServiceHttpErrorMessage: (payload: {
13
30
  method: string;
@@ -16,6 +33,9 @@ export declare const formatServiceHttpErrorMessage: (payload: {
16
33
  statusText?: string;
17
34
  data?: unknown;
18
35
  fallbackMessage?: string;
36
+ contentType?: string;
37
+ rayId?: string;
38
+ mitigated?: string;
19
39
  }) => string;
20
40
  export declare const createServiceHttpClient: (serviceUrl: string, params?: {
21
41
  accessToken?: string;
@@ -3,19 +3,50 @@ import axios, { isAxiosError } from "axios";
3
3
  import { isReportDataFile } from "./files.js";
4
4
  export class KnownError extends Error {
5
5
  constructor(message, status, options) {
6
- super(message, options);
6
+ const { details, ...errorOptions } = options ?? {};
7
+ super(message, errorOptions);
7
8
  this.name = "KnownError";
8
9
  this.status = status;
10
+ this.details = details;
9
11
  }
10
12
  }
11
13
  export class UnknownError extends Error {
12
14
  constructor(message, stack, options) {
13
- super(message, options);
15
+ const { details, ...errorOptions } = options ?? {};
16
+ super(message, errorOptions);
14
17
  this.name = "UnknownError";
15
18
  this.stack = stack;
19
+ this.details = details;
16
20
  }
17
21
  }
18
22
  const ERROR_MESSAGE_FIELDS = ["message", "error_description", "error", "detail", "title", "description"];
23
+ const MAX_RESPONSE_MESSAGE_LENGTH = 512;
24
+ const truncateResponseMessage = (message) => {
25
+ if (!message) {
26
+ return undefined;
27
+ }
28
+ const normalized = message.replace(/\s+/g, " ").trim();
29
+ if (normalized.length <= MAX_RESPONSE_MESSAGE_LENGTH) {
30
+ return normalized;
31
+ }
32
+ return `${normalized.slice(0, MAX_RESPONSE_MESSAGE_LENGTH)}…`;
33
+ };
34
+ const responseHeader = (headers, name) => {
35
+ if (!headers || typeof headers !== "object") {
36
+ return undefined;
37
+ }
38
+ const headerGetter = headers.get;
39
+ if (typeof headerGetter === "function") {
40
+ const value = headerGetter.call(headers, name);
41
+ return value === undefined || value === null ? undefined : String(value);
42
+ }
43
+ const entries = Object.entries(headers);
44
+ const entry = entries.find(([headerName]) => headerName.toLowerCase() === name.toLowerCase());
45
+ if (!entry || entry[1] === undefined || entry[1] === null) {
46
+ return undefined;
47
+ }
48
+ return Array.isArray(entry[1]) ? entry[1].join(", ") : String(entry[1]);
49
+ };
19
50
  const stringifyErrorObject = (value) => {
20
51
  try {
21
52
  return JSON.stringify(value);
@@ -103,11 +134,18 @@ export const formatResponseErrorData = (data) => {
103
134
  return stringifyErrorObject(errorData);
104
135
  };
105
136
  export const formatServiceHttpErrorMessage = (payload) => {
106
- const { method, endpoint, status, statusText, data, fallbackMessage } = payload;
137
+ const { method, endpoint, status, statusText, data, fallbackMessage, contentType, rayId, mitigated } = payload;
107
138
  const request = `${method.toUpperCase()} ${endpoint}`;
139
+ if (status === 403 && mitigated === "challenge") {
140
+ return `Allure service request failed: ${request} was blocked by a Cloudflare managed challenge (status 403${rayId ? `, ray ${rayId}` : ""}). Machine API routes must be excluded from browser challenges.`;
141
+ }
108
142
  const statusMessage = status ? ` responded with ${status}${statusText ? ` ${statusText}` : ""}` : " failed";
109
- const details = formatResponseErrorData(data) || fallbackMessage;
110
- return `Allure service request failed: ${request}${statusMessage}${details ? `: ${details}` : ""}`;
143
+ const responseMessage = contentType?.toLowerCase().includes("text/html") ? undefined : formatResponseErrorData(data);
144
+ const details = truncateResponseMessage(responseMessage || fallbackMessage);
145
+ const metadata = [rayId ? `ray ${rayId}` : undefined, mitigated ? `cf-mitigated ${mitigated}` : undefined]
146
+ .filter(Boolean)
147
+ .join(", ");
148
+ return `Allure service request failed: ${request}${statusMessage}${metadata ? ` (${metadata})` : ""}${details ? `: ${details}` : ""}`;
111
149
  };
112
150
  export const createServiceHttpClient = (serviceUrl, params) => {
113
151
  const client = axios.create({
@@ -145,8 +183,24 @@ export const createServiceHttpClient = (serviceUrl, params) => {
145
183
  if (!axiosError) {
146
184
  throw err;
147
185
  }
148
- const { data, status, statusText } = err.response ?? {};
186
+ const { data, status, statusText, headers: responseHeaders } = err.response ?? {};
149
187
  const responseStatus = status ?? 500;
188
+ const contentType = responseHeader(responseHeaders, "content-type");
189
+ const rayId = responseHeader(responseHeaders, "cf-ray");
190
+ const mitigated = responseHeader(responseHeaders, "cf-mitigated");
191
+ const retryAfter = responseHeader(responseHeaders, "retry-after");
192
+ const responseMessage = truncateResponseMessage(contentType?.toLowerCase().includes("text/html") ? undefined : formatResponseErrorData(data));
193
+ const details = {
194
+ method,
195
+ endpoint,
196
+ status,
197
+ statusText,
198
+ responseMessage,
199
+ rayId,
200
+ mitigated,
201
+ retryAfter,
202
+ networkCode: err.code,
203
+ };
150
204
  const errorMessage = formatServiceHttpErrorMessage({
151
205
  method,
152
206
  endpoint,
@@ -154,11 +208,14 @@ export const createServiceHttpClient = (serviceUrl, params) => {
154
208
  statusText,
155
209
  data,
156
210
  fallbackMessage: err.message,
211
+ contentType,
212
+ rayId,
213
+ mitigated,
157
214
  });
158
215
  if (responseStatus < 500) {
159
- throw new KnownError(errorMessage, responseStatus, { cause: err });
216
+ throw new KnownError(errorMessage, responseStatus, { cause: err, details });
160
217
  }
161
- throw new UnknownError(errorMessage, err.stack, { cause: err });
218
+ throw new UnknownError(errorMessage, err.stack, { cause: err, details });
162
219
  }
163
220
  };
164
221
  return {
@@ -0,0 +1,13 @@
1
+ import type { UploadReportConfig, UploadReportFilePayload, UploadReportPayload, UploadReportResult } from "../model.js";
2
+ export type StorageUploadFile = UploadReportFilePayload & {
3
+ kind: "report" | "asset";
4
+ normalizedFilename: string;
5
+ remotePath: string;
6
+ size: number;
7
+ };
8
+ export declare const normalizeUploadPath: (path: string) => string;
9
+ export declare const encodeUploadPath: (path: string) => string;
10
+ export declare const uploadStorageReport: (payload: UploadReportPayload & UploadReportConfig & {
11
+ uploadedAssets: Set<string>;
12
+ upload: (file: StorageUploadFile, signal: AbortSignal) => Promise<string | undefined>;
13
+ }) => Promise<UploadReportResult>;
@@ -0,0 +1,263 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { posix, win32 } from "node:path";
3
+ import { isReportDataFile } from "./files.js";
4
+ import { KnownError, UnknownError } from "./http.js";
5
+ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
6
+ const RETRYABLE_NETWORK_CODES = new Set([
7
+ "EAI_AGAIN",
8
+ "ECONNABORTED",
9
+ "ECONNREFUSED",
10
+ "ECONNRESET",
11
+ "EHOSTUNREACH",
12
+ "ENETDOWN",
13
+ "ENETUNREACH",
14
+ "EPIPE",
15
+ "ETIMEDOUT",
16
+ "ERR_NETWORK",
17
+ ]);
18
+ const INITIAL_RETRY_DELAY_MS = 500;
19
+ const MAX_RETRY_DELAY_MS = 30000;
20
+ const abortError = (signal) => {
21
+ if (signal.reason instanceof Error) {
22
+ return signal.reason;
23
+ }
24
+ const error = new Error("The upload was aborted");
25
+ error.name = "AbortError";
26
+ return error;
27
+ };
28
+ const throwIfAborted = (signal) => {
29
+ if (signal.aborted) {
30
+ throw abortError(signal);
31
+ }
32
+ };
33
+ export const normalizeUploadPath = (path) => {
34
+ if (!path || path.includes("\0")) {
35
+ throw new Error(`Invalid upload path "${path}": the path must be non-empty and must not contain NUL`);
36
+ }
37
+ const normalizedSeparators = path.replaceAll(win32.sep, posix.sep);
38
+ if (posix.isAbsolute(normalizedSeparators) || win32.parse(path).root !== "") {
39
+ throw new Error(`Invalid upload path "${path}": the path must be relative`);
40
+ }
41
+ const segments = normalizedSeparators.split(posix.sep);
42
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
43
+ throw new Error(`Invalid upload path "${path}": empty, "." and ".." segments are not allowed`);
44
+ }
45
+ return posix.normalize(normalizedSeparators);
46
+ };
47
+ export const encodeUploadPath = (path) => encodeURIComponent(path).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
48
+ const prepareUploadFiles = async (payload, signal) => {
49
+ const { pluginId } = payload;
50
+ const normalizedPluginId = pluginId ? normalizeUploadPath(pluginId) : undefined;
51
+ const fileSets = Array.isArray(payload.files) ? payload.files : [payload.files];
52
+ const files = [];
53
+ const uniqueAssets = new Map();
54
+ for (const fileSet of fileSets) {
55
+ for (const [filename, filepath] of Object.entries(fileSet)) {
56
+ throwIfAborted(signal);
57
+ const normalizedFilename = normalizeUploadPath(filename);
58
+ const kind = isReportDataFile(normalizedFilename) ? "report" : "asset";
59
+ const remotePath = kind === "report" && normalizedPluginId ? `${normalizedPluginId}/${normalizedFilename}` : normalizedFilename;
60
+ if (kind === "asset" && uniqueAssets.has(remotePath)) {
61
+ continue;
62
+ }
63
+ const size = (await stat(filepath)).size;
64
+ const file = {
65
+ filename,
66
+ filepath,
67
+ kind,
68
+ normalizedFilename,
69
+ remotePath,
70
+ size,
71
+ signal,
72
+ };
73
+ if (kind === "report") {
74
+ files.push(file);
75
+ continue;
76
+ }
77
+ uniqueAssets.set(remotePath, file);
78
+ }
79
+ }
80
+ files.push(...uniqueAssets.values());
81
+ return files;
82
+ };
83
+ const parseRetryAfter = (value) => {
84
+ if (!value) {
85
+ return undefined;
86
+ }
87
+ const seconds = Number(value);
88
+ if (Number.isFinite(seconds) && seconds >= 0) {
89
+ return Math.round(seconds * 1000);
90
+ }
91
+ const timestamp = Date.parse(value);
92
+ if (Number.isNaN(timestamp)) {
93
+ return undefined;
94
+ }
95
+ return Math.max(0, timestamp - Date.now());
96
+ };
97
+ const retryDelay = (attempt, retryAfter) => {
98
+ const serverDelay = parseRetryAfter(retryAfter);
99
+ if (serverDelay !== undefined) {
100
+ return serverDelay;
101
+ }
102
+ const maximumDelay = Math.min(MAX_RETRY_DELAY_MS, INITIAL_RETRY_DELAY_MS * 2 ** (attempt - 1));
103
+ return Math.floor(Math.random() * maximumDelay);
104
+ };
105
+ const waitForRetry = async (delay, signal) => {
106
+ throwIfAborted(signal);
107
+ await new Promise((resolve, reject) => {
108
+ const finish = () => {
109
+ signal.removeEventListener("abort", onAbort);
110
+ resolve();
111
+ };
112
+ const timeout = setTimeout(finish, delay);
113
+ const onAbort = () => {
114
+ clearTimeout(timeout);
115
+ signal.removeEventListener("abort", onAbort);
116
+ reject(abortError(signal));
117
+ };
118
+ signal.addEventListener("abort", onAbort, { once: true });
119
+ void Promise.resolve().then(() => {
120
+ if (signal.aborted) {
121
+ onAbort();
122
+ }
123
+ });
124
+ });
125
+ };
126
+ const errorDetails = (error) => {
127
+ if (error instanceof KnownError || error instanceof UnknownError) {
128
+ return error.details;
129
+ }
130
+ return undefined;
131
+ };
132
+ const isRetryable = (error) => {
133
+ const details = errorDetails(error);
134
+ if (!details) {
135
+ return false;
136
+ }
137
+ if (details.status !== undefined) {
138
+ return RETRYABLE_STATUSES.has(details.status);
139
+ }
140
+ return details.networkCode === undefined || RETRYABLE_NETWORK_CODES.has(details.networkCode);
141
+ };
142
+ const formatUploadError = (file, error, attempts) => {
143
+ const details = errorDetails(error);
144
+ const operation = file.kind === "report" ? "upload report file" : "upload shared asset";
145
+ if (!details) {
146
+ return error;
147
+ }
148
+ const method = details.method.toUpperCase();
149
+ if (details.status === 403 && details.mitigated === "challenge") {
150
+ return new KnownError(`Allure service request failed: ${operation} ${method} ${details.endpoint} was blocked by a Cloudflare managed challenge (status 403${details.rayId ? `, ray ${details.rayId}` : ""}, path "${file.remotePath}", attempts ${attempts}). Machine API routes must be excluded from browser challenges.`, 403, { cause: error, details });
151
+ }
152
+ const result = [
153
+ `Allure service request failed: ${operation} ${method} ${details.endpoint}`,
154
+ details.status
155
+ ? ` responded with ${details.status}${details.statusText ? ` ${details.statusText}` : ""}`
156
+ : " failed",
157
+ ` (path "${file.remotePath}", attempts ${attempts}`,
158
+ details.rayId ? `, ray ${details.rayId}` : "",
159
+ details.mitigated ? `, cf-mitigated ${details.mitigated}` : "",
160
+ ")",
161
+ details.responseMessage ? `: ${details.responseMessage}` : "",
162
+ ].join("");
163
+ if (details.status !== undefined) {
164
+ return new KnownError(result, details.status, { cause: error, details });
165
+ }
166
+ return new UnknownError(result, error instanceof Error ? error.stack : undefined, { cause: error, details });
167
+ };
168
+ const uploadWithRetry = async (file, signal, failedUploads, maxAttempts, upload) => {
169
+ const attempts = typeof maxAttempts === "number" && Number.isFinite(maxAttempts) && maxAttempts > 0 ? Math.floor(maxAttempts) : 5;
170
+ for (let attempt = 1; attempt <= attempts; attempt++) {
171
+ throwIfAborted(signal);
172
+ try {
173
+ const result = await upload(file, signal);
174
+ failedUploads.delete(file.remotePath);
175
+ return result;
176
+ }
177
+ catch (error) {
178
+ if (signal.aborted) {
179
+ throw abortError(signal);
180
+ }
181
+ if (!isRetryable(error)) {
182
+ throw formatUploadError(file, error, attempt);
183
+ }
184
+ failedUploads.add(file.remotePath);
185
+ if (attempt >= attempts) {
186
+ throw formatUploadError(file, error, attempt);
187
+ }
188
+ const details = errorDetails(error);
189
+ const delay = retryDelay(attempt, details?.retryAfter);
190
+ console.warn("Retrying Allure service upload", {
191
+ endpoint: details?.endpoint,
192
+ attempt: attempt + 1,
193
+ status: details?.status ?? details?.networkCode ?? "network error",
194
+ delay,
195
+ });
196
+ await waitForRetry(delay, signal);
197
+ }
198
+ }
199
+ return undefined;
200
+ };
201
+ export const uploadStorageReport = async (payload) => {
202
+ const { onProgress, uploadConcurrency, uploadMaxAttempts, uploadedAssets, upload } = payload;
203
+ const controller = new AbortController();
204
+ const externalSignal = payload.signal;
205
+ const abortFromExternalSignal = () => controller.abort(externalSignal?.reason);
206
+ if (externalSignal) {
207
+ externalSignal.addEventListener("abort", abortFromExternalSignal, { once: true });
208
+ if (externalSignal.aborted) {
209
+ abortFromExternalSignal();
210
+ }
211
+ }
212
+ try {
213
+ const prepared = await prepareUploadFiles(payload, controller.signal);
214
+ const files = prepared.filter((file) => file.kind === "report" || !uploadedAssets.has(file.remotePath));
215
+ if (files.length === 0) {
216
+ throwIfAborted(controller.signal);
217
+ return { hrefs: {} };
218
+ }
219
+ const failedUploads = new Set();
220
+ const hrefs = {};
221
+ let indexHref;
222
+ let nextFileIndex = 0;
223
+ const uploadNext = async () => {
224
+ while (!controller.signal.aborted) {
225
+ const fileIndex = nextFileIndex++;
226
+ if (fileIndex >= files.length) {
227
+ return;
228
+ }
229
+ const file = files[fileIndex];
230
+ const href = await uploadWithRetry(file, controller.signal, failedUploads, uploadMaxAttempts, upload);
231
+ if (file.kind === "asset") {
232
+ uploadedAssets.add(file.remotePath);
233
+ }
234
+ else if (href) {
235
+ hrefs[file.filename] = href;
236
+ if (file.normalizedFilename === "index.html") {
237
+ indexHref = href;
238
+ }
239
+ }
240
+ onProgress?.(1);
241
+ }
242
+ };
243
+ const workerCount = Math.min(uploadConcurrency, files.length);
244
+ const workers = Array.from({ length: workerCount }, () => uploadNext());
245
+ try {
246
+ await Promise.all(workers);
247
+ throwIfAborted(controller.signal);
248
+ }
249
+ catch (error) {
250
+ controller.abort(error);
251
+ await Promise.allSettled(workers);
252
+ throw error;
253
+ }
254
+ return { indexHref, hrefs };
255
+ }
256
+ catch (error) {
257
+ controller.abort(error);
258
+ throw error;
259
+ }
260
+ finally {
261
+ externalSignal?.removeEventListener("abort", abortFromExternalSignal);
262
+ }
263
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allurereport/service",
3
- "version": "3.17.0",
3
+ "version": "3.19.0",
4
4
  "description": "Allure Service API",
5
5
  "keywords": [
6
6
  "allure",
@@ -30,9 +30,9 @@
30
30
  "lint:fix": "oxlint --import-plugin --fix src test features stories"
31
31
  },
32
32
  "dependencies": {
33
- "@allurereport/core-api": "3.17.0",
34
- "@allurereport/plugin-api": "3.17.0",
35
- "axios": "^1.18.1",
33
+ "@allurereport/core-api": "3.19.0",
34
+ "@allurereport/plugin-api": "3.19.0",
35
+ "axios": "^1.20.0",
36
36
  "open": "^11.0.0"
37
37
  },
38
38
  "devDependencies": {