mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
520 lines
15 KiB
JavaScript
520 lines
15 KiB
JavaScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const ROOT_DIR = process.cwd();
|
|
const RAW_DIR = path.join(ROOT_DIR, "artifacts", "playwright-coverage", "raw");
|
|
const OUTPUT_DIR = path.join(ROOT_DIR, "artifacts", "playwright-coverage");
|
|
const SOURCE_DIRS = ["js", "torrent"];
|
|
const SOURCE_EXTENSIONS = new Set([".js"]);
|
|
const METHOD_KEYWORD_BLOCKLIST = new Set([
|
|
"if",
|
|
"for",
|
|
"while",
|
|
"switch",
|
|
"catch",
|
|
"return",
|
|
"else",
|
|
"do",
|
|
"try",
|
|
"class",
|
|
"function",
|
|
"import",
|
|
"export",
|
|
"new",
|
|
]);
|
|
|
|
function normalizeSlashes(value) {
|
|
return value.split(path.sep).join("/");
|
|
}
|
|
|
|
function toRelativeIfInRepo(absPath) {
|
|
const rel = path.relative(ROOT_DIR, absPath);
|
|
if (rel.startsWith("..")) return null;
|
|
return normalizeSlashes(rel);
|
|
}
|
|
|
|
async function walkFiles(startDir) {
|
|
const entries = await fs.readdir(startDir, { withFileTypes: true });
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(startDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...(await walkFiles(fullPath)));
|
|
continue;
|
|
}
|
|
files.push(fullPath);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function maskStringsAndComments(source) {
|
|
const out = source.split("");
|
|
let i = 0;
|
|
let state = "code";
|
|
while (i < out.length) {
|
|
const ch = out[i];
|
|
const next = out[i + 1];
|
|
|
|
if (state === "line-comment") {
|
|
if (ch === "\n") {
|
|
state = "code";
|
|
} else {
|
|
out[i] = " ";
|
|
}
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (state === "block-comment") {
|
|
if (ch === "*" && next === "/") {
|
|
out[i] = " ";
|
|
out[i + 1] = " ";
|
|
i += 2;
|
|
state = "code";
|
|
} else {
|
|
out[i] = ch === "\n" ? "\n" : " ";
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (state === "single-quote") {
|
|
if (ch === "\\" && i + 1 < out.length) {
|
|
out[i] = " ";
|
|
out[i + 1] = " ";
|
|
i += 2;
|
|
continue;
|
|
}
|
|
out[i] = ch === "\n" ? "\n" : " ";
|
|
if (ch === "'") state = "code";
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (state === "double-quote") {
|
|
if (ch === "\\" && i + 1 < out.length) {
|
|
out[i] = " ";
|
|
out[i + 1] = " ";
|
|
i += 2;
|
|
continue;
|
|
}
|
|
out[i] = ch === "\n" ? "\n" : " ";
|
|
if (ch === "\"") state = "code";
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (state === "template") {
|
|
if (ch === "\\" && i + 1 < out.length) {
|
|
out[i] = " ";
|
|
out[i + 1] = " ";
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (ch === "`") {
|
|
out[i] = " ";
|
|
i += 1;
|
|
state = "code";
|
|
continue;
|
|
}
|
|
if (ch === "$" && next === "{") {
|
|
out[i] = " ";
|
|
out[i + 1] = " ";
|
|
i += 2;
|
|
let depth = 1;
|
|
while (i < out.length && depth > 0) {
|
|
const c = out[i];
|
|
if (c === "{") depth += 1;
|
|
else if (c === "}") depth -= 1;
|
|
if (depth > 0) out[i] = c === "\n" ? "\n" : " ";
|
|
i += 1;
|
|
}
|
|
continue;
|
|
}
|
|
out[i] = ch === "\n" ? "\n" : " ";
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (ch === "/" && next === "/") {
|
|
out[i] = " ";
|
|
out[i + 1] = " ";
|
|
i += 2;
|
|
state = "line-comment";
|
|
continue;
|
|
}
|
|
if (ch === "/" && next === "*") {
|
|
out[i] = " ";
|
|
out[i + 1] = " ";
|
|
i += 2;
|
|
state = "block-comment";
|
|
continue;
|
|
}
|
|
if (ch === "'") {
|
|
out[i] = " ";
|
|
state = "single-quote";
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (ch === "\"") {
|
|
out[i] = " ";
|
|
state = "double-quote";
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (ch === "`") {
|
|
out[i] = " ";
|
|
state = "template";
|
|
i += 1;
|
|
continue;
|
|
}
|
|
i += 1;
|
|
}
|
|
return out.join("");
|
|
}
|
|
|
|
function estimateStaticFunctionCount(source) {
|
|
const masked = maskStringsAndComments(source);
|
|
const seenPositions = new Set();
|
|
|
|
const functionPattern = /\bfunction\b\s*[A-Za-z0-9_$]*\s*\(/g;
|
|
for (const match of masked.matchAll(functionPattern)) {
|
|
seenPositions.add(match.index);
|
|
}
|
|
|
|
const arrowPattern = /(?:^|[=(:,\[])\s*(?:async\s*)?(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*=>/gm;
|
|
for (const match of masked.matchAll(arrowPattern)) {
|
|
seenPositions.add(match.index);
|
|
}
|
|
|
|
const methodPattern = /(?:^|\n)\s*(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*\([^()\n{};]*\)\s*\{/gm;
|
|
for (const match of masked.matchAll(methodPattern)) {
|
|
const methodName = match[1];
|
|
if (METHOD_KEYWORD_BLOCKLIST.has(methodName)) continue;
|
|
seenPositions.add(match.index);
|
|
}
|
|
|
|
return seenPositions.size;
|
|
}
|
|
|
|
function isLikelyRootCoverageFunction(fn, sourceLength) {
|
|
if (!fn || !Array.isArray(fn.ranges) || fn.ranges.length === 0) return false;
|
|
const startOffset = Math.min(...fn.ranges.map((range) => range.startOffset));
|
|
const endOffset = Math.max(...fn.ranges.map((range) => range.endOffset));
|
|
const rootSpan = startOffset === 0 && endOffset >= sourceLength - 1;
|
|
const name = fn.functionName || "";
|
|
const anonymous = name === "" || name === "(anonymous)";
|
|
return rootSpan && anonymous;
|
|
}
|
|
|
|
function normalizeCoverageUrlToFile(url) {
|
|
if (!url) return null;
|
|
try {
|
|
const parsed = new URL(url);
|
|
const pathname = decodeURIComponent(parsed.pathname || "");
|
|
const rel = pathname.replace(/^\/+/, "");
|
|
if (!rel) return null;
|
|
return normalizeSlashes(rel);
|
|
} catch {
|
|
if (url.startsWith("/")) return normalizeSlashes(url.replace(/^\/+/, ""));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeMessage(text) {
|
|
return String(text)
|
|
.replace(/\b\d+\b/g, "<n>")
|
|
.replace(/\b0x[a-f0-9]+\b/gi, "0x<hex>")
|
|
.replace(/https?:\/\/\S+/g, "<url>")
|
|
.trim();
|
|
}
|
|
|
|
async function main() {
|
|
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
|
|
|
let rawFiles = [];
|
|
try {
|
|
rawFiles = (await walkFiles(RAW_DIR)).filter((file) => file.endsWith(".json"));
|
|
} catch {
|
|
rawFiles = [];
|
|
}
|
|
|
|
if (rawFiles.length === 0) {
|
|
const message = `No raw Playwright coverage artifacts found at ${normalizeSlashes(path.relative(ROOT_DIR, RAW_DIR))}. Run with PLAYWRIGHT_COVERAGE=1 first.`;
|
|
console.error(message);
|
|
process.exit(1);
|
|
}
|
|
|
|
const perFileObserved = new Map();
|
|
const allConsoleLogs = [];
|
|
const allPageErrors = [];
|
|
|
|
for (const rawPath of rawFiles) {
|
|
const raw = JSON.parse(await fs.readFile(rawPath, "utf8"));
|
|
const rawRelPath = toRelativeIfInRepo(rawPath) ?? rawPath;
|
|
|
|
if (Array.isArray(raw.logs)) {
|
|
for (const log of raw.logs) {
|
|
allConsoleLogs.push({
|
|
test: raw.titlePath || [raw.title || rawRelPath],
|
|
project: raw.project || "unknown",
|
|
type: log.type || "log",
|
|
text: log.text || "",
|
|
location: log.location || null,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(raw.pageErrors)) {
|
|
for (const pageError of raw.pageErrors) {
|
|
allPageErrors.push({
|
|
test: raw.titlePath || [raw.title || rawRelPath],
|
|
project: raw.project || "unknown",
|
|
message: pageError.message || String(pageError),
|
|
stack: pageError.stack || "",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(raw.coverage)) continue;
|
|
|
|
for (const entry of raw.coverage) {
|
|
const relFile = normalizeCoverageUrlToFile(entry.url);
|
|
if (!relFile) continue;
|
|
const ext = path.extname(relFile);
|
|
if (!SOURCE_EXTENSIONS.has(ext)) continue;
|
|
if (!SOURCE_DIRS.some((prefix) => relFile.startsWith(`${prefix}/`))) continue;
|
|
|
|
const absFile = path.join(ROOT_DIR, relFile);
|
|
try {
|
|
await fs.access(absFile);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const existing = perFileObserved.get(relFile) || {
|
|
functionMap: new Map(),
|
|
};
|
|
|
|
for (const fn of entry.functions || []) {
|
|
if (isLikelyRootCoverageFunction(fn, entry.text?.length ?? 0)) continue;
|
|
const startOffset = Math.min(...fn.ranges.map((range) => range.startOffset));
|
|
const endOffset = Math.max(...fn.ranges.map((range) => range.endOffset));
|
|
const key = `${startOffset}:${endOffset}:${fn.functionName || ""}`;
|
|
const covered = fn.ranges.some((range) => range.count > 0);
|
|
const previous = existing.functionMap.get(key) || false;
|
|
existing.functionMap.set(key, previous || covered);
|
|
}
|
|
|
|
perFileObserved.set(relFile, existing);
|
|
}
|
|
}
|
|
|
|
const sourceFiles = [];
|
|
for (const sourceDir of SOURCE_DIRS) {
|
|
const absDir = path.join(ROOT_DIR, sourceDir);
|
|
let files = [];
|
|
try {
|
|
files = await walkFiles(absDir);
|
|
} catch {
|
|
files = [];
|
|
}
|
|
for (const absPath of files) {
|
|
const ext = path.extname(absPath);
|
|
if (!SOURCE_EXTENSIONS.has(ext)) continue;
|
|
const rel = toRelativeIfInRepo(absPath);
|
|
if (!rel) continue;
|
|
sourceFiles.push(rel);
|
|
}
|
|
}
|
|
|
|
sourceFiles.sort();
|
|
|
|
const fileSummaries = [];
|
|
let overallCoveredFunctions = 0;
|
|
let overallEstimatedFunctions = 0;
|
|
let loadedFileCount = 0;
|
|
|
|
for (const relFile of sourceFiles) {
|
|
const absPath = path.join(ROOT_DIR, relFile);
|
|
const source = await fs.readFile(absPath, "utf8");
|
|
const estimatedFunctions = estimateStaticFunctionCount(source);
|
|
const observed = perFileObserved.get(relFile);
|
|
const observedTotal = observed ? observed.functionMap.size : 0;
|
|
const observedCovered = observed
|
|
? Array.from(observed.functionMap.values()).filter(Boolean).length
|
|
: 0;
|
|
const effectiveTotal = Math.max(estimatedFunctions, observedTotal);
|
|
const effectiveCovered = Math.min(observedCovered, effectiveTotal);
|
|
const pct = effectiveTotal > 0 ? (effectiveCovered / effectiveTotal) * 100 : 100;
|
|
|
|
if (observed) loadedFileCount += 1;
|
|
overallEstimatedFunctions += effectiveTotal;
|
|
overallCoveredFunctions += effectiveCovered;
|
|
|
|
fileSummaries.push({
|
|
file: relFile,
|
|
loadedByPlaywright: Boolean(observed),
|
|
estimatedFunctions,
|
|
observedFunctions: observedTotal,
|
|
coveredFunctions: effectiveCovered,
|
|
functionCoveragePct: Number(pct.toFixed(2)),
|
|
});
|
|
}
|
|
|
|
fileSummaries.sort((a, b) => a.functionCoveragePct - b.functionCoveragePct);
|
|
|
|
const overallCoveragePct =
|
|
overallEstimatedFunctions > 0
|
|
? Number(((overallCoveredFunctions / overallEstimatedFunctions) * 100).toFixed(2))
|
|
: 0;
|
|
|
|
const consoleErrorLike = allConsoleLogs.filter((entry) =>
|
|
["error", "warning", "assert"].includes(String(entry.type).toLowerCase()),
|
|
);
|
|
|
|
const groupedIssues = new Map();
|
|
for (const entry of consoleErrorLike) {
|
|
const key = `console:${entry.type}:${normalizeMessage(entry.text)}`;
|
|
const existing = groupedIssues.get(key) || {
|
|
kind: "console",
|
|
level: entry.type,
|
|
message: normalizeMessage(entry.text),
|
|
count: 0,
|
|
samples: [],
|
|
};
|
|
existing.count += 1;
|
|
if (existing.samples.length < 5) {
|
|
existing.samples.push({
|
|
test: entry.test.join(" > "),
|
|
rawMessage: entry.text,
|
|
});
|
|
}
|
|
groupedIssues.set(key, existing);
|
|
}
|
|
|
|
for (const entry of allPageErrors) {
|
|
const key = `pageerror:${normalizeMessage(entry.message)}`;
|
|
const existing = groupedIssues.get(key) || {
|
|
kind: "pageerror",
|
|
level: "error",
|
|
message: normalizeMessage(entry.message),
|
|
count: 0,
|
|
samples: [],
|
|
};
|
|
existing.count += 1;
|
|
if (existing.samples.length < 5) {
|
|
existing.samples.push({
|
|
test: entry.test.join(" > "),
|
|
rawMessage: entry.message,
|
|
});
|
|
}
|
|
groupedIssues.set(key, existing);
|
|
}
|
|
|
|
const topIssues = Array.from(groupedIssues.values())
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 50);
|
|
|
|
const summary = {
|
|
generatedAt: new Date().toISOString(),
|
|
scope: {
|
|
sourceDirs: SOURCE_DIRS,
|
|
extensionFilter: Array.from(SOURCE_EXTENSIONS),
|
|
},
|
|
coverage: {
|
|
totalSourceFiles: sourceFiles.length,
|
|
loadedSourceFiles: loadedFileCount,
|
|
estimatedFunctionCount: overallEstimatedFunctions,
|
|
coveredFunctionCount: overallCoveredFunctions,
|
|
functionCoveragePct: overallCoveragePct,
|
|
},
|
|
testArtifacts: {
|
|
rawArtifactCount: rawFiles.length,
|
|
consoleLogCount: allConsoleLogs.length,
|
|
consoleErrorLikeCount: consoleErrorLike.length,
|
|
pageErrorCount: allPageErrors.length,
|
|
},
|
|
topIssues,
|
|
files: fileSummaries,
|
|
};
|
|
|
|
const lowCoverageFiles = fileSummaries
|
|
.filter((item) => item.estimatedFunctions >= 5)
|
|
.slice(0, 25);
|
|
|
|
const summaryMd = [
|
|
"# Playwright Function Coverage Summary",
|
|
"",
|
|
`Generated: ${summary.generatedAt}`,
|
|
"",
|
|
`Overall function coverage: **${overallCoveragePct}%**`,
|
|
"",
|
|
`Covered functions: ${overallCoveredFunctions}/${overallEstimatedFunctions}`,
|
|
"",
|
|
`Source files loaded during Playwright run: ${loadedFileCount}/${sourceFiles.length}`,
|
|
"",
|
|
"## Console/Page Error Signals",
|
|
"",
|
|
`Console logs captured: ${allConsoleLogs.length}`,
|
|
"",
|
|
`Console error-like entries: ${consoleErrorLike.length}`,
|
|
"",
|
|
`Page errors: ${allPageErrors.length}`,
|
|
"",
|
|
"## Highest-Risk Coverage Gaps",
|
|
"",
|
|
"| File | Coverage % | Covered/Total | Loaded |",
|
|
"| --- | ---: | ---: | :---: |",
|
|
...lowCoverageFiles.map(
|
|
(item) =>
|
|
`| ${item.file} | ${item.functionCoveragePct}% | ${item.coveredFunctions}/${item.estimatedFunctions} | ${item.loadedByPlaywright ? "yes" : "no"} |`,
|
|
),
|
|
"",
|
|
].join("\n");
|
|
|
|
const issueLogLines = [
|
|
`Generated: ${summary.generatedAt}`,
|
|
`Console logs: ${allConsoleLogs.length}`,
|
|
`Console error-like: ${consoleErrorLike.length}`,
|
|
`Page errors: ${allPageErrors.length}`,
|
|
"",
|
|
...topIssues.map((issue, index) => {
|
|
const samples = issue.samples
|
|
.map((sample) => ` - ${sample.test}: ${sample.rawMessage}`)
|
|
.join("\n");
|
|
return `${index + 1}. [${issue.kind}/${issue.level}] x${issue.count}\n ${issue.message}\n${samples}`;
|
|
}),
|
|
].join("\n");
|
|
|
|
await fs.writeFile(
|
|
path.join(OUTPUT_DIR, "function-coverage-summary.json"),
|
|
JSON.stringify(summary, null, 2),
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(path.join(OUTPUT_DIR, "function-coverage-summary.md"), summaryMd, "utf8");
|
|
await fs.writeFile(path.join(OUTPUT_DIR, "console-issues.log"), issueLogLines, "utf8");
|
|
await fs.writeFile(
|
|
path.join(OUTPUT_DIR, "console-log-summary.json"),
|
|
JSON.stringify(
|
|
{
|
|
generatedAt: summary.generatedAt,
|
|
consoleLogs: allConsoleLogs,
|
|
pageErrors: allPageErrors,
|
|
topIssues,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
console.log(`Playwright function coverage: ${overallCoveragePct}% (${overallCoveredFunctions}/${overallEstimatedFunctions})`);
|
|
console.log(`Loaded source files: ${loadedFileCount}/${sourceFiles.length}`);
|
|
console.log(`Console/page issues: ${topIssues.length} grouped signals`);
|
|
console.log(`Summary JSON: artifacts/playwright-coverage/function-coverage-summary.json`);
|
|
console.log(`Summary MD: artifacts/playwright-coverage/function-coverage-summary.md`);
|
|
console.log(`Issue log: artifacts/playwright-coverage/console-issues.log`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|