@flakiness/sdk 0.95.0 → 0.97.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/lib/cli/cli.js +1229 -386
- package/lib/cli/cmd-convert.js +9 -12
- package/lib/cli/cmd-download.js +9 -459
- package/lib/cli/cmd-link.js +84 -271
- package/lib/cli/cmd-login.js +26 -232
- package/lib/cli/cmd-logout.js +22 -230
- package/lib/cli/cmd-show-report.js +488 -0
- package/lib/cli/cmd-status.js +84 -271
- package/lib/cli/cmd-unlink.js +68 -35
- package/lib/cli/cmd-upload-playwright-json.js +52 -257
- package/lib/cli/cmd-upload.js +92 -288
- package/lib/cli/cmd-whoami.js +16 -230
- package/lib/{flakinessLink.js → flakinessConfig.js} +65 -36
- package/lib/flakinessSession.js +16 -230
- package/lib/junit.js +8 -9
- package/lib/localGit.js +43 -0
- package/lib/localReportApi.js +40 -0
- package/lib/localReportServer.js +300 -0
- package/lib/playwright-test.js +496 -318
- package/lib/playwrightJSONReport.js +12 -15
- package/lib/reportUploader.js +47 -248
- package/lib/serverapi.js +10 -230
- package/lib/utils.js +46 -18
- package/package.json +16 -5
- package/types/tsconfig.tsbuildinfo +1 -1
- package/lib/cli/cmd-serve.js +0 -7
package/lib/playwright-test.js
CHANGED
|
@@ -1,283 +1,31 @@
|
|
|
1
1
|
// src/playwright-test.ts
|
|
2
2
|
import { FlakinessReport as FK } from "@flakiness/report";
|
|
3
3
|
import { Multimap } from "@flakiness/shared/common/multimap.js";
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import chalk2 from "chalk";
|
|
5
|
+
import fs8 from "fs";
|
|
6
|
+
import path5 from "path";
|
|
6
7
|
|
|
7
|
-
// src/
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
for (const [filepath, steps] of filepathToSteps) {
|
|
12
|
-
let source;
|
|
13
|
-
try {
|
|
14
|
-
source = fs.readFileSync(filepath, "utf-8");
|
|
15
|
-
} catch (e) {
|
|
16
|
-
continue;
|
|
17
|
-
}
|
|
18
|
-
const lines = source.split("\n").length;
|
|
19
|
-
const highlighted = codeFrameColumns(source, { start: { line: lines, column: 1 } }, { highlightCode: true, linesAbove: lines, linesBelow: 0 });
|
|
20
|
-
const highlightedLines = highlighted.split("\n");
|
|
21
|
-
const lineWithArrow = highlightedLines[highlightedLines.length - 1];
|
|
22
|
-
for (const step of steps) {
|
|
23
|
-
if (!step.location)
|
|
24
|
-
continue;
|
|
25
|
-
if (step.location.line < 2 || step.location.line >= lines)
|
|
26
|
-
continue;
|
|
27
|
-
const snippetLines = highlightedLines.slice(step.location.line - 2, step.location.line + 1);
|
|
28
|
-
const index = lineWithArrow.indexOf("^");
|
|
29
|
-
const shiftedArrow = lineWithArrow.slice(0, index) + " ".repeat(step.location.column - 1) + lineWithArrow.slice(index);
|
|
30
|
-
snippetLines.splice(2, 0, shiftedArrow);
|
|
31
|
-
step.snippet = snippetLines.join("\n");
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
8
|
+
// src/cli/cmd-show-report.ts
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import open from "open";
|
|
11
|
+
import path4 from "path";
|
|
35
12
|
|
|
36
|
-
// src/
|
|
37
|
-
import
|
|
38
|
-
import
|
|
39
|
-
import { brotliCompressSync as brotliCompressSync2 } from "zlib";
|
|
40
|
-
|
|
41
|
-
// ../server/lib/common/typedHttp.js
|
|
42
|
-
var TypedHTTP;
|
|
43
|
-
((TypedHTTP2) => {
|
|
44
|
-
TypedHTTP2.StatusCodes = {
|
|
45
|
-
Informational: {
|
|
46
|
-
CONTINUE: 100,
|
|
47
|
-
SWITCHING_PROTOCOLS: 101,
|
|
48
|
-
PROCESSING: 102,
|
|
49
|
-
EARLY_HINTS: 103
|
|
50
|
-
},
|
|
51
|
-
Success: {
|
|
52
|
-
OK: 200,
|
|
53
|
-
CREATED: 201,
|
|
54
|
-
ACCEPTED: 202,
|
|
55
|
-
NON_AUTHORITATIVE_INFORMATION: 203,
|
|
56
|
-
NO_CONTENT: 204,
|
|
57
|
-
RESET_CONTENT: 205,
|
|
58
|
-
PARTIAL_CONTENT: 206,
|
|
59
|
-
MULTI_STATUS: 207
|
|
60
|
-
},
|
|
61
|
-
Redirection: {
|
|
62
|
-
MULTIPLE_CHOICES: 300,
|
|
63
|
-
MOVED_PERMANENTLY: 301,
|
|
64
|
-
MOVED_TEMPORARILY: 302,
|
|
65
|
-
SEE_OTHER: 303,
|
|
66
|
-
NOT_MODIFIED: 304,
|
|
67
|
-
USE_PROXY: 305,
|
|
68
|
-
TEMPORARY_REDIRECT: 307,
|
|
69
|
-
PERMANENT_REDIRECT: 308
|
|
70
|
-
},
|
|
71
|
-
ClientErrors: {
|
|
72
|
-
BAD_REQUEST: 400,
|
|
73
|
-
UNAUTHORIZED: 401,
|
|
74
|
-
PAYMENT_REQUIRED: 402,
|
|
75
|
-
FORBIDDEN: 403,
|
|
76
|
-
NOT_FOUND: 404,
|
|
77
|
-
METHOD_NOT_ALLOWED: 405,
|
|
78
|
-
NOT_ACCEPTABLE: 406,
|
|
79
|
-
PROXY_AUTHENTICATION_REQUIRED: 407,
|
|
80
|
-
REQUEST_TIMEOUT: 408,
|
|
81
|
-
CONFLICT: 409,
|
|
82
|
-
GONE: 410,
|
|
83
|
-
LENGTH_REQUIRED: 411,
|
|
84
|
-
PRECONDITION_FAILED: 412,
|
|
85
|
-
REQUEST_TOO_LONG: 413,
|
|
86
|
-
REQUEST_URI_TOO_LONG: 414,
|
|
87
|
-
UNSUPPORTED_MEDIA_TYPE: 415,
|
|
88
|
-
REQUESTED_RANGE_NOT_SATISFIABLE: 416,
|
|
89
|
-
EXPECTATION_FAILED: 417,
|
|
90
|
-
IM_A_TEAPOT: 418,
|
|
91
|
-
INSUFFICIENT_SPACE_ON_RESOURCE: 419,
|
|
92
|
-
METHOD_FAILURE: 420,
|
|
93
|
-
MISDIRECTED_REQUEST: 421,
|
|
94
|
-
UNPROCESSABLE_ENTITY: 422,
|
|
95
|
-
LOCKED: 423,
|
|
96
|
-
FAILED_DEPENDENCY: 424,
|
|
97
|
-
UPGRADE_REQUIRED: 426,
|
|
98
|
-
PRECONDITION_REQUIRED: 428,
|
|
99
|
-
TOO_MANY_REQUESTS: 429,
|
|
100
|
-
REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
|
|
101
|
-
UNAVAILABLE_FOR_LEGAL_REASONS: 451
|
|
102
|
-
},
|
|
103
|
-
ServerErrors: {
|
|
104
|
-
INTERNAL_SERVER_ERROR: 500,
|
|
105
|
-
NOT_IMPLEMENTED: 501,
|
|
106
|
-
BAD_GATEWAY: 502,
|
|
107
|
-
SERVICE_UNAVAILABLE: 503,
|
|
108
|
-
GATEWAY_TIMEOUT: 504,
|
|
109
|
-
HTTP_VERSION_NOT_SUPPORTED: 505,
|
|
110
|
-
INSUFFICIENT_STORAGE: 507,
|
|
111
|
-
NETWORK_AUTHENTICATION_REQUIRED: 511
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
const AllErrorCodes = {
|
|
115
|
-
...TypedHTTP2.StatusCodes.ClientErrors,
|
|
116
|
-
...TypedHTTP2.StatusCodes.ServerErrors
|
|
117
|
-
};
|
|
118
|
-
class HttpError extends Error {
|
|
119
|
-
constructor(status, message) {
|
|
120
|
-
super(message);
|
|
121
|
-
this.status = status;
|
|
122
|
-
}
|
|
123
|
-
static withCode(code, message) {
|
|
124
|
-
const statusCode = AllErrorCodes[code];
|
|
125
|
-
const defaultMessage = code.split("_").map((word) => word.charAt(0) + word.slice(1).toLowerCase()).join(" ");
|
|
126
|
-
return new HttpError(statusCode, message ?? defaultMessage);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
TypedHTTP2.HttpError = HttpError;
|
|
130
|
-
function isInformationalResponse(response) {
|
|
131
|
-
return response.status >= 100 && response.status < 200;
|
|
132
|
-
}
|
|
133
|
-
TypedHTTP2.isInformationalResponse = isInformationalResponse;
|
|
134
|
-
function isSuccessResponse(response) {
|
|
135
|
-
return response.status >= 200 && response.status < 300;
|
|
136
|
-
}
|
|
137
|
-
TypedHTTP2.isSuccessResponse = isSuccessResponse;
|
|
138
|
-
function isRedirectResponse(response) {
|
|
139
|
-
return response.status >= 300 && response.status < 400;
|
|
140
|
-
}
|
|
141
|
-
TypedHTTP2.isRedirectResponse = isRedirectResponse;
|
|
142
|
-
function isErrorResponse(response) {
|
|
143
|
-
return response.status >= 400 && response.status < 600;
|
|
144
|
-
}
|
|
145
|
-
TypedHTTP2.isErrorResponse = isErrorResponse;
|
|
146
|
-
function info(status) {
|
|
147
|
-
return { status };
|
|
148
|
-
}
|
|
149
|
-
TypedHTTP2.info = info;
|
|
150
|
-
function ok(data, status) {
|
|
151
|
-
return {
|
|
152
|
-
status: status ?? TypedHTTP2.StatusCodes.Success.OK,
|
|
153
|
-
data
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
TypedHTTP2.ok = ok;
|
|
157
|
-
function redirect(url, status = 302) {
|
|
158
|
-
return { status, url };
|
|
159
|
-
}
|
|
160
|
-
TypedHTTP2.redirect = redirect;
|
|
161
|
-
function error(message, status = TypedHTTP2.StatusCodes.ServerErrors.INTERNAL_SERVER_ERROR) {
|
|
162
|
-
return { status, message };
|
|
163
|
-
}
|
|
164
|
-
TypedHTTP2.error = error;
|
|
165
|
-
class Router {
|
|
166
|
-
constructor(_resolveContext) {
|
|
167
|
-
this._resolveContext = _resolveContext;
|
|
168
|
-
}
|
|
169
|
-
static create() {
|
|
170
|
-
return new Router(async (e) => e.ctx);
|
|
171
|
-
}
|
|
172
|
-
rawMethod(method, route) {
|
|
173
|
-
return {
|
|
174
|
-
[method]: {
|
|
175
|
-
method,
|
|
176
|
-
input: route.input,
|
|
177
|
-
etag: route.etag,
|
|
178
|
-
resolveContext: this._resolveContext,
|
|
179
|
-
handler: route.handler
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
get(route) {
|
|
184
|
-
return this.rawMethod("GET", {
|
|
185
|
-
...route,
|
|
186
|
-
handler: (...args) => Promise.resolve(route.handler(...args)).then((result) => TypedHTTP2.ok(result))
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
post(route) {
|
|
190
|
-
return this.rawMethod("POST", {
|
|
191
|
-
...route,
|
|
192
|
-
handler: (...args) => Promise.resolve(route.handler(...args)).then((result) => TypedHTTP2.ok(result))
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
use(resolveContext) {
|
|
196
|
-
return new Router(async (options) => {
|
|
197
|
-
const m = await this._resolveContext(options);
|
|
198
|
-
return resolveContext({ ...options, ctx: m });
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
TypedHTTP2.Router = Router;
|
|
203
|
-
function createClient(base, fetchCallback) {
|
|
204
|
-
function buildUrl(path2, input, options) {
|
|
205
|
-
const method = path2.at(-1);
|
|
206
|
-
const url = new URL(path2.slice(0, path2.length - 1).join("/"), base);
|
|
207
|
-
const signal = options?.signal;
|
|
208
|
-
let body = void 0;
|
|
209
|
-
if (method === "GET" && input)
|
|
210
|
-
url.searchParams.set("input", JSON.stringify(input));
|
|
211
|
-
else if (method !== "GET" && input)
|
|
212
|
-
body = JSON.stringify(input);
|
|
213
|
-
return {
|
|
214
|
-
url,
|
|
215
|
-
method,
|
|
216
|
-
headers: body ? { "Content-Type": "application/json" } : void 0,
|
|
217
|
-
body,
|
|
218
|
-
signal
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
function createProxy(path2 = []) {
|
|
222
|
-
return new Proxy(() => {
|
|
223
|
-
}, {
|
|
224
|
-
get(target, prop) {
|
|
225
|
-
if (typeof prop === "symbol")
|
|
226
|
-
return void 0;
|
|
227
|
-
if (prop === "prepare")
|
|
228
|
-
return (input, options) => buildUrl(path2, input, options);
|
|
229
|
-
const newPath = [...path2, prop];
|
|
230
|
-
return createProxy(newPath);
|
|
231
|
-
},
|
|
232
|
-
apply(target, thisArg, args) {
|
|
233
|
-
const options = buildUrl(path2, args[0], args[1]);
|
|
234
|
-
return fetchCallback(options.url, {
|
|
235
|
-
method: options.method,
|
|
236
|
-
body: options.body,
|
|
237
|
-
headers: options.headers,
|
|
238
|
-
signal: options.signal
|
|
239
|
-
}).then(async (response) => {
|
|
240
|
-
if (response.status >= 200 && response.status < 300) {
|
|
241
|
-
if (response.headers.get("content-type")?.includes("application/json")) {
|
|
242
|
-
const text = await response.text();
|
|
243
|
-
return text.length ? JSON.parse(text) : void 0;
|
|
244
|
-
}
|
|
245
|
-
return await response.blob();
|
|
246
|
-
}
|
|
247
|
-
if (response.status >= 400 && response.status < 600) {
|
|
248
|
-
const text = await response.text();
|
|
249
|
-
if (text)
|
|
250
|
-
throw new Error(`HTTP request failed with status ${response.status}: ${text}`);
|
|
251
|
-
else
|
|
252
|
-
throw new Error(`HTTP request failed with status ${response.status}`);
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
return createProxy();
|
|
259
|
-
}
|
|
260
|
-
TypedHTTP2.createClient = createClient;
|
|
261
|
-
})(TypedHTTP || (TypedHTTP = {}));
|
|
13
|
+
// src/flakinessConfig.ts
|
|
14
|
+
import fs2 from "fs";
|
|
15
|
+
import path2 from "path";
|
|
262
16
|
|
|
263
17
|
// src/utils.ts
|
|
18
|
+
import { FlakinessReport } from "@flakiness/report";
|
|
264
19
|
import assert from "assert";
|
|
265
20
|
import { spawnSync } from "child_process";
|
|
266
21
|
import crypto from "crypto";
|
|
267
|
-
import
|
|
22
|
+
import fs from "fs";
|
|
268
23
|
import http from "http";
|
|
269
24
|
import https from "https";
|
|
270
25
|
import os from "os";
|
|
271
|
-
import { posix as posixPath, win32 as win32Path } from "path";
|
|
272
|
-
import util from "util";
|
|
273
|
-
import zlib from "zlib";
|
|
274
|
-
var gzipAsync = util.promisify(zlib.gzip);
|
|
275
|
-
var gunzipAsync = util.promisify(zlib.gunzip);
|
|
276
|
-
var gunzipSync = zlib.gunzipSync;
|
|
277
|
-
var brotliCompressAsync = util.promisify(zlib.brotliCompress);
|
|
278
|
-
var brotliCompressSync = zlib.brotliCompressSync;
|
|
26
|
+
import path, { posix as posixPath, win32 as win32Path } from "path";
|
|
279
27
|
async function existsAsync(aPath) {
|
|
280
|
-
return
|
|
28
|
+
return fs.promises.stat(aPath).then(() => true).catch((e) => false);
|
|
281
29
|
}
|
|
282
30
|
function extractEnvConfiguration() {
|
|
283
31
|
const ENV_PREFIX = "FK_ENV_";
|
|
@@ -288,18 +36,22 @@ function extractEnvConfiguration() {
|
|
|
288
36
|
function sha1File(filePath) {
|
|
289
37
|
return new Promise((resolve, reject) => {
|
|
290
38
|
const hash = crypto.createHash("sha1");
|
|
291
|
-
const stream =
|
|
39
|
+
const stream = fs.createReadStream(filePath);
|
|
292
40
|
stream.on("data", (chunk) => {
|
|
293
41
|
hash.update(chunk);
|
|
294
42
|
});
|
|
295
43
|
stream.on("end", () => {
|
|
296
44
|
resolve(hash.digest("hex"));
|
|
297
45
|
});
|
|
298
|
-
stream.on("error", (
|
|
299
|
-
reject(
|
|
46
|
+
stream.on("error", (err2) => {
|
|
47
|
+
reject(err2);
|
|
300
48
|
});
|
|
301
49
|
});
|
|
302
50
|
}
|
|
51
|
+
var FLAKINESS_DBG = !!process.env.FLAKINESS_DBG;
|
|
52
|
+
function errorText(error) {
|
|
53
|
+
return FLAKINESS_DBG ? error.stack : error.message;
|
|
54
|
+
}
|
|
303
55
|
function sha1Buffer(data) {
|
|
304
56
|
const hash = crypto.createHash("sha1");
|
|
305
57
|
hash.update(data);
|
|
@@ -311,9 +63,9 @@ async function retryWithBackoff(job, backoff = []) {
|
|
|
311
63
|
return await job();
|
|
312
64
|
} catch (e) {
|
|
313
65
|
if (e instanceof AggregateError)
|
|
314
|
-
console.error(`[flakiness.io err]`, e.errors[0]
|
|
66
|
+
console.error(`[flakiness.io err]`, errorText(e.errors[0]));
|
|
315
67
|
else if (e instanceof Error)
|
|
316
|
-
console.error(`[flakiness.io err]`, e
|
|
68
|
+
console.error(`[flakiness.io err]`, errorText(e));
|
|
317
69
|
else
|
|
318
70
|
console.error(`[flakiness.io err]`, e);
|
|
319
71
|
await new Promise((x) => setTimeout(x, timeout));
|
|
@@ -331,6 +83,7 @@ var httpUtils;
|
|
|
331
83
|
reject = b;
|
|
332
84
|
});
|
|
333
85
|
const protocol = url.startsWith("https") ? https : http;
|
|
86
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([key, value]) => value !== void 0));
|
|
334
87
|
const request = protocol.request(url, { method, headers }, (res) => {
|
|
335
88
|
const chunks = [];
|
|
336
89
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -390,18 +143,16 @@ function shell(command, args, options) {
|
|
|
390
143
|
try {
|
|
391
144
|
const result = spawnSync(command, args, { encoding: "utf-8", ...options });
|
|
392
145
|
if (result.status !== 0) {
|
|
393
|
-
console.log(result);
|
|
394
|
-
console.log(options);
|
|
395
146
|
return void 0;
|
|
396
147
|
}
|
|
397
148
|
return result.stdout.trim();
|
|
398
149
|
} catch (e) {
|
|
399
|
-
console.
|
|
150
|
+
console.error(e);
|
|
400
151
|
return void 0;
|
|
401
152
|
}
|
|
402
153
|
}
|
|
403
154
|
function readLinuxOSRelease() {
|
|
404
|
-
const osReleaseText =
|
|
155
|
+
const osReleaseText = fs.readFileSync("/etc/os-release", "utf-8");
|
|
405
156
|
return new Map(osReleaseText.toLowerCase().split("\n").filter((line) => line.includes("=")).map((line) => {
|
|
406
157
|
line = line.trim();
|
|
407
158
|
let [key, value] = line.split("=");
|
|
@@ -449,6 +200,40 @@ function gitCommitInfo(gitRepo) {
|
|
|
449
200
|
assert(sha, `FAILED: git rev-parse HEAD @ ${gitRepo}`);
|
|
450
201
|
return sha.trim();
|
|
451
202
|
}
|
|
203
|
+
async function resolveAttachmentPaths(report, attachmentsDir) {
|
|
204
|
+
const attachmentFiles = await listFilesRecursively(attachmentsDir);
|
|
205
|
+
const filenameToPath = new Map(attachmentFiles.map((file) => [path.basename(file), file]));
|
|
206
|
+
const attachmentIdToPath = /* @__PURE__ */ new Map();
|
|
207
|
+
const missingAttachments = /* @__PURE__ */ new Set();
|
|
208
|
+
FlakinessReport.visitTests(report, (test) => {
|
|
209
|
+
for (const attempt of test.attempts) {
|
|
210
|
+
for (const attachment of attempt.attachments ?? []) {
|
|
211
|
+
const attachmentPath = filenameToPath.get(attachment.id);
|
|
212
|
+
if (!attachmentPath) {
|
|
213
|
+
missingAttachments.add(attachment.id);
|
|
214
|
+
} else {
|
|
215
|
+
attachmentIdToPath.set(attachment.id, {
|
|
216
|
+
contentType: attachment.contentType,
|
|
217
|
+
id: attachment.id,
|
|
218
|
+
path: attachmentPath
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
return { attachmentIdToPath, missingAttachments: Array.from(missingAttachments) };
|
|
225
|
+
}
|
|
226
|
+
async function listFilesRecursively(dir, result = []) {
|
|
227
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
const fullPath = path.join(dir, entry.name);
|
|
230
|
+
if (entry.isDirectory())
|
|
231
|
+
await listFilesRecursively(fullPath, result);
|
|
232
|
+
else
|
|
233
|
+
result.push(fullPath);
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
452
237
|
function computeGitRoot(somePathInsideGitRepo) {
|
|
453
238
|
const root = shell(`git`, ["rev-parse", "--show-toplevel"], {
|
|
454
239
|
cwd: somePathInsideGitRepo,
|
|
@@ -509,7 +294,75 @@ function createEnvironments(projects) {
|
|
|
509
294
|
return result;
|
|
510
295
|
}
|
|
511
296
|
|
|
297
|
+
// src/flakinessConfig.ts
|
|
298
|
+
function createConfigPath(dir) {
|
|
299
|
+
return path2.join(dir, ".flakiness", "config.json");
|
|
300
|
+
}
|
|
301
|
+
var gConfigPath;
|
|
302
|
+
function ensureConfigPath() {
|
|
303
|
+
if (!gConfigPath)
|
|
304
|
+
gConfigPath = computeConfigPath();
|
|
305
|
+
return gConfigPath;
|
|
306
|
+
}
|
|
307
|
+
function computeConfigPath() {
|
|
308
|
+
for (let p = process.cwd(); p !== path2.resolve(p, ".."); p = path2.resolve(p, "..")) {
|
|
309
|
+
const configPath = createConfigPath(p);
|
|
310
|
+
if (fs2.existsSync(configPath))
|
|
311
|
+
return configPath;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const gitRoot = computeGitRoot(process.cwd());
|
|
315
|
+
return createConfigPath(gitRoot);
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return createConfigPath(process.cwd());
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
var FlakinessConfig = class _FlakinessConfig {
|
|
321
|
+
constructor(_configPath, _config) {
|
|
322
|
+
this._configPath = _configPath;
|
|
323
|
+
this._config = _config;
|
|
324
|
+
}
|
|
325
|
+
static async load() {
|
|
326
|
+
const configPath = ensureConfigPath();
|
|
327
|
+
const data = await fs2.promises.readFile(configPath, "utf-8").catch((e) => void 0);
|
|
328
|
+
const json = data ? JSON.parse(data) : {};
|
|
329
|
+
return new _FlakinessConfig(configPath, json);
|
|
330
|
+
}
|
|
331
|
+
static async projectOrDie(session) {
|
|
332
|
+
const config = await _FlakinessConfig.load();
|
|
333
|
+
const projectPublicId = config.projectPublicId();
|
|
334
|
+
if (!projectPublicId)
|
|
335
|
+
throw new Error(`Please link to flakiness project with 'npx flakiness link'`);
|
|
336
|
+
const project = await session.api.project.getProject.GET({ projectPublicId }).catch((e) => void 0);
|
|
337
|
+
if (!project)
|
|
338
|
+
throw new Error(`Failed to fetch linked project; please re-link with 'npx flakiness link'`);
|
|
339
|
+
return project;
|
|
340
|
+
}
|
|
341
|
+
static createEmpty() {
|
|
342
|
+
return new _FlakinessConfig(ensureConfigPath(), {});
|
|
343
|
+
}
|
|
344
|
+
path() {
|
|
345
|
+
return this._configPath;
|
|
346
|
+
}
|
|
347
|
+
projectPublicId() {
|
|
348
|
+
return this._config.projectPublicId;
|
|
349
|
+
}
|
|
350
|
+
setProjectPublicId(projectId) {
|
|
351
|
+
this._config.projectPublicId = projectId;
|
|
352
|
+
}
|
|
353
|
+
async save() {
|
|
354
|
+
await fs2.promises.mkdir(path2.dirname(this._configPath), { recursive: true });
|
|
355
|
+
await fs2.promises.writeFile(this._configPath, JSON.stringify(this._config, null, 2));
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// src/flakinessSession.ts
|
|
360
|
+
import fs3 from "fs/promises";
|
|
361
|
+
import os2 from "os";
|
|
362
|
+
import path3 from "path";
|
|
363
|
+
|
|
512
364
|
// src/serverapi.ts
|
|
365
|
+
import { TypedHTTP } from "@flakiness/shared/common/typedHttp.js";
|
|
513
366
|
function createServerAPI(endpoint, options) {
|
|
514
367
|
endpoint += "/api/";
|
|
515
368
|
const fetcher = options?.auth ? (url, init) => fetch(url, {
|
|
@@ -524,7 +377,277 @@ function createServerAPI(endpoint, options) {
|
|
|
524
377
|
return TypedHTTP.createClient(endpoint, fetcher);
|
|
525
378
|
}
|
|
526
379
|
|
|
380
|
+
// src/flakinessSession.ts
|
|
381
|
+
var CONFIG_DIR = (() => {
|
|
382
|
+
const configDir = process.platform === "darwin" ? path3.join(os2.homedir(), "Library", "Application Support", "flakiness") : process.platform === "win32" ? path3.join(os2.homedir(), "AppData", "Roaming", "flakiness") : path3.join(os2.homedir(), ".config", "flakiness");
|
|
383
|
+
return configDir;
|
|
384
|
+
})();
|
|
385
|
+
var CONFIG_PATH = path3.join(CONFIG_DIR, "config.json");
|
|
386
|
+
var FlakinessSession = class _FlakinessSession {
|
|
387
|
+
constructor(_config) {
|
|
388
|
+
this._config = _config;
|
|
389
|
+
this.api = createServerAPI(this._config.endpoint, { auth: this._config.token });
|
|
390
|
+
}
|
|
391
|
+
static async loadOrDie() {
|
|
392
|
+
const session = await _FlakinessSession.load();
|
|
393
|
+
if (!session)
|
|
394
|
+
throw new Error(`Please login first with 'npx flakiness login'`);
|
|
395
|
+
return session;
|
|
396
|
+
}
|
|
397
|
+
static async load() {
|
|
398
|
+
const data = await fs3.readFile(CONFIG_PATH, "utf-8").catch((e) => void 0);
|
|
399
|
+
if (!data)
|
|
400
|
+
return void 0;
|
|
401
|
+
const json = JSON.parse(data);
|
|
402
|
+
return new _FlakinessSession(json);
|
|
403
|
+
}
|
|
404
|
+
static async remove() {
|
|
405
|
+
await fs3.unlink(CONFIG_PATH).catch((e) => void 0);
|
|
406
|
+
}
|
|
407
|
+
api;
|
|
408
|
+
endpoint() {
|
|
409
|
+
return this._config.endpoint;
|
|
410
|
+
}
|
|
411
|
+
path() {
|
|
412
|
+
return CONFIG_PATH;
|
|
413
|
+
}
|
|
414
|
+
sessionToken() {
|
|
415
|
+
return this._config.token;
|
|
416
|
+
}
|
|
417
|
+
async save() {
|
|
418
|
+
await fs3.mkdir(CONFIG_DIR, { recursive: true });
|
|
419
|
+
await fs3.writeFile(CONFIG_PATH, JSON.stringify(this._config, null, 2));
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// src/localReportServer.ts
|
|
424
|
+
import { TypedHTTP as TypedHTTP3 } from "@flakiness/shared/common/typedHttp.js";
|
|
425
|
+
import { randomUUIDBase62 } from "@flakiness/shared/node/nodeutils.js";
|
|
426
|
+
import { createTypedHttpExpressMiddleware } from "@flakiness/shared/node/typedHttpExpress.js";
|
|
427
|
+
import bodyParser from "body-parser";
|
|
428
|
+
import compression from "compression";
|
|
429
|
+
import debug from "debug";
|
|
430
|
+
import express from "express";
|
|
431
|
+
import "express-async-errors";
|
|
432
|
+
import fs5 from "fs";
|
|
433
|
+
import http2 from "http";
|
|
434
|
+
|
|
435
|
+
// src/localGit.ts
|
|
436
|
+
import { exec } from "child_process";
|
|
437
|
+
import { promisify } from "util";
|
|
438
|
+
var execAsync = promisify(exec);
|
|
439
|
+
async function listLocalCommits(gitRoot, head, count) {
|
|
440
|
+
const FIELD_SEPARATOR = "|~|";
|
|
441
|
+
const RECORD_SEPARATOR = "\0";
|
|
442
|
+
const prettyFormat = [
|
|
443
|
+
"%H",
|
|
444
|
+
// %H: Full commit hash
|
|
445
|
+
"%at",
|
|
446
|
+
// %at: Author date as a Unix timestamp (seconds since epoch)
|
|
447
|
+
"%an",
|
|
448
|
+
// %an: Author name
|
|
449
|
+
"%s"
|
|
450
|
+
// %s: Subject (the first line of the commit message)
|
|
451
|
+
].join(FIELD_SEPARATOR);
|
|
452
|
+
const command = `git log ${head} -n ${count} --pretty=format:"${prettyFormat}" -z`;
|
|
453
|
+
try {
|
|
454
|
+
const { stdout } = await execAsync(command, { cwd: gitRoot });
|
|
455
|
+
if (!stdout) {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
return stdout.trim().split(RECORD_SEPARATOR).filter((record) => record).map((record) => {
|
|
459
|
+
const [commitId, timestampStr, author, message] = record.split(FIELD_SEPARATOR);
|
|
460
|
+
return {
|
|
461
|
+
commitId,
|
|
462
|
+
timestamp: parseInt(timestampStr, 10) * 1e3,
|
|
463
|
+
// Convert timestamp from seconds to milliseconds
|
|
464
|
+
author,
|
|
465
|
+
message,
|
|
466
|
+
walkIndex: 0
|
|
467
|
+
};
|
|
468
|
+
});
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error(`Failed to list commits for repository at ${gitRoot}:`, error);
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/localReportApi.ts
|
|
476
|
+
import { TypedHTTP as TypedHTTP2 } from "@flakiness/shared/common/typedHttp.js";
|
|
477
|
+
import fs4 from "fs";
|
|
478
|
+
import { z } from "zod/v4";
|
|
479
|
+
var t = TypedHTTP2.Router.create();
|
|
480
|
+
var localReportRouter = {
|
|
481
|
+
ping: t.get({
|
|
482
|
+
handler: async () => {
|
|
483
|
+
return "pong";
|
|
484
|
+
}
|
|
485
|
+
}),
|
|
486
|
+
lastCommits: t.get({
|
|
487
|
+
handler: async ({ ctx }) => {
|
|
488
|
+
return ctx.commits;
|
|
489
|
+
}
|
|
490
|
+
}),
|
|
491
|
+
report: {
|
|
492
|
+
attachment: t.rawMethod("GET", {
|
|
493
|
+
input: z.object({
|
|
494
|
+
attachmentId: z.string().min(1).max(100).transform((id) => id)
|
|
495
|
+
}),
|
|
496
|
+
handler: async ({ ctx, input }) => {
|
|
497
|
+
const idx = ctx.attachmentIdToPath.get(input.attachmentId);
|
|
498
|
+
if (!idx)
|
|
499
|
+
throw TypedHTTP2.HttpError.withCode("NOT_FOUND");
|
|
500
|
+
const buffer = await fs4.promises.readFile(idx.path);
|
|
501
|
+
return TypedHTTP2.ok(buffer, idx.contentType);
|
|
502
|
+
}
|
|
503
|
+
}),
|
|
504
|
+
json: t.get({
|
|
505
|
+
handler: async ({ ctx }) => {
|
|
506
|
+
return ctx.report;
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
// src/localReportServer.ts
|
|
513
|
+
var logHTTPServer = debug("fk:http");
|
|
514
|
+
var LocalReportServer = class _LocalReportServer {
|
|
515
|
+
constructor(_server, _port, _authToken) {
|
|
516
|
+
this._server = _server;
|
|
517
|
+
this._port = _port;
|
|
518
|
+
this._authToken = _authToken;
|
|
519
|
+
}
|
|
520
|
+
static async create(options) {
|
|
521
|
+
const app = express();
|
|
522
|
+
app.set("etag", false);
|
|
523
|
+
const authToken = randomUUIDBase62();
|
|
524
|
+
app.use(compression());
|
|
525
|
+
app.use(bodyParser.json({ limit: 256 * 1024 }));
|
|
526
|
+
app.use((req, res, next) => {
|
|
527
|
+
if (!req.path.startsWith("/" + authToken))
|
|
528
|
+
throw TypedHTTP3.HttpError.withCode("UNAUTHORIZED");
|
|
529
|
+
res.setHeader("Access-Control-Allow-Headers", "*");
|
|
530
|
+
res.setHeader("Access-Control-Allow-Origin", options.endpoint);
|
|
531
|
+
res.setHeader("Access-Control-Allow-Methods", "*");
|
|
532
|
+
if (req.method === "OPTIONS") {
|
|
533
|
+
res.writeHead(204);
|
|
534
|
+
res.end();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
req.on("aborted", () => logHTTPServer(`REQ ABORTED ${req.method} ${req.originalUrl}`));
|
|
538
|
+
res.on("close", () => {
|
|
539
|
+
if (!res.headersSent)
|
|
540
|
+
logHTTPServer(`RES CLOSED BEFORE SEND ${req.method} ${req.originalUrl}`);
|
|
541
|
+
});
|
|
542
|
+
next();
|
|
543
|
+
});
|
|
544
|
+
app.use("/" + authToken, createTypedHttpExpressMiddleware({
|
|
545
|
+
router: localReportRouter,
|
|
546
|
+
createRootContext: async ({ req, res, input }) => {
|
|
547
|
+
const report = JSON.parse(await fs5.promises.readFile(options.reportPath, "utf-8"));
|
|
548
|
+
const attachmentsDir = options.attachmentsFolder;
|
|
549
|
+
const { attachmentIdToPath, missingAttachments } = await resolveAttachmentPaths(report, attachmentsDir);
|
|
550
|
+
if (missingAttachments.length) {
|
|
551
|
+
const first = missingAttachments.slice(0, 3);
|
|
552
|
+
for (let i = 0; i < 3 && i < missingAttachments.length; ++i)
|
|
553
|
+
console.warn(`Missing attachment with id ${missingAttachments[i]}`);
|
|
554
|
+
if (missingAttachments.length > 3)
|
|
555
|
+
console.warn(`...and ${missingAttachments.length - 3} more missing attachments.`);
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
report,
|
|
559
|
+
commits: await listLocalCommits(process.cwd(), report.commitId, 100),
|
|
560
|
+
attachmentIdToPath
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
}));
|
|
564
|
+
app.use((err2, req, res, next) => {
|
|
565
|
+
if (err2 instanceof TypedHTTP3.HttpError)
|
|
566
|
+
return res.status(err2.status).send({ error: err2.message });
|
|
567
|
+
logHTTPServer(err2);
|
|
568
|
+
res.status(500).send({ error: "Internal Server Error" });
|
|
569
|
+
});
|
|
570
|
+
const server = http2.createServer(app);
|
|
571
|
+
server.on("error", (err2) => {
|
|
572
|
+
if (err2.code === "ECONNRESET") {
|
|
573
|
+
logHTTPServer("Client connection reset. Ignoring.");
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
throw err2;
|
|
577
|
+
});
|
|
578
|
+
const port = await new Promise((resolve) => server.listen(options.port, () => {
|
|
579
|
+
resolve(server.address().port);
|
|
580
|
+
}));
|
|
581
|
+
return new _LocalReportServer(server, port, authToken);
|
|
582
|
+
}
|
|
583
|
+
authToken() {
|
|
584
|
+
return this._authToken;
|
|
585
|
+
}
|
|
586
|
+
port() {
|
|
587
|
+
return this._port;
|
|
588
|
+
}
|
|
589
|
+
async dispose() {
|
|
590
|
+
await new Promise((x) => this._server.close(x));
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// src/cli/cmd-show-report.ts
|
|
595
|
+
async function cmdShowReport(reportFolder) {
|
|
596
|
+
const reportPath = path4.join(reportFolder, "report.json");
|
|
597
|
+
const session = await FlakinessSession.load();
|
|
598
|
+
const config = await FlakinessConfig.load();
|
|
599
|
+
const projectPublicId = config.projectPublicId();
|
|
600
|
+
const project = projectPublicId && session ? await session.api.project.getProject.GET({ projectPublicId }) : void 0;
|
|
601
|
+
const endpoint = session?.endpoint() ?? "https://flakiness.io";
|
|
602
|
+
const server = await LocalReportServer.create({
|
|
603
|
+
endpoint,
|
|
604
|
+
port: 9373,
|
|
605
|
+
reportPath,
|
|
606
|
+
attachmentsFolder: reportFolder
|
|
607
|
+
});
|
|
608
|
+
const reportEndpoint = project ? `${endpoint}/localreport/${project.org.orgSlug}/${project.projectSlug}?port=${server.port()}&token=${server.authToken()}` : `${endpoint}/localreport?port=${server.port()}&token=${server.authToken()}`;
|
|
609
|
+
console.log(chalk.cyan(`
|
|
610
|
+
Serving Flakiness report at ${reportEndpoint}
|
|
611
|
+
Press Ctrl+C to quit.`));
|
|
612
|
+
await open(reportEndpoint);
|
|
613
|
+
await new Promise(() => {
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// src/createTestStepSnippets.ts
|
|
618
|
+
import { codeFrameColumns } from "@babel/code-frame";
|
|
619
|
+
import fs6 from "fs";
|
|
620
|
+
function createTestStepSnippets(filepathToSteps) {
|
|
621
|
+
for (const [filepath, steps] of filepathToSteps) {
|
|
622
|
+
let source;
|
|
623
|
+
try {
|
|
624
|
+
source = fs6.readFileSync(filepath, "utf-8");
|
|
625
|
+
} catch (e) {
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
const lines = source.split("\n").length;
|
|
629
|
+
const highlighted = codeFrameColumns(source, { start: { line: lines, column: 1 } }, { highlightCode: true, linesAbove: lines, linesBelow: 0 });
|
|
630
|
+
const highlightedLines = highlighted.split("\n");
|
|
631
|
+
const lineWithArrow = highlightedLines[highlightedLines.length - 1];
|
|
632
|
+
for (const step of steps) {
|
|
633
|
+
if (!step.location)
|
|
634
|
+
continue;
|
|
635
|
+
if (step.location.line < 2 || step.location.line >= lines)
|
|
636
|
+
continue;
|
|
637
|
+
const snippetLines = highlightedLines.slice(step.location.line - 2, step.location.line + 1);
|
|
638
|
+
const index = lineWithArrow.indexOf("^");
|
|
639
|
+
const shiftedArrow = lineWithArrow.slice(0, index) + " ".repeat(step.location.column - 1) + lineWithArrow.slice(index);
|
|
640
|
+
snippetLines.splice(2, 0, shiftedArrow);
|
|
641
|
+
step.snippet = snippetLines.join("\n");
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
527
646
|
// src/reportUploader.ts
|
|
647
|
+
import { compressTextAsync, compressTextSync } from "@flakiness/shared/node/compression.js";
|
|
648
|
+
import assert2 from "assert";
|
|
649
|
+
import fs7 from "fs";
|
|
650
|
+
import { URL } from "url";
|
|
528
651
|
var ReportUploader = class _ReportUploader {
|
|
529
652
|
static optionsFromEnv(overrides) {
|
|
530
653
|
const flakinessAccessToken = overrides?.flakinessAccessToken ?? process.env["FLAKINESS_ACCESS_TOKEN"];
|
|
@@ -536,7 +659,8 @@ var ReportUploader = class _ReportUploader {
|
|
|
536
659
|
static async upload(options) {
|
|
537
660
|
const uploaderOptions = _ReportUploader.optionsFromEnv(options);
|
|
538
661
|
if (!uploaderOptions) {
|
|
539
|
-
|
|
662
|
+
if (process.env.CI)
|
|
663
|
+
options.log?.(`[flakiness.io] Uploading skipped since no FLAKINESS_ACCESS_TOKEN is specified`);
|
|
540
664
|
return void 0;
|
|
541
665
|
}
|
|
542
666
|
const uploader = new _ReportUploader(uploaderOptions);
|
|
@@ -569,11 +693,10 @@ var ReportUpload = class {
|
|
|
569
693
|
this._options = options;
|
|
570
694
|
this._report = report;
|
|
571
695
|
this._attachments = attachments;
|
|
572
|
-
this._api = createServerAPI(this._options.flakinessEndpoint, { retries: HTTP_BACKOFF });
|
|
696
|
+
this._api = createServerAPI(this._options.flakinessEndpoint, { retries: HTTP_BACKOFF, auth: this._options.flakinessAccessToken });
|
|
573
697
|
}
|
|
574
698
|
async upload(options) {
|
|
575
699
|
const response = await this._api.run.startUpload.POST({
|
|
576
|
-
flakinessAccessToken: this._options.flakinessAccessToken,
|
|
577
700
|
attachmentIds: this._attachments.map((attachment) => attachment.id)
|
|
578
701
|
}).then((result) => ({ result, error: void 0 })).catch((e) => ({ result: void 0, error: e }));
|
|
579
702
|
if (response?.error || !response.result)
|
|
@@ -584,17 +707,17 @@ var ReportUpload = class {
|
|
|
584
707
|
const uploadURL = response.result.attachment_upload_urls[attachment.id];
|
|
585
708
|
if (!uploadURL)
|
|
586
709
|
throw new Error("Internal error: missing upload URL for attachment!");
|
|
587
|
-
return this._uploadAttachment(attachment, uploadURL);
|
|
710
|
+
return this._uploadAttachment(attachment, uploadURL, options?.syncCompression ?? false);
|
|
588
711
|
})
|
|
589
712
|
]);
|
|
590
713
|
const response2 = await this._api.run.completeUpload.POST({
|
|
591
714
|
upload_token: response.result.upload_token
|
|
592
715
|
}).then((result) => ({ result, error: void 0 })).catch((e) => ({ error: e, result: void 0 }));
|
|
593
|
-
const url = response2?.result?.report_url ? new
|
|
716
|
+
const url = response2?.result?.report_url ? new URL(response2?.result.report_url, this._options.flakinessEndpoint).toString() : void 0;
|
|
594
717
|
return { success: true, reportUrl: url };
|
|
595
718
|
}
|
|
596
719
|
async _uploadReport(data, uploadUrl, syncCompression) {
|
|
597
|
-
const compressed = syncCompression ?
|
|
720
|
+
const compressed = syncCompression ? compressTextSync(data) : await compressTextAsync(data);
|
|
598
721
|
const headers = {
|
|
599
722
|
"Content-Type": "application/json",
|
|
600
723
|
"Content-Length": Buffer.byteLength(compressed) + "",
|
|
@@ -611,11 +734,34 @@ var ReportUpload = class {
|
|
|
611
734
|
await responseDataPromise;
|
|
612
735
|
}, HTTP_BACKOFF);
|
|
613
736
|
}
|
|
614
|
-
async _uploadAttachment(attachment, uploadUrl) {
|
|
615
|
-
const
|
|
737
|
+
async _uploadAttachment(attachment, uploadUrl, syncCompression) {
|
|
738
|
+
const mimeType = attachment.contentType.toLocaleLowerCase().trim();
|
|
739
|
+
const compressable = mimeType.startsWith("text/") || mimeType.endsWith("+json") || mimeType.endsWith("+text") || mimeType.endsWith("+xml");
|
|
740
|
+
if (!compressable && attachment.path) {
|
|
741
|
+
const attachmentPath = attachment.path;
|
|
742
|
+
await retryWithBackoff(async () => {
|
|
743
|
+
const { request, responseDataPromise } = httpUtils.createRequest({
|
|
744
|
+
url: uploadUrl,
|
|
745
|
+
headers: {
|
|
746
|
+
"Content-Type": attachment.contentType,
|
|
747
|
+
"Content-Length": (await fs7.promises.stat(attachmentPath)).size + ""
|
|
748
|
+
},
|
|
749
|
+
method: "put"
|
|
750
|
+
});
|
|
751
|
+
fs7.createReadStream(attachmentPath).pipe(request);
|
|
752
|
+
await responseDataPromise;
|
|
753
|
+
}, HTTP_BACKOFF);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
let buffer = attachment.body ? attachment.body : attachment.path ? await fs7.promises.readFile(attachment.path) : void 0;
|
|
757
|
+
assert2(buffer);
|
|
758
|
+
const encoding = compressable ? "br" : void 0;
|
|
759
|
+
if (compressable)
|
|
760
|
+
buffer = syncCompression ? compressTextSync(buffer) : await compressTextAsync(buffer);
|
|
616
761
|
const headers = {
|
|
617
762
|
"Content-Type": attachment.contentType,
|
|
618
|
-
"Content-Length":
|
|
763
|
+
"Content-Length": Buffer.byteLength(buffer) + "",
|
|
764
|
+
"Content-Encoding": encoding
|
|
619
765
|
};
|
|
620
766
|
await retryWithBackoff(async () => {
|
|
621
767
|
const { request, responseDataPromise } = httpUtils.createRequest({
|
|
@@ -623,13 +769,8 @@ var ReportUpload = class {
|
|
|
623
769
|
headers,
|
|
624
770
|
method: "put"
|
|
625
771
|
});
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
} else {
|
|
629
|
-
if (attachment.body)
|
|
630
|
-
request.write(attachment.body);
|
|
631
|
-
request.end();
|
|
632
|
-
}
|
|
772
|
+
request.write(buffer);
|
|
773
|
+
request.end();
|
|
633
774
|
await responseDataPromise;
|
|
634
775
|
}, HTTP_BACKOFF);
|
|
635
776
|
}
|
|
@@ -637,7 +778,7 @@ var ReportUpload = class {
|
|
|
637
778
|
|
|
638
779
|
// src/systemUtilizationSampler.ts
|
|
639
780
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
640
|
-
import
|
|
781
|
+
import os3 from "os";
|
|
641
782
|
function getAvailableMemMacOS() {
|
|
642
783
|
const lines = spawnSync2("vm_stat", { encoding: "utf8" }).stdout.trim().split("\n");
|
|
643
784
|
const pageSize = parseInt(lines[0].match(/page size of (\d+) bytes/)[1], 10);
|
|
@@ -658,7 +799,7 @@ function getAvailableMemMacOS() {
|
|
|
658
799
|
function getSystemUtilization() {
|
|
659
800
|
let idleTicks = 0;
|
|
660
801
|
let totalTicks = 0;
|
|
661
|
-
for (const cpu of
|
|
802
|
+
for (const cpu of os3.cpus()) {
|
|
662
803
|
totalTicks += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.irq + cpu.times.idle;
|
|
663
804
|
idleTicks += cpu.times.idle;
|
|
664
805
|
}
|
|
@@ -666,14 +807,14 @@ function getSystemUtilization() {
|
|
|
666
807
|
idleTicks,
|
|
667
808
|
totalTicks,
|
|
668
809
|
timestamp: Date.now(),
|
|
669
|
-
freeBytes:
|
|
810
|
+
freeBytes: os3.platform() === "darwin" ? getAvailableMemMacOS() : os3.freemem()
|
|
670
811
|
};
|
|
671
812
|
}
|
|
672
813
|
function toFKUtilization(sample, previous) {
|
|
673
814
|
const idleTicks = sample.idleTicks - previous.idleTicks;
|
|
674
815
|
const totalTicks = sample.totalTicks - previous.totalTicks;
|
|
675
816
|
const cpuUtilization = Math.floor((1 - idleTicks / totalTicks) * 1e4) / 100;
|
|
676
|
-
const memoryUtilization = Math.floor((1 - sample.freeBytes /
|
|
817
|
+
const memoryUtilization = Math.floor((1 - sample.freeBytes / os3.totalmem()) * 1e4) / 100;
|
|
677
818
|
return {
|
|
678
819
|
cpuUtilization,
|
|
679
820
|
memoryUtilization,
|
|
@@ -688,7 +829,7 @@ var SystemUtilizationSampler = class {
|
|
|
688
829
|
this.result = {
|
|
689
830
|
samples: [],
|
|
690
831
|
startTimestamp: this._lastSample.timestamp,
|
|
691
|
-
totalMemoryBytes:
|
|
832
|
+
totalMemoryBytes: os3.totalmem()
|
|
692
833
|
};
|
|
693
834
|
this._timer = setTimeout(this._addSample.bind(this), 50);
|
|
694
835
|
}
|
|
@@ -704,9 +845,12 @@ var SystemUtilizationSampler = class {
|
|
|
704
845
|
};
|
|
705
846
|
|
|
706
847
|
// src/playwright-test.ts
|
|
848
|
+
var warn = (txt) => console.warn(chalk2.yellow(`[flakiness.io] ${txt}`));
|
|
849
|
+
var err = (txt) => console.error(chalk2.red(`[flakiness.io] ${txt}`));
|
|
707
850
|
var FlakinessReporter = class {
|
|
708
851
|
constructor(_options = {}) {
|
|
709
852
|
this._options = _options;
|
|
853
|
+
this._outputFolder = path5.join(process.cwd(), this._options.outputFolder ?? process.env.FLAKINESS_OUTPUT_DIR ?? "flakiness-report");
|
|
710
854
|
}
|
|
711
855
|
_config;
|
|
712
856
|
_rootSuite;
|
|
@@ -714,6 +858,10 @@ var FlakinessReporter = class {
|
|
|
714
858
|
_unattributedErrors = [];
|
|
715
859
|
_filepathToSteps = new Multimap();
|
|
716
860
|
_systemUtilizationSampler = new SystemUtilizationSampler();
|
|
861
|
+
_report;
|
|
862
|
+
_attachments = [];
|
|
863
|
+
_outputFolder;
|
|
864
|
+
_result;
|
|
717
865
|
printsToStdio() {
|
|
718
866
|
return false;
|
|
719
867
|
}
|
|
@@ -805,7 +953,7 @@ var FlakinessReporter = class {
|
|
|
805
953
|
location: pwStep.location ? this._createLocation(context, pwStep.location) : void 0
|
|
806
954
|
};
|
|
807
955
|
if (pwStep.location) {
|
|
808
|
-
const resolvedPath =
|
|
956
|
+
const resolvedPath = path5.resolve(pwStep.location.file);
|
|
809
957
|
this._filepathToSteps.set(resolvedPath, step);
|
|
810
958
|
}
|
|
811
959
|
if (pwStep.error)
|
|
@@ -834,19 +982,12 @@ var FlakinessReporter = class {
|
|
|
834
982
|
this._systemUtilizationSampler.dispose();
|
|
835
983
|
if (!this._config || !this._rootSuite)
|
|
836
984
|
throw new Error("ERROR: failed to resolve config");
|
|
837
|
-
const uploadOptions = ReportUploader.optionsFromEnv({
|
|
838
|
-
flakinessAccessToken: this._options.token,
|
|
839
|
-
flakinessEndpoint: this._options.endpoint
|
|
840
|
-
});
|
|
841
|
-
if (!uploadOptions) {
|
|
842
|
-
console.log(`[flakiness.io] Uploading skipped since no FLAKINESS_ACCESS_TOKEN is specified`);
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
985
|
let commitId;
|
|
846
986
|
try {
|
|
847
987
|
commitId = gitCommitInfo(this._config.rootDir);
|
|
848
988
|
} catch (e) {
|
|
849
|
-
|
|
989
|
+
warn(`Failed to fetch commit info - is this a git repo?`);
|
|
990
|
+
err(`Report is NOT generated.`);
|
|
850
991
|
return;
|
|
851
992
|
}
|
|
852
993
|
const gitRoot = normalizePath(computeGitRoot(this._config.rootDir));
|
|
@@ -861,10 +1002,10 @@ var FlakinessReporter = class {
|
|
|
861
1002
|
const environmentsMap = createEnvironments(this._config.projects);
|
|
862
1003
|
if (this._options.collectBrowserVersions) {
|
|
863
1004
|
try {
|
|
864
|
-
let playwrightPath =
|
|
865
|
-
while (
|
|
866
|
-
playwrightPath =
|
|
867
|
-
const module = await import(
|
|
1005
|
+
let playwrightPath = fs8.realpathSync(process.argv[1]);
|
|
1006
|
+
while (path5.basename(playwrightPath) !== "test")
|
|
1007
|
+
playwrightPath = path5.dirname(playwrightPath);
|
|
1008
|
+
const module = await import(path5.join(playwrightPath, "index.js"));
|
|
868
1009
|
for (const [project, env] of environmentsMap) {
|
|
869
1010
|
const { browserName = "chromium", channel, headless } = project.use;
|
|
870
1011
|
let browserType;
|
|
@@ -888,7 +1029,7 @@ var FlakinessReporter = class {
|
|
|
888
1029
|
env.userSuppliedData["browser"] = (channel ?? browserName).toLowerCase().trim() + " " + version;
|
|
889
1030
|
}
|
|
890
1031
|
} catch (e) {
|
|
891
|
-
|
|
1032
|
+
err(`Failed to resolve browser version: ${e}`);
|
|
892
1033
|
}
|
|
893
1034
|
}
|
|
894
1035
|
const environments = [...environmentsMap.values()];
|
|
@@ -911,15 +1052,52 @@ var FlakinessReporter = class {
|
|
|
911
1052
|
});
|
|
912
1053
|
createTestStepSnippets(this._filepathToSteps);
|
|
913
1054
|
for (const unaccessibleAttachment of context.unaccessibleAttachmentPaths)
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1055
|
+
warn(`cannot access attachment ${unaccessibleAttachment}`);
|
|
1056
|
+
this._report = report;
|
|
1057
|
+
const reportPath = path5.join(this._outputFolder, "report.json");
|
|
1058
|
+
const attachmentsFolder = path5.join(this._outputFolder, "attachments");
|
|
1059
|
+
await fs8.promises.rm(this._outputFolder, { recursive: true, force: true });
|
|
1060
|
+
await fs8.promises.mkdir(this._outputFolder, { recursive: true });
|
|
1061
|
+
await fs8.promises.writeFile(reportPath, JSON.stringify(report), "utf-8");
|
|
1062
|
+
if (context.attachments.size)
|
|
1063
|
+
await fs8.promises.mkdir(attachmentsFolder);
|
|
1064
|
+
for (const attachment of context.attachments.values()) {
|
|
1065
|
+
const attachmentPath = path5.join(attachmentsFolder, attachment.id);
|
|
1066
|
+
if (attachment.path)
|
|
1067
|
+
await fs8.promises.cp(attachment.path, attachmentPath);
|
|
1068
|
+
else if (attachment.body)
|
|
1069
|
+
await fs8.promises.writeFile(attachmentPath, attachment.body);
|
|
1070
|
+
this._attachments.push({
|
|
1071
|
+
contentType: attachment.contentType,
|
|
1072
|
+
id: attachment.id,
|
|
1073
|
+
path: attachmentPath
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
this._result = result;
|
|
1077
|
+
}
|
|
1078
|
+
async onExit() {
|
|
1079
|
+
if (!this._report)
|
|
1080
|
+
return;
|
|
1081
|
+
await ReportUploader.upload({
|
|
1082
|
+
report: this._report,
|
|
1083
|
+
attachments: this._attachments,
|
|
1084
|
+
flakinessAccessToken: this._options.token,
|
|
1085
|
+
flakinessEndpoint: this._options.endpoint,
|
|
919
1086
|
log: console.log
|
|
920
1087
|
});
|
|
921
|
-
|
|
922
|
-
|
|
1088
|
+
const openMode = this._options.open ?? "on-failure";
|
|
1089
|
+
const shouldOpen = openMode === "always" || openMode === "on-failure" && this._result?.status === "failed";
|
|
1090
|
+
if (shouldOpen) {
|
|
1091
|
+
await cmdShowReport(this._outputFolder);
|
|
1092
|
+
} else {
|
|
1093
|
+
const defaultOutputFolder = path5.join(process.cwd(), "flakiness-report");
|
|
1094
|
+
const folder = defaultOutputFolder === this._outputFolder ? "" : path5.relative(process.cwd(), this._outputFolder);
|
|
1095
|
+
console.log(`
|
|
1096
|
+
To open last Flakiness report run:
|
|
1097
|
+
|
|
1098
|
+
${chalk2.cyan(`npx flakiness show-report ${folder}`)}
|
|
1099
|
+
`);
|
|
1100
|
+
}
|
|
923
1101
|
}
|
|
924
1102
|
};
|
|
925
1103
|
function toSTDIOEntry(data) {
|