@flakiness/sdk 0.146.0 → 0.147.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/LICENSE +21 -45
- package/README.md +2 -27
- package/lib/browser/index.js +127 -0
- package/lib/createEnvironment.js +78 -0
- package/lib/createTestStepSnippets.js +142 -4
- package/lib/flakinessProjectConfig.js +22 -100
- package/lib/git.js +55 -0
- package/lib/{serverapi.js → httpUtils.js} +13 -25
- package/lib/{playwright-test.js → index.js} +746 -814
- package/lib/localGit.js +4 -2
- package/lib/localReportApi.js +155 -125
- package/lib/localReportServer.js +159 -129
- package/lib/pathutils.js +20 -0
- package/lib/reportUploader.js +77 -37
- package/lib/reportUtils.js +123 -0
- package/lib/showReport.js +195 -152
- package/lib/utils.js +0 -330
- package/package.json +12 -38
- package/types/tsconfig.tsbuildinfo +1 -1
|
@@ -1,91 +1,49 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __export = (target, all) => {
|
|
3
|
+
for (var name in all)
|
|
4
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// src/index.ts
|
|
8
|
+
import chalk from "chalk";
|
|
5
9
|
import fs6 from "fs";
|
|
6
|
-
import
|
|
10
|
+
import open from "open";
|
|
11
|
+
import path3 from "path";
|
|
7
12
|
|
|
8
|
-
// src/
|
|
9
|
-
import { codeFrameColumns } from "@babel/code-frame";
|
|
13
|
+
// src/flakinessProjectConfig.ts
|
|
10
14
|
import fs from "fs";
|
|
11
|
-
|
|
12
|
-
for (const [filepath, steps] of filepathToSteps) {
|
|
13
|
-
let source;
|
|
14
|
-
try {
|
|
15
|
-
source = fs.readFileSync(filepath, "utf-8");
|
|
16
|
-
} catch (e) {
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
const lines = source.split("\n").length;
|
|
20
|
-
const highlighted = codeFrameColumns(source, { start: { line: lines, column: 1 } }, { highlightCode: true, linesAbove: lines, linesBelow: 0 });
|
|
21
|
-
const highlightedLines = highlighted.split("\n");
|
|
22
|
-
const lineWithArrow = highlightedLines[highlightedLines.length - 1];
|
|
23
|
-
for (const step of steps) {
|
|
24
|
-
if (!step.location)
|
|
25
|
-
continue;
|
|
26
|
-
if (step.location.line < 2 || step.location.line >= lines)
|
|
27
|
-
continue;
|
|
28
|
-
const snippetLines = highlightedLines.slice(step.location.line - 2, step.location.line + 1);
|
|
29
|
-
const index = lineWithArrow.indexOf("^");
|
|
30
|
-
const shiftedArrow = lineWithArrow.slice(0, index) + " ".repeat(step.location.column - 1) + lineWithArrow.slice(index);
|
|
31
|
-
snippetLines.splice(2, 0, shiftedArrow);
|
|
32
|
-
step.snippet = snippetLines.join("\n");
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
15
|
+
import path from "path";
|
|
36
16
|
|
|
37
|
-
// src/
|
|
38
|
-
import
|
|
39
|
-
import assert2 from "assert";
|
|
40
|
-
import fs3 from "fs";
|
|
41
|
-
import { URL as URL2 } from "url";
|
|
17
|
+
// src/git.ts
|
|
18
|
+
import assert from "assert";
|
|
42
19
|
|
|
43
|
-
// src/
|
|
44
|
-
|
|
20
|
+
// src/pathutils.ts
|
|
21
|
+
var pathutils_exports = {};
|
|
22
|
+
__export(pathutils_exports, {
|
|
23
|
+
gitFilePath: () => gitFilePath,
|
|
24
|
+
normalizePath: () => normalizePath
|
|
25
|
+
});
|
|
26
|
+
import { posix as posixPath, win32 as win32Path } from "path";
|
|
27
|
+
var IS_WIN32_PATH = new RegExp("^[a-zA-Z]:\\\\", "i");
|
|
28
|
+
var IS_ALMOST_POSIX_PATH = new RegExp("^[a-zA-Z]:/", "i");
|
|
29
|
+
function normalizePath(aPath) {
|
|
30
|
+
if (IS_WIN32_PATH.test(aPath)) {
|
|
31
|
+
aPath = aPath.split(win32Path.sep).join(posixPath.sep);
|
|
32
|
+
}
|
|
33
|
+
if (IS_ALMOST_POSIX_PATH.test(aPath))
|
|
34
|
+
return "/" + aPath[0] + aPath.substring(2);
|
|
35
|
+
return aPath;
|
|
36
|
+
}
|
|
37
|
+
function gitFilePath(gitRoot, absolutePath) {
|
|
38
|
+
return posixPath.relative(gitRoot, absolutePath);
|
|
39
|
+
}
|
|
45
40
|
|
|
46
41
|
// src/utils.ts
|
|
47
|
-
import { ReportUtils } from "@flakiness/report";
|
|
48
|
-
import assert from "assert";
|
|
49
42
|
import { spawnSync } from "child_process";
|
|
50
|
-
import crypto from "crypto";
|
|
51
|
-
import fs2 from "fs";
|
|
52
|
-
import http from "http";
|
|
53
|
-
import https from "https";
|
|
54
|
-
import os from "os";
|
|
55
|
-
import path, { posix as posixPath, win32 as win32Path } from "path";
|
|
56
|
-
async function existsAsync(aPath) {
|
|
57
|
-
return fs2.promises.stat(aPath).then(() => true).catch((e) => false);
|
|
58
|
-
}
|
|
59
|
-
function extractEnvConfiguration() {
|
|
60
|
-
const ENV_PREFIX = "FK_ENV_";
|
|
61
|
-
return Object.fromEntries(
|
|
62
|
-
Object.entries(process.env).filter(([key]) => key.toUpperCase().startsWith(ENV_PREFIX.toUpperCase())).map(([key, value]) => [key.substring(ENV_PREFIX.length).toLowerCase(), (value ?? "").trim().toLowerCase()])
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
function sha1File(filePath) {
|
|
66
|
-
return new Promise((resolve, reject) => {
|
|
67
|
-
const hash = crypto.createHash("sha1");
|
|
68
|
-
const stream = fs2.createReadStream(filePath);
|
|
69
|
-
stream.on("data", (chunk) => {
|
|
70
|
-
hash.update(chunk);
|
|
71
|
-
});
|
|
72
|
-
stream.on("end", () => {
|
|
73
|
-
resolve(hash.digest("hex"));
|
|
74
|
-
});
|
|
75
|
-
stream.on("error", (err2) => {
|
|
76
|
-
reject(err2);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
43
|
var FLAKINESS_DBG = !!process.env.FLAKINESS_DBG;
|
|
81
44
|
function errorText(error) {
|
|
82
45
|
return FLAKINESS_DBG ? error.stack : error.message;
|
|
83
46
|
}
|
|
84
|
-
function sha1Buffer(data) {
|
|
85
|
-
const hash = crypto.createHash("sha1");
|
|
86
|
-
hash.update(data);
|
|
87
|
-
return hash.digest("hex");
|
|
88
|
-
}
|
|
89
47
|
async function retryWithBackoff(job, backoff = []) {
|
|
90
48
|
for (const timeout of backoff) {
|
|
91
49
|
try {
|
|
@@ -102,95 +60,10 @@ async function retryWithBackoff(job, backoff = []) {
|
|
|
102
60
|
}
|
|
103
61
|
return await job();
|
|
104
62
|
}
|
|
105
|
-
var httpUtils;
|
|
106
|
-
((httpUtils2) => {
|
|
107
|
-
function createRequest({ url, method = "get", headers = {} }) {
|
|
108
|
-
let resolve;
|
|
109
|
-
let reject;
|
|
110
|
-
const responseDataPromise = new Promise((a, b) => {
|
|
111
|
-
resolve = a;
|
|
112
|
-
reject = b;
|
|
113
|
-
});
|
|
114
|
-
const protocol = url.startsWith("https") ? https : http;
|
|
115
|
-
headers = Object.fromEntries(Object.entries(headers).filter(([key, value]) => value !== void 0));
|
|
116
|
-
const request = protocol.request(url, { method, headers }, (res) => {
|
|
117
|
-
const chunks = [];
|
|
118
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
119
|
-
res.on("end", () => {
|
|
120
|
-
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300)
|
|
121
|
-
resolve(Buffer.concat(chunks));
|
|
122
|
-
else
|
|
123
|
-
reject(new Error(`Request to ${url} failed with ${res.statusCode}`));
|
|
124
|
-
});
|
|
125
|
-
res.on("error", (error) => reject(error));
|
|
126
|
-
});
|
|
127
|
-
request.on("error", reject);
|
|
128
|
-
return { request, responseDataPromise };
|
|
129
|
-
}
|
|
130
|
-
httpUtils2.createRequest = createRequest;
|
|
131
|
-
async function getBuffer(url, backoff) {
|
|
132
|
-
return await retryWithBackoff(async () => {
|
|
133
|
-
const { request, responseDataPromise } = createRequest({ url });
|
|
134
|
-
request.end();
|
|
135
|
-
return await responseDataPromise;
|
|
136
|
-
}, backoff);
|
|
137
|
-
}
|
|
138
|
-
httpUtils2.getBuffer = getBuffer;
|
|
139
|
-
async function getText(url, backoff) {
|
|
140
|
-
const buffer = await getBuffer(url, backoff);
|
|
141
|
-
return buffer.toString("utf-8");
|
|
142
|
-
}
|
|
143
|
-
httpUtils2.getText = getText;
|
|
144
|
-
async function getJSON(url) {
|
|
145
|
-
return JSON.parse(await getText(url));
|
|
146
|
-
}
|
|
147
|
-
httpUtils2.getJSON = getJSON;
|
|
148
|
-
async function postText(url, text, backoff) {
|
|
149
|
-
const headers = {
|
|
150
|
-
"Content-Type": "application/json",
|
|
151
|
-
"Content-Length": Buffer.byteLength(text) + ""
|
|
152
|
-
};
|
|
153
|
-
return await retryWithBackoff(async () => {
|
|
154
|
-
const { request, responseDataPromise } = createRequest({ url, headers, method: "post" });
|
|
155
|
-
request.write(text);
|
|
156
|
-
request.end();
|
|
157
|
-
return await responseDataPromise;
|
|
158
|
-
}, backoff);
|
|
159
|
-
}
|
|
160
|
-
httpUtils2.postText = postText;
|
|
161
|
-
async function postJSON(url, json, backoff) {
|
|
162
|
-
const buffer = await postText(url, JSON.stringify(json), backoff);
|
|
163
|
-
return JSON.parse(buffer.toString("utf-8"));
|
|
164
|
-
}
|
|
165
|
-
httpUtils2.postJSON = postJSON;
|
|
166
|
-
})(httpUtils || (httpUtils = {}));
|
|
167
63
|
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");
|
|
168
64
|
function stripAnsi(str) {
|
|
169
65
|
return str.replace(ansiRegex, "");
|
|
170
66
|
}
|
|
171
|
-
async function saveReportAndAttachments(report, attachments, outputFolder) {
|
|
172
|
-
const reportPath = path.join(outputFolder, "report.json");
|
|
173
|
-
const attachmentsFolder = path.join(outputFolder, "attachments");
|
|
174
|
-
await fs2.promises.rm(outputFolder, { recursive: true, force: true });
|
|
175
|
-
await fs2.promises.mkdir(outputFolder, { recursive: true });
|
|
176
|
-
await fs2.promises.writeFile(reportPath, JSON.stringify(report), "utf-8");
|
|
177
|
-
if (attachments.length)
|
|
178
|
-
await fs2.promises.mkdir(attachmentsFolder);
|
|
179
|
-
const movedAttachments = [];
|
|
180
|
-
for (const attachment of attachments) {
|
|
181
|
-
const attachmentPath = path.join(attachmentsFolder, attachment.id);
|
|
182
|
-
if (attachment.path)
|
|
183
|
-
await fs2.promises.cp(attachment.path, attachmentPath);
|
|
184
|
-
else if (attachment.body)
|
|
185
|
-
await fs2.promises.writeFile(attachmentPath, attachment.body);
|
|
186
|
-
movedAttachments.push({
|
|
187
|
-
contentType: attachment.contentType,
|
|
188
|
-
id: attachment.id,
|
|
189
|
-
path: attachmentPath
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
return movedAttachments;
|
|
193
|
-
}
|
|
194
67
|
function shell(command, args, options) {
|
|
195
68
|
try {
|
|
196
69
|
const result = spawnSync(command, args, { encoding: "utf-8", ...options });
|
|
@@ -203,47 +76,8 @@ function shell(command, args, options) {
|
|
|
203
76
|
return void 0;
|
|
204
77
|
}
|
|
205
78
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return new Map(osReleaseText.toLowerCase().split("\n").filter((line) => line.includes("=")).map((line) => {
|
|
209
|
-
line = line.trim();
|
|
210
|
-
let [key, value] = line.split("=");
|
|
211
|
-
if (value.startsWith('"') && value.endsWith('"'))
|
|
212
|
-
value = value.substring(1, value.length - 1);
|
|
213
|
-
return [key, value];
|
|
214
|
-
}));
|
|
215
|
-
}
|
|
216
|
-
function osLinuxInfo() {
|
|
217
|
-
const arch = shell(`uname`, [`-m`]);
|
|
218
|
-
const osReleaseMap = readLinuxOSRelease();
|
|
219
|
-
const name = osReleaseMap.get("name") ?? shell(`uname`);
|
|
220
|
-
const version = osReleaseMap.get("version_id");
|
|
221
|
-
return { name, arch, version };
|
|
222
|
-
}
|
|
223
|
-
function osDarwinInfo() {
|
|
224
|
-
const name = "macos";
|
|
225
|
-
const arch = shell(`uname`, [`-m`]);
|
|
226
|
-
const version = shell(`sw_vers`, [`-productVersion`]);
|
|
227
|
-
return { name, arch, version };
|
|
228
|
-
}
|
|
229
|
-
function osWinInfo() {
|
|
230
|
-
const name = "win";
|
|
231
|
-
const arch = process.arch;
|
|
232
|
-
const version = os.release();
|
|
233
|
-
return { name, arch, version };
|
|
234
|
-
}
|
|
235
|
-
function getOSInfo() {
|
|
236
|
-
if (process.platform === "darwin")
|
|
237
|
-
return osDarwinInfo();
|
|
238
|
-
if (process.platform === "win32")
|
|
239
|
-
return osWinInfo();
|
|
240
|
-
return osLinuxInfo();
|
|
241
|
-
}
|
|
242
|
-
function inferRunUrl() {
|
|
243
|
-
if (process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID)
|
|
244
|
-
return `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
245
|
-
return void 0;
|
|
246
|
-
}
|
|
79
|
+
|
|
80
|
+
// src/git.ts
|
|
247
81
|
function gitCommitInfo(gitRepo) {
|
|
248
82
|
const sha = shell(`git`, ["rev-parse", "HEAD"], {
|
|
249
83
|
cwd: gitRepo,
|
|
@@ -252,40 +86,6 @@ function gitCommitInfo(gitRepo) {
|
|
|
252
86
|
assert(sha, `FAILED: git rev-parse HEAD @ ${gitRepo}`);
|
|
253
87
|
return sha.trim();
|
|
254
88
|
}
|
|
255
|
-
async function resolveAttachmentPaths(report, attachmentsDir) {
|
|
256
|
-
const attachmentFiles = await listFilesRecursively(attachmentsDir);
|
|
257
|
-
const filenameToPath = new Map(attachmentFiles.map((file) => [path.basename(file), file]));
|
|
258
|
-
const attachmentIdToPath = /* @__PURE__ */ new Map();
|
|
259
|
-
const missingAttachments = /* @__PURE__ */ new Set();
|
|
260
|
-
ReportUtils.visitTests(report, (test) => {
|
|
261
|
-
for (const attempt of test.attempts) {
|
|
262
|
-
for (const attachment of attempt.attachments ?? []) {
|
|
263
|
-
const attachmentPath = filenameToPath.get(attachment.id);
|
|
264
|
-
if (!attachmentPath) {
|
|
265
|
-
missingAttachments.add(attachment.id);
|
|
266
|
-
} else {
|
|
267
|
-
attachmentIdToPath.set(attachment.id, {
|
|
268
|
-
contentType: attachment.contentType,
|
|
269
|
-
id: attachment.id,
|
|
270
|
-
path: attachmentPath
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
});
|
|
276
|
-
return { attachmentIdToPath, missingAttachments: Array.from(missingAttachments) };
|
|
277
|
-
}
|
|
278
|
-
async function listFilesRecursively(dir, result = []) {
|
|
279
|
-
const entries = await fs2.promises.readdir(dir, { withFileTypes: true });
|
|
280
|
-
for (const entry of entries) {
|
|
281
|
-
const fullPath = path.join(dir, entry.name);
|
|
282
|
-
if (entry.isDirectory())
|
|
283
|
-
await listFilesRecursively(fullPath, result);
|
|
284
|
-
else
|
|
285
|
-
result.push(fullPath);
|
|
286
|
-
}
|
|
287
|
-
return result;
|
|
288
|
-
}
|
|
289
89
|
function computeGitRoot(somePathInsideGitRepo) {
|
|
290
90
|
const root = shell(`git`, ["rev-parse", "--show-toplevel"], {
|
|
291
91
|
cwd: somePathInsideGitRepo,
|
|
@@ -294,224 +94,22 @@ function computeGitRoot(somePathInsideGitRepo) {
|
|
|
294
94
|
assert(root, `FAILED: git rev-parse --show-toplevel HEAD @ ${somePathInsideGitRepo}`);
|
|
295
95
|
return normalizePath(root);
|
|
296
96
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
function
|
|
300
|
-
|
|
301
|
-
aPath = aPath.split(win32Path.sep).join(posixPath.sep);
|
|
302
|
-
}
|
|
303
|
-
if (IS_ALMOST_POSIX_PATH.test(aPath))
|
|
304
|
-
return "/" + aPath[0] + aPath.substring(2);
|
|
305
|
-
return aPath;
|
|
306
|
-
}
|
|
307
|
-
function gitFilePath(gitRoot, absolutePath) {
|
|
308
|
-
return posixPath.relative(gitRoot, absolutePath);
|
|
97
|
+
|
|
98
|
+
// src/flakinessProjectConfig.ts
|
|
99
|
+
function createConfigPath(dir) {
|
|
100
|
+
return path.join(dir, ".flakiness", "config.json");
|
|
309
101
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
return value | 0;
|
|
102
|
+
var gConfigPath;
|
|
103
|
+
function ensureConfigPath() {
|
|
104
|
+
if (!gConfigPath)
|
|
105
|
+
gConfigPath = computeConfigPath();
|
|
106
|
+
return gConfigPath;
|
|
316
107
|
}
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
for (const project of projects) {
|
|
323
|
-
let defaultName = project.name;
|
|
324
|
-
if (!defaultName.trim())
|
|
325
|
-
defaultName = "anonymous";
|
|
326
|
-
let name = defaultName;
|
|
327
|
-
for (let i = 2; uniqueNames.has(name); ++i)
|
|
328
|
-
name = `${defaultName}-${i}`;
|
|
329
|
-
uniqueNames.add(defaultName);
|
|
330
|
-
result.set(project, {
|
|
331
|
-
name,
|
|
332
|
-
systemData: {
|
|
333
|
-
osArch: osInfo.arch,
|
|
334
|
-
osName: osInfo.name,
|
|
335
|
-
osVersion: osInfo.version
|
|
336
|
-
},
|
|
337
|
-
userSuppliedData: {
|
|
338
|
-
...envConfiguration,
|
|
339
|
-
...project.metadata
|
|
340
|
-
},
|
|
341
|
-
opaqueData: {
|
|
342
|
-
project
|
|
343
|
-
}
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
return result;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// src/serverapi.ts
|
|
350
|
-
function createServerAPI(endpoint, options) {
|
|
351
|
-
endpoint += "/api/";
|
|
352
|
-
const fetcher = options?.auth ? (url, init) => fetch(url, {
|
|
353
|
-
...init,
|
|
354
|
-
headers: {
|
|
355
|
-
...init.headers,
|
|
356
|
-
"Authorization": `Bearer ${options.auth}`
|
|
357
|
-
}
|
|
358
|
-
}) : fetch;
|
|
359
|
-
if (options?.retries)
|
|
360
|
-
return TypedHTTP.createClient(endpoint, (url, init) => retryWithBackoff(() => fetcher(url, init), options.retries));
|
|
361
|
-
return TypedHTTP.createClient(endpoint, fetcher);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// src/reportUploader.ts
|
|
365
|
-
var ReportUploader = class _ReportUploader {
|
|
366
|
-
static optionsFromEnv(overrides) {
|
|
367
|
-
const flakinessAccessToken = overrides?.flakinessAccessToken ?? process.env["FLAKINESS_ACCESS_TOKEN"];
|
|
368
|
-
if (!flakinessAccessToken)
|
|
369
|
-
return void 0;
|
|
370
|
-
const flakinessEndpoint = overrides?.flakinessEndpoint ?? process.env["FLAKINESS_ENDPOINT"] ?? "https://flakiness.io";
|
|
371
|
-
return { flakinessAccessToken, flakinessEndpoint };
|
|
372
|
-
}
|
|
373
|
-
static async upload(options) {
|
|
374
|
-
const uploaderOptions = _ReportUploader.optionsFromEnv(options);
|
|
375
|
-
if (!uploaderOptions) {
|
|
376
|
-
if (process.env.CI)
|
|
377
|
-
options.log?.(`[flakiness.io] Uploading skipped since no FLAKINESS_ACCESS_TOKEN is specified`);
|
|
378
|
-
return void 0;
|
|
379
|
-
}
|
|
380
|
-
const uploader = new _ReportUploader(uploaderOptions);
|
|
381
|
-
const upload = uploader.createUpload(options.report, options.attachments);
|
|
382
|
-
const uploadResult = await upload.upload();
|
|
383
|
-
if (!uploadResult.success) {
|
|
384
|
-
options.log?.(`[flakiness.io] X Failed to upload to ${uploaderOptions.flakinessEndpoint}: ${uploadResult.message}`);
|
|
385
|
-
return { errorMessage: uploadResult.message };
|
|
386
|
-
}
|
|
387
|
-
options.log?.(`[flakiness.io] \u2713 Report uploaded ${uploadResult.message ?? ""}`);
|
|
388
|
-
if (uploadResult.reportUrl)
|
|
389
|
-
options.log?.(`[flakiness.io] ${uploadResult.reportUrl}`);
|
|
390
|
-
}
|
|
391
|
-
_options;
|
|
392
|
-
constructor(options) {
|
|
393
|
-
this._options = options;
|
|
394
|
-
}
|
|
395
|
-
createUpload(report, attachments) {
|
|
396
|
-
const upload = new ReportUpload(this._options, report, attachments);
|
|
397
|
-
return upload;
|
|
398
|
-
}
|
|
399
|
-
};
|
|
400
|
-
var HTTP_BACKOFF = [100, 500, 1e3, 1e3, 1e3, 1e3];
|
|
401
|
-
var ReportUpload = class {
|
|
402
|
-
_report;
|
|
403
|
-
_attachments;
|
|
404
|
-
_options;
|
|
405
|
-
_api;
|
|
406
|
-
constructor(options, report, attachments) {
|
|
407
|
-
this._options = options;
|
|
408
|
-
this._report = report;
|
|
409
|
-
this._attachments = attachments;
|
|
410
|
-
this._api = createServerAPI(this._options.flakinessEndpoint, { retries: HTTP_BACKOFF, auth: this._options.flakinessAccessToken });
|
|
411
|
-
}
|
|
412
|
-
async upload(options) {
|
|
413
|
-
const response = await this._api.run.startUpload.POST({
|
|
414
|
-
attachmentIds: this._attachments.map((attachment) => attachment.id)
|
|
415
|
-
}).then((result) => ({ result, error: void 0 })).catch((e) => ({ result: void 0, error: e }));
|
|
416
|
-
if (response?.error || !response.result)
|
|
417
|
-
return { success: false, message: `flakiness.io returned error: ${response.error.message}` };
|
|
418
|
-
await Promise.all([
|
|
419
|
-
this._uploadReport(JSON.stringify(this._report), response.result.report_upload_url, options?.syncCompression ?? false),
|
|
420
|
-
...this._attachments.map((attachment) => {
|
|
421
|
-
const uploadURL = response.result.attachment_upload_urls[attachment.id];
|
|
422
|
-
if (!uploadURL)
|
|
423
|
-
throw new Error("Internal error: missing upload URL for attachment!");
|
|
424
|
-
return this._uploadAttachment(attachment, uploadURL, options?.syncCompression ?? false);
|
|
425
|
-
})
|
|
426
|
-
]);
|
|
427
|
-
const response2 = await this._api.run.completeUpload.POST({
|
|
428
|
-
upload_token: response.result.upload_token
|
|
429
|
-
}).then((result) => ({ result, error: void 0 })).catch((e) => ({ error: e, result: void 0 }));
|
|
430
|
-
const url = response2?.result?.report_url ? new URL2(response2?.result.report_url, this._options.flakinessEndpoint).toString() : void 0;
|
|
431
|
-
return { success: true, reportUrl: url };
|
|
432
|
-
}
|
|
433
|
-
async _uploadReport(data, uploadUrl, syncCompression) {
|
|
434
|
-
const compressed = syncCompression ? compressTextSync(data) : await compressTextAsync(data);
|
|
435
|
-
const headers = {
|
|
436
|
-
"Content-Type": "application/json",
|
|
437
|
-
"Content-Length": Buffer.byteLength(compressed) + "",
|
|
438
|
-
"Content-Encoding": "br"
|
|
439
|
-
};
|
|
440
|
-
await retryWithBackoff(async () => {
|
|
441
|
-
const { request, responseDataPromise } = httpUtils.createRequest({
|
|
442
|
-
url: uploadUrl,
|
|
443
|
-
headers,
|
|
444
|
-
method: "put"
|
|
445
|
-
});
|
|
446
|
-
request.write(compressed);
|
|
447
|
-
request.end();
|
|
448
|
-
await responseDataPromise;
|
|
449
|
-
}, HTTP_BACKOFF);
|
|
450
|
-
}
|
|
451
|
-
async _uploadAttachment(attachment, uploadUrl, syncCompression) {
|
|
452
|
-
const mimeType = attachment.contentType.toLocaleLowerCase().trim();
|
|
453
|
-
const compressable = mimeType.startsWith("text/") || mimeType.endsWith("+json") || mimeType.endsWith("+text") || mimeType.endsWith("+xml");
|
|
454
|
-
if (!compressable && attachment.path) {
|
|
455
|
-
const attachmentPath = attachment.path;
|
|
456
|
-
await retryWithBackoff(async () => {
|
|
457
|
-
const { request, responseDataPromise } = httpUtils.createRequest({
|
|
458
|
-
url: uploadUrl,
|
|
459
|
-
headers: {
|
|
460
|
-
"Content-Type": attachment.contentType,
|
|
461
|
-
"Content-Length": (await fs3.promises.stat(attachmentPath)).size + ""
|
|
462
|
-
},
|
|
463
|
-
method: "put"
|
|
464
|
-
});
|
|
465
|
-
fs3.createReadStream(attachmentPath).pipe(request);
|
|
466
|
-
await responseDataPromise;
|
|
467
|
-
}, HTTP_BACKOFF);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
let buffer = attachment.body ? attachment.body : attachment.path ? await fs3.promises.readFile(attachment.path) : void 0;
|
|
471
|
-
assert2(buffer);
|
|
472
|
-
const encoding = compressable ? "br" : void 0;
|
|
473
|
-
if (compressable)
|
|
474
|
-
buffer = syncCompression ? compressTextSync(buffer) : await compressTextAsync(buffer);
|
|
475
|
-
const headers = {
|
|
476
|
-
"Content-Type": attachment.contentType,
|
|
477
|
-
"Content-Length": Buffer.byteLength(buffer) + "",
|
|
478
|
-
"Content-Encoding": encoding
|
|
479
|
-
};
|
|
480
|
-
await retryWithBackoff(async () => {
|
|
481
|
-
const { request, responseDataPromise } = httpUtils.createRequest({
|
|
482
|
-
url: uploadUrl,
|
|
483
|
-
headers,
|
|
484
|
-
method: "put"
|
|
485
|
-
});
|
|
486
|
-
request.write(buffer);
|
|
487
|
-
request.end();
|
|
488
|
-
await responseDataPromise;
|
|
489
|
-
}, HTTP_BACKOFF);
|
|
490
|
-
}
|
|
491
|
-
};
|
|
492
|
-
|
|
493
|
-
// src/showReport.ts
|
|
494
|
-
import chalk from "chalk";
|
|
495
|
-
import open from "open";
|
|
496
|
-
import path4 from "path";
|
|
497
|
-
|
|
498
|
-
// src/flakinessProjectConfig.ts
|
|
499
|
-
import fs4 from "fs";
|
|
500
|
-
import path2 from "path";
|
|
501
|
-
function createConfigPath(dir) {
|
|
502
|
-
return path2.join(dir, ".flakiness", "config.json");
|
|
503
|
-
}
|
|
504
|
-
var gConfigPath;
|
|
505
|
-
function ensureConfigPath() {
|
|
506
|
-
if (!gConfigPath)
|
|
507
|
-
gConfigPath = computeConfigPath();
|
|
508
|
-
return gConfigPath;
|
|
509
|
-
}
|
|
510
|
-
function computeConfigPath() {
|
|
511
|
-
for (let p = process.cwd(); p !== path2.resolve(p, ".."); p = path2.resolve(p, "..")) {
|
|
512
|
-
const configPath = createConfigPath(p);
|
|
513
|
-
if (fs4.existsSync(configPath))
|
|
514
|
-
return configPath;
|
|
108
|
+
function computeConfigPath() {
|
|
109
|
+
for (let p = process.cwd(); p !== path.resolve(p, ".."); p = path.resolve(p, "..")) {
|
|
110
|
+
const configPath = createConfigPath(p);
|
|
111
|
+
if (fs.existsSync(configPath))
|
|
112
|
+
return configPath;
|
|
515
113
|
}
|
|
516
114
|
try {
|
|
517
115
|
const gitRoot = computeGitRoot(process.cwd());
|
|
@@ -527,7 +125,7 @@ var FlakinessProjectConfig = class _FlakinessProjectConfig {
|
|
|
527
125
|
}
|
|
528
126
|
static async load() {
|
|
529
127
|
const configPath = ensureConfigPath();
|
|
530
|
-
const data = await
|
|
128
|
+
const data = await fs.promises.readFile(configPath, "utf-8").catch((e) => void 0);
|
|
531
129
|
const json = data ? JSON.parse(data) : {};
|
|
532
130
|
return new _FlakinessProjectConfig(configPath, json);
|
|
533
131
|
}
|
|
@@ -547,31 +145,33 @@ var FlakinessProjectConfig = class _FlakinessProjectConfig {
|
|
|
547
145
|
this._config.projectPublicId = projectId;
|
|
548
146
|
}
|
|
549
147
|
async save() {
|
|
550
|
-
await
|
|
551
|
-
await
|
|
148
|
+
await fs.promises.mkdir(path.dirname(this._configPath), { recursive: true });
|
|
149
|
+
await fs.promises.writeFile(this._configPath, JSON.stringify(this._config, null, 2));
|
|
552
150
|
}
|
|
553
151
|
};
|
|
554
152
|
|
|
555
153
|
// src/localReportServer.ts
|
|
556
|
-
import { TypedHTTP as
|
|
154
|
+
import { TypedHTTP as TypedHTTP2 } from "@flakiness/shared/common/typedHttp.js";
|
|
557
155
|
import { randomUUIDBase62 } from "@flakiness/shared/node/nodeutils.js";
|
|
558
156
|
import { createTypedHttpExpressMiddleware } from "@flakiness/shared/node/typedHttpExpress.js";
|
|
559
157
|
import bodyParser from "body-parser";
|
|
560
158
|
import compression from "compression";
|
|
561
|
-
import
|
|
159
|
+
import debug2 from "debug";
|
|
562
160
|
import express from "express";
|
|
563
161
|
import "express-async-errors";
|
|
564
|
-
import
|
|
162
|
+
import http from "http";
|
|
565
163
|
|
|
566
164
|
// src/localReportApi.ts
|
|
567
|
-
import { TypedHTTP
|
|
568
|
-
import
|
|
569
|
-
import
|
|
165
|
+
import { TypedHTTP } from "@flakiness/shared/common/typedHttp.js";
|
|
166
|
+
import fs2 from "fs";
|
|
167
|
+
import path2 from "path";
|
|
570
168
|
import { z } from "zod/v4";
|
|
571
169
|
|
|
572
170
|
// src/localGit.ts
|
|
573
171
|
import { exec } from "child_process";
|
|
172
|
+
import debug from "debug";
|
|
574
173
|
import { promisify } from "util";
|
|
174
|
+
var log = debug("fk:git");
|
|
575
175
|
var execAsync = promisify(exec);
|
|
576
176
|
async function listLocalCommits(gitRoot, head, count) {
|
|
577
177
|
const FIELD_SEPARATOR = "|~|";
|
|
@@ -607,11 +207,131 @@ async function listLocalCommits(gitRoot, head, count) {
|
|
|
607
207
|
};
|
|
608
208
|
});
|
|
609
209
|
} catch (error) {
|
|
610
|
-
|
|
611
|
-
|
|
210
|
+
log(`Failed to list commits for repository at ${gitRoot}:`, error);
|
|
211
|
+
return [];
|
|
612
212
|
}
|
|
613
213
|
}
|
|
614
214
|
|
|
215
|
+
// src/reportUtils.ts
|
|
216
|
+
import { Multimap } from "@flakiness/shared/common/multimap.js";
|
|
217
|
+
import { xxHash, xxHashObject } from "@flakiness/shared/common/utils.js";
|
|
218
|
+
var ReportUtils;
|
|
219
|
+
((ReportUtils2) => {
|
|
220
|
+
function visitTests(report, testVisitor) {
|
|
221
|
+
function visitSuite(suite, parents) {
|
|
222
|
+
parents.push(suite);
|
|
223
|
+
for (const test of suite.tests ?? [])
|
|
224
|
+
testVisitor(test, parents);
|
|
225
|
+
for (const childSuite of suite.suites ?? [])
|
|
226
|
+
visitSuite(childSuite, parents);
|
|
227
|
+
parents.pop();
|
|
228
|
+
}
|
|
229
|
+
for (const test of report.tests ?? [])
|
|
230
|
+
testVisitor(test, []);
|
|
231
|
+
for (const suite of report.suites)
|
|
232
|
+
visitSuite(suite, []);
|
|
233
|
+
}
|
|
234
|
+
ReportUtils2.visitTests = visitTests;
|
|
235
|
+
function normalizeReport(report) {
|
|
236
|
+
const gEnvs = /* @__PURE__ */ new Map();
|
|
237
|
+
const gSuites = /* @__PURE__ */ new Map();
|
|
238
|
+
const gTests = new Multimap();
|
|
239
|
+
const gSuiteIds = /* @__PURE__ */ new Map();
|
|
240
|
+
const gTestIds = /* @__PURE__ */ new Map();
|
|
241
|
+
const gEnvIds = /* @__PURE__ */ new Map();
|
|
242
|
+
const gSuiteChildren = new Multimap();
|
|
243
|
+
const gSuiteTests = new Multimap();
|
|
244
|
+
for (const env of report.environments) {
|
|
245
|
+
const envId = computeEnvId(env);
|
|
246
|
+
gEnvs.set(envId, env);
|
|
247
|
+
gEnvIds.set(env, envId);
|
|
248
|
+
}
|
|
249
|
+
const usedEnvIds = /* @__PURE__ */ new Set();
|
|
250
|
+
function visitTests2(tests, suiteId) {
|
|
251
|
+
for (const test of tests ?? []) {
|
|
252
|
+
const testId = computeTestId(test, suiteId);
|
|
253
|
+
gTests.set(testId, test);
|
|
254
|
+
gTestIds.set(test, testId);
|
|
255
|
+
gSuiteTests.set(suiteId, test);
|
|
256
|
+
for (const attempt of test.attempts) {
|
|
257
|
+
const env = report.environments[attempt.environmentIdx];
|
|
258
|
+
const envId = gEnvIds.get(env);
|
|
259
|
+
usedEnvIds.add(envId);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function visitSuite(suite, parentSuiteId) {
|
|
264
|
+
const suiteId = computeSuiteId(suite, parentSuiteId);
|
|
265
|
+
gSuites.set(suiteId, suite);
|
|
266
|
+
gSuiteIds.set(suite, suiteId);
|
|
267
|
+
for (const childSuite of suite.suites ?? []) {
|
|
268
|
+
visitSuite(childSuite, suiteId);
|
|
269
|
+
gSuiteChildren.set(suiteId, childSuite);
|
|
270
|
+
}
|
|
271
|
+
visitTests2(suite.tests ?? [], suiteId);
|
|
272
|
+
}
|
|
273
|
+
function transformTests(tests) {
|
|
274
|
+
const testIds = new Set(tests.map((test) => gTestIds.get(test)));
|
|
275
|
+
return [...testIds].map((testId) => {
|
|
276
|
+
const tests2 = gTests.getAll(testId);
|
|
277
|
+
const tags = tests2.map((test) => test.tags ?? []).flat();
|
|
278
|
+
return {
|
|
279
|
+
location: tests2[0].location,
|
|
280
|
+
title: tests2[0].title,
|
|
281
|
+
tags: tags.length ? tags : void 0,
|
|
282
|
+
attempts: tests2.map((t2) => t2.attempts).flat().map((attempt) => ({
|
|
283
|
+
...attempt,
|
|
284
|
+
environmentIdx: envIdToIndex.get(gEnvIds.get(report.environments[attempt.environmentIdx]))
|
|
285
|
+
}))
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function transformSuites(suites) {
|
|
290
|
+
const suiteIds = new Set(suites.map((suite) => gSuiteIds.get(suite)));
|
|
291
|
+
return [...suiteIds].map((suiteId) => {
|
|
292
|
+
const suite = gSuites.get(suiteId);
|
|
293
|
+
return {
|
|
294
|
+
location: suite.location,
|
|
295
|
+
title: suite.title,
|
|
296
|
+
type: suite.type,
|
|
297
|
+
suites: transformSuites(gSuiteChildren.getAll(suiteId)),
|
|
298
|
+
tests: transformTests(gSuiteTests.getAll(suiteId))
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
visitTests2(report.tests ?? [], "suiteless");
|
|
303
|
+
for (const suite of report.suites)
|
|
304
|
+
visitSuite(suite);
|
|
305
|
+
const newEnvironments = [...usedEnvIds];
|
|
306
|
+
const envIdToIndex = new Map(newEnvironments.map((envId, index) => [envId, index]));
|
|
307
|
+
return {
|
|
308
|
+
...report,
|
|
309
|
+
environments: newEnvironments.map((envId) => gEnvs.get(envId)),
|
|
310
|
+
suites: transformSuites(report.suites),
|
|
311
|
+
tests: transformTests(report.tests ?? [])
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
ReportUtils2.normalizeReport = normalizeReport;
|
|
315
|
+
function computeEnvId(env) {
|
|
316
|
+
return xxHashObject(env);
|
|
317
|
+
}
|
|
318
|
+
function computeSuiteId(suite, parentSuiteId) {
|
|
319
|
+
return xxHash([
|
|
320
|
+
parentSuiteId ?? "",
|
|
321
|
+
suite.type,
|
|
322
|
+
suite.location?.file ?? "",
|
|
323
|
+
suite.title
|
|
324
|
+
]);
|
|
325
|
+
}
|
|
326
|
+
function computeTestId(test, suiteId) {
|
|
327
|
+
return xxHash([
|
|
328
|
+
suiteId,
|
|
329
|
+
test.location?.file ?? "",
|
|
330
|
+
test.title
|
|
331
|
+
]);
|
|
332
|
+
}
|
|
333
|
+
})(ReportUtils || (ReportUtils = {}));
|
|
334
|
+
|
|
615
335
|
// src/localReportApi.ts
|
|
616
336
|
var ReportInfo = class {
|
|
617
337
|
constructor(_options) {
|
|
@@ -621,7 +341,7 @@ var ReportInfo = class {
|
|
|
621
341
|
attachmentIdToPath = /* @__PURE__ */ new Map();
|
|
622
342
|
commits = [];
|
|
623
343
|
async refresh() {
|
|
624
|
-
const report = await
|
|
344
|
+
const report = await fs2.promises.readFile(this._options.reportPath, "utf-8").then((x) => JSON.parse(x)).catch((e) => void 0);
|
|
625
345
|
if (!report) {
|
|
626
346
|
this.report = void 0;
|
|
627
347
|
this.commits = [];
|
|
@@ -631,7 +351,7 @@ var ReportInfo = class {
|
|
|
631
351
|
if (JSON.stringify(report) === JSON.stringify(this.report))
|
|
632
352
|
return;
|
|
633
353
|
this.report = report;
|
|
634
|
-
this.commits = await listLocalCommits(
|
|
354
|
+
this.commits = await listLocalCommits(path2.dirname(this._options.reportPath), report.commitId, 100);
|
|
635
355
|
const attachmentsDir = this._options.attachmentsFolder;
|
|
636
356
|
const { attachmentIdToPath, missingAttachments } = await resolveAttachmentPaths(report, attachmentsDir);
|
|
637
357
|
if (missingAttachments.length) {
|
|
@@ -644,7 +364,7 @@ var ReportInfo = class {
|
|
|
644
364
|
this.attachmentIdToPath = attachmentIdToPath;
|
|
645
365
|
}
|
|
646
366
|
};
|
|
647
|
-
var t =
|
|
367
|
+
var t = TypedHTTP.Router.create();
|
|
648
368
|
var localReportRouter = {
|
|
649
369
|
ping: t.get({
|
|
650
370
|
handler: async () => {
|
|
@@ -655,125 +375,513 @@ var localReportRouter = {
|
|
|
655
375
|
handler: async ({ ctx }) => {
|
|
656
376
|
return ctx.reportInfo.commits;
|
|
657
377
|
}
|
|
658
|
-
}),
|
|
659
|
-
report: {
|
|
660
|
-
attachment: t.rawMethod("GET", {
|
|
661
|
-
input: z.object({
|
|
662
|
-
attachmentId: z.string().min(1).max(100).transform((id) => id)
|
|
663
|
-
}),
|
|
664
|
-
handler: async ({ ctx, input }) => {
|
|
665
|
-
const idx = ctx.reportInfo.attachmentIdToPath.get(input.attachmentId);
|
|
666
|
-
if (!idx)
|
|
667
|
-
throw
|
|
668
|
-
const buffer = await
|
|
669
|
-
return
|
|
670
|
-
}
|
|
671
|
-
}),
|
|
672
|
-
json: t.get({
|
|
673
|
-
handler: async ({ ctx }) => {
|
|
674
|
-
await ctx.reportInfo.refresh();
|
|
675
|
-
return ctx.reportInfo.report;
|
|
676
|
-
}
|
|
677
|
-
})
|
|
378
|
+
}),
|
|
379
|
+
report: {
|
|
380
|
+
attachment: t.rawMethod("GET", {
|
|
381
|
+
input: z.object({
|
|
382
|
+
attachmentId: z.string().min(1).max(100).transform((id) => id)
|
|
383
|
+
}),
|
|
384
|
+
handler: async ({ ctx, input }) => {
|
|
385
|
+
const idx = ctx.reportInfo.attachmentIdToPath.get(input.attachmentId);
|
|
386
|
+
if (!idx)
|
|
387
|
+
throw TypedHTTP.HttpError.withCode("NOT_FOUND");
|
|
388
|
+
const buffer = await fs2.promises.readFile(idx.path);
|
|
389
|
+
return TypedHTTP.ok(buffer, idx.contentType);
|
|
390
|
+
}
|
|
391
|
+
}),
|
|
392
|
+
json: t.get({
|
|
393
|
+
handler: async ({ ctx }) => {
|
|
394
|
+
await ctx.reportInfo.refresh();
|
|
395
|
+
return ctx.reportInfo.report;
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
async function resolveAttachmentPaths(report, attachmentsDir) {
|
|
401
|
+
const attachmentFiles = await listFilesRecursively(attachmentsDir);
|
|
402
|
+
const filenameToPath = new Map(attachmentFiles.map((file) => [path2.basename(file), file]));
|
|
403
|
+
const attachmentIdToPath = /* @__PURE__ */ new Map();
|
|
404
|
+
const missingAttachments = /* @__PURE__ */ new Set();
|
|
405
|
+
ReportUtils.visitTests(report, (test) => {
|
|
406
|
+
for (const attempt of test.attempts) {
|
|
407
|
+
for (const attachment of attempt.attachments ?? []) {
|
|
408
|
+
const attachmentPath = filenameToPath.get(attachment.id);
|
|
409
|
+
if (!attachmentPath) {
|
|
410
|
+
missingAttachments.add(attachment.id);
|
|
411
|
+
} else {
|
|
412
|
+
attachmentIdToPath.set(attachment.id, {
|
|
413
|
+
contentType: attachment.contentType,
|
|
414
|
+
id: attachment.id,
|
|
415
|
+
path: attachmentPath
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
return { attachmentIdToPath, missingAttachments: Array.from(missingAttachments) };
|
|
422
|
+
}
|
|
423
|
+
async function listFilesRecursively(dir, result = []) {
|
|
424
|
+
const entries = await fs2.promises.readdir(dir, { withFileTypes: true });
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
const fullPath = path2.join(dir, entry.name);
|
|
427
|
+
if (entry.isDirectory())
|
|
428
|
+
await listFilesRecursively(fullPath, result);
|
|
429
|
+
else
|
|
430
|
+
result.push(fullPath);
|
|
431
|
+
}
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/localReportServer.ts
|
|
436
|
+
var logHTTPServer = debug2("fk:http");
|
|
437
|
+
var LocalReportServer = class _LocalReportServer {
|
|
438
|
+
constructor(_server, _port, _authToken) {
|
|
439
|
+
this._server = _server;
|
|
440
|
+
this._port = _port;
|
|
441
|
+
this._authToken = _authToken;
|
|
442
|
+
}
|
|
443
|
+
static async create(options) {
|
|
444
|
+
const app = express();
|
|
445
|
+
app.set("etag", false);
|
|
446
|
+
const authToken = randomUUIDBase62();
|
|
447
|
+
app.use(compression());
|
|
448
|
+
app.use(bodyParser.json({ limit: 256 * 1024 }));
|
|
449
|
+
app.use((req, res, next) => {
|
|
450
|
+
if (!req.path.startsWith("/" + authToken))
|
|
451
|
+
throw TypedHTTP2.HttpError.withCode("UNAUTHORIZED");
|
|
452
|
+
res.setHeader("Access-Control-Allow-Headers", "*");
|
|
453
|
+
res.setHeader("Access-Control-Allow-Origin", options.endpoint);
|
|
454
|
+
res.setHeader("Access-Control-Allow-Methods", "*");
|
|
455
|
+
if (req.method === "OPTIONS") {
|
|
456
|
+
res.writeHead(204);
|
|
457
|
+
res.end();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
req.on("aborted", () => logHTTPServer(`REQ ABORTED ${req.method} ${req.originalUrl}`));
|
|
461
|
+
res.on("close", () => {
|
|
462
|
+
if (!res.headersSent) logHTTPServer(`RES CLOSED BEFORE SEND ${req.method} ${req.originalUrl}`);
|
|
463
|
+
});
|
|
464
|
+
next();
|
|
465
|
+
});
|
|
466
|
+
const reportInfo = new ReportInfo(options);
|
|
467
|
+
app.use("/" + authToken, createTypedHttpExpressMiddleware({
|
|
468
|
+
router: localReportRouter,
|
|
469
|
+
createRootContext: async ({ req, res, input }) => ({ reportInfo })
|
|
470
|
+
}));
|
|
471
|
+
app.use((err, req, res, next) => {
|
|
472
|
+
if (err instanceof TypedHTTP2.HttpError)
|
|
473
|
+
return res.status(err.status).send({ error: err.message });
|
|
474
|
+
logHTTPServer(err);
|
|
475
|
+
res.status(500).send({ error: "Internal Server Error" });
|
|
476
|
+
});
|
|
477
|
+
const server = http.createServer(app);
|
|
478
|
+
server.on("error", (err) => {
|
|
479
|
+
if (err.code === "ECONNRESET") {
|
|
480
|
+
logHTTPServer("Client connection reset. Ignoring.");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
throw err;
|
|
484
|
+
});
|
|
485
|
+
const port = await new Promise((resolve) => server.listen(options.port, () => {
|
|
486
|
+
resolve(server.address().port);
|
|
487
|
+
}));
|
|
488
|
+
return new _LocalReportServer(server, port, authToken);
|
|
489
|
+
}
|
|
490
|
+
authToken() {
|
|
491
|
+
return this._authToken;
|
|
492
|
+
}
|
|
493
|
+
port() {
|
|
494
|
+
return this._port;
|
|
495
|
+
}
|
|
496
|
+
async dispose() {
|
|
497
|
+
await new Promise((x) => this._server.close(x));
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// src/index.ts
|
|
502
|
+
import { FlakinessReport } from "@flakiness/flakiness-report";
|
|
503
|
+
|
|
504
|
+
// src/createEnvironment.ts
|
|
505
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
506
|
+
import fs3 from "fs";
|
|
507
|
+
import os from "os";
|
|
508
|
+
function shell2(command, args, options) {
|
|
509
|
+
try {
|
|
510
|
+
const result = spawnSync2(command, args, { encoding: "utf-8", ...options });
|
|
511
|
+
if (result.status !== 0) {
|
|
512
|
+
return void 0;
|
|
513
|
+
}
|
|
514
|
+
return result.stdout.trim();
|
|
515
|
+
} catch (e) {
|
|
516
|
+
console.error(e);
|
|
517
|
+
return void 0;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function readLinuxOSRelease() {
|
|
521
|
+
const osReleaseText = fs3.readFileSync("/etc/os-release", "utf-8");
|
|
522
|
+
return new Map(osReleaseText.toLowerCase().split("\n").filter((line) => line.includes("=")).map((line) => {
|
|
523
|
+
line = line.trim();
|
|
524
|
+
let [key, value] = line.split("=");
|
|
525
|
+
if (value.startsWith('"') && value.endsWith('"'))
|
|
526
|
+
value = value.substring(1, value.length - 1);
|
|
527
|
+
return [key, value];
|
|
528
|
+
}));
|
|
529
|
+
}
|
|
530
|
+
function osLinuxInfo() {
|
|
531
|
+
const arch = shell2(`uname`, [`-m`]);
|
|
532
|
+
const osReleaseMap = readLinuxOSRelease();
|
|
533
|
+
const name = osReleaseMap.get("name") ?? shell2(`uname`);
|
|
534
|
+
const version = osReleaseMap.get("version_id");
|
|
535
|
+
return { name, arch, version };
|
|
536
|
+
}
|
|
537
|
+
function osDarwinInfo() {
|
|
538
|
+
const name = "macos";
|
|
539
|
+
const arch = shell2(`uname`, [`-m`]);
|
|
540
|
+
const version = shell2(`sw_vers`, [`-productVersion`]);
|
|
541
|
+
return { name, arch, version };
|
|
542
|
+
}
|
|
543
|
+
function osWinInfo() {
|
|
544
|
+
const name = "win";
|
|
545
|
+
const arch = process.arch;
|
|
546
|
+
const version = os.release();
|
|
547
|
+
return { name, arch, version };
|
|
548
|
+
}
|
|
549
|
+
function getOSInfo() {
|
|
550
|
+
if (process.platform === "darwin")
|
|
551
|
+
return osDarwinInfo();
|
|
552
|
+
if (process.platform === "win32")
|
|
553
|
+
return osWinInfo();
|
|
554
|
+
return osLinuxInfo();
|
|
555
|
+
}
|
|
556
|
+
function extractEnvConfiguration() {
|
|
557
|
+
const ENV_PREFIX = "FK_ENV_";
|
|
558
|
+
return Object.fromEntries(
|
|
559
|
+
Object.entries(process.env).filter(([key]) => key.toUpperCase().startsWith(ENV_PREFIX.toUpperCase())).map(([key, value]) => [key.substring(ENV_PREFIX.length).toLowerCase(), (value ?? "").trim().toLowerCase()])
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
function createEnvironment(options) {
|
|
563
|
+
const osInfo = getOSInfo();
|
|
564
|
+
return {
|
|
565
|
+
name: options.name,
|
|
566
|
+
systemData: {
|
|
567
|
+
osArch: osInfo.arch,
|
|
568
|
+
osName: osInfo.name,
|
|
569
|
+
osVersion: osInfo.version
|
|
570
|
+
},
|
|
571
|
+
userSuppliedData: {
|
|
572
|
+
...extractEnvConfiguration(),
|
|
573
|
+
...options.userSuppliedData ?? {}
|
|
574
|
+
},
|
|
575
|
+
opaqueData: options.opaqueData
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/createTestStepSnippets.ts
|
|
580
|
+
import { codeFrameColumns } from "@babel/code-frame";
|
|
581
|
+
import fs4 from "fs";
|
|
582
|
+
import { posix as posixPath2 } from "path";
|
|
583
|
+
function createTestStepSnippetsInplace(report, gitRoot) {
|
|
584
|
+
const allSteps = /* @__PURE__ */ new Map();
|
|
585
|
+
ReportUtils.visitTests(report, (test) => {
|
|
586
|
+
for (const attempt of test.attempts) {
|
|
587
|
+
for (const step of attempt.steps ?? []) {
|
|
588
|
+
if (!step.location)
|
|
589
|
+
continue;
|
|
590
|
+
let fileSteps = allSteps.get(step.location.file);
|
|
591
|
+
if (!fileSteps) {
|
|
592
|
+
fileSteps = /* @__PURE__ */ new Set();
|
|
593
|
+
allSteps.set(step.location.file, fileSteps);
|
|
594
|
+
}
|
|
595
|
+
fileSteps.add(step);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
for (const [gitFilePath2, steps] of allSteps) {
|
|
600
|
+
let source;
|
|
601
|
+
try {
|
|
602
|
+
source = fs4.readFileSync(posixPath2.join(gitRoot, gitFilePath2), "utf-8");
|
|
603
|
+
} catch (e) {
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const lines = source.split("\n").length;
|
|
607
|
+
const highlighted = codeFrameColumns(source, { start: { line: lines, column: 1 } }, { highlightCode: true, linesAbove: lines, linesBelow: 0 });
|
|
608
|
+
const highlightedLines = highlighted.split("\n");
|
|
609
|
+
const lineWithArrow = highlightedLines[highlightedLines.length - 1];
|
|
610
|
+
for (const step of steps) {
|
|
611
|
+
if (!step.location)
|
|
612
|
+
continue;
|
|
613
|
+
if (step.location.line < 2 || step.location.line >= lines)
|
|
614
|
+
continue;
|
|
615
|
+
const snippetLines = highlightedLines.slice(step.location.line - 2, step.location.line + 1);
|
|
616
|
+
const index = lineWithArrow.indexOf("^");
|
|
617
|
+
const shiftedArrow = lineWithArrow.slice(0, index) + " ".repeat(step.location.column - 1) + lineWithArrow.slice(index);
|
|
618
|
+
snippetLines.splice(2, 0, shiftedArrow);
|
|
619
|
+
step.snippet = snippetLines.join("\n");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// src/reportUploader.ts
|
|
625
|
+
import { compressTextAsync, compressTextSync } from "@flakiness/shared/node/compression.js";
|
|
626
|
+
import assert2 from "assert";
|
|
627
|
+
import crypto from "crypto";
|
|
628
|
+
import fs5 from "fs";
|
|
629
|
+
import { URL as URL2 } from "url";
|
|
630
|
+
|
|
631
|
+
// src/httpUtils.ts
|
|
632
|
+
import http2 from "http";
|
|
633
|
+
import https from "https";
|
|
634
|
+
var FLAKINESS_DBG2 = !!process.env.FLAKINESS_DBG;
|
|
635
|
+
var httpUtils;
|
|
636
|
+
((httpUtils2) => {
|
|
637
|
+
function createRequest({ url, method = "get", headers = {} }) {
|
|
638
|
+
let resolve;
|
|
639
|
+
let reject;
|
|
640
|
+
const responseDataPromise = new Promise((a, b) => {
|
|
641
|
+
resolve = a;
|
|
642
|
+
reject = b;
|
|
643
|
+
});
|
|
644
|
+
const protocol = url.startsWith("https") ? https : http2;
|
|
645
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([key, value]) => value !== void 0));
|
|
646
|
+
const request = protocol.request(url, { method, headers }, (res) => {
|
|
647
|
+
const chunks = [];
|
|
648
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
649
|
+
res.on("end", () => {
|
|
650
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300)
|
|
651
|
+
resolve(Buffer.concat(chunks));
|
|
652
|
+
else
|
|
653
|
+
reject(new Error(`Request to ${url} failed with ${res.statusCode}`));
|
|
654
|
+
});
|
|
655
|
+
res.on("error", (error) => reject(error));
|
|
656
|
+
});
|
|
657
|
+
request.on("error", reject);
|
|
658
|
+
return { request, responseDataPromise };
|
|
659
|
+
}
|
|
660
|
+
httpUtils2.createRequest = createRequest;
|
|
661
|
+
async function getBuffer(url, backoff) {
|
|
662
|
+
return await retryWithBackoff(async () => {
|
|
663
|
+
const { request, responseDataPromise } = createRequest({ url });
|
|
664
|
+
request.end();
|
|
665
|
+
return await responseDataPromise;
|
|
666
|
+
}, backoff);
|
|
667
|
+
}
|
|
668
|
+
httpUtils2.getBuffer = getBuffer;
|
|
669
|
+
async function getText(url, backoff) {
|
|
670
|
+
const buffer = await getBuffer(url, backoff);
|
|
671
|
+
return buffer.toString("utf-8");
|
|
672
|
+
}
|
|
673
|
+
httpUtils2.getText = getText;
|
|
674
|
+
async function getJSON(url) {
|
|
675
|
+
return JSON.parse(await getText(url));
|
|
676
|
+
}
|
|
677
|
+
httpUtils2.getJSON = getJSON;
|
|
678
|
+
async function postText(url, text, backoff) {
|
|
679
|
+
const headers = {
|
|
680
|
+
"Content-Type": "application/json",
|
|
681
|
+
"Content-Length": Buffer.byteLength(text) + ""
|
|
682
|
+
};
|
|
683
|
+
return await retryWithBackoff(async () => {
|
|
684
|
+
const { request, responseDataPromise } = createRequest({ url, headers, method: "post" });
|
|
685
|
+
request.write(text);
|
|
686
|
+
request.end();
|
|
687
|
+
return await responseDataPromise;
|
|
688
|
+
}, backoff);
|
|
689
|
+
}
|
|
690
|
+
httpUtils2.postText = postText;
|
|
691
|
+
async function postJSON(url, json, backoff) {
|
|
692
|
+
const buffer = await postText(url, JSON.stringify(json), backoff);
|
|
693
|
+
return JSON.parse(buffer.toString("utf-8"));
|
|
694
|
+
}
|
|
695
|
+
httpUtils2.postJSON = postJSON;
|
|
696
|
+
})(httpUtils || (httpUtils = {}));
|
|
697
|
+
|
|
698
|
+
// src/reportUploader.ts
|
|
699
|
+
function sha1File(filePath) {
|
|
700
|
+
return new Promise((resolve, reject) => {
|
|
701
|
+
const hash = crypto.createHash("sha1");
|
|
702
|
+
const stream = fs5.createReadStream(filePath);
|
|
703
|
+
stream.on("data", (chunk) => {
|
|
704
|
+
hash.update(chunk);
|
|
705
|
+
});
|
|
706
|
+
stream.on("end", () => {
|
|
707
|
+
resolve(hash.digest("hex"));
|
|
708
|
+
});
|
|
709
|
+
stream.on("error", (err) => {
|
|
710
|
+
reject(err);
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
async function createFileAttachment(contentType, filePath) {
|
|
715
|
+
return {
|
|
716
|
+
contentType,
|
|
717
|
+
id: await sha1File(filePath),
|
|
718
|
+
path: filePath
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
async function createDataAttachment(contentType, data) {
|
|
722
|
+
const hash = crypto.createHash("sha1");
|
|
723
|
+
hash.update(data);
|
|
724
|
+
const id = hash.digest("hex");
|
|
725
|
+
return {
|
|
726
|
+
contentType,
|
|
727
|
+
id,
|
|
728
|
+
body: data
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
var ReportUploader = class _ReportUploader {
|
|
732
|
+
static optionsFromEnv(overrides) {
|
|
733
|
+
const flakinessAccessToken = overrides?.flakinessAccessToken ?? process.env["FLAKINESS_ACCESS_TOKEN"];
|
|
734
|
+
if (!flakinessAccessToken)
|
|
735
|
+
return void 0;
|
|
736
|
+
const flakinessEndpoint = overrides?.flakinessEndpoint ?? process.env["FLAKINESS_ENDPOINT"] ?? "https://flakiness.io";
|
|
737
|
+
return { flakinessAccessToken, flakinessEndpoint };
|
|
738
|
+
}
|
|
739
|
+
static async upload(options) {
|
|
740
|
+
const uploaderOptions = _ReportUploader.optionsFromEnv(options);
|
|
741
|
+
if (!uploaderOptions) {
|
|
742
|
+
if (process.env.CI)
|
|
743
|
+
options.log?.(`[flakiness.io] Uploading skipped since no FLAKINESS_ACCESS_TOKEN is specified`);
|
|
744
|
+
return void 0;
|
|
745
|
+
}
|
|
746
|
+
const uploader = new _ReportUploader(uploaderOptions);
|
|
747
|
+
const upload = uploader.createUpload(options.report, options.attachments);
|
|
748
|
+
const uploadResult = await upload.upload();
|
|
749
|
+
if (!uploadResult.success) {
|
|
750
|
+
options.log?.(`[flakiness.io] X Failed to upload to ${uploaderOptions.flakinessEndpoint}: ${uploadResult.message}`);
|
|
751
|
+
return { errorMessage: uploadResult.message };
|
|
752
|
+
}
|
|
753
|
+
options.log?.(`[flakiness.io] \u2713 Report uploaded ${uploadResult.message ?? ""}`);
|
|
754
|
+
if (uploadResult.reportUrl)
|
|
755
|
+
options.log?.(`[flakiness.io] ${uploadResult.reportUrl}`);
|
|
756
|
+
}
|
|
757
|
+
_options;
|
|
758
|
+
constructor(options) {
|
|
759
|
+
this._options = options;
|
|
760
|
+
}
|
|
761
|
+
createUpload(report, attachments) {
|
|
762
|
+
const upload = new ReportUpload(this._options, report, attachments);
|
|
763
|
+
return upload;
|
|
678
764
|
}
|
|
679
765
|
};
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
this.
|
|
687
|
-
this.
|
|
766
|
+
var HTTP_BACKOFF = [100, 500, 1e3, 1e3, 1e3, 1e3];
|
|
767
|
+
var ReportUpload = class {
|
|
768
|
+
_report;
|
|
769
|
+
_attachments;
|
|
770
|
+
_options;
|
|
771
|
+
constructor(options, report, attachments) {
|
|
772
|
+
this._options = options;
|
|
773
|
+
this._report = report;
|
|
774
|
+
this._attachments = attachments;
|
|
688
775
|
}
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if (!res.headersSent) logHTTPServer(`RES CLOSED BEFORE SEND ${req.method} ${req.originalUrl}`);
|
|
709
|
-
});
|
|
710
|
-
next();
|
|
711
|
-
});
|
|
712
|
-
const reportInfo = new ReportInfo(options);
|
|
713
|
-
app.use("/" + authToken, createTypedHttpExpressMiddleware({
|
|
714
|
-
router: localReportRouter,
|
|
715
|
-
createRootContext: async ({ req, res, input }) => ({ reportInfo })
|
|
716
|
-
}));
|
|
717
|
-
app.use((err2, req, res, next) => {
|
|
718
|
-
if (err2 instanceof TypedHTTP3.HttpError)
|
|
719
|
-
return res.status(err2.status).send({ error: err2.message });
|
|
720
|
-
logHTTPServer(err2);
|
|
721
|
-
res.status(500).send({ error: "Internal Server Error" });
|
|
722
|
-
});
|
|
723
|
-
const server = http2.createServer(app);
|
|
724
|
-
server.on("error", (err2) => {
|
|
725
|
-
if (err2.code === "ECONNRESET") {
|
|
726
|
-
logHTTPServer("Client connection reset. Ignoring.");
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
throw err2;
|
|
730
|
-
});
|
|
731
|
-
const port = await new Promise((resolve) => server.listen(options.port, () => {
|
|
732
|
-
resolve(server.address().port);
|
|
776
|
+
async _api(pathname, token, body) {
|
|
777
|
+
const url = new URL2(this._options.flakinessEndpoint);
|
|
778
|
+
url.pathname = pathname;
|
|
779
|
+
return await fetch(url, {
|
|
780
|
+
method: "POST",
|
|
781
|
+
headers: {
|
|
782
|
+
"Authorization": `Bearer ${token}`,
|
|
783
|
+
"Content-Type": "application/json"
|
|
784
|
+
},
|
|
785
|
+
body: body ? JSON.stringify(body) : void 0
|
|
786
|
+
}).then(async (response) => !response.ok ? {
|
|
787
|
+
result: void 0,
|
|
788
|
+
error: response.status + " " + url.href + " " + await response.text()
|
|
789
|
+
} : {
|
|
790
|
+
result: await response.json(),
|
|
791
|
+
error: void 0
|
|
792
|
+
}).catch((error) => ({
|
|
793
|
+
result: void 0,
|
|
794
|
+
error
|
|
733
795
|
}));
|
|
734
|
-
return new _LocalReportServer(server, port, authToken);
|
|
735
796
|
}
|
|
736
|
-
|
|
737
|
-
|
|
797
|
+
async upload(options) {
|
|
798
|
+
const response = await this._api("/api/upload/start", this._options.flakinessAccessToken);
|
|
799
|
+
if (response?.error || !response.result)
|
|
800
|
+
return { success: false, message: response.error };
|
|
801
|
+
const webUrl = new URL2(response.result.webUrl, this._options.flakinessEndpoint).toString();
|
|
802
|
+
const attachmentsPresignedUrls = await this._api("/api/upload/attachments", response.result.uploadToken, {
|
|
803
|
+
attachmentIds: this._attachments.map((a) => a.id)
|
|
804
|
+
});
|
|
805
|
+
if (attachmentsPresignedUrls?.error || !attachmentsPresignedUrls.result)
|
|
806
|
+
return { success: false, message: attachmentsPresignedUrls.error };
|
|
807
|
+
const attachments = new Map(attachmentsPresignedUrls.result.map((a) => [a.attachmentId, a.presignedUrl]));
|
|
808
|
+
await Promise.all([
|
|
809
|
+
this._uploadReport(JSON.stringify(this._report), response.result.presignedReportUrl, options?.syncCompression ?? false),
|
|
810
|
+
...this._attachments.map((attachment) => {
|
|
811
|
+
const uploadURL = attachments.get(attachment.id);
|
|
812
|
+
if (!uploadURL)
|
|
813
|
+
throw new Error("Internal error: missing upload URL for attachment!");
|
|
814
|
+
return this._uploadAttachment(attachment, uploadURL, options?.syncCompression ?? false);
|
|
815
|
+
})
|
|
816
|
+
]);
|
|
817
|
+
await this._api("/api/upload/finish", response.result.uploadToken);
|
|
818
|
+
return { success: true, reportUrl: webUrl };
|
|
738
819
|
}
|
|
739
|
-
|
|
740
|
-
|
|
820
|
+
async _uploadReport(data, uploadUrl, syncCompression) {
|
|
821
|
+
const compressed = syncCompression ? compressTextSync(data) : await compressTextAsync(data);
|
|
822
|
+
const headers = {
|
|
823
|
+
"Content-Type": "application/json",
|
|
824
|
+
"Content-Length": Buffer.byteLength(compressed) + "",
|
|
825
|
+
"Content-Encoding": "br"
|
|
826
|
+
};
|
|
827
|
+
await retryWithBackoff(async () => {
|
|
828
|
+
const { request, responseDataPromise } = httpUtils.createRequest({
|
|
829
|
+
url: uploadUrl,
|
|
830
|
+
headers,
|
|
831
|
+
method: "put"
|
|
832
|
+
});
|
|
833
|
+
request.write(compressed);
|
|
834
|
+
request.end();
|
|
835
|
+
await responseDataPromise;
|
|
836
|
+
}, HTTP_BACKOFF);
|
|
741
837
|
}
|
|
742
|
-
async
|
|
743
|
-
|
|
838
|
+
async _uploadAttachment(attachment, uploadUrl, syncCompression) {
|
|
839
|
+
const mimeType = attachment.contentType.toLocaleLowerCase().trim();
|
|
840
|
+
const compressable = mimeType.startsWith("text/") || mimeType.endsWith("+json") || mimeType.endsWith("+text") || mimeType.endsWith("+xml");
|
|
841
|
+
if (!compressable && attachment.path) {
|
|
842
|
+
const attachmentPath = attachment.path;
|
|
843
|
+
await retryWithBackoff(async () => {
|
|
844
|
+
const { request, responseDataPromise } = httpUtils.createRequest({
|
|
845
|
+
url: uploadUrl,
|
|
846
|
+
headers: {
|
|
847
|
+
"Content-Type": attachment.contentType,
|
|
848
|
+
"Content-Length": (await fs5.promises.stat(attachmentPath)).size + ""
|
|
849
|
+
},
|
|
850
|
+
method: "put"
|
|
851
|
+
});
|
|
852
|
+
fs5.createReadStream(attachmentPath).pipe(request);
|
|
853
|
+
await responseDataPromise;
|
|
854
|
+
}, HTTP_BACKOFF);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
let buffer = attachment.body ? attachment.body : attachment.path ? await fs5.promises.readFile(attachment.path) : void 0;
|
|
858
|
+
assert2(buffer);
|
|
859
|
+
const encoding = compressable ? "br" : void 0;
|
|
860
|
+
if (compressable)
|
|
861
|
+
buffer = syncCompression ? compressTextSync(buffer) : await compressTextAsync(buffer);
|
|
862
|
+
const headers = {
|
|
863
|
+
"Content-Type": attachment.contentType,
|
|
864
|
+
"Content-Length": Buffer.byteLength(buffer) + "",
|
|
865
|
+
"Content-Encoding": encoding
|
|
866
|
+
};
|
|
867
|
+
await retryWithBackoff(async () => {
|
|
868
|
+
const { request, responseDataPromise } = httpUtils.createRequest({
|
|
869
|
+
url: uploadUrl,
|
|
870
|
+
headers,
|
|
871
|
+
method: "put"
|
|
872
|
+
});
|
|
873
|
+
request.write(buffer);
|
|
874
|
+
request.end();
|
|
875
|
+
await responseDataPromise;
|
|
876
|
+
}, HTTP_BACKOFF);
|
|
744
877
|
}
|
|
745
878
|
};
|
|
746
879
|
|
|
747
|
-
// src/showReport.ts
|
|
748
|
-
async function showReport(reportFolder) {
|
|
749
|
-
const reportPath = path4.join(reportFolder, "report.json");
|
|
750
|
-
const config = await FlakinessProjectConfig.load();
|
|
751
|
-
const projectPublicId = config.projectPublicId();
|
|
752
|
-
const reportViewerEndpoint = config.reportViewerEndpoint();
|
|
753
|
-
const server = await LocalReportServer.create({
|
|
754
|
-
endpoint: reportViewerEndpoint,
|
|
755
|
-
port: 9373,
|
|
756
|
-
reportPath,
|
|
757
|
-
attachmentsFolder: reportFolder
|
|
758
|
-
});
|
|
759
|
-
const url = new URL(reportViewerEndpoint);
|
|
760
|
-
url.searchParams.set("port", String(server.port()));
|
|
761
|
-
url.searchParams.set("token", server.authToken());
|
|
762
|
-
if (projectPublicId)
|
|
763
|
-
url.searchParams.set("ppid", projectPublicId);
|
|
764
|
-
console.log(chalk.cyan(`
|
|
765
|
-
Serving Flakiness report at ${url.toString()}
|
|
766
|
-
Press Ctrl+C to quit.`));
|
|
767
|
-
await open(url.toString());
|
|
768
|
-
await new Promise(() => {
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
|
|
772
880
|
// src/systemUtilizationSampler.ts
|
|
773
|
-
import { spawnSync as
|
|
881
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
774
882
|
import os2 from "os";
|
|
775
883
|
function getAvailableMemMacOS() {
|
|
776
|
-
const lines =
|
|
884
|
+
const lines = spawnSync3("vm_stat", { encoding: "utf8" }).stdout.trim().split("\n");
|
|
777
885
|
const pageSize = parseInt(lines[0].match(/page size of (\d+) bytes/)[1], 10);
|
|
778
886
|
if (isNaN(pageSize)) {
|
|
779
887
|
console.warn("[flakiness.io] Error detecting macos page size");
|
|
@@ -837,250 +945,74 @@ var SystemUtilizationSampler = class {
|
|
|
837
945
|
}
|
|
838
946
|
};
|
|
839
947
|
|
|
840
|
-
// src/
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
}];
|
|
889
|
-
}
|
|
890
|
-
async _toFKTest(context, pwTest) {
|
|
891
|
-
return {
|
|
892
|
-
title: pwTest.title,
|
|
893
|
-
// Playwright Test tags must start with '@' so we cut it off.
|
|
894
|
-
tags: pwTest.tags.map((tag) => tag.startsWith("@") ? tag.substring(1) : tag),
|
|
895
|
-
location: this._createLocation(context, pwTest.location),
|
|
896
|
-
// de-duplication of tests will happen later, so here we will have all attempts.
|
|
897
|
-
attempts: await Promise.all(this._results.getAll(pwTest).map((result) => this._toFKRunAttempt(context, pwTest, result)))
|
|
898
|
-
};
|
|
899
|
-
}
|
|
900
|
-
async _toFKRunAttempt(context, pwTest, result) {
|
|
901
|
-
const attachments = [];
|
|
902
|
-
const attempt = {
|
|
903
|
-
timeout: parseDurationMS(pwTest.timeout),
|
|
904
|
-
annotations: pwTest.annotations.map((annotation) => ({
|
|
905
|
-
type: annotation.type,
|
|
906
|
-
description: annotation.description,
|
|
907
|
-
location: annotation.location ? this._createLocation(context, annotation.location) : void 0
|
|
908
|
-
})),
|
|
909
|
-
environmentIdx: context.project2environmentIdx.get(pwTest.parent.project()),
|
|
910
|
-
expectedStatus: pwTest.expectedStatus,
|
|
911
|
-
parallelIndex: result.parallelIndex,
|
|
912
|
-
status: result.status,
|
|
913
|
-
errors: result.errors && result.errors.length ? result.errors.map((error) => this._toFKTestError(context, error)) : void 0,
|
|
914
|
-
stdout: result.stdout ? result.stdout.map(toSTDIOEntry) : void 0,
|
|
915
|
-
stderr: result.stderr ? result.stderr.map(toSTDIOEntry) : void 0,
|
|
916
|
-
steps: result.steps ? result.steps.map((jsonTestStep) => this._toFKTestStep(context, jsonTestStep)) : void 0,
|
|
917
|
-
startTimestamp: +result.startTime,
|
|
918
|
-
duration: +result.duration,
|
|
919
|
-
attachments
|
|
920
|
-
};
|
|
921
|
-
await Promise.all((result.attachments ?? []).map(async (jsonAttachment) => {
|
|
922
|
-
if (jsonAttachment.path && !await existsAsync(jsonAttachment.path)) {
|
|
923
|
-
context.unaccessibleAttachmentPaths.push(jsonAttachment.path);
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
const id = jsonAttachment.path ? await sha1File(jsonAttachment.path) : sha1Buffer(jsonAttachment.body ?? "");
|
|
927
|
-
context.attachments.set(id, {
|
|
928
|
-
contentType: jsonAttachment.contentType,
|
|
929
|
-
id,
|
|
930
|
-
body: jsonAttachment.body,
|
|
931
|
-
path: jsonAttachment.path
|
|
932
|
-
});
|
|
933
|
-
attachments.push({
|
|
934
|
-
id,
|
|
935
|
-
name: jsonAttachment.name,
|
|
936
|
-
contentType: jsonAttachment.contentType
|
|
937
|
-
});
|
|
938
|
-
}));
|
|
939
|
-
return attempt;
|
|
940
|
-
}
|
|
941
|
-
_toFKTestStep(context, pwStep) {
|
|
942
|
-
const step = {
|
|
943
|
-
// NOTE: jsonStep.duration was -1 in some playwright versions
|
|
944
|
-
duration: parseDurationMS(Math.max(pwStep.duration, 0)),
|
|
945
|
-
title: pwStep.title,
|
|
946
|
-
location: pwStep.location ? this._createLocation(context, pwStep.location) : void 0
|
|
947
|
-
};
|
|
948
|
-
if (pwStep.location) {
|
|
949
|
-
const resolvedPath = path5.resolve(pwStep.location.file);
|
|
950
|
-
this._filepathToSteps.set(resolvedPath, step);
|
|
951
|
-
}
|
|
952
|
-
if (pwStep.error)
|
|
953
|
-
step.error = this._toFKTestError(context, pwStep.error);
|
|
954
|
-
if (pwStep.steps)
|
|
955
|
-
step.steps = pwStep.steps.map((childJSONStep) => this._toFKTestStep(context, childJSONStep));
|
|
956
|
-
return step;
|
|
957
|
-
}
|
|
958
|
-
_createLocation(context, pwLocation) {
|
|
959
|
-
return {
|
|
960
|
-
file: gitFilePath(context.gitRoot, normalizePath(pwLocation.file)),
|
|
961
|
-
line: pwLocation.line,
|
|
962
|
-
column: pwLocation.column
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
_toFKTestError(context, pwError) {
|
|
966
|
-
return {
|
|
967
|
-
location: pwError.location ? this._createLocation(context, pwError.location) : void 0,
|
|
968
|
-
message: stripAnsi(pwError.message ?? "").split("\n")[0],
|
|
969
|
-
snippet: pwError.snippet,
|
|
970
|
-
stack: pwError.stack,
|
|
971
|
-
value: pwError.value
|
|
972
|
-
};
|
|
973
|
-
}
|
|
974
|
-
async onEnd(result) {
|
|
975
|
-
this._systemUtilizationSampler.dispose();
|
|
976
|
-
if (!this._config || !this._rootSuite)
|
|
977
|
-
throw new Error("ERROR: failed to resolve config");
|
|
978
|
-
let commitId;
|
|
979
|
-
try {
|
|
980
|
-
commitId = gitCommitInfo(this._config.rootDir);
|
|
981
|
-
} catch (e) {
|
|
982
|
-
warn(`Failed to fetch commit info - is this a git repo?`);
|
|
983
|
-
err(`Report is NOT generated.`);
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
const gitRoot = normalizePath(computeGitRoot(this._config.rootDir));
|
|
987
|
-
const configPath = this._config.configFile ? gitFilePath(gitRoot, normalizePath(this._config.configFile)) : void 0;
|
|
988
|
-
const context = {
|
|
989
|
-
project2environmentIdx: /* @__PURE__ */ new Map(),
|
|
990
|
-
testBaseDir: normalizePath(this._config.rootDir),
|
|
991
|
-
gitRoot,
|
|
992
|
-
attachments: /* @__PURE__ */ new Map(),
|
|
993
|
-
unaccessibleAttachmentPaths: []
|
|
994
|
-
};
|
|
995
|
-
const environmentsMap = createEnvironments(this._config.projects);
|
|
996
|
-
if (this._options.collectBrowserVersions) {
|
|
997
|
-
try {
|
|
998
|
-
let playwrightPath = fs6.realpathSync(process.argv[1]);
|
|
999
|
-
while (path5.basename(playwrightPath) !== "test")
|
|
1000
|
-
playwrightPath = path5.dirname(playwrightPath);
|
|
1001
|
-
const module = await import(path5.join(playwrightPath, "index.js"));
|
|
1002
|
-
for (const [project, env] of environmentsMap) {
|
|
1003
|
-
const { browserName = "chromium", channel, headless } = project.use;
|
|
1004
|
-
let browserType;
|
|
1005
|
-
switch (browserName) {
|
|
1006
|
-
case "chromium":
|
|
1007
|
-
browserType = module.default.chromium;
|
|
1008
|
-
break;
|
|
1009
|
-
case "firefox":
|
|
1010
|
-
browserType = module.default.firefox;
|
|
1011
|
-
break;
|
|
1012
|
-
case "webkit":
|
|
1013
|
-
browserType = module.default.webkit;
|
|
1014
|
-
break;
|
|
1015
|
-
default:
|
|
1016
|
-
throw new Error(`Unsupported browser: ${browserName}`);
|
|
1017
|
-
}
|
|
1018
|
-
const browser = await browserType.launch({ channel, headless });
|
|
1019
|
-
const version = browser.version();
|
|
1020
|
-
await browser.close();
|
|
1021
|
-
env.userSuppliedData ??= {};
|
|
1022
|
-
env.userSuppliedData["browser"] = (channel ?? browserName).toLowerCase().trim() + " " + version;
|
|
1023
|
-
}
|
|
1024
|
-
} catch (e) {
|
|
1025
|
-
err(`Failed to resolve browser version: ${e}`);
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
const environments = [...environmentsMap.values()];
|
|
1029
|
-
for (let envIdx = 0; envIdx < environments.length; ++envIdx)
|
|
1030
|
-
context.project2environmentIdx.set(this._config.projects[envIdx], envIdx);
|
|
1031
|
-
const relatedCommitIds = this._options.relatedCommitIds;
|
|
1032
|
-
const report = ReportUtils2.dedupeSuitesTestsEnvironments({
|
|
1033
|
-
category: "playwright",
|
|
1034
|
-
commitId,
|
|
1035
|
-
relatedCommitIds,
|
|
1036
|
-
systemUtilization: this._systemUtilizationSampler.result,
|
|
1037
|
-
configPath,
|
|
1038
|
-
url: inferRunUrl(),
|
|
1039
|
-
environments,
|
|
1040
|
-
suites: await this._toFKSuites(context, this._rootSuite),
|
|
1041
|
-
opaqueData: this._config,
|
|
1042
|
-
unattributedErrors: this._unattributedErrors.map((e) => this._toFKTestError(context, e)),
|
|
1043
|
-
duration: parseDurationMS(result.duration),
|
|
1044
|
-
startTimestamp: +result.startTime
|
|
1045
|
-
});
|
|
1046
|
-
createTestStepSnippets(this._filepathToSteps);
|
|
1047
|
-
for (const unaccessibleAttachment of context.unaccessibleAttachmentPaths)
|
|
1048
|
-
warn(`cannot access attachment ${unaccessibleAttachment}`);
|
|
1049
|
-
this._report = report;
|
|
1050
|
-
this._attachments = await saveReportAndAttachments(report, Array.from(context.attachments.values()), this._outputFolder);
|
|
1051
|
-
this._result = result;
|
|
1052
|
-
}
|
|
1053
|
-
async onExit() {
|
|
1054
|
-
if (!this._report)
|
|
1055
|
-
return;
|
|
1056
|
-
await ReportUploader.upload({
|
|
1057
|
-
report: this._report,
|
|
1058
|
-
attachments: this._attachments,
|
|
1059
|
-
flakinessAccessToken: this._options.token,
|
|
1060
|
-
flakinessEndpoint: this._options.endpoint,
|
|
1061
|
-
log: console.log
|
|
948
|
+
// src/index.ts
|
|
949
|
+
function inferRunUrl() {
|
|
950
|
+
if (process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID)
|
|
951
|
+
return `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
952
|
+
return void 0;
|
|
953
|
+
}
|
|
954
|
+
async function showReport(reportFolder) {
|
|
955
|
+
const reportPath = path3.join(reportFolder, "report.json");
|
|
956
|
+
const config = await FlakinessProjectConfig.load();
|
|
957
|
+
const projectPublicId = config.projectPublicId();
|
|
958
|
+
const reportViewerEndpoint = config.reportViewerEndpoint();
|
|
959
|
+
const server = await LocalReportServer.create({
|
|
960
|
+
endpoint: reportViewerEndpoint,
|
|
961
|
+
port: 9373,
|
|
962
|
+
reportPath,
|
|
963
|
+
attachmentsFolder: reportFolder
|
|
964
|
+
});
|
|
965
|
+
const url = new URL(reportViewerEndpoint);
|
|
966
|
+
url.searchParams.set("port", String(server.port()));
|
|
967
|
+
url.searchParams.set("token", server.authToken());
|
|
968
|
+
if (projectPublicId)
|
|
969
|
+
url.searchParams.set("ppid", projectPublicId);
|
|
970
|
+
console.log(chalk.cyan(`
|
|
971
|
+
Serving Flakiness report at ${url.toString()}
|
|
972
|
+
Press Ctrl+C to quit.`));
|
|
973
|
+
await open(url.toString());
|
|
974
|
+
await new Promise(() => {
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
async function saveReport(report, attachments, outputFolder) {
|
|
978
|
+
const reportPath = path3.join(outputFolder, "report.json");
|
|
979
|
+
const attachmentsFolder = path3.join(outputFolder, "attachments");
|
|
980
|
+
await fs6.promises.rm(outputFolder, { recursive: true, force: true });
|
|
981
|
+
await fs6.promises.mkdir(outputFolder, { recursive: true });
|
|
982
|
+
await fs6.promises.writeFile(reportPath, JSON.stringify(report), "utf-8");
|
|
983
|
+
if (attachments.length)
|
|
984
|
+
await fs6.promises.mkdir(attachmentsFolder);
|
|
985
|
+
const movedAttachments = [];
|
|
986
|
+
for (const attachment of attachments) {
|
|
987
|
+
const attachmentPath = path3.join(attachmentsFolder, attachment.id);
|
|
988
|
+
if (attachment.path)
|
|
989
|
+
await fs6.promises.cp(attachment.path, attachmentPath);
|
|
990
|
+
else if (attachment.body)
|
|
991
|
+
await fs6.promises.writeFile(attachmentPath, attachment.body);
|
|
992
|
+
movedAttachments.push({
|
|
993
|
+
contentType: attachment.contentType,
|
|
994
|
+
id: attachment.id,
|
|
995
|
+
path: attachmentPath
|
|
1062
996
|
});
|
|
1063
|
-
const openMode = this._options.open ?? "on-failure";
|
|
1064
|
-
const shouldOpen = process.stdin.isTTY && !process.env.CI && (openMode === "always" || openMode === "on-failure" && this._result?.status === "failed");
|
|
1065
|
-
if (shouldOpen) {
|
|
1066
|
-
await showReport(this._outputFolder);
|
|
1067
|
-
} else {
|
|
1068
|
-
const defaultOutputFolder = path5.join(process.cwd(), "flakiness-report");
|
|
1069
|
-
const folder = defaultOutputFolder === this._outputFolder ? "" : path5.relative(process.cwd(), this._outputFolder);
|
|
1070
|
-
console.log(`
|
|
1071
|
-
To open last Flakiness report, install Flakiness CLI tool and run:
|
|
1072
|
-
|
|
1073
|
-
${chalk2.cyan(`flakiness show ${folder}`)}
|
|
1074
|
-
`);
|
|
1075
|
-
}
|
|
1076
997
|
}
|
|
1077
|
-
|
|
1078
|
-
function toSTDIOEntry(data) {
|
|
1079
|
-
if (Buffer.isBuffer(data))
|
|
1080
|
-
return { buffer: data.toString("base64") };
|
|
1081
|
-
return { text: data };
|
|
998
|
+
return movedAttachments;
|
|
1082
999
|
}
|
|
1083
1000
|
export {
|
|
1084
|
-
|
|
1001
|
+
FlakinessProjectConfig,
|
|
1002
|
+
FlakinessReport,
|
|
1003
|
+
ReportUploader,
|
|
1004
|
+
ReportUtils,
|
|
1005
|
+
SystemUtilizationSampler,
|
|
1006
|
+
computeGitRoot,
|
|
1007
|
+
createDataAttachment,
|
|
1008
|
+
createEnvironment,
|
|
1009
|
+
createFileAttachment,
|
|
1010
|
+
createTestStepSnippetsInplace,
|
|
1011
|
+
gitCommitInfo,
|
|
1012
|
+
inferRunUrl,
|
|
1013
|
+
pathutils_exports as pathutils,
|
|
1014
|
+
saveReport,
|
|
1015
|
+
showReport,
|
|
1016
|
+
stripAnsi
|
|
1085
1017
|
};
|
|
1086
|
-
//# sourceMappingURL=
|
|
1018
|
+
//# sourceMappingURL=index.js.map
|