@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.
@@ -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 fs4 from "fs";
5
- import path from "path";
4
+ import chalk2 from "chalk";
5
+ import fs8 from "fs";
6
+ import path5 from "path";
6
7
 
7
- // src/createTestStepSnippets.ts
8
- import { codeFrameColumns } from "@babel/code-frame";
9
- import fs from "fs";
10
- function createTestStepSnippets(filepathToSteps) {
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/reportUploader.ts
37
- import fs3 from "fs";
38
- import { URL as URL2 } from "url";
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 fs2 from "fs";
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 fs2.promises.stat(aPath).then(() => true).catch((e) => false);
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 = fs2.createReadStream(filePath);
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", (err) => {
299
- reject(err);
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].message);
66
+ console.error(`[flakiness.io err]`, errorText(e.errors[0]));
315
67
  else if (e instanceof Error)
316
- console.error(`[flakiness.io err]`, e.message);
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.log(e);
150
+ console.error(e);
400
151
  return void 0;
401
152
  }
402
153
  }
403
154
  function readLinuxOSRelease() {
404
- const osReleaseText = fs2.readFileSync("/etc/os-release", "utf-8");
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
- options.log?.(`[flakiness.io] Uploading skipped since no FLAKINESS_ACCESS_TOKEN is specified`);
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 URL2(response2?.result.report_url, this._options.flakinessEndpoint).toString() : void 0;
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 ? brotliCompressSync2(data) : await brotliCompressAsync(data);
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 bytesLength = attachment.path ? (await fs3.promises.stat(attachment.path)).size : attachment.body ? Buffer.byteLength(attachment.body) : 0;
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": bytesLength + ""
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
- if (attachment.path) {
627
- fs3.createReadStream(attachment.path).pipe(request);
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 os2 from "os";
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 os2.cpus()) {
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: os2.platform() === "darwin" ? getAvailableMemMacOS() : os2.freemem()
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 / os2.totalmem()) * 1e4) / 100;
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: os2.totalmem()
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 = path.resolve(pwStep.location.file);
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
- console.log(`[flakiness.io] Uploading skipped since failed to get commit info - is this a git repo?`);
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 = fs4.realpathSync(process.argv[1]);
865
- while (path.basename(playwrightPath) !== "test")
866
- playwrightPath = path.dirname(playwrightPath);
867
- const module = await import(path.join(playwrightPath, "index.js"));
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
- console.error("[flakiness.io] failed to resolve browser version", e);
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
- console.warn(`[flakiness.io] WARN: cannot access attachment ${unaccessibleAttachment}`);
915
- const uploadError = await ReportUploader.upload({
916
- report,
917
- attachments: [...context.attachments.values()],
918
- ...uploadOptions,
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
- if (uploadError)
922
- return { status: "failed" };
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) {