Browse Source

build: add support for running wpt suites

deepak1556 4 months ago
parent
commit
e5a6036ce0

+ 50 - 0
.github/workflows/pipeline-segment-node-nan-test.yml

@@ -161,3 +161,53 @@ jobs:
         do
           sleep 60
         done
+  wpt-tests:
+    name: Run WPT Tests
+    runs-on: electron-arc-linux-amd64-4core
+    timeout-minutes: 20
+    env: 
+      TARGET_ARCH: ${{ inputs.target-arch }}
+      BUILD_TYPE: linux
+    container: ${{ fromJSON(inputs.test-container) }}
+    steps:
+    - name: Checkout Electron
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        path: src/electron
+        fetch-depth: 0
+    - name: Install Dependencies
+      run: |
+        cd src/electron
+        node script/yarn install --frozen-lockfile
+    - name: Download Generated Artifacts
+      uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16
+      with:
+        name: generated_artifacts_${{ env.BUILD_TYPE }}_${{ env.TARGET_ARCH }}
+        path: ./generated_artifacts_${{ env.BUILD_TYPE }}_${{ env.TARGET_ARCH }}
+    - name: Download Src Artifacts
+      uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16
+      with:
+        name: src_artifacts_linux_${{ env.TARGET_ARCH }}
+        path: ./src_artifacts_linux_${{ env.TARGET_ARCH }}
+    - name: Restore Generated Artifacts
+      run: ./src/electron/script/actions/restore-artifacts.sh
+    - name: Unzip Dist
+      run: |
+        cd src/out/Default
+        unzip -:o dist.zip
+    - name: Setup Linux for Headless Testing
+      run: sh -e /etc/init.d/xvfb start
+    - name: Run WPT Tests
+      run: |
+        cd src
+        chown :builduser . && chmod g+w .
+        chown -R :builduser ./electron && chmod -R g+w ./electron
+        chmod 4755 ./out/Default/chrome-sandbox
+        runuser -u builduser -- xvfb-run electron/script/actions/run-tests.sh electron/script/wpt-spec-runner.js
+    - name: Wait for active SSH sessions
+      if: always() && !cancelled()
+      run: |
+        while [ -f /var/.ssh-lock ]
+        do
+          sleep 60
+        done

+ 29 - 0
script/wpt-spec-runner.js

@@ -0,0 +1,29 @@
+const cp = require('node:child_process');
+const path = require('node:path');
+
+const utils = require('./lib/utils');
+
+const BASE = path.resolve(__dirname, '../..');
+const WPT_DIR = path.resolve(BASE, 'third_party', 'blink', 'web_tests', 'external', 'wpt');
+
+if (!require.main) {
+  throw new Error('Must call the wpt spec runner directly');
+}
+
+async function main () {
+  const testChild = cp.spawn(utils.getAbsoluteElectronExec(), [path.join(__dirname, 'wpt'), '--enable-logging=stderr'], {
+    env: {
+      ...process.env,
+      WPT_DIR
+    },
+    stdio: 'inherit'
+  });
+  testChild.on('exit', (testCode) => {
+    process.exit(testCode);
+  });
+}
+
+main().catch((err) => {
+  console.error('An unhandled error occurred in the wpt spec runner', err);
+  process.exit(1);
+});

+ 5 - 0
script/wpt/package.json

@@ -0,0 +1,5 @@
+{
+    "name": "electron-test-wpt",
+    "main": "start.mjs",
+    "type": "module"
+}

+ 388 - 0
script/wpt/runner/runner.mjs

