@flakiness/sdk 0.148.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.
@@ -2,26 +2,102 @@
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
 
5
- // src/git.ts
5
+ // src/gitWorktree.ts
6
6
  import assert from "assert";
7
-
8
- // src/pathutils.ts
7
+ import { exec } from "child_process";
8
+ import debug from "debug";
9
9
  import { posix as posixPath, win32 as win32Path } from "path";
10
- var IS_WIN32_PATH = new RegExp("^[a-zA-Z]:\\\\", "i");
11
- var IS_ALMOST_POSIX_PATH = new RegExp("^[a-zA-Z]:/", "i");
12
- function normalizePath(aPath) {
13
- if (IS_WIN32_PATH.test(aPath)) {
14
- aPath = aPath.split(win32Path.sep).join(posixPath.sep);
15
- }
16
- if (IS_ALMOST_POSIX_PATH.test(aPath))
17
- return "/" + aPath[0] + aPath.substring(2);
18
- return aPath;
19
- }
10
+ import { promisify } from "util";
20
11
 
21
- // src/utils.ts
12
+ // src/_internalUtils.ts
22
13
  import { spawnSync } from "child_process";
14
+ import http from "http";
15
+ import https from "https";
16
+ import util from "util";
17
+ import zlib from "zlib";
18
+ var asyncBrotliCompress = util.promisify(zlib.brotliCompress);
23
19
  var FLAKINESS_DBG = !!process.env.FLAKINESS_DBG;
