From a505f8bffcc8a5fe1cada8fe6683b597704688b9 Mon Sep 17 00:00:00 2001 From: Robert Thach Date: Thu, 3 Sep 2026 13:31:33 -0700 Subject: [PATCH] Add Fantom WPT conformance harness (#58267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: This diff lays down the measurement foundation (a Web Platform Tests harness) for adding streaming fetch support to React Native—without changing any actual product code or streaming functionality yet. Core Strategy: Measurement Before Code Before making deep C++ and JavaScript networking changes across React Native, the engineering team needs an objective, automated scoreboard. Building the test harness first ensures every future change can mechanically prove conformance improvements and catch regressions in continuous integration (CI). The 4-Phase Roadmap Phase 1: The Foundation (ReadableStream) The Goal: Add the standard ReadableStream class to React Native's core. Details: Establish the required JavaScript object and set up the testing framework (WPT) to measure progress. No active streaming is enabled yet. Phase 2: Wire Bridge to ReadableStream (Feature Flagged) The Goal: Reuse and extend the existing network code to support incremental chunk delivery. Details: Native Adjustments: Modify the existing RCTNetworking module to deliver binary data in incremental chunks rather than waiting for the complete payload. JS Fetch Module: Build a new JS module that triggers RCTNetworking.sendRequest and listens to didReceiveNetworkData events. Data Pipeline: Decode the incoming base64 binary chunks and enqueue them directly into the ReadableStream. Lifecycle & Teardown: Implement the 4 critical teardown paths to ensure native network connections safely close (preventing memory leaks) upon user cancellation or app crash. Rollout Strategy: Place the new streaming capability behind a feature flag to allow safe toggling between the legacy and updated network flows. Phase 3: Web Standards Polish The Goal: Reasonable compliance match with web standards. Details: Finalize edge cases, ensuring developers can successfully clone streams (clone()) and the system accurately tracks when a stream has been consumed (bodyUsed). Differential Revision: D116965389 --- .github/actions/run-fantom-tests/action.yml | 22 + .gitignore | 2 + .prettierignore | 2 + package.json | 3 + .../private/webapis/__tests__/wpt/README.md | 103 + .../webapis/__tests__/wpt/WPTBaseline.js | 21 + .../__tests__/wpt/WPTBaselineCapture.js | 34 + .../webapis/__tests__/wpt/WPTFetch-itest.js | 74 + .../webapis/__tests__/wpt/WPTFixtures.js | 24 + .../webapis/__tests__/wpt/WPTStreams-itest.js | 34 + .../webapis/__tests__/wpt/WPTTestHarness.js | 630 +++++ .../webapis/__tests__/wpt/wpt-baseline.json | 2273 +++++++++++++++++ scripts/web-platform-tests/sync.js | 529 ++++ scripts/web-platform-tests/update-baseline.js | 119 + 14 files changed, 3870 insertions(+) create mode 100644 packages/react-native/src/private/webapis/__tests__/wpt/README.md create mode 100644 packages/react-native/src/private/webapis/__tests__/wpt/WPTBaseline.js create mode 100644 packages/react-native/src/private/webapis/__tests__/wpt/WPTBaselineCapture.js create mode 100644 packages/react-native/src/private/webapis/__tests__/wpt/WPTFetch-itest.js create mode 100644 packages/react-native/src/private/webapis/__tests__/wpt/WPTFixtures.js create mode 100644 packages/react-native/src/private/webapis/__tests__/wpt/WPTStreams-itest.js create mode 100644 packages/react-native/src/private/webapis/__tests__/wpt/WPTTestHarness.js create mode 100644 packages/react-native/src/private/webapis/__tests__/wpt/wpt-baseline.json create mode 100644 scripts/web-platform-tests/sync.js create mode 100644 scripts/web-platform-tests/update-baseline.js diff --git a/.github/actions/run-fantom-tests/action.yml b/.github/actions/run-fantom-tests/action.yml index 8a6fdcd832ac..7ccc8a79aa72 100644 --- a/.github/actions/run-fantom-tests/action.yml +++ b/.github/actions/run-fantom-tests/action.yml @@ -14,6 +14,28 @@ runs: uses: ./.github/actions/setup-node - name: Install node dependencies uses: ./.github/actions/yarn-install + - name: Read WPT revision + id: wpt-revision + shell: bash + run: | + echo "revision=$(node -e 'process.stdout.write(require("./scripts/web-platform-tests/sync").WPT_REVISION)')" >> "$GITHUB_OUTPUT" + - name: Checkout WPT + uses: actions/checkout@v6 + with: + repository: web-platform-tests/wpt + ref: ${{ steps.wpt-revision.outputs.revision }} + path: .wpt + sparse-checkout: | + fetch + resources + streams + tools + wpt + - name: Generate WPT fixtures + shell: bash + run: | + ./.wpt/wpt manifest --no-download --rebuild fetch streams + yarn sync-wpt --wpt-root="$GITHUB_WORKSPACE/.wpt" - name: Download Fantom Runner binary uses: actions/download-artifact@v7 with: diff --git a/.gitignore b/.gitignore index d1e403fa6aa7..d2454926464c 100644 --- a/.gitignore +++ b/.gitignore @@ -170,9 +170,11 @@ fix_*.patch .metro-health-check* # Jest Integration +/.wpt/ /private/react-native-fantom/build/ /private/react-native-fantom/.out/ /private/react-native-fantom/tester/build/ +/packages/react-native/src/private/webapis/__tests__/wpt/generated/ # [Experimental] Generated TS type definitions /packages/**/types_generated/ diff --git a/.prettierignore b/.prettierignore index a2c77e3527d0..148ea454ae7f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,5 @@ vendor packages/**/types_generated/ packages/react-native-codegen/e2e/__test_fixtures__/modules/NativeEnumTurboModule.js +packages/react-native/src/private/webapis/__tests__/wpt/generated/wpt-fixtures.json +packages/react-native/src/private/webapis/__tests__/wpt/wpt-baseline.json diff --git a/package.json b/package.json index f27f754f8fde..54c1289dbaf4 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,12 @@ "test-ios": "./scripts/objc-test.sh test", "test-typescript-legacy": "tsc -p packages/react-native/__typetests__/tsconfig.legacy.json", "test-generated-typescript": "tsc -p packages/react-native/__typetests__/tsconfig.json", + "test-wpt": "yarn fantom 'WPT(Fetch|Streams)-itest'", "test": "jest", "fantom": "./scripts/fantom.sh", "fantom-cli": "./scripts/fantom-cli.sh", + "sync-wpt": "node ./scripts/web-platform-tests/sync.js", + "update-wpt-baseline": "node ./scripts/web-platform-tests/update-baseline.js", "trigger-react-native-release": "node ./scripts/releases-local/trigger-react-native-release.js", "update-lock": "npx yarn-deduplicate" }, diff --git a/packages/react-native/src/private/webapis/__tests__/wpt/README.md b/packages/react-native/src/private/webapis/__tests__/wpt/README.md new file mode 100644 index 000000000000..e4781e8d042b --- /dev/null +++ b/packages/react-native/src/private/webapis/__tests__/wpt/README.md @@ -0,0 +1,103 @@ +# Web Platform Tests for Fetch and Streams + +These tests run the shell-compatible portions of the upstream Web Platform +Tests (WPT) `fetch/` and `streams/` suites inside React Native's Fantom runtime. +The committed `wpt-baseline.json` results are an expectation baseline: both an +unexpected failure and an unexpected pass fail CI until the baseline is +deliberately updated. + +The fixtures are generated on demand and are not committed. They are pinned to +the WPT revision named by `WPT_REVISION` in +`scripts/web-platform-tests/sync.js` and contain byte-for-byte copies of the +upstream test sources, their `// META: script` dependencies, and WPT's +`testharness.js`. WPT is distributed under the +[3-Clause BSD License](https://github.com/web-platform-tests/wpt/blob/master/LICENSE.md). +The runner recognizes a `test(() => {}, ...)` registration without changing +its source so that assertion-free markers remain excluded from conformance +counts. The current expected results live in `wpt-baseline.json`; the generated +fixture also records every test file excluded by the environment boundary and +its reason. + +Selection is derived from WPT's `MANIFEST.json`, not from a file allowlist. A +source is runnable when the manifest classifies it as a `testharness` `.any.js` +test with a dedicated-worker variant and its declared scripts and reachable +host APIs are available in Fantom. Unsupported records include the manifest +type, generated URLs, declared globals/scripts, and the detected requirement. + +## Running the suites + +From the React Native repository root: + +```shell +yarn test-wpt +``` + +Fantom `-itest.js` files are part of the existing Fantom CI job, so the GitHub +Actions job checks out the pinned WPT revision, generates the fixture, and then +runs these suites. The CI tests execute the files in a deterministic shuffled +order and compare them with a baseline captured in manifest order. This makes +an order-dependent result fail the suite. + +An expected result can be changed to `FLAKY` in the baseline when it is known to +be intermittent. A flaky result accepts either a pass or a failure in CI, and +the baseline updater carries the `FLAKY` status forward by file, test name, and +duplicate-name occurrence. It must be removed manually once the test is stable. + +When a suite has no conformance passes and every ordinary subtest failure names +a runtime global that is actually absent, the result is reported as `BLOCKED`. +The baseline records the dominant missing global, all missing globals, and the +number of gated files and subtests instead of retaining hundreds of equivalent +failure rows. Harness-generated failures such as file-evaluation errors remain +visible separately. + +## Updating WPT and the baseline + +1. Obtain a checkout of + [web-platform-tests/wpt](https://github.com/web-platform-tests/wpt) at the + revision named by `WPT_REVISION` in + `scripts/web-platform-tests/sync.js`. To move to a newer upstream revision, + update that constant first. +2. Generate WPT's manifest at that revision: + + ```shell + ./wpt manifest --no-download --rebuild fetch streams + ``` + +3. Regenerate the local fixture: + + ```shell + yarn sync-wpt --wpt-root=/absolute/path/to/wpt + ``` + + The fixture is ignored by Git. The generator verifies the checkout revision + and every selected or excluded test source against its manifest Git-blob + hash. +4. Review the manifest-derived runnable and unsupported inventory in the + generated fixture. If the environment capability rules have changed, update + the classifier in `sync.js`. +5. Record current React Native behavior and verify the new baseline: + + ```shell + yarn update-wpt-baseline + ``` + +6. Review every changed result before accepting the new baseline. The update + command preserves existing `FLAKY` statuses and reruns `yarn test-wpt` + against the newly written baseline. + +## Environment limitations + +Fantom supplies React Native's real JavaScript globals and Hermes runtime, but +its HTTP client is intentionally a non-completing stub. Fetch tests that require +the WPT HTTP(S) server therefore cannot run without changing the Fantom native +test environment. Browser-only tests also cannot run because Fantom has no DOM, +navigation, Window, service worker, or shared worker environment. + +The Streams suite runs `.any.js` tests that are compatible with a JavaScript +shell. WebIDL harness tests, explicit garbage-collection tests, and tests that +require browser transferables such as `MessagePort` or `VideoFrame` are recorded +as unsupported in the generated fixture. + +The adapter processes direct `// META: script=` dependencies. Other WPT +execution metadata, nested dependency metadata, and browser or worker realm +creation are not implemented. diff --git a/packages/react-native/src/private/webapis/__tests__/wpt/WPTBaseline.js b/packages/react-native/src/private/webapis/__tests__/wpt/WPTBaseline.js new file mode 100644 index 000000000000..5665f9f7a06b --- /dev/null +++ b/packages/react-native/src/private/webapis/__tests__/wpt/WPTBaseline.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {WPTSuiteResult} from './WPTTestHarness'; + +export type WPTBaseline = { + fetch: WPTSuiteResult, + streams: WPTSuiteResult, +}; + +// $FlowExpectedError[untyped-import] JSON cannot carry a Flow declaration. +const baseline = require('./wpt-baseline.json') as WPTBaseline; + +export default baseline; diff --git a/packages/react-native/src/private/webapis/__tests__/wpt/WPTBaselineCapture.js b/packages/react-native/src/private/webapis/__tests__/wpt/WPTBaselineCapture.js new file mode 100644 index 000000000000..0df046059e0d --- /dev/null +++ b/packages/react-native/src/private/webapis/__tests__/wpt/WPTBaselineCapture.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import baseline from './WPTBaseline'; +import fixtures from './WPTFixtures'; +import {applyWPTFlakyStatuses, runWPTSuite} from './WPTTestHarness'; + +const BASELINE_MARKER = '__RN_WPT_BASELINE__'; +describe('Web Platform Tests baseline capture', () => { + it('captures fetch', () => { + const result = applyWPTFlakyStatuses( + runWPTSuite(fixtures, 'fetch'), + baseline.fetch, + ); + console.log(`${BASELINE_MARKER}fetch:${JSON.stringify(result)}`); + }); + + it('captures streams', () => { + const result = applyWPTFlakyStatuses( + runWPTSuite(fixtures, 'streams'), + baseline.streams, + ); + console.log(`${BASELINE_MARKER}streams:${JSON.stringify(result)}`); + }); +}); diff --git a/packages/react-native/src/private/webapis/__tests__/wpt/WPTFetch-itest.js b/packages/react-native/src/private/webapis/__tests__/wpt/WPTFetch-itest.js new file mode 100644 index 000000000000..8c9c0f9db4f9 --- /dev/null +++ b/packages/react-native/src/private/webapis/__tests__/wpt/WPTFetch-itest.js @@ -0,0 +1,74 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {WPTSuiteResult} from './WPTTestHarness'; + +import baseline from './WPTBaseline'; +import fixtures from './WPTFixtures'; +import { + applyWPTFlakyStatuses, + getWPTExecutionPaths, + runWPTSuite, +} from './WPTTestHarness'; + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +function singleTestResult(status: string): WPTSuiteResult { + return { + files: [ + { + harnessStatus: 'OK', + path: 'example.any.js', + tests: [{name: 'intermittent result', status}], + }, + ], + manifest: {sha256: 'test', version: 0}, + revision: 'test', + source: 'test', + suite: 'fetch', + summary: { + BLOCKED: 0, + FAIL: status === 'FAIL' ? 1 : 0, + FLAKY: status === 'FLAKY' ? 1 : 0, + NOTRUN: 0, + PASS: status === 'PASS' ? 1 : 0, + PRECONDITION_FAILED: 0, + TIMEOUT: 0, + noOpTests: 0, + total: 1, + unsupportedFiles: 0, + }, + }; +} + +describe('Web Platform Tests: fetch', () => { + it('uses a non-manifest deterministic order', () => { + expect( + getWPTExecutionPaths(fixtures, 'fetch', 'deterministic-shuffle'), + ).not.toEqual(getWPTExecutionPaths(fixtures, 'fetch', 'manifest')); + }); + + it('matches the manifest-order baseline when shuffled', () => { + const result = runWPTSuite(fixtures, 'fetch', 'deterministic-shuffle'); + expect(applyWPTFlakyStatuses(result, baseline.fetch)).toEqual( + baseline.fetch, + ); + }); + + it('preserves a flaky baseline result for either outcome', () => { + const flakyBaseline = singleTestResult('FLAKY'); + expect( + applyWPTFlakyStatuses(singleTestResult('PASS'), flakyBaseline), + ).toEqual(flakyBaseline); + expect( + applyWPTFlakyStatuses(singleTestResult('FAIL'), flakyBaseline), + ).toEqual(flakyBaseline); + }); +}); diff --git a/packages/react-native/src/private/webapis/__tests__/wpt/WPTFixtures.js b/packages/react-native/src/private/webapis/__tests__/wpt/WPTFixtures.js new file mode 100644 index 000000000000..2d2880f84a46 --- /dev/null +++ b/packages/react-native/src/private/webapis/__tests__/wpt/WPTFixtures.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {WPTFixtures} from './WPTTestHarness'; + +let fixtures: WPTFixtures; +try { + // $FlowExpectedError[untyped-import] JSON cannot carry a Flow declaration. + // $FlowExpectedError[cannot-resolve-module] Generated by sync.js, not committed. + fixtures = require('./generated/wpt-fixtures.json') as WPTFixtures; +} catch { + throw new Error( + 'wpt-fixtures.json not found. Run: node scripts/web-platform-tests/sync.js --wpt-root=/path/to/wpt', + ); +} + +export default fixtures; diff --git a/packages/react-native/src/private/webapis/__tests__/wpt/WPTStreams-itest.js b/packages/react-native/src/private/webapis/__tests__/wpt/WPTStreams-itest.js new file mode 100644 index 000000000000..bb2661e47fb2 --- /dev/null +++ b/packages/react-native/src/private/webapis/__tests__/wpt/WPTStreams-itest.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import baseline from './WPTBaseline'; +import fixtures from './WPTFixtures'; +import { + applyWPTFlakyStatuses, + getWPTExecutionPaths, + runWPTSuite, +} from './WPTTestHarness'; + +describe('Web Platform Tests: streams', () => { + it('uses a non-manifest deterministic order', () => { + expect( + getWPTExecutionPaths(fixtures, 'streams', 'deterministic-shuffle'), + ).not.toEqual(getWPTExecutionPaths(fixtures, 'streams', 'manifest')); + }); + + it('matches the manifest-order baseline when shuffled', () => { + const result = runWPTSuite(fixtures, 'streams', 'deterministic-shuffle'); + expect(applyWPTFlakyStatuses(result, baseline.streams)).toEqual( + baseline.streams, + ); + }); +}); diff --git a/packages/react-native/src/private/webapis/__tests__/wpt/WPTTestHarness.js b/packages/react-native/src/private/webapis/__tests__/wpt/WPTTestHarness.js new file mode 100644 index 000000000000..fcc107fe26a8 --- /dev/null +++ b/packages/react-native/src/private/webapis/__tests__/wpt/WPTTestHarness.js @@ -0,0 +1,630 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import * as Fantom from '@react-native/fantom'; + +type WPTSourceFile = { + path: string, + source: string, +}; + +type WPTTestFixture = WPTSourceFile & { + dependencies: ReadonlyArray, + manifest: { + globals: ReadonlyArray, + type: string, + urls: ReadonlyArray, + }, +}; + +type WPTUnsupportedFile = { + path: string, + reason: string, +}; + +export type WPTFixtures = { + manifest: { + sha256: string, + version: number, + }, + revision: string, + selection: { + environment: string, + manifestTypes: ReadonlyArray, + sourceFormat: string, + }, + source: string, + testharness: string, + suites: { + fetch: ReadonlyArray, + streams: ReadonlyArray, + }, + unsupported: { + fetch: ReadonlyArray, + streams: ReadonlyArray, + }, +}; + +type WPTSuiteName = keyof WPTFixtures['suites']; +type WPTExecutionOrder = 'deterministic-shuffle' | 'manifest'; + +type WPTTestharnessResult = { + message: unknown, + name: unknown, + status: unknown, +}; + +type WPTTestharnessStatus = { + status: unknown, +}; + +type WPTTestFunction = ( + callback: () => void, + name: string, + properties?: unknown, +) => void; + +type WPTHarnessAPI = { + add_completion_callback: ( + callback: ( + tests: ReadonlyArray, + status: WPTTestharnessStatus, + ) => void, + ) => void, + add_result_callback: (callback: (test: WPTTestharnessResult) => void) => void, + done: () => void, + setup: (options: {explicit_done: boolean, output: boolean}) => void, + test: WPTTestFunction, +}; + +type WPTGlobal = WPTHarnessAPI & { + addEventListener: () => void, + eval: (source: string) => unknown, + GLOBAL: { + isDedicatedWorker: () => boolean, + isServiceWorker: () => boolean, + isShadowRealm: () => boolean, + isSharedWorker: () => boolean, + isWindow: () => boolean, + isWorker: () => boolean, + }, + self: WPTGlobal, +}; + +type WPTSubtestResult = { + name: string, + status: string, +}; + +type WPTFileResult = { + harnessStatus: string, + path: string, + readonly tests: ReadonlyArray, +}; + +type WPTBlockedSuite = { + gatedFiles: number, + gatedTests: number, + missingGlobals: ReadonlyArray, + reason: string, +}; + +type WPTSummary = { + BLOCKED: number, + FAIL: number, + FLAKY: number, + NOTRUN: number, + PASS: number, + PRECONDITION_FAILED: number, + TIMEOUT: number, + noOpTests: number, + total: number, + unsupportedFiles: number, +}; + +export type WPTSuiteResult = { + blocked?: WPTBlockedSuite, + files: ReadonlyArray, + manifest: WPTFixtures['manifest'], + revision: string, + source: string, + summary: WPTSummary, + suite: WPTSuiteName, +}; + +type WPTObservedSubtestResult = { + message: string, + name: string, + status: string, +}; + +type WPTObservedFileResult = { + harnessStatus: string, + noOpTests: number, + path: string, + readonly tests: ReadonlyArray, +}; + +const HARNESS_STATUSES = ['OK', 'ERROR', 'TIMEOUT', 'PRECONDITION_FAILED']; +const TEST_STATUSES = [ + 'PASS', + 'FAIL', + 'TIMEOUT', + 'NOTRUN', + 'PRECONDITION_FAILED', +]; + +function statusName(statuses: ReadonlyArray, status: unknown): string { + return typeof status === 'number' && statuses[status] != null + ? statuses[status] + : `UNKNOWN(${String(status)})`; +} + +function getWPTGlobal(): WPTGlobal { + // $FlowExpectedError[incompatible-type] testharness.js installs these APIs dynamically. + // $FlowExpectedError[incompatible-variance] testharness.js replaces read-only host globals. + return globalThis; +} + +function configureShellGlobals(wptGlobal: WPTGlobal): void { + wptGlobal.addEventListener = () => {}; + wptGlobal.GLOBAL = { + isDedicatedWorker: () => true, + isServiceWorker: () => false, + isShadowRealm: () => false, + isSharedWorker: () => false, + isWindow: () => false, + isWorker: () => true, + }; + wptGlobal.self = wptGlobal; +} + +const EMPTY_FUNCTION_BODY = + /^(?:\s|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*(?:\r?\n|$))*$/; +// Hermes otherwise replaces callback bodies with `[bytecode]` in toString(). +const HERMES_SHOW_SOURCE_DIRECTIVE = "'show source';"; + +function isNoOpTestCallback(callback: () => void): boolean { + const source = callback.toString(); + if ( + callback.length !== 0 || + /^\s*async\b/.test(source) || + /^\s*function\s*\*/.test(source) + ) { + return false; + } + const body = source.match(/\{([\s\S]*)\}\s*$/)?.[1]; + return body != null && EMPTY_FUNCTION_BODY.test(body); +} + +type PendingWPTFile = { + completion: Promise, + timedOutResult: () => WPTObservedFileResult, +}; + +function normalizeTestResult( + testResult: WPTTestharnessResult, +): WPTObservedSubtestResult { + return { + message: testResult.message == null ? '' : String(testResult.message), + name: String(testResult.name) + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\t/g, '\\t'), + status: statusName(TEST_STATUSES, testResult.status), + }; +} + +function beginWPTFile( + fixtures: WPTFixtures, + fixture: WPTTestFixture, +): PendingWPTFile { + const completedTests: Array = []; + const noOpTestResults = new WeakSet(); + let isRegisteringNoOpTest = false; + let noOpTests = 0; + const wptGlobal = getWPTGlobal(); + configureShellGlobals(wptGlobal); + wptGlobal.eval(fixtures.testharness); + wptGlobal.setup({explicit_done: true, output: false}); + + wptGlobal.add_result_callback(testResult => { + if (isRegisteringNoOpTest) { + noOpTestResults.add(testResult); + noOpTests++; + return; + } + completedTests.push(normalizeTestResult(testResult)); + }); + + const wptTest = wptGlobal.test; + wptGlobal.test = (callback, name, properties) => { + isRegisteringNoOpTest = isNoOpTestCallback(callback); + try { + wptTest(callback, name, properties); + } finally { + isRegisteringNoOpTest = false; + } + }; + + const completion = new Promise(resolve => { + wptGlobal.add_completion_callback((tests, harnessStatus) => { + const normalizedHarnessStatus = statusName( + HARNESS_STATUSES, + harnessStatus.status, + ); + const normalizedTests = tests + .filter(testResult => !noOpTestResults.has(testResult)) + .map(normalizeTestResult); + if (normalizedHarnessStatus !== 'OK') { + normalizedTests.push({ + message: normalizedHarnessStatus, + name: '', + status: + normalizedHarnessStatus === 'TIMEOUT' + ? 'TIMEOUT' + : normalizedHarnessStatus === 'PRECONDITION_FAILED' + ? 'PRECONDITION_FAILED' + : 'FAIL', + }); + } + resolve({ + harnessStatus: normalizedHarnessStatus, + noOpTests, + path: fixture.path, + tests: normalizedTests.sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ), + }); + }); + }); + + try { + const source = [ + ...fixture.dependencies.map(dependency => dependency.source), + fixture.source, + ].join('\n'); + wptGlobal.eval( + `(function () {\n${HERMES_SHOW_SOURCE_DIRECTIVE}\n${source}\n}).call(self);\n//# sourceURL=wpt/${fixture.path}`, + ); + } catch (error) { + wptGlobal.test(() => { + throw error; + }, ''); + } + + wptGlobal.done(); + return { + completion, + timedOutResult: () => ({ + harnessStatus: 'TIMEOUT', + noOpTests, + path: fixture.path, + tests: [ + ...completedTests, + { + message: 'The WPT file did not complete.', + name: '', + status: 'TIMEOUT', + }, + ].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)), + }), + }; +} + +function missingGlobalFromMessage(message: string): string | void { + const hermesMatch = message.match(/Property '([^']+)' doesn't exist/); + if (hermesMatch?.[1] != null) { + return hermesMatch[1]; + } + const referenceErrorMatch = message.match( + /(?:ReferenceError:\s*)?([A-Za-z_$][\w$]*) is not defined/, + ); + return referenceErrorMatch?.[1]; +} + +function isMissingRuntimeGlobal(name: string): boolean { + return !(name in getWPTGlobal()); +} + +function collapseBlockedSuite(files: ReadonlyArray): { + blocked?: WPTBlockedSuite, + files: Array, +} { + const observedResults = files.flatMap(file => + file.tests.map(testResult => ({file: file.path, testResult})), + ); + const classifiedResults = observedResults.map(({file, testResult}) => { + const missingGlobal = missingGlobalFromMessage(testResult.message); + return { + file, + missingGlobal: + missingGlobal != null && isMissingRuntimeGlobal(missingGlobal) + ? missingGlobal + : null, + testResult, + }; + }); + const conformanceResults = classifiedResults.filter( + ({testResult}) => !testResult.name.startsWith('<'), + ); + const isBlocked = + conformanceResults.length > 0 && + conformanceResults.every( + ({missingGlobal, testResult}) => + testResult.status === 'FAIL' && missingGlobal != null, + ); + + if (!isBlocked) { + return { + files: files.map(file => ({ + harnessStatus: file.harnessStatus, + path: file.path, + tests: file.tests.map(({name, status}) => ({name, status})), + })), + }; + } + + const gatedResults = classifiedResults.filter( + ({missingGlobal, testResult}) => + testResult.status === 'FAIL' && missingGlobal != null, + ); + const missingGlobalCounts = new Map(); + const gatedFiles = new Set(); + for (const {file, missingGlobal} of gatedResults) { + if (missingGlobal == null) { + throw new Error('A blocked WPT result must name a missing global.'); + } + gatedFiles.add(file); + missingGlobalCounts.set( + missingGlobal, + (missingGlobalCounts.get(missingGlobal) ?? 0) + 1, + ); + } + const missingGlobalsByFrequency = [...missingGlobalCounts].sort( + ([nameA, countA], [nameB, countB]) => + countB - countA || (nameA < nameB ? -1 : nameA > nameB ? 1 : 0), + ); + const primaryMissingGlobal = missingGlobalsByFrequency[0]?.[0]; + if (primaryMissingGlobal == null) { + throw new Error('A blocked WPT suite must name a primary missing global.'); + } + + return { + blocked: { + gatedFiles: gatedFiles.size, + gatedTests: gatedResults.length, + missingGlobals: [...missingGlobalCounts.keys()].sort(), + reason: `${primaryMissingGlobal} not implemented`, + }, + files: files + .map((file): WPTFileResult => ({ + harnessStatus: file.harnessStatus, + path: file.path, + tests: file.tests + .filter( + testResult => missingGlobalFromMessage(testResult.message) == null, + ) + .map(({name, status}) => ({name, status})), + })) + .filter(file => file.tests.length > 0), + }; +} + +function summarizeWPTSuite( + files: ReadonlyArray, + blockedTests: number, + noOpTests: number, + unsupportedFiles: number, +): WPTSummary { + const summary = { + BLOCKED: blockedTests, + FAIL: 0, + FLAKY: 0, + NOTRUN: 0, + PASS: 0, + PRECONDITION_FAILED: 0, + TIMEOUT: 0, + noOpTests, + total: blockedTests, + unsupportedFiles, + }; + + for (const file of files) { + for (const testResult of file.tests) { + switch (testResult.status) { + case 'FAIL': + summary.FAIL++; + break; + case 'FLAKY': + summary.FLAKY++; + break; + case 'NOTRUN': + summary.NOTRUN++; + break; + case 'PASS': + summary.PASS++; + break; + case 'PRECONDITION_FAILED': + summary.PRECONDITION_FAILED++; + break; + case 'TIMEOUT': + summary.TIMEOUT++; + break; + } + summary.total++; + } + } + + return summary; +} + +function isWPTStatusAccepted( + actualStatus: string, + baselineStatus: string, +): boolean { + return baselineStatus === 'FLAKY' || actualStatus === baselineStatus; +} + +export function applyWPTFlakyStatuses( + result: WPTSuiteResult, + baseline: WPTSuiteResult, +): WPTSuiteResult { + const baselineFiles = new Map( + baseline.files.map(file => [file.path, file.tests]), + ); + const files = result.files.map(file => { + const baselineTests = baselineFiles.get(file.path) ?? []; + const occurrenceByName = new Map(); + const flakyOccurrences = new Set(); + for (const testResult of baselineTests) { + const occurrence = occurrenceByName.get(testResult.name) ?? 0; + occurrenceByName.set(testResult.name, occurrence + 1); + if (testResult.status === 'FLAKY') { + flakyOccurrences.add(`${testResult.name}\0${String(occurrence)}`); + } + } + + occurrenceByName.clear(); + return { + ...file, + tests: file.tests.map(testResult => { + const occurrence = occurrenceByName.get(testResult.name) ?? 0; + occurrenceByName.set(testResult.name, occurrence + 1); + const baselineStatus = flakyOccurrences.has( + `${testResult.name}\0${String(occurrence)}`, + ) + ? 'FLAKY' + : testResult.status; + return { + ...testResult, + status: isWPTStatusAccepted(testResult.status, baselineStatus) + ? baselineStatus + : testResult.status, + }; + }), + }; + }); + + return { + ...result, + files, + summary: summarizeWPTSuite( + files, + result.summary.BLOCKED, + result.summary.noOpTests, + result.summary.unsupportedFiles, + ), + }; +} + +export function getWPTExecutionPaths( + fixtures: WPTFixtures, + suite: WPTSuiteName, + executionOrder: WPTExecutionOrder, +): Array { + const paths = fixtures.suites[suite].map(fixture => fixture.path); + if (executionOrder === 'manifest') { + return paths; + } + + let state = 0x2856095; + for (let index = paths.length - 1; index > 0; index--) { + state = (state * 1664525 + 1013904223) % 4294967296; + const swapIndex = state % (index + 1); + const currentPath = paths[index]; + const swapPath = paths[swapIndex]; + if (currentPath == null || swapPath == null) { + throw new Error('Could not shuffle WPT fixture paths.'); + } + paths[index] = swapPath; + paths[swapIndex] = currentPath; + } + return paths; +} + +export function runWPTSuite( + fixtures: WPTFixtures, + suite: WPTSuiteName, + executionOrder: WPTExecutionOrder = 'manifest', +): WPTSuiteResult { + const wptGlobal = getWPTGlobal(); + const originalTestDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'test', + ); + if (originalTestDescriptor == null) { + throw new Error('The Jest test global is not defined.'); + } + const files: Array = []; + const fixturesByPath = new Map( + fixtures.suites[suite].map(fixture => [fixture.path, fixture]), + ); + + try { + for (const fixturePath of getWPTExecutionPaths( + fixtures, + suite, + executionOrder, + )) { + const fixture = fixturesByPath.get(fixturePath); + if (fixture == null) { + throw new Error(`Missing WPT fixture ${fixturePath}.`); + } + let completedResult: WPTObservedFileResult | void; + let pendingFile: PendingWPTFile | void; + Fantom.runTask(async () => { + pendingFile = beginWPTFile(fixtures, fixture); + completedResult = await pendingFile.completion; + }); + const fallbackResult: WPTObservedFileResult = { + harnessStatus: 'ERROR', + noOpTests: 0, + path: fixture.path, + tests: [ + { + message: 'The WPT file could not be initialized.', + name: '', + status: 'FAIL', + }, + ], + }; + files.push( + completedResult ?? pendingFile?.timedOutResult() ?? fallbackResult, + ); + } + } finally { + // $FlowExpectedError[incompatible-type] The descriptor contains Jest's test function, not WPT's. + Object.defineProperty(wptGlobal, 'test', originalTestDescriptor); + } + + const noOpTests = files.reduce((count, file) => count + file.noOpTests, 0); + const collapsed = collapseBlockedSuite(files); + const sortedFiles = collapsed.files.sort((a, b) => + a.path < b.path ? -1 : a.path > b.path ? 1 : 0, + ); + + return { + ...(collapsed.blocked == null ? {} : {blocked: collapsed.blocked}), + files: sortedFiles, + manifest: fixtures.manifest, + revision: fixtures.revision, + source: fixtures.source, + summary: summarizeWPTSuite( + sortedFiles, + collapsed.blocked?.gatedTests ?? 0, + noOpTests, + fixtures.unsupported[suite].length, + ), + suite, + }; +} diff --git a/packages/react-native/src/private/webapis/__tests__/wpt/wpt-baseline.json b/packages/react-native/src/private/webapis/__tests__/wpt/wpt-baseline.json new file mode 100644 index 000000000000..33151af6f536 --- /dev/null +++ b/packages/react-native/src/private/webapis/__tests__/wpt/wpt-baseline.json @@ -0,0 +1,2273 @@ +{ + "fetch": { + "files": [ + { + "harnessStatus": "OK", + "path": "fetch/api/basic/historical.any.js", + "tests": [ + { + "name": "'type' getter should not exist on Request objects", + "status": "PASS" + }, + { + "name": "Headers object no longer has a getAll() method", + "status": "PASS" + }, + { + "name": "Response object no longer has a trailer getter", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/body/formdata.any.js", + "tests": [ + { + "name": "Consume empty request.formData() as FormData", + "status": "FAIL" + }, + { + "name": "Consume empty response.formData() as FormData", + "status": "FAIL" + }, + { + "name": "Consume multipart/form-data headers case-insensitively", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/body/mime-type.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + }, + { + "name": "Request: MIME type for Blob from empty body", + "status": "FAIL" + }, + { + "name": "Request: MIME type for Blob from empty body with Content-Type", + "status": "FAIL" + }, + { + "name": "Request: overriding explicit Content-Type", + "status": "FAIL" + }, + { + "name": "Request: removing implicit Content-Type", + "status": "FAIL" + }, + { + "name": "Request: setting missing Content-Type", + "status": "FAIL" + }, + { + "name": "Response: MIME type for Blob from empty body", + "status": "FAIL" + }, + { + "name": "Response: MIME type for Blob from empty body with Content-Type", + "status": "FAIL" + }, + { + "name": "Response: overriding explicit Content-Type", + "status": "FAIL" + }, + { + "name": "Response: removing implicit Content-Type", + "status": "FAIL" + }, + { + "name": "Response: setting missing Content-Type", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/body/textstream.any.js", + "tests": [ + { + "name": "Request.textStream() basic functionality", + "status": "FAIL" + }, + { + "name": "Request.textStream() ignores Content-Type charset (UTF-16LE)", + "status": "FAIL" + }, + { + "name": "Request.textStream() on consumed body throws TypeError", + "status": "PASS" + }, + { + "name": "Request.textStream() on locked body throws TypeError", + "status": "FAIL" + }, + { + "name": "Request.textStream() with null body", + "status": "FAIL" + }, + { + "name": "Response.textStream() basic functionality", + "status": "FAIL" + }, + { + "name": "Response.textStream() ignores Content-Type charset (UTF-16LE)", + "status": "FAIL" + }, + { + "name": "Response.textStream() ignores invalid Content-Type charset (invalid-charset)", + "status": "FAIL" + }, + { + "name": "Response.textStream() on consumed body throws TypeError", + "status": "PASS" + }, + { + "name": "Response.textStream() on locked body throws TypeError", + "status": "FAIL" + }, + { + "name": "Response.textStream() with empty body", + "status": "FAIL" + }, + { + "name": "Response.textStream() with null body", + "status": "FAIL" + }, + { + "name": "textStream method existence", + "status": "FAIL" + }, + { + "name": "textStream() handles chunked byte stream input", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/headers/header-setcookie.any.js", + "tests": [ + { + "name": "Adding Set-Cookie headers normalizes their value", + "status": "FAIL" + }, + { + "name": "Adding invalid Set-Cookie headers throws", + "status": "FAIL" + }, + { + "name": "Headers iterator does not combine set-cookie & set-cookie2 headers", + "status": "FAIL" + }, + { + "name": "Headers iterator does not combine set-cookie headers", + "status": "FAIL" + }, + { + "name": "Headers iterator does not special case set-cookie2 headers", + "status": "PASS" + }, + { + "name": "Headers iterator is correctly updated with set-cookie changes", + "status": "FAIL" + }, + { + "name": "Headers iterator is correctly updated with set-cookie changes #2", + "status": "FAIL" + }, + { + "name": "Headers iterator preserves per header ordering, but sorts keys alphabetically", + "status": "FAIL" + }, + { + "name": "Headers iterator preserves per header ordering, but sorts keys alphabetically (and ignores value ordering)", + "status": "FAIL" + }, + { + "name": "Headers iterator preserves set-cookie ordering", + "status": "FAIL" + }, + { + "name": "Headers.prototype.append works for set-cookie", + "status": "FAIL" + }, + { + "name": "Headers.prototype.delete works for set-cookie", + "status": "PASS" + }, + { + "name": "Headers.prototype.get combines set-cookie headers in order", + "status": "PASS" + }, + { + "name": "Headers.prototype.getSetCookie ignores set-cookie2 headers", + "status": "FAIL" + }, + { + "name": "Headers.prototype.getSetCookie preserves header ordering", + "status": "FAIL" + }, + { + "name": "Headers.prototype.getSetCookie with an empty header", + "status": "FAIL" + }, + { + "name": "Headers.prototype.getSetCookie with multiple headers", + "status": "FAIL" + }, + { + "name": "Headers.prototype.getSetCookie with no headers present", + "status": "FAIL" + }, + { + "name": "Headers.prototype.getSetCookie with one header", + "status": "FAIL" + }, + { + "name": "Headers.prototype.getSetCookie with one header created from an object", + "status": "FAIL" + }, + { + "name": "Headers.prototype.getSetCookie with two equal headers", + "status": "FAIL" + }, + { + "name": "Headers.prototype.has works for set-cookie", + "status": "PASS" + }, + { + "name": "Headers.prototype.set works for set-cookie", + "status": "PASS" + }, + { + "name": "Set-Cookie is a forbidden response header", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/headers/headers-basic.any.js", + "tests": [ + { + "name": "Appending a value pair during iteration causes it to be reached during iteration", + "status": "FAIL" + }, + { + "name": "Check Symbol.iterator method", + "status": "FAIL" + }, + { + "name": "Check append method", + "status": "PASS" + }, + { + "name": "Check delete method", + "status": "PASS" + }, + { + "name": "Check entries method", + "status": "FAIL" + }, + { + "name": "Check forEach method", + "status": "FAIL" + }, + { + "name": "Check get method", + "status": "PASS" + }, + { + "name": "Check has method", + "status": "PASS" + }, + { + "name": "Check keys method", + "status": "FAIL" + }, + { + "name": "Check set method", + "status": "PASS" + }, + { + "name": "Check values method", + "status": "FAIL" + }, + { + "name": "Create headers from empty object", + "status": "PASS" + }, + { + "name": "Create headers from no parameter", + "status": "PASS" + }, + { + "name": "Create headers from undefined parameter", + "status": "PASS" + }, + { + "name": "Create headers with 1 should throw", + "status": "FAIL" + }, + { + "name": "Create headers with existing headers", + "status": "PASS" + }, + { + "name": "Create headers with existing headers with custom iterator", + "status": "FAIL" + }, + { + "name": "Create headers with null should throw", + "status": "FAIL" + }, + { + "name": "Create headers with record", + "status": "PASS" + }, + { + "name": "Create headers with sequence", + "status": "PASS" + }, + { + "name": "Iteration skips elements removed while iterating", + "status": "FAIL" + }, + { + "name": "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time", + "status": "FAIL" + }, + { + "name": "Removing elements already iterated over causes an element to be skipped during iteration", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/headers/headers-casing.any.js", + "tests": [ + { + "name": "Check append method, names use characters with different case", + "status": "PASS" + }, + { + "name": "Check delete method, names use characters with different case", + "status": "PASS" + }, + { + "name": "Check set method, names use characters with different case", + "status": "PASS" + }, + { + "name": "Create headers, names use characters with different case", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/headers/headers-combine.any.js", + "tests": [ + { + "name": "Check append methods when called with already used name", + "status": "PASS" + }, + { + "name": "Check delete and has methods when using same name for different values", + "status": "PASS" + }, + { + "name": "Check set methods when called with already used name", + "status": "PASS" + }, + { + "name": "Create headers using same name for different values", + "status": "PASS" + }, + { + "name": "Iterate combined values", + "status": "PASS" + }, + { + "name": "Iterate combined values in sorted order", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/headers/headers-errors.any.js", + "tests": [ + { + "name": "Check headers append with an invalid name [object Object]", + "status": "PASS" + }, + { + "name": "Check headers append with an invalid name invalidĀ", + "status": "PASS" + }, + { + "name": "Check headers append with an invalid value invalidĀ", + "status": "FAIL" + }, + { + "name": "Check headers delete with an invalid name [object Object]", + "status": "PASS" + }, + { + "name": "Check headers delete with an invalid name invalidĀ", + "status": "PASS" + }, + { + "name": "Check headers get with an invalid name [object Object]", + "status": "PASS" + }, + { + "name": "Check headers get with an invalid name invalidĀ", + "status": "PASS" + }, + { + "name": "Check headers has with an invalid name [object Object]", + "status": "PASS" + }, + { + "name": "Check headers has with an invalid name invalidĀ", + "status": "PASS" + }, + { + "name": "Check headers set with an invalid name [object Object]", + "status": "PASS" + }, + { + "name": "Check headers set with an invalid name invalidĀ", + "status": "PASS" + }, + { + "name": "Check headers set with an invalid value invalidĀ", + "status": "FAIL" + }, + { + "name": "Create headers giving an array having one string as init argument", + "status": "PASS" + }, + { + "name": "Create headers giving an array having three strings as init argument", + "status": "PASS" + }, + { + "name": "Create headers giving bad header name as init argument", + "status": "PASS" + }, + { + "name": "Create headers giving bad header value as init argument", + "status": "FAIL" + }, + { + "name": "Headers forEach loop should stop if callback is throwing exception", + "status": "PASS" + }, + { + "name": "Headers forEach throws if argument is not callable", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/headers/headers-forbidden-override.any.js", + "tests": [ + { + "name": "header X-HTTP-METHOD is allowed to use value \",TRACE\",", + "status": "PASS" + }, + { + "name": "header X-HTTP-METHOD is allowed to use value GET", + "status": "PASS" + }, + { + "name": "header X-HTTP-METHOD is allowed to use value GETTRACE", + "status": "PASS" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value CONNECT", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value GET,track ", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value TRACE", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value TRACK", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value \\nconnect", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value \\rtrace", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value \\ttrack", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value trace", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value trace,", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD is forbidden to use value track", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is allowed to use value \",TRACE\",", + "status": "PASS" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is allowed to use value GET", + "status": "PASS" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is allowed to use value GETTRACE", + "status": "PASS" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value CONNECT", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value GET,track ", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value TRACE", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value TRACK", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value \\nconnect", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value \\rtrace", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value \\ttrack", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value trace", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value trace,", + "status": "FAIL" + }, + { + "name": "header X-HTTP-METHOD-OVERRIDE is forbidden to use value track", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is allowed to use value \",TRACE\",", + "status": "PASS" + }, + { + "name": "header X-METHOD-OVERRIDE is allowed to use value GET", + "status": "PASS" + }, + { + "name": "header X-METHOD-OVERRIDE is allowed to use value GETTRACE", + "status": "PASS" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value CONNECT", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value GET,track ", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value TRACE", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value TRACK", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value \\nconnect", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value \\rtrace", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value \\ttrack", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value trace", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value trace,", + "status": "FAIL" + }, + { + "name": "header X-METHOD-OVERRIDE is forbidden to use value track", + "status": "FAIL" + }, + { + "name": "header x-http-method is allowed to use value \",TRACE\",", + "status": "PASS" + }, + { + "name": "header x-http-method is allowed to use value GET", + "status": "PASS" + }, + { + "name": "header x-http-method is allowed to use value GETTRACE", + "status": "PASS" + }, + { + "name": "header x-http-method is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value CONNECT", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value GET,track ", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value TRACE", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value TRACK", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value \\nconnect", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value \\rtrace", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value \\ttrack", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value trace", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value trace,", + "status": "FAIL" + }, + { + "name": "header x-http-method is forbidden to use value track", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is allowed to use value \",TRACE\",", + "status": "PASS" + }, + { + "name": "header x-http-method-override is allowed to use value GET", + "status": "PASS" + }, + { + "name": "header x-http-method-override is allowed to use value GETTRACE", + "status": "PASS" + }, + { + "name": "header x-http-method-override is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value CONNECT", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value GET,track ", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value TRACE", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value TRACK", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value \\nconnect", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value \\rtrace", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value \\ttrack", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value trace", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value trace,", + "status": "FAIL" + }, + { + "name": "header x-http-method-override is forbidden to use value track", + "status": "FAIL" + }, + { + "name": "header x-method-override is allowed to use value \",TRACE\",", + "status": "PASS" + }, + { + "name": "header x-method-override is allowed to use value GET", + "status": "PASS" + }, + { + "name": "header x-method-override is allowed to use value GETTRACE", + "status": "PASS" + }, + { + "name": "header x-method-override is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value CONNECT", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value GET,track ", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value TRACE", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value TRACK", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value \\nconnect", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value \\rtrace", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value \\ttrack", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value connect", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value trace", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value trace,", + "status": "FAIL" + }, + { + "name": "header x-method-override is forbidden to use value track", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/headers/headers-normalize.any.js", + "tests": [ + { + "name": "Check append method with not normalized values", + "status": "FAIL" + }, + { + "name": "Check set method with not normalized values", + "status": "FAIL" + }, + { + "name": "Create headers with not normalized values", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "ERROR", + "path": "fetch/api/headers/headers-record.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/headers/headers-structure.any.js", + "tests": [ + { + "name": "Headers has append method", + "status": "PASS" + }, + { + "name": "Headers has delete method", + "status": "PASS" + }, + { + "name": "Headers has entries method", + "status": "PASS" + }, + { + "name": "Headers has get method", + "status": "PASS" + }, + { + "name": "Headers has has method", + "status": "PASS" + }, + { + "name": "Headers has keys method", + "status": "PASS" + }, + { + "name": "Headers has set method", + "status": "PASS" + }, + { + "name": "Headers has values method", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/forbidden-method.any.js", + "tests": [ + { + "name": "Request() with a forbidden method CONNECT must throw.", + "status": "FAIL" + }, + { + "name": "Request() with a forbidden method TRACE must throw.", + "status": "FAIL" + }, + { + "name": "Request() with a forbidden method TRACK must throw.", + "status": "FAIL" + }, + { + "name": "Request() with a forbidden method connect must throw.", + "status": "FAIL" + }, + { + "name": "Request() with a forbidden method trace must throw.", + "status": "FAIL" + }, + { + "name": "Request() with a forbidden method track must throw.", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-clone-readable-stream-body.any.js", + "tests": [ + { + "name": "new Request(clone) preserves a ReadableStream body that came from clone()", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-constructor-init-body-override.any.js", + "tests": [ + { + "name": "Check that the body of a new request can be duplicated from an existing Request object", + "status": "FAIL" + }, + { + "name": "Check that the body of a new request can be overridden when created from an existing Request object", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-consume-empty.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + }, + { + "name": "Consume request's body as arrayBuffer", + "status": "FAIL" + }, + { + "name": "Consume request's body as blob", + "status": "FAIL" + }, + { + "name": "Consume request's body as formData with correct multipart type (error case)", + "status": "FAIL" + }, + { + "name": "Consume request's body as formData with correct urlencoded type", + "status": "PASS" + }, + { + "name": "Consume request's body as formData without correct type (error case)", + "status": "FAIL" + }, + { + "name": "Consume request's body as json (error case)", + "status": "PASS" + }, + { + "name": "Consume request's body as text", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-consume.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-disturbed.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + }, + { + "name": "Request's body: initial state", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-error.any.js", + "tests": [ + { + "name": "Bad cache init parameter value", + "status": "FAIL" + }, + { + "name": "Bad credentials init parameter value", + "status": "FAIL" + }, + { + "name": "Bad mode init parameter value", + "status": "FAIL" + }, + { + "name": "Bad redirect init parameter value", + "status": "FAIL" + }, + { + "name": "Bad referrerPolicy init parameter value", + "status": "FAIL" + }, + { + "name": "Input URL has credentials", + "status": "FAIL" + }, + { + "name": "Input URL is not valid", + "status": "FAIL" + }, + { + "name": "Request should get its content-type from init headers if one is provided", + "status": "PASS" + }, + { + "name": "Request should get its content-type from the body if none is provided", + "status": "PASS" + }, + { + "name": "Request should get its content-type from the init request", + "status": "PASS" + }, + { + "name": "Request should not get its content-type from the init request if init headers are provided", + "status": "PASS" + }, + { + "name": "Request with cache mode: only-if-cached and fetch mode cors", + "status": "FAIL" + }, + { + "name": "Request with cache mode: only-if-cached and fetch mode no-cors", + "status": "FAIL" + }, + { + "name": "Request with cache mode: only-if-cached and fetch mode: same-origin", + "status": "PASS" + }, + { + "name": "RequestInit's cache mode is only-if-cached and mode is not same-origin", + "status": "FAIL" + }, + { + "name": "RequestInit's method is forbidden", + "status": "FAIL" + }, + { + "name": "RequestInit's method is invalid", + "status": "FAIL" + }, + { + "name": "RequestInit's mode is navigate", + "status": "FAIL" + }, + { + "name": "RequestInit's mode is no-cors and method is not simple", + "status": "FAIL" + }, + { + "name": "RequestInit's referrer is invalid", + "status": "FAIL" + }, + { + "name": "RequestInit's window is not null", + "status": "FAIL" + }, + { + "name": "Untitled", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-headers.any.js", + "tests": [ + { + "name": "Adding invalid no-cors request header \"Content-Type: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid no-cors request header \"Empty-Value: \"", + "status": "FAIL" + }, + { + "name": "Adding invalid no-cors request header \"Potato: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid no-cors request header \"proxy: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid no-cors request header \"proxya: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid no-cors request header \"sec: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid no-cors request header \"secb: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"ACCEPT-ENCODING: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Accept-Charset: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Accept-Encoding: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Access-Control-Request-Headers: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Access-Control-Request-Method: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Connection: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Content-Length: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Cookie2: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Cookie: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"DNT: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Date: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Expect: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Host: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Keep-Alive: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Origin: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Proxy-: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Referer: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Sec-: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Set-Cookie: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"TE: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Trailer: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Transfer-Encoding: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Upgrade: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"Via: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"accept-charset: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"proxy-a: KO\"", + "status": "FAIL" + }, + { + "name": "Adding invalid request header \"sec-b: KO\"", + "status": "FAIL" + }, + { + "name": "Adding valid no-cors request header \"Accept-Language: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid no-cors request header \"Accept: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid no-cors request header \"CONTENT-type: text/plain;charset=UTF-8\"", + "status": "PASS" + }, + { + "name": "Adding valid no-cors request header \"content-TYPE: text/plain\"", + "status": "PASS" + }, + { + "name": "Adding valid no-cors request header \"content-language: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid no-cors request header \"content-type: application/x-www-form-urlencoded\"", + "status": "PASS" + }, + { + "name": "Adding valid no-cors request header \"content-type: application/x-www-form-urlencoded;charset=UTF-8\"", + "status": "PASS" + }, + { + "name": "Adding valid no-cors request header \"content-type: multipart/form-data\"", + "status": "PASS" + }, + { + "name": "Adding valid no-cors request header \"content-type: multipart/form-data;charset=UTF-8\"", + "status": "PASS" + }, + { + "name": "Adding valid request header \"Content-Type: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid request header \"Potato: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid request header \"Set-Cookie2: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid request header \"User-Agent: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid request header \"proxy: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid request header \"proxya: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid request header \"sec: OK\"", + "status": "PASS" + }, + { + "name": "Adding valid request header \"secb: OK\"", + "status": "PASS" + }, + { + "name": "Check that no-cors request constructor is filtering headers provided as init parameter", + "status": "FAIL" + }, + { + "name": "Check that no-cors request constructor is filtering headers provided as part of request parameter", + "status": "FAIL" + }, + { + "name": "Check that request constructor is filtering headers provided as init parameter", + "status": "FAIL" + }, + { + "name": "Request should get its content-type from init headers if one is provided", + "status": "PASS" + }, + { + "name": "Request should get its content-type from the body if none is provided", + "status": "PASS" + }, + { + "name": "Request should get its content-type from the init request", + "status": "PASS" + }, + { + "name": "Request should not get its content-type from the init request if init headers are provided", + "status": "PASS" + }, + { + "name": "Test that Request.headers has the [SameObject] extended attribute", + "status": "PASS" + }, + { + "name": "Testing empty Request Content-Type header", + "status": "FAIL" + }, + { + "name": "Testing request header creations with various objects", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-init-002.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + }, + { + "name": "Initialize Request with headers values", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-init-contenttype.any.js", + "tests": [ + { + "name": "Can override Content-Type for Request with Blob body (empty type)", + "status": "FAIL" + }, + { + "name": "Can override Content-Type for Request with Blob body (no type set)", + "status": "FAIL" + }, + { + "name": "Can override Content-Type for Request with Blob body (set type)", + "status": "FAIL" + }, + { + "name": "Can override Content-Type for Request with FormData body", + "status": "PASS" + }, + { + "name": "Can override Content-Type for Request with ReadableStream body", + "status": "FAIL" + }, + { + "name": "Can override Content-Type for Request with URLSearchParams body", + "status": "PASS" + }, + { + "name": "Can override Content-Type for Request with buffer source body", + "status": "PASS" + }, + { + "name": "Can override Content-Type for Request with empty body", + "status": "PASS" + }, + { + "name": "Can override Content-Type for Request with string body", + "status": "PASS" + }, + { + "name": "Default Content-Type for Request with Blob body (empty type)", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Request with Blob body (no type set)", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Request with Blob body (set type)", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Request with FormData body", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Request with ReadableStream body", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Request with URLSearchParams body", + "status": "PASS" + }, + { + "name": "Default Content-Type for Request with buffer source body", + "status": "PASS" + }, + { + "name": "Default Content-Type for Request with empty body", + "status": "PASS" + }, + { + "name": "Default Content-Type for Request with string body", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-init-stream.any.js", + "tests": [ + { + "name": "Constructing a Request with a Request on which body.getReader() is called", + "status": "FAIL" + }, + { + "name": "Constructing a Request with a Request on which body.getReader().read() is called", + "status": "FAIL" + }, + { + "name": "Constructing a Request with a Request on which read() and releaseLock() are called", + "status": "FAIL" + }, + { + "name": "Constructing a Request with a stream holds the original object.", + "status": "FAIL" + }, + { + "name": "Constructing a Request with a stream on which getReader() is called", + "status": "FAIL" + }, + { + "name": "Constructing a Request with a stream on which read() and releaseLock() are called", + "status": "FAIL" + }, + { + "name": "Constructing a Request with a stream on which read() is called", + "status": "FAIL" + }, + { + "name": "It is OK to omit .duplex when the body is a Blob.", + "status": "FAIL" + }, + { + "name": "It is OK to omit .duplex when the body is a Uint8Array.", + "status": "PASS" + }, + { + "name": "It is OK to omit .duplex when the body is a string.", + "status": "PASS" + }, + { + "name": "It is OK to omit .duplex when the body is null.", + "status": "PASS" + }, + { + "name": "It is OK to omit duplex when init.body is not given and input.body is given.", + "status": "FAIL" + }, + { + "name": "It is OK to set .duplex = 'half' when the body is a Blob.", + "status": "FAIL" + }, + { + "name": "It is OK to set .duplex = 'half' when the body is a ReadableStream.", + "status": "FAIL" + }, + { + "name": "It is OK to set .duplex = 'half' when the body is a Uint8Array.", + "status": "PASS" + }, + { + "name": "It is OK to set .duplex = 'half' when the body is a string.", + "status": "PASS" + }, + { + "name": "It is OK to set .duplex = 'half' when the body is null.", + "status": "PASS" + }, + { + "name": "It is error to omit .duplex when the body is a ReadableStream.", + "status": "FAIL" + }, + { + "name": "It is error to set .duplex = 'full' when the body is a Blob.", + "status": "FAIL" + }, + { + "name": "It is error to set .duplex = 'full' when the body is a ReadableStream.", + "status": "FAIL" + }, + { + "name": "It is error to set .duplex = 'full' when the body is a Uint8Array.", + "status": "FAIL" + }, + { + "name": "It is error to set .duplex = 'full' when the body is a string.", + "status": "FAIL" + }, + { + "name": "It is error to set .duplex = 'full' when the body is null.", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-keepalive.any.js", + "tests": [ + { + "name": "keepalive flag", + "status": "FAIL" + }, + { + "name": "keepalive flag with stream body", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/request/request-structure.any.js", + "tests": [ + { + "name": "Check bodyUsed attribute", + "status": "FAIL" + }, + { + "name": "Check cache attribute", + "status": "FAIL" + }, + { + "name": "Check credentials attribute", + "status": "FAIL" + }, + { + "name": "Check destination attribute", + "status": "FAIL" + }, + { + "name": "Check duplex attribute", + "status": "FAIL" + }, + { + "name": "Check headers attribute", + "status": "FAIL" + }, + { + "name": "Check integrity attribute", + "status": "FAIL" + }, + { + "name": "Check isHistoryNavigation attribute", + "status": "FAIL" + }, + { + "name": "Check isReloadNavigation attribute", + "status": "FAIL" + }, + { + "name": "Check method attribute", + "status": "FAIL" + }, + { + "name": "Check mode attribute", + "status": "FAIL" + }, + { + "name": "Check redirect attribute", + "status": "FAIL" + }, + { + "name": "Check referrer attribute", + "status": "FAIL" + }, + { + "name": "Check referrerPolicy attribute", + "status": "FAIL" + }, + { + "name": "Check url attribute", + "status": "FAIL" + }, + { + "name": "Request does not expose blocking attribute", + "status": "PASS" + }, + { + "name": "Request does not expose internalpriority attribute", + "status": "PASS" + }, + { + "name": "Request does not expose priority attribute", + "status": "PASS" + }, + { + "name": "Request has arrayBuffer method", + "status": "PASS" + }, + { + "name": "Request has blob method", + "status": "FAIL" + }, + { + "name": "Request has clone method", + "status": "PASS" + }, + { + "name": "Request has formData method", + "status": "PASS" + }, + { + "name": "Request has json method", + "status": "PASS" + }, + { + "name": "Request has text method", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-consume-empty.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + }, + { + "name": "Consume response's body as arrayBuffer", + "status": "FAIL" + }, + { + "name": "Consume response's body as blob", + "status": "FAIL" + }, + { + "name": "Consume response's body as formData with correct multipart type (error case)", + "status": "FAIL" + }, + { + "name": "Consume response's body as formData with correct urlencoded type", + "status": "PASS" + }, + { + "name": "Consume response's body as formData without correct type (error case)", + "status": "FAIL" + }, + { + "name": "Consume response's body as json (error case)", + "status": "PASS" + }, + { + "name": "Consume response's body as text", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-consume-stream.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + }, + { + "name": "Read empty blob response's body as readableStream", + "status": "FAIL" + }, + { + "name": "Read empty text response's body as readableStream", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-error-from-stream.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + }, + { + "name": "ReadableStreamDefaultReader Promise receives ReadableStream pull() Error", + "status": "FAIL" + }, + { + "name": "ReadableStreamDefaultReader Promise receives ReadableStream start() Error", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-error.any.js", + "tests": [ + { + "name": "Throws RangeError when responseInit's status is 0", + "status": "PASS" + }, + { + "name": "Throws RangeError when responseInit's status is 100", + "status": "PASS" + }, + { + "name": "Throws RangeError when responseInit's status is 1000", + "status": "PASS" + }, + { + "name": "Throws RangeError when responseInit's status is 199", + "status": "PASS" + }, + { + "name": "Throws RangeError when responseInit's status is 600", + "status": "PASS" + }, + { + "name": "Throws TypeError when building a response with body and a body status of 204", + "status": "FAIL" + }, + { + "name": "Throws TypeError when building a response with body and a body status of 205", + "status": "FAIL" + }, + { + "name": "Throws TypeError when building a response with body and a body status of 304", + "status": "FAIL" + }, + { + "name": "Throws TypeError when responseInit's statusText is \\n", + "status": "FAIL" + }, + { + "name": "Throws TypeError when responseInit's statusText is Ā", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-from-stream.any.js", + "tests": [ + { + "name": "Constructing a Response with a stream on which getReader() is called", + "status": "FAIL" + }, + { + "name": "Constructing a Response with a stream on which read() and releaseLock() are called", + "status": "FAIL" + }, + { + "name": "Constructing a Response with a stream on which read() is called", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-init-001.any.js", + "tests": [ + { + "name": "Check default value for body attribute", + "status": "FAIL" + }, + { + "name": "Check default value for ok attribute", + "status": "PASS" + }, + { + "name": "Check default value for status attribute", + "status": "PASS" + }, + { + "name": "Check default value for statusText attribute", + "status": "PASS" + }, + { + "name": "Check default value for type attribute", + "status": "PASS" + }, + { + "name": "Check default value for url attribute", + "status": "PASS" + }, + { + "name": "Check status init values and associated getter", + "status": "PASS" + }, + { + "name": "Check statusText init values and associated getter", + "status": "PASS" + }, + { + "name": "Test that Response.headers has the [SameObject] extended attribute", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-init-002.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + }, + { + "name": "Initialize Response with headers values", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-init-contenttype.any.js", + "tests": [ + { + "name": "Can override Content-Type for Response with Blob body (empty type)", + "status": "FAIL" + }, + { + "name": "Can override Content-Type for Response with Blob body (no type set)", + "status": "FAIL" + }, + { + "name": "Can override Content-Type for Response with Blob body (set type)", + "status": "FAIL" + }, + { + "name": "Can override Content-Type for Response with FormData body", + "status": "PASS" + }, + { + "name": "Can override Content-Type for Response with ReadableStream body", + "status": "FAIL" + }, + { + "name": "Can override Content-Type for Response with URLSearchParams body", + "status": "PASS" + }, + { + "name": "Can override Content-Type for Response with buffer source body", + "status": "PASS" + }, + { + "name": "Can override Content-Type for Response with empty body", + "status": "PASS" + }, + { + "name": "Can override Content-Type for Response with string body", + "status": "PASS" + }, + { + "name": "Default Content-Type for Response with Blob body (empty type)", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Response with Blob body (no type set)", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Response with Blob body (set type)", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Response with FormData body", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Response with ReadableStream body", + "status": "FAIL" + }, + { + "name": "Default Content-Type for Response with URLSearchParams body", + "status": "PASS" + }, + { + "name": "Default Content-Type for Response with buffer source body", + "status": "PASS" + }, + { + "name": "Default Content-Type for Response with empty body", + "status": "PASS" + }, + { + "name": "Default Content-Type for Response with string body", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-static-error.any.js", + "tests": [ + { + "name": "Check response returned by static method error()", + "status": "FAIL" + }, + { + "name": "the 'guard' of the Headers instance should be immutable", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-static-json.any.js", + "tests": [ + { + "name": "Check response returned by static json() with init undefined", + "status": "FAIL" + }, + { + "name": "Check response returned by static json() with init {\"headers\":{\"content-type\":\"foo/bar\"}}", + "status": "FAIL" + }, + { + "name": "Check response returned by static json() with init {\"headers\":{\"x-foo\":\"bar\"}}", + "status": "FAIL" + }, + { + "name": "Check response returned by static json() with init {\"headers\":{}}", + "status": "FAIL" + }, + { + "name": "Check response returned by static json() with init {\"status\":400}", + "status": "FAIL" + }, + { + "name": "Check response returned by static json() with init {\"statusText\":\"foo\"}", + "status": "FAIL" + }, + { + "name": "Check response returned by static json() with input U+dead", + "status": "FAIL" + }, + { + "name": "Check response returned by static json() with input U+df06U+d834", + "status": "FAIL" + }, + { + "name": "Check response returned by static json() with input 𝌆", + "status": "FAIL" + }, + { + "name": "Check static json() encodes JSON objects correctly", + "status": "FAIL" + }, + { + "name": "Check static json() propagates JSON serializer errors", + "status": "FAIL" + }, + { + "name": "Check static json() throws when data is circular", + "status": "PASS" + }, + { + "name": "Check static json() throws when data is not encodable", + "status": "PASS" + }, + { + "name": "Throws TypeError when calling static json() with a status of 204", + "status": "PASS" + }, + { + "name": "Throws TypeError when calling static json() with a status of 205", + "status": "PASS" + }, + { + "name": "Throws TypeError when calling static json() with a status of 304", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-static-redirect.any.js", + "tests": [ + { + "name": "Check default redirect response", + "status": "FAIL" + }, + { + "name": "Check error returned when giving invalid status to redirect(), status = 200", + "status": "PASS" + }, + { + "name": "Check error returned when giving invalid status to redirect(), status = 309", + "status": "PASS" + }, + { + "name": "Check error returned when giving invalid status to redirect(), status = 400", + "status": "PASS" + }, + { + "name": "Check error returned when giving invalid status to redirect(), status = 500", + "status": "PASS" + }, + { + "name": "Check error returned when giving invalid url to redirect()", + "status": "FAIL" + }, + { + "name": "Check response returned by static method redirect(), status = 301", + "status": "FAIL" + }, + { + "name": "Check response returned by static method redirect(), status = 302", + "status": "FAIL" + }, + { + "name": "Check response returned by static method redirect(), status = 303", + "status": "FAIL" + }, + { + "name": "Check response returned by static method redirect(), status = 307", + "status": "FAIL" + }, + { + "name": "Check response returned by static method redirect(), status = 308", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-stream-bad-chunk.any.js", + "tests": [ + { + "name": "ReadableStream with non-Uint8Array chunk passed to Response.arrayBuffer() causes TypeError", + "status": "FAIL" + }, + { + "name": "ReadableStream with non-Uint8Array chunk passed to Response.blob() causes TypeError", + "status": "FAIL" + }, + { + "name": "ReadableStream with non-Uint8Array chunk passed to Response.bytes() causes TypeError", + "status": "FAIL" + }, + { + "name": "ReadableStream with non-Uint8Array chunk passed to Response.formData() causes TypeError", + "status": "FAIL" + }, + { + "name": "ReadableStream with non-Uint8Array chunk passed to Response.json() causes TypeError", + "status": "FAIL" + }, + { + "name": "ReadableStream with non-Uint8Array chunk passed to Response.text() causes TypeError", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-stream-disturbed-6.any.js", + "tests": [ + { + "name": "A closed stream on which read() has been called", + "status": "FAIL" + }, + { + "name": "A non-closed stream on which cancel() has been called", + "status": "FAIL" + }, + { + "name": "A non-closed stream on which read() has been called", + "status": "FAIL" + }, + { + "name": "An errored stream on which cancel() has been called", + "status": "FAIL" + }, + { + "name": "An errored stream on which read() has been called", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-stream-disturbed-by-pipe.any.js", + "tests": [ + { + "name": "using pipeThrough on Response body should disturb it synchronously", + "status": "FAIL" + }, + { + "name": "using pipeTo on Response body should disturb it synchronously", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/api/response/response-stream-with-broken-then.any.js", + "tests": [ + { + "name": "Attempt to inject 8.2 via Object.prototype.then.", + "status": "FAIL" + }, + { + "name": "Attempt to inject undefined via Object.prototype.then.", + "status": "FAIL" + }, + { + "name": "Attempt to inject value: undefined via Object.prototype.then.", + "status": "FAIL" + }, + { + "name": "Attempt to inject {done: false, value: bye} via Object.prototype.then.", + "status": "FAIL" + }, + { + "name": "intercepting arraybuffer to body readable stream conversion via Object.prototype.then should not be possible", + "status": "FAIL" + }, + { + "name": "intercepting arraybuffer to text conversion via Object.prototype.then should not be possible", + "status": "PASS" + } + ] + }, + { + "harnessStatus": "OK", + "path": "fetch/content-type/multipart-malformed.any.js", + "tests": [ + { + "name": "Invalid form data should not crash the browser", + "status": "FAIL" + } + ] + } + ], + "manifest": { + "sha256": "aad155ccc1c4d436994b1b60646f44f38b51b21b1f23f6a036cb1299f54ab38e", + "version": 9 + }, + "revision": "6c7127bdd9f2cc6a3668fd9791757843e09d5a9e", + "source": "https://chromium.googlesource.com/external/w3c/web-platform-tests/+/6c7127bdd9f2cc6a3668fd9791757843e09d5a9e", + "summary": { + "BLOCKED": 0, + "FAIL": 321, + "FLAKY": 0, + "NOTRUN": 0, + "PASS": 164, + "PRECONDITION_FAILED": 0, + "TIMEOUT": 0, + "noOpTests": 0, + "total": 485, + "unsupportedFiles": 447 + }, + "suite": "fetch" + }, + "streams": { + "blocked": { + "gatedFiles": 64, + "gatedTests": 978, + "missingGlobals": [ + "CountQueuingStrategy", + "ReadableByteStreamController", + "ReadableStream", + "ReadableStreamDefaultReader", + "TransformStream", + "WritableStream" + ], + "reason": "ReadableStream not implemented" + }, + "files": [ + { + "harnessStatus": "OK", + "path": "streams/readable-streams/async-iterator.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + } + ] + }, + { + "harnessStatus": "OK", + "path": "streams/readable-streams/from.any.js", + "tests": [ + { + "name": "", + "status": "FAIL" + } + ] + } + ], + "manifest": { + "sha256": "aad155ccc1c4d436994b1b60646f44f38b51b21b1f23f6a036cb1299f54ab38e", + "version": 9 + }, + "revision": "6c7127bdd9f2cc6a3668fd9791757843e09d5a9e", + "source": "https://chromium.googlesource.com/external/w3c/web-platform-tests/+/6c7127bdd9f2cc6a3668fd9791757843e09d5a9e", + "summary": { + "BLOCKED": 978, + "FAIL": 2, + "FLAKY": 0, + "NOTRUN": 0, + "PASS": 0, + "PRECONDITION_FAILED": 0, + "TIMEOUT": 0, + "noOpTests": 22, + "total": 980, + "unsupportedFiles": 29 + }, + "suite": "streams" + } +} diff --git a/scripts/web-platform-tests/sync.js b/scripts/web-platform-tests/sync.js new file mode 100644 index 000000000000..3fc5ef21d955 --- /dev/null +++ b/scripts/web-platform-tests/sync.js @@ -0,0 +1,529 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +'use strict'; + +const babel = require('@babel/core'); +const {spawnSync} = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WPT_REVISION = '6c7127bdd9f2cc6a3668fd9791757843e09d5a9e'; +const SUITES = ['fetch', 'streams']; +const UNSUPPORTED_GLOBALS = new Set([ + 'MessageChannel', + 'VideoFrame', + 'fetch', + 'garbageCollect', + 'gc', +]); + +const REPO_ROOT = path.resolve(__dirname, '../..'); +const OUTPUT_DIR = path.join( + REPO_ROOT, + 'packages/react-native/src/private/webapis/__tests__/wpt/generated', +); +const OUTPUT_PATH = path.join(OUTPUT_DIR, 'wpt-fixtures.json'); + +function parseArgs() { + const args = process.argv.slice(2); + let wptRoot; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--wpt-root') { + wptRoot = args[++i]; + } else if (arg.startsWith('--wpt-root=')) { + wptRoot = arg.slice('--wpt-root='.length); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (wptRoot == null) { + throw new Error('Expected --wpt-root=/path/to/web-platform-tests/wpt'); + } + + return path.resolve(wptRoot); +} + +function readBuffer(wptRoot, relativePath) { + const absolutePath = path.join(wptRoot, relativePath); + if (!fs.existsSync(absolutePath)) { + throw new Error(`Missing WPT file: ${relativePath}`); + } + return fs.readFileSync(absolutePath); +} + +function readFile(wptRoot, relativePath) { + return readBuffer(wptRoot, relativePath).toString('utf8'); +} + +function readManifest(wptRoot) { + const manifestPath = path.join(wptRoot, 'MANIFEST.json'); + if (!fs.existsSync(manifestPath)) { + throw new Error( + 'Missing MANIFEST.json. Generate it with WPT before running sync-wpt.', + ); + } + return { + bytes: fs.readFileSync(manifestPath), + value: JSON.parse(fs.readFileSync(manifestPath, 'utf8')), + }; +} + +function verifyRevision(wptRoot) { + const result = spawnSync('git', ['-C', wptRoot, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error( + 'The WPT root must be a Git checkout at the pinned commit.', + ); + } + const revision = result.stdout.trim(); + if (revision !== WPT_REVISION) { + throw new Error( + `Expected WPT ${WPT_REVISION}, but the checkout is at ${revision}.`, + ); + } +} + +function gitBlobHash(bytes) { + return crypto + .createHash('sha1') + .update(`blob ${bytes.length}\0`) + .update(bytes) + .digest('hex'); +} + +function flattenManifestTree(tree, manifestType, suite) { + const entries = []; + const pending = [{prefix: [], value: tree}]; + + while (pending.length > 0) { + const current = pending.pop(); + if (current == null) { + continue; + } + for (const [name, value] of Object.entries(current.value ?? {})) { + const prefix = [...current.prefix, name]; + if (Array.isArray(value)) { + entries.push({ + hash: value[0], + items: value.slice(1), + manifestType, + path: `${suite}/${prefix.join('/')}`, + }); + } else { + pending.push({prefix, value}); + } + } + } + + return entries; +} + +function getManifestEntries(manifest, suite) { + return Object.entries(manifest.items) + .filter(([manifestType]) => manifestType !== 'support') + .flatMap(([manifestType, tree]) => + flattenManifestTree(tree[suite], manifestType, suite), + ) + .sort((a, b) => + a.path < b.path + ? -1 + : a.path > b.path + ? 1 + : a.manifestType.localeCompare(b.manifestType), + ); +} + +function getMetadata(entry, name) { + return [ + ...new Set( + entry.items.flatMap(item => + (item[1]?.script_metadata ?? []) + .filter(metadata => metadata[0] === name) + .map(metadata => metadata[1]), + ), + ), + ]; +} + +function getMetadataScripts(entry) { + return getMetadata(entry, 'script').map(scriptPath => + scriptPath.startsWith('/') + ? scriptPath.slice(1) + : path.posix.normalize( + path.posix.join(path.posix.dirname(entry.path), scriptPath), + ), + ); +} + +function hasDedicatedWorkerVariant(entry) { + return entry.items.some( + item => + typeof item[0] === 'string' && + /\.any\.worker(?:-module)?\.html$/.test(item[0]), + ); +} + +function getManifestUrls(entry) { + return entry.items + .map(item => item[0]) + .filter(url => typeof url === 'string') + .sort(); +} + +function validateManifestHash(wptRoot, entry) { + const bytes = readBuffer(wptRoot, entry.path); + const actualHash = gitBlobHash(bytes); + if (actualHash !== entry.hash) { + throw new Error( + `WPT manifest hash mismatch for ${entry.path}: expected ${entry.hash}, got ${actualHash}.`, + ); + } +} + +function analyzeSource(source) { + const ast = babel.parseSync(source, { + babelrc: false, + configFile: false, + sourceType: 'script', + }); + if (ast == null) { + throw new Error('Babel did not return an AST for a WPT source.'); + } + const functions = new Map(); + const rootCalls = new Set(); + const rootGlobals = new Set(); + + function ensureFunction(name) { + if (!functions.has(name)) { + functions.set(name, {calls: new Set(), globals: new Set()}); + } + return functions.get(name); + } + + function visit(node, owner, parent) { + if (Array.isArray(node)) { + for (const item of node) { + visit(item, owner, parent); + } + return; + } + if (node == null || typeof node !== 'object') { + return; + } + + let childOwner = owner; + if ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + const functionName = + node.id?.name ?? + (parent?.type === 'VariableDeclarator' && + parent.id.type === 'Identifier' + ? parent.id.name + : parent?.type === 'AssignmentExpression' && + parent.left.type === 'Identifier' + ? parent.left.name + : owner); + childOwner = functionName; + if (functionName != null) { + ensureFunction(functionName); + } + } + + if (node.type === 'CallExpression' && node.callee.type === 'Identifier') { + const name = node.callee.name; + (childOwner == null ? rootCalls : ensureFunction(childOwner).calls).add( + name, + ); + if (UNSUPPORTED_GLOBALS.has(name)) { + (childOwner == null + ? rootGlobals + : ensureFunction(childOwner).globals + ).add(name); + } + } + if ( + node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + UNSUPPORTED_GLOBALS.has(node.callee.name) + ) { + (childOwner == null + ? rootGlobals + : ensureFunction(childOwner).globals + ).add(node.callee.name); + } + + for (const [key, value] of Object.entries(node)) { + if (!['end', 'loc', 'start'].includes(key)) { + visit(value, childOwner, node); + } + } + } + + visit(ast, null, null); + + return {functions, rootCalls, rootGlobals}; +} + +function findUnsupportedGlobal(wptRoot, entry, dependencyPaths) { + const analyses = [entry.path, ...dependencyPaths].map(sourcePath => ({ + ...analyzeSource(readFile(wptRoot, sourcePath)), + sourcePath, + })); + const functions = new Map(); + const pendingFunctions = []; + const requirements = []; + + for (const analysis of analyses) { + pendingFunctions.push(...analysis.rootCalls); + for (const name of analysis.rootGlobals) { + requirements.push({name, sourcePath: analysis.sourcePath}); + } + for (const [name, data] of analysis.functions) { + if (!functions.has(name)) { + functions.set(name, []); + } + functions.get(name).push({ + ...data, + sourcePath: analysis.sourcePath, + }); + } + } + + const visitedFunctions = new Set(); + while (pendingFunctions.length > 0) { + const functionName = pendingFunctions.pop(); + if (functionName == null || visitedFunctions.has(functionName)) { + continue; + } + visitedFunctions.add(functionName); + for (const definition of functions.get(functionName) ?? []) { + pendingFunctions.push(...definition.calls); + for (const name of definition.globals) { + requirements.push({ + functionName, + name, + sourcePath: definition.sourcePath, + }); + } + } + } + + return requirements.sort((a, b) => + `${a.name}:${a.sourcePath}:${a.functionName ?? ''}`.localeCompare( + `${b.name}:${b.sourcePath}:${b.functionName ?? ''}`, + ), + )[0]; +} + +function classifyManifestEntry(wptRoot, entry) { + const manifest = { + globals: getMetadata(entry, 'global'), + type: entry.manifestType, + urls: getManifestUrls(entry), + }; + + if (entry.manifestType !== 'testharness') { + return { + manifest, + path: entry.path, + reason: `WPT manifest type ${entry.manifestType} requires a browser-specific runner.`, + }; + } + if (!entry.path.endsWith('.any.js')) { + return { + manifest, + path: entry.path, + reason: + 'The WPT manifest does not classify this source as an .any.js multi-global test.', + }; + } + if (!hasDedicatedWorkerVariant(entry)) { + return { + manifest, + path: entry.path, + reason: + 'The WPT manifest does not generate a dedicated-worker variant for this test.', + }; + } + if (entry.path.includes('.sub.')) { + return { + manifest, + path: entry.path, + reason: + 'The .sub. filename declares WPT server-side substitution, which Fantom does not provide.', + }; + } + + const dependencyPaths = getMetadataScripts(entry); + if (dependencyPaths.includes('resources/idlharness.js')) { + return { + manifest: {...manifest, scripts: dependencyPaths}, + path: entry.path, + reason: + 'META scripts declare the WPT WebIDL harness, which requires browser IDL exposure data.', + }; + } + + const missingDependency = dependencyPaths.find( + dependencyPath => !fs.existsSync(path.join(wptRoot, dependencyPath)), + ); + if (missingDependency != null) { + return { + manifest: {...manifest, scripts: dependencyPaths}, + path: entry.path, + reason: `META script ${missingDependency} is not present in the WPT checkout.`, + }; + } + + const unsupportedGlobal = findUnsupportedGlobal( + wptRoot, + entry, + dependencyPaths, + ); + if (unsupportedGlobal != null) { + const via = + unsupportedGlobal.functionName == null + ? '' + : ` through ${unsupportedGlobal.functionName}()`; + const reasons = { + MessageChannel: + 'requires MessageChannel and transferable MessagePort support', + VideoFrame: 'requires the browser VideoFrame API', + fetch: + "requires a completed network request, but Fantom's StubHttpClient never invokes request callbacks", + garbageCollect: 'requires exposed deterministic garbage collection', + gc: 'requires exposed deterministic garbage collection', + }; + return { + manifest: {...manifest, scripts: dependencyPaths}, + path: entry.path, + reason: `Static analysis found global ${unsupportedGlobal.name}${via} in ${unsupportedGlobal.sourcePath}; this ${reasons[unsupportedGlobal.name]}.`, + }; + } + + return { + dependencyPaths, + manifest, + path: entry.path, + }; +} + +function makeTestFixture(wptRoot, classification) { + return { + dependencies: classification.dependencyPaths.map(dependencyPath => ({ + path: dependencyPath, + source: readFile(wptRoot, dependencyPath), + })), + manifest: classification.manifest, + path: classification.path, + source: readFile(wptRoot, classification.path), + }; +} + +function validateFixtureSources(wptRoot, fixtures) { + const sourceFiles = [ + {path: 'resources/testharness.js', source: fixtures.testharness}, + ...SUITES.flatMap(suite => + fixtures.suites[suite].flatMap(fixture => [ + ...fixture.dependencies, + {path: fixture.path, source: fixture.source}, + ]), + ), + ]; + + for (const sourceFile of sourceFiles) { + if ( + !Buffer.from(sourceFile.source, 'utf8').equals( + readBuffer(wptRoot, sourceFile.path), + ) + ) { + throw new Error(`WPT fixture source mismatch for ${sourceFile.path}.`); + } + } +} + +function main() { + const wptRoot = parseArgs(); + verifyRevision(wptRoot); + const manifest = readManifest(wptRoot); + const classifications = Object.fromEntries( + SUITES.map(suite => [ + suite, + getManifestEntries(manifest.value, suite).map(entry => { + validateManifestHash(wptRoot, entry); + return classifyManifestEntry(wptRoot, entry); + }), + ]), + ); + const fixtures = { + manifest: { + sha256: crypto.createHash('sha256').update(manifest.bytes).digest('hex'), + version: manifest.value.version, + }, + revision: WPT_REVISION, + selection: { + environment: 'dedicatedworker', + manifestTypes: ['testharness'], + sourceFormat: '.any.js', + }, + source: + 'https://chromium.googlesource.com/external/w3c/web-platform-tests/+/' + + WPT_REVISION, + testharness: readFile(wptRoot, 'resources/testharness.js'), + suites: Object.fromEntries( + SUITES.map(suite => [ + suite, + classifications[suite] + .filter(classification => classification.reason == null) + .map(classification => makeTestFixture(wptRoot, classification)), + ]), + ), + unsupported: Object.fromEntries( + SUITES.map(suite => [ + suite, + classifications[suite] + .filter(classification => classification.reason != null) + .map(({manifest: manifestMetadata, path: testPath, reason}) => ({ + manifest: manifestMetadata, + path: testPath, + reason, + })), + ]), + ), + }; + + fs.mkdirSync(OUTPUT_DIR, {recursive: true}); + const output = `${JSON.stringify(fixtures, null, 2)}\n`; + fs.writeFileSync(OUTPUT_PATH, output); + validateFixtureSources(wptRoot, JSON.parse(output)); + + console.log( + `Synced ${fixtures.suites.fetch.length} fetch files and ${fixtures.suites.streams.length} streams files from WPT ${WPT_REVISION}.`, + ); + console.log( + `Recorded ${fixtures.unsupported.fetch.length} unsupported fetch files and ${fixtures.unsupported.streams.length} unsupported streams files from MANIFEST.json.`, + ); +} + +if (require.main === module) { + main(); +} + +module.exports = {WPT_REVISION}; diff --git a/scripts/web-platform-tests/update-baseline.js b/scripts/web-platform-tests/update-baseline.js new file mode 100644 index 000000000000..d5436dae94f3 --- /dev/null +++ b/scripts/web-platform-tests/update-baseline.js @@ -0,0 +1,119 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +'use strict'; + +const {spawnSync} = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const BASELINE_MARKER = '__RN_WPT_BASELINE__'; +const ANSI_COLOR_SEQUENCE = new RegExp( + `${String.fromCharCode(27)}\\[[0-9;]*m`, + 'g', +); +const REPO_ROOT = path.resolve(__dirname, '../..'); +const BASELINE_PATH = path.join( + REPO_ROOT, + 'packages/react-native/src/private/webapis/__tests__/wpt/wpt-baseline.json', +); +const CAPTURE_TEST_PATH = path.join( + REPO_ROOT, + 'packages/react-native/src/private/webapis/__tests__/wpt/WPTBaselineCapture.js', +); +const CAPTURE_TEST_REGEX = `^${CAPTURE_TEST_PATH.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&', +)}$`; + +function parseSuiteBaseline(output, suite) { + const marker = `${BASELINE_MARKER}${suite}:`; + const markerLine = output.split('\n').find(line => line.includes(marker)); + + if (markerLine == null) { + process.stderr.write(output); + throw new Error(`Could not capture the ${suite} WPT baseline.`); + } + + const payload = markerLine + .slice(markerLine.indexOf(marker) + marker.length) + .replace(ANSI_COLOR_SEQUENCE, '') + .trim(); + return JSON.parse(payload); +} + +function captureBaseline() { + const result = spawnSync( + 'yarn', + ['fantom', '--testRegex', CAPTURE_TEST_REGEX, '--runInBand'], + { + cwd: REPO_ROOT, + encoding: 'utf8', + env: {...process.env, FANTOM_PRINT_OUTPUT: '1'}, + maxBuffer: 50 * 1024 * 1024, + }, + ); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + if (result.error != null) { + throw result.error; + } + if (result.status !== 0) { + const diagnostics = output + .split('\n') + .filter(line => !line.includes(BASELINE_MARKER)) + .join('\n') + .trim(); + if (diagnostics !== '') { + process.stderr.write(`${diagnostics}\n`); + } + throw new Error( + `WPT baseline capture exited with status ${String(result.status)}.`, + ); + } + return { + fetch: parseSuiteBaseline(output, 'fetch'), + streams: parseSuiteBaseline(output, 'streams'), + }; +} + +function formatSuiteSummary(suite, baseline) { + const summary = baseline.summary; + const counts = [ + `${summary.PASS} pass`, + `${summary.FLAKY} flaky`, + `${summary.FAIL} fail`, + `${summary.TIMEOUT} timeout`, + `${summary.noOpTests} no-op excluded`, + ].join(', '); + return baseline.blocked == null + ? `Recorded ${suite}: ${counts}.` + : `Recorded ${suite}: BLOCKED, ${baseline.blocked.reason}, ${baseline.blocked.gatedTests} subtests gated; ${counts}.`; +} + +function main() { + const baseline = captureBaseline(); + fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`); + + console.log(formatSuiteSummary('fetch', baseline.fetch)); + console.log(formatSuiteSummary('streams', baseline.streams)); + + const verification = spawnSync('yarn', ['test-wpt'], { + cwd: REPO_ROOT, + stdio: 'inherit', + }); + if (verification.error != null) { + throw verification.error; + } + if (verification.status !== 0) { + process.exitCode = verification.status ?? 1; + } +} + +main();