Skip to content

Commit ee6d5f2

Browse files
authored
Merge pull request #21598 from calixteman/use_per_test
Download the per-test coverage index
2 parents eddd70a + e6c7ab5 commit ee6d5f2

3 files changed

Lines changed: 339 additions & 68 deletions

File tree

README.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,31 @@ browsable HTML report instead, or pass several formats at once, e.g.
136136

137137
### Finding which tests cover a given line
138138

139-
Run a browser test task with `--coverage-per-test` to build an index
140-
(`per-test-index.json`) in the coverage directory, then query it to list the
141-
tests that exercised a specific source line or function:
139+
`coverage_search` lists the ref tests that exercised a specific source line or
140+
function. It uses the per-test index (`per-test-index.json`) that is rebuilt on
141+
every push to `master` and published to the
142+
[`pdf.js.refs`](https://github.com/mozilla/pdf.js.refs/tree/gh-pages)
143+
repository. The index is downloaded on demand, cached locally, and only
144+
re-downloaded when it has changed, so no local coverage build is required:
142145

143-
$ npx gulp botbrowsertest --coverage-per-test
144146
$ npx gulp coverage_search --code="canvas.js::205"
145147
$ npx gulp coverage_search --code="canvas.js::drawImageAtIntegerCoords"
146148

149+
To run — or regenerate the reference images for — only the ref tests that touch
150+
a given line or function, pass the same `--code` option to a browser test or
151+
`makeref` task:
152+
153+
$ npx gulp browsertest --code="canvas.js::205"
154+
$ npx gulp makeref --code="canvas.js::205"
155+
156+
Pass `--no-download` to reuse the locally cached index without contacting the
157+
network. The index can also be built and queried locally (the CI job that
158+
publishes it builds it the same way):
159+
160+
$ npx gulp botbrowsertest --coverage-per-test
161+
$ npx gulp coverage_search --code="canvas.js::205" \
162+
--index=build/coverage/per-test-index.json --no-download
163+
147164
### Continuous integration
148165

149166
On every push and pull request three GitHub Actions workflows collect coverage

external/ccov/coverage_search.mjs

Lines changed: 150 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,46 @@ import path from "path";
2020
const __dirname = import.meta.dirname;
2121
const PROJECT_ROOT = path.join(__dirname, "../..");
2222

23-
const { values } = parseArgs({
24-
args: process.argv.slice(2),
25-
options: {
26-
code: { type: "string" },
27-
"coverage-dir": { type: "string", default: "build/coverage" },
28-
help: { type: "boolean", short: "h", default: false },
29-
},
30-
});
23+
// The per-test coverage index (which ref test exercises which source
24+
// line/function) is rebuilt on every push to master and published to the
25+
// gh-pages branch of the pdf.js.refs repository.
26+
const PER_TEST_INDEX_URL =
27+
"https://raw.githubusercontent.com/mozilla/pdf.js.refs/gh-pages/per-test-index.json";
28+
29+
let values;
30+
try {
31+
({ values } = parseArgs({
32+
args: process.argv.slice(2),
33+
options: {
34+
code: { type: "string" },
35+
index: { type: "string", default: "build/per-test-index.json" },
36+
"no-download": { type: "boolean", default: false },
37+
help: { type: "boolean", short: "h", default: false },
38+
},
39+
}));
40+
} catch (error) {
41+
// parseArgs is strict, so an unknown/renamed option (e.g. the removed
42+
// --coverage-dir) would otherwise abort with an uncaught stack trace.
43+
console.error(`Error: ${error.message}`);
44+
console.error("Run with --help to see the available options.");
45+
process.exit(1);
46+
}
3147

3248
if (values.help || !values.code) {
3349
console.log(
34-
"Usage: coverage_search.mjs --code=<file>::<line|function> [--coverage-dir=<path>]\n\n" +
50+
"Usage: coverage_search.mjs --code=<file>::<line|function> [--index=<path>] [--no-download]\n\n" +
3551
" --code Source file and line number or function name to search for.\n" +
3652
" Examples:\n" +
3753
" --code=canvas.js::205\n" +
3854
" --code=canvas.js::drawImageAtIntegerCoords\n" +
39-
" --coverage-dir Coverage directory containing per-test-index.json [build/coverage]\n\n" +
55+
" --index Where to cache or read the per-test index.\n" +
56+
" [build/per-test-index.json]\n" +
57+
" --no-download Don't contact the network; use the cached index as-is.\n\n" +
4058
"Prints to stdout the IDs of tests whose coverage includes the given line or\n" +
41-
"function (one ID per line).\n" +
42-
"Run browsertest with --coverage-per-test first to generate the index."
59+
"function (one ID per line).\n\n" +
60+
"The index is downloaded from the pdf.js.refs repository and cached locally;\n" +
61+
"it is only re-downloaded when the published file has changed.\n" +
62+
`Source: ${PER_TEST_INDEX_URL}`
4363
);
4464
process.exit(values.help ? 0 : 1);
4565
}
@@ -58,18 +78,129 @@ const isLine = /^\d+$/.test(location);
5878
const lineNum = isLine ? parseInt(location, 10) : null;
5979
const funcName = isLine ? null : location;
6080

61-
const coverageDir = path.isAbsolute(values["coverage-dir"])
62-
? values["coverage-dir"]
63-
: path.join(PROJECT_ROOT, values["coverage-dir"]);
81+
const indexPath = path.isAbsolute(values.index)
82+
? values.index
83+
: path.join(PROJECT_ROOT, values.index);
84+
// The ETag of the cached copy is stored alongside it, so the next run can ask
85+
// the server (via If-None-Match) to only re-send the file when it has changed.
86+
const etagPath = `${indexPath}.etag`;
87+
88+
// Refreshes the locally cached index from the published copy, downloading it
89+
// only when it has changed since the last run. When the network is unavailable
90+
// a previously cached copy is reused if present.
91+
async function refreshIndex() {
92+
if (values["no-download"]) {
93+
return; // Freshness check disabled; the read below validates existence.
94+
}
95+
96+
const hasCached = fs.existsSync(indexPath);
97+
98+
// On any download failure, fall back to a previously cached copy when one
99+
// exists; otherwise there's nothing to search, so fail.
100+
const fallbackOrFail = reason => {
101+
if (hasCached) {
102+
console.error(
103+
`Warning: couldn't refresh per-test index (${reason}); using the cached copy.`
104+
);
105+
return;
106+
}
107+
console.error(`Error: couldn't download per-test index (${reason}).`);
108+
process.exit(1);
109+
};
110+
111+
const headers = {};
112+
if (hasCached && fs.existsSync(etagPath)) {
113+
const etag = fs.readFileSync(etagPath, "utf8").trim();
114+
// Only forward a syntactically valid HTTP ETag (RFC 7232), so the cached
115+
// file's contents can't be used to inject arbitrary data into the request.
116+
if (/^(?:W\/)?"[\x21\x23-\x7e]*"$/.test(etag)) {
117+
headers["If-None-Match"] = etag;
118+
}
119+
}
120+
121+
let response;
122+
try {
123+
console.error(`Fetching per-test index from ${PER_TEST_INDEX_URL} ...`);
124+
response = await fetch(PER_TEST_INDEX_URL, { headers });
125+
} catch (error) {
126+
fallbackOrFail(error.message);
127+
return;
128+
}
129+
130+
if (response.status === 304) {
131+
console.error("Per-test index is up to date.");
132+
return;
133+
}
134+
if (!response.ok) {
135+
fallbackOrFail(`HTTP ${response.status}`);
136+
return;
137+
}
138+
139+
let text;
140+
try {
141+
text = await response.text();
142+
} catch (error) {
143+
fallbackOrFail(error.message);
144+
return;
145+
}
146+
147+
// Parse the payload before caching it, and cache the re-serialized result
148+
// rather than the raw response body: only well-formed JSON produced by our
149+
// own JSON.stringify is ever written to disk.
150+
let serialized;
151+
try {
152+
serialized = JSON.stringify(JSON.parse(text));
153+
} catch {
154+
fallbackOrFail("the downloaded index is not valid JSON");
155+
return;
156+
}
157+
158+
// Write to a temporary file and rename it into place.
159+
try {
160+
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
161+
const tmpPath = `${indexPath}.${process.pid}.tmp`;
162+
fs.writeFileSync(tmpPath, serialized);
163+
fs.renameSync(tmpPath, indexPath);
164+
165+
const etag = response.headers.get("etag");
166+
if (etag) {
167+
fs.writeFileSync(etagPath, etag);
168+
} else {
169+
fs.rmSync(etagPath, { force: true });
170+
}
171+
} catch (error) {
172+
// A write failure (disk full, read-only dir, ...) shouldn't be fatal when
173+
// a usable cached copy already exists.
174+
fallbackOrFail(error.message);
175+
return;
176+
}
177+
console.error(`Per-test index updated (${serialized.length} bytes).`);
178+
}
179+
180+
await refreshIndex();
64181

65-
const indexPath = path.join(coverageDir, "per-test-index.json");
66182
if (!fs.existsSync(indexPath)) {
67-
console.error(`Error: index file not found: ${indexPath}`);
68-
console.error("Run browsertest with --coverage-per-test first.");
183+
console.error(`Error: per-test index not found: ${indexPath}`);
184+
console.error(
185+
"Build it locally (gulp botbrowsertest --coverage-per-test) or omit " +
186+
"--no-download to fetch it from the pdf.js.refs repository."
187+
);
69188
process.exit(1);
70189
}
71190

72-
const { ids, files } = JSON.parse(fs.readFileSync(indexPath, "utf8"));
191+
let ids, files;
192+
try {
193+
({ ids, files } = JSON.parse(fs.readFileSync(indexPath, "utf8")));
194+
} catch (error) {
195+
console.error(
196+
`Error: couldn't read per-test index at ${indexPath}: ${error.message}`
197+
);
198+
console.error(
199+
"The cached index may be corrupt; delete it and re-run without " +
200+
"--no-download to refetch it."
201+
);
202+
process.exit(1);
203+
}
73204

74205
// Find the file entry whose path matches fileName.
75206
let fileEntry = null;

0 commit comments

Comments
 (0)