24
- 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");
20
+ function errorText(error) {
21
+ return FLAKINESS_DBG ? error.stack : error.message;
22
+ }
23
+ async function retryWithBackoff(job, backoff = []) {
24
+ for (const timeout of backoff) {
25
+ try {
26
+ return await job();
27
+ } catch (e) {
28
+ if (e instanceof AggregateError)
29
+ console.error(`[flakiness.io err]`, errorText(e.errors[0]));
30
+ else if (e instanceof Error)
31
+ console.error(`[flakiness.io err]`, errorText(e));
32
+ else
33
+ console.error(`[flakiness.io err]`, e);
34
+ await new Promise((x) => setTimeout(x, timeout));
35
+ }
36
+ }
37
+ return await job();
38
+ }
39
+ var httpUtils;
40
+ ((httpUtils2) => {
41
+ function createRequest({ url, method = "get", headers = {} }) {
42
+ let resolve;
43
+ let reject;
44
+ const responseDataPromise = new Promise((a, b) => {
45
+ resolve = a;
46
+ reject = b;
47
+ });
48
+ const protocol = url.startsWith("https") ? https : http;
49
+ headers = Object.fromEntries(Object.entries(headers).filter(([key, value]) => value !== void 0));
50
+ const request = protocol.request(url, { method, headers }, (res) => {
51
+ const chunks = [];
52
+ res.on("data", (chunk) => chunks.push(chunk));
53
+ res.on("end", () => {
54
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300)
55
+ resolve(Buffer.concat(chunks));
56
+ else
57
+ reject(new Error(`Request to ${url} failed with ${res.statusCode}`));
58
+ });
59
+ res.on("error", (error) => reject(error));
60
+ });
61
+ request.on("error", reject);
62
+ return { request, responseDataPromise };
63
+ }
64
+ httpUtils2.createRequest = createRequest;
65
+ async function getBuffer(url, backoff) {
66
+ return await retryWithBackoff(async () => {
67
+ const { request, responseDataPromise } = createRequest({ url });
68
+ request.end();
69
+ return await responseDataPromise;
70
+ }, backoff);
71
+ }
72
+ httpUtils2.getBuffer = getBuffer;
73
+ async function getText(url, backoff) {
74
+ const buffer = await getBuffer(url, backoff);
75
+ return buffer.toString("utf-8");
76
+ }
77
+ httpUtils2.getText = getText;
78
+ async function getJSON(url) {
79
+ return JSON.parse(await getText(url));
80
+ }
81
+ httpUtils2.getJSON = getJSON;
82
+ async function postText(url, text, backoff) {
83
+ const headers = {
84
+ "Content-Type": "application/json",
85
+ "Content-Length": Buffer.byteLength(text) + ""
86
+ };
87
+ return await retryWithBackoff(async () => {
88
+ const { request, responseDataPromise } = createRequest({ url, headers, method: "post" });
89
+ request.write(text);
90
+ request.end();
91
+ return await responseDataPromise;
92
+ }, backoff);
93
+ }
94
+ httpUtils2.postText = postText;
95
+ async function postJSON(url, json, backoff) {
96
+ const buffer = await postText(url, JSON.stringify(json), backoff);
97
+ return JSON.parse(buffer.toString("utf-8"));
98
+ }
99
+ httpUtils2.postJSON = postJSON;
100
+ })(httpUtils || (httpUtils = {}));
25
101
  function shell(command, args, options) {
26
102
  try {
27
103
  const result = spawnSync(command, args, { encoding: "utf-8", ...options });
@@ -35,14 +111,204 @@ function shell(command, args, options) {
35
111
  }
36
112
  }
37
113
 
38
- // src/git.ts
39
- function computeGitRoot(somePathInsideGitRepo) {
40
- const root = shell(`git`, ["rev-parse", "--show-toplevel"], {
41
- cwd: somePathInsideGitRepo,
42
- encoding: "utf-8"
43
- });
44
- assert(root, `FAILED: git rev-parse --show-toplevel HEAD @ ${somePathInsideGitRepo}`);
45
- return normalizePath(root);
114
+ // src/gitWorktree.ts
115
+ var log = debug("fk:git");
116
+ var execAsync = promisify(exec);
117
+ var IS_WIN32_PATH = new RegExp("^[a-zA-Z]:\\\\", "i");
118
+ var IS_ALMOST_POSIX_PATH = new RegExp("^[a-zA-Z]:/", "i");
119
+ function toPosixAbsolutePath(absolutePath) {
120
+ if (IS_WIN32_PATH.test(absolutePath)) {
121
+ absolutePath = absolutePath.split(win32Path.sep).join(posixPath.sep);
122
+ }
123
+ if (IS_ALMOST_POSIX_PATH.test(absolutePath))
124
+ return "/" + absolutePath[0] + absolutePath.substring(2);
125
+ return absolutePath;
126
+ }
127
+ function toNativeAbsolutePath(posix) {
128
+ if (process.platform !== "win32")
129
+ return posix;
130
+ assert(posix.startsWith("/"), "The path must be absolute");
131
+ const m = posix.match(/^\/([a-zA-Z])(\/.*)?$/);
132
+ assert(m, `Invalid POSIX path: ${posix}`);
133
+ const drive = m[1];
134
+ const rest = (m[2] ?? "").split(posixPath.sep).join(win32Path.sep);
135
+ return drive.toUpperCase() + ":" + rest;
136
+ }
137
+ var GitWorktree = class _GitWorktree {
138
+ constructor(_gitRoot) {
139
+ this._gitRoot = _gitRoot;
140
+ this._posixGitRoot = toPosixAbsolutePath(this._gitRoot);
141
+ }
142
+ /**
143
+ * Creates a GitWorktree instance from any path inside a git repository.
144
+ *
145
+ * @param {string} somePathInsideGitRepo - Any path (file or directory) within a git repository.
146
+ * Can be absolute or relative. The function will locate the git root directory.
147
+ *
148
+ * @returns {GitWorktree} A new GitWorktree instance bound to the discovered git root.
149
+ *
150
+ * @throws {Error} Throws if the path is not inside a git repository or if git commands fail.
151
+ *
152
+ * @example
153
+ * ```typescript
154
+ * const worktree = GitWorktree.create('./src/my-test.ts');
155
+ * const gitRoot = worktree.rootPath();
156
+ * ```
157
+ */
158
+ static create(somePathInsideGitRepo) {
159
+ const root = shell(`git`, ["rev-parse", "--show-toplevel"], {
160
+ cwd: somePathInsideGitRepo,
161
+ encoding: "utf-8"
162
+ });
163
+ assert(root, `FAILED: git rev-parse --show-toplevel HEAD @ ${somePathInsideGitRepo}`);
164
+ return new _GitWorktree(root);
165
+ }
166
+ _posixGitRoot;
167
+ /**
168
+ * Returns the native absolute path of the git repository root directory.
169
+ *
170
+ * @returns {string} Native absolute path to the git root. Format matches the current platform
171
+ * (Windows or POSIX).
172
+ *
173
+ * @example
174
+ * ```typescript
175
+ * const root = worktree.rootPath();
176
+ * // On Windows: 'D:\project'
177
+ * // On Unix: '/project'
178
+ * ```
179
+ */
180
+ rootPath() {
181
+ return this._gitRoot;
182
+ }
183
+ /**
184
+ * Returns the commit ID (SHA-1 hash) of the current HEAD commit.
185
+ *
186
+ * @returns {FlakinessReport.CommitId} Full 40-character commit hash of the HEAD commit.
187
+ *
188
+ * @throws {Error} Throws if git command fails or repository is in an invalid state.
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * const commitId = worktree.headCommitId();
193
+ * // Returns: 'a1b2c3d4e5f6...' (40-character SHA-1)
194
+ * ```
195
+ */
196
+ headCommitId() {
197
+ const sha = shell(`git`, ["rev-parse", "HEAD"], {
198
+ cwd: this._gitRoot,
199
+ encoding: "utf-8"
200
+ });
201
+ assert(sha, `FAILED: git rev-parse HEAD @ ${this._gitRoot}`);
202
+ return sha.trim();
203
+ }
204
+ /**
205
+ * Converts a native absolute path to a git-relative POSIX path.
206
+ *
207
+ * Takes any absolute path (Windows or POSIX format) and converts it to a POSIX path
208
+ * relative to the git repository root. This is essential for Flakiness reports where
209
+ * all file paths must be git-relative and use POSIX separators.
210
+ *
211
+ * @param {string} absolutePath - Native absolute path to convert. Can be in Windows format
212
+ * (e.g., `D:\project\src\test.ts`) or POSIX format (e.g., `/project/src/test.ts`).
213
+ *
214
+ * @returns {FlakinessReport.GitFilePath} POSIX path relative to git root (e.g., `src/test.ts`).
215
+ * Returns an empty string if the path is the git root itself.
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * const gitPath = worktree.gitPath('/Users/project/src/test.ts');
220
+ * // Returns: 'src/test.ts'
221
+ * ```
222
+ */
223
+ gitPath(absolutePath) {
224
+ return posixPath.relative(this._posixGitRoot, toPosixAbsolutePath(absolutePath));
225
+ }
226
+ /**
227
+ * Converts a git-relative POSIX path to a native absolute path.
228
+ *
229
+ * Takes a POSIX path relative to the git root and converts it to the native absolute path
230
+ * format for the current platform (Windows or POSIX). This is the inverse of `gitPath()`.
231
+ *
232
+ * @param {FlakinessReport.GitFilePath} relativePath - POSIX path relative to git root
233
+ * (e.g., `src/test.ts`).
234
+ *
235
+ * @returns {string} Native absolute path. On Windows, returns Windows format (e.g., `D:\project\src\test.ts`).
236
+ * On POSIX systems, returns POSIX format (e.g., `/project/src/test.ts`).
237
+ *
238
+ * @example
239
+ * ```typescript
240
+ * const absolutePath = worktree.absolutePath('src/test.ts');
241
+ * // On Windows: 'D:\project\src\test.ts'
242
+ * // On Unix: '/project/src/test.ts'
243
+ * ```
244
+ */
245
+ absolutePath(relativePath) {
246
+ return toNativeAbsolutePath(posixPath.join(this._posixGitRoot, relativePath));
247
+ }
248
+ /**
249
+ * Lists recent commits from the repository.
250
+ *
251
+ * Retrieves commit information including commit ID, timestamp, author, message, and parent commits.
252
+ * Note: CI environments often have shallow checkouts with limited history, which may affect
253
+ * the number of commits returned.
254
+ *
255
+ * @param {number} count - Maximum number of commits to retrieve, starting from HEAD.
256
+ *
257
+ * @returns {Promise<GitCommit[]>} Promise that resolves to an array of commit objects, ordered
258
+ * from most recent to oldest. Each commit includes:
259
+ * - `commitId` - Full commit hash
260
+ * - `timestamp` - Commit timestamp in milliseconds since Unix epoch
261
+ * - `message` - Commit message (subject line)
262
+ * - `author` - Author name
263
+ * - `parents` - Array of parent commit IDs
264
+ *
265
+ * @example
266
+ * ```typescript
267
+ * const commits = await worktree.listCommits(10);
268
+ * console.log(`Latest commit: ${commits[0].message}`);
269
+ * ```
270
+ */
271
+ async listCommits(count) {
272
+ return await listCommits(this._gitRoot, "HEAD", count);
273
+ }
274
+ };
275
+ async function listCommits(gitRoot, head, count) {
276
+ const FIELD_SEPARATOR = "|~|";
277
+ const RECORD_SEPARATOR = "\0";
278
+ const prettyFormat = [
279
+ "%H",
280
+ // Full commit hash
281
+ "%ct",
282
+ // Commit timestamp (Unix seconds)
283
+ "%an",
284
+ // Author name
285
+ "%s",
286
+ // Subject line
287
+ "%P"
288
+ // Parent hashes (space-separated)
289
+ ].join(FIELD_SEPARATOR);
290
+ const command = `git log ${head} -n ${count} --pretty=format:"${prettyFormat}" -z`;
291
+ try {
292
+ const { stdout } = await execAsync(command, { cwd: gitRoot });
293
+ if (!stdout) {
294
+ return [];
295
+ }
296
+ return stdout.trim().split(RECORD_SEPARATOR).filter((record) => record).map((record) => {
297
+ const [commitId, timestampStr, author, message, parentsStr] = record.split(FIELD_SEPARATOR);
298
+ const parents = parentsStr ? parentsStr.split(" ").filter((p) => p) : [];
299
+ return {
300
+ commitId,
301
+ timestamp: parseInt(timestampStr, 10) * 1e3,
302
+ author,
303
+ message,
304
+ parents,
305
+ walkIndex: 0
306
+ };
307
+ });
308
+ } catch (error) {
309
+ log(`Failed to list commits for repository at ${gitRoot}:`, error);
310
+ return [];
311
+ }
46
312
  }
47
313
 
48
314
  // src/flakinessProjectConfig.ts
@@ -62,8 +328,8 @@ function computeConfigPath() {
62
328
  return configPath;
63
329
  }
64
330
  try {
65
- const gitRoot = computeGitRoot(process.cwd());
66
- return createConfigPath(gitRoot);
331
+ const worktree = GitWorktree.create(process.cwd());
332
+ return createConfigPath(worktree.rootPath());
67
333
  } catch (e) {
68
334
  return createConfigPath(process.cwd());
69
335
  }
@@ -73,33 +339,110 @@ var FlakinessProjectConfig = class _FlakinessProjectConfig {
73
339
  this._configPath = _configPath;
74
340
  this._config = _config;
75
341
  }
342
+ /**
343
+ * Loads the Flakiness project configuration from disk.
344
+ *
345
+ * Searches for an existing `.flakiness/config.json` file starting from the current working
346
+ * directory and walking up the directory tree. If no config exists, it determines the
347
+ * appropriate location (git root or current directory) for future saves.
348
+ *
349
+ * @returns {Promise<FlakinessProjectConfig>} Promise that resolves to a FlakinessProjectConfig
350
+ * instance. If no config file exists, returns an instance with default/empty values.
351
+ *
352
+ * @example
353
+ * ```typescript
354
+ * const config = await FlakinessProjectConfig.load();
355
+ * const projectId = config.projectPublicId();
356
+ * ```
357
+ */
76
358
  static async load() {
77
359
  const configPath = ensureConfigPath();
78
360
  const data = await fs.promises.readFile(configPath, "utf-8").catch((e) => void 0);
79
361
  const json = data ? JSON.parse(data) : {};
80
362
  return new _FlakinessProjectConfig(configPath, json);
81
363
  }
364
+ /**
365
+ * Creates a new empty Flakiness project configuration.
366
+ *
367
+ * Creates a configuration instance with no values set. Use this when you want to build
368
+ * a configuration from scratch. Call `save()` to persist it to disk.
369
+ *
370
+ * @returns {FlakinessProjectConfig} A new empty configuration instance.
371
+ *
372
+ * @example
373
+ * ```typescript
374
+ * const config = FlakinessProjectConfig.createEmpty();
375
+ * config.setProjectPublicId('my-project-id');
376
+ * await config.save();
377
+ * ```
378
+ */
82
379
  static createEmpty() {
83
380
  return new _FlakinessProjectConfig(ensureConfigPath(), {});
84
381
  }
382
+ /**
383
+ * Returns the absolute path to the configuration file.
384
+ *
385
+ * @returns {string} Absolute path to `.flakiness/config.json`.
386
+ */
85
387
  path() {
86
388
  return this._configPath;
87
389
  }
390
+ /**
391
+ * Returns the project's public ID, if configured.
392
+ *
393
+ * The project public ID is used to associate reports with a specific Flakiness.io project.
394
+ *
395
+ * @returns {string | undefined} Project public ID, or `undefined` if not set.
396
+ */
88
397
  projectPublicId() {
89
398
  return this._config.projectPublicId;
90
399
  }
400
+ /**
401
+ * Returns the report viewer URL, either custom or default.
402
+ *
403
+ * @returns {string} Custom report viewer URL if configured, otherwise the default
404
+ * `https://report.flakiness.io`.
405
+ */
91
406
  reportViewerUrl() {
92
407
  return this._config.customReportViewerUrl ?? "https://report.flakiness.io";
93
408
  }
409
+ /**
410
+ * Sets or clears the custom report viewer URL.
411
+ *
412
+ * @param {string | undefined} url - Custom report viewer URL to use, or `undefined` to
413
+ * clear and use the default URL.
414
+ */
94
415
  setCustomReportViewerUrl(url) {
95
416
  if (url)
96
417
  this._config.customReportViewerUrl = url;
97
418
  else
98
419
  delete this._config.customReportViewerUrl;
99
420
  }
421
+ /**
422
+ * Sets the project's public ID.
423
+ *
424
+ * @param {string | undefined} projectId - Project public ID to set, or `undefined` to clear.
425
+ */
100
426
  setProjectPublicId(projectId) {
101
427
  this._config.projectPublicId = projectId;
102
428
  }
429
+ /**
430
+ * Saves the configuration to disk.
431
+ *
432
+ * Writes the current configuration values to `.flakiness/config.json`. Creates the
433
+ * `.flakiness` directory if it doesn't exist.
434
+ *
435
+ * @returns {Promise<void>} Promise that resolves when the file has been written.
436
+ *
437
+ * @throws {Error} Throws if unable to create directories or write the file.
438
+ *
439
+ * @example
440
+ * ```typescript
441
+ * const config = await FlakinessProjectConfig.load();
442
+ * config.setProjectPublicId('my-project');
443
+ * await config.save();
444
+ * ```
445
+ */
103
446
  async save() {
104
447
  await fs.promises.mkdir(path.dirname(this._configPath), { recursive: true });
105
448
  await fs.promises.writeFile(this._configPath, JSON.stringify(this._config, null, 2));