deno/tools/wpt.ts

813 lines
23 KiB
TypeScript
Raw Normal View History

#!/usr/bin/env -S deno run --unstable --allow-write --allow-read --allow-net --allow-env --allow-run
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// This script is used to run WPT tests for Deno.
import {
runSingleTest,
runWithTestUtil,
TestCaseResult,
TestResult,
} from "./wpt/runner.ts";
import {
assert,
autoConfig,
cargoBuild,
checkPy3Available,
escapeLoneSurrogates,
Expectation,
generateRunInfo,
getExpectation,
getExpectFailForCase,
getManifest,
inspectBrk,
json,
ManifestFolder,
ManifestTestOptions,
ManifestTestVariation,
noIgnore,
quiet,
rest,
runPy,
updateManifest,
wptreport,
} from "./wpt/utils.ts";
import { pooledMap } from "../test_util/std/async/pool.ts";
import { blue, bold, green, red, yellow } from "../test_util/std/fmt/colors.ts";
import { writeAll, writeAllSync } from "../test_util/std/streams/write_all.ts";
import { saveExpectation } from "./wpt/utils.ts";
class TestFilter {
filter?: string[];
constructor(filter?: string[]) {
this.filter = filter;
}
matches(path: string): boolean {
if (this.filter === undefined || this.filter.length == 0) {
return true;
}
for (const filter of this.filter) {
if (filter.startsWith("/")) {
if (path.startsWith(filter)) {
return true;
}
} else {
if (path.substring(1).startsWith(filter)) {
return true;
}
}
}
return false;
}
}
const command = Deno.args[0];
switch (command) {
case "setup":
await checkPy3Available();
await updateManifest();
await setup();
break;
case "run":
await cargoBuild();
await run();
break;
case "update":
await cargoBuild();
await update();
break;
default:
console.log(`Possible commands:
setup
Validate that your environment is configured correctly, or help you configure it.
run
Run all tests like specified in \`expectation.json\`.
update
Update the \`expectation.json\` to match the current reality.
More details at https://deno.land/manual@main/contributing/web_platform_tests
`);
break;
}
async function setup() {
2021-06-14 18:57:02 +00:00
const hostsPath = Deno.build.os == "windows"
? `${Deno.env.get("SystemRoot")}\\System32\\drivers\\etc\\hosts`
: "/etc/hosts";
// TODO(lucacsonato): use this when 1.7.1 is released.
// const records = await Deno.resolveDns("web-platform.test", "A");
// const etcHostsConfigured = records[0] == "127.0.0.1";
2021-06-14 18:57:02 +00:00
const hostsFile = await Deno.readTextFile(hostsPath);
const etcHostsConfigured = hostsFile.includes("web-platform.test");
if (etcHostsConfigured) {
2021-06-14 18:57:02 +00:00
console.log(hostsPath + " is already configured.");
} else {
const autoConfigure = autoConfig ||
confirm(
2021-06-14 18:57:02 +00:00
`The WPT require certain entries to be present in your ${hostsPath} file. Should these be configured automatically?`,
);
if (autoConfigure) {
const { success, stdout } = await runPy(["wpt", "make-hosts-file"], {
stdout: "piped",
}).output();
assert(success, "wpt make-hosts-file should not fail");
const entries = new TextDecoder().decode(stdout);
const file = await Deno.open(hostsPath, { append: true }).catch((err) => {
if (err instanceof Deno.errors.PermissionDenied) {
throw new Error(
`Failed to open ${hostsPath} (permission error). Please run this command again with sudo, or configure the entries manually.`,
);
} else {
throw err;
}
});
await writeAll(
file,
new TextEncoder().encode(
"\n\n# Configured for Web Platform Tests (Deno)\n" + entries,
),
);
2021-06-14 18:57:02 +00:00
console.log(`Updated ${hostsPath}`);
} else {
2021-06-14 18:57:02 +00:00
console.log(`Please configure the ${hostsPath} entries manually.`);
if (Deno.build.os == "windows") {
console.log("To do this run the following command in PowerShell:");
console.log("");
console.log(" cd test_util/wpt/");
console.log(
" python.exe wpt make-hosts-file | Out-File $env:SystemRoot\\System32\\drivers\\etc\\hosts -Encoding ascii -Append",
);
console.log("");
} else {
console.log("To do this run the following command in your shell:");
console.log("");
console.log(" cd test_util/wpt/");
console.log(
" python3 ./wpt make-hosts-file | sudo tee -a /etc/hosts",
);
console.log("");
}
}
}
console.log(green("Setup complete!"));
}
interface TestToRun {
path: string;
url: URL;
options: ManifestTestOptions;
expectation: boolean | string[];
}
function getTestTimeout(test: TestToRun) {
if (Deno.env.get("CI")) {
// Don't give expected failures the full time
if (test.expectation === false) {
return { long: 60_000, default: 10_000 };
}
return { long: 4 * 60_000, default: 4 * 60_000 };
}
return { long: 60_000, default: 10_000 };
}
async function run() {
const startTime = new Date().getTime();
assert(Array.isArray(rest), "filter must be array");
2021-05-09 14:32:30 +00:00
const expectation = getExpectation();
const filter = new TestFilter(rest);
2021-05-09 14:32:30 +00:00
const tests = discoverTestsToRun(
filter,
2021-05-09 14:32:30 +00:00
expectation,
);
assertAllExpectationsHaveTests(expectation, tests, filter);
const cores = navigator.hardwareConcurrency;
console.log(`Going to run ${tests.length} test files on ${cores} cores.`);
const results = await runWithTestUtil(false, async () => {
const results: { test: TestToRun; result: TestResult }[] = [];
const inParallel = !(cores === 1 || tests.length === 1);
// ideally we would parallelize all tests, but we ran into some flakiness
// on the CI, so here we're partitioning based on the start of the test path
const partitionedTests = partitionTests(tests);
const iter = pooledMap(cores, partitionedTests, async (tests) => {
for (const test of tests) {
if (!inParallel) {
console.log(`${blue("-".repeat(40))}\n${bold(test.path)}\n`);
}
const result = await runSingleTest(
test.url,
test.options,
inParallel ? () => {} : createReportTestCase(test.expectation),
inspectBrk,
getTestTimeout(test),
);
results.push({ test, result });
if (inParallel) {
console.log(`${blue("-".repeat(40))}\n${bold(test.path)}\n`);
}
reportVariation(result, test.expectation);
}
});
for await (const _ of iter) {
// ignore
}
return results;
});
const endTime = new Date().getTime();
if (json) {
const minifiedResults = [];
for (const result of results) {
const minified = {
file: result.test.path,
name:
Object.fromEntries(result.test.options.script_metadata ?? []).title ??
null,
cases: result.result.cases.map((case_) => ({
name: case_.name,
passed: case_.passed,
})),
};
minifiedResults.push(minified);
}
await Deno.writeTextFile(json, JSON.stringify(minifiedResults));
}
if (wptreport) {
const report = await generateWptReport(results, startTime, endTime);
await Deno.writeTextFile(wptreport, JSON.stringify(report));
}
const code = reportFinal(results, endTime - startTime);
Deno.exit(code);
}
async function generateWptReport(
results: { test: TestToRun; result: TestResult }[],
startTime: number,
endTime: number,
) {
const runInfo = await generateRunInfo();
const reportResults = [];
for (const { test, result } of results) {
const status = result.status !== 0
? "CRASH"
: result.harnessStatus?.status === 0
? "OK"
: "ERROR";
let message;
if (result.harnessStatus === null && result.status === 0) {
// If the only error is the event loop running out of tasks, using stderr
// as the message won't help.
message = "Event loop run out of tasks.";
} else {
message = result.harnessStatus?.message ?? (result.stderr.trim() || null);
}
const reportResult = {
test: test.url.pathname + test.url.search + test.url.hash,
subtests: result.cases.map((case_) => {
let expected = undefined;
if (!case_.passed) {
if (typeof test.expectation === "boolean") {
expected = test.expectation ? "PASS" : "FAIL";
} else if (Array.isArray(test.expectation)) {
expected = test.expectation.includes(case_.name) ? "FAIL" : "PASS";
} else {
expected = "PASS";
}
}
return {
name: escapeLoneSurrogates(case_.name),
status: case_.passed ? "PASS" : "FAIL",
message: escapeLoneSurrogates(case_.message),
expected,
known_intermittent: [],
};
}),
status,
message: escapeLoneSurrogates(message),
duration: result.duration,
expected: status === "OK" ? undefined : "OK",
"known_intermittent": [],
};
reportResults.push(reportResult);
}
return {
"run_info": runInfo,
"time_start": startTime,
"time_end": endTime,
"results": reportResults,
};
}
2021-05-09 14:32:30 +00:00
// Check that all expectations in the expectations file have a test that will be
// run.
function assertAllExpectationsHaveTests(
expectation: Expectation,
testsToRun: TestToRun[],
filter: TestFilter,
2021-05-09 14:32:30 +00:00
): void {
const tests = new Set(testsToRun.map((t) => t.path));
const missingTests: string[] = [];
function walk(parentExpectation: Expectation, parent: string) {
for (const [key, expectation] of Object.entries(parentExpectation)) {
2021-05-09 14:32:30 +00:00
const path = `${parent}/${key}`;
if (!filter.matches(path)) continue;
2021-05-09 14:32:30 +00:00
if (typeof expectation == "boolean" || Array.isArray(expectation)) {
if (!tests.has(path)) {
missingTests.push(path);
}
} else {
walk(expectation, path);
}
}
}
walk(expectation, "");
if (missingTests.length > 0) {
console.log(
red(
"Following tests are missing in manifest, but are present in expectations:",
),
);
console.log("");
console.log(missingTests.join("\n"));
Deno.exit(1);
}
}
async function update() {
assert(Array.isArray(rest), "filter must be array");
const startTime = new Date().getTime();
const filter = new TestFilter(rest);
const tests = discoverTestsToRun(filter, true);
console.log(`Going to run ${tests.length} test files.`);
const results = await runWithTestUtil(false, async () => {
const results = [];
for (const test of tests) {
console.log(`${blue("-".repeat(40))}\n${bold(test.path)}\n`);
const result = await runSingleTest(
test.url,
test.options,
json ? () => {} : createReportTestCase(test.expectation),
inspectBrk,
{ long: 60_000, default: 10_000 },
);
results.push({ test, result });
reportVariation(result, test.expectation);
}
return results;
});
const endTime = new Date().getTime();
if (json) {
await Deno.writeTextFile(json, JSON.stringify(results));
}
const resultTests: Record<
string,
{ passed: string[]; failed: string[]; testSucceeded: boolean }
> = {};
for (const { test, result } of results) {
2021-05-09 14:32:30 +00:00
if (!resultTests[test.path]) {
resultTests[test.path] = {
passed: [],
failed: [],
testSucceeded: result.status === 0 && result.harnessStatus !== null,
};
}
for (const case_ of result.cases) {
if (case_.passed) {
2021-05-09 14:32:30 +00:00
resultTests[test.path].passed.push(case_.name);
} else {
2021-05-09 14:32:30 +00:00
resultTests[test.path].failed.push(case_.name);
}
}
}
const currentExpectation = getExpectation();
for (const [path, result] of Object.entries(resultTests)) {
const { passed, failed, testSucceeded } = result;
let finalExpectation: boolean | string[];
if (failed.length == 0 && testSucceeded) {
finalExpectation = true;
} else if (failed.length > 0 && passed.length > 0 && testSucceeded) {
finalExpectation = failed;
} else {
finalExpectation = false;
}
insertExpectation(
path.slice(1).split("/"),
currentExpectation,
finalExpectation,
);
}
saveExpectation(currentExpectation);
reportFinal(results, endTime - startTime);
console.log(blue("Updated expectation.json to match reality."));
Deno.exit(0);
}
function insertExpectation(
segments: string[],
currentExpectation: Expectation,
finalExpectation: boolean | string[],
) {
const segment = segments.shift();
assert(segment, "segments array must never be empty");
if (segments.length > 0) {
if (
!currentExpectation[segment] ||
Array.isArray(currentExpectation[segment]) ||
typeof currentExpectation[segment] === "boolean"
) {
currentExpectation[segment] = {};
}
insertExpectation(
segments,
currentExpectation[segment] as Expectation,
finalExpectation,
);
} else {
currentExpectation[segment] = finalExpectation;
}
}
function reportFinal(
results: { test: TestToRun; result: TestResult }[],
duration: number,
): number {
const finalTotalCount = results.length;
let finalFailedCount = 0;
const finalFailed: [string, TestCaseResult][] = [];
let finalExpectedFailedAndFailedCount = 0;
const finalExpectedFailedButPassedTests: [string, TestCaseResult][] = [];
const finalExpectedFailedButPassedFiles: string[] = [];
const finalFailedFiles: string[] = [];
for (const { test, result } of results) {
const {
failed,
failedCount,
expectedFailedButPassed,
expectedFailedAndFailedCount,
} = analyzeTestResult(
result,
test.expectation,
);
if (result.status !== 0 || result.harnessStatus === null) {
if (test.expectation === false) {
finalExpectedFailedAndFailedCount += 1;
} else {
finalFailedCount += 1;
finalFailedFiles.push(test.path);
}
} else if (failedCount > 0) {
finalFailedCount += 1;
for (const case_ of failed) {
finalFailed.push([test.path, case_]);
}
for (const case_ of expectedFailedButPassed) {
finalExpectedFailedButPassedTests.push([test.path, case_]);
}
} else if (
test.expectation === false &&
expectedFailedAndFailedCount != result.cases.length
) {
finalExpectedFailedButPassedFiles.push(test.path);
}
}
const finalPassedCount = finalTotalCount - finalFailedCount;
console.log(bold(blue("=".repeat(40))));
if (finalFailed.length > 0) {
console.log(`\nfailures:\n`);
}
for (const result of finalFailed) {
console.log(
` ${JSON.stringify(`${result[0]} - ${result[1].name}`)}`,
);
}
if (finalFailedFiles.length > 0) {
console.log(`\nfile failures:\n`);
}
for (const result of finalFailedFiles) {
console.log(
` ${JSON.stringify(result)}`,
);
}
if (finalExpectedFailedButPassedTests.length > 0) {
console.log(`\nexpected test failures that passed:\n`);
}
for (const result of finalExpectedFailedButPassedTests) {
console.log(
` ${JSON.stringify(`${result[0]} - ${result[1].name}`)}`,
);
}
if (finalExpectedFailedButPassedFiles.length > 0) {
console.log(`\nexpected file failures that passed:\n`);
}
for (const result of finalExpectedFailedButPassedFiles) {
console.log(` ${JSON.stringify(result)}`);
}
const failed = (finalFailedCount > 0) ||
(finalExpectedFailedButPassedFiles.length > 0);
console.log(
`\nfinal result: ${
failed ? red("failed") : green("ok")
}. ${finalPassedCount} passed; ${finalFailedCount} failed; ${finalExpectedFailedAndFailedCount} expected failure; total ${finalTotalCount} (${duration}ms)\n`,
);
return failed ? 1 : 0;
}
function analyzeTestResult(
result: TestResult,
expectation: boolean | string[],
): {
failed: TestCaseResult[];
failedCount: number;
passedCount: number;
totalCount: number;
expectedFailedButPassed: TestCaseResult[];
expectedFailedButPassedCount: number;
expectedFailedAndFailedCount: number;
} {
const failed = result.cases.filter(
(t) => !getExpectFailForCase(expectation, t.name) && !t.passed,
);
const expectedFailedButPassed = result.cases.filter(
(t) => getExpectFailForCase(expectation, t.name) && t.passed,
);
const expectedFailedButPassedCount = expectedFailedButPassed.length;
const failedCount = failed.length + expectedFailedButPassedCount;
const expectedFailedAndFailedCount = result.cases.filter(
(t) => getExpectFailForCase(expectation, t.name) && !t.passed,
).length;
const totalCount = result.cases.length;
const passedCount = totalCount - failedCount - expectedFailedAndFailedCount;
return {
failed,
failedCount,
passedCount,
totalCount,
expectedFailedButPassed,
expectedFailedButPassedCount,
expectedFailedAndFailedCount,
};
}
function reportVariation(result: TestResult, expectation: boolean | string[]) {
if (result.status !== 0 || result.harnessStatus === null) {
if (result.stderr) {
console.log(`test stderr:\n${result.stderr}\n`);
}
const expectFail = expectation === false;
const failReason = result.status !== 0
? "runner failed during test"
: "the event loop run out of tasks during the test";
console.log(
`\nfile result: ${
expectFail ? yellow("failed (expected)") : red("failed")
}. ${failReason} (${formatDuration(result.duration)})\n`,
);
return;
}
const {
failed,
failedCount,
passedCount,
totalCount,
expectedFailedButPassed,
expectedFailedButPassedCount,
expectedFailedAndFailedCount,
} = analyzeTestResult(result, expectation);
if (failed.length > 0) {
console.log(`\nfailures:`);
}
for (const result of failed) {
console.log(`\n${result.name}\n${result.message}\n${result.stack}`);
}
if (failedCount > 0) {
console.log(`\nfailures:\n`);
}
for (const result of failed) {
console.log(` ${JSON.stringify(result.name)}`);
}
if (expectedFailedButPassedCount > 0) {
console.log(`\nexpected failures that passed:\n`);
}
for (const result of expectedFailedButPassed) {
console.log(` ${JSON.stringify(result.name)}`);
}
if (result.stderr) {
console.log("\ntest stderr:\n" + result.stderr);
}
console.log(
`\nfile result: ${
failedCount > 0 ? red("failed") : green("ok")
}. ${passedCount} passed; ${failedCount} failed; ${expectedFailedAndFailedCount} expected failure; total ${totalCount} (${
formatDuration(result.duration)
})\n`,
);
}
function createReportTestCase(expectation: boolean | string[]) {
return function reportTestCase({ name, status }: TestCaseResult) {
const expectFail = getExpectFailForCase(expectation, name);
let simpleMessage = `test ${name} ... `;
switch (status) {
case 0:
if (expectFail) {
simpleMessage += red("ok (expected fail)");
} else {
simpleMessage += green("ok");
if (quiet) {
// don't print `ok` tests if --quiet is enabled
return;
}
}
break;
case 1:
if (expectFail) {
simpleMessage += yellow("failed (expected)");
} else {
simpleMessage += red("failed");
}
break;
case 2:
if (expectFail) {
simpleMessage += yellow("failed (expected)");
} else {
simpleMessage += red("failed (timeout)");
}
break;
case 3:
if (expectFail) {
simpleMessage += yellow("failed (expected)");
} else {
simpleMessage += red("failed (incomplete)");
}
break;
}
writeAllSync(Deno.stdout, new TextEncoder().encode(simpleMessage + "\n"));
};
}
function discoverTestsToRun(
filter: TestFilter,
expectation: Expectation | string[] | boolean = getExpectation(),
): TestToRun[] {
const manifestFolder = getManifest().items.testharness;
const testsToRun: TestToRun[] = [];
function walk(
parentFolder: ManifestFolder,
parentExpectation: Expectation | string[] | boolean,
prefix: string,
) {
for (const [key, entry] of Object.entries(parentFolder)) {
if (Array.isArray(entry)) {
for (
const [path, options] of entry.slice(
1,
) as ManifestTestVariation[]
) {
if (!path) continue;
const url = new URL(path, "http://web-platform.test:8000");
if (
!url.pathname.endsWith(".any.html") &&
!url.pathname.endsWith(".window.html") &&
chore(wpt): Enable WPT worker tests (#12222) Classic workers were implemented in denoland#11338, which also enabled the WPT tests in the `workers` directory. However, the rest of WPT worker tests were not enabled because a number of them were hanging due to web-platform-tests/wpt#29777. Now that that WPT issue is fixed, the bulk of worker tests can be enabled. There are still a few tests that hang, and so haven't been enabled. In particular: - The following tests seem to hang because a promise fails to resolve. We can detect such cases in non-worker tests because the process will exit without calling the WPT completion callback, but in worker tests the worker message ops will keep the event loop running. This will be fixed when we add timeouts to WPT tests (denoland#9460). - `/fetch/api/basic/error-after-response.any.worker.html` - `/html/webappapis/microtask-queuing/queue-microtask-exceptions.any.worker.html` - `/webmessaging/message-channels/worker-post-after-close.any.worker.html` - `/webmessaging/message-channels/worker.any.worker.html` - `/websockets/Create-on-worker-shutdown.any.worker.html` - The following tests apparently hang because a promise rejection is never handled, which will kill the process in the main thread but not in workers (denoland#12221). - `/streams/readable-streams/async-iterator.any.worker.html` - `/workers/interfaces/WorkerUtils/importScripts/report-error-setTimeout-cross-origin.sub.any.worker.html` - `/workers/interfaces/WorkerUtils/importScripts/report-error-setTimeout-redirect-to-cross-origin.sub.any.worker.html` - `/workers/interfaces/WorkerUtils/importScripts/report-error-setTimeout-same-origin.sub.any.worker.html`
2021-10-08 15:44:38 +00:00
!url.pathname.endsWith(".worker.html") &&
!url.pathname.endsWith(".worker-module.html")
) {
continue;
}
// These tests require an HTTP2 compatible server.
if (url.pathname.includes(".h2.")) {
continue;
}
// Streaming fetch requests need a server that supports chunked
// encoding, which the WPT test server does not. Unfortunately this
// also disables some useful fetch tests.
if (url.pathname.includes("request-upload")) {
continue;
}
2021-05-09 14:32:30 +00:00
const finalPath = url.pathname + url.search;
const split = finalPath.split("/");
const finalKey = split[split.length - 1];
const expectation = Array.isArray(parentExpectation) ||
typeof parentExpectation == "boolean"
? parentExpectation
: parentExpectation[finalKey];
if (expectation === undefined) continue;
if (typeof expectation === "object") {
if (typeof expectation.ignore !== "undefined") {
assert(
typeof expectation.ignore === "boolean",
"test entry's `ignore` key must be a boolean",
);
if (expectation.ignore === true && !noIgnore) continue;
}
}
if (!noIgnore) {
assert(
Array.isArray(expectation) || typeof expectation == "boolean",
"test entry must not have a folder expectation",
);
}
2021-05-09 14:32:30 +00:00
if (!filter.matches(finalPath)) continue;
testsToRun.push({
2021-05-09 14:32:30 +00:00
path: finalPath,
url,
options,
expectation,
});
}
} else {
2021-05-09 14:32:30 +00:00
const expectation = Array.isArray(parentExpectation) ||
typeof parentExpectation == "boolean"
? parentExpectation
: parentExpectation[key];
if (expectation === undefined) continue;
walk(entry, expectation, `${prefix}/${key}`);
}
}
}
walk(manifestFolder, expectation, "");
return testsToRun;
}
function partitionTests(tests: TestToRun[]): TestToRun[][] {
const testsByKey: { [key: string]: TestToRun[] } = {};
for (const test of tests) {
// Run all WebCryptoAPI tests in parallel
if (test.path.includes("/WebCryptoAPI")) {
testsByKey[test.path] = [test];
continue;
}
// Paths looks like: /fetch/corb/img-html-correctly-labeled.sub-ref.html
const key = test.path.split("/")[1];
if (!(key in testsByKey)) {
testsByKey[key] = [];
}
testsByKey[key].push(test);
}
return Object.values(testsByKey);
}
function formatDuration(duration: number): string {
if (duration >= 5000) {
return red(`${duration}ms`);
} else if (duration >= 1000) {
return yellow(`${duration}ms`);
} else {
return `${duration}ms`;
}
}