@@ -0,0 +1,388 @@
+import { utilityProcess } from 'electron';
+
+import { EventEmitter, once } from 'node:events';
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { isAbsolute, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { colors, handlePipes, normalizeName, parseMeta, resolveStatusPath } from './util.mjs';
+
+const basePath = fileURLToPath(join(import.meta.url, '../..'));
+const testPath = process.env.WPT_DIR;
+const statusPath = join(basePath, 'status');
+
+// https://github.com/web-platform-tests/wpt/blob/b24eedd/resources/testharness.js#L3705
+function sanitizeUnpairedSurrogates (str) {
+  return str.replace(
+    /([\ud800-\udbff]+)(?![\udc00-\udfff])|(^|[^\ud800-\udbff])([\udc00-\udfff]+)/g,
+    function (_, low, prefix, high) {
+      let output = prefix || ''; // Prefix may be undefined
+      const string = low || high; // Only one of these alternates can match
+      for (let i = 0; i < string.length; i++) {
+        output += codeUnitStr(string[i]);
+      }
+      return output;
+    });
+}
+
+function codeUnitStr (char) {
+  return 'U+' + char.charCodeAt(0).toString(16);
+}
+
+export class WPTRunner extends EventEmitter {
+  /** @type {string} */
+  #folderName;
+
+  /** @type {string} */
+  #folderPath;
+
+  /** @type {string[]} */
+  #files = [];
+
+  /** @type {string[]} */
+  #initScripts = [];
+
+  /** @type {string} */
+  #url;
+
+  /** @type {import('../../status/fetch.status.json')} */
+  #status;
+
+  /** Tests that have expectedly failed mapped by file name */
+  #statusOutput = {};
+
+  #uncaughtExceptions = [];
+
+  #stats = {
+    completedTests: 0,
+    failedTests: 0,
+    passedTests: 0,
+    expectedFailures: 0,
+    failedFiles: 0,
+    passedFiles: 0,
+    skippedFiles: 0
+  };
+
+  constructor (folder, url) {
+    super();
+
+    this.#folderName = folder;
+    this.#folderPath = join(testPath, folder);
+    this.#files.push(
+      ...WPTRunner.walk(
+        this.#folderPath,
+        (file) => file.endsWith('.any.js')
+      )
+    );
+
+    this.#status = JSON.parse(readFileSync(join(statusPath, `${folder}.status.json`)));
+    this.#url = url;
+
+    if (this.#files.length === 0) {
+      queueMicrotask(() => {
+        this.emit('completion');
+      });
+    }
+
+    this.once('completion', () => {
+      for (const { error, test } of this.#uncaughtExceptions) {
+        console.log(colors(`Uncaught exception in "${test}":`, 'red'));
+        console.log(colors(`${error.stack}`, 'red'));
+        console.log('='.repeat(96));
+      }
+    });
+  }
+
+  static walk (dir, fn) {
+    const ini = new Set(readdirSync(dir));
+    const files = new Set();
+
+    while (ini.size !== 0) {
+      for (const d of ini) {
+        const path = resolve(dir, d);
+        ini.delete(d); // remove from set
+        const stats = statSync(path);
+
+        if (stats.isDirectory()) {
+          for (const f of readdirSync(path)) {
+            ini.add(resolve(path, f));
+          }
+        } else if (stats.isFile() && fn(d)) {
+          files.add(path);
+        }
+      }
+    }
+
+    return [...files].sort();
+  }
+
+  async run () {
+    const workerPath = fileURLToPath(join(import.meta.url, '../worker.mjs'));
+    /** @type {Set<Worker>} */
+    const activeWorkers = new Set();
+    let finishedFiles = 1;
+    let total = this.#files.length;
+
+    const files = this.#files.map((test) => {
+      const code = test.includes('.sub.')
+        ? handlePipes(readFileSync(test, 'utf-8'), this.#url)
+        : readFileSync(test, 'utf-8');
+      const meta = this.resolveMeta(code, test);
+
+      if (meta.variant.length) {
+        total += meta.variant.length - 1;
+      }
+
+      return [test, code, meta];
+    });
+
+    console.log('='.repeat(96));
+
+    for (const [test, code, meta] of files) {
+      console.log(`Started ${test}`);
+
+      const status = resolveStatusPath(test, this.#status);
+
+      if (status.file.skip || status.topLevel.skip) {
+        this.#stats.skippedFiles += 1;
+
+        console.log(colors(`[${finishedFiles}/${total}] SKIPPED - ${test}`, 'yellow'));
+        console.log('='.repeat(96));
+
+        finishedFiles++;
+        continue;
+      }
+
+      const start = performance.now();
+
+      for (const variant of meta.variant.length ? meta.variant : ['']) {
+        const url = new URL(this.#url);
+        if (variant) {
+          url.search = variant;
+        }
+        const worker = new utilityProcess.fork(workerPath, [], {
+          stdio: 'pipe'
+        });
+        await once(worker, 'spawn');
+        worker.postMessage({
+          workerData: {
+            // The test file.
+            test: code,
+            // Parsed META tag information
+            meta,
+            url: url.href,
+            path: test
+          }
+        });
+
+        worker.stdout.pipe(process.stdout);
+        worker.stderr.pipe(process.stderr);
+
+        const fileUrl = new URL(`/${this.#folderName}${test.slice(this.#folderPath.length)}`, 'http://wpt');
+        fileUrl.pathname = fileUrl.pathname.replace(/\.js$/, '.html');
+        fileUrl.search = variant;
+        const result = {
+          test: fileUrl.href.slice(fileUrl.origin.length),
+          subtests: [],
+          status: ''
+        };
+
+        activeWorkers.add(worker);
+        // These values come directly from the web-platform-tests
+        const timeout = meta.timeout === 'long' ? 60_000 : 10_000;
+
+        worker.on('message', (message) => {
+          if (message.type === 'result') {
+            this.handleIndividualTestCompletion(message, status, test, meta, result);
+          } else if (message.type === 'completion') {
+            this.handleTestCompletion(worker, status, test);
+          } else if (message.type === 'error') {
+            this.#uncaughtExceptions.push({ error: message.error, test });
+            this.#stats.failedTests += 1;
+            this.#stats.passedTests -= 1;
+          }
+        });
+
+        try {
+          await once(worker, 'exit', {
+            signal: AbortSignal.timeout(timeout)
+          });
+
+          if (result.subtests.some((subtest) => subtest?.isExpectedFailure === false)) {
+            this.#stats.failedFiles += 1;
+            console.log(colors(`[${finishedFiles}/${total}] FAILED - ${test}`, 'red'));
+          } else {
+            this.#stats.passedFiles += 1;
+            console.log(colors(`[${finishedFiles}/${total}] PASSED - ${test}`, 'green'));
+          }
+
+          if (variant) console.log('Variant:', variant);
+          console.log(`File took ${(performance.now() - start).toFixed(2)}ms`);
+          console.log('='.repeat(96));
+        } catch (_) {
+          // If the worker is terminated by the timeout signal, the test is marked as failed
+          this.#stats.failedFiles += 1;
+          console.log(colors(`[${finishedFiles}/${total}] FAILED - ${test}`, 'red'));
+
+          if (variant) console.log('Variant:', variant);
+          console.log(`File timed out after ${timeout}ms`);
+          console.log('='.repeat(96));
+        } finally {
+          finishedFiles++;
+          activeWorkers.delete(worker);
+        }
+      }
+    }
+
+    this.handleRunnerCompletion();
+  }
+
+  /**
+   * Called after a test has succeeded or failed.
+   */
+  handleIndividualTestCompletion (message, status, path, meta, wptResult) {
+    this.#stats.completedTests += 1;
+
+    const { file, topLevel } = status;
+    const isFailure = message.result.status === 1;
+
+    const testResult = {
+      status: isFailure ? 'FAIL' : 'PASS',
+      name: sanitizeUnpairedSurrogates(message.result.name)
+    };
+
+    if (isFailure) {
+      let isExpectedFailure = false;
+      this.#stats.failedTests += 1;
+
+      const name = normalizeName(message.result.name);
+      const sanitizedMessage = sanitizeUnpairedSurrogates(message.result.message);
+
+      if (file.flaky?.includes(name)) {
+        isExpectedFailure = true;
+        this.#stats.expectedFailures += 1;
+        wptResult?.subtests.push({ ...testResult, message: sanitizedMessage, isExpectedFailure });
+      } else if (file.allowUnexpectedFailures || topLevel.allowUnexpectedFailures || file.fail?.includes(name)) {
+        if (!file.allowUnexpectedFailures && !topLevel.allowUnexpectedFailures) {
+          if (Array.isArray(file.fail)) {
+            this.#statusOutput[path] ??= [];
+            this.#statusOutput[path].push(name);
+          }
+        }
+
+        isExpectedFailure = true;
+        this.#stats.expectedFailures += 1;
+        wptResult?.subtests.push({ ...testResult, message: sanitizedMessage, isExpectedFailure });
+      } else {
+        wptResult?.subtests.push({ ...testResult, message: sanitizedMessage, isExpectedFailure });
+        process.exitCode = 1;
+        console.error(message.result);
+      }
+      if (!isExpectedFailure) {
+        process._rawDebug(`Failed test: ${path}`);
+      }
+    } else {
+      this.#stats.passedTests += 1;
+      wptResult?.subtests.push(testResult);
+    }
+  }
+
+  /**
+   * Called after all the tests in a worker are completed.
+   * @param {Worker} worker
+   */
+  handleTestCompletion (worker, status, path) {
+    worker.kill();
+
+    const { file } = status;
+    const hasExpectedFailures = !!file.fail;
+    const testHasFailures = !!this.#statusOutput?.[path];
+    const failed = this.#statusOutput?.[path] ?? [];
+
+    if (hasExpectedFailures !== testHasFailures) {
+      console.log({ expected: file.fail, failed });
+
+      if (failed.length === 0) {
+        console.log(colors('Tests are marked as failure but did not fail, yay!', 'red'));
+      } else if (!hasExpectedFailures) {
+        console.log(colors('Test failed but there were no expected errors.', 'red'));
+      }
+
+      process.exitCode = 1;
+    } else if (hasExpectedFailures && testHasFailures) {
+      const diff = [
+        ...file.fail.filter(x => !failed.includes(x)),
+        ...failed.filter(x => !file.fail.includes(x))
+      ];
+
+      if (diff.length) {
+        console.log({ diff });
+        console.log(colors('Expected failures did not match actual failures', 'red'));
+        process.exitCode = 1;
+      }
+    }
+  }
+
+  /**
+   * Called after every test has completed.
+   */
+  handleRunnerCompletion () {
+    // tests that failed
+    if (Object.keys(this.#statusOutput).length !== 0) {
+      console.log(this.#statusOutput);
+    }
+
+    this.emit('completion');
+
+    const { passedFiles, failedFiles, skippedFiles } = this.#stats;
+    console.log(
+      `File results for folder [${this.#folderName}]: ` +
+      `completed: ${this.#files.length}, passed: ${passedFiles}, failed: ${failedFiles}, ` +
+      `skipped: ${skippedFiles}`
+    );
+
+    const { completedTests, failedTests, passedTests, expectedFailures } = this.#stats;
+    console.log(
+      `Test results for folder [${this.#folderName}]: ` +
+      `completed: ${completedTests}, failed: ${failedTests}, passed: ${passedTests}, ` +
+      `expected failures: ${expectedFailures}, ` +
+      `unexpected failures: ${failedTests - expectedFailures}`
+    );
+
+    process.exit(failedTests - expectedFailures ? 1 : process.exitCode);
+  }
+
+  /**
+   * Parses META tags and resolves any script file paths.
+   * @param {string} code
+   * @param {string} path The absolute path of the test
+   */
+  resolveMeta (code, path) {
+    const meta = parseMeta(code);
+    const scripts = meta.scripts.map((filePath) => {
+      let content = '';
+
+      if (filePath === '/resources/WebIDLParser.js') {
+        // See https://github.com/web-platform-tests/wpt/pull/731
+        return readFileSync(join(testPath, '/resources/webidl2/lib/webidl2.js'), 'utf-8');
+      } else if (isAbsolute(filePath)) {
+        content = readFileSync(join(testPath, filePath), 'utf-8');
+      } else {
+        content = readFileSync(resolve(path, '..', filePath), 'utf-8');
+      }
+
+      // If the file has any built-in pipes.
+      if (filePath.includes('.sub.')) {
+        content = handlePipes(content, this.#url);
+      }
+
+      return content;
+    });
+
+    return {
+      ...meta,
+      resourcePaths: meta.scripts,
+      scripts
+    };
+  }
+}

+ 172 - 0
script/wpt/runner/util.mjs

@@ -0,0 +1,172 @@
+import assert from 'node:assert';
+import { sep } from 'node:path';
+import { exit } from 'node:process';
+import tty from 'node:tty';
+import { inspect } from 'node:util';
+
+/**
+ * Parse the `Meta:` tags sometimes included in tests.
+ * These can include resources to inject, how long it should
+ * take to timeout, and which globals to expose.
+ * @example
+ * // META: timeout=long
+ * // META: global=window,worker
+ * // META: script=/common/utils.js
+ * // META: script=/common/get-host-info.sub.js
+ * // META: script=../request/request-error.js
+ * @see https://nodejs.org/api/readline.html#readline_example_read_file_stream_line_by_line
+ * @param {string} fileContents
+ */
+export function parseMeta (fileContents) {
+  const lines = fileContents.split(/\r?\n/g);
+
+  const meta = {
+    /** @type {string|null} */
+    timeout: null,
+    /** @type {string[]} */
+    global: [],
+    /** @type {string[]} */
+    scripts: [],
+    /** @type {string[]} */
+    variant: []
+  };
+
+  for (const line of lines) {
+    if (!line.startsWith('// META: ')) {
+      break;
+    }
+
+    const groups = /^\/\/ META: (?<type>.*?)=(?<match>.*)$/.exec(line)?.groups;
+
+    if (!groups) {
+      console.log(`Failed to parse META tag: ${line}`);
+      exit(1);
+    }
+
+    switch (groups.type) {
+      case 'variant':
+        meta[groups.type].push(groups.match);
+        break;
+      case 'title':
+      case 'timeout': {
+        meta[groups.type] = groups.match;
+        break;
+      }
+      case 'global': {
+        // window,worker -> ['window', 'worker']
+        meta.global.push(...groups.match.split(','));
+        break;
+      }
+      case 'script': {
+        // A relative or absolute file path to the resources
+        // needed for the current test.
+        meta.scripts.push(groups.match);
+        break;
+      }
+      default: {
+        console.log(`Unknown META tag: ${groups.type}`);
+        exit(1);
+      }
+    }
+  }
+
+  return meta;
+}
+
+/**
+ * @param {string} sub
+ */
+function parseSubBlock (sub) {
+  const subName = sub.includes('[') ? sub.slice(0, sub.indexOf('[')) : sub;
+  const options = sub.matchAll(/\[(.*?)\]/gm);
+
+  return {
+    sub: subName,
+    options: [...options].map(match => match[1])
+  };
+}
+
+/**
+ * @see https://web-platform-tests.org/writing-tests/server-pipes.html?highlight=sub#built-in-pipes
+ * @param {string} code
+ * @param {string} url
+ */
+export function handlePipes (code, url) {
+  const server = new URL(url);
+
+  // "Substitutions are marked in a file using a block delimited by
+  //  {{ and }}. Inside the block the following variables are available:"
+  return code.replace(/{{(.*?)}}/gm, (_, match) => {
+    const { sub } = parseSubBlock(match);
+
+    switch (sub) {
+      // "The host name of the server excluding any subdomain part."
+      // eslint-disable-next-line no-fallthrough
+      case 'host':
+      // "The domain name of a particular subdomain e.g.
+      //  {{domains[www]}} for the www subdomain."
+      // eslint-disable-next-line no-fallthrough
+      case 'domains':
+      // "The domain name of a particular subdomain for a particular host.
+      //  The first key may be empty (designating the “default” host) or
+      //  the value alt; i.e., {{hosts[alt][]}} (designating the alternate
+      //  host)."
+      // eslint-disable-next-line no-fallthrough
+      case 'hosts': {
+        return 'localhost';
+      }
+      // "The port number of servers, by protocol e.g. {{ports[http][0]}}
+      //  for the first (and, depending on setup, possibly only) http server"
+      case 'ports': {
+        return server.port;
+      }
+      default: {
+        throw new TypeError(`Unknown substitute "${sub}".`);
+      }
+    }
+  });
+}
+
+/**
+ * Some test names may contain characters that JSON cannot handle.
+ * @param {string} name
+ */
+export function normalizeName (name) {
+  return name.replace(/(\v)/g, (_, match) => {
+    switch (inspect(match)) {
+      case '\'\\x0B\'': return '\\x0B';
+      default: return match;
+    }
+  });
+}
+
+export function colors (str, color) {
+  assert(Object.hasOwn(inspect.colors, color), `Missing color ${color}`);
+
+  if (!tty.WriteStream.prototype.hasColors()) {
+    return str;
+  }
+
+  const [start, end] = inspect.colors[color];
+
+  return `\u001b[${start}m${str}\u001b[${end}m`;
+}
+
+/** @param {string} path */
+export function resolveStatusPath (path, status) {
+  const paths = path
+    .slice(process.cwd().length + sep.length)
+    .split(sep)
+    .slice(5); // [test, wpt, tests, fetch, b, c.js] -> [fetch, b, c.js]
+
+  // skip the first folder name
+  for (let i = 1; i < paths.length - 1; i++) {
+    status = status[paths[i]];
+
+    if (!status) {
+      break;
+    }
+  }
+
+  return { fullPath: path, topLevel: status ?? {}, file: status?.[paths.at(-1)] ?? {} };
+}

+ 128 - 0
script/wpt/runner/worker.mjs

@@ -0,0 +1,128 @@
+import { net } from 'electron';
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { runInThisContext } from 'node:vm';
+
+const basePath = process.env.WPT_DIR;
+
+process.on('uncaughtException', (err) => {
+  console.log('uncaughtException', err);
+  process.parentPort.postMessage({
+    type: 'error',
+    error: {
+      message: err.message,
+      name: err.name,
+      stack: err.stack
+    }
+  });
+});
+
+const globalPropertyDescriptors = {
+  writable: true,
+  enumerable: false,
+  configurable: true
+};
+
+Object.defineProperties(globalThis, {
+  fetch: {
+    ...globalPropertyDescriptors,
+    enumerable: true,
+    value: net.fetch
+  }
+});
+
+// TODO: remove once Float16Array is added. Otherwise a test throws with an uncaught exception.
+globalThis.Float16Array ??= class Float16Array {};
+
+process.parentPort.on('message', (message) => {
+  const { meta, test, url, path } = message.data.workerData;
+  const urlPath = path.slice(basePath.length);
+
+  // self is required by testharness
+  // GLOBAL is required by self
+  runInThisContext(`
+    globalThis.self = globalThis
+    globalThis.GLOBAL = {
+      isWorker () {
+        return false
+      },
+      isShadowRealm () {
+        return false
+      },
+      isWindow () {
+        return false
+      }
+    }
+    globalThis.window = globalThis
+    globalThis.location = new URL('${urlPath.replace(/\\/g, '/')}', '${url}')
+    globalThis.Window = Object.getPrototypeOf(globalThis).constructor
+  `);
+
+  if (meta.title) {
+    runInThisContext(`globalThis.META_TITLE = "${meta.title.replace(/"/g, '\\"')}"`);
+  }
+
+  const harness = readFileSync(join(basePath, '/resources/testharness.js'), 'utf-8');
+  runInThisContext(harness);
+
+  // add_*_callback comes from testharness
+  // stolen from node's wpt test runner
+  // eslint-disable-next-line no-undef
+  add_result_callback((result) => {
+    process.parentPort.postMessage({
+      type: 'result',
+      result: {
+        status: result.status,
+        name: result.name,
+        message: result.message,
+        stack: result.stack
+      }
+    });
+  });
+
+  // eslint-disable-next-line no-undef
+  add_completion_callback((_, status) => {
+    process.parentPort.postMessage({
+      type: 'completion',
+      status
+    });
+  });
+
+  const globalOrigin = Symbol.for('undici.globalOrigin.1');
+  function setGlobalOrigin (newOrigin) {
+    if (newOrigin === undefined) {
+      Object.defineProperty(globalThis, globalOrigin, {
+        value: undefined,
+        writable: true,
+        enumerable: false,
+        configurable: false
+      });
+
+      return;
+    }
+
+    const parsedURL = new URL(newOrigin);
+
+    if (parsedURL.protocol !== 'http:' && parsedURL.protocol !== 'https:') {
+      throw new TypeError(`Only http & https urls are allowed, received ${parsedURL.protocol}`);
+    }
+
+    Object.defineProperty(globalThis, globalOrigin, {
+      value: parsedURL,
+      writable: true,
+      enumerable: false,
+      configurable: false
+    });
+  }
+
+  setGlobalOrigin(globalThis.location);
+
+  // Inject any files from the META tags
+  for (const script of meta.scripts) {
+    runInThisContext(script);
+  }
+
+  // Finally, run the test.
+  runInThisContext(test);
+});

+ 3 - 0
script/wpt/server/constants.mjs

@@ -0,0 +1,3 @@
+export const symbols = {
+  kContent: Symbol('content')
+};

+ 104 - 0
script/wpt/server/routes/redirect.mjs

@@ -0,0 +1,104 @@
+import { setTimeout } from 'node:timers/promises';
+
+const stash = new Map();
+
+/**
+ * @see https://github.com/web-platform-tests/wpt/blob/master/fetch/connection-pool/resources/network-partition-key.py
+ * @param {Parameters<import('http').RequestListener>[0]} req
+ * @param {Parameters<import('http').RequestListener>[1]} res
+ * @param {URL} fullUrl
+ */
+export async function route (req, res, fullUrl) {
+  const { searchParams } = fullUrl;
+
+  let stashedData = { count: 0, preflight: 0 };
+  let status = 302;
+  res.setHeader('Content-Type', 'text/plain');
+  res.setHeader('Cache-Control', 'no-cache');
+  res.setHeader('Pragma', 'no-cache');
+
+  if (Object.hasOwn(req.headers, 'origin')) {
+    res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '');
+    res.setHeader('Access-Control-Allow-Credentials', 'true');
+  } else {
+    res.setHeader('Access-Control-Allow-Origin', '*');
+  }
+
+  let token = null;
+  if (searchParams.has('token')) {
+    token = searchParams.get('token');
+    const data = stash.get(token);
+    stash.delete(token);
+    if (data) {
+      stashedData = data;
+    }
+  }
+
+  if (req.method === 'OPTIONS') {
+    if (searchParams.has('allow_headers')) {
+      res.setHeader('Access-Control-Allow-Headers', searchParams.get('allow_headers'));
+    }
+
+    stashedData.preflight = '1';
+
+    if (!searchParams.has('redirect_preflight')) {
+      if (token) {
+        stash.set(searchParams.get('token'), stashedData);
+      }
+
+      res.statusCode = 200;
+      res.end('');
+      return;
+    }
+  }
+
+  if (searchParams.has('redirect_status')) {
+    status = parseInt(searchParams.get('redirect_status'));
+  }
+
+  stashedData.count += 1;
+
+  if (searchParams.has('location')) {
+    let url = decodeURIComponent(searchParams.get('location'));
+
+    if (!searchParams.has('simple')) {
+      const scheme = new URL(url, fullUrl).protocol;
+
+      if (scheme === 'http:' || scheme === 'https:') {
+        url += url.includes('?') ? '&' : '?';
+
+        for (const [key, value] of searchParams) {
+          url += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(value);
+        }
+
+        url += '&count=' + stashedData.count;
+      }
+    }
+
+    res.setHeader('location', url);
+  }
+
+  if (searchParams.has('redirect_referrerpolicy')) {
+    res.setHeader('Referrer-Policy', searchParams.get('redirect_referrerpolicy'));
+  }
+
+  if (searchParams.has('delay')) {
+    await setTimeout(parseFloat(searchParams.get('delay') ?? 0));
+  }
+
+  if (token) {
+    stash.set(searchParams.get('token'), stashedData);
+
+    if (searchParams.has('max_count')) {
+      const maxCount = parseInt(searchParams.get('max_count'));
+
+      if (stashedData.count > maxCount) {
+        res.end((stashedData.count - 1).toString());
+        return;
+      }
+    }
+  }
+
+  res.statusCode = status;
+  res.end('');
+}

+ 350 - 0
script/wpt/server/server.mjs

@@ -0,0 +1,350 @@
+import { once } from 'node:events';
+import { createReadStream, readFileSync, existsSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { join } from 'node:path';
+import process from 'node:process';
+import { setTimeout as sleep } from 'node:timers/promises';
+
+import { symbols } from './constants.mjs';
+import { route as redirectRoute } from './routes/redirect.mjs';
+
+const tests = process.env.WPT_DIR;
+
+// https://web-platform-tests.org/tools/wptserve/docs/stash.html
+class Stash extends Map {
+  take (key) {
+    if (this.has(key)) {
+      const value = this.get(key);
+
+      this.delete(key);
+      return value.value;
+    }
+  }
+
+  put (key, value, path) {
+    this.set(key, { value, path });
+  }
+}
+
+const stash = new Stash();
+
+const server = createServer(async (req, res) => {
+  const fullUrl = new URL(req.url, `http://localhost:${server.address().port}`);
+
+  switch (fullUrl.pathname) {
+    case '/fetch/content-encoding/resources/big.text.gz':
+    case '/fetch/content-encoding/resources/foo.octetstream.gz':
+    case '/fetch/content-encoding/resources/foo.text.gz':
+    case '/fetch/api/resources/cors-top.txt':
+    case '/fetch/api/resources/top.txt':
+    case '/fetch/data-urls/resources/base64.json':
+    case '/fetch/data-urls/resources/data-urls.json':
+    case '/fetch/api/resources/empty.txt':
+    case '/fetch/api/resources/data.json': {
+      // If this specific resources requires custom headers
+      const customHeadersPath = join(tests, fullUrl.pathname + '.headers');
+      if (existsSync(customHeadersPath)) {
+        const headers = readFileSync(customHeadersPath, 'utf-8')
+          .trim()
+          .split(/\r?\n/g)
+          .map((h) => h.split(': '));
+
+        for (const [key, value] of headers) {
+          if (!key || !value) {
+            console.warn(`Skipping ${key}:${value} header pair`);
+            continue;
+          }
+          res.setHeader(key, value);
+        }
+      }
+
+      // https://github.com/web-platform-tests/wpt/blob/6ae3f702a332e8399fab778c831db6b7dca3f1c6/fetch/api/resources/data.json
+      createReadStream(join(tests, fullUrl.pathname))
+        .on('end', () => res.end())
+        .pipe(res);
+
+      break;
+    }
+    case '/fetch/api/resources/trickle.py': {
+      // Note: python's time.sleep(...) takes seconds, while setTimeout
+      // takes ms.
+      const delay = parseFloat(fullUrl.searchParams.get('ms') ?? 500);
+      const count = parseInt(fullUrl.searchParams.get('count') ?? 50);
+
+      // eslint-disable-next-line no-unused-vars
+      for await (const chunk of req); // read request body
+
+      await sleep(delay);
+
+      if (!fullUrl.searchParams.has('notype')) {
+        res.setHeader('Content-type', 'text/plain');
+      }
+
+      res.statusCode = 200;
+      await sleep(delay);
+
+      for (let i = 0; i < count; i++) {
+        res.write('TEST_TRICKLE\n');
+        await sleep(delay);
+      }
+
+      res.end();
+      break;
+    }
+    case '/fetch/api/resources/infinite-slow-response.py': {
+      // https://github.com/web-platform-tests/wpt/blob/master/fetch/api/resources/infinite-slow-response.py
+      const stateKey = fullUrl.searchParams.get('stateKey') ?? '';
+      const abortKey = fullUrl.searchParams.get('abortKey') ?? '';
+
+      if (stateKey) {
+        stash.put(stateKey, 'open', fullUrl.pathname);
+      }
+
+      res.setHeader('Content-Type', 'text/plain');
+      res.statusCode = 200;
+
+      res.write('.'.repeat(2048));
+
+      while (true) {
+        if (!res.write('.')) {
+          break;
+        } else if (abortKey && stash.take(abortKey)) {
+          break;
+        }
+
+        await sleep(100);
+      }
+
+      if (stateKey) {
+        stash.put(stateKey, 'closed', fullUrl.pathname);
+      }
+
+      res.end();
+      break;
+    }
+    case '/fetch/api/resources/stash-take.py': {
+      // https://github.com/web-platform-tests/wpt/blob/6ae3f702a332e8399fab778c831db6b7dca3f1c6/fetch/api/resources/stash-take.py
+
+      const key = fullUrl.searchParams.get('key');
+      res.setHeader('Access-Control-Allow-Origin', '*');
+
+      const took = stash.take(key, fullUrl.pathname) ?? null;
+
+      res.write(JSON.stringify(took));
+      res.end();
+      break;
+    }
+    case '/fetch/api/resources/echo-content.py': {
+      res.setHeader('X-Request-Method', req.method);
+      res.setHeader('X-Request-Content-Length', req.headers['content-length'] ?? 'NO');
+      res.setHeader('X-Request-Content-Type', req.headers['content-type'] ?? 'NO');
+      res.setHeader('Content-Type', 'text/plain');
+
+      for await (const chunk of req) {
+        res.write(chunk);
+      }
+
+      res.end();
+      break;
+    }
+    case '/fetch/api/resources/cache.py': {
+      if (req.headers['if-none-match'] === '"123abc"') {
+        res.statusCode = 304;
+        res.statusMessage = 'Not Modified';
+        res.setHeader('X-HTTP-STATUS', '304');
+        res.end();
+      } else {
+        // cache miss, so respond with the actual content
+        res.statusCode = 200;
+        res.statusMessage = 'OK';
+        res.setHeader('Content-Type', 'text/plain');
+        res.setHeader('ETag', '"123abc"');
+        res.end('lorem ipsum dolor sit amet');
+      }
+      break;
+    }
+    case '/fetch/api/resources/status.py': {
+      const code = parseInt(fullUrl.searchParams.get('code') ?? 200);
+      const text = fullUrl.searchParams.get('text') ?? 'OMG';
+      const content = fullUrl.searchParams.get('content') ?? '';
+      const type = fullUrl.searchParams.get('type') ?? '';
+      res.statusCode = code;
+      res.statusMessage = text;
+      res.setHeader('Content-Type', type);
+      res.setHeader('X-Request-Method', req.method);
+      res.end(content);
+      break;
+    }
+    case '/fetch/api/resources/inspect-headers.py': {
+      const query = fullUrl.searchParams;
+      const checkedHeaders = query.get('headers')
+        ?.split('|')
+        .map(h => h.toLowerCase()) ?? [];
+
+      if (query.has('headers')) {
+        for (const header of checkedHeaders) {
+          if (Object.hasOwn(req.headers, header)) {
+            res.setHeader(`x-request-${header}`, req.headers[header] ?? '');
+          }
+        }
+      }
+
+      if (query.has('cors')) {
+        if (Object.hasOwn(req.headers, 'origin')) {
+          res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '');
+        } else {
+          res.setHeader('Access-Control-Allow-Origin', '*');
+        }
+
+        res.setHeader('Access-Control-Allow-Credentials', 'true');
+        res.setHeader('Access-Control-Allow-Methods', 'GET, POST, HEAD');
+        const exposedHeaders = checkedHeaders.map(h => `x-request-${h}`).join(', ');
+        res.setHeader('Access-Control-Expose-Headers', exposedHeaders);
+        if (query.has('allow_headers')) {
+          res.setHeader('Access-Control-Allow-Headers', query.get('allowed_headers'));
+        } else {
+          res.setHeader('Access-Control-Allow-Headers', Object.keys(req.headers).join(', '));
+        }
+      }
+
+      res.setHeader('content-type', 'text/plain');
+      res.end('');
+      break;
+    }
+    case '/fetch/api/resources/bad-chunk-encoding.py': {
+      const query = fullUrl.searchParams;
+
+      const delay = parseFloat(query.get('ms') ?? 1000);
+      const count = parseInt(query.get('count') ?? 50);
+      await sleep(delay);
+      res.socket.write(
+        'HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n'
+      );
+      await sleep(delay);
+
+      for (let i = 0; i < count; i++) {
+        res.socket.write('a\r\nTEST_CHUNK\r\n');
+        await sleep(delay);
+      }
+
+      res.end('garbage');
+      break;
+    }
+    case '/fetch/api/resources/redirect.py': {
+      redirectRoute(req, res, fullUrl);
+      break;
+    }
+    case '/fetch/api/resources/method.py': {
+      if (fullUrl.searchParams.has('cors')) {
+        res.setHeader('Access-Control-Allow-Origin', '*');
+        res.setHeader('Access-Control-Allow-Credentials', 'true');
+        res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, FOO');
+        res.setHeader('Access-Control-Allow-Headers', 'x-test, x-foo');
+        res.setHeader('Access-Control-Expose-Headers', 'x-request-method');
+      }
+
+      res.setHeader('x-request-method', req.method);
+      res.setHeader('x-request-content-type', req.headers['content-type'] ?? 'NO');
+      res.setHeader('x-request-content-length', req.headers['content-length'] ?? 'NO');
+      res.setHeader('x-request-content-encoding', req.headers['content-encoding'] ?? 'NO');
+      res.setHeader('x-request-content-language', req.headers['content-language'] ?? 'NO');
+      res.setHeader('x-request-content-location', req.headers['content-location'] ?? 'NO');
+
+      for await (const chunk of req) {
+        res.write(chunk);
+      }
+
+      res.end();
+      break;
+    }
+    case '/fetch/api/resources/clean-stash.py': {
+      const token = fullUrl.searchParams.get('token');
+      const took = stash.take(token);
+
+      if (took) {
+        res.end('1');
+      } else {
+        res.end('0');
+      }
+
+      break;
+    }
+    case '/fetch/content-encoding/resources/bad-gzip-body.py': {
+      res.setHeader('Content-Encoding', 'gzip');
+      res.end('not actually gzip');
+      break;
+    }
+    case '/fetch/api/resources/dump-authorization-header.py': {
+      res.setHeader('Content-Type', 'text/html');
+      res.setHeader('Cache-Control', 'no-cache');
+
+      if (req.headers.origin) {
+        res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
+        res.setHeader('Access-Control-Allow-Credentials', 'true');
+      } else {
+        res.setHeader('Access-Control-Allow-Origin', '*');
+      }
+
+      res.setHeader('Access-Control-Allow-Headers', 'Authorization');
+      res.statusCode = 200;
+
+      if (req.headers.authorization) {
+        res.end(req.headers.authorization);
+        break;
+      }
+
+      res.end('none');
+      break;
+    }
+    case '/fetch/api/resources/authentication.py': {
+      const auth = Buffer.from(req.headers.authorization.slice('Basic '.length), 'base64');
+      const [user, password] = auth.toString().split(':');
+
+      if (user === 'user' && password === 'password') {
+        res.end('Authentication done');
+        break;
+      }
+
+      const realm = fullUrl.searchParams.get('realm') ?? 'test';
+
+      res.statusCode = 401;
+      res.setHeader('WWW-Authenticate', `Basic realm="${realm}"`);
+      res.end('Please login with credentials \'user\' and \'password\'');
+      break;
+    }
+    case '/fetch/api/resources/redirect-empty-location.py': {
+      res.setHeader('location', '');
+      res.statusCode = 302;
+      res.end('');
+      break;
+    }
+    default: {
+      res.statusCode = 200;
+      res.end(fullUrl.toString());
+    }
+  }
+
+  if (res[symbols.kContent]) {
+    res.write(res[symbols.kContent]);
+  }
+}).listen(0);
+
+await once(server, 'listening');
+
+const send = (message) => {
+  if (typeof process.send === 'function') {
+    process.send(message);
+  }
+};
+
+const url = `http://localhost:${server.address().port}`;
+console.log('server opened ' + url);
+send({ server: url });
+
+process.on('message', (message) => {
+  if (message === 'shutdown') {
+    server.close((err) => process.exit(err ? 1 : 0));
+  }
+});
+
+export { server };

+ 32 - 0
script/wpt/start.mjs

@@ -0,0 +1,32 @@
+import { app } from 'electron';
+
+import { fork } from 'node:child_process';
+import { on } from 'node:events';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { WPTRunner } from './runner/runner.mjs';
+
+const serverPath = fileURLToPath(join(import.meta.url, '../server/server.mjs'));
+
+app.whenReady().then(async () => {
+  const child = fork(serverPath, [], {
+    stdio: ['pipe', 'pipe', 'pipe', 'ipc']
+  });
+
+  child.stdout.pipe(process.stdout);
+  child.stderr.pipe(process.stderr);
+  child.on('exit', (code) => process.exit(code));
+
+  for await (const [message] of on(child, 'message')) {
+    if (message.server) {
+      const runner = new WPTRunner('fetch', message.server);
+      runner.run();
+      runner.once('completion', () => {
+        if (child.connected) {
+          child.send('shutdown');
+        }
+      });
+    }
+  }
+});

+ 578 - 0
script/wpt/status/fetch.status.json

@@ -0,0 +1,578 @@
+{
+  "api": {
+    "abort": {
+      "general.any.js": {
+        "fail": [
+          "response.arrayBuffer() rejects if already aborted",
+          "response.blob() rejects if already aborted",
+          "response.formData() rejects if already aborted",
+          "response.json() rejects if already aborted",
+          "response.text() rejects if already aborted",
+          "response.bytes() rejects if already aborted",
+          "Call text() twice on aborted response",
+          "Fetch aborted & connection closed when aborted after calling response.arrayBuffer()",
+          "Fetch aborted & connection closed when aborted after calling response.blob()",
+          "Fetch aborted & connection closed when aborted after calling response.formData()",
+          "Fetch aborted & connection closed when aborted after calling response.json()",
+          "Fetch aborted & connection closed when aborted after calling response.text()",
+          "Fetch aborted & connection closed when aborted after calling response.bytes()",
+          "Stream errors once aborted. Underlying connection closed.",
+          "Stream errors once aborted, after reading. Underlying connection closed."
+        ]
+      },
+      "cache.https.any.js": {
+        "note": "undici doesn't implement http caching",
+        "skip": true
+      }
+    },
+    "basic": {
+      "accept-header.any.js": {
+        "fail": [
+          "Request through fetch should have 'accept' header with value '*/*'",
+          "Request through fetch should have 'accept' header with value 'custom/*'",
+          "Request through fetch should have a 'accept-language' header",
+          "Request through fetch should have 'accept-language' header with value 'bzh'"
+        ]
+      },
+      "conditional-get.any.js": {
+        "fail": [
+          "Testing conditional GET with ETags"
+        ],
+        "note": "undici doesn't keep track of etags"
+      },
+      "error-after-response.any.js": {
+        "fail": [
+          "Response reader read() promise should reject after a network error happening after resolving fetch promise",
+          "Response reader closed promise should reject after a network error happening after resolving fetch promise"
+        ]
+      },
+      "header-value-combining.any.js": {
+        "fail": [
+          "response.headers.get('content-length') expects 0, 0",
+          "response.headers.get('foo-test') expects 1, 2, 3",
+          "response.headers.get('heya') expects , \\x0B\f, 1, , , 2"
+        ],
+        "flaky": [
+          "response.headers.get('content-length') expects 0",
+          "response.headers.get('double-trouble') expects , ",
+          "response.headers.get('www-authenticate') expects 1, 2, 3, 4"
+        ]
+      },
+      "header-value-null-byte.any.js": {
+        "fail": [
+          "Ensure fetch() rejects null bytes in headers"
+        ]
+      },
+      "http-response-code.any.js": {
+        "fail": [
+          "Fetch on 425 response should not be retried for non TLS early data."
+        ]
+      },
+      "integrity.sub.any.js": {
+        "note": "Electron: integrity is not working",
+        "skip": true
+      },
+      "keepalive.any.js": {
+        "note": "document is not defined",
+        "skip": true
+      },
+      "mode-no-cors.sub.any.js": {
+        "note": "undici doesn't implement CORs",
+        "skip": true
+      },
+      "mode-same-origin.any.js": {
+        "note": "undici doesn't respect RequestInit.mode",
+        "skip": true
+      },
+      "referrer.any.js": {
+        "note": "Electron: fix referrrer handling",
+        "skip": true
+      },
+      "request-forbidden-headers.any.js": {
+        "note": "undici doesn't filter headers",
+        "skip": true
+      },
+      "request-headers.any.js": {
+        "note": "Electron: fix response type",
+        "skip": true
+      },
+      "request-headers-case.any.js": {
+        "note": "Electron: rework header generation",
+        "skip": true
+      },
+      "request-private-network-headers.tentative.any.js": {
+        "note": "undici doesn't filter headers",
+        "skip": true
+      },
+      "request-referrer.any.js": {
+        "note": "Electron: fix referrrer handling",
+        "skip": true
+      },
+      "request-upload.any.js": {
+        "note": "no Float16Array",
+        "fail": [
+          "Fetch with POST with Float16Array body",
+          "Fetch with POST with text body on 421 response should be retried once on new connection."
+        ]
+      },
+      "request-upload.h2.any.js": {
+        "note": "undici doesn't support http/2",
+        "skip": true
+      },
+      "response-url.sub.any.js": {
+        "note": "Electron: does not support response.url",
+        "skip": true
+      },
+      "scheme-about.any.js": {
+        "note": "Electron: does not handle about urls",
+        "skip": true
+      },
+      "scheme-blob.sub.any.js": {
+        "note": "Electron: does not support blob urls",
+        "skip": true
+      },
+      "scheme-data.any.js": {
+        "note": "Electron: does not support data urls",
+        "skip": true
+      },
+      "scheme-others.sub.any.js": {
+        "note": "Electron: does not support unknown urls",
+        "skip": true
+      },
+      "status.h2.any.js": {
+        "note": "undici doesn't support http/2",
+        "skip": true
+      },
+      "stream-response.any.js": {
+        "fail": [
+          "Stream response's body when content-type is not present"
+        ]
+      },
+      "stream-safe-creation.any.js": {
+        "note": "Electron: stream accessors are broken",
+        "skip": true
+      }
+    },
+    "body": {
+      "mime-type.any.js": {
+        "note": "fails on all platforms, https://wpt.fyi/results/fetch/api/body/mime-type.any.html?label=master&label=experimental&product=chrome&product=firefox&product=safari&product=node.js&product=deno&aligned",
+        "fail": [
+          "Response: Extract a MIME type with clone"
+        ]
+      }
+    },
+    "cors": {
+      "note": "undici doesn't implement CORs",
+      "skip": true
+    },
+    "credentials": {
+      "authentication-basic.any.js": {
+        "note": "Electron: fix response type",
+        "skip": true
+      },
+      "authentication-redirection.any.js": {
+        "note": "connects to https server",
+        "fail": [
+          "getAuthorizationHeaderValue - cross origin redirection",
+          "getAuthorizationHeaderValue - same origin redirection"
+        ]
+      },
+      "cookies.any.js": {
+        "note": "Electron: fix response type",
+        "skip": true
+      }
+    },
+    "fetch-later": {
+      "note": "this is not part of the spec, only a proposal",
+      "skip": true
+    },
+    "headers": {
+      "header-setcookie.any.js": {
+        "note": "undici doesn't filter headers",
+        "fail": [
+          "Set-Cookie is a forbidden response header"
+        ]
+      },
+      "header-values-normalize.any.js": {
+        "note": "TODO(@KhafraDev): https://github.com/nodejs/undici/issues/1680",
+        "fail": [
+          "XMLHttpRequest with value %00",
+          "XMLHttpRequest with value %01",
+          "XMLHttpRequest with value %02",
+          "XMLHttpRequest with value %03",
+          "XMLHttpRequest with value %04",
+          "XMLHttpRequest with value %05",
+          "XMLHttpRequest with value %06",
+          "XMLHttpRequest with value %07",
+          "XMLHttpRequest with value %08",
+          "XMLHttpRequest with value %09",
+          "XMLHttpRequest with value %0A",
+          "XMLHttpRequest with value %0D",
+          "XMLHttpRequest with value %0E",
+          "XMLHttpRequest with value %0F",
+          "XMLHttpRequest with value %10",
+          "XMLHttpRequest with value %11",
+          "XMLHttpRequest with value %12",
+          "XMLHttpRequest with value %13",
+          "XMLHttpRequest with value %14",
+          "XMLHttpRequest with value %15",
+          "XMLHttpRequest with value %16",
+          "XMLHttpRequest with value %17",
+          "XMLHttpRequest with value %18",
+          "XMLHttpRequest with value %19",
+          "XMLHttpRequest with value %1A",
+          "XMLHttpRequest with value %1B",
+          "XMLHttpRequest with value %1C",
+          "XMLHttpRequest with value %1D",
+          "XMLHttpRequest with value %1E",
+          "XMLHttpRequest with value %1F",
+          "XMLHttpRequest with value %20",
+          "fetch() with value %01",
+          "fetch() with value %02",
+          "fetch() with value %03",
+          "fetch() with value %04",
+          "fetch() with value %05",
+          "fetch() with value %06",
+          "fetch() with value %07",
+          "fetch() with value %08",
+          "fetch() with value %0E",
+          "fetch() with value %0F",
+          "fetch() with value %10",
+          "fetch() with value %11",
+          "fetch() with value %12",
+          "fetch() with value %13",
+          "fetch() with value %14",
+          "fetch() with value %15",
+          "fetch() with value %16",
+          "fetch() with value %17",
+          "fetch() with value %18",
+          "fetch() with value %19",
+          "fetch() with value %1A",
+          "fetch() with value %1B",
+          "fetch() with value %1C",
+          "fetch() with value %1D",
+          "fetch() with value %1E",
+          "fetch() with value %1F"
+        ]
+      },
+      "header-values.any.js": {
+        "fail": [
+          "XMLHttpRequest with value x%00x needs to throw",
+          "XMLHttpRequest with value x%0Ax needs to throw",
+          "XMLHttpRequest with value x%0Dx needs to throw",
+          "XMLHttpRequest with all valid values",
+          "fetch() with all valid values"
+        ]
+      },
+      "headers-no-cors.any.js": {
+        "note": "undici doesn't implement CORs",
+        "skip": true
+      }
+    },
+    "redirect": {
+      "redirect-back-to-original-origin.any.js": {
+        "note": "Electron: fix response type",
+        "skip": true
+      },
+      "redirect-count.any.js": {
+        "note": "Electron: handle too many redirects",
+        "skip": true
+      },
+      "redirect-empty-location.any.js": {
+        "note": "undici handles redirect: manual differently than browsers",
+        "fail": [
+          "redirect response with empty Location, manual mode",
+          "redirect response with empty Location, follow mode"
+        ]
+      },
+      "redirect-keepalive.any.js": {
+        "note": "document is not defined",
+        "skip": true
+      },
+      "redirect-keepalive.https.any.js": {
+        "note": "document is not defined",
+        "skip": true
+      },
+      "redirect-location-escape.tentative.any.js": {
+        "note": "TODO(@KhafraDev): crashes runner",
+        "skip": true
+      },
+      "redirect-location.any.js": {
+        "note": "Electron: fix redirect handling",
+        "skip": true
+      },
+      "redirect-method.any.js": {
+        "note": "Electron: fix response type",
+        "skip": true
+      },
+      "redirect-mode.any.js": {
+        "note": "mode isn't respected",
+        "skip": true
+      },
+      "redirect-origin.any.js": {
+        "note": "TODO(@KhafraDev): investigate",
+        "skip": true
+      },
+      "redirect-referrer-override.any.js": {
+        "note": "TODO(@KhafraDev): investigate",
+        "skip": true
+      },
+      "redirect-referrer.any.js": {
+        "note": "TODO(@KhafraDev): investigate",
+        "skip": true
+      },
+      "redirect-schemes.any.js": {
+        "note": "Electron: fix redirect handling",
+        "skip": true
+      },
+      "redirect-to-dataurl.any.js": {
+        "note": "Electron: does not support data urls",
+        "skip": true
+      },
+      "redirect-upload.h2.any.js": {
+        "note": "undici doesn't support http/2",
+        "skip": true
+      }
+    },
+    "request": {
+      "request-cache-default-conditional.any.js": {
+        "note": "undici doesn't implement an http cache",
+        "skip": true
+      },
+      "request-cache-default.any.js": {
+        "note": "undici doesn't implement an http cache",
+        "skip": true
+      },
+      "request-cache-force-cache.any.js": {
+        "note": "undici doesn't implement an http cache",
+        "skip": true
+      },
+      "request-cache-no-cache.any.js": {
+        "note": "undici doesn't implement an http cache",
+        "skip": true
+      },
+      "request-cache-no-store.any.js": {
+        "note": "undici doesn't implement an http cache",
+        "skip": true
+      },
+      "request-cache-only-if-cached.any.js": {
+        "note": "undici doesn't implement an http cache",
+        "skip": true
+      },
+      "request-cache-reload.any.js": {
+        "note": "undici doesn't implement an http cache",
+        "skip": true
+      },
+      "request-consume-empty.any.js": {
+        "note": "the semantics about this test are being discussed - https://github.com/web-platform-tests/wpt/pull/3950",
+        "fail": [
+          "Consume empty FormData request body as text"
+        ]
+      },
+      "request-disturbed.any.js": {
+        "note": "this test fails in all other platforms - https://wpt.fyi/results/fetch/api/request/request-disturbed.any.html?label=master&label=experimental&product=chrome&product=firefox&product=safari&product=deno&aligned&view=subtest",
+        "fail": [
+          "Input request used for creating new request became disturbed even if body is not used"
+        ]
+      },
+      "request-headers.any.js": {
+        "note": "undici doesn't filter headers",
+        "fail": [
+          "Adding invalid request header \"Accept-Charset: KO\"",
+          "Adding invalid request header \"accept-charset: KO\"",
+          "Adding invalid request header \"ACCEPT-ENCODING: KO\"",
+          "Adding invalid request header \"Accept-Encoding: KO\"",
+          "Adding invalid request header \"Access-Control-Request-Headers: KO\"",
+          "Adding invalid request header \"Access-Control-Request-Method: KO\"",
+          "Adding invalid request header \"Connection: KO\"",
+          "Adding invalid request header \"Content-Length: KO\"",
+          "Adding invalid request header \"Cookie: KO\"",
+          "Adding invalid request header \"Cookie2: KO\"",
+          "Adding invalid request header \"Date: KO\"",
+          "Adding invalid request header \"DNT: KO\"",
+          "Adding invalid request header \"Expect: KO\"",
+          "Adding invalid request header \"Host: KO\"",
+          "Adding invalid request header \"Keep-Alive: KO\"",
+          "Adding invalid request header \"Origin: KO\"",
+          "Adding invalid request header \"Referer: KO\"",
+          "Adding invalid request header \"Set-Cookie: KO\"",
+          "Adding invalid request header \"TE: KO\"",
+          "Adding invalid request header \"Trailer: KO\"",
+          "Adding invalid request header \"Transfer-Encoding: KO\"",
+          "Adding invalid request header \"Upgrade: KO\"",
+          "Adding invalid request header \"Via: KO\"",
+          "Adding invalid request header \"Proxy-: KO\"",
+          "Adding invalid request header \"proxy-a: KO\"",
+          "Adding invalid request header \"Sec-: KO\"",
+          "Adding invalid request header \"sec-b: KO\"",
+          "Adding invalid no-cors request header \"Content-Type: KO\"",
+          "Adding invalid no-cors request header \"Potato: KO\"",
+          "Adding invalid no-cors request header \"proxy: KO\"",
+          "Adding invalid no-cors request header \"proxya: KO\"",
+          "Adding invalid no-cors request header \"sec: KO\"",
+          "Adding invalid no-cors request header \"secb: KO\"",
+          "Adding invalid no-cors request header \"Empty-Value: \"",
+          "Check that request constructor is filtering headers provided as init parameter",
+          "Check that no-cors request constructor is filtering headers provided as init parameter",
+          "Check that no-cors request constructor is filtering headers provided as part of request parameter"
+        ]
+      },
+      "request-init-priority.any.js": {
+        "note": "undici doesn't implement priority hints, yet(?)",
+        "skip": true
+      }
+    },
+    "response": {
+      "json.any.js": {
+        "note": "Electron: does not support data urls",
+        "skip": true
+      },
+      "response-blob-realm.any.js": {
+        "note": "onload is not defined (globalThis does not extend EventTarget)",
+        "fail": [
+          "realm of the Uint8Array from Response bytes()"
+        ]
+      },
+      "response-clone.any.js": {
+        "note": "Node streams are too buggy currently.",
+        "skip": true
+      },
+      "response-consume-empty.any.js": {
+        "fail": [
+          "Consume empty FormData response body as text"
+        ]
+      },
+      "response-consume-stream.any.js": {
+        "note": "only fail in node v18",
+        "flaky": [
+          "Read blob response's body as readableStream with mode=byob",
+          "Read text response's body as readableStream with mode=byob",
+          "Read URLSearchParams response's body as readableStream with mode=byob",
+          "Read array buffer response's body as readableStream with mode=byob",
+          "Read form data response's body as readableStream with mode=byob"
+        ]
+      },
+      "response-headers-guard.any.js": {
+        "fail": [
+          "Ensure response headers are immutable"
+        ]
+      },
+      "response-stream-with-broken-then.any.js": {
+        "note": "this is a bug in webstreams, see https://github.com/nodejs/node/issues/46786",
+        "skip": true
+      }
+    },
+    "idlharness.any.js": {
+      "note": "Electron: fix idl generation",
+      "skip": true
+    }
+  },
+  "content-encoding": {
+    "br": {
+      "bad-br-body.https.any.js": {
+        "note": "TODO(@KhafraDev): investigate failure",
+        "fail": [
+          "Consuming the body of a resource with bad br content with arrayBuffer() should reject"
+        ]
+      },
+      "big-br-body.https.any.js": {
+        "note": "TODO(@KhafraDev): investigate failure",
+        "fail": [
+          "large br data should be decompressed successfully",
+          "large br data should be decompressed successfully with byte stream"
+        ]
+      },
+      "br-body.https.any.js": {
+        "note": "TODO(@KhafraDev): investigate failure",
+        "fail": [
+          "fetched br data with content type text should be decompressed.",
+          "fetched br data with content type octetstream should be decompressed."
+        ]
+      }
+    },
+    "gzip": {
+      "bad-gzip-body.any.js": {
+        "note": "TODO(@KhafraDev): investigate failure",
+        "fail": [
+          "Consuming the body of a resource with bad gzip content with arrayBuffer() should reject",
+          "Consuming the body of a resource with bad gzip content with blob() should reject",
+          "Consuming the body of a resource with bad gzip content with json() should reject",
+          "Consuming the body of a resource with bad gzip content with text() should reject"
+        ]
+      },
+      "gzip-body.any.js": {
+        "note": "TODO(@KhafraDev): investigate failure",
+        "fail": [
+          "fetched gzip data with content type text should be decompressed.",
+          "fetched gzip data with content type octetstream should be decompressed."
+        ]
+      },
+      "big-gzip-body.https.any.js": {
+        "note": "TODO(@KhafraDev): investigate failure",
+        "fail": [
+          "large gzip data should be decompressed successfully",
+          "large gzip data should be decompressed successfully with byte stream"
+        ]
+      }
+    },
+    "zstd": {
+      "note": "node does not have zstd yet",
+      "skip": true
+    }
+  },
+  "content-length": {
+    "api-and-duplicate-headers.any.js": {
+      "fail": [
+        "XMLHttpRequest and duplicate Content-Length/Content-Type headers",
+        "fetch() and duplicate Content-Length/Content-Type headers"
+      ]
+    }
+  },
+  "cross-origin-resource-policy": {
+    "note": "undici doesn't implement CORs",
+    "skip": true
+  },
+  "data-urls": {
+    "note": "Electron: does not support data urls",
+    "skip": true
+  },
+  "http-cache": {
+    "note": "undici doesn't implement http caching",
+    "skip": true
+  },
+  "metadata": {
+    "note": "undici doesn't respect RequestInit.mode",
+    "skip": true
+  },
+  "orb": {
+    "tentative": {
+      "note": "undici doesn't implement orb",
+      "skip": true
+    }
+  },
+  "range": {
+    "note": "undici doesn't respect range header",
+    "skip": true
+  },
+  "security": {
+    "1xx-response.any.js": {
+      "note": "TODO(@KhafraDev): investigate timeout",
+      "skip": true,
+      "fail": [
+        "Status(100) should be ignored.",
+        "Status(101) should be accepted, with removing body.",
+        "Status(103) should be ignored.",
+        "Status(199) should be ignored."
+      ]
+    }
+  },
+  "stale-while-revalidate": {
+    "note": "undici doesn't implement http caching",
+    "skip": true
+  },
+	"idlharness.any.js": {
+		"flaky": [
+			"Window interface: operation fetch(RequestInfo, optional RequestInit)"
+		]
+	}
+}

+ 0 - 9
spec/api-net-spec.ts

@@ -1508,15 +1508,6 @@ describe('net module', () => {
     });
 
     describe('net.fetch', () => {
-      // NB. there exist much more comprehensive tests for fetch() in the form of
-      // the WPT: https://github.com/web-platform-tests/wpt/tree/master/fetch
-      // It's possible to run these tests against net.fetch(), but the test
-      // harness to do so is quite complex and hasn't been munged to smoothly run
-      // inside the Electron test runner yet.
-      //
-      // In the meantime, here are some tests for basic functionality and
-      // Electron-specific behavior.
-
       describe('basic', () => {
         test('can fetch http urls', async () => {
           const serverUrl = await respondOnce.toSingleURL((request, response) => {