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