@flakiness/sdk 0.147.0 → 0.149.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/showReport.js CHANGED
@@ -1,32 +1,97 @@
1
1
  // src/showReport.ts
2
2
  import chalk from "chalk";
3
3
  import open from "open";
4
- import path3 from "path";
5
4
 
6
- // src/flakinessProjectConfig.ts
7
- import fs from "fs";
8
- import path from "path";
9
-
10
- // src/git.ts
11
- import assert from "assert";
12
-
13
- // src/pathutils.ts
14
- import { posix as posixPath, win32 as win32Path } from "path";
15
- var IS_WIN32_PATH = new RegExp("^[a-zA-Z]:\\\\", "i");
16
- var IS_ALMOST_POSIX_PATH = new RegExp("^[a-zA-Z]:/", "i");
17
- function normalizePath(aPath) {
18
- if (IS_WIN32_PATH.test(aPath)) {
19
- aPath = aPath.split(win32Path.sep).join(posixPath.sep);
20
- }
21
- if (IS_ALMOST_POSIX_PATH.test(aPath))
22
- return "/" + aPath[0] + aPath.substring(2);
23
- return aPath;
24
- }
25
-
26
- // src/utils.ts
5
+ // src/_internalUtils.ts
27
6
  import { spawnSync } from "child_process";
7
+ import crypto from "crypto";
8
+ import http from "http";
9
+ import https from "https";
10
+ import util from "util";
11
+ import zlib from "zlib";
12
+ var asyncBrotliCompress = util.promisify(zlib.brotliCompress);
28
13
  var FLAKINESS_DBG = !!process.env.FLAKINESS_DBG;
