mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-07-25 08:27:19 +02:00
Download the per-test coverage index
`coverage_search` now fetches `per-test-index.json` from the pdf.js.refs gh-pages branch, caches it locally, and only re-downloads it (via ETag) when it changed, so querying which ref tests cover a line/function no longer needs a local `--coverage-per-test` build. `--no-download` reuses the cached index offline and `--index` points at a local one. `browsertest` and `makeref` accept the same `--code` filter to run (or regenerate refs for) only the covering tests, dropping any covered IDs that aren't in this branch's manifest so the run isn't rejected.
This commit is contained in:
parent
dd7e3731d1
commit
d504000012
25
README.md
25
README.md
@ -136,14 +136,31 @@ browsable HTML report instead, or pass several formats at once, e.g.
|
||||
|
||||
### Finding which tests cover a given line
|
||||
|
||||
Run a browser test task with `--coverage-per-test` to build an index
|
||||
(`per-test-index.json`) in the coverage directory, then query it to list the
|
||||
tests that exercised a specific source line or function:
|
||||
`coverage_search` lists the ref tests that exercised a specific source line or
|
||||
function. It uses the per-test index (`per-test-index.json`) that is rebuilt on
|
||||
every push to `master` and published to the
|
||||
[`pdf.js.refs`](https://github.com/mozilla/pdf.js.refs/tree/gh-pages)
|
||||
repository. The index is downloaded on demand, cached locally, and only
|
||||
re-downloaded when it has changed, so no local coverage build is required:
|
||||
|
||||
$ npx gulp botbrowsertest --coverage-per-test
|
||||
$ npx gulp coverage_search --code="canvas.js::205"
|
||||
$ npx gulp coverage_search --code="canvas.js::drawImageAtIntegerCoords"
|
||||
|
||||
To run — or regenerate the reference images for — only the ref tests that touch
|
||||
a given line or function, pass the same `--code` option to a browser test or
|
||||
`makeref` task:
|
||||
|
||||
$ npx gulp browsertest --code="canvas.js::205"
|
||||
$ npx gulp makeref --code="canvas.js::205"
|
||||
|
||||
Pass `--no-download` to reuse the locally cached index without contacting the
|
||||
network. The index can also be built and queried locally (the CI job that
|
||||
publishes it builds it the same way):
|
||||
|
||||
$ npx gulp botbrowsertest --coverage-per-test
|
||||
$ npx gulp coverage_search --code="canvas.js::205" \
|
||||
--index=build/coverage/per-test-index.json --no-download
|
||||
|
||||
### Continuous integration
|
||||
|
||||
On every push and pull request three GitHub Actions workflows collect coverage
|
||||
|
||||
164
external/ccov/coverage_search.mjs
vendored
164
external/ccov/coverage_search.mjs
vendored
@ -20,26 +20,46 @@ import path from "path";
|
||||
const __dirname = import.meta.dirname;
|
||||
const PROJECT_ROOT = path.join(__dirname, "../..");
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
code: { type: "string" },
|
||||
"coverage-dir": { type: "string", default: "build/coverage" },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
});
|
||||
// The per-test coverage index (which ref test exercises which source
|
||||
// line/function) is rebuilt on every push to master and published to the
|
||||
// gh-pages branch of the pdf.js.refs repository.
|
||||
const PER_TEST_INDEX_URL =
|
||||
"https://raw.githubusercontent.com/mozilla/pdf.js.refs/gh-pages/per-test-index.json";
|
||||
|
||||
let values;
|
||||
try {
|
||||
({ values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
code: { type: "string" },
|
||||
index: { type: "string", default: "build/per-test-index.json" },
|
||||
"no-download": { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
// parseArgs is strict, so an unknown/renamed option (e.g. the removed
|
||||
// --coverage-dir) would otherwise abort with an uncaught stack trace.
|
||||
console.error(`Error: ${error.message}`);
|
||||
console.error("Run with --help to see the available options.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (values.help || !values.code) {
|
||||
console.log(
|
||||
"Usage: coverage_search.mjs --code=<file>::<line|function> [--coverage-dir=<path>]\n\n" +
|
||||
"Usage: coverage_search.mjs --code=<file>::<line|function> [--index=<path>] [--no-download]\n\n" +
|
||||
" --code Source file and line number or function name to search for.\n" +
|
||||
" Examples:\n" +
|
||||
" --code=canvas.js::205\n" +
|
||||
" --code=canvas.js::drawImageAtIntegerCoords\n" +
|
||||
" --coverage-dir Coverage directory containing per-test-index.json [build/coverage]\n\n" +
|
||||
" --index Where to cache or read the per-test index.\n" +
|
||||
" [build/per-test-index.json]\n" +
|
||||
" --no-download Don't contact the network; use the cached index as-is.\n\n" +
|
||||
"Prints to stdout the IDs of tests whose coverage includes the given line or\n" +
|
||||
"function (one ID per line).\n" +
|
||||
"Run browsertest with --coverage-per-test first to generate the index."
|
||||
"function (one ID per line).\n\n" +
|
||||
"The index is downloaded from the pdf.js.refs repository and cached locally;\n" +
|
||||
"it is only re-downloaded when the published file has changed.\n" +
|
||||
`Source: ${PER_TEST_INDEX_URL}`
|
||||
);
|
||||
process.exit(values.help ? 0 : 1);
|
||||
}
|
||||
@ -58,18 +78,124 @@ const isLine = /^\d+$/.test(location);
|
||||
const lineNum = isLine ? parseInt(location, 10) : null;
|
||||
const funcName = isLine ? null : location;
|
||||
|
||||
const coverageDir = path.isAbsolute(values["coverage-dir"])
|
||||
? values["coverage-dir"]
|
||||
: path.join(PROJECT_ROOT, values["coverage-dir"]);
|
||||
const indexPath = path.isAbsolute(values.index)
|
||||
? values.index
|
||||
: path.join(PROJECT_ROOT, values.index);
|
||||
// The ETag of the cached copy is stored alongside it, so the next run can ask
|
||||
// the server (via If-None-Match) to only re-send the file when it has changed.
|
||||
const etagPath = `${indexPath}.etag`;
|
||||
|
||||
// Refreshes the locally cached index from the published copy, downloading it
|
||||
// only when it has changed since the last run. When the network is unavailable
|
||||
// a previously cached copy is reused if present.
|
||||
async function refreshIndex() {
|
||||
const hasCached = fs.existsSync(indexPath);
|
||||
|
||||
if (values["no-download"]) {
|
||||
return; // Freshness check disabled; the read below validates existence.
|
||||
}
|
||||
|
||||
// On any download failure, fall back to a previously cached copy when one
|
||||
// exists; otherwise there's nothing to search, so fail.
|
||||
const fallbackOrFail = reason => {
|
||||
if (hasCached) {
|
||||
console.error(
|
||||
`Warning: couldn't refresh per-test index (${reason}); using the cached copy.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(`Error: couldn't download per-test index (${reason}).`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
const headers = {};
|
||||
if (hasCached && fs.existsSync(etagPath)) {
|
||||
const etag = fs.readFileSync(etagPath, "utf8").trim();
|
||||
if (etag) {
|
||||
headers["If-None-Match"] = etag;
|
||||
}
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
console.error(`Fetching per-test index from ${PER_TEST_INDEX_URL} ...`);
|
||||
response = await fetch(PER_TEST_INDEX_URL, { headers });
|
||||
} catch (error) {
|
||||
fallbackOrFail(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 304) {
|
||||
console.error("Per-test index is up to date.");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
fallbackOrFail(`HTTP ${response.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = Buffer.from(await response.arrayBuffer());
|
||||
} catch (error) {
|
||||
fallbackOrFail(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the payload before caching it.
|
||||
try {
|
||||
JSON.parse(data.toString("utf8"));
|
||||
} catch {
|
||||
fallbackOrFail("the downloaded index is not valid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to a temporary file and rename it into place.
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
||||
const tmpPath = `${indexPath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmpPath, data);
|
||||
fs.renameSync(tmpPath, indexPath);
|
||||
|
||||
const etag = response.headers.get("etag");
|
||||
if (etag) {
|
||||
fs.writeFileSync(etagPath, etag);
|
||||
} else {
|
||||
fs.rmSync(etagPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// A write failure (disk full, read-only dir, ...) shouldn't be fatal when
|
||||
// a usable cached copy already exists.
|
||||
fallbackOrFail(error.message);
|
||||
return;
|
||||
}
|
||||
console.error(`Per-test index updated (${data.length} bytes).`);
|
||||
}
|
||||
|
||||
await refreshIndex();
|
||||
|
||||
const indexPath = path.join(coverageDir, "per-test-index.json");
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.error(`Error: index file not found: ${indexPath}`);
|
||||
console.error("Run browsertest with --coverage-per-test first.");
|
||||
console.error(`Error: per-test index not found: ${indexPath}`);
|
||||
console.error(
|
||||
"Build it locally (gulp botbrowsertest --coverage-per-test) or omit " +
|
||||
"--no-download to fetch it from the pdf.js.refs repository."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { ids, files } = JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
||||
let ids, files;
|
||||
try {
|
||||
({ ids, files } = JSON.parse(fs.readFileSync(indexPath, "utf8")));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error: couldn't read per-test index at ${indexPath}: ${error.message}`
|
||||
);
|
||||
console.error(
|
||||
"The cached index may be corrupt; delete it and re-run without " +
|
||||
"--no-download to refetch it."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find the file entry whose path matches fileName.
|
||||
let fileEntry = null;
|
||||
|
||||
213
gulpfile.mjs
213
gulpfile.mjs
@ -793,36 +793,18 @@ function runTests(testsName, { bot = false } = {}) {
|
||||
args.push("--coveragePerTest");
|
||||
}
|
||||
|
||||
const codeArg = testsName === "browser" ? getArgValue("--code") : null;
|
||||
if (codeArg) {
|
||||
const coverageDir =
|
||||
getArgValue("--coverage-output") || BUILD_DIR + "coverage";
|
||||
const result = spawnSync(
|
||||
"node",
|
||||
[
|
||||
path.join(__dirname, "external/ccov/coverage_search.mjs"),
|
||||
`--code=${codeArg}`,
|
||||
`--coverage-dir=${coverageDir}`,
|
||||
],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
reject(new Error(result.stderr?.trim() || "coverage_search failed"));
|
||||
if (testsName === "browser") {
|
||||
let shouldRun;
|
||||
try {
|
||||
shouldRun = applyCodeTestFilter(args);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
const testIds = result.stdout.trim().split("\n").filter(Boolean);
|
||||
if (testIds.length === 0) {
|
||||
console.log(`\n### No tests found covering "${codeArg}"`);
|
||||
if (!shouldRun) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`\n### Found ${testIds.length} test(s) covering "${codeArg}":\n` +
|
||||
testIds.map(id => ` ${id}`).join("\n")
|
||||
);
|
||||
for (const id of testIds) {
|
||||
args.push(`-t=${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const testProcess = startNode(args, { cwd: TEST_DIR, stdio: "inherit" });
|
||||
@ -888,6 +870,136 @@ function collectArgs(options, args) {
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the coverage_search command line. By default the published index is
|
||||
// downloaded; an explicit --index=<path> selects a local index instead, used
|
||||
// read-only so it's never overwritten by the published copy.
|
||||
function getCoverageSearchArgs(codeArg) {
|
||||
const searchArgs = [
|
||||
path.join(__dirname, "external/ccov/coverage_search.mjs"),
|
||||
`--code=${codeArg}`,
|
||||
];
|
||||
const indexArg = getArgValue("--index");
|
||||
if (indexArg === "") {
|
||||
// An explicit but empty value (e.g. an unset shell variable expanding to
|
||||
// `--index=`) almost certainly isn't intended; fail loudly rather than
|
||||
// silently falling back to the downloaded index.
|
||||
throw new Error("--index was given without a value");
|
||||
}
|
||||
if (indexArg) {
|
||||
searchArgs.push(`--index=${indexArg}`, "--no-download");
|
||||
} else if (process.argv.includes("--no-download")) {
|
||||
searchArgs.push("--no-download");
|
||||
}
|
||||
return searchArgs;
|
||||
}
|
||||
|
||||
// Returns the set of test IDs defined in the local ref-test manifest, or null
|
||||
// when it can't be read. Used to drop coverage-derived IDs that don't exist on
|
||||
// this branch (e.g. a test renamed since the published index was built), which
|
||||
// would otherwise make test.mjs reject the entire run.
|
||||
function readManifestTestIds() {
|
||||
try {
|
||||
const manifestFile = process.env.PDF_TEST || "test_manifest.json";
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, TEST_DIR, manifestFile), "utf8")
|
||||
);
|
||||
return new Set(manifest.map(entry => entry.id));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// For a --code=<file>::<line|function> argument, runs coverage_search to find
|
||||
// the ref tests that exercise that location and returns their IDs (an empty
|
||||
// array when none match locally). Returns null when --code wasn't given; throws
|
||||
// when the search itself fails.
|
||||
function resolveCodeTestIds() {
|
||||
const codeArg = getArgValue("--code");
|
||||
if (!codeArg) {
|
||||
return null;
|
||||
}
|
||||
// Inherit stderr so the index download progress is visible; stdout is
|
||||
// captured because it carries the matching test IDs.
|
||||
const result = spawnSync("node", getCoverageSearchArgs(codeArg), {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
// status is null when the child couldn't be spawned or was killed by a
|
||||
// signal; surface the real cause instead of a generic message.
|
||||
throw new Error(
|
||||
result.error
|
||||
? `coverage_search failed: ${result.error.message}`
|
||||
: `coverage_search failed (exit code ${result.status})`
|
||||
);
|
||||
}
|
||||
let testIds = result.stdout.trim().split("\n").filter(Boolean);
|
||||
|
||||
// The published index is built from master, so some covering tests may not
|
||||
// exist on this branch. Drop them (with a note) rather than letting test.mjs
|
||||
// reject the whole run — and silently exit 0 — on the first unknown ID.
|
||||
const knownIds = readManifestTestIds();
|
||||
if (knownIds) {
|
||||
const missing = testIds.filter(id => !knownIds.has(id));
|
||||
if (missing.length) {
|
||||
console.log(
|
||||
`\n### Ignoring ${missing.length} covered test(s) not in the manifest:\n` +
|
||||
missing.map(id => ` ${id}`).join("\n")
|
||||
);
|
||||
testIds = testIds.filter(id => knownIds.has(id));
|
||||
}
|
||||
}
|
||||
|
||||
if (testIds.length === 0) {
|
||||
console.log(`\n### No tests found covering "${codeArg}"`);
|
||||
} else {
|
||||
console.log(
|
||||
`\n### Found ${testIds.length} test(s) covering "${codeArg}":\n` +
|
||||
testIds.map(id => ` ${id}`).join("\n")
|
||||
);
|
||||
}
|
||||
return testIds;
|
||||
}
|
||||
|
||||
function appendTestFilters(args, testIds) {
|
||||
const existingIds = new Set();
|
||||
|
||||
// collectArgs always normalizes filters to the space-separated
|
||||
// `-t <id>` / `--testfilter <id>` form, so that's the only shape to read.
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "-t" || args[i] === "--testfilter") {
|
||||
if (i + 1 < args.length) {
|
||||
existingIds.add(args[++i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of testIds) {
|
||||
if (!existingIds.has(id)) {
|
||||
// Pass the id as a separate argument: node's parseArgs parses the short
|
||||
// form `-t=<id>` as the literal value "=<id>", which matches no test and
|
||||
// aborts the run with "Unrecognized test IDs".
|
||||
args.push("-t", id);
|
||||
existingIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Applies the --code coverage filter to `args`. Returns false when the run
|
||||
// should be skipped (--code was given but resolved to no runnable tests), and
|
||||
// true otherwise (no --code, or matching tests were appended). Throws when the
|
||||
// coverage search itself fails.
|
||||
function applyCodeTestFilter(args) {
|
||||
const codeTestIds = resolveCodeTestIds();
|
||||
if (codeTestIds === null) {
|
||||
return true; // No --code argument; run normally.
|
||||
}
|
||||
if (codeTestIds.length === 0) {
|
||||
return false; // Nothing (runnable) covers the requested location.
|
||||
}
|
||||
appendTestFilters(args, codeTestIds);
|
||||
return true;
|
||||
}
|
||||
|
||||
function makeRef(done, bot) {
|
||||
console.log("\n### Creating reference images");
|
||||
|
||||
@ -936,6 +1048,18 @@ function makeRef(done, bot) {
|
||||
args
|
||||
);
|
||||
|
||||
let shouldRun;
|
||||
try {
|
||||
shouldRun = applyCodeTestFilter(args);
|
||||
} catch (error) {
|
||||
done(error);
|
||||
return;
|
||||
}
|
||||
if (!shouldRun) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const testProcess = startNode(args, { cwd: TEST_DIR, stdio: "inherit" });
|
||||
testProcess.on("close", function (code) {
|
||||
if (code !== 0) {
|
||||
@ -946,34 +1070,33 @@ function makeRef(done, bot) {
|
||||
});
|
||||
}
|
||||
|
||||
// Queries the per-test coverage index built by --coverage-per-test and prints
|
||||
// the IDs of tests that exercised a given source file location. Run with
|
||||
// --code=<file>::<line|function>, e.g. --code=canvas.js::205
|
||||
// Prints the IDs of tests that exercised a given source file location, using
|
||||
// the per-test coverage index published to the pdf.js.refs repository. The
|
||||
// index is downloaded on demand, cached locally, and only re-downloaded when
|
||||
// it has changed. Run with --code=<file>::<line|function>, e.g.
|
||||
// --code=canvas.js::205 (add --no-download to reuse the cached index offline).
|
||||
gulp.task("coverage_search", function (done) {
|
||||
const codeArg = getArgValue("--code");
|
||||
if (!codeArg) {
|
||||
done(new Error('Missing --code argument, e.g. --code="canvas.js::205"'));
|
||||
return;
|
||||
}
|
||||
const coverageDir =
|
||||
getArgValue("--coverage-output") || BUILD_DIR + "coverage";
|
||||
const result = spawnSync(
|
||||
"node",
|
||||
[
|
||||
path.join(__dirname, "external/ccov/coverage_search.mjs"),
|
||||
`--code=${codeArg}`,
|
||||
`--coverage-dir=${coverageDir}`,
|
||||
],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
let searchArgs;
|
||||
try {
|
||||
searchArgs = getCoverageSearchArgs(codeArg);
|
||||
} catch (error) {
|
||||
done(error);
|
||||
return;
|
||||
}
|
||||
const result = spawnSync("node", searchArgs, { stdio: "inherit" });
|
||||
if (result.status !== 0) {
|
||||
done(new Error("coverage_search failed"));
|
||||
done(
|
||||
new Error(
|
||||
result.error
|
||||
? `coverage_search failed: ${result.error.message}`
|
||||
: `coverage_search failed (exit code ${result.status})`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
done();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user