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();