29
- var ansiRegex = new RegExp("[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))", "g");
14
+ function errorText(error) {
15
+ return FLAKINESS_DBG ? error.stack : error.message;
16
+ }
17
+ async function retryWithBackoff(job, backoff = []) {
18
+ for (const timeout of backoff) {
19
+ try {
20
+ return await job();
21
+ } catch (e) {
22
+ if (e instanceof AggregateError)
23
+ console.error(`[flakiness.io err]`, errorText(e.errors[0]));
24
+ else if (e instanceof Error)
25
+ console.error(`[flakiness.io err]`, errorText(e));
26
+ else
27
+ console.error(`[flakiness.io err]`, e);
28
+ await new Promise((x) => setTimeout(x, timeout));
29
+ }
30
+ }
31
+ return await job();
32
+ }
33
+ var httpUtils;
34
+ ((httpUtils2) => {
35
+ function createRequest({ url, method = "get", headers = {} }) {
36
+ let resolve2;
37
+ let reject;
38
+ const responseDataPromise = new Promise((a, b) => {
39
+ resolve2 = a;
40
+ reject = b;
41
+ });
42
+ const protocol = url.startsWith("https") ? https : http;
43
+ headers = Object.fromEntries(Object.entries(headers).filter(([key, value]) => value !== void 0));
44
+ const request = protocol.request(url, { method, headers }, (res) => {
45
+ const chunks = [];
46
+ res.on("data", (chunk) => chunks.push(chunk));
47
+ res.on("end", () => {
48
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300)
49
+ resolve2(Buffer.concat(chunks));
50
+ else
51
+ reject(new Error(`Request to ${url} failed with ${res.statusCode}`));
52
+ });
53
+ res.on("error", (error) => reject(error));
54
+ });
55
+ request.on("error", reject);
56
+ return { request, responseDataPromise };
57
+ }
58
+ httpUtils2.createRequest = createRequest;
59
+ async function getBuffer(url, backoff) {
60
+ return await retryWithBackoff(async () => {
61
+ const { request, responseDataPromise } = createRequest({ url });
62
+ request.end();
63
+ return await responseDataPromise;
64
+ }, backoff);
65
+ }
66
+ httpUtils2.getBuffer = getBuffer;
67
+ async function getText(url, backoff) {
68
+ const buffer = await getBuffer(url, backoff);
69
+ return buffer.toString("utf-8");
70
+ }
71
+ httpUtils2.getText = getText;
72
+ async function getJSON(url) {
73
+ return JSON.parse(await getText(url));
74
+ }
75
+ httpUtils2.getJSON = getJSON;
76
+ async function postText(url, text, backoff) {
77
+ const headers = {
78
+ "Content-Type": "application/json",
79
+ "Content-Length": Buffer.byteLength(text) + ""
80
+ };
81
+ return await retryWithBackoff(async () => {
82
+ const { request, responseDataPromise } = createRequest({ url, headers, method: "post" });
83
+ request.write(text);
84
+ request.end();
85
+ return await responseDataPromise;
86
+ }, backoff);
87
+ }
88
+ httpUtils2.postText = postText;
89
+ async function postJSON(url, json, backoff) {
90
+ const buffer = await postText(url, JSON.stringify(json), backoff);
91
+ return JSON.parse(buffer.toString("utf-8"));
92
+ }
93
+ httpUtils2.postJSON = postJSON;
94
+ })(httpUtils || (httpUtils = {}));
30
95
  function shell(command, args, options) {
31
96
  try {
32
97
  const result = spawnSync(command, args, { encoding: "utf-8", ...options });
@@ -39,15 +104,227 @@ function shell(command, args, options) {
39
104
  return void 0;
40
105
  }
41
106
  }
107
+ function randomUUIDBase62() {
108
+ const BASE62_CHARSET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
109
+ let num = BigInt("0x" + crypto.randomUUID().replace(/-/g, ""));
110
+ if (num === 0n)
111
+ return BASE62_CHARSET[0];
112
+ const chars = [];
113
+ while (num > 0n) {
114
+ const remainder = Number(num % 62n);
115
+ num /= 62n;
116
+ chars.push(BASE62_CHARSET[remainder]);
117
+ }
118
+ return chars.reverse().join("");
119
+ }
42
120
 
43
- // src/git.ts
44
- function computeGitRoot(somePathInsideGitRepo) {
45
- const root = shell(`git`, ["rev-parse", "--show-toplevel"], {
46
- cwd: somePathInsideGitRepo,
47
- encoding: "utf-8"
48
- });
49
- assert(root, `FAILED: git rev-parse --show-toplevel HEAD @ ${somePathInsideGitRepo}`);
50
- return normalizePath(root);
121
+ // src/flakinessProjectConfig.ts
122
+ import fs from "fs";
123
+ import path from "path";
124
+
125
+ // src/gitWorktree.ts
126
+ import assert from "assert";
127
+ import { exec } from "child_process";
128
+ import debug from "debug";
129
+ import { posix as posixPath, win32 as win32Path } from "path";
130
+ import { promisify } from "util";
131
+ var log = debug("fk:git");
132
+ var execAsync = promisify(exec);
133
+ var IS_WIN32_PATH = new RegExp("^[a-zA-Z]:\\\\", "i");
134
+ var IS_ALMOST_POSIX_PATH = new RegExp("^[a-zA-Z]:/", "i");
135
+ function toPosixAbsolutePath(absolutePath) {
136
+ if (IS_WIN32_PATH.test(absolutePath)) {
137
+ absolutePath = absolutePath.split(win32Path.sep).join(posixPath.sep);
138
+ }
139
+ if (IS_ALMOST_POSIX_PATH.test(absolutePath))
140
+ return "/" + absolutePath[0] + absolutePath.substring(2);
141
+ return absolutePath;
142
+ }
143
+ function toNativeAbsolutePath(posix) {
144
+ if (process.platform !== "win32")
145
+ return posix;
146
+ assert(posix.startsWith("/"), "The path must be absolute");
147
+ const m = posix.match(/^\/([a-zA-Z])(\/.*)?$/);
148
+ assert(m, `Invalid POSIX path: ${posix}`);
149
+ const drive = m[1];
150
+ const rest = (m[2] ?? "").split(posixPath.sep).join(win32Path.sep);
151
+ return drive.toUpperCase() + ":" + rest;
152
+ }
153
+ var GitWorktree = class _GitWorktree {
154
+ constructor(_gitRoot) {
155
+ this._gitRoot = _gitRoot;
156
+ this._posixGitRoot = toPosixAbsolutePath(this._gitRoot);
157
+ }
158
+ /**
159
+ * Creates a GitWorktree instance from any path inside a git repository.
160
+ *
161
+ * @param {string} somePathInsideGitRepo - Any path (file or directory) within a git repository.
162
+ * Can be absolute or relative. The function will locate the git root directory.
163
+ *
164
+ * @returns {GitWorktree} A new GitWorktree instance bound to the discovered git root.
165
+ *
166
+ * @throws {Error} Throws if the path is not inside a git repository or if git commands fail.
167
+ *
168
+ * @example
169
+ * ```typescript
170
+ * const worktree = GitWorktree.create('./src/my-test.ts');
171
+ * const gitRoot = worktree.rootPath();
172
+ * ```
173
+ */
174
+ static create(somePathInsideGitRepo) {
175
+ const root = shell(`git`, ["rev-parse", "--show-toplevel"], {
176
+ cwd: somePathInsideGitRepo,
177
+ encoding: "utf-8"
178
+ });
179
+ assert(root, `FAILED: git rev-parse --show-toplevel HEAD @ ${somePathInsideGitRepo}`);
180
+ return new _GitWorktree(root);
181
+ }
182
+ _posixGitRoot;
183
+ /**
184
+ * Returns the native absolute path of the git repository root directory.
185
+ *
186
+ * @returns {string} Native absolute path to the git root. Format matches the current platform
187
+ * (Windows or POSIX).
188
+ *
189
+ * @example
190
+ * ```typescript
191
+ * const root = worktree.rootPath();
192
+ * // On Windows: 'D:\project'
193
+ * // On Unix: '/project'
194
+ * ```
195
+ */
196
+ rootPath() {
197
+ return this._gitRoot;
198
+ }
199
+ /**
200
+ * Returns the commit ID (SHA-1 hash) of the current HEAD commit.
201
+ *
202
+ * @returns {FlakinessReport.CommitId} Full 40-character commit hash of the HEAD commit.
203
+ *
204
+ * @throws {Error} Throws if git command fails or repository is in an invalid state.
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * const commitId = worktree.headCommitId();
209
+ * // Returns: 'a1b2c3d4e5f6...' (40-character SHA-1)
210
+ * ```
211
+ */
212
+ headCommitId() {
213
+ const sha = shell(`git`, ["rev-parse", "HEAD"], {
214
+ cwd: this._gitRoot,
215
+ encoding: "utf-8"
216
+ });
217
+ assert(sha, `FAILED: git rev-parse HEAD @ ${this._gitRoot}`);
218
+ return sha.trim();
219
+ }
220
+ /**
221
+ * Converts a native absolute path to a git-relative POSIX path.
222
+ *
223
+ * Takes any absolute path (Windows or POSIX format) and converts it to a POSIX path
224
+ * relative to the git repository root. This is essential for Flakiness reports where
225
+ * all file paths must be git-relative and use POSIX separators.
226
+ *
227
+ * @param {string} absolutePath - Native absolute path to convert. Can be in Windows format
228
+ * (e.g., `D:\project\src\test.ts`) or POSIX format (e.g., `/project/src/test.ts`).
229
+ *
230
+ * @returns {FlakinessReport.GitFilePath} POSIX path relative to git root (e.g., `src/test.ts`).
231
+ * Returns an empty string if the path is the git root itself.
232
+ *
233
+ * @example
234
+ * ```typescript
235
+ * const gitPath = worktree.gitPath('/Users/project/src/test.ts');
236
+ * // Returns: 'src/test.ts'
237
+ * ```
238
+ */
239
+ gitPath(absolutePath) {
240
+ return posixPath.relative(this._posixGitRoot, toPosixAbsolutePath(absolutePath));
241
+ }
242
+ /**
243
+ * Converts a git-relative POSIX path to a native absolute path.
244
+ *
245
+ * Takes a POSIX path relative to the git root and converts it to the native absolute path
246
+ * format for the current platform (Windows or POSIX). This is the inverse of `gitPath()`.
247
+ *
248
+ * @param {FlakinessReport.GitFilePath} relativePath - POSIX path relative to git root
249
+ * (e.g., `src/test.ts`).
250
+ *
251
+ * @returns {string} Native absolute path. On Windows, returns Windows format (e.g., `D:\project\src\test.ts`).
252
+ * On POSIX systems, returns POSIX format (e.g., `/project/src/test.ts`).
253
+ *
254
+ * @example
255
+ * ```typescript
256
+ * const absolutePath = worktree.absolutePath('src/test.ts');
257
+ * // On Windows: 'D:\project\src\test.ts'
258
+ * // On Unix: '/project/src/test.ts'
259
+ * ```
260
+ */
261
+ absolutePath(relativePath) {
262
+ return toNativeAbsolutePath(posixPath.join(this._posixGitRoot, relativePath));
263
+ }
264
+ /**
265
+ * Lists recent commits from the repository.
266
+ *
267
+ * Retrieves commit information including commit ID, timestamp, author, message, and parent commits.
268
+ * Note: CI environments often have shallow checkouts with limited history, which may affect
269
+ * the number of commits returned.
270
+ *
271
+ * @param {number} count - Maximum number of commits to retrieve, starting from HEAD.
272
+ *
273
+ * @returns {Promise<GitCommit[]>} Promise that resolves to an array of commit objects, ordered
274
+ * from most recent to oldest. Each commit includes:
275
+ * - `commitId` - Full commit hash
276
+ * - `timestamp` - Commit timestamp in milliseconds since Unix epoch
277
+ * - `message` - Commit message (subject line)
278
+ * - `author` - Author name
279
+ * - `parents` - Array of parent commit IDs
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * const commits = await worktree.listCommits(10);
284
+ * console.log(`Latest commit: ${commits[0].message}`);
285
+ * ```
286
+ */
287
+ async listCommits(count) {
288
+ return await listCommits(this._gitRoot, "HEAD", count);
289
+ }
290
+ };
291
+ async function listCommits(gitRoot, head, count) {
292
+ const FIELD_SEPARATOR = "|~|";
293
+ const RECORD_SEPARATOR = "\0";
294
+ const prettyFormat = [
295
+ "%H",
296
+ // Full commit hash
297
+ "%ct",
298
+ // Commit timestamp (Unix seconds)
299
+ "%an",
300
+ // Author name
301
+ "%s",
302
+ // Subject line
303
+ "%P"
304
+ // Parent hashes (space-separated)
305
+ ].join(FIELD_SEPARATOR);
306
+ const command = `git log ${head} -n ${count} --pretty=format:"${prettyFormat}" -z`;
307
+ try {
308
+ const { stdout } = await execAsync(command, { cwd: gitRoot });
309
+ if (!stdout) {
310
+ return [];
311
+ }
312
+ return stdout.trim().split(RECORD_SEPARATOR).filter((record) => record).map((record) => {
313
+ const [commitId, timestampStr, author, message, parentsStr] = record.split(FIELD_SEPARATOR);
314
+ const parents = parentsStr ? parentsStr.split(" ").filter((p) => p) : [];
315
+ return {
316
+ commitId,
317
+ timestamp: parseInt(timestampStr, 10) * 1e3,
318
+ author,
319
+ message,
320
+ parents,
321
+ walkIndex: 0
322
+ };
323
+ });
324
+ } catch (error) {
325
+ log(`Failed to list commits for repository at ${gitRoot}:`, error);
326
+ return [];
327
+ }
51
328
  }
52
329
 
53
330
  // src/flakinessProjectConfig.ts
@@ -67,8 +344,8 @@ function computeConfigPath() {
67
344
  return configPath;
68
345
  }
69
346
  try {
70
- const gitRoot = computeGitRoot(process.cwd());
71
- return createConfigPath(gitRoot);
347
+ const worktree = GitWorktree.create(process.cwd());
348
+ return createConfigPath(worktree.rootPath());
72
349
  } catch (e) {
73
350
  return createConfigPath(process.cwd());
74
351
  }
@@ -78,396 +355,273 @@ var FlakinessProjectConfig = class _FlakinessProjectConfig {
78
355
  this._configPath = _configPath;
79
356
  this._config = _config;
80
357
  }
358
+ /**
359
+ * Loads the Flakiness project configuration from disk.
360
+ *
361
+ * Searches for an existing `.flakiness/config.json` file starting from the current working
362
+ * directory and walking up the directory tree. If no config exists, it determines the
363
+ * appropriate location (git root or current directory) for future saves.
364
+ *
365
+ * @returns {Promise<FlakinessProjectConfig>} Promise that resolves to a FlakinessProjectConfig
366
+ * instance. If no config file exists, returns an instance with default/empty values.
367
+ *
368
+ * @example
369
+ * ```typescript
370
+ * const config = await FlakinessProjectConfig.load();
371
+ * const projectId = config.projectPublicId();
372
+ * ```
373
+ */
81
374
  static async load() {
82
375
  const configPath = ensureConfigPath();
83
376
  const data = await fs.promises.readFile(configPath, "utf-8").catch((e) => void 0);
84
377
  const json = data ? JSON.parse(data) : {};
85
378
  return new _FlakinessProjectConfig(configPath, json);
86
379
  }
380
+ /**
381
+ * Creates a new empty Flakiness project configuration.
382
+ *
383
+ * Creates a configuration instance with no values set. Use this when you want to build
384
+ * a configuration from scratch. Call `save()` to persist it to disk.
385
+ *
386
+ * @returns {FlakinessProjectConfig} A new empty configuration instance.
387
+ *
388
+ * @example
389
+ * ```typescript
390
+ * const config = FlakinessProjectConfig.createEmpty();
391
+ * config.setProjectPublicId('my-project-id');
392
+ * await config.save();
393
+ * ```
394
+ */
87
395
  static createEmpty() {
88
396
  return new _FlakinessProjectConfig(ensureConfigPath(), {});
89
397
  }
398
+ /**
399
+ * Returns the absolute path to the configuration file.
400
+ *
401
+ * @returns {string} Absolute path to `.flakiness/config.json`.
402
+ */
90
403
  path() {
91
404
  return this._configPath;
92
405
  }
406
+ /**
407
+ * Returns the project's public ID, if configured.
408
+ *
409
+ * The project public ID is used to associate reports with a specific Flakiness.io project.
410
+ *
411
+ * @returns {string | undefined} Project public ID, or `undefined` if not set.
412
+ */
93
413
  projectPublicId() {
94
414
  return this._config.projectPublicId;
95
415
  }
96
- reportViewerEndpoint() {
97
- return this._config.customReportViewerEndpoint ?? "https://report.flakiness.io";
416
+ /**
417
+ * Returns the report viewer URL, either custom or default.
418
+ *
419
+ * @returns {string} Custom report viewer URL if configured, otherwise the default
420
+ * `https://report.flakiness.io`.
421
+ */
422
+ reportViewerUrl() {
423
+ return this._config.customReportViewerUrl ?? "https://report.flakiness.io";
424
+ }
425
+ /**
426
+ * Sets or clears the custom report viewer URL.
427
+ *
428
+ * @param {string | undefined} url - Custom report viewer URL to use, or `undefined` to
429
+ * clear and use the default URL.
430
+ */
431
+ setCustomReportViewerUrl(url) {
432
+ if (url)
433
+ this._config.customReportViewerUrl = url;
434
+ else
435
+ delete this._config.customReportViewerUrl;
98
436
  }
437
+ /**
438
+ * Sets the project's public ID.
439
+ *
440
+ * @param {string | undefined} projectId - Project public ID to set, or `undefined` to clear.
441
+ */
99
442
  setProjectPublicId(projectId) {
100
443
  this._config.projectPublicId = projectId;
101
444
  }
445
+ /**
446
+ * Saves the configuration to disk.
447
+ *
448
+ * Writes the current configuration values to `.flakiness/config.json`. Creates the
449
+ * `.flakiness` directory if it doesn't exist.
450
+ *
451
+ * @returns {Promise<void>} Promise that resolves when the file has been written.
452
+ *
453
+ * @throws {Error} Throws if unable to create directories or write the file.
454
+ *
455
+ * @example
456
+ * ```typescript
457
+ * const config = await FlakinessProjectConfig.load();
458
+ * config.setProjectPublicId('my-project');
459
+ * await config.save();
460
+ * ```
461
+ */
102
462
  async save() {
103
463
  await fs.promises.mkdir(path.dirname(this._configPath), { recursive: true });
104
464
  await fs.promises.writeFile(this._configPath, JSON.stringify(this._config, null, 2));
105
465
  }
106
466
  };
107
467
 
108
- // src/localReportServer.ts
109
- import { TypedHTTP as TypedHTTP2 } from "@flakiness/shared/common/typedHttp.js";
110
- import { randomUUIDBase62 } from "@flakiness/shared/node/nodeutils.js";
111
- import { createTypedHttpExpressMiddleware } from "@flakiness/shared/node/typedHttpExpress.js";
112
- import bodyParser from "body-parser";
113
- import compression from "compression";
468
+ // src/staticServer.ts
114
469
  import debug2 from "debug";
115
- import express from "express";
116
- import "express-async-errors";
117
- import http from "http";
118
-
119
- // src/localReportApi.ts
120
- import { TypedHTTP } from "@flakiness/shared/common/typedHttp.js";
121
- import fs2 from "fs";
122
- import path2 from "path";
123
- import { z } from "zod/v4";
124
-
125
- // src/localGit.ts
126
- import { exec } from "child_process";
127
- import debug from "debug";
128
- import { promisify } from "util";
129
- var log = debug("fk:git");
130
- var execAsync = promisify(exec);
131
- async function listLocalCommits(gitRoot, head, count) {
132
- const FIELD_SEPARATOR = "|~|";
133
- const RECORD_SEPARATOR = "\0";
134
- const prettyFormat = [
135
- "%H",
136
- // %H: Full commit hash
137
- "%at",
138
- // %at: Author date as a Unix timestamp (seconds since epoch)
139
- "%an",
140
- // %an: Author name
141
- "%s",
142
- // %s: Subject (the first line of the commit message)
143
- "%P"
144
- // %P: Parent hashes (space-separated)
145
- ].join(FIELD_SEPARATOR);
146
- const command = `git log ${head} -n ${count} --pretty=format:"${prettyFormat}" -z`;
147
- try {
148
- const { stdout } = await execAsync(command, { cwd: gitRoot });
149
- if (!stdout) {
150
- return [];
151
- }
152
- return stdout.trim().split(RECORD_SEPARATOR).filter((record) => record).map((record) => {
153
- const [commitId, timestampStr, author, message, parentsStr] = record.split(FIELD_SEPARATOR);
154
- const parents = parentsStr ? parentsStr.split(" ").filter((p) => p) : [];
155
- return {
156
- commitId,
157
- timestamp: parseInt(timestampStr, 10) * 1e3,
158
- author,
159
- message,
160
- parents,
161
- walkIndex: 0
162
- };
163
- });
164
- } catch (error) {
165
- log(`Failed to list commits for repository at ${gitRoot}:`, error);
166
- return [];
470
+ import * as fs2 from "fs";
471
+ import * as http2 from "http";
472
+ import * as path2 from "path";
473
+ var log2 = debug2("fk:static_server");
474
+ var StaticServer = class {
475
+ _server;
476
+ _absoluteFolderPath;
477
+ _pathPrefix;
478
+ _cors;
479
+ _mimeTypes = {
480
+ ".html": "text/html",
481
+ ".js": "text/javascript",
482
+ ".css": "text/css",
483
+ ".json": "application/json",
484
+ ".png": "image/png",
485
+ ".jpg": "image/jpeg",
486
+ ".gif": "image/gif",
487
+ ".svg": "image/svg+xml",
488
+ ".ico": "image/x-icon",
489
+ ".txt": "text/plain"
490
+ };
491
+ constructor(pathPrefix, folderPath, cors) {
492
+ this._pathPrefix = "/" + pathPrefix.replace(/^\//, "").replace(/\/$/, "");
493
+ this._absoluteFolderPath = path2.resolve(folderPath);
494
+ this._cors = cors;
495
+ this._server = http2.createServer((req, res) => this._handleRequest(req, res));
167
496
  }
168
- }
169
-
170
- // src/reportUtils.ts
171
- import { Multimap } from "@flakiness/shared/common/multimap.js";
172
- import { xxHash, xxHashObject } from "@flakiness/shared/common/utils.js";
173
- var ReportUtils;
174
- ((ReportUtils2) => {
175
- function visitTests(report, testVisitor) {
176
- function visitSuite(suite, parents) {
177
- parents.push(suite);
178
- for (const test of suite.tests ?? [])
179
- testVisitor(test, parents);
180
- for (const childSuite of suite.suites ?? [])
181
- visitSuite(childSuite, parents);
182
- parents.pop();
183
- }
184
- for (const test of report.tests ?? [])
185
- testVisitor(test, []);
186
- for (const suite of report.suites)
187
- visitSuite(suite, []);
188
- }
189
- ReportUtils2.visitTests = visitTests;
190
- function normalizeReport(report) {
191
- const gEnvs = /* @__PURE__ */ new Map();
192
- const gSuites = /* @__PURE__ */ new Map();
193
- const gTests = new Multimap();
194
- const gSuiteIds = /* @__PURE__ */ new Map();
195
- const gTestIds = /* @__PURE__ */ new Map();
196
- const gEnvIds = /* @__PURE__ */ new Map();
197
- const gSuiteChildren = new Multimap();
198
- const gSuiteTests = new Multimap();
199
- for (const env of report.environments) {
200
- const envId = computeEnvId(env);
201
- gEnvs.set(envId, env);
202
- gEnvIds.set(env, envId);
203
- }
204
- const usedEnvIds = /* @__PURE__ */ new Set();
205
- function visitTests2(tests, suiteId) {
206
- for (const test of tests ?? []) {
207
- const testId = computeTestId(test, suiteId);
208
- gTests.set(testId, test);
209
- gTestIds.set(test, testId);
210
- gSuiteTests.set(suiteId, test);
211
- for (const attempt of test.attempts) {
212
- const env = report.environments[attempt.environmentIdx];
213
- const envId = gEnvIds.get(env);
214
- usedEnvIds.add(envId);
215
- }
216
- }
217
- }
218
- function visitSuite(suite, parentSuiteId) {
219
- const suiteId = computeSuiteId(suite, parentSuiteId);
220
- gSuites.set(suiteId, suite);
221
- gSuiteIds.set(suite, suiteId);
222
- for (const childSuite of suite.suites ?? []) {
223
- visitSuite(childSuite, suiteId);
224
- gSuiteChildren.set(suiteId, childSuite);
225
- }
226
- visitTests2(suite.tests ?? [], suiteId);
227
- }
228
- function transformTests(tests) {
229
- const testIds = new Set(tests.map((test) => gTestIds.get(test)));
230
- return [...testIds].map((testId) => {
231
- const tests2 = gTests.getAll(testId);
232
- const tags = tests2.map((test) => test.tags ?? []).flat();
233
- return {
234
- location: tests2[0].location,
235
- title: tests2[0].title,
236
- tags: tags.length ? tags : void 0,
237
- attempts: tests2.map((t2) => t2.attempts).flat().map((attempt) => ({
238
- ...attempt,
239
- environmentIdx: envIdToIndex.get(gEnvIds.get(report.environments[attempt.environmentIdx]))
240
- }))
241
- };
242
- });
243
- }
244
- function transformSuites(suites) {
245
- const suiteIds = new Set(suites.map((suite) => gSuiteIds.get(suite)));
246
- return [...suiteIds].map((suiteId) => {
247
- const suite = gSuites.get(suiteId);
248
- return {
249
- location: suite.location,
250
- title: suite.title,
251
- type: suite.type,
252
- suites: transformSuites(gSuiteChildren.getAll(suiteId)),
253
- tests: transformTests(gSuiteTests.getAll(suiteId))
254
- };
255
- });
256
- }
257
- visitTests2(report.tests ?? [], "suiteless");
258
- for (const suite of report.suites)
259
- visitSuite(suite);
260
- const newEnvironments = [...usedEnvIds];
261
- const envIdToIndex = new Map(newEnvironments.map((envId, index) => [envId, index]));
262
- return {
263
- ...report,
264
- environments: newEnvironments.map((envId) => gEnvs.get(envId)),
265
- suites: transformSuites(report.suites),
266
- tests: transformTests(report.tests ?? [])
267
- };
497
+ port() {
498
+ const address = this._server.address();
499
+ if (!address)
500
+ return void 0;
501
+ return address.port;
268
502
  }
269
- ReportUtils2.normalizeReport = normalizeReport;
270
- function computeEnvId(env) {
271
- return xxHashObject(env);
272
- }
273
- function computeSuiteId(suite, parentSuiteId) {
274
- return xxHash([
275
- parentSuiteId ?? "",
276
- suite.type,
277
- suite.location?.file ?? "",
278
- suite.title
279
- ]);
280
- }
281
- function computeTestId(test, suiteId) {
282
- return xxHash([
283
- suiteId,
284
- test.location?.file ?? "",
285
- test.title
286
- ]);
287
- }
288
- })(ReportUtils || (ReportUtils = {}));
289
-
290
- // src/localReportApi.ts
291
- var ReportInfo = class {
292
- constructor(_options) {
293
- this._options = _options;
294
- }
295
- report;
296
- attachmentIdToPath = /* @__PURE__ */ new Map();
297
- commits = [];
298
- async refresh() {
299
- const report = await fs2.promises.readFile(this._options.reportPath, "utf-8").then((x) => JSON.parse(x)).catch((e) => void 0);
300
- if (!report) {
301
- this.report = void 0;
302
- this.commits = [];
303
- this.attachmentIdToPath = /* @__PURE__ */ new Map();
304
- return;
305
- }
306
- if (JSON.stringify(report) === JSON.stringify(this.report))
307
- return;
308
- this.report = report;
309
- this.commits = await listLocalCommits(path2.dirname(this._options.reportPath), report.commitId, 100);
310
- const attachmentsDir = this._options.attachmentsFolder;
311
- const { attachmentIdToPath, missingAttachments } = await resolveAttachmentPaths(report, attachmentsDir);
312
- if (missingAttachments.length) {
313
- const first = missingAttachments.slice(0, 3);
314
- for (let i = 0; i < 3 && i < missingAttachments.length; ++i)
315
- console.warn(`Missing attachment with id ${missingAttachments[i]}`);
316
- if (missingAttachments.length > 3)
317
- console.warn(`...and ${missingAttachments.length - 3} more missing attachments.`);
318
- }
319
- this.attachmentIdToPath = attachmentIdToPath;
503
+ address() {
504
+ const address = this._server.address();
505
+ if (!address)
506
+ return void 0;
507
+ const displayHost = address.address.includes(":") ? `[${address.address}]` : address.address;
508
+ return `http://${displayHost}:${address.port}${this._pathPrefix}`;
320
509
  }
321
- };
322
- var t = TypedHTTP.Router.create();
323
- var localReportRouter = {
324
- ping: t.get({
325
- handler: async () => {
326
- return "pong";
510
+ async _startServer(port, host) {
511
+ let okListener;
512
+ let errListener;
513
+ const result = new Promise((resolve2, reject) => {
514
+ okListener = resolve2;
515
+ errListener = reject;
516
+ }).finally(() => {
517
+ this._server.removeListener("listening", okListener);
518
+ this._server.removeListener("error", errListener);
519
+ });
520
+ this._server.once("listening", okListener);
521
+ this._server.once("error", errListener);
522
+ this._server.listen(port, host);
523
+ await result;
524
+ log2('Serving "%s" on "%s"', this._absoluteFolderPath, this.address());
525
+ }
526
+ async start(port, host = "127.0.0.1") {
527
+ if (port === 0) {
528
+ await this._startServer(port, host);
529
+ return this.address();
327
530
  }
328
- }),
329
- lastCommits: t.get({
330
- handler: async ({ ctx }) => {
331
- return ctx.reportInfo.commits;
531
+ for (let i = 0; i < 20; ++i) {
532
+ const err = await this._startServer(port, host).then(() => void 0).catch((e) => e);
533
+ if (!err)
534
+ return this.address();
535
+ if (err.code !== "EADDRINUSE")
536
+ throw err;
537
+ log2("Port %d is busy (EADDRINUSE). Trying next port...", port);
538
+ port = port + 1;
539
+ if (port > 65535)
540
+ port = 4e3;
332
541
  }
333
- }),
334
- report: {
335
- attachment: t.rawMethod("GET", {
336
- input: z.object({
337
- attachmentId: z.string().min(1).max(100).transform((id) => id)
338
- }),
339
- handler: async ({ ctx, input }) => {
340
- const idx = ctx.reportInfo.attachmentIdToPath.get(input.attachmentId);
341
- if (!idx)
342
- throw TypedHTTP.HttpError.withCode("NOT_FOUND");
343
- const buffer = await fs2.promises.readFile(idx.path);
344
- return TypedHTTP.ok(buffer, idx.contentType);
345
- }
346
- }),
347
- json: t.get({
348
- handler: async ({ ctx }) => {
349
- await ctx.reportInfo.refresh();
350
- return ctx.reportInfo.report;
351
- }
352
- })
542
+ log2("All sequential ports busy. Falling back to random port.");
543
+ await this._startServer(0, host);
544
+ return this.address();
353
545
  }
354
- };
355
- async function resolveAttachmentPaths(report, attachmentsDir) {
356
- const attachmentFiles = await listFilesRecursively(attachmentsDir);
357
- const filenameToPath = new Map(attachmentFiles.map((file) => [path2.basename(file), file]));
358
- const attachmentIdToPath = /* @__PURE__ */ new Map();
359
- const missingAttachments = /* @__PURE__ */ new Set();
360
- ReportUtils.visitTests(report, (test) => {
361
- for (const attempt of test.attempts) {
362
- for (const attachment of attempt.attachments ?? []) {
363
- const attachmentPath = filenameToPath.get(attachment.id);
364
- if (!attachmentPath) {
365
- missingAttachments.add(attachment.id);
546
+ stop() {
547
+ return new Promise((resolve2, reject) => {
548
+ this._server.close((err) => {
549
+ if (err) {
550
+ log2("Error stopping server: %o", err);
551
+ reject(err);
366
552
  } else {
367
- attachmentIdToPath.set(attachment.id, {
368
- contentType: attachment.contentType,
369
- id: attachment.id,
370
- path: attachmentPath
371
- });
553
+ log2("Server stopped.");
554
+ resolve2();
372
555
  }
373
- }
374
- }
375
- });
376
- return { attachmentIdToPath, missingAttachments: Array.from(missingAttachments) };
377
- }
378
- async function listFilesRecursively(dir, result = []) {
379
- const entries = await fs2.promises.readdir(dir, { withFileTypes: true });
380
- for (const entry of entries) {
381
- const fullPath = path2.join(dir, entry.name);
382
- if (entry.isDirectory())
383
- await listFilesRecursively(fullPath, result);
384
- else
385
- result.push(fullPath);
556
+ });
557
+ });
386
558
  }
387
- return result;
388
- }
389
-
390
- // src/localReportServer.ts
391
- var logHTTPServer = debug2("fk:http");
392
- var LocalReportServer = class _LocalReportServer {
393
- constructor(_server, _port, _authToken) {
394
- this._server = _server;
395
- this._port = _port;
396
- this._authToken = _authToken;
397
- }
398
- static async create(options) {
399
- const app = express();
400
- app.set("etag", false);
401
- const authToken = randomUUIDBase62();
402
- app.use(compression());
403
- app.use(bodyParser.json({ limit: 256 * 1024 }));
404
- app.use((req, res, next) => {
405
- if (!req.path.startsWith("/" + authToken))
406
- throw TypedHTTP2.HttpError.withCode("UNAUTHORIZED");
559
+ _errorResponse(req, res, code, text) {
560
+ res.writeHead(code, { "Content-Type": "text/plain" });
561
+ res.end(text);
562
+ log2(`[${code}] ${req.method} ${req.url}`);
563
+ }
564
+ _handleRequest(req, res) {
565
+ const { url, method } = req;
566
+ if (this._cors) {
407
567
  res.setHeader("Access-Control-Allow-Headers", "*");
408
- res.setHeader("Access-Control-Allow-Origin", options.endpoint);
568
+ res.setHeader("Access-Control-Allow-Origin", this._cors);
409
569
  res.setHeader("Access-Control-Allow-Methods", "*");
410
570
  if (req.method === "OPTIONS") {
411
571
  res.writeHead(204);
412
572
  res.end();
413
573
  return;
414
574
  }
415
- req.on("aborted", () => logHTTPServer(`REQ ABORTED ${req.method} ${req.originalUrl}`));
416
- res.on("close", () => {
417
- if (!res.headersSent) logHTTPServer(`RES CLOSED BEFORE SEND ${req.method} ${req.originalUrl}`);
418
- });
419
- next();
420
- });
421
- const reportInfo = new ReportInfo(options);
422
- app.use("/" + authToken, createTypedHttpExpressMiddleware({
423
- router: localReportRouter,
424
- createRootContext: async ({ req, res, input }) => ({ reportInfo })
425
- }));
426
- app.use((err, req, res, next) => {
427
- if (err instanceof TypedHTTP2.HttpError)
428
- return res.status(err.status).send({ error: err.message });
429
- logHTTPServer(err);
430
- res.status(500).send({ error: "Internal Server Error" });
575
+ }
576
+ if (method !== "GET") {
577
+ this._errorResponse(req, res, 405, "Method Not Allowed");
578
+ return;
579
+ }
580
+ req.on("aborted", () => log2(`ABORTED ${req.method} ${req.url}`));
581
+ res.on("close", () => {
582
+ if (!res.headersSent) log2(`CLOSED BEFORE SEND ${req.method} ${req.url}`);
431
583
  });
432
- const server = http.createServer(app);
433
- server.on("error", (err) => {
434
- if (err.code === "ECONNRESET") {
435
- logHTTPServer("Client connection reset. Ignoring.");
584
+ if (!url || !url.startsWith(this._pathPrefix)) {
585
+ this._errorResponse(req, res, 404, "Not Found");
586
+ return;
587
+ }
588
+ const relativePath = url.slice(this._pathPrefix.length);
589
+ const safeSuffix = path2.normalize(decodeURIComponent(relativePath)).replace(/^(\.\.[\/\\])+/, "");
590
+ const filePath = path2.join(this._absoluteFolderPath, safeSuffix);
591
+ if (!filePath.startsWith(this._absoluteFolderPath)) {
592
+ this._errorResponse(req, res, 403, "Forbidden");
593
+ return;
594
+ }
595
+ fs2.stat(filePath, (err, stats) => {
596
+ if (err || !stats.isFile()) {
597
+ this._errorResponse(req, res, 404, "File Not Found");
436
598
  return;
437
599
  }
438
- throw err;
600
+ const ext = path2.extname(filePath).toLowerCase();
601
+ const contentType = this._mimeTypes[ext] || "application/octet-stream";
602
+ res.writeHead(200, { "Content-Type": contentType });
603
+ log2(`[200] ${req.method} ${req.url} -> ${filePath}`);
604
+ const readStream = fs2.createReadStream(filePath);
605
+ readStream.pipe(res);
606
+ readStream.on("error", (err2) => {
607
+ log2("Stream error: %o", err2);
608
+ res.end();
609
+ });
439
610
  });
440
- const port = await new Promise((resolve) => server.listen(options.port, () => {
441
- resolve(server.address().port);
442
- }));
443
- return new _LocalReportServer(server, port, authToken);
444
- }
445
- authToken() {
446
- return this._authToken;
447
- }
448
- port() {
449
- return this._port;
450
- }
451
- async dispose() {
452
- await new Promise((x) => this._server.close(x));
453
611
  }
454
612
  };
455
613
 
456
614
  // src/showReport.ts
457
615
  async function showReport(reportFolder) {
458
- const reportPath = path3.join(reportFolder, "report.json");
459
616
  const config = await FlakinessProjectConfig.load();
460
617
  const projectPublicId = config.projectPublicId();
461
- const reportViewerEndpoint = config.reportViewerEndpoint();
462
- const server = await LocalReportServer.create({
463
- endpoint: reportViewerEndpoint,
464
- port: 9373,
465
- reportPath,
466
- attachmentsFolder: reportFolder
467
- });
618
+ const reportViewerEndpoint = config.reportViewerUrl();
619
+ const token = randomUUIDBase62();
620
+ const server = new StaticServer(token, reportFolder, reportViewerEndpoint);
621
+ await server.start(9373, "127.0.0.1");
468
622
  const url = new URL(reportViewerEndpoint);
469
623
  url.searchParams.set("port", String(server.port()));
470
- url.searchParams.set("token", server.authToken());
624
+ url.searchParams.set("token", token);
471
625
  if (projectPublicId)
472
626
  url.searchParams.set("ppid", projectPublicId);
473
627
  console.log(chalk.cyan(`