lighthouse-reporting 1.0.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/README.md +13 -0
- package/dist/constants-226e9774.js +8 -0
- package/dist/constants-667b8033.cjs +7 -0
- package/dist/constants.d.ts +3 -0
- package/dist/hooks.cjs +25 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +25 -0
- package/dist/index.cjs +18 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +18 -0
- package/dist/lighthouse-reporting.umd.cjs +202 -0
- package/dist/lighthouse.html +187 -0
- package/dist/lighthouseReports.cjs +47 -0
- package/dist/lighthouseReports.d.ts +16 -0
- package/dist/lighthouseReports.js +47 -0
- package/dist/lighthouseTest.cjs +31 -0
- package/dist/lighthouseTest.d.ts +6 -0
- package/dist/lighthouseTest.js +31 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# lighthouse-reporting
|
|
2
|
+
|
|
3
|
+
> Generate Lighthouse reports in HTML, CSV, and JSON formats, optimized for publishing in Jenkins or GitHub Actions.
|
|
4
|
+
|
|
5
|
+
The reports include trend history support, allowing you to track performance improvements over time.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
### Jenkins
|
|
10
|
+
|
|
11
|
+
### GitHub Actions
|
|
12
|
+
|
|
13
|
+
TODO
|
package/dist/hooks.cjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const fse = require("fs-extra");
|
|
6
|
+
const url = require("url");
|
|
7
|
+
const constants = require("./constants-667b8033.cjs");
|
|
8
|
+
let dirname;
|
|
9
|
+
if (typeof __dirname !== "string") {
|
|
10
|
+
const filename = url.fileURLToPath(typeof document === "undefined" ? require("url").pathToFileURL(__filename).href : document.currentScript && document.currentScript.src || new URL("hooks.cjs", document.baseURI).href);
|
|
11
|
+
dirname = path.dirname(filename);
|
|
12
|
+
} else {
|
|
13
|
+
dirname = __dirname;
|
|
14
|
+
}
|
|
15
|
+
const reportDir = path.join(process.cwd(), process.env.LH_REPORT_DIR || constants.LH_OUT_DIR);
|
|
16
|
+
const htmlTemplatePath = path.join(dirname, "lighthouse.html");
|
|
17
|
+
async function lighthouseSetup() {
|
|
18
|
+
await fse.ensureDir(reportDir);
|
|
19
|
+
await fse.copyFile(htmlTemplatePath, path.join(reportDir, constants.INDEX_HTML));
|
|
20
|
+
}
|
|
21
|
+
async function lighthousePlaywrightTeardown() {
|
|
22
|
+
await fse.remove(path.join(os.tmpdir(), constants.PW_TMP_DIR));
|
|
23
|
+
}
|
|
24
|
+
exports.lighthousePlaywrightTeardown = lighthousePlaywrightTeardown;
|
|
25
|
+
exports.lighthouseSetup = lighthouseSetup;
|
package/dist/hooks.d.ts
ADDED
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fse from "fs-extra";
|
|
4
|
+
import url from "url";
|
|
5
|
+
import { L as LH_OUT_DIR, I as INDEX_HTML, P as PW_TMP_DIR } from "./constants-226e9774.js";
|
|
6
|
+
let dirname;
|
|
7
|
+
if (typeof __dirname !== "string") {
|
|
8
|
+
const filename = url.fileURLToPath(import.meta.url);
|
|
9
|
+
dirname = path.dirname(filename);
|
|
10
|
+
} else {
|
|
11
|
+
dirname = __dirname;
|
|
12
|
+
}
|
|
13
|
+
const reportDir = path.join(process.cwd(), process.env.LH_REPORT_DIR || LH_OUT_DIR);
|
|
14
|
+
const htmlTemplatePath = path.join(dirname, "lighthouse.html");
|
|
15
|
+
async function lighthouseSetup() {
|
|
16
|
+
await fse.ensureDir(reportDir);
|
|
17
|
+
await fse.copyFile(htmlTemplatePath, path.join(reportDir, INDEX_HTML));
|
|
18
|
+
}
|
|
19
|
+
async function lighthousePlaywrightTeardown() {
|
|
20
|
+
await fse.remove(path.join(os.tmpdir(), PW_TMP_DIR));
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
lighthousePlaywrightTeardown,
|
|
24
|
+
lighthouseSetup
|
|
25
|
+
};
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const hooks = require("./hooks.cjs");
|
|
4
|
+
const lighthouseReports = require("./lighthouseReports.cjs");
|
|
5
|
+
const lighthouseTest = require("./lighthouseTest.cjs");
|
|
6
|
+
require("os");
|
|
7
|
+
require("path");
|
|
8
|
+
require("fs-extra");
|
|
9
|
+
require("url");
|
|
10
|
+
require("./constants-667b8033.cjs");
|
|
11
|
+
require("get-port");
|
|
12
|
+
require("@playwright/test");
|
|
13
|
+
exports.lighthousePlaywrightTeardown = hooks.lighthousePlaywrightTeardown;
|
|
14
|
+
exports.lighthouseSetup = hooks.lighthouseSetup;
|
|
15
|
+
exports.getScores = lighthouseReports.getScores;
|
|
16
|
+
exports.writeCsvResult = lighthouseReports.writeCsvResult;
|
|
17
|
+
exports.writeHtmlListEntryWithRetry = lighthouseReports.writeHtmlListEntryWithRetry;
|
|
18
|
+
exports.lighthouseTest = lighthouseTest.lighthouseTest;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { lighthousePlaywrightTeardown, lighthouseSetup } from "./hooks.js";
|
|
2
|
+
import { getScores, writeCsvResult, writeHtmlListEntryWithRetry } from "./lighthouseReports.js";
|
|
3
|
+
import { lighthouseTest } from "./lighthouseTest.js";
|
|
4
|
+
import "os";
|
|
5
|
+
import "path";
|
|
6
|
+
import "fs-extra";
|
|
7
|
+
import "url";
|
|
8
|
+
import "./constants-226e9774.js";
|
|
9
|
+
import "get-port";
|
|
10
|
+
import "@playwright/test";
|
|
11
|
+
export {
|
|
12
|
+
getScores,
|
|
13
|
+
lighthousePlaywrightTeardown,
|
|
14
|
+
lighthouseSetup,
|
|
15
|
+
lighthouseTest,
|
|
16
|
+
writeCsvResult,
|
|
17
|
+
writeHtmlListEntryWithRetry
|
|
18
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
(function(global, factory) {
|
|
2
|
+
typeof exports === "object" && typeof module !== "undefined" ? factory(exports, require("os"), require("path"), require("fs-extra"), require("url"), require("net"), require("@playwright/test")) : typeof define === "function" && define.amd ? define(["exports", "os", "path", "fs-extra", "url", "net", "@playwright/test"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.lh_rep = {}, global.os, global.path, global.fse, global.url, global.net, global.test));
|
|
3
|
+
})(this, function(exports2, os, path, fse, url, net, test) {
|
|
4
|
+
"use strict";
|
|
5
|
+
const INDEX_HTML = "index.html";
|
|
6
|
+
const LH_OUT_DIR = "lighthouse";
|
|
7
|
+
const PW_TMP_DIR = "pwlh";
|
|
8
|
+
let dirname;
|
|
9
|
+
if (typeof __dirname !== "string") {
|
|
10
|
+
const filename = url.fileURLToPath(typeof document === "undefined" && typeof location === "undefined" ? require("url").pathToFileURL(__filename).href : typeof document === "undefined" ? location.href : document.currentScript && document.currentScript.src || new URL("lighthouse-reporting.umd.cjs", document.baseURI).href);
|
|
11
|
+
dirname = path.dirname(filename);
|
|
12
|
+
} else {
|
|
13
|
+
dirname = __dirname;
|
|
14
|
+
}
|
|
15
|
+
const reportDir = path.join(process.cwd(), process.env.LH_REPORT_DIR || LH_OUT_DIR);
|
|
16
|
+
const htmlTemplatePath = path.join(dirname, "lighthouse.html");
|
|
17
|
+
async function lighthouseSetup() {
|
|
18
|
+
await fse.ensureDir(reportDir);
|
|
19
|
+
await fse.copyFile(htmlTemplatePath, path.join(reportDir, INDEX_HTML));
|
|
20
|
+
}
|
|
21
|
+
async function lighthousePlaywrightTeardown() {
|
|
22
|
+
await fse.remove(path.join(os.tmpdir(), PW_TMP_DIR));
|
|
23
|
+
}
|
|
24
|
+
const writeCsvResult = async (reportDir2, name, scores, thresholds, swimlanes = []) => {
|
|
25
|
+
let csvData = Object.keys(scores).join(",");
|
|
26
|
+
if (swimlanes.length > 0) {
|
|
27
|
+
csvData += "," + swimlanes.map((swimlane) => `${swimlane}_threshold`);
|
|
28
|
+
}
|
|
29
|
+
csvData += "\n";
|
|
30
|
+
csvData += Object.values(scores).join(",");
|
|
31
|
+
if (swimlanes.length > 0) {
|
|
32
|
+
csvData += "," + swimlanes.map((swimlane) => thresholds[swimlane]);
|
|
33
|
+
}
|
|
34
|
+
await fse.writeFile(path.join(reportDir2, `${name}.csv`), csvData);
|
|
35
|
+
};
|
|
36
|
+
const writeHtmlListEntry = async (htmlFilePath, name, scores, thresholds, error, addReportLink) => {
|
|
37
|
+
const htmlFileData = (await fse.readFile(htmlFilePath)).toString("utf-8").split("\n");
|
|
38
|
+
const insertIdx = htmlFileData.findIndex((v) => v.includes("// lighthouse-page-results"));
|
|
39
|
+
if (insertIdx < 0) {
|
|
40
|
+
throw new Error("Failed to write results to index.html");
|
|
41
|
+
}
|
|
42
|
+
htmlFileData.splice(insertIdx + 1, 0, JSON.stringify({ href: `${name}.html`, name, scores, thresholds, error, addReportLink }) + ",");
|
|
43
|
+
await fse.writeFile(htmlFilePath, htmlFileData.join("\n"));
|
|
44
|
+
};
|
|
45
|
+
const writeHtmlListEntryWithRetry = async (htmlFilePath, name, scores, thresholds, comparisonError, addReportLink = true) => {
|
|
46
|
+
let attempts = 10;
|
|
47
|
+
let error;
|
|
48
|
+
while (attempts > 0) {
|
|
49
|
+
attempts--;
|
|
50
|
+
try {
|
|
51
|
+
await writeHtmlListEntry(htmlFilePath, name, scores, thresholds, comparisonError, addReportLink);
|
|
52
|
+
return;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
error = e;
|
|
55
|
+
}
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
};
|
|
60
|
+
const getScores = (result) => Object.entries(result.lhr.categories).reduce((prev, [key, c]) => {
|
|
61
|
+
prev[key] = Math.floor(c.score * 100);
|
|
62
|
+
return prev;
|
|
63
|
+
}, {});
|
|
64
|
+
class Locked extends Error {
|
|
65
|
+
constructor(port) {
|
|
66
|
+
super(`${port} is locked`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const lockedPorts = {
|
|
70
|
+
old: /* @__PURE__ */ new Set(),
|
|
71
|
+
young: /* @__PURE__ */ new Set()
|
|
72
|
+
};
|
|
73
|
+
const releaseOldLockedPortsIntervalMs = 1e3 * 15;
|
|
74
|
+
let interval;
|
|
75
|
+
const getLocalHosts = () => {
|
|
76
|
+
const interfaces = os.networkInterfaces();
|
|
77
|
+
const results = /* @__PURE__ */ new Set([void 0, "0.0.0.0"]);
|
|
78
|
+
for (const _interface of Object.values(interfaces)) {
|
|
79
|
+
for (const config of _interface) {
|
|
80
|
+
results.add(config.address);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
};
|
|
85
|
+
const checkAvailablePort = (options) => new Promise((resolve, reject) => {
|
|
86
|
+
const server = net.createServer();
|
|
87
|
+
server.unref();
|
|
88
|
+
server.on("error", reject);
|
|
89
|
+
server.listen(options, () => {
|
|
90
|
+
const { port } = server.address();
|
|
91
|
+
server.close(() => {
|
|
92
|
+
resolve(port);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
const getAvailablePort = async (options, hosts) => {
|
|
97
|
+
if (options.host || options.port === 0) {
|
|
98
|
+
return checkAvailablePort(options);
|
|
99
|
+
}
|
|
100
|
+
for (const host of hosts) {
|
|
101
|
+
try {
|
|
102
|
+
await checkAvailablePort({ port: options.port, host });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!["EADDRNOTAVAIL", "EINVAL"].includes(error.code)) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return options.port;
|
|
110
|
+
};
|
|
111
|
+
const portCheckSequence = function* (ports) {
|
|
112
|
+
if (ports) {
|
|
113
|
+
yield* ports;
|
|
114
|
+
}
|
|
115
|
+
yield 0;
|
|
116
|
+
};
|
|
117
|
+
async function getPorts(options) {
|
|
118
|
+
let ports;
|
|
119
|
+
let exclude = /* @__PURE__ */ new Set();
|
|
120
|
+
if (options) {
|
|
121
|
+
if (options.port) {
|
|
122
|
+
ports = typeof options.port === "number" ? [options.port] : options.port;
|
|
123
|
+
}
|
|
124
|
+
if (options.exclude) {
|
|
125
|
+
const excludeIterable = options.exclude;
|
|
126
|
+
if (typeof excludeIterable[Symbol.iterator] !== "function") {
|
|
127
|
+
throw new TypeError("The `exclude` option must be an iterable.");
|
|
128
|
+
}
|
|
129
|
+
for (const element of excludeIterable) {
|
|
130
|
+
if (typeof element !== "number") {
|
|
131
|
+
throw new TypeError("Each item in the `exclude` option must be a number corresponding to the port you want excluded.");
|
|
132
|
+
}
|
|
133
|
+
if (!Number.isSafeInteger(element)) {
|
|
134
|
+
throw new TypeError(`Number ${element} in the exclude option is not a safe integer and can't be used`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
exclude = new Set(excludeIterable);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (interval === void 0) {
|
|
141
|
+
interval = setInterval(() => {
|
|
142
|
+
lockedPorts.old = lockedPorts.young;
|
|
143
|
+
lockedPorts.young = /* @__PURE__ */ new Set();
|
|
144
|
+
}, releaseOldLockedPortsIntervalMs);
|
|
145
|
+
if (interval.unref) {
|
|
146
|
+
interval.unref();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const hosts = getLocalHosts();
|
|
150
|
+
for (const port of portCheckSequence(ports)) {
|
|
151
|
+
try {
|
|
152
|
+
if (exclude.has(port)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
let availablePort = await getAvailablePort({ ...options, port }, hosts);
|
|
156
|
+
while (lockedPorts.old.has(availablePort) || lockedPorts.young.has(availablePort)) {
|
|
157
|
+
if (port !== 0) {
|
|
158
|
+
throw new Locked(port);
|
|
159
|
+
}
|
|
160
|
+
availablePort = await getAvailablePort({ ...options, port }, hosts);
|
|
161
|
+
}
|
|
162
|
+
lockedPorts.young.add(availablePort);
|
|
163
|
+
return availablePort;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (!["EADDRINUSE", "EACCES"].includes(error.code) && !(error instanceof Locked)) {
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
throw new Error("No available ports found");
|
|
171
|
+
}
|
|
172
|
+
const lighthouseTest = test.test.extend({
|
|
173
|
+
port: [
|
|
174
|
+
// eslint-disable-next-line no-empty-pattern
|
|
175
|
+
async ({}, use) => {
|
|
176
|
+
const port = await getPorts();
|
|
177
|
+
await use(port);
|
|
178
|
+
},
|
|
179
|
+
{ scope: "worker" }
|
|
180
|
+
],
|
|
181
|
+
context: [
|
|
182
|
+
async ({ port, launchOptions }, use) => {
|
|
183
|
+
const context = await test.chromium.launchPersistentContext(
|
|
184
|
+
path.join(os.tmpdir(), PW_TMP_DIR, `${Math.random()}`.replace(".", "")),
|
|
185
|
+
{
|
|
186
|
+
args: [...launchOptions.args || [], `--remote-debugging-port=${port}`]
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
await use(context);
|
|
190
|
+
await context.close();
|
|
191
|
+
},
|
|
192
|
+
{ scope: "test" }
|
|
193
|
+
]
|
|
194
|
+
});
|
|
195
|
+
exports2.getScores = getScores;
|
|
196
|
+
exports2.lighthousePlaywrightTeardown = lighthousePlaywrightTeardown;
|
|
197
|
+
exports2.lighthouseSetup = lighthouseSetup;
|
|
198
|
+
exports2.lighthouseTest = lighthouseTest;
|
|
199
|
+
exports2.writeCsvResult = writeCsvResult;
|
|
200
|
+
exports2.writeHtmlListEntryWithRetry = writeHtmlListEntryWithRetry;
|
|
201
|
+
Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
|
|
202
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<title>Lighthouse Results</title>
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8" />
|
|
6
|
+
<title>Lighthouse</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
margin: 0px;
|
|
10
|
+
font-size: 0.9em;
|
|
11
|
+
font-family: sans-serif;
|
|
12
|
+
}
|
|
13
|
+
iframe {
|
|
14
|
+
display: block;
|
|
15
|
+
border: none;
|
|
16
|
+
height: 100vh;
|
|
17
|
+
width: 100vw;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#table-filter {
|
|
21
|
+
margin: 10px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#scrollTop {
|
|
25
|
+
margin: 0 10px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#lhTable {
|
|
29
|
+
border-collapse: collapse;
|
|
30
|
+
margin: 10px;
|
|
31
|
+
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
|
|
32
|
+
}
|
|
33
|
+
.hide,
|
|
34
|
+
#lhTable.hide-passed tbody > tr:not(.has-error) {
|
|
35
|
+
display: none;
|
|
36
|
+
}
|
|
37
|
+
#lhTable .lh-fail {
|
|
38
|
+
color: #a51515;
|
|
39
|
+
}
|
|
40
|
+
#lhTable .lh-pass {
|
|
41
|
+
color: #008000;
|
|
42
|
+
}
|
|
43
|
+
#lhTable thead tr {
|
|
44
|
+
background-color: #4c90e2;
|
|
45
|
+
color: #ffffff;
|
|
46
|
+
text-align: left;
|
|
47
|
+
}
|
|
48
|
+
#lhTable th,
|
|
49
|
+
#lhTable td {
|
|
50
|
+
padding: 12px 15px;
|
|
51
|
+
}
|
|
52
|
+
#lhTable tbody tr {
|
|
53
|
+
border-bottom: 1px solid #dddddd;
|
|
54
|
+
}
|
|
55
|
+
#lhTable tbody tr:nth-of-type(even) {
|
|
56
|
+
background-color: #f3f3f3;
|
|
57
|
+
}
|
|
58
|
+
#lhTable tbody tr:hover {
|
|
59
|
+
background-color: #b7d2f3;
|
|
60
|
+
}
|
|
61
|
+
#lhTable tfoot tr {
|
|
62
|
+
border-top: 2px solid #4c90e2;
|
|
63
|
+
font-weight: bold;
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<div id="table-filter">
|
|
69
|
+
<label for="showPassed"><input type="checkbox" name="showPassed" id="showPassed" />Show passed?</label>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<table id="lhTable" class="hide-passed">
|
|
73
|
+
<thead></thead>
|
|
74
|
+
<tbody></tbody>
|
|
75
|
+
<tfoot></tfoot>
|
|
76
|
+
</table>
|
|
77
|
+
|
|
78
|
+
<button id="scrollTop" class="hide" aria-label="Scroll Top" title="Close Lighthouse report and scroll top">Scroll Top</button>
|
|
79
|
+
|
|
80
|
+
<iframe id="lh-iframe" height="100%" width="100%" frameborder="0" src="" title="Lighthouse report"></iframe>
|
|
81
|
+
</body>
|
|
82
|
+
<script type="text/javascript">
|
|
83
|
+
window.onbeforeunload = () => window.scrollTo(0, 0)
|
|
84
|
+
|
|
85
|
+
const results = [
|
|
86
|
+
// lighthouse-page-results
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
let sortKey
|
|
90
|
+
if (results.length > 0 && results[0].scores && typeof results[0].scores[Object.keys(results[0].scores).sort()[0]] === 'number') {
|
|
91
|
+
sortKey = Object.keys(results[0].scores).sort()[0]
|
|
92
|
+
}
|
|
93
|
+
results.sort((a, b) => {
|
|
94
|
+
if (sortKey) {
|
|
95
|
+
return a.scores[sortKey] - b.scores[sortKey]
|
|
96
|
+
}
|
|
97
|
+
return b.error ? 1 : -1
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const createTr = (data, cellType = 'td', isError = false) => {
|
|
101
|
+
const row = document.createElement('tr')
|
|
102
|
+
if (isError) {
|
|
103
|
+
row.classList.add('has-error')
|
|
104
|
+
}
|
|
105
|
+
data.forEach((el) => {
|
|
106
|
+
const cell = document.createElement(cellType)
|
|
107
|
+
cell.appendChild(el)
|
|
108
|
+
row.appendChild(cell)
|
|
109
|
+
})
|
|
110
|
+
return row
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lhTable = document.getElementById('lhTable')
|
|
114
|
+
|
|
115
|
+
// header data
|
|
116
|
+
const lhTableHeader = lhTable.getElementsByTagName('thead')[0]
|
|
117
|
+
const headers = ['Result', ...Object.keys(results[0].scores), 'Page'].map((t) => document.createTextNode(t))
|
|
118
|
+
lhTableHeader.appendChild(createTr(headers, 'th'))
|
|
119
|
+
|
|
120
|
+
// footer data
|
|
121
|
+
const lhTableFooter = lhTable.getElementsByTagName('tfoot')[0]
|
|
122
|
+
lhTableFooter.appendChild(
|
|
123
|
+
createTr(
|
|
124
|
+
['TOTAL', '', `Failed ${results.filter((r) => r.error).length} of ${results.length}`].map((t) => document.createTextNode(t))
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// body data
|
|
129
|
+
const lhTableBody = lhTable.getElementsByTagName('tbody')[0]
|
|
130
|
+
results.forEach((r) => {
|
|
131
|
+
const rowData = []
|
|
132
|
+
|
|
133
|
+
// Result
|
|
134
|
+
const resultText = document.createElement('span')
|
|
135
|
+
resultText.classList.add(r.error ? 'lh-fail' : 'lh-pass')
|
|
136
|
+
resultText.appendChild(document.createTextNode(r.error ? 'FAIL' : 'PASS'))
|
|
137
|
+
rowData.push(resultText)
|
|
138
|
+
|
|
139
|
+
// Scores
|
|
140
|
+
Object.entries(r.scores).forEach(([k, v]) => {
|
|
141
|
+
rowData.push(document.createTextNode(`${v} (${r.thresholds[k]})`))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Page
|
|
145
|
+
if (r.addReportLink) {
|
|
146
|
+
const link = document.createElement('a')
|
|
147
|
+
link.href = r.href
|
|
148
|
+
link.appendChild(document.createTextNode(r.name))
|
|
149
|
+
rowData.push(link)
|
|
150
|
+
} else {
|
|
151
|
+
rowData.push(document.createTextNode(r.name))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// append row to body
|
|
155
|
+
lhTableBody.appendChild(createTr(rowData, 'td', !!r.error))
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const iframe = document.getElementById('lh-iframe')
|
|
159
|
+
const scrollTopButton = document.getElementById('scrollTop')
|
|
160
|
+
|
|
161
|
+
// add click listener to open specific lighthouse report
|
|
162
|
+
lhTableBody.addEventListener('click', (e) => {
|
|
163
|
+
if (e.target && e.target.nodeName == 'A') {
|
|
164
|
+
iframe.src = e.target.href
|
|
165
|
+
iframe.scrollIntoView({ behavior: 'smooth' })
|
|
166
|
+
scrollTopButton.classList.remove('hide')
|
|
167
|
+
}
|
|
168
|
+
e.preventDefault()
|
|
169
|
+
e.stopPropagation()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// scroll top
|
|
173
|
+
scrollTopButton.addEventListener('click', (e) => {
|
|
174
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
|
|
175
|
+
iframe.src = ''
|
|
176
|
+
scrollTopButton.classList.add('hide')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// checkbox change listener to show/hide passed
|
|
180
|
+
document
|
|
181
|
+
.getElementById('table-filter')
|
|
182
|
+
.getElementsByTagName('input')[0]
|
|
183
|
+
.addEventListener('change', (e) => {
|
|
184
|
+
lhTable.classList.toggle('hide-passed')
|
|
185
|
+
})
|
|
186
|
+
</script>
|
|
187
|
+
</html>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const fse = require("fs-extra");
|
|
5
|
+
const writeCsvResult = async (reportDir, name, scores, thresholds, swimlanes = []) => {
|
|
6
|
+
let csvData = Object.keys(scores).join(",");
|
|
7
|
+
if (swimlanes.length > 0) {
|
|
8
|
+
csvData += "," + swimlanes.map((swimlane) => `${swimlane}_threshold`);
|
|
9
|
+
}
|
|
10
|
+
csvData += "\n";
|
|
11
|
+
csvData += Object.values(scores).join(",");
|
|
12
|
+
if (swimlanes.length > 0) {
|
|
13
|
+
csvData += "," + swimlanes.map((swimlane) => thresholds[swimlane]);
|
|
14
|
+
}
|
|
15
|
+
await fse.writeFile(path.join(reportDir, `${name}.csv`), csvData);
|
|
16
|
+
};
|
|
17
|
+
const writeHtmlListEntry = async (htmlFilePath, name, scores, thresholds, error, addReportLink) => {
|
|
18
|
+
const htmlFileData = (await fse.readFile(htmlFilePath)).toString("utf-8").split("\n");
|
|
19
|
+
const insertIdx = htmlFileData.findIndex((v) => v.includes("// lighthouse-page-results"));
|
|
20
|
+
if (insertIdx < 0) {
|
|
21
|
+
throw new Error("Failed to write results to index.html");
|
|
22
|
+
}
|
|
23
|
+
htmlFileData.splice(insertIdx + 1, 0, JSON.stringify({ href: `${name}.html`, name, scores, thresholds, error, addReportLink }) + ",");
|
|
24
|
+
await fse.writeFile(htmlFilePath, htmlFileData.join("\n"));
|
|
25
|
+
};
|
|
26
|
+
const writeHtmlListEntryWithRetry = async (htmlFilePath, name, scores, thresholds, comparisonError, addReportLink = true) => {
|
|
27
|
+
let attempts = 10;
|
|
28
|
+
let error;
|
|
29
|
+
while (attempts > 0) {
|
|
30
|
+
attempts--;
|
|
31
|
+
try {
|
|
32
|
+
await writeHtmlListEntry(htmlFilePath, name, scores, thresholds, comparisonError, addReportLink);
|
|
33
|
+
return;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
error = e;
|
|
36
|
+
}
|
|
37
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
};
|
|
41
|
+
const getScores = (result) => Object.entries(result.lhr.categories).reduce((prev, [key, c]) => {
|
|
42
|
+
prev[key] = Math.floor(c.score * 100);
|
|
43
|
+
return prev;
|
|
44
|
+
}, {});
|
|
45
|
+
exports.getScores = getScores;
|
|
46
|
+
exports.writeCsvResult = writeCsvResult;
|
|
47
|
+
exports.writeHtmlListEntryWithRetry = writeHtmlListEntryWithRetry;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface LighthouseResult {
|
|
2
|
+
lhr: {
|
|
3
|
+
categories: {
|
|
4
|
+
[k: string]: {
|
|
5
|
+
score: number;
|
|
6
|
+
};
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
comparisonError?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const writeCsvResult: (reportDir: string, name: string, scores: Record<string, number>, thresholds: Record<string, number>, swimlanes?: Array<string>) => Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* workaround conflicts when multiple threads write the same file
|
|
14
|
+
*/
|
|
15
|
+
export declare const writeHtmlListEntryWithRetry: (htmlFilePath: string, name: string, scores: Record<string, number>, thresholds: Record<string, number>, comparisonError?: string, addReportLink?: boolean) => Promise<void>;
|
|
16
|
+
export declare const getScores: (result: LighthouseResult) => Record<string, number>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fse from "fs-extra";
|
|
3
|
+
const writeCsvResult = async (reportDir, name, scores, thresholds, swimlanes = []) => {
|
|
4
|
+
let csvData = Object.keys(scores).join(",");
|
|
5
|
+
if (swimlanes.length > 0) {
|
|
6
|
+
csvData += "," + swimlanes.map((swimlane) => `${swimlane}_threshold`);
|
|
7
|
+
}
|
|
8
|
+
csvData += "\n";
|
|
9
|
+
csvData += Object.values(scores).join(",");
|
|
10
|
+
if (swimlanes.length > 0) {
|
|
11
|
+
csvData += "," + swimlanes.map((swimlane) => thresholds[swimlane]);
|
|
12
|
+
}
|
|
13
|
+
await fse.writeFile(path.join(reportDir, `${name}.csv`), csvData);
|
|
14
|
+
};
|
|
15
|
+
const writeHtmlListEntry = async (htmlFilePath, name, scores, thresholds, error, addReportLink) => {
|
|
16
|
+
const htmlFileData = (await fse.readFile(htmlFilePath)).toString("utf-8").split("\n");
|
|
17
|
+
const insertIdx = htmlFileData.findIndex((v) => v.includes("// lighthouse-page-results"));
|
|
18
|
+
if (insertIdx < 0) {
|
|
19
|
+
throw new Error("Failed to write results to index.html");
|
|
20
|
+
}
|
|
21
|
+
htmlFileData.splice(insertIdx + 1, 0, JSON.stringify({ href: `${name}.html`, name, scores, thresholds, error, addReportLink }) + ",");
|
|
22
|
+
await fse.writeFile(htmlFilePath, htmlFileData.join("\n"));
|
|
23
|
+
};
|
|
24
|
+
const writeHtmlListEntryWithRetry = async (htmlFilePath, name, scores, thresholds, comparisonError, addReportLink = true) => {
|
|
25
|
+
let attempts = 10;
|
|
26
|
+
let error;
|
|
27
|
+
while (attempts > 0) {
|
|
28
|
+
attempts--;
|
|
29
|
+
try {
|
|
30
|
+
await writeHtmlListEntry(htmlFilePath, name, scores, thresholds, comparisonError, addReportLink);
|
|
31
|
+
return;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
error = e;
|
|
34
|
+
}
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
};
|
|
39
|
+
const getScores = (result) => Object.entries(result.lhr.categories).reduce((prev, [key, c]) => {
|
|
40
|
+
prev[key] = Math.floor(c.score * 100);
|
|
41
|
+
return prev;
|
|
42
|
+
}, {});
|
|
43
|
+
export {
|
|
44
|
+
getScores,
|
|
45
|
+
writeCsvResult,
|
|
46
|
+
writeHtmlListEntryWithRetry
|
|
47
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const getPort = require("get-port");
|
|
6
|
+
const test = require("@playwright/test");
|
|
7
|
+
const constants = require("./constants-667b8033.cjs");
|
|
8
|
+
const lighthouseTest = test.test.extend({
|
|
9
|
+
port: [
|
|
10
|
+
// eslint-disable-next-line no-empty-pattern
|
|
11
|
+
async ({}, use) => {
|
|
12
|
+
const port = await getPort();
|
|
13
|
+
await use(port);
|
|
14
|
+
},
|
|
15
|
+
{ scope: "worker" }
|
|
16
|
+
],
|
|
17
|
+
context: [
|
|
18
|
+
async ({ port, launchOptions }, use) => {
|
|
19
|
+
const context = await test.chromium.launchPersistentContext(
|
|
20
|
+
path.join(os.tmpdir(), constants.PW_TMP_DIR, `${Math.random()}`.replace(".", "")),
|
|
21
|
+
{
|
|
22
|
+
args: [...launchOptions.args || [], `--remote-debugging-port=${port}`]
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
await use(context);
|
|
26
|
+
await context.close();
|
|
27
|
+
},
|
|
28
|
+
{ scope: "test" }
|
|
29
|
+
]
|
|
30
|
+
});
|
|
31
|
+
exports.lighthouseTest = lighthouseTest;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BrowserContext } from '@playwright/test';
|
|
2
|
+
export declare const lighthouseTest: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & {
|
|
3
|
+
context: BrowserContext;
|
|
4
|
+
}, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & {
|
|
5
|
+
port: number;
|
|
6
|
+
}>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import getPort from "get-port";
|
|
4
|
+
import { test, chromium } from "@playwright/test";
|
|
5
|
+
import { P as PW_TMP_DIR } from "./constants-226e9774.js";
|
|
6
|
+
const lighthouseTest = test.extend({
|
|
7
|
+
port: [
|
|
8
|
+
// eslint-disable-next-line no-empty-pattern
|
|
9
|
+
async ({}, use) => {
|
|
10
|
+
const port = await getPort();
|
|
11
|
+
await use(port);
|
|
12
|
+
},
|
|
13
|
+
{ scope: "worker" }
|
|
14
|
+
],
|
|
15
|
+
context: [
|
|
16
|
+
async ({ port, launchOptions }, use) => {
|
|
17
|
+
const context = await chromium.launchPersistentContext(
|
|
18
|
+
path.join(os.tmpdir(), PW_TMP_DIR, `${Math.random()}`.replace(".", "")),
|
|
19
|
+
{
|
|
20
|
+
args: [...launchOptions.args || [], `--remote-debugging-port=${port}`]
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
await use(context);
|
|
24
|
+
await context.close();
|
|
25
|
+
},
|
|
26
|
+
{ scope: "test" }
|
|
27
|
+
]
|
|
28
|
+
});
|
|
29
|
+
export {
|
|
30
|
+
lighthouseTest
|
|
31
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lighthouse-reporting",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "vite build && vite build -c vite.umd.config.ts && tsc -p ./tsconfig.build.json",
|
|
7
|
+
"compile": "tsc",
|
|
8
|
+
"lint": "eslint --ext .ts --ignore-path .gitignore .",
|
|
9
|
+
"precommit": "run-p lint compile",
|
|
10
|
+
"semantic-release": "semantic-release"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/"
|
|
14
|
+
],
|
|
15
|
+
"main": "./dist/lighthouse-reporting.umd.cjs",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"require": "./dist/lighthouse-reporting.umd.cjs",
|
|
22
|
+
"default": "./dist/index.cjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@playwright/test": "1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@playwright/test": "^1.34.2",
|
|
30
|
+
"@types/fs-extra": "^11.0.1",
|
|
31
|
+
"@types/node": "^20.2.3",
|
|
32
|
+
"@typescript-eslint/eslint-plugin": "^5.59.7",
|
|
33
|
+
"@typescript-eslint/parser": "^5.59.7",
|
|
34
|
+
"eslint": "^8.41.0",
|
|
35
|
+
"eslint-config-prettier": "^8.8.0",
|
|
36
|
+
"eslint-plugin-prettier": "^4.2.1",
|
|
37
|
+
"husky": "^8.0.3",
|
|
38
|
+
"npm-run-all": "^4.1.5",
|
|
39
|
+
"prettier": "^2.8.8",
|
|
40
|
+
"semantic-release": "^21.0.2",
|
|
41
|
+
"typescript": "^5.0.4",
|
|
42
|
+
"vite": "^4.3.8",
|
|
43
|
+
"vite-plugin-static-copy": "^0.15.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"fs-extra": "^11.1.1",
|
|
47
|
+
"get-port": "^6.1.2"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "https://github.com/mgrybyk/lighthouse-reporting.git"
|
|
52
|
+
}
|
|
53
|
+
}
|