diff --git a/index.js b/index.js index e69de29..3fd6446 100644 --- a/index.js +++ b/index.js @@ -0,0 +1,33 @@ +'use strict'; + +const { + ByteLengthQueuingStrategy, + CountQueuingStrategy, + ReadableByteStreamController, + ReadableStream, + ReadableStreamBYOBReader, + ReadableStreamBYOBRequest, + ReadableStreamDefaultController, + ReadableStreamDefaultReader, + TransformStream, + TransformStreamDefaultController, + WritableStream, + WritableStreamDefaultController, + WritableStreamDefaultWriter +} = require('web-streams-polyfill/ponyfill/es2018'); + +module.exports = { + ByteLengthQueuingStrategy, + CountQueuingStrategy, + ReadableByteStreamController, + ReadableStream, + ReadableStreamBYOBReader, + ReadableStreamBYOBRequest, + ReadableStreamDefaultController, + ReadableStreamDefaultReader, + TransformStream, + TransformStreamDefaultController, + WritableStream, + WritableStreamDefaultController, + WritableStreamDefaultWriter +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fd8481b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "whatwg-stream", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "web-streams-polyfill": "^3.0.2" + }, + "engines": { + "node": ">= 15" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.0.2.tgz", + "integrity": "sha512-JTNkNbAKoSo8NKiqu2UUaqRFCDWWZaCOsXuJEsToWopikTA0YHKKUf91GNkS/SnD8JixOkJjVsiacNlrFnRECA==", + "engines": { + "node": ">= 8" + } + } + }, + "dependencies": { + "web-streams-polyfill": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.0.2.tgz", + "integrity": "sha512-JTNkNbAKoSo8NKiqu2UUaqRFCDWWZaCOsXuJEsToWopikTA0YHKKUf91GNkS/SnD8JixOkJjVsiacNlrFnRECA==" + } + } +} diff --git a/package.json b/package.json index d10df67..19a4ac3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "WIP support for WHATWG Stream in Node", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --expose-internals --expose_gc test/wpt/test.js" }, "repository": { "type": "git", @@ -15,5 +15,11 @@ "bugs": { "url": "https://github.com/nodejs/whatwg-stream/issues" }, - "homepage": "https://github.com/nodejs/whatwg-stream#readme" + "homepage": "https://github.com/nodejs/whatwg-stream#readme", + "engines": { + "node": ">= 15" + }, + "dependencies": { + "web-streams-polyfill": "^3.0.2" + } } diff --git a/test/common/fixtures.js b/test/common/fixtures.js new file mode 100644 index 0000000..e33ab1d --- /dev/null +++ b/test/common/fixtures.js @@ -0,0 +1,33 @@ +/* eslint-disable node-core/require-common-first, node-core/required-modules */ +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +const fixturesDir = path.join(__dirname, '..', 'fixtures'); + +function fixturesPath(...args) { + return path.join(fixturesDir, ...args); +} + +function readFixtureSync(args, enc) { + if (Array.isArray(args)) + return fs.readFileSync(fixturesPath(...args), enc); + return fs.readFileSync(fixturesPath(args), enc); +} + +function readFixtureKey(name, enc) { + return fs.readFileSync(fixturesPath('keys', name), enc); +} + +function readFixtureKeys(enc, ...names) { + return names.map((name) => readFixtureKey(name, enc)); +} + +module.exports = { + fixturesDir, + path: fixturesPath, + readSync: readFixtureSync, + readKey: readFixtureKey, + readKeys: readFixtureKeys, +}; diff --git a/test/common/index.js b/test/common/index.js new file mode 100644 index 0000000..8e9a0f5 --- /dev/null +++ b/test/common/index.js @@ -0,0 +1,4 @@ +'use strict'; + +exports.hasIntl = true; + diff --git a/test/common/wpt.js b/test/common/wpt.js new file mode 100644 index 0000000..137ea59 --- /dev/null +++ b/test/common/wpt.js @@ -0,0 +1,621 @@ +/* eslint-disable node-core/require-common-first, node-core/required-modules */ +'use strict'; + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const fs = require('fs'); +const fsPromises = fs.promises; +const path = require('path'); +const { inspect } = require('util'); +const { Worker } = require('worker_threads'); + +// https://github.com/web-platform-tests/wpt/blob/master/resources/testharness.js +// TODO: get rid of this half-baked harness in favor of the one +// pulled from WPT +const harnessMock = { + test: (fn, desc) => { + try { + fn(); + } catch (err) { + console.error(`In ${desc}:`); + throw err; + } + }, + assert_equals: assert.strictEqual, + assert_true: (value, message) => assert.strictEqual(value, true, message), + assert_false: (value, message) => assert.strictEqual(value, false, message), + assert_throws: (code, func, desc) => { + assert.throws(func, function(err) { + return typeof err === 'object' && + 'name' in err && + err.name.startsWith(code.name); + }, desc); + }, + assert_array_equals: assert.deepStrictEqual, + assert_unreached(desc) { + assert.fail(`Reached unreachable code: ${desc}`); + } +}; + +class ResourceLoader { + constructor(path) { + this.path = path; + } + + toRealFilePath(from, url) { + // We need to patch this to load the WebIDL parser + url = url.replace( + '/resources/WebIDLParser.js', + '/resources/webidl2/lib/webidl2.js' + ); + const base = path.dirname(from); + return url.startsWith('/') ? + fixtures.path('wpt', url) : + fixtures.path('wpt', base, url); + } + + /** + * Load a resource in test/fixtures/wpt specified with a URL + * @param {string} from the path of the file loading this resource, + * relative to thw WPT folder. + * @param {string} url the url of the resource being loaded. + * @param {boolean} asPromise if true, return the resource in a + * pseudo-Response object. + */ + read(from, url, asFetch = true) { + const file = this.toRealFilePath(from, url); + if (asFetch) { + return fsPromises.readFile(file) + .then((data) => { + return { + ok: true, + json() { return JSON.parse(data.toString()); }, + text() { return data.toString(); } + }; + }); + } + return fs.readFileSync(file, 'utf8'); + } +} + +class StatusRule { + constructor(key, value, pattern = undefined) { + this.key = key; + this.requires = value.requires || []; + this.fail = value.fail; + this.skip = value.skip; + if (pattern) { + this.pattern = this.transformPattern(pattern); + } + // TODO(joyeecheung): implement this + this.scope = value.scope; + this.comment = value.comment; + } + + /** + * Transform a filename pattern into a RegExp + * @param {string} pattern + * @returns {RegExp} + */ + transformPattern(pattern) { + const result = path.normalize(pattern).replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&'); + return new RegExp(result.replace('*', '.*')); + } +} + +class StatusRuleSet { + constructor() { + // We use two sets of rules to speed up matching + this.exactMatch = {}; + this.patternMatch = []; + } + + /** + * @param {object} rules + */ + addRules(rules) { + for (const key of Object.keys(rules)) { + if (key.includes('*')) { + this.patternMatch.push(new StatusRule(key, rules[key], key)); + } else { + const normalizedPath = path.normalize(key); + this.exactMatch[normalizedPath] = new StatusRule(key, rules[key]); + } + } + } + + match(file) { + const result = []; + const exact = this.exactMatch[file]; + if (exact) { + result.push(exact); + } + for (const item of this.patternMatch) { + if (item.pattern.test(file)) { + result.push(item); + } + } + return result; + } +} + +// A specification of WPT test +class WPTTestSpec { + /** + * @param {string} mod name of the WPT module, e.g. + * 'html/webappapis/microtask-queuing' + * @param {string} filename path of the test, relative to mod, e.g. + * 'test.any.js' + * @param {StatusRule[]} rules + */ + constructor(mod, filename, rules) { + this.module = mod; + this.filename = filename; + + this.requires = new Set(); + this.failReasons = []; + this.skipReasons = []; + for (const item of rules) { + if (item.requires.length) { + for (const req of item.requires) { + this.requires.add(req); + } + } + if (item.fail) { + this.failReasons.push(item.fail); + } + if (item.skip) { + this.skipReasons.push(item.skip); + } + } + } + + getRelativePath() { + return path.join(this.module, this.filename); + } + + getAbsolutePath() { + return fixtures.path('wpt', this.getRelativePath()); + } + + getContent() { + return fs.readFileSync(this.getAbsolutePath(), 'utf8'); + } +} + +const kIntlRequirement = { + none: 0, + small: 1, + full: 2, + // TODO(joyeecheung): we may need to deal with --with-intl=system-icu +}; + +class IntlRequirement { + constructor() { + this.currentIntl = kIntlRequirement.none; + if (process.config.variables.v8_enable_i18n_support === 0) { + this.currentIntl = kIntlRequirement.none; + return; + } + // i18n enabled + if (process.config.variables.icu_small) { + this.currentIntl = kIntlRequirement.small; + } else { + this.currentIntl = kIntlRequirement.full; + } + } + + /** + * @param {Set} requires + * @returns {string|false} The config that the build is lacking, or false + */ + isLacking(requires) { + const current = this.currentIntl; + if (requires.has('full-icu') && current !== kIntlRequirement.full) { + return 'full-icu'; + } + if (requires.has('small-icu') && current < kIntlRequirement.small) { + return 'small-icu'; + } + return false; + } +} + +const intlRequirements = new IntlRequirement(); + +class StatusLoader { + /** + * @param {string} path relative path of the WPT subset + */ + constructor(path) { + this.path = path; + this.loaded = false; + this.rules = new StatusRuleSet(); + /** @type {WPTTestSpec[]} */ + this.specs = []; + } + + /** + * Grep for all .*.js file recursively in a directory. + * @param {string} dir + */ + grep(dir) { + let result = []; + const list = fs.readdirSync(dir); + for (const file of list) { + const filepath = path.join(dir, file); + const stat = fs.statSync(filepath); + if (stat.isDirectory()) { + const list = this.grep(filepath); + result = result.concat(list); + } else { + if (!(/\.\w+\.js$/.test(filepath))) { + continue; + } + result.push(filepath); + } + } + return result; + } + + load() { + const dir = path.join(__dirname, '..', 'wpt'); + const statusFile = path.join(dir, 'status', `${this.path}.json`); + const result = JSON.parse(fs.readFileSync(statusFile, 'utf8')); + this.rules.addRules(result); + + const subDir = fixtures.path('wpt', this.path); + const list = this.grep(subDir); + for (const file of list) { + const relativePath = path.relative(subDir, file); + const match = this.rules.match(relativePath); + this.specs.push(new WPTTestSpec(this.path, relativePath, match)); + } + this.loaded = true; + } +} + +const kPass = 'pass'; +const kFail = 'fail'; +const kSkip = 'skip'; +const kTimeout = 'timeout'; +const kIncomplete = 'incomplete'; +const kUncaught = 'uncaught'; +const NODE_UNCAUGHT = 100; + +class WPTRunner { + constructor(path) { + this.path = path; + this.resource = new ResourceLoader(path); + + this.flags = []; + this.initScript = null; + + this.status = new StatusLoader(path); + this.status.load(); + this.specMap = new Map( + this.status.specs.map((item) => [item.filename, item]) + ); + + this.results = {}; + this.inProgress = new Set(); + this.workers = new Map(); + this.unexpectedFailures = []; + } + + /** + * Sets the Node.js flags passed to the worker. + * @param {Array} flags + */ + setFlags(flags) { + this.flags = flags; + } + + /** + * Sets a script to be run in the worker before executing the tests. + * @param {string} script + */ + setInitScript(script) { + this.initScript = script; + } + + // TODO(joyeecheung): work with the upstream to port more tests in .html + // to .js. + runJsTests() { + let queue = []; + + // If the tests are run as `node test/wpt/test-something.js subset.any.js`, + // only `subset.any.js` will be run by the runner. + if (process.argv[2]) { + const filename = process.argv[2]; + if (!this.specMap.has(filename)) { + throw new Error(`${filename} not found!`); + } + queue.push(this.specMap.get(filename)); + } else { + queue = this.buildQueue(); + } + + this.inProgress = new Set(queue.map((spec) => spec.filename)); + + for (const spec of queue) { + const testFileName = spec.filename; + const content = spec.getContent(); + const meta = spec.title = this.getMeta(content); + + const absolutePath = spec.getAbsolutePath(); + const relativePath = spec.getRelativePath(); + const harnessPath = fixtures.path('wpt', 'resources', 'testharness.js'); + const scriptsToRun = []; + // Scripts specified with the `// META: script=` header + if (meta.script) { + for (const script of meta.script) { + scriptsToRun.push({ + filename: this.resource.toRealFilePath(relativePath, script), + code: this.resource.read(relativePath, script, false) + }); + } + } + // The actual test + scriptsToRun.push({ + code: content, + filename: absolutePath + }); + + const workerPath = path.join(__dirname, 'wpt/worker.js'); + const worker = new Worker(workerPath, { + execArgv: this.flags, + workerData: { + testRelativePath: relativePath, + wptRunner: __filename, + wptPath: this.path, + initScript: this.initScript, + harness: { + code: fs.readFileSync(harnessPath, 'utf8'), + filename: harnessPath, + }, + scriptsToRun, + }, + }); + this.workers.set(testFileName, worker); + + worker.on('message', (message) => { + switch (message.type) { + case 'result': + return this.resultCallback(testFileName, message.result); + case 'completion': + return this.completionCallback(testFileName, message.status); + default: + throw new Error(`Unexpected message from worker: ${message.type}`); + } + }); + + worker.on('error', (err) => { + if (!this.inProgress.has(testFileName)) { + // The test is already finished. Ignore errors that occur after it. + // This can happen normally, for example in timers tests. + return; + } + this.fail( + testFileName, + { + status: NODE_UNCAUGHT, + name: 'evaluation in WPTRunner.runJsTests()', + message: err.message, + stack: inspect(err) + }, + kUncaught + ); + this.inProgress.delete(testFileName); + }); + } + + process.on('exit', () => { + const total = this.specMap.size; + if (this.inProgress.size > 0) { + for (const filename of this.inProgress) { + this.fail(filename, { name: 'Unknown' }, kIncomplete); + } + } + inspect.defaultOptions.depth = Infinity; + console.log(this.results); + + const failures = []; + let expectedFailures = 0; + let skipped = 0; + for (const key of Object.keys(this.results)) { + const item = this.results[key]; + if (item.fail && item.fail.unexpected) { + failures.push(key); + } + if (item.fail && item.fail.expected) { + expectedFailures++; + } + if (item.skip) { + skipped++; + } + } + const ran = total - skipped; + const passed = ran - expectedFailures - failures.length; + console.log(`Ran ${ran}/${total} tests, ${skipped} skipped,`, + `${passed} passed, ${expectedFailures} expected failures,`, + `${failures.length} unexpected failures`); + if (failures.length > 0) { + const file = path.join('test', 'wpt', 'status', `${this.path}.json`); + throw new Error( + `Found ${failures.length} unexpected failures. ` + + `Consider updating ${file} for these files:\n${failures.join('\n')}`); + } + }); + } + + getTestTitle(filename) { + const spec = this.specMap.get(filename); + const title = spec.meta && spec.meta.title; + return title ? `${filename} : ${title}` : filename; + } + + // Map WPT test status to strings + getTestStatus(status) { + switch (status) { + case 1: + return kFail; + case 2: + return kTimeout; + case 3: + return kIncomplete; + case NODE_UNCAUGHT: + return kUncaught; + default: + return kPass; + } + } + + /** + * Report the status of each specific test case (there could be multiple + * in one test file). + * + * @param {string} filename + * @param {Test} test The Test object returned by WPT harness + */ + resultCallback(filename, test) { + const status = this.getTestStatus(test.status); + const title = this.getTestTitle(filename); + console.log(`---- ${title} ----`); + if (status !== kPass) { + this.fail(filename, test, status); + } else { + this.succeed(filename, test, status); + } + } + + /** + * Report the status of each WPT test (one per file) + * + * @param {string} filename + * @param {object} harnessStatus - The status object returned by WPT harness. + */ + completionCallback(filename, harnessStatus) { + // Treat it like a test case failure + if (harnessStatus.status === 2) { + const title = this.getTestTitle(filename); + console.log(`---- ${title} ----`); + this.resultCallback(filename, { status: 2, name: 'Unknown' }); + } + this.inProgress.delete(filename); + // Always force termination of the worker. Some tests allocate resources + // that would otherwise keep it alive. + this.workers.get(filename).terminate(); + } + + addTestResult(filename, item) { + let result = this.results[filename]; + if (!result) { + result = this.results[filename] = {}; + } + if (item.status === kSkip) { + // { filename: { skip: 'reason' } } + result[kSkip] = item.reason; + } else { + // { filename: { fail: { expected: [ ... ], + // unexpected: [ ... ] } }} + if (!result[item.status]) { + result[item.status] = {}; + } + const key = item.expected ? 'expected' : 'unexpected'; + if (!result[item.status][key]) { + result[item.status][key] = []; + } + if (result[item.status][key].indexOf(item.reason) === -1) { + result[item.status][key].push(item.reason); + } + } + } + + succeed(filename, test, status) { + console.log(`[${status.toUpperCase()}] ${test.name}`); + } + + fail(filename, test, status) { + const spec = this.specMap.get(filename); + const expected = !!(spec.failReasons.length); + if (expected) { + console.log(`[EXPECTED_FAILURE][${status.toUpperCase()}] ${test.name}`); + console.log(spec.failReasons.join('; ')); + } else { + console.log(`[UNEXPECTED_FAILURE][${status.toUpperCase()}] ${test.name}`); + } + if (status === kFail || status === kUncaught) { + console.log(test.message); + console.log(test.stack); + } + const command = `${process.execPath} ${process.execArgv}` + + ` ${require.main.filename} ${filename}`; + console.log(`Command: ${command}\n`); + this.addTestResult(filename, { + expected, + status: kFail, + reason: test.message || status + }); + } + + skip(filename, reasons) { + const title = this.getTestTitle(filename); + console.log(`---- ${title} ----`); + const joinedReasons = reasons.join('; '); + console.log(`[SKIPPED] ${joinedReasons}`); + this.addTestResult(filename, { + status: kSkip, + reason: joinedReasons + }); + } + + getMeta(code) { + const matches = code.match(/\/\/ META: .+/g); + if (!matches) { + return {}; + } + const result = {}; + for (const match of matches) { + const parts = match.match(/\/\/ META: ([^=]+?)=(.+)/); + const key = parts[1]; + const value = parts[2]; + if (key === 'script') { + if (result[key]) { + result[key].push(value); + } else { + result[key] = [value]; + } + } else { + result[key] = value; + } + } + return result; + } + + buildQueue() { + const queue = []; + for (const spec of this.specMap.values()) { + const filename = spec.filename; + if (spec.skipReasons.length > 0) { + this.skip(filename, spec.skipReasons); + continue; + } + + const lackingIntl = intlRequirements.isLacking(spec.requires); + if (lackingIntl) { + this.skip(filename, [ `requires ${lackingIntl}` ]); + continue; + } + + queue.push(spec); + } + return queue; + } +} + +module.exports = { + harness: harnessMock, + ResourceLoader, + WPTRunner +}; diff --git a/test/common/wpt/worker.js b/test/common/wpt/worker.js new file mode 100644 index 0000000..afaee07 --- /dev/null +++ b/test/common/wpt/worker.js @@ -0,0 +1,55 @@ +/* eslint-disable node-core/required-modules,node-core/require-common-first */ + +'use strict'; + +const { runInThisContext } = require('vm'); +const { parentPort, workerData } = require('worker_threads'); + +const { ResourceLoader } = require(workerData.wptRunner); +const resource = new ResourceLoader(workerData.wptPath); + +global.self = global; +global.GLOBAL = { + isWindow() { return false; } +}; +global.require = require; + +// This is a mock, because at the moment fetch is not implemented +// in Node.js, but some tests and harness depend on this to pull +// resources. +global.fetch = function fetch(file) { + return resource.read(workerData.testRelativePath, file, true); +}; + +if (workerData.initScript) { + runInThisContext(workerData.initScript); +} + +runInThisContext(workerData.harness.code, { + filename: workerData.harness.filename +}); + +// eslint-disable-next-line no-undef +add_result_callback((result) => { + parentPort.postMessage({ + type: 'result', + result: { + status: result.status, + name: result.name, + message: result.message, + stack: result.stack, + }, + }); +}); + +// eslint-disable-next-line no-undef +add_completion_callback((_, status) => { + parentPort.postMessage({ + type: 'completion', + status, + }); +}); + +for (const scriptToRun of workerData.scriptsToRun) { + runInThisContext(scriptToRun.code, { filename: scriptToRun.filename }); +} diff --git a/test/fixtures/wpt/LICENSE.md b/test/fixtures/wpt/LICENSE.md new file mode 100644 index 0000000..ad4858c --- /dev/null +++ b/test/fixtures/wpt/LICENSE.md @@ -0,0 +1,11 @@ +# The 3-Clause BSD License + +Copyright 2019 web-platform-tests contributors + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/test/fixtures/wpt/README.md b/test/fixtures/wpt/README.md new file mode 100644 index 0000000..611f797 --- /dev/null +++ b/test/fixtures/wpt/README.md @@ -0,0 +1,19 @@ +# Web Platform Test Fixtures + +The files in this folder, including this document, +are generated by [`git node wpt`][]. + +This folder contains a subset of the [Web Platform Tests][] for the +implementation of Web APIs in Node.js. + +See [test/wpt](../../wpt/README.md) for information on how these tests are run. + +Last update: + +- common: https://github.com/web-platform-tests/wpt/tree/d758aedae2/common +- interfaces: https://github.com/web-platform-tests/wpt/tree/4c7a0a8381/interfaces +- resources: https://github.com/web-platform-tests/wpt/tree/4235130a74/resources +- streams: https://github.com/web-platform-tests/wpt/tree/7e94a4bcb5/streams + +[Web Platform Tests]: https://github.com/web-platform-tests/wpt +[`git node wpt`]: https://github.com/nodejs/node-core-utils/blob/master/docs/git-node.md#git-node-wpt diff --git a/test/fixtures/wpt/common/META.yml b/test/fixtures/wpt/common/META.yml new file mode 100644 index 0000000..ca4d2e5 --- /dev/null +++ b/test/fixtures/wpt/common/META.yml @@ -0,0 +1,3 @@ +suggested_reviewers: + - zqzhang + - deniak diff --git a/test/fixtures/wpt/common/PrefixedLocalStorage.js b/test/fixtures/wpt/common/PrefixedLocalStorage.js new file mode 100644 index 0000000..2f4e7b6 --- /dev/null +++ b/test/fixtures/wpt/common/PrefixedLocalStorage.js @@ -0,0 +1,116 @@ +/** + * Supports pseudo-"namespacing" localStorage for a given test + * by generating and using a unique prefix for keys. Why trounce on other + * tests' localStorage items when you can keep it "separated"? + * + * PrefixedLocalStorageTest: Instantiate in testharness.js tests to generate + * a new unique-ish prefix + * PrefixedLocalStorageResource: Instantiate in supporting test resource + * files to use/share a prefix generated by a test. + */ +var PrefixedLocalStorage = function () { + this.prefix = ''; // Prefix for localStorage keys + this.param = 'prefixedLocalStorage'; // Param to use in querystrings +}; + +PrefixedLocalStorage.prototype.clear = function () { + if (this.prefix === '') { return; } + Object.keys(localStorage).forEach(sKey => { + if (sKey.indexOf(this.prefix) === 0) { + localStorage.removeItem(sKey); + } + }); +}; + +/** + * Append/replace prefix parameter and value in URI querystring + * Use to generate URLs to resource files that will share the prefix. + */ +PrefixedLocalStorage.prototype.url = function (uri) { + function updateUrlParameter (uri, key, value) { + var i = uri.indexOf('#'); + var hash = (i === -1) ? '' : uri.substr(i); + uri = (i === -1) ? uri : uri.substr(0, i); + var re = new RegExp(`([?&])${key}=.*?(&|$)`, 'i'); + var separator = uri.indexOf('?') !== -1 ? '&' : '?'; + uri = (uri.match(re)) ? uri.replace(re, `$1${key}=${value}$2`) : + `${uri}${separator}${key}=${value}`; + return uri + hash; + } + return updateUrlParameter(uri, this.param, this.prefix); +}; + +PrefixedLocalStorage.prototype.prefixedKey = function (baseKey) { + return `${this.prefix}${baseKey}`; +}; + +PrefixedLocalStorage.prototype.setItem = function (baseKey, value) { + localStorage.setItem(this.prefixedKey(baseKey), value); +}; + +/** + * Listen for `storage` events pertaining to a particular key, + * prefixed with this object's prefix. Ignore when value is being set to null + * (i.e. removeItem). + */ +PrefixedLocalStorage.prototype.onSet = function (baseKey, fn) { + window.addEventListener('storage', e => { + var match = this.prefixedKey(baseKey); + if (e.newValue !== null && e.key.indexOf(match) === 0) { + fn.call(this, e); + } + }); +}; + +/***************************************************************************** + * Use in a testharnessjs test to generate a new key prefix. + * async_test(t => { + * var prefixedStorage = new PrefixedLocalStorageTest(); + * t.add_cleanup(() => prefixedStorage.cleanup()); + * /... + * }); + */ +var PrefixedLocalStorageTest = function () { + PrefixedLocalStorage.call(this); + this.prefix = `${document.location.pathname}-${Math.random()}-${Date.now()}-`; +}; +PrefixedLocalStorageTest.prototype = Object.create(PrefixedLocalStorage.prototype); +PrefixedLocalStorageTest.prototype.constructor = PrefixedLocalStorageTest; + +/** + * Use in a cleanup function to clear out prefixed entries in localStorage + */ +PrefixedLocalStorageTest.prototype.cleanup = function () { + this.setItem('closeAll', 'true'); + this.clear(); +}; + +/***************************************************************************** + * Use in test resource files to share a prefix generated by a + * PrefixedLocalStorageTest. Will look in URL querystring for prefix. + * Setting `close_on_cleanup` opt truthy will make this script's window listen + * for storage `closeAll` event from controlling test and close itself. + * + * var PrefixedLocalStorageResource({ close_on_cleanup: true }); + */ +var PrefixedLocalStorageResource = function (options) { + PrefixedLocalStorage.call(this); + this.options = Object.assign({}, { + close_on_cleanup: false + }, options || {}); + // Check URL querystring for prefix to use + var regex = new RegExp(`[?&]${this.param}(=([^&#]*)|&|#|$)`), + results = regex.exec(document.location.href); + if (results && results[2]) { + this.prefix = results[2]; + } + // Optionally have this window close itself when the PrefixedLocalStorageTest + // sets a `closeAll` item. + if (this.options.close_on_cleanup) { + this.onSet('closeAll', () => { + window.close(); + }); + } +}; +PrefixedLocalStorageResource.prototype = Object.create(PrefixedLocalStorage.prototype); +PrefixedLocalStorageResource.prototype.constructor = PrefixedLocalStorageResource; diff --git a/test/fixtures/wpt/common/PrefixedLocalStorage.js.headers b/test/fixtures/wpt/common/PrefixedLocalStorage.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/PrefixedLocalStorage.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/PrefixedPostMessage.js b/test/fixtures/wpt/common/PrefixedPostMessage.js new file mode 100644 index 0000000..674b528 --- /dev/null +++ b/test/fixtures/wpt/common/PrefixedPostMessage.js @@ -0,0 +1,100 @@ +/** + * Supports pseudo-"namespacing" for window-posted messages for a given test + * by generating and using a unique prefix that gets wrapped into message + * objects. This makes it more feasible to have multiple tests that use + * `window.postMessage` in a single test file. Basically, make it possible + * for the each test to listen for only the messages that are pertinent to it. + * + * 'Prefix' not an elegant term to use here but this models itself after + * PrefixedLocalStorage. + * + * PrefixedMessageTest: Instantiate in testharness.js tests to generate + * a new unique-ish prefix that can be used by other test support files + * PrefixedMessageResource: Instantiate in supporting test resource + * files to use/share a prefix generated by a test. + */ +var PrefixedMessage = function () { + this.prefix = ''; + this.param = 'prefixedMessage'; // Param to use in querystrings +}; + +/** + * Generate a URL that adds/replaces param with this object's prefix + * Use to link to test support files that make use of + * PrefixedMessageResource. + */ +PrefixedMessage.prototype.url = function (uri) { + function updateUrlParameter (uri, key, value) { + var i = uri.indexOf('#'); + var hash = (i === -1) ? '' : uri.substr(i); + uri = (i === -1) ? uri : uri.substr(0, i); + var re = new RegExp(`([?&])${key}=.*?(&|$)`, 'i'); + var separator = uri.indexOf('?') !== -1 ? '&' : '?'; + uri = (uri.match(re)) ? uri.replace(re, `$1${key}=${value}$2`) : + `${uri}${separator}${key}=${value}`; + return uri + hash; + } + return updateUrlParameter(uri, this.param, this.prefix); +}; + +/** + * Add an eventListener on `message` but only invoke the given callback + * for messages whose object contains this object's prefix. Remove the + * event listener once the anticipated message has been received. + */ +PrefixedMessage.prototype.onMessage = function (fn) { + window.addEventListener('message', e => { + if (typeof e.data === 'object' && e.data.hasOwnProperty('prefix')) { + if (e.data.prefix === this.prefix) { + // Only invoke callback when `data` is an object containing + // a `prefix` key with this object's prefix value + // Note fn is invoked with "unwrapped" data first, then the event `e` + // (which contains the full, wrapped e.data should it be needed) + fn.call(this, e.data.data, e); + window.removeEventListener('message', fn); + } + } + }); +}; + +/** + * Instantiate in a test file (e.g. during `setup`) to create a unique-ish + * prefix that can be shared by support files + */ +var PrefixedMessageTest = function () { + PrefixedMessage.call(this); + this.prefix = `${document.location.pathname}-${Math.random()}-${Date.now()}-`; +}; +PrefixedMessageTest.prototype = Object.create(PrefixedMessage.prototype); +PrefixedMessageTest.prototype.constructor = PrefixedMessageTest; + +/** + * Instantiate in a test support script to use a "prefix" generated by a + * PrefixedMessageTest in a controlling test file. It will look for + * the prefix in a URL param (see also PrefixedMessage#url) + */ +var PrefixedMessageResource = function () { + PrefixedMessage.call(this); + // Check URL querystring for prefix to use + var regex = new RegExp(`[?&]${this.param}(=([^&#]*)|&|#|$)`), + results = regex.exec(document.location.href); + if (results && results[2]) { + this.prefix = results[2]; + } +}; +PrefixedMessageResource.prototype = Object.create(PrefixedMessage.prototype); +PrefixedMessageResource.prototype.constructor = PrefixedMessageResource; + +/** + * This is how a test resource document can "send info" to its + * opener context. It will whatever message is being sent (`data`) in + * an object that injects the prefix. + */ +PrefixedMessageResource.prototype.postToOpener = function (data) { + if (window.opener) { + window.opener.postMessage({ + prefix: this.prefix, + data: data + }, '*'); + } +}; diff --git a/test/fixtures/wpt/common/PrefixedPostMessage.js.headers b/test/fixtures/wpt/common/PrefixedPostMessage.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/PrefixedPostMessage.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/README.md b/test/fixtures/wpt/common/README.md new file mode 100644 index 0000000..9aef19c --- /dev/null +++ b/test/fixtures/wpt/common/README.md @@ -0,0 +1,10 @@ +The files in this directory are non-infrastructure support files that can be used by tests. + +* `blank.html` - An empty HTML document. +* `domain-setter.sub.html` - An HTML document that sets `document.domain`. +* `dummy.xhtml` - An XHTML document. +* `dummy.xml` - An XML document. +* `text-plain.txt` - A text/plain document. +* `*.js` - Utility scripts. These are documented in the source. +* `*.py` - wptserve [Python Handlers](https://web-platform-tests.org/writing-tests/python-handlers/). These are documented in the source. +* `security-features` - Documented in `security-features/README.md`. diff --git a/test/fixtures/wpt/common/arrays.js b/test/fixtures/wpt/common/arrays.js new file mode 100644 index 0000000..2b31bb4 --- /dev/null +++ b/test/fixtures/wpt/common/arrays.js @@ -0,0 +1,31 @@ +/** + * Callback for checking equality of c and d. + * + * @callback equalityCallback + * @param {*} c + * @param {*} d + * @returns {boolean} + */ + +/** + * Returns true if the given arrays are equal. Optionally can pass an equality function. + * @param {Array} a + * @param {Array} b + * @param {equalityCallback} callbackFunction - defaults to `c === d` + * @returns {boolean} + */ +export function areArraysEqual(a, b, equalityFunction = (c, d) => { return c === d; }) { + try { + if (a.length !== b.length) + return false; + + for (let i = 0; i < a.length; i++) { + if (!equalityFunction(a[i], b[i])) + return false; + } + } catch (ex) { + return false; + } + + return true; +} diff --git a/test/fixtures/wpt/common/blank.html b/test/fixtures/wpt/common/blank.html new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/common/domain-setter.sub.html b/test/fixtures/wpt/common/domain-setter.sub.html new file mode 100644 index 0000000..ad3b9f8 --- /dev/null +++ b/test/fixtures/wpt/common/domain-setter.sub.html @@ -0,0 +1,8 @@ + + +A page that will likely be same-origin-domain but not same-origin + + diff --git a/test/fixtures/wpt/common/dummy.xhtml b/test/fixtures/wpt/common/dummy.xhtml new file mode 100644 index 0000000..dba6945 --- /dev/null +++ b/test/fixtures/wpt/common/dummy.xhtml @@ -0,0 +1,2 @@ + +Dummy XHTML document diff --git a/test/fixtures/wpt/common/dummy.xml b/test/fixtures/wpt/common/dummy.xml new file mode 100644 index 0000000..4a60c30 --- /dev/null +++ b/test/fixtures/wpt/common/dummy.xml @@ -0,0 +1 @@ +Dummy XML document diff --git a/test/fixtures/wpt/common/echo.py b/test/fixtures/wpt/common/echo.py new file mode 100644 index 0000000..911b54a --- /dev/null +++ b/test/fixtures/wpt/common/echo.py @@ -0,0 +1,6 @@ +def main(request, response): + # Without X-XSS-Protection to disable non-standard XSS protection the functionality this + # resource offers is useless + response.headers.set(b"X-XSS-Protection", b"0") + response.headers.set(b"Content-Type", b"text/html") + response.content = request.GET.first(b"content") diff --git a/test/fixtures/wpt/common/get-host-info.sub.js b/test/fixtures/wpt/common/get-host-info.sub.js new file mode 100644 index 0000000..8f37d55 --- /dev/null +++ b/test/fixtures/wpt/common/get-host-info.sub.js @@ -0,0 +1,58 @@ +/** + * Host information for cross-origin tests. + * @returns {Object} with properties for different host information. + */ +function get_host_info() { + + var HTTP_PORT = '{{ports[http][0]}}'; + var HTTP_PORT2 = '{{ports[http][1]}}'; + var HTTPS_PORT = '{{ports[https][0]}}'; + var HTTPS_PORT2 = '{{ports[https][1]}}'; + var PROTOCOL = self.location.protocol; + var IS_HTTPS = (PROTOCOL == "https:"); + var HTTP_PORT_ELIDED = HTTP_PORT == "80" ? "" : (":" + HTTP_PORT); + var HTTP_PORT2_ELIDED = HTTP_PORT2 == "80" ? "" : (":" + HTTP_PORT2); + var HTTPS_PORT_ELIDED = HTTPS_PORT == "443" ? "" : (":" + HTTPS_PORT); + var PORT_ELIDED = IS_HTTPS ? HTTPS_PORT_ELIDED : HTTP_PORT_ELIDED; + var ORIGINAL_HOST = '{{host}}'; + var REMOTE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('www1.' + ORIGINAL_HOST); + var OTHER_HOST = '{{domains[www2]}}'; + var NOTSAMESITE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('{{hosts[alt][]}}'); + + return { + HTTP_PORT: HTTP_PORT, + HTTP_PORT2: HTTP_PORT2, + HTTPS_PORT: HTTPS_PORT, + HTTPS_PORT2: HTTPS_PORT2, + ORIGINAL_HOST: ORIGINAL_HOST, + REMOTE_HOST: REMOTE_HOST, + + ORIGIN: PROTOCOL + "//" + ORIGINAL_HOST + PORT_ELIDED, + HTTP_ORIGIN: 'http://' + ORIGINAL_HOST + HTTP_PORT_ELIDED, + HTTPS_ORIGIN: 'https://' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTPS_ORIGIN_WITH_CREDS: 'https://foo:bar@' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTP_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + ORIGINAL_HOST + HTTP_PORT2_ELIDED, + REMOTE_ORIGIN: PROTOCOL + "//" + REMOTE_HOST + PORT_ELIDED, + HTTP_REMOTE_ORIGIN: 'http://' + REMOTE_HOST + HTTP_PORT_ELIDED, + HTTP_NOTSAMESITE_ORIGIN: 'http://' + NOTSAMESITE_HOST + HTTP_PORT_ELIDED, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + REMOTE_HOST + HTTP_PORT2_ELIDED, + HTTPS_REMOTE_ORIGIN: 'https://' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_REMOTE_ORIGIN_WITH_CREDS: 'https://foo:bar@' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_NOTSAMESITE_ORIGIN: 'https://' + NOTSAMESITE_HOST + HTTPS_PORT_ELIDED, + UNAUTHENTICATED_ORIGIN: 'http://' + OTHER_HOST + HTTP_PORT_ELIDED, + AUTHENTICATED_ORIGIN: 'https://' + OTHER_HOST + HTTPS_PORT_ELIDED + }; +} + +/** + * When a default port is used, location.port returns the empty string. + * This function attempts to provide an exact port, assuming we are running under wptserve. + * @param {*} loc - can be Location///URL, but assumes http/https only. + * @returns {string} The port number. + */ +function get_port(loc) { + if (loc.port) { + return loc.port; + } + return loc.protocol === 'https:' ? '443' : '80'; +} diff --git a/test/fixtures/wpt/common/get-host-info.sub.js.headers b/test/fixtures/wpt/common/get-host-info.sub.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/get-host-info.sub.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/media.js b/test/fixtures/wpt/common/media.js new file mode 100644 index 0000000..e9b1e6b --- /dev/null +++ b/test/fixtures/wpt/common/media.js @@ -0,0 +1,55 @@ +/** + * Returns the URL of a supported video source based on the user agent + * @param {string} base - media URL without file extension + * @returns {string} + */ +function getVideoURI(base) +{ + var extension = '.mp4'; + + var videotag = document.createElement("video"); + + if ( videotag.canPlayType && + videotag.canPlayType('video/ogg; codecs="theora, vorbis"') ) + { + extension = '.ogv'; + } + + return base + extension; +} + +/** + * Returns the URL of a supported audio source based on the user agent + * @param {string} base - media URL without file extension + * @returns {string} + */ +function getAudioURI(base) +{ + var extension = '.mp3'; + + var audiotag = document.createElement("audio"); + + if ( audiotag.canPlayType && + audiotag.canPlayType('audio/ogg') ) + { + extension = '.oga'; + } + + return base + extension; +} + +/** + * Returns the MIME type for a media URL based on the file extension. + * @param {string} url + * @returns {string} + */ +function getMediaContentType(url) { + var extension = new URL(url, location).pathname.split(".").pop(); + var map = { + "mp4": "video/mp4", + "ogv": "video/ogg", + "mp3": "audio/mp3", + "oga": "audio/ogg", + }; + return map[extension]; +} diff --git a/test/fixtures/wpt/common/media.js.headers b/test/fixtures/wpt/common/media.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/media.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/object-association.js b/test/fixtures/wpt/common/object-association.js new file mode 100644 index 0000000..458aae6 --- /dev/null +++ b/test/fixtures/wpt/common/object-association.js @@ -0,0 +1,68 @@ +"use strict"; + +// For now this only has per-Window tests, but we could expand it to also test per-Document + +/** + * Run tests for window[propertyName] after discarding the browsing context, navigating, etc. + * @param {string} propertyName + */ +window.testIsPerWindow = propertyName => { + test(t => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const frame = iframe.contentWindow; + + const before = frame[propertyName]; + assert_true(before !== undefined && before !== null, `window.${propertyName} must be implemented`); + + iframe.remove(); + + const after = frame[propertyName]; + assert_equals(after, before, `window.${propertyName} should not change after iframe.remove()`); + }, `Discarding the browsing context must not change window.${propertyName}`); + + async_test(t => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const frame = iframe.contentWindow; + + const before = frame[propertyName]; + assert_true(before !== undefined && before !== null, `window.${propertyName} must be implemented`); + + // Note: cannot use step_func_done for this because it might be called twice, per the below comment. + iframe.onload = t.step_func(() => { + if (frame.location.href === "about:blank") { + // Browsers are not reliable on whether about:blank fires the load event; see + // https://github.com/whatwg/html/issues/490 + return; + } + + const after = frame[propertyName]; + assert_equals(after, before); + t.done(); + }); + + iframe.src = "/common/blank.html"; + }, `Navigating from the initial about:blank must not replace window.${propertyName}`); + + // Per spec, document.open() should not change any of the Window state. + async_test(t => { + const iframe = document.createElement("iframe"); + + iframe.onload = t.step_func_done(() => { + const frame = iframe.contentWindow; + const before = frame[propertyName]; + assert_true(before !== undefined && before !== null, `window.${propertyName} must be implemented`); + + frame.document.open(); + + const after = frame[propertyName]; + assert_equals(after, before); + + frame.document.close(); + }); + + iframe.src = "/common/blank.html"; + document.body.appendChild(iframe); + }, `document.open() must replace window.${propertyName}`); +}; diff --git a/test/fixtures/wpt/common/object-association.js.headers b/test/fixtures/wpt/common/object-association.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/object-association.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/performance-timeline-utils.js b/test/fixtures/wpt/common/performance-timeline-utils.js new file mode 100644 index 0000000..b20241c --- /dev/null +++ b/test/fixtures/wpt/common/performance-timeline-utils.js @@ -0,0 +1,56 @@ +/* +author: W3C http://www.w3.org/ +help: http://www.w3.org/TR/navigation-timing/#sec-window.performance-attribute +*/ +var performanceNamespace = window.performance; +var namespace_check = false; +function wp_test(func, msg, properties) +{ + // only run the namespace check once + if (!namespace_check) + { + namespace_check = true; + + if (performanceNamespace === undefined || performanceNamespace == null) + { + // show a single error that window.performance is undefined + // The window.performance attribute provides a hosting area for performance related attributes. + test(function() { assert_true(performanceNamespace !== undefined && performanceNamespace != null, "window.performance is defined and not null"); }, "window.performance is defined and not null."); + } + } + + test(func, msg, properties); +} + +function test_true(value, msg, properties) +{ + wp_test(function () { assert_true(value, msg); }, msg, properties); +} + +function test_equals(value, equals, msg, properties) +{ + wp_test(function () { assert_equals(value, equals, msg); }, msg, properties); +} + +// assert for every entry in `expectedEntries`, there is a matching entry _somewhere_ in `actualEntries` +function test_entries(actualEntries, expectedEntries) { + test_equals(actualEntries.length, expectedEntries.length) + expectedEntries.forEach(function (expectedEntry) { + var foundEntry = actualEntries.find(function (actualEntry) { + return typeof Object.keys(expectedEntry).find(function (key) { + return actualEntry[key] !== expectedEntry[key] + }) === 'undefined' + }) + test_true(!!foundEntry, `Entry ${JSON.stringify(expectedEntry)} could not be found.`) + if (foundEntry) { + assert_object_equals(foundEntry.toJSON(), expectedEntry) + } + }) +} + +function delayedLoadListener(callback) { + window.addEventListener('load', function() { + // TODO(cvazac) Remove this setTimeout when spec enforces sync entries. + step_timeout(callback, 0) + }) +} diff --git a/test/fixtures/wpt/common/performance-timeline-utils.js.headers b/test/fixtures/wpt/common/performance-timeline-utils.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/performance-timeline-utils.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/redirect-opt-in.py b/test/fixtures/wpt/common/redirect-opt-in.py new file mode 100644 index 0000000..b5e674a --- /dev/null +++ b/test/fixtures/wpt/common/redirect-opt-in.py @@ -0,0 +1,20 @@ +def main(request, response): + """Simple handler that causes redirection. + + The request should typically have two query parameters: + status - The status to use for the redirection. Defaults to 302. + location - The resource to redirect to. + """ + status = 302 + if b"status" in request.GET: + try: + status = int(request.GET.first(b"status")) + except ValueError: + pass + + response.status = status + + location = request.GET.first(b"location") + + response.headers.set(b"Location", location) + response.headers.set(b"Timing-Allow-Origin", b"*") diff --git a/test/fixtures/wpt/common/redirect.py b/test/fixtures/wpt/common/redirect.py new file mode 100644 index 0000000..f2fd1eb --- /dev/null +++ b/test/fixtures/wpt/common/redirect.py @@ -0,0 +1,19 @@ +def main(request, response): + """Simple handler that causes redirection. + + The request should typically have two query parameters: + status - The status to use for the redirection. Defaults to 302. + location - The resource to redirect to. + """ + status = 302 + if b"status" in request.GET: + try: + status = int(request.GET.first(b"status")) + except ValueError: + pass + + response.status = status + + location = request.GET.first(b"location") + + response.headers.set(b"Location", location) diff --git a/test/fixtures/wpt/common/reftest-wait.js b/test/fixtures/wpt/common/reftest-wait.js new file mode 100644 index 0000000..0a30a19 --- /dev/null +++ b/test/fixtures/wpt/common/reftest-wait.js @@ -0,0 +1,20 @@ +/** + * Remove the `reftest-wait` class on the document element. + * The reftest runner will wait with taking a screenshot while + * this class is present. + * + * See https://web-platform-tests.org/writing-tests/reftests.html#controlling-when-comparison-occurs + */ +function takeScreenshot() { + document.documentElement.classList.remove("reftest-wait"); +} + +/** + * Call `takeScreenshot()` after a delay of at least |timeout| milliseconds. + * @param {number} timeout - milliseconds + */ +function takeScreenshotDelayed(timeout) { + setTimeout(function() { + takeScreenshot(); + }, timeout); +} diff --git a/test/fixtures/wpt/common/reftest-wait.js.headers b/test/fixtures/wpt/common/reftest-wait.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/reftest-wait.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/rendering-utils.js b/test/fixtures/wpt/common/rendering-utils.js new file mode 100644 index 0000000..46283bd --- /dev/null +++ b/test/fixtures/wpt/common/rendering-utils.js @@ -0,0 +1,19 @@ +"use strict"; + +/** + * Waits until we have at least one frame rendered, regardless of the engine. + * + * @returns {Promise} + */ +function waitForAtLeastOneFrame() { + return new Promise(resolve => { + // Different web engines work slightly different on this area but waiting + // for two requestAnimationFrames() to happen, one after another, should be + // sufficient to ensure at least one frame has been generated anywhere. + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + resolve(); + }); + }); + }); +} diff --git a/test/fixtures/wpt/common/sab.js b/test/fixtures/wpt/common/sab.js new file mode 100644 index 0000000..d40dd7c --- /dev/null +++ b/test/fixtures/wpt/common/sab.js @@ -0,0 +1,16 @@ +const createBuffer = (() => { + // See https://github.com/whatwg/html/issues/5380 for why not `new SharedArrayBuffer()` + const sabConstructor = new WebAssembly.Memory({ shared:true, initial:0, maximum:0 }).buffer.constructor; + return (type, length) => { + if (type === "ArrayBuffer") { + return new ArrayBuffer(length); + } else if (type === "SharedArrayBuffer") { + if (sabConstructor.name !== "SharedArrayBuffer") { + throw new Error("WebAssembly.Memory does not support shared:true"); + } + return new sabConstructor(length); + } else { + throw new Error("type has to be ArrayBuffer or SharedArrayBuffer"); + } + } +})(); diff --git a/test/fixtures/wpt/common/security-features/README.md b/test/fixtures/wpt/common/security-features/README.md new file mode 100644 index 0000000..f957541 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/README.md @@ -0,0 +1,460 @@ +This directory contains the common infrastructure for the following tests (also referred below as projects). + +- referrer-policy/ +- mixed-content/ +- upgrade-insecure-requests/ + +Subdirectories: + +- `resources`: + Serves JavaScript test helpers. +- `subresource`: + Serves subresources, with support for redirects, stash, etc. + The subresource paths are managed by `subresourceMap` and + fetched in `requestVia*()` functions in `resources/common.js`. +- `scope`: + Serves nested contexts, such as iframe documents or workers. + Used from `invokeFrom*()` functions in `resources/common.js`. +- `tools`: + Scripts that generate test HTML files. Not used while running tests. +- `/referrer-policy/generic/subresource-test`: + Sanity checking tests for subresource invocation + (This is still placed outside common/) + +# Test generator + +The test generator ([common/security-features/tools/generate.py](tools/generate.py)) generates test HTML files from templates and a seed (`spec.src.json`) that defines all the test scenarios. + +The project (i.e. a WPT subdirectory, for example `referrer-policy/`) that uses the generator should define per-project data and invoke the common generator logic in `common/security-features/tools`. + +This is the overview of the project structure: + +``` +common/security-features/ +└── tools/ - the common test generator logic + ├── spec.src.json + └── template/ - the test files templates +project-directory/ (e.g. referrer-policy/) +├── spec.src.json +├── generic/ +│ ├── test-case.sub.js - Per-project test helper +│ ├── sanity-checker.js (Used by debug target only) +│ └── spec_json.js (Used by debug target only) +└── gen/ - generated tests +``` + +## Generating the tests + +Note: When the repository already contains generated tests, [remove all generated tests](#removing-all-generated-tests) first. + +```bash +# Install json5 module if needed. +pip install --user json5 + +# Generate the test files under gen/ (HTMLs and .headers files). +path/to/common/security-features/tools/generate.py --spec path/to/project-directory/ + +# Add all generated tests to the repo. +git add path/to/project-directory/gen/ && git commit -m "Add generated tests" +``` + +This will parse the spec JSON5 files and determine which tests to generate (or skip) while using templates. + +- The default spec JSON5: `common/security-features/tools/spec.src.json`. + - Describes common configurations, such as subresource types, source context types, etc. +- The per-project spec JSON5: `project-directory/spec.src.json`. + - Describes project-specific configurations, particularly those related to test generation patterns (`specification`), policy deliveries (e.g. `delivery_type`, `delivery_value`) and `expectation`. + +For how these two spec JSON5 files are merged, see [Sub projects](#sub-projects) section. + +Note: `spec.src.json` is transitioning to JSON5 [#21710](https://github.com/web-platform-tests/wpt/issues/21710). + +During the generation, the spec is validated by ```common/security-features/tools/spec_validator.py```. This is specially important when you're making changes to `spec.src.json`. Make sure it's a valid JSON (no comments or trailing commas). The validator reports specific errors (missing keys etc.), if any. + +### Removing all generated tests + +Simply remove all files under `project-directory/gen/`. + +```bash +rm -r path/to/project-directory/gen/ +``` + +### Options for generating tests + +Note: this section is currently obsolete. Only the release template is working. + +The generator script has two targets: ```release``` and ```debug```. + +* Using **release** for the target will produce tests using a template for optimizing size and performance. The release template is intended for the official web-platform-tests and possibly other test suites. No sanity checking is done in release mode. Use this option whenever you're checking into web-platform-tests. + +* When generating for ```debug```, the produced tests will contain more verbosity and sanity checks. Use this target to identify problems with the test suites when making changes locally. Make sure you don't check in tests generated with the debug target. + +Note that **release** is the default target when invoking ```generate.py```. + + +## Sub projects + +Projects can be nested, for example to reuse a single `spec.src.json` across similar but slightly different sets of generated tests. +The directory structure would look like: + +``` +project-directory/ (e.g. referrer-policy/) +├── spec.src.json - Parent project's spec JSON +├── generic/ +│ └── test-case.sub.js - Parent project's test helper +├── gen/ - parent project's generated tests +└── sub-project-directory/ (e.g. 4K) + ├── spec.src.json - Child project's spec JSON + ├── generic/ + │ └── test-case.sub.js - Child project's test helper + └── gen/ - child project's generated tests +``` + +`generate.py --spec project-directory/sub-project-directory` generates test files under `project-directory/sub-project-directory/gen`, based on `project-directory/spec.src.json` and `project-directory/sub-project-directory/spec.src.json`. + +- The child project's `spec.src.json` is merged into parent project's `spec.src.json`. + - Two spec JSON objects are merged recursively. + - If a same key exists in both objects, the child's value overwrites the parent's value. + - If both (child's and parent's) values are arrays, then the child's value is concatenated to the parent's value. + - For debugging, `generate.py` dumps the merged spec JSON object as `generic/debug-output.spec.src.json`. +- The child project's generated tests include both of the parent and child project's `test-case.sub.js`: + ```html + + + + ``` + + +## Updating the tests + +The main test logic lives in ```project-directory/generic/test-case.sub.js``` with helper functions defined in ```/common/security-features/resources/common.js``` so you should probably start there. + +For updating the test suites you will most likely do **a subset** of the following: + +* Add a new subresource type: + + * Add a new sub-resource python script to `/common/security-features/subresource/`. + * Add a sanity check test for a sub-resource to `referrer-policy/generic/subresource-test/`. + * Add a new entry to `subresourceMap` in `/common/security-features/resources/common.js`. + * Add a new entry to `valid_subresource_names` in `/common/security-features/tools/spec_validator.py`. + * Add a new entry to `subresource_schema` in `spec.src.json`. + * Update `source_context_schema` to specify in which source context the subresource can be used. + +* Add a new subresource redirection type + + * TODO: to be documented. Example: [https://github.com/web-platform-tests/wpt/pull/18939](https://github.com/web-platform-tests/wpt/pull/18939) + +* Add a new subresource origin type + + * TODO: to be documented. Example: [https://github.com/web-platform-tests/wpt/pull/18940](https://github.com/web-platform-tests/wpt/pull/18940) + +* Add a new source context (e.g. "module sharedworker global scope") + + * TODO: to be documented. Example: [https://github.com/web-platform-tests/wpt/pull/18904](https://github.com/web-platform-tests/wpt/pull/18904) + +* Add a new source context list (e.g. "subresource request from a dedicated worker in a ` + invoker: invokeFromIframe, + }, + "iframe": { // + invoker: invokeFromIframe, + }, + "iframe-blank": { // + invoker: invokeFromIframe, + }, + "worker-classic": { + // Classic dedicated worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "worker", false, {}), + }, + "worker-classic-data": { + // Classic dedicated worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "worker", true, {}), + }, + "worker-module": { + // Module dedicated worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "worker", false, {type: 'module'}), + }, + "worker-module-data": { + // Module dedicated worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "worker", true, {type: 'module'}), + }, + "sharedworker-classic": { + // Classic shared worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "sharedworker", false, {}), + }, + "sharedworker-classic-data": { + // Classic shared worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "sharedworker", true, {}), + }, + "sharedworker-module": { + // Module shared worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "sharedworker", false, {type: 'module'}), + }, + "sharedworker-module-data": { + // Module shared worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "sharedworker", true, {type: 'module'}), + }, + }; + + return sourceContextMap[sourceContextList[0].sourceContextType].invoker( + subresource, sourceContextList); +} + +// Quick hack to expose invokeRequest when common.sub.js is loaded either +// as a classic or module script. +self.invokeRequest = invokeRequest; + +/** + invokeFrom*() functions are helper functions with the same parameters + and return values as invokeRequest(), that are tied to specific types + of top-most environment settings objects. + For example, invokeFromIframe() is the helper function for the cases where + sourceContextList[0] is an iframe. +*/ + +/** + @param {string} workerType + "worker" (for dedicated worker) or "sharedworker". + @param {boolean} isDataUrl + true if the worker script is loaded from data: URL. + Otherwise, the script is loaded from same-origin. + @param {object} workerOptions + The `options` argument for Worker constructor. + + Other parameters and return values are the same as those of invokeRequest(). +*/ +function invokeFromWorker(workerType, isDataUrl, workerOptions, + subresource, sourceContextList) { + const currentSourceContext = sourceContextList[0]; + let workerUrl = + "/common/security-features/scope/worker.py?policyDeliveries=" + + encodeURIComponent(JSON.stringify( + currentSourceContext.policyDeliveries || [])); + if (workerOptions.type === 'module') { + workerUrl += "&type=module"; + } + + let promise; + if (isDataUrl) { + promise = fetch(workerUrl) + .then(r => r.text()) + .then(source => { + return 'data:text/javascript;base64,' + btoa(source); + }); + } else { + promise = Promise.resolve(workerUrl); + } + + return promise + .then(url => { + if (workerType === "worker") { + const worker = new Worker(url, workerOptions); + worker.postMessage({subresource: subresource, + sourceContextList: sourceContextList.slice(1)}); + return bindEvents2(worker, "message", worker, "error", window, "error"); + } else if (workerType === "sharedworker") { + const worker = new SharedWorker(url, workerOptions); + worker.port.start(); + worker.port.postMessage({subresource: subresource, + sourceContextList: sourceContextList.slice(1)}); + return bindEvents2(worker.port, "message", worker, "error", window, "error"); + } else { + throw new Error('Invalid worker type: ' + workerType); + } + }) + .then(event => { + if (event.data.error) + return Promise.reject(event.data.error); + return event.data; + }); +} + +function invokeFromIframe(subresource, sourceContextList) { + const currentSourceContext = sourceContextList[0]; + const frameUrl = + "/common/security-features/scope/document.py?policyDeliveries=" + + encodeURIComponent(JSON.stringify( + currentSourceContext.policyDeliveries || [])); + + let iframe; + let promise; + if (currentSourceContext.sourceContextType === 'srcdoc') { + promise = fetch(frameUrl) + .then(r => r.text()) + .then(srcdoc => { + iframe = createElement( + "iframe", {srcdoc: srcdoc}, document.body, true); + return iframe.eventPromise; + }); + } else if (currentSourceContext.sourceContextType === 'iframe') { + iframe = createElement("iframe", {src: frameUrl}, document.body, true); + promise = iframe.eventPromise; + } else if (currentSourceContext.sourceContextType === 'iframe-blank') { + let frameContent; + promise = fetch(frameUrl) + .then(r => r.text()) + .then(t => { + frameContent = t; + iframe = createElement("iframe", {}, document.body, true); + return iframe.eventPromise; + }) + .then(() => { + // Reinitialize `iframe.eventPromise` with a new promise + // that catches the load event for the document.write() below. + bindEvents(iframe); + + iframe.contentDocument.write(frameContent); + iframe.contentDocument.close(); + return iframe.eventPromise; + }); + } + + return promise + .then(() => { + const promise = bindEvents2( + window, "message", iframe, "error", window, "error"); + iframe.contentWindow.postMessage( + {subresource: subresource, + sourceContextList: sourceContextList.slice(1)}, + "*"); + return promise; + }) + .then(event => { + if (event.data.error) + return Promise.reject(event.data.error); + return event.data; + }); +} + +// SanityChecker does nothing in release mode. See sanity-checker.js for debug +// mode. +function SanityChecker() {} +SanityChecker.prototype.checkScenario = function() {}; +SanityChecker.prototype.setFailTimeout = function(test, timeout) {}; +SanityChecker.prototype.checkSubresourceResult = function() {}; diff --git a/test/fixtures/wpt/common/security-features/resources/common.sub.js.headers b/test/fixtures/wpt/common/security-features/resources/common.sub.js.headers new file mode 100644 index 0000000..cb762ef --- /dev/null +++ b/test/fixtures/wpt/common/security-features/resources/common.sub.js.headers @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/test/fixtures/wpt/common/security-features/scope/document.py b/test/fixtures/wpt/common/security-features/scope/document.py new file mode 100644 index 0000000..9a9f045 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/document.py @@ -0,0 +1,36 @@ +import os, sys, json + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +import importlib +util = importlib.import_module("common.security-features.scope.util") + +def main(request, response): + policyDeliveries = json.loads(request.GET.first(b"policyDeliveries", b"[]")) + maybe_additional_headers = {} + meta = u'' + error = u'' + for delivery in policyDeliveries: + if delivery[u'deliveryType'] == u'meta': + if delivery[u'key'] == u'referrerPolicy': + meta += u'' % delivery[u'value'] + else: + error = u'invalid delivery key' + elif delivery[u'deliveryType'] == u'http-rp': + if delivery[u'key'] == u'referrerPolicy': + maybe_additional_headers[b'Referrer-Policy'] = isomorphic_encode(delivery[u'value']) + else: + error = u'invalid delivery key' + else: + error = u'invalid deliveryType' + + handler = lambda: util.get_template(u"document.html.template") % ({ + u"meta": meta, + u"error": error + }) + util.respond( + request, + response, + payload_generator=handler, + content_type=b"text/html", + maybe_additional_headers=maybe_additional_headers) diff --git a/test/fixtures/wpt/common/security-features/scope/template/document.html.template b/test/fixtures/wpt/common/security-features/scope/template/document.html.template new file mode 100644 index 0000000..37e29f8 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/template/document.html.template @@ -0,0 +1,30 @@ + + + + %(meta)s + + + + diff --git a/test/fixtures/wpt/common/security-features/scope/template/worker.js.template b/test/fixtures/wpt/common/security-features/scope/template/worker.js.template new file mode 100644 index 0000000..7a2a6e0 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/template/worker.js.template @@ -0,0 +1,29 @@ +%(import)s + +if ('DedicatedWorkerGlobalScope' in self && + self instanceof DedicatedWorkerGlobalScope) { + self.onmessage = event => onMessageFromParent(event, self); +} else if ('SharedWorkerGlobalScope' in self && + self instanceof SharedWorkerGlobalScope) { + onconnect = event => { + const port = event.ports[0]; + port.onmessage = event => onMessageFromParent(event, port); + }; +} + +// Receive a message from the parent and start the test. +function onMessageFromParent(event, port) { + const configurationError = "%(error)s"; + if (configurationError.length > 0) { + port.postMessage({error: configurationError}); + return; + } + + invokeRequest(event.data.subresource, + event.data.sourceContextList) + .then(result => port.postMessage(result)) + .catch(e => { + const message = (e.error && e.error.stack) || e.message || "Error"; + port.postMessage({error: message}); + }); +} diff --git a/test/fixtures/wpt/common/security-features/scope/util.py b/test/fixtures/wpt/common/security-features/scope/util.py new file mode 100644 index 0000000..da5aacf --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/util.py @@ -0,0 +1,43 @@ +import os + +from wptserve.utils import isomorphic_decode + +def get_template(template_basename): + script_directory = os.path.dirname(os.path.abspath(isomorphic_decode(__file__))) + template_directory = os.path.abspath( + os.path.join(script_directory, u"template")) + template_filename = os.path.join(template_directory, template_basename) + + with open(template_filename, "r") as f: + return f.read() + + +def __noop(request, response): + return u"" + + +def respond(request, + response, + status_code=200, + content_type=b"text/html", + payload_generator=__noop, + cache_control=b"no-cache; must-revalidate", + access_control_allow_origin=b"*", + maybe_additional_headers=None): + response.add_required_headers = False + response.writer.write_status(status_code) + + if access_control_allow_origin != None: + response.writer.write_header(b"access-control-allow-origin", + access_control_allow_origin) + response.writer.write_header(b"content-type", content_type) + response.writer.write_header(b"cache-control", cache_control) + + additional_headers = maybe_additional_headers or {} + for header, value in additional_headers.items(): + response.writer.write_header(header, value) + + response.writer.end_headers() + + payload = payload_generator() + response.writer.write(payload) diff --git a/test/fixtures/wpt/common/security-features/scope/worker.py b/test/fixtures/wpt/common/security-features/scope/worker.py new file mode 100644 index 0000000..6b321e7 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/worker.py @@ -0,0 +1,44 @@ +import os, sys, json + +from wptserve.utils import isomorphic_decode, isomorphic_encode +import importlib +util = importlib.import_module("common.security-features.scope.util") + +def main(request, response): + policyDeliveries = json.loads(request.GET.first(b'policyDeliveries', b'[]')) + worker_type = request.GET.first(b'type', b'classic') + commonjs_url = u'%s://%s:%s/common/security-features/resources/common.sub.js' % ( + request.url_parts.scheme, request.url_parts.hostname, + request.url_parts.port) + if worker_type == b'classic': + import_line = u'importScripts("%s");' % commonjs_url + else: + import_line = u'import "%s";' % commonjs_url + + maybe_additional_headers = {} + error = u'' + for delivery in policyDeliveries: + if delivery[u'deliveryType'] == u'meta': + error = u' cannot be used in WorkerGlobalScope' + elif delivery[u'deliveryType'] == u'http-rp': + if delivery[u'key'] == u'referrerPolicy': + maybe_additional_headers[b'Referrer-Policy'] = isomorphic_encode(delivery[u'value']) + elif delivery[u'key'] == u'mixedContent' and delivery[u'value'] == u'opt-in': + maybe_additional_headers[b'Content-Security-Policy'] = b'block-all-mixed-content' + elif delivery[u'key'] == u'upgradeInsecureRequests' and delivery[u'value'] == u'upgrade': + maybe_additional_headers[b'Content-Security-Policy'] = b'upgrade-insecure-requests' + else: + error = u'invalid delivery key for http-rp: %s' % delivery[u'key'] + else: + error = u'invalid deliveryType: %s' % delivery[u'deliveryType'] + + handler = lambda: util.get_template(u'worker.js.template') % ({ + u'import': import_line, + u'error': error + }) + util.respond( + request, + response, + payload_generator=handler, + content_type=b'text/javascript', + maybe_additional_headers=maybe_additional_headers) diff --git a/test/fixtures/wpt/common/security-features/subresource/__init__.py b/test/fixtures/wpt/common/security-features/subresource/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/common/security-features/subresource/audio.py b/test/fixtures/wpt/common/security-features/subresource/audio.py new file mode 100644 index 0000000..f16a0f7 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/audio.py @@ -0,0 +1,18 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + file = os.path.join(request.doc_root, u"webaudio", u"resources", + u"sin_440Hz_-6dBFS_1s.wav") + return open(file, "rb").read() + + +def main(request, response): + handler = lambda data: generate_payload(request, data) + subresource.respond(request, + response, + payload_generator = handler, + access_control_allow_origin = b"*", + content_type = b"audio/wav") diff --git a/test/fixtures/wpt/common/security-features/subresource/document.py b/test/fixtures/wpt/common/security-features/subresource/document.py new file mode 100644 index 0000000..52b684a --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/document.py @@ -0,0 +1,12 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"document.html.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload) diff --git a/test/fixtures/wpt/common/security-features/subresource/empty.py b/test/fixtures/wpt/common/security-features/subresource/empty.py new file mode 100644 index 0000000..312e12c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/empty.py @@ -0,0 +1,14 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return u'' + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + access_control_allow_origin = b"*", + content_type = b"text/plain") diff --git a/test/fixtures/wpt/common/security-features/subresource/font.py b/test/fixtures/wpt/common/security-features/subresource/font.py new file mode 100644 index 0000000..6e33dbb --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/font.py @@ -0,0 +1,81 @@ +import os, sys, base64 +import six + +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + + +def decodebytes(s): + if six.PY3: + return base64.decodebytes(six.ensure_binary(s)) + return base64.decodestring(s) + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + if b"id" in request.GET: + request.server.stash.put(request.GET[b"id"], data) + # Simple base64 encoded .tff font + return decodebytes(b"AAEAAAANAIAAAwBQRkZUTU6u6MkAAAXcAAAAHE9TLzJWYW" + b"QKAAABWAAAAFZjbWFwAA8D7wAAAcAAAAFCY3Z0IAAhAnkA" + b"AAMEAAAABGdhc3D//wADAAAF1AAAAAhnbHlmCC6aTwAAAx" + b"QAAACMaGVhZO8ooBcAAADcAAAANmhoZWEIkAV9AAABFAAA" + b"ACRobXR4EZQAhQAAAbAAAAAQbG9jYQBwAFQAAAMIAAAACm" + b"1heHAASQA9AAABOAAAACBuYW1lehAVOgAAA6AAAAIHcG9z" + b"dP+uADUAAAWoAAAAKgABAAAAAQAAMhPyuV8PPPUACwPoAA" + b"AAAMU4Lm0AAAAAxTgubQAh/5wFeAK8AAAACAACAAAAAAAA" + b"AAEAAAK8/5wAWgXcAAAAAAV4AAEAAAAAAAAAAAAAAAAAAA" + b"AEAAEAAAAEAAwAAwAAAAAAAgAAAAEAAQAAAEAALgAAAAAA" + b"AQXcAfQABQAAAooCvAAAAIwCigK8AAAB4AAxAQIAAAIABg" + b"kAAAAAAAAAAAABAAAAAAAAAAAAAAAAUGZFZABAAEEAQQMg" + b"/zgAWgK8AGQAAAABAAAAAAAABdwAIQAAAAAF3AAABdwAZA" + b"AAAAMAAAADAAAAHAABAAAAAAA8AAMAAQAAABwABAAgAAAA" + b"BAAEAAEAAABB//8AAABB////wgABAAAAAAAAAQYAAAEAAA" + b"AAAAAAAQIAAAACAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAhAnkAAAAqACoAKgBGAAAAAgAhAA" + b"ABKgKaAAMABwAusQEALzyyBwQA7TKxBgXcPLIDAgDtMgCx" + b"AwAvPLIFBADtMrIHBgH8PLIBAgDtMjMRIREnMxEjIQEJ6M" + b"fHApr9ZiECWAAAAwBk/5wFeAK8AAMABwALAAABNSEVATUh" + b"FQE1IRUB9AH0/UQDhPu0BRQB9MjI/tTIyP7UyMgAAAAAAA" + b"4ArgABAAAAAAAAACYATgABAAAAAAABAAUAgQABAAAAAAAC" + b"AAYAlQABAAAAAAADACEA4AABAAAAAAAEAAUBDgABAAAAAA" + b"AFABABNgABAAAAAAAGAAUBUwADAAEECQAAAEwAAAADAAEE" + b"CQABAAoAdQADAAEECQACAAwAhwADAAEECQADAEIAnAADAA" + b"EECQAEAAoBAgADAAEECQAFACABFAADAAEECQAGAAoBRwBD" + b"AG8AcAB5AHIAaQBnAGgAdAAgACgAYwApACAAMgAwADAAOA" + b"AgAE0AbwB6AGkAbABsAGEAIABDAG8AcgBwAG8AcgBhAHQA" + b"aQBvAG4AAENvcHlyaWdodCAoYykgMjAwOCBNb3ppbGxhIE" + b"NvcnBvcmF0aW9uAABNAGEAcgBrAEEAAE1hcmtBAABNAGUA" + b"ZABpAHUAbQAATWVkaXVtAABGAG8AbgB0AEYAbwByAGcAZQ" + b"AgADIALgAwACAAOgAgAE0AYQByAGsAQQAgADoAIAA1AC0A" + b"MQAxAC0AMgAwADAAOAAARm9udEZvcmdlIDIuMCA6IE1hcm" + b"tBIDogNS0xMS0yMDA4AABNAGEAcgBrAEEAAE1hcmtBAABW" + b"AGUAcgBzAGkAbwBuACAAMAAwADEALgAwADAAMAAgAABWZX" + b"JzaW9uIDAwMS4wMDAgAABNAGEAcgBrAEEAAE1hcmtBAAAA" + b"AgAAAAAAAP+DADIAAAABAAAAAAAAAAAAAAAAAAAAAAAEAA" + b"AAAQACACQAAAAAAAH//wACAAAAAQAAAADEPovuAAAAAMU4" + b"Lm0AAAAAxTgubQ==") + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + handler = lambda data: generate_payload(request, data) + content_type = b'application/x-font-truetype' + + if b"report-headers" in request.GET: + handler = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + subresource.respond(request, + response, + payload_generator = handler, + content_type = content_type, + access_control_allow_origin = b"*") diff --git a/test/fixtures/wpt/common/security-features/subresource/image.py b/test/fixtures/wpt/common/security-features/subresource/image.py new file mode 100644 index 0000000..6991224 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/image.py @@ -0,0 +1,116 @@ +import os, sys, array, math + +from six import BytesIO + +from wptserve.utils import isomorphic_decode + +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +class Image: + """This class partially implements the interface of the PIL.Image.Image. + One day in the future WPT might support the PIL module or another imaging + library, so this hacky BMP implementation will no longer be required. + """ + def __init__(self, width, height): + self.width = width + self.height = height + self.img = bytearray([0 for i in range(3 * width * height)]) + + @staticmethod + def new(mode, size, color=0): + return Image(size[0], size[1]) + + def _int_to_bytes(self, number): + packed_bytes = [0, 0, 0, 0] + for i in range(4): + packed_bytes[i] = number & 0xFF + number >>= 8 + + return packed_bytes + + def putdata(self, color_data): + for y in range(self.height): + for x in range(self.width): + i = x + y * self.width + if i > len(color_data) - 1: + return + + self.img[i * 3: i * 3 + 3] = color_data[i][::-1] + + def save(self, f, type): + assert type == "BMP" + # 54 bytes of preambule + image color data. + filesize = 54 + 3 * self.width * self.height + # 14 bytes of header. + bmpfileheader = bytearray([ord('B'), ord('M')] + self._int_to_bytes(filesize) + + [0, 0, 0, 0, 54, 0, 0, 0]) + # 40 bytes of info. + bmpinfoheader = bytearray([40, 0, 0, 0] + + self._int_to_bytes(self.width) + + self._int_to_bytes(self.height) + + [1, 0, 24] + (25 * [0])) + + padlength = (4 - (self.width * 3) % 4) % 4 + bmppad = bytearray([0, 0, 0]) + padding = bmppad[0 : padlength] + + f.write(bmpfileheader) + f.write(bmpinfoheader) + + for i in range(self.height): + offset = self.width * (self.height - i - 1) * 3 + f.write(self.img[offset : offset + 3 * self.width]) + f.write(padding) + +def encode_string_as_bmp_image(string_data): + data_bytes = array.array("B", string_data.encode("utf-8")) + + num_bytes = len(data_bytes) + + # Encode data bytes to color data (RGB), one bit per channel. + # This is to avoid errors due to different color spaces used in decoding. + color_data = [] + for byte in data_bytes: + p = [int(x) * 255 for x in '{0:08b}'.format(byte)] + color_data.append((p[0], p[1], p[2])) + color_data.append((p[3], p[4], p[5])) + color_data.append((p[6], p[7], 0)) + + # Render image. + num_pixels = len(color_data) + sqrt = int(math.ceil(math.sqrt(num_pixels))) + img = Image.new("RGB", (sqrt, sqrt), "black") + img.putdata(color_data) + + # Flush image to string. + f = BytesIO() + img.save(f, "BMP") + f.seek(0) + + return f.read() + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + if b"id" in request.GET: + request.server.stash.put(request.GET[b"id"], data) + data = encode_string_as_bmp_image(data) + return data + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + handler = lambda data: generate_payload(request, data) + content_type = b'image/bmp' + + if b"report-headers" in request.GET: + handler = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + subresource.respond(request, + response, + payload_generator = handler, + content_type = content_type, + access_control_allow_origin = b"*") diff --git a/test/fixtures/wpt/common/security-features/subresource/referrer.py b/test/fixtures/wpt/common/security-features/subresource/referrer.py new file mode 100644 index 0000000..e366314 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/referrer.py @@ -0,0 +1,4 @@ +def main(request, response): + referrer = request.headers.get(b"referer", b"") + response_headers = [(b"Content-Type", b"text/javascript")] + return (200, response_headers, b"window.referrer = '" + referrer + b"'") diff --git a/test/fixtures/wpt/common/security-features/subresource/script.py b/test/fixtures/wpt/common/security-features/subresource/script.py new file mode 100644 index 0000000..9701816 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/script.py @@ -0,0 +1,14 @@ +import os, sys +from wptserve.utils import isomorphic_decode + +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"script.js.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + content_type = b"application/javascript") diff --git a/test/fixtures/wpt/common/security-features/subresource/shared-worker.py b/test/fixtures/wpt/common/security-features/subresource/shared-worker.py new file mode 100644 index 0000000..bdfb61b --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/shared-worker.py @@ -0,0 +1,13 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"shared-worker.js.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + content_type = b"application/javascript") diff --git a/test/fixtures/wpt/common/security-features/subresource/static-import.py b/test/fixtures/wpt/common/security-features/subresource/static-import.py new file mode 100644 index 0000000..435753e --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/static-import.py @@ -0,0 +1,19 @@ +import os, sys +from six.moves.urllib.parse import unquote + +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request): + import_url = unquote(isomorphic_decode(request.GET[b'import_url'])) + return subresource.get_template(u"static-import.js.template") % { + u"import_url": import_url + } + +def main(request, response): + payload_generator = lambda _: generate_payload(request) + subresource.respond(request, + response, + payload_generator = payload_generator, + content_type = b"application/javascript") diff --git a/test/fixtures/wpt/common/security-features/subresource/stylesheet.py b/test/fixtures/wpt/common/security-features/subresource/stylesheet.py new file mode 100644 index 0000000..29079af --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/stylesheet.py @@ -0,0 +1,55 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + type = b'image' + if b"type" in request.GET: + type = request.GET[b"type"] + + if b"id" in request.GET: + request.server.stash.put(request.GET[b"id"], data) + + if type == b'image': + return subresource.get_template(u"image.css.template") % {u"id": isomorphic_decode(request.GET[b"id"])} + + elif type == b'font': + return subresource.get_template(u"font.css.template") % {u"id": isomorphic_decode(request.GET[b"id"])} + + elif type == b'svg': + return subresource.get_template(u"svg.css.template") % { + u"id": isomorphic_decode(request.GET[b"id"]), + u"property": isomorphic_decode(request.GET[b"property"])} + +def generate_import_rule(request, server_data): + return u"@import url('%(url)s');" % { + u"url": subresource.create_url(request, swap_origin=True, + query_parameter_to_remove=u"import-rule") + } + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + payload_generator = lambda data: generate_payload(request, data) + content_type = b"text/css" + referrer_policy = b"unsafe-url" + if b"import-rule" in request.GET: + payload_generator = lambda data: generate_import_rule(request, data) + + if b"report-headers" in request.GET: + payload_generator = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + if b"referrer-policy" in request.GET: + referrer_policy = request.GET[b"referrer-policy"] + + subresource.respond( + request, + response, + payload_generator = payload_generator, + content_type = content_type, + maybe_additional_headers = { b"Referrer-Policy": referrer_policy }) diff --git a/test/fixtures/wpt/common/security-features/subresource/subresource.py b/test/fixtures/wpt/common/security-features/subresource/subresource.py new file mode 100644 index 0000000..0416c32 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/subresource.py @@ -0,0 +1,199 @@ +import os, json +from six.moves.urllib.parse import parse_qsl, SplitResult, urlencode, urlsplit, urlunsplit + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +def get_template(template_basename): + script_directory = os.path.dirname(os.path.abspath(isomorphic_decode(__file__))) + template_directory = os.path.abspath(os.path.join(script_directory, + u"template")) + template_filename = os.path.join(template_directory, template_basename) + + with open(template_filename, "r") as f: + return f.read() + + +def redirect(url, response): + response.add_required_headers = False + response.writer.write_status(301) + response.writer.write_header(b"access-control-allow-origin", b"*") + response.writer.write_header(b"location", isomorphic_encode(url)) + response.writer.end_headers() + response.writer.write(u"") + + +# TODO(kristijanburnik): subdomain_prefix is a hardcoded value aligned with +# referrer-policy-test-case.js. The prefix should be configured in one place. +def __get_swapped_origin_netloc(netloc, subdomain_prefix = u"www1."): + if netloc.startswith(subdomain_prefix): + return netloc[len(subdomain_prefix):] + else: + return subdomain_prefix + netloc + + +# Creates a URL (typically a redirect target URL) that is the same as the +# current request URL `request.url`, except for: +# - When `swap_scheme` or `swap_origin` is True, its scheme/origin is changed +# to the other one. (http <-> https, ws <-> wss, etc.) +# - For `downgrade`, we redirect to a URL that would be successfully loaded +# if and only if upgrade-insecure-request is applied. +# - `query_parameter_to_remove` parameter is removed from query part. +# Its default is "redirection" to avoid redirect loops. +def create_url(request, + swap_scheme=False, + swap_origin=False, + downgrade=False, + query_parameter_to_remove=u"redirection"): + parsed = urlsplit(request.url) + destination_netloc = parsed.netloc + + scheme = parsed.scheme + if swap_scheme: + scheme = u"http" if parsed.scheme == u"https" else u"https" + hostname = parsed.netloc.split(u':')[0] + port = request.server.config[u"ports"][scheme][0] + destination_netloc = u":".join([hostname, str(port)]) + + if downgrade: + # These rely on some unintuitive cleverness due to WPT's test setup: + # 'Upgrade-Insecure-Requests' does not upgrade the port number, + # so we use URLs in the form `http://[domain]:[https-port]`, + # which will be upgraded to `https://[domain]:[https-port]`. + # If the upgrade fails, the load will fail, as we don't serve HTTP over + # the secure port. + if parsed.scheme == u"https": + scheme = u"http" + elif parsed.scheme == u"wss": + scheme = u"ws" + else: + raise ValueError(u"Downgrade redirection: Invalid scheme '%s'" % + parsed.scheme) + hostname = parsed.netloc.split(u':')[0] + port = request.server.config[u"ports"][parsed.scheme][0] + destination_netloc = u":".join([hostname, str(port)]) + + if swap_origin: + destination_netloc = __get_swapped_origin_netloc(destination_netloc) + + parsed_query = parse_qsl(parsed.query, keep_blank_values=True) + parsed_query = [x for x in parsed_query if x[0] != query_parameter_to_remove] + + destination_url = urlunsplit(SplitResult( + scheme = scheme, + netloc = destination_netloc, + path = parsed.path, + query = urlencode(parsed_query), + fragment = None)) + + return destination_url + + +def preprocess_redirection(request, response): + if b"redirection" not in request.GET: + return False + + redirection = request.GET[b"redirection"] + + if redirection == b"no-redirect": + return False + elif redirection == b"keep-scheme": + redirect_url = create_url(request, swap_scheme=False) + elif redirection == b"swap-scheme": + redirect_url = create_url(request, swap_scheme=True) + elif redirection == b"downgrade": + redirect_url = create_url(request, downgrade=True) + elif redirection == b"keep-origin": + redirect_url = create_url(request, swap_origin=False) + elif redirection == b"swap-origin": + redirect_url = create_url(request, swap_origin=True) + else: + raise ValueError(u"Invalid redirection type '%s'" % isomorphic_decode(redirection)) + + redirect(redirect_url, response) + return True + + +def preprocess_stash_action(request, response): + if b"action" not in request.GET: + return False + + action = request.GET[b"action"] + + key = request.GET[b"key"] + stash = request.server.stash + path = request.GET[b"path"] if b"path" in request.GET \ + else isomorphic_encode(request.url.split(u'?')[0]) + + if action == b"put": + value = isomorphic_decode(request.GET[b"value"]) + stash.take(key=key, path=path) + stash.put(key=key, value=value, path=path) + response_data = json.dumps({u"status": u"success", u"result": isomorphic_decode(key)}) + elif action == b"purge": + value = stash.take(key=key, path=path) + return False + elif action == b"take": + value = stash.take(key=key, path=path) + if value is None: + status = u"allowed" + else: + status = u"blocked" + response_data = json.dumps({u"status": status, u"result": value}) + else: + return False + + response.add_required_headers = False + response.writer.write_status(200) + response.writer.write_header(b"content-type", b"text/javascript") + response.writer.write_header(b"cache-control", b"no-cache; must-revalidate") + response.writer.end_headers() + response.writer.write(response_data) + return True + + +def __noop(request, response): + return u"" + + +def respond(request, + response, + status_code = 200, + content_type = b"text/html", + payload_generator = __noop, + cache_control = b"no-cache; must-revalidate", + access_control_allow_origin = b"*", + maybe_additional_headers = None): + if preprocess_redirection(request, response): + return + + if preprocess_stash_action(request, response): + return + + response.add_required_headers = False + response.writer.write_status(status_code) + + if access_control_allow_origin != None: + response.writer.write_header(b"access-control-allow-origin", + access_control_allow_origin) + response.writer.write_header(b"content-type", content_type) + response.writer.write_header(b"cache-control", cache_control) + + additional_headers = maybe_additional_headers or {} + for header, value in additional_headers.items(): + response.writer.write_header(header, value) + + response.writer.end_headers() + + new_headers = {} + new_val = [] + for key, val in request.headers.items(): + if len(val) == 1: + new_val = isomorphic_decode(val[0]) + else: + new_val = [isomorphic_decode(x) for x in val] + new_headers[isomorphic_decode(key)] = new_val + + server_data = {u"headers": json.dumps(new_headers, indent = 4)} + + payload = payload_generator(server_data) + response.writer.write(payload) diff --git a/test/fixtures/wpt/common/security-features/subresource/svg.py b/test/fixtures/wpt/common/security-features/subresource/svg.py new file mode 100644 index 0000000..9c569e3 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/svg.py @@ -0,0 +1,37 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + if b"id" in request.GET: + with request.server.stash.lock: + request.server.stash.take(request.GET[b"id"]) + request.server.stash.put(request.GET[b"id"], data) + return u"" + +def generate_payload_embedded(request, server_data): + return subresource.get_template(u"svg.embedded.template") % { + u"id": isomorphic_decode(request.GET[b"id"]), + u"property": isomorphic_decode(request.GET[b"property"])} + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + handler = lambda data: generate_payload(request, data) + content_type = b'image/svg+xml' + + if b"embedded-svg" in request.GET: + handler = lambda data: generate_payload_embedded(request, data) + + if b"report-headers" in request.GET: + handler = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + subresource.respond(request, + response, + payload_generator = handler, + content_type = content_type) diff --git a/test/fixtures/wpt/common/security-features/subresource/template/document.html.template b/test/fixtures/wpt/common/security-features/subresource/template/document.html.template new file mode 100644 index 0000000..141711c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/document.html.template @@ -0,0 +1,16 @@ + + + + This page reports back it's request details to the parent frame + + + + + diff --git a/test/fixtures/wpt/common/security-features/subresource/template/font.css.template b/test/fixtures/wpt/common/security-features/subresource/template/font.css.template new file mode 100644 index 0000000..9d1e9c4 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/font.css.template @@ -0,0 +1,9 @@ +@font-face { + font-family: 'wpt'; + font-style: normal; + font-weight: normal; + src: url(/common/security-features/subresource/font.py?id=%(id)s) format('truetype'); +} +body { + font-family: 'wpt'; +} diff --git a/test/fixtures/wpt/common/security-features/subresource/template/image.css.template b/test/fixtures/wpt/common/security-features/subresource/template/image.css.template new file mode 100644 index 0000000..dfe41f1 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/image.css.template @@ -0,0 +1,3 @@ +div.styled::before { + content:url(/common/security-features/subresource/image.py?id=%(id)s) +} diff --git a/test/fixtures/wpt/common/security-features/subresource/template/script.js.template b/test/fixtures/wpt/common/security-features/subresource/template/script.js.template new file mode 100644 index 0000000..e2edf21 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/script.js.template @@ -0,0 +1,3 @@ +postMessage({ + "headers": %(headers)s +}, "*"); diff --git a/test/fixtures/wpt/common/security-features/subresource/template/shared-worker.js.template b/test/fixtures/wpt/common/security-features/subresource/template/shared-worker.js.template new file mode 100644 index 0000000..c3f109e --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/shared-worker.js.template @@ -0,0 +1,5 @@ +onconnect = function(e) { + e.ports[0].postMessage({ + "headers": %(headers)s + }); +}; diff --git a/test/fixtures/wpt/common/security-features/subresource/template/static-import.js.template b/test/fixtures/wpt/common/security-features/subresource/template/static-import.js.template new file mode 100644 index 0000000..095459b --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/static-import.js.template @@ -0,0 +1 @@ +import '%(import_url)s'; diff --git a/test/fixtures/wpt/common/security-features/subresource/template/svg.css.template b/test/fixtures/wpt/common/security-features/subresource/template/svg.css.template new file mode 100644 index 0000000..c2e509c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/svg.css.template @@ -0,0 +1,3 @@ +path { + %(property)s: url(/common/security-features/subresource/svg.py?id=%(id)s#invalidFragment); +} diff --git a/test/fixtures/wpt/common/security-features/subresource/template/svg.embedded.template b/test/fixtures/wpt/common/security-features/subresource/template/svg.embedded.template new file mode 100644 index 0000000..5986c48 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/svg.embedded.template @@ -0,0 +1,5 @@ + + + + + diff --git a/test/fixtures/wpt/common/security-features/subresource/template/worker.js.template b/test/fixtures/wpt/common/security-features/subresource/template/worker.js.template new file mode 100644 index 0000000..817dd8c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/worker.js.template @@ -0,0 +1,3 @@ +postMessage({ + "headers": %(headers)s +}); diff --git a/test/fixtures/wpt/common/security-features/subresource/video.py b/test/fixtures/wpt/common/security-features/subresource/video.py new file mode 100644 index 0000000..7cfbbfa --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/video.py @@ -0,0 +1,17 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + file = os.path.join(request.doc_root, u"media", u"movie_5.ogv") + return open(file, "rb").read() + + +def main(request, response): + handler = lambda data: generate_payload(request, data) + subresource.respond(request, + response, + payload_generator = handler, + access_control_allow_origin = b"*", + content_type = b"video/ogg") diff --git a/test/fixtures/wpt/common/security-features/subresource/worker.py b/test/fixtures/wpt/common/security-features/subresource/worker.py new file mode 100644 index 0000000..f655633 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/worker.py @@ -0,0 +1,13 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"worker.js.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + content_type = b"application/javascript") diff --git a/test/fixtures/wpt/common/security-features/subresource/xhr.py b/test/fixtures/wpt/common/security-features/subresource/xhr.py new file mode 100644 index 0000000..75921e9 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/xhr.py @@ -0,0 +1,16 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + data = (u'{"headers": %(headers)s}') % server_data + return data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + access_control_allow_origin = b"*", + content_type = b"application/json", + cache_control = b"no-store") diff --git a/test/fixtures/wpt/common/security-features/tools/format_spec_src_json.py b/test/fixtures/wpt/common/security-features/tools/format_spec_src_json.py new file mode 100644 index 0000000..d1bf581 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/format_spec_src_json.py @@ -0,0 +1,24 @@ +import collections +import json +import os + + +def main(): + '''Formats spec.src.json.''' + script_directory = os.path.dirname(os.path.abspath(__file__)) + for dir in [ + 'mixed-content', 'referrer-policy', 'referrer-policy/4K-1', + 'referrer-policy/4K', 'referrer-policy/4K+1', + 'upgrade-insecure-requests' + ]: + filename = os.path.join(script_directory, '..', '..', '..', dir, + 'spec.src.json') + spec = json.load( + open(filename, 'r'), object_pairs_hook=collections.OrderedDict) + with open(filename, 'w') as f: + f.write(json.dumps(spec, indent=2, separators=(',', ': '))) + f.write('\n') + + +if __name__ == '__main__': + main() diff --git a/test/fixtures/wpt/common/security-features/tools/generate.py b/test/fixtures/wpt/common/security-features/tools/generate.py new file mode 100644 index 0000000..01103a2 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/generate.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python + +from __future__ import print_function + +import argparse +import collections +import copy +import json +import os +import sys + +import spec_validator +import util + + +def expand_pattern(expansion_pattern, test_expansion_schema): + expansion = {} + for artifact_key in expansion_pattern: + artifact_value = expansion_pattern[artifact_key] + if artifact_value == '*': + expansion[artifact_key] = test_expansion_schema[artifact_key] + elif isinstance(artifact_value, list): + expansion[artifact_key] = artifact_value + elif isinstance(artifact_value, dict): + # Flattened expansion. + expansion[artifact_key] = [] + values_dict = expand_pattern(artifact_value, + test_expansion_schema[artifact_key]) + for sub_key in values_dict.keys(): + expansion[artifact_key] += values_dict[sub_key] + else: + expansion[artifact_key] = [artifact_value] + + return expansion + + +def permute_expansion(expansion, + artifact_order, + selection={}, + artifact_index=0): + assert isinstance(artifact_order, list), "artifact_order should be a list" + + if artifact_index >= len(artifact_order): + yield selection + return + + artifact_key = artifact_order[artifact_index] + + for artifact_value in expansion[artifact_key]: + selection[artifact_key] = artifact_value + for next_selection in permute_expansion(expansion, artifact_order, + selection, artifact_index + 1): + yield next_selection + + +# Dumps the test config `selection` into a serialized JSON string. +def dump_test_parameters(selection): + return json.dumps( + selection, + indent=2, + separators=(',', ': '), + sort_keys=True, + cls=util.CustomEncoder) + + +def get_test_filename(spec_directory, spec_json, selection): + '''Returns the filname for the main test HTML file''' + + selection_for_filename = copy.deepcopy(selection) + # Use 'unset' rather than 'None' in test filenames. + if selection_for_filename['delivery_value'] is None: + selection_for_filename['delivery_value'] = 'unset' + + return os.path.join( + spec_directory, + spec_json['test_file_path_pattern'] % selection_for_filename) + + +def get_csp_value(value): + ''' + Returns actual CSP header values (e.g. "worker-src 'self'") for the + given string used in PolicyDelivery's value (e.g. "worker-src-self"). + ''' + + # script-src + # Test-related scripts like testharness.js and inline scripts containing + # test bodies. + # 'unsafe-inline' is added as a workaround here. This is probably not so + # bad, as it shouldn't intefere non-inline-script requests that we want to + # test. + if value == 'script-src-wildcard': + return "script-src * 'unsafe-inline'" + if value == 'script-src-self': + return "script-src 'self' 'unsafe-inline'" + # Workaround for "script-src 'none'" would be more complicated, because + # - "script-src 'none' 'unsafe-inline'" is handled somehow differently from + # "script-src 'none'", i.e. + # https://w3c.github.io/webappsec-csp/#match-url-to-source-list Step 3 + # handles the latter but not the former. + # - We need nonce- or path-based additional values to allow same-origin + # test scripts like testharness.js. + # Therefore, we disable 'script-src-none' tests for now in + # `/content-security-policy/spec.src.json`. + if value == 'script-src-none': + return "script-src 'none'" + + # worker-src + if value == 'worker-src-wildcard': + return 'worker-src *' + if value == 'worker-src-self': + return "worker-src 'self'" + if value == 'worker-src-none': + return "worker-src 'none'" + raise Exception('Invalid delivery_value: %s' % value) + +def handle_deliveries(policy_deliveries): + ''' + Generate elements and HTTP headers for the given list of + PolicyDelivery. + TODO(hiroshige): Merge duplicated code here, scope/document.py, etc. + ''' + + meta = '' + headers = {} + + for delivery in policy_deliveries: + if delivery.value is None: + continue + if delivery.key == 'referrerPolicy': + if delivery.delivery_type == 'meta': + meta += \ + '' % delivery.value + elif delivery.delivery_type == 'http-rp': + headers['Referrer-Policy'] = delivery.value + # TODO(kristijanburnik): Limit to WPT origins. + headers['Access-Control-Allow-Origin'] = '*' + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + elif delivery.key == 'mixedContent': + assert (delivery.value == 'opt-in') + if delivery.delivery_type == 'meta': + meta += '' + elif delivery.delivery_type == 'http-rp': + headers['Content-Security-Policy'] = 'block-all-mixed-content' + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + elif delivery.key == 'contentSecurityPolicy': + csp_value = get_csp_value(delivery.value) + if delivery.delivery_type == 'meta': + meta += '' + elif delivery.delivery_type == 'http-rp': + headers['Content-Security-Policy'] = csp_value + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + elif delivery.key == 'upgradeInsecureRequests': + # https://w3c.github.io/webappsec-upgrade-insecure-requests/#delivery + assert (delivery.value == 'upgrade') + if delivery.delivery_type == 'meta': + meta += '' + elif delivery.delivery_type == 'http-rp': + headers[ + 'Content-Security-Policy'] = 'upgrade-insecure-requests' + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + else: + raise Exception('Invalid delivery_key: %s' % delivery.key) + return {"meta": meta, "headers": headers} + + +def generate_selection(spec_json, selection): + ''' + Returns a scenario object (with a top-level source_context_list entry, + which will be removed in generate_test_file() later). + ''' + + target_policy_delivery = util.PolicyDelivery(selection['delivery_type'], + selection['delivery_key'], + selection['delivery_value']) + del selection['delivery_type'] + del selection['delivery_key'] + del selection['delivery_value'] + + # Parse source context list and policy deliveries of source contexts. + # `util.ShouldSkip()` exceptions are raised if e.g. unsuppported + # combinations of source contexts and policy deliveries are used. + source_context_list_scheme = spec_json['source_context_list_schema'][ + selection['source_context_list']] + selection['source_context_list'] = [ + util.SourceContext.from_json(source_context, target_policy_delivery, + spec_json['source_context_schema']) + for source_context in source_context_list_scheme['sourceContextList'] + ] + + # Check if the subresource is supported by the innermost source context. + innermost_source_context = selection['source_context_list'][-1] + supported_subresource = spec_json['source_context_schema'][ + 'supported_subresource'][innermost_source_context.source_context_type] + if supported_subresource != '*': + if selection['subresource'] not in supported_subresource: + raise util.ShouldSkip() + + # Parse subresource policy deliveries. + selection[ + 'subresource_policy_deliveries'] = util.PolicyDelivery.list_from_json( + source_context_list_scheme['subresourcePolicyDeliveries'], + target_policy_delivery, spec_json['subresource_schema'] + ['supported_delivery_type'][selection['subresource']]) + + # Generate per-scenario test description. + selection['test_description'] = spec_json[ + 'test_description_template'] % selection + + return selection + + +def generate_test_file(spec_directory, test_helper_filenames, + test_html_template_basename, test_filename, scenarios): + ''' + Generates a test HTML file (and possibly its associated .headers file) + from `scenarios`. + ''' + + # Scenarios for the same file should have the same `source_context_list`, + # including the top-level one. + # Note: currently, non-top-level source contexts aren't necessarily required + # to be the same, but we set this requirement as it will be useful e.g. when + # we e.g. reuse a worker among multiple scenarios. + for scenario in scenarios: + assert (scenario['source_context_list'] == scenarios[0] + ['source_context_list']) + + # We process the top source context below, and do not include it in + # the JSON objects (i.e. `scenarios`) in generated HTML files. + top_source_context = scenarios[0]['source_context_list'].pop(0) + assert (top_source_context.source_context_type == 'top') + for scenario in scenarios[1:]: + assert (scenario['source_context_list'].pop(0) == top_source_context) + + parameters = {} + + # Sort scenarios, to avoid unnecessary diffs due to different orders in + # `scenarios`. + serialized_scenarios = sorted( + [dump_test_parameters(scenario) for scenario in scenarios]) + + parameters['scenarios'] = ",\n".join(serialized_scenarios).replace( + "\n", "\n" + " " * 10) + + test_directory = os.path.dirname(test_filename) + + parameters['helper_js'] = "" + for test_helper_filename in test_helper_filenames: + parameters['helper_js'] += ' \n' % ( + os.path.relpath(test_helper_filename, test_directory)) + parameters['sanity_checker_js'] = os.path.relpath( + os.path.join(spec_directory, 'generic', 'sanity-checker.js'), + test_directory) + parameters['spec_json_js'] = os.path.relpath( + os.path.join(spec_directory, 'generic', 'spec_json.js'), + test_directory) + + test_headers_filename = test_filename + ".headers" + + test_html_template = util.get_template(test_html_template_basename) + disclaimer_template = util.get_template('disclaimer.template') + + html_template_filename = os.path.join(util.template_directory, + test_html_template_basename) + generated_disclaimer = disclaimer_template \ + % {'generating_script_filename': os.path.relpath(sys.argv[0], + util.test_root_directory), + 'spec_directory': os.path.relpath(spec_directory, + util.test_root_directory)} + + # Adjust the template for the test invoking JS. Indent it to look nice. + parameters['generated_disclaimer'] = generated_disclaimer.rstrip() + + # Directory for the test files. + try: + os.makedirs(test_directory) + except: + pass + + delivery = handle_deliveries(top_source_context.policy_deliveries) + + if len(delivery['headers']) > 0: + with open(test_headers_filename, "w") as f: + for header in delivery['headers']: + f.write('%s: %s\n' % (header, delivery['headers'][header])) + + parameters['meta_delivery_method'] = delivery['meta'] + # Obey the lint and pretty format. + if len(parameters['meta_delivery_method']) > 0: + parameters['meta_delivery_method'] = "\n " + \ + parameters['meta_delivery_method'] + + # Write out the generated HTML file. + util.write_file(test_filename, test_html_template % parameters) + + +def generate_test_source_files(spec_directory, test_helper_filenames, + spec_json, target): + test_expansion_schema = spec_json['test_expansion_schema'] + specification = spec_json['specification'] + + if target == "debug": + spec_json_js_template = util.get_template('spec_json.js.template') + util.write_file( + os.path.join(spec_directory, "generic", "spec_json.js"), + spec_json_js_template % {'spec_json': json.dumps(spec_json)}) + util.write_file( + os.path.join(spec_directory, "generic", + "debug-output.spec.src.json"), + json.dumps(spec_json, indent=2, separators=(',', ': '))) + + # Choose a debug/release template depending on the target. + html_template = "test.%s.html.template" % target + + artifact_order = test_expansion_schema.keys() + artifact_order.remove('expansion') + + excluded_selection_pattern = '' + for key in artifact_order: + excluded_selection_pattern += '%(' + key + ')s/' + + # Create list of excluded tests. + exclusion_dict = set() + for excluded_pattern in spec_json['excluded_tests']: + excluded_expansion = \ + expand_pattern(excluded_pattern, test_expansion_schema) + for excluded_selection in permute_expansion(excluded_expansion, + artifact_order): + excluded_selection['delivery_key'] = spec_json['delivery_key'] + exclusion_dict.add(excluded_selection_pattern % excluded_selection) + + # `scenarios[filename]` represents the list of scenario objects to be + # generated into `filename`. + scenarios = {} + + for spec in specification: + # Used to make entries with expansion="override" override preceding + # entries with the same |selection_path|. + output_dict = {} + + for expansion_pattern in spec['test_expansion']: + expansion = expand_pattern(expansion_pattern, + test_expansion_schema) + for selection in permute_expansion(expansion, artifact_order): + selection['delivery_key'] = spec_json['delivery_key'] + selection_path = spec_json['selection_pattern'] % selection + if selection_path in output_dict: + if expansion_pattern['expansion'] != 'override': + print("Error: expansion is default in:") + print(dump_test_parameters(selection)) + print("but overrides:") + print(dump_test_parameters( + output_dict[selection_path])) + sys.exit(1) + output_dict[selection_path] = copy.deepcopy(selection) + + for selection_path in output_dict: + selection = output_dict[selection_path] + if (excluded_selection_pattern % selection) in exclusion_dict: + print('Excluding selection:', selection_path) + continue + try: + test_filename = get_test_filename(spec_directory, spec_json, + selection) + scenario = generate_selection(spec_json, selection) + scenarios[test_filename] = scenarios.get(test_filename, + []) + [scenario] + except util.ShouldSkip: + continue + + for filename in scenarios: + generate_test_file(spec_directory, test_helper_filenames, + html_template, filename, scenarios[filename]) + + +def merge_json(base, child): + for key in child: + if key not in base: + base[key] = child[key] + continue + # `base[key]` and `child[key]` both exists. + if isinstance(base[key], list) and isinstance(child[key], list): + base[key].extend(child[key]) + elif isinstance(base[key], dict) and isinstance(child[key], dict): + merge_json(base[key], child[key]) + else: + base[key] = child[key] + + +def main(): + parser = argparse.ArgumentParser( + description='Test suite generator utility') + parser.add_argument( + '-t', + '--target', + type=str, + choices=("release", "debug"), + default="release", + help='Sets the appropriate template for generating tests') + parser.add_argument( + '-s', + '--spec', + type=str, + default=os.getcwd(), + help='Specify a file used for describing and generating the tests') + # TODO(kristijanburnik): Add option for the spec_json file. + args = parser.parse_args() + + spec_directory = os.path.abspath(args.spec) + + # Read `spec.src.json` files, starting from `spec_directory`, and + # continuing to parent directories as long as `spec.src.json` exists. + spec_filenames = [] + test_helper_filenames = [] + spec_src_directory = spec_directory + while len(spec_src_directory) >= len(util.test_root_directory): + spec_filename = os.path.join(spec_src_directory, "spec.src.json") + if not os.path.exists(spec_filename): + break + spec_filenames.append(spec_filename) + test_filename = os.path.join(spec_src_directory, 'generic', + 'test-case.sub.js') + assert (os.path.exists(test_filename)) + test_helper_filenames.append(test_filename) + spec_src_directory = os.path.abspath( + os.path.join(spec_src_directory, "..")) + + spec_filenames = list(reversed(spec_filenames)) + test_helper_filenames = list(reversed(test_helper_filenames)) + + if len(spec_filenames) == 0: + print('Error: No spec.src.json is found at %s.' % spec_directory) + return + + # Load the default spec JSON file, ... + default_spec_filename = os.path.join(util.script_directory, + 'spec.src.json') + spec_json = collections.OrderedDict() + if os.path.exists(default_spec_filename): + spec_json = util.load_spec_json(default_spec_filename) + + # ... and then make spec JSON files in subdirectories override the default. + for spec_filename in spec_filenames: + child_spec_json = util.load_spec_json(spec_filename) + merge_json(spec_json, child_spec_json) + + spec_validator.assert_valid_spec_json(spec_json) + generate_test_source_files(spec_directory, test_helper_filenames, + spec_json, args.target) + + +if __name__ == '__main__': + main() diff --git a/test/fixtures/wpt/common/security-features/tools/spec.src.json b/test/fixtures/wpt/common/security-features/tools/spec.src.json new file mode 100644 index 0000000..0a46a1c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/spec.src.json @@ -0,0 +1,574 @@ +{ + "selection_pattern": "%(source_context_list)s.%(delivery_type)s/%(delivery_value)s/%(subresource)s/%(origin)s.%(redirection)s.%(source_scheme)s", + "test_file_path_pattern": "gen/%(source_context_list)s.%(delivery_type)s/%(delivery_value)s/%(subresource)s.%(source_scheme)s.html", + "excluded_tests": [ + { + // Workers are same-origin only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": [ + "worker-classic", + "worker-module", + "sharedworker-classic", + "sharedworker-module" + ], + "origin": [ + "cross-https", + "cross-http", + "cross-http-downgrade", + "cross-wss", + "cross-ws", + "cross-ws-downgrade" + ], + "expectation": "*" + }, + { + // Workers are same-origin only (redirects) + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": [ + "swap-origin", + "swap-scheme" + ], + "subresource": [ + "worker-classic", + "worker-module", + "sharedworker-classic", + "sharedworker-module" + ], + "origin": "*", + "expectation": "*" + }, + { + // Websockets are ws/wss-only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": "websocket", + "origin": [ + "same-https", + "same-http", + "same-http-downgrade", + "cross-https", + "cross-http", + "cross-http-downgrade" + ], + "expectation": "*" + }, + { + // Redirects are intentionally forbidden in browsers: + // https://fetch.spec.whatwg.org/#concept-websocket-establish + // Websockets are no-redirect only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": [ + "keep-origin", + "swap-origin", + "keep-scheme", + "swap-scheme", + "downgrade" + ], + "subresource": "websocket", + "origin": "*", + "expectation": "*" + }, + { + // ws/wss are websocket-only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": [ + "a-tag", + "area-tag", + "audio-tag", + "beacon", + "fetch", + "iframe-tag", + "img-tag", + "link-css-tag", + "link-prefetch-tag", + "object-tag", + "picture-tag", + "script-tag", + "sharedworker-classic", + "sharedworker-import", + "sharedworker-import-data", + "sharedworker-module", + "video-tag", + "worker-classic", + "worker-import", + "worker-import-data", + "worker-module", + "worklet-animation", + "worklet-animation-import-data", + "worklet-audio", + "worklet-audio-import-data", + "worklet-layout", + "worklet-layout-import-data", + "worklet-paint", + "worklet-paint-import-data", + "xhr" + ], + "origin": [ + "same-wss", + "same-ws", + "same-ws-downgrade", + "cross-wss", + "cross-ws", + "cross-ws-downgrade" + ], + "expectation": "*" + }, + { + // Worklets are HTTPS contexts only + "expansion": "*", + "source_scheme": "http", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": [ + "worklet-animation", + "worklet-animation-import-data", + "worklet-audio", + "worklet-audio-import-data", + "worklet-layout", + "worklet-layout-import-data", + "worklet-paint", + "worklet-paint-import-data" + ], + "origin": "*", + "expectation": "*" + } + ], + "source_context_schema": { + "supported_subresource": { + "top": "*", + "iframe": "*", + "iframe-blank": "*", + "srcdoc": "*", + "worker-classic": [ + "xhr", + "fetch", + "websocket", + "worker-classic", + "worker-module" + ], + "worker-module": [ + "xhr", + "fetch", + "websocket", + "worker-classic", + "worker-module" + ], + "worker-classic-data": [ + "xhr", + "fetch", + "websocket" + ], + "worker-module-data": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-classic": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-module": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-classic-data": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-module-data": [ + "xhr", + "fetch", + "websocket" + ] + } + }, + "source_context_list_schema": { + // Warning: Currently, some nested patterns of contexts have different + // inheritance rules for different kinds of policies. + // The generated tests will be used to test/investigate the policy + // inheritance rules, and eventually the policy inheritance rules will + // be unified (https://github.com/w3ctag/design-principles/issues/111). + "top": { + "description": "Policy set by the top-level Document", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "req": { + "description": "Subresource request's policy should override Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + } + ], + "subresourcePolicyDeliveries": [ + "nonNullPolicy" + ] + }, + "srcdoc-inherit": { + "description": "srcdoc iframe without its own policy should inherit parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "srcdoc" + } + ], + "subresourcePolicyDeliveries": [] + }, + "srcdoc": { + "description": "srcdoc iframe's policy should override parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "srcdoc", + "policyDeliveries": [ + "nonNullPolicy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "iframe": { + "description": "external iframe's policy should override parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "iframe", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "iframe-blank-inherit": { + "description": "blank iframe should inherit parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "iframe-blank" + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-classic": { + // This is applicable to referrer-policy tests. + // Use "worker-classic-inherit" for CSP (mixed-content, etc.). + "description": "dedicated workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "worker-classic", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-classic-inherit": { + // This is applicable to upgrade-insecure-requests and mixed-content tests. + // Use "worker-classic" for referrer-policy. + "description": "dedicated workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "worker-classic", + "policyDeliveries": [ + "anotherPolicy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-classic-data": { + "description": "data: dedicated workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "worker-classic-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-module": { + // This is applicable to referrer-policy tests. + // Use "worker-module-inherit" for CSP (mixed-content, etc.). + "description": "dedicated workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "worker-module", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-module-inherit": { + // This is applicable to upgrade-insecure-requests and mixed-content tests. + // Use "worker-module" for referrer-policy. + "description": "dedicated workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "worker-module", + "policyDeliveries": [ + "anotherPolicy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-module-data": { + "description": "data: dedicated workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "worker-module-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-classic": { + "description": "shared workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "sharedworker-classic", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-classic-data": { + "description": "data: shared workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "sharedworker-classic-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-module": { + "description": "shared workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "sharedworker-module", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-module-data": { + "description": "data: shared workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "sharedworker-module-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + } + }, + "test_expansion_schema": { + "expansion": [ + "default", + "override" + ], + "source_scheme": [ + "http", + "https" + ], + "source_context_list": [ + "top", + "req", + "srcdoc-inherit", + "srcdoc", + "iframe", + "iframe-blank-inherit", + "worker-classic", + "worker-classic-inherit", + "worker-classic-data", + "worker-module", + "worker-module-inherit", + "worker-module-data", + "sharedworker-classic", + "sharedworker-classic-data", + "sharedworker-module", + "sharedworker-module-data" + ], + "redirection": [ + "no-redirect", + "keep-origin", + "swap-origin", + "keep-scheme", + "swap-scheme", + "downgrade" + ], + "origin": [ + "same-https", + "same-http", + "same-http-downgrade", + "cross-https", + "cross-http", + "cross-http-downgrade", + "same-wss", + "same-ws", + "same-ws-downgrade", + "cross-wss", + "cross-ws", + "cross-ws-downgrade" + ], + "subresource": [ + "a-tag", + "area-tag", + "audio-tag", + "beacon", + "fetch", + "iframe-tag", + "img-tag", + "link-css-tag", + "link-prefetch-tag", + "object-tag", + "picture-tag", + "script-tag", + "sharedworker-classic", + "sharedworker-import", + "sharedworker-import-data", + "sharedworker-module", + "video-tag", + "websocket", + "worker-classic", + "worker-import", + "worker-import-data", + "worker-module", + "worklet-animation", + "worklet-animation-import-data", + "worklet-audio", + "worklet-audio-import-data", + "worklet-layout", + "worklet-layout-import-data", + "worklet-paint", + "worklet-paint-import-data", + "xhr" + ] + } +} diff --git a/test/fixtures/wpt/common/security-features/tools/spec_validator.py b/test/fixtures/wpt/common/security-features/tools/spec_validator.py new file mode 100644 index 0000000..15f0e1e --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/spec_validator.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python + +from __future__ import print_function + +import json, sys + + +def assert_non_empty_string(obj, field): + assert field in obj, 'Missing field "%s"' % field + assert isinstance(obj[field], basestring), \ + 'Field "%s" must be a string' % field + assert len(obj[field]) > 0, 'Field "%s" must not be empty' % field + + +def assert_non_empty_list(obj, field): + assert isinstance(obj[field], list), \ + '%s must be a list' % field + assert len(obj[field]) > 0, \ + '%s list must not be empty' % field + + +def assert_non_empty_dict(obj, field): + assert isinstance(obj[field], dict), \ + '%s must be a dict' % field + assert len(obj[field]) > 0, \ + '%s dict must not be empty' % field + + +def assert_contains(obj, field): + assert field in obj, 'Must contain field "%s"' % field + + +def assert_value_from(obj, field, items): + assert obj[field] in items, \ + 'Field "%s" must be from: %s' % (field, str(items)) + + +def assert_atom_or_list_items_from(obj, field, items): + if isinstance(obj[field], basestring) or isinstance( + obj[field], int) or obj[field] is None: + assert_value_from(obj, field, items) + return + + assert isinstance(obj[field], list), '%s must be a list' % field + for allowed_value in obj[field]: + assert allowed_value != '*', "Wildcard is not supported for lists!" + assert allowed_value in items, \ + 'Field "%s" must be from: %s' % (field, str(items)) + + +def assert_contains_only_fields(obj, expected_fields): + for expected_field in expected_fields: + assert_contains(obj, expected_field) + + for actual_field in obj: + assert actual_field in expected_fields, \ + 'Unexpected field "%s".' % actual_field + + +def leaf_values(schema): + if isinstance(schema, list): + return schema + ret = [] + for _, sub_schema in schema.iteritems(): + ret += leaf_values(sub_schema) + return ret + + +def assert_value_unique_in(value, used_values): + assert value not in used_values, 'Duplicate value "%s"!' % str(value) + used_values[value] = True + + +def assert_valid_artifact(exp_pattern, artifact_key, schema): + if isinstance(schema, list): + assert_atom_or_list_items_from(exp_pattern, artifact_key, + ["*"] + schema) + return + + for sub_artifact_key, sub_schema in schema.iteritems(): + assert_valid_artifact(exp_pattern[artifact_key], sub_artifact_key, + sub_schema) + + +def validate(spec_json, details): + """ Validates the json specification for generating tests. """ + + details['object'] = spec_json + assert_contains_only_fields(spec_json, [ + "selection_pattern", "test_file_path_pattern", + "test_description_template", "test_page_title_template", + "specification", "delivery_key", "subresource_schema", + "source_context_schema", "source_context_list_schema", + "test_expansion_schema", "excluded_tests" + ]) + assert_non_empty_list(spec_json, "specification") + assert_non_empty_dict(spec_json, "test_expansion_schema") + assert_non_empty_list(spec_json, "excluded_tests") + + specification = spec_json['specification'] + test_expansion_schema = spec_json['test_expansion_schema'] + excluded_tests = spec_json['excluded_tests'] + + valid_test_expansion_fields = test_expansion_schema.keys() + + # Should be consistent with `sourceContextMap` in + # `/common/security-features/resources/common.sub.js`. + valid_source_context_names = [ + "top", "iframe", "iframe-blank", "srcdoc", "worker-classic", + "worker-module", "worker-classic-data", "worker-module-data", + "sharedworker-classic", "sharedworker-module", + "sharedworker-classic-data", "sharedworker-module-data" + ] + + valid_subresource_names = [ + "a-tag", "area-tag", "audio-tag", "form-tag", "iframe-tag", "img-tag", + "link-css-tag", "link-prefetch-tag", "object-tag", "picture-tag", + "script-tag", "video-tag" + ] + ["beacon", "fetch", "xhr", "websocket"] + [ + "worker-classic", "worker-module", "worker-import", + "worker-import-data", "sharedworker-classic", "sharedworker-module", + "sharedworker-import", "sharedworker-import-data", + "serviceworker-classic", "serviceworker-module", + "serviceworker-import", "serviceworker-import-data" + ] + [ + "worklet-animation", "worklet-audio", "worklet-layout", + "worklet-paint", "worklet-animation-import", "worklet-audio-import", + "worklet-layout-import", "worklet-paint-import", + "worklet-animation-import-data", "worklet-audio-import-data", + "worklet-layout-import-data", "worklet-paint-import-data" + ] + + # Validate each single spec. + for spec in specification: + details['object'] = spec + + # Validate required fields for a single spec. + assert_contains_only_fields(spec, [ + 'title', 'description', 'specification_url', 'test_expansion' + ]) + assert_non_empty_string(spec, 'title') + assert_non_empty_string(spec, 'description') + assert_non_empty_string(spec, 'specification_url') + assert_non_empty_list(spec, 'test_expansion') + + for spec_exp in spec['test_expansion']: + details['object'] = spec_exp + assert_contains_only_fields(spec_exp, valid_test_expansion_fields) + + for artifact in test_expansion_schema: + details['test_expansion_field'] = artifact + assert_valid_artifact(spec_exp, artifact, + test_expansion_schema[artifact]) + del details['test_expansion_field'] + + # Validate source_context_schema. + details['object'] = spec_json['source_context_schema'] + assert_contains_only_fields( + spec_json['source_context_schema'], + ['supported_delivery_type', 'supported_subresource']) + assert_contains_only_fields( + spec_json['source_context_schema']['supported_delivery_type'], + valid_source_context_names) + for source_context in spec_json['source_context_schema'][ + 'supported_delivery_type']: + assert_valid_artifact( + spec_json['source_context_schema']['supported_delivery_type'], + source_context, test_expansion_schema['delivery_type']) + assert_contains_only_fields( + spec_json['source_context_schema']['supported_subresource'], + valid_source_context_names) + for source_context in spec_json['source_context_schema'][ + 'supported_subresource']: + assert_valid_artifact( + spec_json['source_context_schema']['supported_subresource'], + source_context, leaf_values(test_expansion_schema['subresource'])) + + # Validate subresource_schema. + details['object'] = spec_json['subresource_schema'] + assert_contains_only_fields(spec_json['subresource_schema'], + ['supported_delivery_type']) + assert_contains_only_fields( + spec_json['subresource_schema']['supported_delivery_type'], + leaf_values(test_expansion_schema['subresource'])) + for subresource in spec_json['subresource_schema'][ + 'supported_delivery_type']: + assert_valid_artifact( + spec_json['subresource_schema']['supported_delivery_type'], + subresource, test_expansion_schema['delivery_type']) + + # Validate the test_expansion schema members. + details['object'] = test_expansion_schema + assert_contains_only_fields(test_expansion_schema, [ + 'expansion', 'source_scheme', 'source_context_list', 'delivery_type', + 'delivery_value', 'redirection', 'subresource', 'origin', 'expectation' + ]) + assert_atom_or_list_items_from(test_expansion_schema, 'expansion', + ['default', 'override']) + assert_atom_or_list_items_from(test_expansion_schema, 'source_scheme', + ['http', 'https']) + assert_atom_or_list_items_from( + test_expansion_schema, 'source_context_list', + spec_json['source_context_list_schema'].keys()) + + # Should be consistent with `preprocess_redirection` in + # `/common/security-features/subresource/subresource.py`. + assert_atom_or_list_items_from(test_expansion_schema, 'redirection', [ + 'no-redirect', 'keep-origin', 'swap-origin', 'keep-scheme', + 'swap-scheme', 'downgrade' + ]) + for subresource in leaf_values(test_expansion_schema['subresource']): + assert subresource in valid_subresource_names, "Invalid subresource %s" % subresource + # Should be consistent with getSubresourceOrigin() in + # `/common/security-features/resources/common.sub.js`. + assert_atom_or_list_items_from(test_expansion_schema, 'origin', [ + 'same-http', 'same-https', 'same-ws', 'same-wss', 'cross-http', + 'cross-https', 'cross-ws', 'cross-wss', 'same-http-downgrade', + 'cross-http-downgrade', 'same-ws-downgrade', 'cross-ws-downgrade' + ]) + + # Validate excluded tests. + details['object'] = excluded_tests + for excluded_test_expansion in excluded_tests: + assert_contains_only_fields(excluded_test_expansion, + valid_test_expansion_fields) + details['object'] = excluded_test_expansion + for artifact in test_expansion_schema: + details['test_expansion_field'] = artifact + assert_valid_artifact(excluded_test_expansion, artifact, + test_expansion_schema[artifact]) + del details['test_expansion_field'] + + del details['object'] + + +def assert_valid_spec_json(spec_json): + error_details = {} + try: + validate(spec_json, error_details) + except AssertionError as err: + print('ERROR:', err.message) + print(json.dumps(error_details, indent=4)) + sys.exit(1) + + +def main(): + spec_json = load_spec_json() + assert_valid_spec_json(spec_json) + print("Spec JSON is valid.") + + +if __name__ == '__main__': + main() diff --git a/test/fixtures/wpt/common/security-features/tools/template/disclaimer.template b/test/fixtures/wpt/common/security-features/tools/template/disclaimer.template new file mode 100644 index 0000000..ba9458c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/template/disclaimer.template @@ -0,0 +1 @@ + diff --git a/test/fixtures/wpt/common/security-features/tools/template/spec_json.js.template b/test/fixtures/wpt/common/security-features/tools/template/spec_json.js.template new file mode 100644 index 0000000..e4cbd03 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/template/spec_json.js.template @@ -0,0 +1 @@ +var SPEC_JSON = %(spec_json)s; diff --git a/test/fixtures/wpt/common/security-features/tools/template/test.debug.html.template b/test/fixtures/wpt/common/security-features/tools/template/test.debug.html.template new file mode 100644 index 0000000..b6be088 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/template/test.debug.html.template @@ -0,0 +1,26 @@ + +%(generated_disclaimer)s + + + + %(meta_delivery_method)s + + + + + + + +%(helper_js)s + + +
+ + diff --git a/test/fixtures/wpt/common/security-features/tools/template/test.release.html.template b/test/fixtures/wpt/common/security-features/tools/template/test.release.html.template new file mode 100644 index 0000000..bac2d5b --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/template/test.release.html.template @@ -0,0 +1,22 @@ + +%(generated_disclaimer)s + + + + %(meta_delivery_method)s + + + +%(helper_js)s + + +
+ + diff --git a/test/fixtures/wpt/common/security-features/tools/util.py b/test/fixtures/wpt/common/security-features/tools/util.py new file mode 100644 index 0000000..1233ffb --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/util.py @@ -0,0 +1,218 @@ +from __future__ import print_function + +import os, sys, json, json5, re +import collections + +script_directory = os.path.dirname(os.path.abspath(__file__)) +template_directory = os.path.abspath( + os.path.join(script_directory, 'template')) +test_root_directory = os.path.abspath( + os.path.join(script_directory, '..', '..', '..')) + + +def get_template(basename): + with open(os.path.join(template_directory, basename), "r") as f: + return f.read() + + +def write_file(filename, contents): + with open(filename, "w") as f: + f.write(contents) + + +def read_nth_line(fp, line_number): + fp.seek(0) + for i, line in enumerate(fp): + if (i + 1) == line_number: + return line + + +def load_spec_json(path_to_spec): + re_error_location = re.compile('line ([0-9]+) column ([0-9]+)') + with open(path_to_spec, "r") as f: + try: + return json5.load(f, object_pairs_hook=collections.OrderedDict) + except ValueError as ex: + print(ex.message) + match = re_error_location.search(ex.message) + if match: + line_number, column = int(match.group(1)), int(match.group(2)) + print(read_nth_line(f, line_number).rstrip()) + print(" " * (column - 1) + "^") + sys.exit(1) + + +class ShouldSkip(Exception): + ''' + Raised when the given combination of subresource type, source context type, + delivery type etc. are not supported and we should skip that configuration. + ShouldSkip is expected in normal generator execution (and thus subsequent + generation continues), as we first enumerate a broad range of configurations + first, and later raise ShouldSkip to filter out unsupported combinations. + + ShouldSkip is distinguished from other general errors that cause immediate + termination of the generator and require fix. + ''' + def __init__(self): + pass + + +class PolicyDelivery(object): + ''' + See `@typedef PolicyDelivery` comments in + `common/security-features/resources/common.sub.js`. + ''' + + def __init__(self, delivery_type, key, value): + self.delivery_type = delivery_type + self.key = key + self.value = value + + def __eq__(self, other): + return type(self) is type(other) and self.__dict__ == other.__dict__ + + @classmethod + def list_from_json(cls, list, target_policy_delivery, + supported_delivery_types): + # type: (dict, PolicyDelivery, typing.List[str]) -> typing.List[PolicyDelivery] + ''' + Parses a JSON object `list` that represents a list of `PolicyDelivery` + and returns a list of `PolicyDelivery`, plus supporting placeholders + (see `from_json()` comments below or + `common/security-features/README.md`). + + Can raise `ShouldSkip`. + ''' + if list is None: + return [] + + out = [] + for obj in list: + policy_delivery = PolicyDelivery.from_json( + obj, target_policy_delivery, supported_delivery_types) + # Drop entries with null values. + if policy_delivery.value is None: + continue + out.append(policy_delivery) + return out + + @classmethod + def from_json(cls, obj, target_policy_delivery, supported_delivery_types): + # type: (dict, PolicyDelivery, typing.List[str]) -> PolicyDelivery + ''' + Parses a JSON object `obj` and returns a `PolicyDelivery` object. + In addition to dicts (in the same format as to_json() outputs), + this method accepts the following placeholders: + "policy": + `target_policy_delivery` + "policyIfNonNull": + `target_policy_delivery` if its value is not None. + "anotherPolicy": + A PolicyDelivery that has the same key as + `target_policy_delivery` but a different value. + The delivery type is selected from `supported_delivery_types`. + + Can raise `ShouldSkip`. + ''' + + if obj == "policy": + policy_delivery = target_policy_delivery + elif obj == "nonNullPolicy": + if target_policy_delivery.value is None: + raise ShouldSkip() + policy_delivery = target_policy_delivery + elif obj == "anotherPolicy": + if len(supported_delivery_types) == 0: + raise ShouldSkip() + policy_delivery = target_policy_delivery.get_another_policy( + supported_delivery_types[0]) + elif isinstance(obj, dict): + policy_delivery = PolicyDelivery(obj['deliveryType'], obj['key'], + obj['value']) + else: + raise Exception('policy delivery is invalid: ' + obj) + + # Omit unsupported combinations of source contexts and delivery type. + if policy_delivery.delivery_type not in supported_delivery_types: + raise ShouldSkip() + + return policy_delivery + + def to_json(self): + # type: () -> dict + return { + "deliveryType": self.delivery_type, + "key": self.key, + "value": self.value + } + + def get_another_policy(self, delivery_type): + # type: (str) -> PolicyDelivery + if self.key == 'referrerPolicy': + if self.value == 'no-referrer': + return PolicyDelivery(delivery_type, self.key, 'unsafe-url') + else: + return PolicyDelivery(delivery_type, self.key, 'no-referrer') + elif self.key == 'mixedContent': + if self.value == 'opt-in': + return PolicyDelivery(delivery_type, self.key, None) + else: + return PolicyDelivery(delivery_type, self.key, 'opt-in') + elif self.key == 'contentSecurityPolicy': + if self.value is not None: + return PolicyDelivery(delivery_type, self.key, None) + else: + return PolicyDelivery(delivery_type, self.key, 'worker-src-none') + elif self.key == 'upgradeInsecureRequests': + if self.value == 'upgrade': + return PolicyDelivery(delivery_type, self.key, None) + else: + return PolicyDelivery(delivery_type, self.key, 'upgrade') + else: + raise Exception('delivery key is invalid: ' + self.key) + + +class SourceContext(object): + def __init__(self, source_context_type, policy_deliveries): + # type: (unicode, typing.List[PolicyDelivery]) -> None + self.source_context_type = source_context_type + self.policy_deliveries = policy_deliveries + + def __eq__(self, other): + return type(self) is type(other) and self.__dict__ == other.__dict__ + + @classmethod + def from_json(cls, obj, target_policy_delivery, source_context_schema): + ''' + Parses a JSON object `obj` and returns a `SourceContext` object. + + `target_policy_delivery` and `source_context_schema` are used for + policy delivery placeholders and filtering out unsupported + delivery types. + + Can raise `ShouldSkip`. + ''' + source_context_type = obj.get('sourceContextType') + policy_deliveries = PolicyDelivery.list_from_json( + obj.get('policyDeliveries'), target_policy_delivery, + source_context_schema['supported_delivery_type'] + [source_context_type]) + return SourceContext(source_context_type, policy_deliveries) + + def to_json(self): + return { + "sourceContextType": self.source_context_type, + "policyDeliveries": [x.to_json() for x in self.policy_deliveries] + } + + +class CustomEncoder(json.JSONEncoder): + ''' + Used to dump dicts containing `SourceContext`/`PolicyDelivery` into JSON. + ''' + def default(self, obj): + if isinstance(obj, SourceContext): + return obj.to_json() + if isinstance(obj, PolicyDelivery): + return obj.to_json() + return json.JSONEncoder.default(self, obj) diff --git a/test/fixtures/wpt/common/security-features/types.md b/test/fixtures/wpt/common/security-features/types.md new file mode 100644 index 0000000..1707991 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/types.md @@ -0,0 +1,62 @@ +# Types around the generator and generated tests + +This document describes types and concepts used across JavaScript and Python parts of this test framework. +Please refer to the JSDoc in `common.sub.js` or docstrings in Python scripts (if any). + +## Scenario + +### Properties + +- All keys of `test_expansion_schema` in `spec.src.json`, except for `expansion`, `delivery_type`, `delivery_value`, and `source_context_list`. Their values are **string**s specified in `test_expansion_schema`. +- `source_context_list` +- `subresource_policy_deliveries` + +### Types + +- Generator (`spec.src.json`): JSON object +- Generator (Python): `dict` +- Runtime (JS): JSON object +- Runtime (Python): N/A + +## `PolicyDelivery` + +### Types + +- Generator (`spec.src.json`): JSON object +- Generator (Python): `util.PolicyDelivery` +- Runtime (JS): JSON object (`@typedef PolicyDelivery` in `common.sub.js`) +- Runtime (Python): N/A + +## `SourceContext` + +Subresource requests can be possibly sent from various kinds of fetch client's environment settings objects. For example: + +- top-level windows, +- ` diff --git a/test/fixtures/wpt/streams/transferable/worker.html b/test/fixtures/wpt/streams/transferable/worker.html new file mode 100644 index 0000000..c5dc9fc --- /dev/null +++ b/test/fixtures/wpt/streams/transferable/worker.html @@ -0,0 +1,76 @@ + + + + + + + diff --git a/test/fixtures/wpt/streams/transferable/writable-stream.html b/test/fixtures/wpt/streams/transferable/writable-stream.html new file mode 100644 index 0000000..adc6f45 --- /dev/null +++ b/test/fixtures/wpt/streams/transferable/writable-stream.html @@ -0,0 +1,136 @@ + + + + + + + + diff --git a/test/fixtures/wpt/streams/transform-streams/backpressure.any.js b/test/fixtures/wpt/streams/transform-streams/backpressure.any.js new file mode 100644 index 0000000..64c9d09 --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/backpressure.any.js @@ -0,0 +1,195 @@ +// META: global=window,worker,jsshell +// META: script=../resources/recording-streams.js +// META: script=../resources/test-utils.js +'use strict'; + +const error1 = new Error('error1 message'); +error1.name = 'error1'; + +promise_test(() => { + const ts = recordingTransformStream(); + const writer = ts.writable.getWriter(); + // This call never resolves. + writer.write('a'); + return flushAsyncEvents().then(() => { + assert_array_equals(ts.events, [], 'transform should not be called'); + }); +}, 'backpressure allows no transforms with a default identity transform and no reader'); + +promise_test(() => { + const ts = recordingTransformStream({}, undefined, { highWaterMark: 1 }); + const writer = ts.writable.getWriter(); + // This call to write() resolves asynchronously. + writer.write('a'); + // This call to write() waits for backpressure that is never relieved and never calls transform(). + writer.write('b'); + return flushAsyncEvents().then(() => { + assert_array_equals(ts.events, ['transform', 'a'], 'transform should be called once'); + }); +}, 'backpressure only allows one transform() with a identity transform with a readable HWM of 1 and no reader'); + +promise_test(() => { + // Without a transform() implementation, recordingTransformStream() never enqueues anything. + const ts = recordingTransformStream({ + transform() { + // Discard all chunks. As a result, the readable side is never full enough to exert backpressure and transform() + // keeps being called. + } + }, undefined, { highWaterMark: 1 }); + const writer = ts.writable.getWriter(); + const writePromises = []; + for (let i = 0; i < 4; ++i) { + writePromises.push(writer.write(i)); + } + return Promise.all(writePromises).then(() => { + assert_array_equals(ts.events, ['transform', 0, 'transform', 1, 'transform', 2, 'transform', 3], + 'all 4 events should be transformed'); + }); +}, 'transform() should keep being called as long as there is no backpressure'); + +promise_test(() => { + const ts = new TransformStream({}, undefined, { highWaterMark: 1 }); + const writer = ts.writable.getWriter(); + const reader = ts.readable.getReader(); + const events = []; + const writerPromises = [ + writer.write('a').then(() => events.push('a')), + writer.write('b').then(() => events.push('b')), + writer.close().then(() => events.push('closed'))]; + return delay(0).then(() => { + assert_array_equals(events, ['a'], 'the first write should have resolved'); + return reader.read(); + }).then(({ value, done }) => { + assert_false(done, 'done should not be true'); + assert_equals('a', value, 'value should be "a"'); + return delay(0); + }).then(() => { + assert_array_equals(events, ['a', 'b', 'closed'], 'both writes and close() should have resolved'); + return reader.read(); + }).then(({ value, done }) => { + assert_false(done, 'done should still not be true'); + assert_equals('b', value, 'value should be "b"'); + return reader.read(); + }).then(({ done }) => { + assert_true(done, 'done should be true'); + return writerPromises; + }); +}, 'writes should resolve as soon as transform completes'); + +promise_test(() => { + const ts = new TransformStream(undefined, undefined, { highWaterMark: 0 }); + const writer = ts.writable.getWriter(); + const reader = ts.readable.getReader(); + const readPromise = reader.read(); + writer.write('a'); + return readPromise.then(({ value, done }) => { + assert_false(done, 'not done'); + assert_equals(value, 'a', 'value should be "a"'); + }); +}, 'calling pull() before the first write() with backpressure should work'); + +promise_test(() => { + let reader; + const ts = recordingTransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk); + return reader.read(); + } + }, undefined, { highWaterMark: 1 }); + const writer = ts.writable.getWriter(); + reader = ts.readable.getReader(); + return writer.write('a'); +}, 'transform() should be able to read the chunk it just enqueued'); + +promise_test(() => { + let resolveTransform; + const transformPromise = new Promise(resolve => { + resolveTransform = resolve; + }); + const ts = recordingTransformStream({ + transform() { + return transformPromise; + } + }, undefined, new CountQueuingStrategy({ highWaterMark: Infinity })); + const writer = ts.writable.getWriter(); + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1'); + return delay(0).then(() => { + writer.write('a'); + assert_array_equals(ts.events, ['transform', 'a']); + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0'); + return flushAsyncEvents(); + }).then(() => { + assert_equals(writer.desiredSize, 0, 'desiredSize should still be 0'); + resolveTransform(); + return delay(0); + }).then(() => { + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1'); + }); +}, 'blocking transform() should cause backpressure'); + +promise_test(t => { + const ts = new TransformStream(); + ts.readable.cancel(error1); + return promise_rejects_exactly(t, error1, ts.writable.getWriter().closed, 'closed should reject'); +}, 'writer.closed should resolve after readable is canceled during start'); + +promise_test(t => { + const ts = new TransformStream({}, undefined, { highWaterMark: 0 }); + return delay(0).then(() => { + ts.readable.cancel(error1); + return promise_rejects_exactly(t, error1, ts.writable.getWriter().closed, 'closed should reject'); + }); +}, 'writer.closed should resolve after readable is canceled with backpressure'); + +promise_test(t => { + const ts = new TransformStream({}, undefined, { highWaterMark: 1 }); + return delay(0).then(() => { + ts.readable.cancel(error1); + return promise_rejects_exactly(t, error1, ts.writable.getWriter().closed, 'closed should reject'); + }); +}, 'writer.closed should resolve after readable is canceled with no backpressure'); + +promise_test(() => { + const ts = new TransformStream({}, undefined, { highWaterMark: 1 }); + const writer = ts.writable.getWriter(); + return delay(0).then(() => { + const writePromise = writer.write('a'); + ts.readable.cancel(error1); + return writePromise; + }); +}, 'cancelling the readable should cause a pending write to resolve'); + +promise_test(t => { + const rs = new ReadableStream(); + const ts = new TransformStream(); + const pipePromise = rs.pipeTo(ts.writable); + ts.readable.cancel(error1); + return promise_rejects_exactly(t, error1, pipePromise, 'promise returned from pipeTo() should be rejected'); +}, 'cancelling the readable side of a TransformStream should abort an empty pipe'); + +promise_test(t => { + const rs = new ReadableStream(); + const ts = new TransformStream(); + const pipePromise = rs.pipeTo(ts.writable); + return delay(0).then(() => { + ts.readable.cancel(error1); + return promise_rejects_exactly(t, error1, pipePromise, 'promise returned from pipeTo() should be rejected'); + }); +}, 'cancelling the readable side of a TransformStream should abort an empty pipe after startup'); + +promise_test(t => { + const rs = new ReadableStream({ + start(controller) { + controller.enqueue('a'); + controller.enqueue('b'); + controller.enqueue('c'); + } + }); + const ts = new TransformStream(); + const pipePromise = rs.pipeTo(ts.writable); + // Allow data to flow into the pipe. + return delay(0).then(() => { + ts.readable.cancel(error1); + return promise_rejects_exactly(t, error1, pipePromise, 'promise returned from pipeTo() should be rejected'); + }); +}, 'cancelling the readable side of a TransformStream should abort a full pipe'); diff --git a/test/fixtures/wpt/streams/transform-streams/errors.any.js b/test/fixtures/wpt/streams/transform-streams/errors.any.js new file mode 100644 index 0000000..ba26b32 --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/errors.any.js @@ -0,0 +1,341 @@ +// META: global=window,worker,jsshell +// META: script=../resources/test-utils.js +'use strict'; + +const thrownError = new Error('bad things are happening!'); +thrownError.name = 'error1'; + +promise_test(t => { + const ts = new TransformStream({ + transform() { + throw thrownError; + } + }); + + const reader = ts.readable.getReader(); + + const writer = ts.writable.getWriter(); + + return Promise.all([ + promise_rejects_exactly(t, thrownError, writer.write('a'), + 'writable\'s write should reject with the thrown error'), + promise_rejects_exactly(t, thrownError, reader.read(), + 'readable\'s read should reject with the thrown error'), + promise_rejects_exactly(t, thrownError, reader.closed, + 'readable\'s closed should be rejected with the thrown error'), + promise_rejects_exactly(t, thrownError, writer.closed, + 'writable\'s closed should be rejected with the thrown error') + ]); +}, 'TransformStream errors thrown in transform put the writable and readable in an errored state'); + +promise_test(t => { + const ts = new TransformStream({ + transform() { + }, + flush() { + throw thrownError; + } + }); + + const reader = ts.readable.getReader(); + + const writer = ts.writable.getWriter(); + + return Promise.all([ + writer.write('a'), + promise_rejects_exactly(t, thrownError, writer.close(), + 'writable\'s close should reject with the thrown error'), + promise_rejects_exactly(t, thrownError, reader.read(), + 'readable\'s read should reject with the thrown error'), + promise_rejects_exactly(t, thrownError, reader.closed, + 'readable\'s closed should be rejected with the thrown error'), + promise_rejects_exactly(t, thrownError, writer.closed, + 'writable\'s closed should be rejected with the thrown error') + ]); +}, 'TransformStream errors thrown in flush put the writable and readable in an errored state'); + +test(() => { + new TransformStream({ + start(c) { + c.enqueue('a'); + c.error(new Error('generic error')); + assert_throws_js(TypeError, () => c.enqueue('b'), 'enqueue() should throw'); + } + }); +}, 'errored TransformStream should not enqueue new chunks'); + +promise_test(t => { + const ts = new TransformStream({ + start() { + return flushAsyncEvents().then(() => { + throw thrownError; + }); + }, + transform: t.unreached_func('transform should not be called'), + flush: t.unreached_func('flush should not be called') + }); + + const writer = ts.writable.getWriter(); + const reader = ts.readable.getReader(); + return Promise.all([ + promise_rejects_exactly(t, thrownError, writer.write('a'), 'writer should reject with thrownError'), + promise_rejects_exactly(t, thrownError, writer.close(), 'close() should reject with thrownError'), + promise_rejects_exactly(t, thrownError, reader.read(), 'reader should reject with thrownError') + ]); +}, 'TransformStream transformer.start() rejected promise should error the stream'); + +promise_test(t => { + const controllerError = new Error('start failure'); + controllerError.name = 'controllerError'; + const ts = new TransformStream({ + start(c) { + return flushAsyncEvents() + .then(() => { + c.error(controllerError); + throw new Error('ignored error'); + }); + }, + transform: t.unreached_func('transform should never be called if start() fails'), + flush: t.unreached_func('flush should never be called if start() fails') + }); + + const writer = ts.writable.getWriter(); + const reader = ts.readable.getReader(); + return Promise.all([ + promise_rejects_exactly(t, controllerError, writer.write('a'), 'writer should reject with controllerError'), + promise_rejects_exactly(t, controllerError, writer.close(), 'close should reject with same error'), + promise_rejects_exactly(t, controllerError, reader.read(), 'reader should reject with same error') + ]); +}, 'when controller.error is followed by a rejection, the error reason should come from controller.error'); + +test(() => { + assert_throws_js(URIError, () => new TransformStream({ + start() { throw new URIError('start thrown error'); }, + transform() {} + }), 'constructor should throw'); +}, 'TransformStream constructor should throw when start does'); + +test(() => { + const strategy = { + size() { throw new URIError('size thrown error'); } + }; + + assert_throws_js(URIError, () => new TransformStream({ + start(c) { + c.enqueue('a'); + }, + transform() {} + }, undefined, strategy), 'constructor should throw the same error strategy.size throws'); +}, 'when strategy.size throws inside start(), the constructor should throw the same error'); + +test(() => { + const controllerError = new URIError('controller.error'); + + let controller; + const strategy = { + size() { + controller.error(controllerError); + throw new Error('redundant error'); + } + }; + + assert_throws_js(URIError, () => new TransformStream({ + start(c) { + controller = c; + c.enqueue('a'); + }, + transform() {} + }, undefined, strategy), 'the first error should be thrown'); +}, 'when strategy.size calls controller.error() then throws, the constructor should throw the first error'); + +promise_test(t => { + const ts = new TransformStream(); + const writer = ts.writable.getWriter(); + const closedPromise = writer.closed; + return Promise.all([ + ts.readable.cancel(thrownError), + promise_rejects_exactly(t, thrownError, closedPromise, 'closed should throw a TypeError') + ]); +}, 'cancelling the readable side should error the writable'); + +promise_test(t => { + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }); + const writer = ts.writable.getWriter(); + const reader = ts.readable.getReader(); + const writePromise = writer.write('a'); + const closePromise = writer.close(); + controller.error(thrownError); + return Promise.all([ + promise_rejects_exactly(t, thrownError, reader.closed, 'reader.closed should reject'), + promise_rejects_exactly(t, thrownError, writePromise, 'writePromise should reject'), + promise_rejects_exactly(t, thrownError, closePromise, 'closePromise should reject')]); +}, 'it should be possible to error the readable between close requested and complete'); + +promise_test(t => { + const ts = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk); + controller.terminate(); + throw thrownError; + } + }, undefined, { highWaterMark: 1 }); + const writePromise = ts.writable.getWriter().write('a'); + const closedPromise = ts.readable.getReader().closed; + return Promise.all([ + promise_rejects_exactly(t, thrownError, writePromise, 'write() should reject'), + promise_rejects_exactly(t, thrownError, closedPromise, 'reader.closed should reject') + ]); +}, 'an exception from transform() should error the stream if terminate has been requested but not completed'); + +promise_test(t => { + const ts = new TransformStream(); + const writer = ts.writable.getWriter(); + // The microtask following transformer.start() hasn't completed yet, so the abort is queued and not notified to the + // TransformStream yet. + const abortPromise = writer.abort(thrownError); + const cancelPromise = ts.readable.cancel(new Error('cancel reason')); + return Promise.all([ + abortPromise, + cancelPromise, + promise_rejects_exactly(t, thrownError, writer.closed, 'writer.closed should reject with thrownError')]); +}, 'abort should set the close reason for the writable when it happens before cancel during start, but cancel should ' + + 'still succeed'); + +promise_test(t => { + let resolveTransform; + const transformPromise = new Promise(resolve => { + resolveTransform = resolve; + }); + const ts = new TransformStream({ + transform() { + return transformPromise; + } + }, undefined, { highWaterMark: 2 }); + const writer = ts.writable.getWriter(); + return delay(0).then(() => { + const writePromise = writer.write(); + const abortPromise = writer.abort(thrownError); + const cancelPromise = ts.readable.cancel(new Error('cancel reason')); + resolveTransform(); + return Promise.all([ + writePromise, + abortPromise, + cancelPromise, + promise_rejects_exactly(t, thrownError, writer.closed, 'writer.closed should reject with thrownError')]); + }); +}, 'abort should set the close reason for the writable when it happens before cancel during underlying sink write, ' + + 'but cancel should still succeed'); + +const ignoredError = new Error('ignoredError'); +ignoredError.name = 'ignoredError'; + +promise_test(t => { + const ts = new TransformStream({ + start(controller) { + controller.error(thrownError); + controller.error(ignoredError); + } + }); + return promise_rejects_exactly(t, thrownError, ts.writable.abort(), 'abort() should reject with thrownError'); +}, 'controller.error() should do nothing the second time it is called'); + +promise_test(t => { + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }); + const cancelPromise = ts.readable.cancel(thrownError); + controller.error(ignoredError); + return Promise.all([ + cancelPromise, + promise_rejects_exactly(t, thrownError, ts.writable.getWriter().closed, 'closed should reject with thrownError') + ]); +}, 'controller.error() should do nothing after readable.cancel()'); + +promise_test(t => { + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }); + return ts.writable.abort(thrownError).then(() => { + controller.error(ignoredError); + return promise_rejects_exactly(t, thrownError, ts.writable.getWriter().closed, 'closed should reject with thrownError'); + }); +}, 'controller.error() should do nothing after writable.abort() has completed'); + +promise_test(t => { + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + }, + transform() { + throw thrownError; + } + }, undefined, { highWaterMark: Infinity }); + const writer = ts.writable.getWriter(); + return promise_rejects_exactly(t, thrownError, writer.write(), 'write() should reject').then(() => { + controller.error(); + return promise_rejects_exactly(t, thrownError, writer.closed, 'closed should reject with thrownError'); + }); +}, 'controller.error() should do nothing after a transformer method has thrown an exception'); + +promise_test(t => { + let controller; + let calls = 0; + const ts = new TransformStream({ + start(c) { + controller = c; + }, + transform() { + ++calls; + } + }, undefined, { highWaterMark: 1 }); + return delay(0).then(() => { + // Create backpressure. + controller.enqueue('a'); + const writer = ts.writable.getWriter(); + // transform() will not be called until backpressure is relieved. + const writePromise = writer.write('b'); + assert_equals(calls, 0, 'transform() should not have been called'); + controller.error(thrownError); + // Now backpressure has been relieved and the write can proceed. + return promise_rejects_exactly(t, thrownError, writePromise, 'write() should reject').then(() => { + assert_equals(calls, 0, 'transform() should not be called'); + }); + }); +}, 'erroring during write with backpressure should result in the write failing'); + +promise_test(t => { + const ts = new TransformStream({}, undefined, { highWaterMark: 0 }); + return delay(0).then(() => { + const writer = ts.writable.getWriter(); + // write should start synchronously + const writePromise = writer.write(0); + // The underlying sink's abort() is not called until the write() completes. + const abortPromise = writer.abort(thrownError); + // Perform a read to relieve backpressure and permit the write() to complete. + const readPromise = ts.readable.getReader().read(); + return Promise.all([ + promise_rejects_exactly(t, thrownError, readPromise, 'read() should reject'), + promise_rejects_exactly(t, thrownError, writePromise, 'write() should reject'), + abortPromise + ]); + }); +}, 'a write() that was waiting for backpressure should reject if the writable is aborted'); + +promise_test(t => { + const ts = new TransformStream(); + ts.writable.abort(thrownError); + const reader = ts.readable.getReader(); + return promise_rejects_exactly(t, thrownError, reader.read(), 'read() should reject with thrownError'); +}, 'the readable should be errored with the reason passed to the writable abort() method'); diff --git a/test/fixtures/wpt/streams/transform-streams/flush.any.js b/test/fixtures/wpt/streams/transform-streams/flush.any.js new file mode 100644 index 0000000..dc40532 --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/flush.any.js @@ -0,0 +1,131 @@ +// META: global=window,worker,jsshell +// META: script=../resources/test-utils.js +'use strict'; + +promise_test(() => { + let flushCalled = false; + const ts = new TransformStream({ + transform() { }, + flush() { + flushCalled = true; + } + }); + + return ts.writable.getWriter().close().then(() => { + return assert_true(flushCalled, 'closing the writable triggers the transform flush immediately'); + }); +}, 'TransformStream flush is called immediately when the writable is closed, if no writes are queued'); + +promise_test(() => { + let flushCalled = false; + let resolveTransform; + const ts = new TransformStream({ + transform() { + return new Promise(resolve => { + resolveTransform = resolve; + }); + }, + flush() { + flushCalled = true; + return new Promise(() => {}); // never resolves + } + }, undefined, { highWaterMark: 1 }); + + const writer = ts.writable.getWriter(); + writer.write('a'); + writer.close(); + assert_false(flushCalled, 'closing the writable does not immediately call flush if writes are not finished'); + + let rsClosed = false; + ts.readable.getReader().closed.then(() => { + rsClosed = true; + }); + + return delay(0).then(() => { + assert_false(flushCalled, 'closing the writable does not asynchronously call flush if writes are not finished'); + resolveTransform(); + return delay(0); + }).then(() => { + assert_true(flushCalled, 'flush is eventually called'); + assert_false(rsClosed, 'if flushPromise does not resolve, the readable does not become closed'); + }); +}, 'TransformStream flush is called after all queued writes finish, once the writable is closed'); + +promise_test(() => { + let c; + const ts = new TransformStream({ + start(controller) { + c = controller; + }, + transform() { + }, + flush() { + c.enqueue('x'); + c.enqueue('y'); + } + }); + + const reader = ts.readable.getReader(); + + const writer = ts.writable.getWriter(); + writer.write('a'); + writer.close(); + return reader.read().then(result1 => { + assert_equals(result1.value, 'x', 'the first chunk read is the first one enqueued in flush'); + assert_equals(result1.done, false, 'the first chunk read is the first one enqueued in flush'); + + return reader.read().then(result2 => { + assert_equals(result2.value, 'y', 'the second chunk read is the second one enqueued in flush'); + assert_equals(result2.done, false, 'the second chunk read is the second one enqueued in flush'); + }); + }); +}, 'TransformStream flush gets a chance to enqueue more into the readable'); + +promise_test(() => { + let c; + const ts = new TransformStream({ + start(controller) { + c = controller; + }, + transform() { + }, + flush() { + c.enqueue('x'); + c.enqueue('y'); + return delay(0); + } + }); + + const reader = ts.readable.getReader(); + + const writer = ts.writable.getWriter(); + writer.write('a'); + writer.close(); + + return Promise.all([ + reader.read().then(result1 => { + assert_equals(result1.value, 'x', 'the first chunk read is the first one enqueued in flush'); + assert_equals(result1.done, false, 'the first chunk read is the first one enqueued in flush'); + + return reader.read().then(result2 => { + assert_equals(result2.value, 'y', 'the second chunk read is the second one enqueued in flush'); + assert_equals(result2.done, false, 'the second chunk read is the second one enqueued in flush'); + }); + }), + reader.closed.then(() => { + assert_true(true, 'readable reader becomes closed'); + }) + ]); +}, 'TransformStream flush gets a chance to enqueue more into the readable, and can then async close'); + +const error1 = new Error('error1'); +error1.name = 'error1'; + +promise_test(t => { + const ts = new TransformStream({ + flush(controller) { + controller.error(error1); + } + }); + return promise_rejects_exactly(t, error1, ts.writable.getWriter().close(), 'close() should reject'); +}, 'error() during flush should cause writer.close() to reject'); diff --git a/test/fixtures/wpt/streams/transform-streams/general.any.js b/test/fixtures/wpt/streams/transform-streams/general.any.js new file mode 100644 index 0000000..d4f2a1d --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/general.any.js @@ -0,0 +1,437 @@ +// META: global=window,worker,jsshell +// META: script=../resources/test-utils.js +// META: script=../resources/rs-utils.js +'use strict'; + +test(() => { + new TransformStream({ transform() { } }); +}, 'TransformStream can be constructed with a transform function'); + +test(() => { + new TransformStream(); + new TransformStream({}); +}, 'TransformStream can be constructed with no transform function'); + +test(() => { + const ts = new TransformStream({ transform() { } }); + + const writer = ts.writable.getWriter(); + assert_equals(writer.desiredSize, 1, 'writer.desiredSize should be 1'); +}, 'TransformStream writable starts in the writable state'); + +promise_test(() => { + const ts = new TransformStream(); + + const writer = ts.writable.getWriter(); + writer.write('a'); + assert_equals(writer.desiredSize, 0, 'writer.desiredSize should be 0 after write()'); + + return ts.readable.getReader().read().then(result => { + assert_equals(result.value, 'a', + 'result from reading the readable is the same as was written to writable'); + assert_false(result.done, 'stream should not be done'); + + return delay(0).then(() => assert_equals(writer.desiredSize, 1, 'desiredSize should be 1 again')); + }); +}, 'Identity TransformStream: can read from readable what is put into writable'); + +promise_test(() => { + let c; + const ts = new TransformStream({ + start(controller) { + c = controller; + }, + transform(chunk) { + c.enqueue(chunk.toUpperCase()); + } + }); + + const writer = ts.writable.getWriter(); + writer.write('a'); + + return ts.readable.getReader().read().then(result => { + assert_equals(result.value, 'A', + 'result from reading the readable is the transformation of what was written to writable'); + assert_false(result.done, 'stream should not be done'); + }); +}, 'Uppercaser sync TransformStream: can read from readable transformed version of what is put into writable'); + +promise_test(() => { + let c; + const ts = new TransformStream({ + start(controller) { + c = controller; + }, + transform(chunk) { + c.enqueue(chunk.toUpperCase()); + c.enqueue(chunk.toUpperCase()); + } + }); + + const writer = ts.writable.getWriter(); + writer.write('a'); + + const reader = ts.readable.getReader(); + + return reader.read().then(result1 => { + assert_equals(result1.value, 'A', + 'the first chunk read is the transformation of the single chunk written'); + assert_false(result1.done, 'stream should not be done'); + + return reader.read().then(result2 => { + assert_equals(result2.value, 'A', + 'the second chunk read is also the transformation of the single chunk written'); + assert_false(result2.done, 'stream should not be done'); + }); + }); +}, 'Uppercaser-doubler sync TransformStream: can read both chunks put into the readable'); + +promise_test(() => { + let c; + const ts = new TransformStream({ + start(controller) { + c = controller; + }, + transform(chunk) { + return delay(0).then(() => c.enqueue(chunk.toUpperCase())); + } + }); + + const writer = ts.writable.getWriter(); + writer.write('a'); + + return ts.readable.getReader().read().then(result => { + assert_equals(result.value, 'A', + 'result from reading the readable is the transformation of what was written to writable'); + assert_false(result.done, 'stream should not be done'); + }); +}, 'Uppercaser async TransformStream: can read from readable transformed version of what is put into writable'); + +promise_test(() => { + let doSecondEnqueue; + let returnFromTransform; + const ts = new TransformStream({ + transform(chunk, controller) { + delay(0).then(() => controller.enqueue(chunk.toUpperCase())); + doSecondEnqueue = () => controller.enqueue(chunk.toUpperCase()); + return new Promise(resolve => { + returnFromTransform = resolve; + }); + } + }); + + const reader = ts.readable.getReader(); + + const writer = ts.writable.getWriter(); + writer.write('a'); + + return reader.read().then(result1 => { + assert_equals(result1.value, 'A', + 'the first chunk read is the transformation of the single chunk written'); + assert_false(result1.done, 'stream should not be done'); + doSecondEnqueue(); + + return reader.read().then(result2 => { + assert_equals(result2.value, 'A', + 'the second chunk read is also the transformation of the single chunk written'); + assert_false(result2.done, 'stream should not be done'); + returnFromTransform(); + }); + }); +}, 'Uppercaser-doubler async TransformStream: can read both chunks put into the readable'); + +promise_test(() => { + const ts = new TransformStream({ transform() { } }); + + const writer = ts.writable.getWriter(); + writer.close(); + + return Promise.all([writer.closed, ts.readable.getReader().closed]); +}, 'TransformStream: by default, closing the writable closes the readable (when there are no queued writes)'); + +promise_test(() => { + let transformResolve; + const transformPromise = new Promise(resolve => { + transformResolve = resolve; + }); + const ts = new TransformStream({ + transform() { + return transformPromise; + } + }, undefined, { highWaterMark: 1 }); + + const writer = ts.writable.getWriter(); + writer.write('a'); + writer.close(); + + let rsClosed = false; + ts.readable.getReader().closed.then(() => { + rsClosed = true; + }); + + return delay(0).then(() => { + assert_equals(rsClosed, false, 'readable is not closed after a tick'); + transformResolve(); + + return writer.closed.then(() => { + // TODO: Is this expectation correct? + assert_equals(rsClosed, true, 'readable is closed at that point'); + }); + }); +}, 'TransformStream: by default, closing the writable waits for transforms to finish before closing both'); + +promise_test(() => { + let c; + const ts = new TransformStream({ + start(controller) { + c = controller; + }, + transform() { + c.enqueue('x'); + c.enqueue('y'); + return delay(0); + } + }); + + const writer = ts.writable.getWriter(); + writer.write('a'); + writer.close(); + + const readableChunks = readableStreamToArray(ts.readable); + + return writer.closed.then(() => { + return readableChunks.then(chunks => { + assert_array_equals(chunks, ['x', 'y'], 'both enqueued chunks can be read from the readable'); + }); + }); +}, 'TransformStream: by default, closing the writable closes the readable after sync enqueues and async done'); + +promise_test(() => { + let c; + const ts = new TransformStream({ + start(controller) { + c = controller; + }, + transform() { + return delay(0) + .then(() => c.enqueue('x')) + .then(() => c.enqueue('y')) + .then(() => delay(0)); + } + }); + + const writer = ts.writable.getWriter(); + writer.write('a'); + writer.close(); + + const readableChunks = readableStreamToArray(ts.readable); + + return writer.closed.then(() => { + return readableChunks.then(chunks => { + assert_array_equals(chunks, ['x', 'y'], 'both enqueued chunks can be read from the readable'); + }); + }); +}, 'TransformStream: by default, closing the writable closes the readable after async enqueues and async done'); + +promise_test(() => { + let c; + const ts = new TransformStream({ + suffix: '-suffix', + + start(controller) { + c = controller; + c.enqueue('start' + this.suffix); + }, + + transform(chunk) { + c.enqueue(chunk + this.suffix); + }, + + flush() { + c.enqueue('flushed' + this.suffix); + } + }); + + const writer = ts.writable.getWriter(); + writer.write('a'); + writer.close(); + + const readableChunks = readableStreamToArray(ts.readable); + + return writer.closed.then(() => { + return readableChunks.then(chunks => { + assert_array_equals(chunks, ['start-suffix', 'a-suffix', 'flushed-suffix'], 'all enqueued chunks have suffixes'); + }); + }); +}, 'Transform stream should call transformer methods as methods'); + +promise_test(() => { + function functionWithOverloads() {} + functionWithOverloads.apply = () => assert_unreached('apply() should not be called'); + functionWithOverloads.call = () => assert_unreached('call() should not be called'); + const ts = new TransformStream({ + start: functionWithOverloads, + transform: functionWithOverloads, + flush: functionWithOverloads + }); + const writer = ts.writable.getWriter(); + writer.write('a'); + writer.close(); + + return readableStreamToArray(ts.readable); +}, 'methods should not not have .apply() or .call() called'); + +promise_test(t => { + let startCalled = false; + let startDone = false; + let transformDone = false; + let flushDone = false; + const ts = new TransformStream({ + start() { + startCalled = true; + return flushAsyncEvents().then(() => { + startDone = true; + }); + }, + transform() { + return t.step(() => { + assert_true(startDone, 'transform() should not be called until the promise returned from start() has resolved'); + return flushAsyncEvents().then(() => { + transformDone = true; + }); + }); + }, + flush() { + return t.step(() => { + assert_true(transformDone, + 'flush() should not be called until the promise returned from transform() has resolved'); + return flushAsyncEvents().then(() => { + flushDone = true; + }); + }); + } + }, undefined, { highWaterMark: 1 }); + + assert_true(startCalled, 'start() should be called synchronously'); + + const writer = ts.writable.getWriter(); + const writePromise = writer.write('a'); + return writer.close().then(() => { + assert_true(flushDone, 'promise returned from flush() should have resolved'); + return writePromise; + }); +}, 'TransformStream start, transform, and flush should be strictly ordered'); + +promise_test(() => { + let transformCalled = false; + const ts = new TransformStream({ + transform() { + transformCalled = true; + } + }, undefined, { highWaterMark: Infinity }); + // transform() is only called synchronously when there is no backpressure and all microtasks have run. + return delay(0).then(() => { + const writePromise = ts.writable.getWriter().write(); + assert_true(transformCalled, 'transform() should have been called'); + return writePromise; + }); +}, 'it should be possible to call transform() synchronously'); + +promise_test(() => { + const ts = new TransformStream({}, undefined, { highWaterMark: 0 }); + + const writer = ts.writable.getWriter(); + writer.close(); + + return Promise.all([writer.closed, ts.readable.getReader().closed]); +}, 'closing the writable should close the readable when there are no queued chunks, even with backpressure'); + +test(() => { + new TransformStream({ + start(controller) { + controller.terminate(); + assert_throws_js(TypeError, () => controller.enqueue(), 'enqueue should throw'); + } + }); +}, 'enqueue() should throw after controller.terminate()'); + +promise_test(() => { + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }); + const cancelPromise = ts.readable.cancel(); + assert_throws_js(TypeError, () => controller.enqueue(), 'enqueue should throw'); + return cancelPromise; +}, 'enqueue() should throw after readable.cancel()'); + +test(() => { + new TransformStream({ + start(controller) { + controller.terminate(); + controller.terminate(); + } + }); +}, 'controller.terminate() should do nothing the second time it is called'); + +promise_test(t => { + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }); + const cancelReason = { name: 'cancelReason' }; + const cancelPromise = ts.readable.cancel(cancelReason); + controller.terminate(); + return Promise.all([ + cancelPromise, + promise_rejects_exactly(t, cancelReason, ts.writable.getWriter().closed, 'closed should reject with cancelReason') + ]); +}, 'terminate() should do nothing after readable.cancel()'); + +promise_test(() => { + let calls = 0; + new TransformStream({ + start() { + ++calls; + } + }); + return flushAsyncEvents().then(() => { + assert_equals(calls, 1, 'start() should have been called exactly once'); + }); +}, 'start() should not be called twice'); + +test(() => { + assert_throws_js(RangeError, () => new TransformStream({ readableType: 'bytes' }), 'constructor should throw'); +}, 'specifying a defined readableType should throw'); + +test(() => { + assert_throws_js(RangeError, () => new TransformStream({ writableType: 'bytes' }), 'constructor should throw'); +}, 'specifying a defined writableType should throw'); + +test(() => { + class Subclass extends TransformStream { + extraFunction() { + return true; + } + } + assert_equals( + Object.getPrototypeOf(Subclass.prototype), TransformStream.prototype, + 'Subclass.prototype\'s prototype should be TransformStream.prototype'); + assert_equals(Object.getPrototypeOf(Subclass), TransformStream, + 'Subclass\'s prototype should be TransformStream'); + const sub = new Subclass(); + assert_true(sub instanceof TransformStream, + 'Subclass object should be an instance of TransformStream'); + assert_true(sub instanceof Subclass, + 'Subclass object should be an instance of Subclass'); + const readableGetter = Object.getOwnPropertyDescriptor( + TransformStream.prototype, 'readable').get; + assert_equals(readableGetter.call(sub), sub.readable, + 'Subclass object should pass brand check'); + assert_true(sub.extraFunction(), + 'extraFunction() should be present on Subclass object'); +}, 'Subclassing TransformStream should work'); diff --git a/test/fixtures/wpt/streams/transform-streams/lipfuzz.any.js b/test/fixtures/wpt/streams/transform-streams/lipfuzz.any.js new file mode 100644 index 0000000..c8c3803 --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/lipfuzz.any.js @@ -0,0 +1,163 @@ +// META: global=window,worker,jsshell +'use strict'; + +class LipFuzzTransformer { + constructor(substitutions) { + this.substitutions = substitutions; + this.partialChunk = ''; + this.lastIndex = undefined; + } + + transform(chunk, controller) { + chunk = this.partialChunk + chunk; + this.partialChunk = ''; + // lastIndex is the index of the first character after the last substitution. + this.lastIndex = 0; + chunk = chunk.replace(/\{\{([a-zA-Z0-9_-]+)\}\}/g, this.replaceTag.bind(this)); + // Regular expression for an incomplete template at the end of a string. + const partialAtEndRegexp = /\{(\{([a-zA-Z0-9_-]+(\})?)?)?$/g; + // Avoid looking at any characters that have already been substituted. + partialAtEndRegexp.lastIndex = this.lastIndex; + this.lastIndex = undefined; + const match = partialAtEndRegexp.exec(chunk); + if (match) { + this.partialChunk = chunk.substring(match.index); + chunk = chunk.substring(0, match.index); + } + controller.enqueue(chunk); + } + + flush(controller) { + if (this.partialChunk.length > 0) { + controller.enqueue(this.partialChunk); + } + } + + replaceTag(match, p1, offset) { + let replacement = this.substitutions[p1]; + if (replacement === undefined) { + replacement = ''; + } + this.lastIndex = offset + replacement.length; + return replacement; + } +} + +const substitutions = { + in1: 'out1', + in2: 'out2', + quine: '{{quine}}', + bogusPartial: '{{incompleteResult}' +}; + +const cases = [ + { + input: [''], + output: [''] + }, + { + input: [], + output: [] + }, + { + input: ['{{in1}}'], + output: ['out1'] + }, + { + input: ['z{{in1}}'], + output: ['zout1'] + }, + { + input: ['{{in1}}q'], + output: ['out1q'] + }, + { + input: ['{{in1}}{{in1}'], + output: ['out1', '{{in1}'] + }, + { + input: ['{{in1}}{{in1}', '}'], + output: ['out1', 'out1'] + }, + { + input: ['{{in1', '}}'], + output: ['', 'out1'] + }, + { + input: ['{{', 'in1}}'], + output: ['', 'out1'] + }, + { + input: ['{', '{in1}}'], + output: ['', 'out1'] + }, + { + input: ['{{', 'in1}'], + output: ['', '', '{{in1}'] + }, + { + input: ['{'], + output: ['', '{'] + }, + { + input: ['{', ''], + output: ['', '', '{'] + }, + { + input: ['{', '{', 'i', 'n', '1', '}', '}'], + output: ['', '', '', '', '', '', 'out1'] + }, + { + input: ['{{in1}}{{in2}}{{in1}}'], + output: ['out1out2out1'] + }, + { + input: ['{{wrong}}'], + output: [''] + }, + { + input: ['{{wron', 'g}}'], + output: ['', ''] + }, + { + input: ['{{quine}}'], + output: ['{{quine}}'] + }, + { + input: ['{{bogusPartial}}'], + output: ['{{incompleteResult}'] + }, + { + input: ['{{bogusPartial}}}'], + output: ['{{incompleteResult}}'] + } +]; + +for (const testCase of cases) { + const inputChunks = testCase.input; + const outputChunks = testCase.output; + promise_test(() => { + const lft = new TransformStream(new LipFuzzTransformer(substitutions)); + const writer = lft.writable.getWriter(); + const promises = []; + for (const inputChunk of inputChunks) { + promises.push(writer.write(inputChunk)); + } + promises.push(writer.close()); + const reader = lft.readable.getReader(); + let readerChain = Promise.resolve(); + for (const outputChunk of outputChunks) { + readerChain = readerChain.then(() => { + return reader.read().then(({ value, done }) => { + assert_false(done, `done should be false when reading ${outputChunk}`); + assert_equals(value, outputChunk, `value should match outputChunk`); + }); + }); + } + readerChain = readerChain.then(() => { + return reader.read().then(({ done }) => assert_true(done, `done should be true`)); + }); + promises.push(readerChain); + return Promise.all(promises); + }, `testing "${inputChunks}" (length ${inputChunks.length})`); +} diff --git a/test/fixtures/wpt/streams/transform-streams/patched-global.any.js b/test/fixtures/wpt/streams/transform-streams/patched-global.any.js new file mode 100644 index 0000000..416edf8 --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/patched-global.any.js @@ -0,0 +1,51 @@ +// META: global=window,worker,jsshell +'use strict'; + +// Tests which patch the global environment are kept separate to avoid +// interfering with other tests. + +test(t => { + // eslint-disable-next-line no-extend-native, accessor-pairs + Object.defineProperty(Object.prototype, 'highWaterMark', { + set() { throw new Error('highWaterMark setter called'); } + }); + + // eslint-disable-next-line no-extend-native, accessor-pairs + Object.defineProperty(Object.prototype, 'size', { + set() { throw new Error('size setter called'); } + }); + + t.add_cleanup(() => { + delete Object.prototype.highWaterMark; + delete Object.prototype.size; + }); + + assert_not_equals(new TransformStream(), null, 'constructor should work'); +}, 'TransformStream constructor should not call setters for highWaterMark or size'); + +test(t => { + const oldReadableStream = ReadableStream; + const oldWritableStream = WritableStream; + const getReader = ReadableStream.prototype.getReader; + const getWriter = WritableStream.prototype.getWriter; + + // Replace ReadableStream and WritableStream with broken versions. + ReadableStream = function () { + throw new Error('Called the global ReadableStream constructor'); + }; + WritableStream = function () { + throw new Error('Called the global WritableStream constructor'); + }; + t.add_cleanup(() => { + ReadableStream = oldReadableStream; + WritableStream = oldWritableStream; + }); + + const ts = new TransformStream(); + + // Just to be sure, ensure the readable and writable pass brand checks. + assert_not_equals(getReader.call(ts.readable), undefined, + 'getReader should work when called on ts.readable'); + assert_not_equals(getWriter.call(ts.writable), undefined, + 'getWriter should work when called on ts.writable'); +}, 'TransformStream should use the original value of ReadableStream and WritableStream'); diff --git a/test/fixtures/wpt/streams/transform-streams/properties.any.js b/test/fixtures/wpt/streams/transform-streams/properties.any.js new file mode 100644 index 0000000..f2ac482 --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/properties.any.js @@ -0,0 +1,49 @@ +// META: global=window,worker,jsshell +'use strict'; + +const transformerMethods = { + start: { + length: 1, + trigger: () => Promise.resolve() + }, + transform: { + length: 2, + trigger: ts => ts.writable.getWriter().write() + }, + flush: { + length: 1, + trigger: ts => ts.writable.getWriter().close() + } +}; + +for (const method in transformerMethods) { + const { length, trigger } = transformerMethods[method]; + + // Some semantic tests of how transformer methods are called can be found in general.js, as well as in the test files + // specific to each method. + promise_test(() => { + let argCount; + const ts = new TransformStream({ + [method](...args) { + argCount = args.length; + } + }, undefined, { highWaterMark: Infinity }); + return Promise.resolve(trigger(ts)).then(() => { + assert_equals(argCount, length, `${method} should be called with ${length} arguments`); + }); + }, `transformer method ${method} should be called with the right number of arguments`); + + promise_test(() => { + let methodWasCalled = false; + function Transformer() {} + Transformer.prototype = { + [method]() { + methodWasCalled = true; + } + }; + const ts = new TransformStream(new Transformer(), undefined, { highWaterMark: Infinity }); + return Promise.resolve(trigger(ts)).then(() => { + assert_true(methodWasCalled, `${method} should be called`); + }); + }, `transformer method ${method} should be called even when it's located on the prototype chain`); +} diff --git a/test/fixtures/wpt/streams/transform-streams/reentrant-strategies.any.js b/test/fixtures/wpt/streams/transform-streams/reentrant-strategies.any.js new file mode 100644 index 0000000..31e5394 --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/reentrant-strategies.any.js @@ -0,0 +1,319 @@ +// META: global=window,worker,jsshell +// META: script=../resources/recording-streams.js +// META: script=../resources/rs-utils.js +// META: script=../resources/test-utils.js +'use strict'; + +// The size() function of readableStrategy can re-entrantly call back into the TransformStream implementation. This +// makes it risky to cache state across the call to ReadableStreamDefaultControllerEnqueue. These tests attempt to catch +// such errors. They are separated from the other strategy tests because no real user code should ever do anything like +// this. +// +// There is no such issue with writableStrategy size() because it is never called from within TransformStream +// algorithms. + +const error1 = new Error('error1'); +error1.name = 'error1'; + +promise_test(() => { + let controller; + let calls = 0; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }, undefined, { + size() { + ++calls; + if (calls < 2) { + controller.enqueue('b'); + } + return 1; + }, + highWaterMark: Infinity + }); + const writer = ts.writable.getWriter(); + return Promise.all([writer.write('a'), writer.close()]) + .then(() => readableStreamToArray(ts.readable)) + .then(array => assert_array_equals(array, ['b', 'a'], 'array should contain two chunks')); +}, 'enqueue() inside size() should work'); + +promise_test(() => { + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }, undefined, { + size() { + // The readable queue is empty. + controller.terminate(); + // The readable state has gone from "readable" to "closed". + return 1; + // This chunk will be enqueued, but will be impossible to read because the state is already "closed". + }, + highWaterMark: Infinity + }); + const writer = ts.writable.getWriter(); + return writer.write('a') + .then(() => readableStreamToArray(ts.readable)) + .then(array => assert_array_equals(array, [], 'array should contain no chunks')); + // The chunk 'a' is still in readable's queue. readable is closed so 'a' cannot be read. writable's queue is empty and + // it is still writable. +}, 'terminate() inside size() should work'); + +promise_test(t => { + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }, undefined, { + size() { + controller.error(error1); + return 1; + }, + highWaterMark: Infinity + }); + const writer = ts.writable.getWriter(); + return writer.write('a') + .then(() => promise_rejects_exactly(t, error1, ts.readable.getReader().read(), 'read() should reject')); +}, 'error() inside size() should work'); + +promise_test(() => { + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }, undefined, { + size() { + assert_equals(controller.desiredSize, 1, 'desiredSize should be 1'); + return 1; + }, + highWaterMark: 1 + }); + const writer = ts.writable.getWriter(); + return Promise.all([writer.write('a'), writer.close()]) + .then(() => readableStreamToArray(ts.readable)) + .then(array => assert_array_equals(array, ['a'], 'array should contain one chunk')); +}, 'desiredSize inside size() should work'); + +promise_test(t => { + let cancelPromise; + const ts = new TransformStream({}, undefined, { + size() { + cancelPromise = ts.readable.cancel(error1); + return 1; + }, + highWaterMark: Infinity + }); + const writer = ts.writable.getWriter(); + return writer.write('a') + .then(() => { + promise_rejects_exactly(t, error1, writer.closed, 'writer.closed should reject'); + return cancelPromise; + }); +}, 'readable cancel() inside size() should work'); + +promise_test(() => { + let controller; + let pipeToPromise; + const ws = recordingWritableStream(); + const ts = new TransformStream({ + start(c) { + controller = c; + } + }, undefined, { + size() { + if (!pipeToPromise) { + pipeToPromise = ts.readable.pipeTo(ws); + } + return 1; + }, + highWaterMark: 1 + }); + // Allow promise returned by start() to resolve so that enqueue() will happen synchronously. + return delay(0).then(() => { + controller.enqueue('a'); + assert_not_equals(pipeToPromise, undefined); + + // Some pipeTo() implementations need an additional chunk enqueued in order for the first one to be processed. See + // https://github.com/whatwg/streams/issues/794 for background. + controller.enqueue('a'); + + // Give pipeTo() a chance to process the queued chunks. + return delay(0); + }).then(() => { + assert_array_equals(ws.events, ['write', 'a', 'write', 'a'], 'ws should contain two chunks'); + controller.terminate(); + return pipeToPromise; + }).then(() => { + assert_array_equals(ws.events, ['write', 'a', 'write', 'a', 'close'], 'target should have been closed'); + }); +}, 'pipeTo() inside size() should work'); + +promise_test(() => { + let controller; + let readPromise; + let calls = 0; + let reader; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }, undefined, { + size() { + // This is triggered by controller.enqueue(). The queue is empty and there are no pending reads. pull() is called + // synchronously, allowing transform() to proceed asynchronously. This results in a second call to enqueue(), + // which resolves this pending read() without calling size() again. + readPromise = reader.read(); + ++calls; + return 1; + }, + highWaterMark: 0 + }); + reader = ts.readable.getReader(); + const writer = ts.writable.getWriter(); + let writeResolved = false; + const writePromise = writer.write('b').then(() => { + writeResolved = true; + }); + return flushAsyncEvents().then(() => { + assert_false(writeResolved); + controller.enqueue('a'); + assert_equals(calls, 1, 'size() should have been called once'); + return delay(0); + }).then(() => { + assert_true(writeResolved); + assert_equals(calls, 1, 'size() should only be called once'); + return readPromise; + }).then(({ value, done }) => { + assert_false(done, 'done should be false'); + // See https://github.com/whatwg/streams/issues/794 for why this chunk is not 'a'. + assert_equals(value, 'b', 'chunk should have been read'); + assert_equals(calls, 1, 'calls should still be 1'); + return writePromise; + }); +}, 'read() inside of size() should work'); + +promise_test(() => { + let writer; + let writePromise1; + let calls = 0; + const ts = new TransformStream({}, undefined, { + size() { + ++calls; + if (calls < 2) { + writePromise1 = writer.write('a'); + } + return 1; + }, + highWaterMark: Infinity + }); + writer = ts.writable.getWriter(); + // Give pull() a chance to be called. + return delay(0).then(() => { + // This write results in a synchronous call to transform(), enqueue(), and size(). + const writePromise2 = writer.write('b'); + assert_equals(calls, 1, 'size() should have been called once'); + return Promise.all([writePromise1, writePromise2, writer.close()]); + }).then(() => { + assert_equals(calls, 2, 'size() should have been called twice'); + return readableStreamToArray(ts.readable); + }).then(array => { + assert_array_equals(array, ['b', 'a'], 'both chunks should have been enqueued'); + assert_equals(calls, 2, 'calls should still be 2'); + }); +}, 'writer.write() inside size() should work'); + +promise_test(() => { + let controller; + let writer; + let writePromise; + let calls = 0; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }, undefined, { + size() { + ++calls; + if (calls < 2) { + writePromise = writer.write('a'); + } + return 1; + }, + highWaterMark: Infinity + }); + writer = ts.writable.getWriter(); + // Give pull() a chance to be called. + return delay(0).then(() => { + // This enqueue results in synchronous calls to size(), write(), transform() and enqueue(). + controller.enqueue('b'); + assert_equals(calls, 2, 'size() should have been called twice'); + return Promise.all([writePromise, writer.close()]); + }).then(() => { + return readableStreamToArray(ts.readable); + }).then(array => { + // Because one call to enqueue() is nested inside the other, they finish in the opposite order that they were + // called, so the chunks end up reverse order. + assert_array_equals(array, ['a', 'b'], 'both chunks should have been enqueued'); + assert_equals(calls, 2, 'calls should still be 2'); + }); +}, 'synchronous writer.write() inside size() should work'); + +promise_test(() => { + let writer; + let closePromise; + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }, undefined, { + size() { + closePromise = writer.close(); + return 1; + }, + highWaterMark: 1 + }); + writer = ts.writable.getWriter(); + const reader = ts.readable.getReader(); + // Wait for the promise returned by start() to be resolved so that the call to close() will result in a synchronous + // call to TransformStreamDefaultSink. + return delay(0).then(() => { + controller.enqueue('a'); + return reader.read(); + }).then(({ value, done }) => { + assert_false(done, 'done should be false'); + assert_equals(value, 'a', 'value should be correct'); + return reader.read(); + }).then(({ done }) => { + assert_true(done, 'done should be true'); + return closePromise; + }); +}, 'writer.close() inside size() should work'); + +promise_test(t => { + let abortPromise; + let controller; + const ts = new TransformStream({ + start(c) { + controller = c; + } + }, undefined, { + size() { + abortPromise = ts.writable.abort(error1); + return 1; + }, + highWaterMark: 1 + }); + const reader = ts.readable.getReader(); + // Wait for the promise returned by start() to be resolved so that the call to abort() will result in a synchronous + // call to TransformStreamDefaultSink. + return delay(0).then(() => { + controller.enqueue('a'); + return Promise.all([promise_rejects_exactly(t, error1, reader.read(), 'read() should reject'), abortPromise]); + }); +}, 'writer.abort() inside size() should work'); diff --git a/test/fixtures/wpt/streams/transform-streams/strategies.any.js b/test/fixtures/wpt/streams/transform-streams/strategies.any.js new file mode 100644 index 0000000..d465d31 --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/strategies.any.js @@ -0,0 +1,150 @@ +// META: global=window,worker,jsshell +// META: script=../resources/recording-streams.js +// META: script=../resources/test-utils.js +'use strict'; + +// Here we just test that the strategies are correctly passed to the readable and writable sides. We assume that +// ReadableStream and WritableStream will correctly apply the strategies when they are being used by a TransformStream +// and so it isn't necessary to repeat their tests here. + +test(() => { + const ts = new TransformStream({}, { highWaterMark: 17 }); + assert_equals(ts.writable.getWriter().desiredSize, 17, 'desiredSize should be 17'); +}, 'writableStrategy highWaterMark should work'); + +promise_test(() => { + const ts = recordingTransformStream({}, undefined, { highWaterMark: 9 }); + const writer = ts.writable.getWriter(); + for (let i = 0; i < 10; ++i) { + writer.write(i); + } + return delay(0).then(() => { + assert_array_equals(ts.events, [ + 'transform', 0, 'transform', 1, 'transform', 2, 'transform', 3, 'transform', 4, + 'transform', 5, 'transform', 6, 'transform', 7, 'transform', 8], + 'transform() should have been called 9 times'); + }); +}, 'readableStrategy highWaterMark should work'); + +promise_test(t => { + let writableSizeCalled = false; + let readableSizeCalled = false; + let transformCalled = false; + const ts = new TransformStream( + { + transform(chunk, controller) { + t.step(() => { + transformCalled = true; + assert_true(writableSizeCalled, 'writableStrategy.size() should have been called'); + assert_false(readableSizeCalled, 'readableStrategy.size() should not have been called'); + controller.enqueue(chunk); + assert_true(readableSizeCalled, 'readableStrategy.size() should have been called'); + }); + } + }, + { + size() { + writableSizeCalled = true; + return 1; + } + }, + { + size() { + readableSizeCalled = true; + return 1; + }, + highWaterMark: Infinity + }); + return ts.writable.getWriter().write().then(() => { + assert_true(transformCalled, 'transform() should be called'); + }); +}, 'writable should have the correct size() function'); + +test(() => { + const ts = new TransformStream(); + const writer = ts.writable.getWriter(); + assert_equals(writer.desiredSize, 1, 'default writable HWM is 1'); + writer.write(undefined); + assert_equals(writer.desiredSize, 0, 'default chunk size is 1'); +}, 'default writable strategy should be equivalent to { highWaterMark: 1 }'); + +promise_test(t => { + const ts = new TransformStream({ + transform(chunk, controller) { + return t.step(() => { + assert_equals(controller.desiredSize, 0, 'desiredSize should be 0'); + controller.enqueue(undefined); + // The first chunk enqueued is consumed by the pending read(). + assert_equals(controller.desiredSize, 0, 'desiredSize should still be 0'); + controller.enqueue(undefined); + assert_equals(controller.desiredSize, -1, 'desiredSize should be -1'); + }); + } + }); + const writePromise = ts.writable.getWriter().write(); + return ts.readable.getReader().read().then(() => writePromise); +}, 'default readable strategy should be equivalent to { highWaterMark: 0 }'); + +test(() => { + assert_throws_js(RangeError, () => new TransformStream(undefined, { highWaterMark: -1 }), + 'should throw RangeError for negative writableHighWaterMark'); + assert_throws_js(RangeError, () => new TransformStream(undefined, undefined, { highWaterMark: -1 }), + 'should throw RangeError for negative readableHighWaterMark'); + assert_throws_js(RangeError, () => new TransformStream(undefined, { highWaterMark: NaN }), + 'should throw RangeError for NaN writableHighWaterMark'); + assert_throws_js(RangeError, () => new TransformStream(undefined, undefined, { highWaterMark: NaN }), + 'should throw RangeError for NaN readableHighWaterMark'); +}, 'a RangeError should be thrown for an invalid highWaterMark'); + +const objectThatConvertsTo42 = { + toString() { + return '42'; + } +}; + +test(() => { + const ts = new TransformStream(undefined, { highWaterMark: objectThatConvertsTo42 }); + const writer = ts.writable.getWriter(); + assert_equals(writer.desiredSize, 42, 'writable HWM is 42'); +}, 'writableStrategy highWaterMark should be converted to a number'); + +test(() => { + const ts = new TransformStream({ + start(controller) { + assert_equals(controller.desiredSize, 42, 'desiredSize should be 42'); + } + }, undefined, { highWaterMark: objectThatConvertsTo42 }); +}, 'readableStrategy highWaterMark should be converted to a number'); + +promise_test(t => { + const ts = new TransformStream(undefined, undefined, { + size() { return NaN; }, + highWaterMark: 1 + }); + const writer = ts.writable.getWriter(); + return promise_rejects_js(t, RangeError, writer.write(), 'write should reject'); +}, 'a bad readableStrategy size function should cause writer.write() to reject on an identity transform'); + +promise_test(t => { + const ts = new TransformStream({ + transform(chunk, controller) { + // This assert has the important side-effect of catching the error, so transform() does not throw. + assert_throws_js(RangeError, () => controller.enqueue(chunk), 'enqueue should throw'); + } + }, undefined, { + size() { + return -1; + }, + highWaterMark: 1 + }); + + const writer = ts.writable.getWriter(); + return writer.write().then(() => { + return Promise.all([ + promise_rejects_js(t, RangeError, writer.ready, 'ready should reject'), + promise_rejects_js(t, RangeError, writer.closed, 'closed should reject'), + promise_rejects_js(t, RangeError, ts.readable.getReader().closed, 'readable closed should reject') + ]); + }); +}, 'a bad readableStrategy size function should error the stream on enqueue even when transformer.transform() ' + + 'catches the exception'); diff --git a/test/fixtures/wpt/streams/transform-streams/terminate.any.js b/test/fixtures/wpt/streams/transform-streams/terminate.any.js new file mode 100644 index 0000000..8cb1067 --- /dev/null +++ b/test/fixtures/wpt/streams/transform-streams/terminate.any.js @@ -0,0 +1,100 @@ +// META: global=window,worker,jsshell +// META: script=../resources/recording-streams.js +// META: script=../resources/test-utils.js +'use strict'; + +promise_test(t => { + const ts = recordingTransformStream({}, undefined, { highWaterMark: 0 }); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(0); + } + }); + let pipeToRejected = false; + const pipeToPromise = promise_rejects_js(t, TypeError, rs.pipeTo(ts.writable), 'pipeTo should reject').then(() => { + pipeToRejected = true; + }); + return delay(0).then(() => { + assert_array_equals(ts.events, [], 'transform() should have seen no chunks'); + assert_false(pipeToRejected, 'pipeTo() should not have rejected yet'); + ts.controller.terminate(); + return pipeToPromise; + }).then(() => { + assert_array_equals(ts.events, [], 'transform() should still have seen no chunks'); + assert_true(pipeToRejected, 'pipeToRejected must be true'); + }); +}, 'controller.terminate() should error pipeTo()'); + +promise_test(t => { + const ts = recordingTransformStream({}, undefined, { highWaterMark: 1 }); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(0); + controller.enqueue(1); + } + }); + const pipeToPromise = rs.pipeTo(ts.writable); + return delay(0).then(() => { + assert_array_equals(ts.events, ['transform', 0], 'transform() should have seen one chunk'); + ts.controller.terminate(); + return promise_rejects_js(t, TypeError, pipeToPromise, 'pipeTo() should reject'); + }).then(() => { + assert_array_equals(ts.events, ['transform', 0], 'transform() should still have seen only one chunk'); + }); +}, 'controller.terminate() should prevent remaining chunks from being processed'); + +test(() => { + new TransformStream({ + start(controller) { + controller.enqueue(0); + controller.terminate(); + assert_throws_js(TypeError, () => controller.enqueue(1), 'enqueue should throw'); + } + }); +}, 'controller.enqueue() should throw after controller.terminate()'); + +const error1 = new Error('error1'); +error1.name = 'error1'; + +promise_test(t => { + const ts = new TransformStream({ + start(controller) { + controller.enqueue(0); + controller.terminate(); + controller.error(error1); + } + }); + return Promise.all([ + promise_rejects_js(t, TypeError, ts.writable.abort(), 'abort() should reject with a TypeError'), + promise_rejects_exactly(t, error1, ts.readable.cancel(), 'cancel() should reject with error1'), + promise_rejects_exactly(t, error1, ts.readable.getReader().closed, 'closed should reject with error1') + ]); +}, 'controller.error() after controller.terminate() with queued chunk should error the readable'); + +promise_test(t => { + const ts = new TransformStream({ + start(controller) { + controller.terminate(); + controller.error(error1); + } + }); + return Promise.all([ + promise_rejects_js(t, TypeError, ts.writable.abort(), 'abort() should reject with a TypeError'), + ts.readable.cancel(), + ts.readable.getReader().closed + ]); +}, 'controller.error() after controller.terminate() without queued chunk should do nothing'); + +promise_test(() => { + const ts = new TransformStream({ + flush(controller) { + controller.terminate(); + } + }); + const writer = ts.writable.getWriter(); + return Promise.all([ + writer.close(), + writer.closed, + ts.readable.getReader().closed + ]); +}, 'controller.terminate() inside flush() should not prevent writer.close() from succeeding'); diff --git a/test/fixtures/wpt/streams/writable-streams/aborting.any.js b/test/fixtures/wpt/streams/writable-streams/aborting.any.js new file mode 100644 index 0000000..5c053ba --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/aborting.any.js @@ -0,0 +1,1378 @@ +// META: global=window,worker,jsshell +// META: script=../resources/test-utils.js +// META: script=../resources/recording-streams.js +'use strict'; + +const error1 = new Error('error1'); +error1.name = 'error1'; + +const error2 = new Error('error2'); +error2.name = 'error2'; + +promise_test(t => { + const ws = new WritableStream({ + write: t.unreached_func('write() should not be called') + }); + + const writer = ws.getWriter(); + const writePromise = writer.write('a'); + + const readyPromise = writer.ready; + + writer.abort(error1); + + assert_equals(writer.ready, readyPromise, 'the ready promise property should not change'); + + return Promise.all([ + promise_rejects_exactly(t, error1, readyPromise, 'the ready promise should reject with error1'), + promise_rejects_exactly(t, error1, writePromise, 'the write() promise should reject with error1') + ]); +}, 'Aborting a WritableStream before it starts should cause the writer\'s unsettled ready promise to reject'); + +promise_test(t => { + const ws = new WritableStream(); + + const writer = ws.getWriter(); + writer.write('a'); + + const readyPromise = writer.ready; + + return readyPromise.then(() => { + writer.abort(error1); + + assert_not_equals(writer.ready, readyPromise, 'the ready promise property should change'); + return promise_rejects_exactly(t, error1, writer.ready, 'the ready promise should reject with error1'); + }); +}, 'Aborting a WritableStream should cause the writer\'s fulfilled ready promise to reset to a rejected one'); + +promise_test(t => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + + writer.releaseLock(); + + return promise_rejects_js(t, TypeError, writer.abort(), 'abort() should reject with a TypeError'); +}, 'abort() on a released writer rejects'); + +promise_test(t => { + const ws = recordingWritableStream(); + + return delay(0) + .then(() => { + const writer = ws.getWriter(); + + const abortPromise = writer.abort(error1); + + return Promise.all([ + promise_rejects_exactly(t, error1, writer.write(1), 'write(1) must reject with error1'), + promise_rejects_exactly(t, error1, writer.write(2), 'write(2) must reject with error1'), + abortPromise + ]); + }) + .then(() => { + assert_array_equals(ws.events, ['abort', error1]); + }); +}, 'Aborting a WritableStream immediately prevents future writes'); + +promise_test(t => { + const ws = recordingWritableStream(); + const results = []; + + return delay(0) + .then(() => { + const writer = ws.getWriter(); + + results.push( + writer.write(1), + promise_rejects_exactly(t, error1, writer.write(2), 'write(2) must reject with error1'), + promise_rejects_exactly(t, error1, writer.write(3), 'write(3) must reject with error1') + ); + + const abortPromise = writer.abort(error1); + + results.push( + promise_rejects_exactly(t, error1, writer.write(4), 'write(4) must reject with error1'), + promise_rejects_exactly(t, error1, writer.write(5), 'write(5) must reject with error1') + ); + + return abortPromise; + }).then(() => { + assert_array_equals(ws.events, ['write', 1, 'abort', error1]); + + return Promise.all(results); + }); +}, 'Aborting a WritableStream prevents further writes after any that are in progress'); + +promise_test(() => { + const ws = new WritableStream({ + abort() { + return 'Hello'; + } + }); + const writer = ws.getWriter(); + + return writer.abort('a').then(value => { + assert_equals(value, undefined, 'fulfillment value must be undefined'); + }); +}, 'Fulfillment value of writer.abort() call must be undefined even if the underlying sink returns a non-undefined ' + + 'value'); + +promise_test(t => { + const ws = new WritableStream({ + abort() { + throw error1; + } + }); + const writer = ws.getWriter(); + + return promise_rejects_exactly(t, error1, writer.abort(undefined), + 'rejection reason of abortPromise must be the error thrown by abort'); +}, 'WritableStream if sink\'s abort throws, the promise returned by writer.abort() rejects'); + +promise_test(t => { + const ws = new WritableStream({ + abort() { + throw error1; + } + }); + const writer = ws.getWriter(); + + const abortPromise1 = writer.abort(undefined); + const abortPromise2 = writer.abort(undefined); + + assert_equals(abortPromise1, abortPromise2, 'the promises must be the same'); + + return promise_rejects_exactly(t, error1, abortPromise1, 'promise must have matching rejection'); +}, 'WritableStream if sink\'s abort throws, the promise returned by multiple writer.abort()s is the same and rejects'); + +promise_test(t => { + const ws = new WritableStream({ + abort() { + throw error1; + } + }); + + return promise_rejects_exactly(t, error1, ws.abort(undefined), + 'rejection reason of abortPromise must be the error thrown by abort'); +}, 'WritableStream if sink\'s abort throws, the promise returned by ws.abort() rejects'); + +promise_test(t => { + let resolveWritePromise; + const ws = new WritableStream({ + write() { + return new Promise(resolve => { + resolveWritePromise = resolve; + }); + }, + abort() { + throw error1; + } + }); + + const writer = ws.getWriter(); + + writer.write().catch(() => {}); + return flushAsyncEvents().then(() => { + const abortPromise = writer.abort(undefined); + + resolveWritePromise(); + return promise_rejects_exactly(t, error1, abortPromise, + 'rejection reason of abortPromise must be the error thrown by abort'); + }); +}, 'WritableStream if sink\'s abort throws, for an abort performed during a write, the promise returned by ' + + 'ws.abort() rejects'); + +promise_test(() => { + const ws = recordingWritableStream(); + const writer = ws.getWriter(); + + return writer.abort(error1).then(() => { + assert_array_equals(ws.events, ['abort', error1]); + }); +}, 'Aborting a WritableStream passes through the given reason'); + +promise_test(t => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + + const abortPromise = writer.abort(error1); + + const events = []; + writer.ready.catch(() => { + events.push('ready'); + }); + writer.closed.catch(() => { + events.push('closed'); + }); + + return Promise.all([ + abortPromise, + promise_rejects_exactly(t, error1, writer.write(), 'writing should reject with error1'), + promise_rejects_exactly(t, error1, writer.close(), 'closing should reject with error1'), + promise_rejects_exactly(t, error1, writer.ready, 'ready should reject with error1'), + promise_rejects_exactly(t, error1, writer.closed, 'closed should reject with error1') + ]).then(() => { + assert_array_equals(['ready', 'closed'], events, 'ready should reject before closed'); + }); +}, 'Aborting a WritableStream puts it in an errored state with the error passed to abort()'); + +promise_test(t => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + + const writePromise = promise_rejects_exactly(t, error1, writer.write('a'), + 'writing should reject with error1'); + + writer.abort(error1); + + return writePromise; +}, 'Aborting a WritableStream causes any outstanding write() promises to be rejected with the reason supplied'); + +promise_test(t => { + const ws = recordingWritableStream(); + const writer = ws.getWriter(); + + const closePromise = writer.close(); + const abortPromise = writer.abort(error1); + + return Promise.all([ + promise_rejects_exactly(t, error1, writer.closed, 'closed should reject with error1'), + promise_rejects_exactly(t, error1, closePromise, 'close() should reject with error1'), + abortPromise + ]).then(() => { + assert_array_equals(ws.events, ['abort', error1]); + }); +}, 'Closing but then immediately aborting a WritableStream causes the stream to error'); + +promise_test(() => { + let resolveClose; + const ws = new WritableStream({ + close() { + return new Promise(resolve => { + resolveClose = resolve; + }); + } + }); + const writer = ws.getWriter(); + + const closePromise = writer.close(); + + return delay(0).then(() => { + const abortPromise = writer.abort(error1); + resolveClose(); + return Promise.all([ + writer.closed, + abortPromise, + closePromise + ]); + }); +}, 'Closing a WritableStream and aborting it while it closes causes the stream to ignore the abort attempt'); + +promise_test(() => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + + writer.close(); + + return delay(0).then(() => writer.abort()); +}, 'Aborting a WritableStream after it is closed is a no-op'); + +promise_test(t => { + // Testing that per https://github.com/whatwg/streams/issues/620#issuecomment-263483953 the fallback to close was + // removed. + + // Cannot use recordingWritableStream since it always has an abort + let closeCalled = false; + const ws = new WritableStream({ + close() { + closeCalled = true; + } + }); + + const writer = ws.getWriter(); + + writer.abort(error1); + + return promise_rejects_exactly(t, error1, writer.closed, 'closed should reject with error1').then(() => { + assert_false(closeCalled, 'close must not have been called'); + }); +}, 'WritableStream should NOT call underlying sink\'s close if no abort is supplied (historical)'); + +promise_test(() => { + let thenCalled = false; + const ws = new WritableStream({ + abort() { + return { + then(onFulfilled) { + thenCalled = true; + onFulfilled(); + } + }; + } + }); + const writer = ws.getWriter(); + return writer.abort().then(() => assert_true(thenCalled, 'then() should be called')); +}, 'returning a thenable from abort() should work'); + +promise_test(t => { + const ws = new WritableStream({ + write() { + return flushAsyncEvents(); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const writePromise = writer.write('a'); + writer.abort(error1); + let closedRejected = false; + return Promise.all([ + writePromise.then(() => assert_false(closedRejected, '.closed should not resolve before write()')), + promise_rejects_exactly(t, error1, writer.closed, '.closed should reject').then(() => { + closedRejected = true; + }) + ]); + }); +}, '.closed should not resolve before fulfilled write()'); + +promise_test(t => { + const ws = new WritableStream({ + write() { + return Promise.reject(error1); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const writePromise = writer.write('a'); + const abortPromise = writer.abort(error2); + let closedRejected = false; + return Promise.all([ + promise_rejects_exactly(t, error1, writePromise, 'write() should reject') + .then(() => assert_false(closedRejected, '.closed should not resolve before write()')), + promise_rejects_exactly(t, error2, writer.closed, '.closed should reject') + .then(() => { + closedRejected = true; + }), + abortPromise + ]); + }); +}, '.closed should not resolve before rejected write(); write() error should not overwrite abort() error'); + +promise_test(t => { + const ws = new WritableStream({ + write() { + return flushAsyncEvents(); + } + }, new CountQueuingStrategy({ highWaterMark: 4 })); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const settlementOrder = []; + return Promise.all([ + writer.write('1').then(() => settlementOrder.push(1)), + promise_rejects_exactly(t, error1, writer.write('2'), 'first queued write should be rejected') + .then(() => settlementOrder.push(2)), + promise_rejects_exactly(t, error1, writer.write('3'), 'second queued write should be rejected') + .then(() => settlementOrder.push(3)), + writer.abort(error1) + ]).then(() => assert_array_equals([1, 2, 3], settlementOrder, 'writes should be satisfied in order')); + }); +}, 'writes should be satisfied in order when aborting'); + +promise_test(t => { + const ws = new WritableStream({ + write() { + return Promise.reject(error1); + } + }, new CountQueuingStrategy({ highWaterMark: 4 })); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const settlementOrder = []; + return Promise.all([ + promise_rejects_exactly(t, error1, writer.write('1'), 'in-flight write should be rejected') + .then(() => settlementOrder.push(1)), + promise_rejects_exactly(t, error2, writer.write('2'), 'first queued write should be rejected') + .then(() => settlementOrder.push(2)), + promise_rejects_exactly(t, error2, writer.write('3'), 'second queued write should be rejected') + .then(() => settlementOrder.push(3)), + writer.abort(error2) + ]).then(() => assert_array_equals([1, 2, 3], settlementOrder, 'writes should be satisfied in order')); + }); +}, 'writes should be satisfied in order after rejected write when aborting'); + +promise_test(t => { + const ws = new WritableStream({ + write() { + return Promise.reject(error1); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + return Promise.all([ + promise_rejects_exactly(t, error1, writer.write('a'), 'writer.write() should reject with error from underlying write()'), + promise_rejects_exactly(t, error2, writer.close(), + 'writer.close() should reject with error from underlying write()'), + writer.abort(error2) + ]); + }); +}, 'close() should reject with abort reason why abort() is first error'); + +promise_test(() => { + let resolveWrite; + const ws = recordingWritableStream({ + write() { + return new Promise(resolve => { + resolveWrite = resolve; + }); + } + }); + + const writer = ws.getWriter(); + return writer.ready.then(() => { + writer.write('a'); + const abortPromise = writer.abort('b'); + return flushAsyncEvents().then(() => { + assert_array_equals(ws.events, ['write', 'a'], 'abort should not be called while write is in-flight'); + resolveWrite(); + return abortPromise.then(() => { + assert_array_equals(ws.events, ['write', 'a', 'abort', 'b'], 'abort should be called after the write finishes'); + }); + }); + }); +}, 'underlying abort() should not be called until underlying write() completes'); + +promise_test(() => { + let resolveClose; + const ws = recordingWritableStream({ + close() { + return new Promise(resolve => { + resolveClose = resolve; + }); + } + }); + + const writer = ws.getWriter(); + return writer.ready.then(() => { + writer.close(); + const abortPromise = writer.abort(); + return flushAsyncEvents().then(() => { + assert_array_equals(ws.events, ['close'], 'abort should not be called while close is in-flight'); + resolveClose(); + return abortPromise.then(() => { + assert_array_equals(ws.events, ['close'], 'abort should not be called'); + }); + }); + }); +}, 'underlying abort() should not be called if underlying close() has started'); + +promise_test(t => { + let rejectClose; + let abortCalled = false; + const ws = new WritableStream({ + close() { + return new Promise((resolve, reject) => { + rejectClose = reject; + }); + }, + abort() { + abortCalled = true; + } + }); + + const writer = ws.getWriter(); + return writer.ready.then(() => { + const closePromise = writer.close(); + const abortPromise = writer.abort(); + return flushAsyncEvents().then(() => { + assert_false(abortCalled, 'underlying abort should not be called while close is in-flight'); + rejectClose(error1); + return promise_rejects_exactly(t, error1, abortPromise, 'abort should reject with the same reason').then(() => { + return promise_rejects_exactly(t, error1, closePromise, 'close should reject with the same reason'); + }).then(() => { + assert_false(abortCalled, 'underlying abort should not be called after close completes'); + }); + }); + }); +}, 'if underlying close() has started and then rejects, the abort() and close() promises should reject with the ' + + 'underlying close rejection reason'); + +promise_test(t => { + let resolveWrite; + const ws = recordingWritableStream({ + write() { + return new Promise(resolve => { + resolveWrite = resolve; + }); + } + }); + + const writer = ws.getWriter(); + return writer.ready.then(() => { + writer.write('a'); + const closePromise = writer.close(); + const abortPromise = writer.abort(error1); + + return flushAsyncEvents().then(() => { + assert_array_equals(ws.events, ['write', 'a'], 'abort should not be called while write is in-flight'); + resolveWrite(); + return abortPromise.then(() => { + assert_array_equals(ws.events, ['write', 'a', 'abort', error1], 'abort should be called after write completes'); + return promise_rejects_exactly(t, error1, closePromise, 'promise returned by close() should be rejected'); + }); + }); + }); +}, 'an abort() that happens during a write() should trigger the underlying abort() even with a close() queued'); + +promise_test(t => { + const ws = new WritableStream({ + write() { + return new Promise(() => {}); + } + }); + + const writer = ws.getWriter(); + return writer.ready.then(() => { + writer.write('a'); + writer.abort(error1); + writer.releaseLock(); + const writer2 = ws.getWriter(); + return promise_rejects_exactly(t, error1, writer2.ready, + 'ready of the second writer should be rejected with error1'); + }); +}, 'if a writer is created for a stream with a pending abort, its ready should be rejected with the abort error'); + +promise_test(() => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const closePromise = writer.close(); + const abortPromise = writer.abort(); + const events = []; + return Promise.all([ + closePromise.then(() => { events.push('close'); }), + abortPromise.then(() => { events.push('abort'); }) + ]).then(() => { + assert_array_equals(events, ['close', 'abort']); + }); + }); +}, 'writer close() promise should resolve before abort() promise'); + +promise_test(t => { + const ws = new WritableStream({ + write(chunk, controller) { + controller.error(error1); + return new Promise(() => {}); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + writer.write('a'); + return promise_rejects_exactly(t, error1, writer.ready, 'writer.ready should reject'); + }); +}, 'writer.ready should reject on controller error without waiting for underlying write'); + +promise_test(t => { + let rejectWrite; + const ws = new WritableStream({ + write() { + return new Promise((resolve, reject) => { + rejectWrite = reject; + }); + } + }); + + let writePromise; + let abortPromise; + + const events = []; + + const writer = ws.getWriter(); + + writer.closed.catch(() => { + events.push('closed'); + }); + + // Wait for ws to start + return flushAsyncEvents().then(() => { + writePromise = writer.write('a'); + writePromise.catch(() => { + events.push('writePromise'); + }); + + abortPromise = writer.abort(error1); + abortPromise.then(() => { + events.push('abortPromise'); + }); + + const writePromise2 = writer.write('a'); + + return Promise.all([ + promise_rejects_exactly(t, error1, writePromise2, 'writePromise2 must reject with the error from abort'), + promise_rejects_exactly(t, error1, writer.ready, 'writer.ready must reject with the error from abort'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals(events, [], 'writePromise, abortPromise and writer.closed must not be rejected yet'); + + rejectWrite(error2); + + return Promise.all([ + promise_rejects_exactly(t, error2, writePromise, + 'writePromise must reject with the error returned from the sink\'s write method'), + abortPromise, + promise_rejects_exactly(t, error1, writer.closed, + 'writer.closed must reject with the error from abort'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals(events, ['writePromise', 'abortPromise', 'closed'], + 'writePromise, abortPromise and writer.closed must settle'); + + const writePromise3 = writer.write('a'); + + return Promise.all([ + promise_rejects_exactly(t, error1, writePromise3, + 'writePromise3 must reject with the error from abort'), + promise_rejects_exactly(t, error1, writer.ready, + 'writer.ready must be still rejected with the error indicating abort') + ]); + }).then(() => { + writer.releaseLock(); + + return Promise.all([ + promise_rejects_js(t, TypeError, writer.ready, + 'writer.ready must be rejected with an error indicating release'), + promise_rejects_js(t, TypeError, writer.closed, + 'writer.closed must be rejected with an error indicating release') + ]); + }); +}, 'writer.abort() while there is an in-flight write, and then finish the write with rejection'); + +promise_test(t => { + let resolveWrite; + let controller; + const ws = new WritableStream({ + write(chunk, c) { + controller = c; + return new Promise(resolve => { + resolveWrite = resolve; + }); + } + }); + + let writePromise; + let abortPromise; + + const events = []; + + const writer = ws.getWriter(); + + writer.closed.catch(() => { + events.push('closed'); + }); + + // Wait for ws to start + return flushAsyncEvents().then(() => { + writePromise = writer.write('a'); + writePromise.then(() => { + events.push('writePromise'); + }); + + abortPromise = writer.abort(error1); + abortPromise.then(() => { + events.push('abortPromise'); + }); + + const writePromise2 = writer.write('a'); + + return Promise.all([ + promise_rejects_exactly(t, error1, writePromise2, 'writePromise2 must reject with the error from abort'), + promise_rejects_exactly(t, error1, writer.ready, 'writer.ready must reject with the error from abort'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals(events, [], 'writePromise, abortPromise and writer.closed must not be fulfilled/rejected yet'); + + // This error is too late to change anything. abort() has already changed the stream state to 'erroring'. + controller.error(error2); + + const writePromise3 = writer.write('a'); + + return Promise.all([ + promise_rejects_exactly(t, error1, writePromise3, + 'writePromise3 must reject with the error from abort'), + promise_rejects_exactly(t, error1, writer.ready, + 'writer.ready must be still rejected with the error indicating abort'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals( + events, [], + 'writePromise, abortPromise and writer.closed must not be fulfilled/rejected yet even after ' + + 'controller.error() call'); + + resolveWrite(); + + return Promise.all([ + writePromise, + abortPromise, + promise_rejects_exactly(t, error1, writer.closed, + 'writer.closed must reject with the error from abort'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals(events, ['writePromise', 'abortPromise', 'closed'], + 'writePromise, abortPromise and writer.closed must settle'); + + const writePromise4 = writer.write('a'); + + return Promise.all([ + writePromise, + promise_rejects_exactly(t, error1, writePromise4, + 'writePromise4 must reject with the error from abort'), + promise_rejects_exactly(t, error1, writer.ready, + 'writer.ready must be still rejected with the error indicating abort') + ]); + }).then(() => { + writer.releaseLock(); + + return Promise.all([ + promise_rejects_js(t, TypeError, writer.ready, + 'writer.ready must be rejected with an error indicating release'), + promise_rejects_js(t, TypeError, writer.closed, + 'writer.closed must be rejected with an error indicating release') + ]); + }); +}, 'writer.abort(), controller.error() while there is an in-flight write, and then finish the write'); + +promise_test(t => { + let resolveClose; + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + }, + close() { + return new Promise(resolve => { + resolveClose = resolve; + }); + } + }); + + let closePromise; + let abortPromise; + + const events = []; + + const writer = ws.getWriter(); + + writer.closed.then(() => { + events.push('closed'); + }); + + // Wait for ws to start + return flushAsyncEvents().then(() => { + closePromise = writer.close(); + closePromise.then(() => { + events.push('closePromise'); + }); + + abortPromise = writer.abort(error1); + abortPromise.then(() => { + events.push('abortPromise'); + }); + + return Promise.all([ + promise_rejects_js(t, TypeError, writer.close(), + 'writer.close() must reject with an error indicating already closing'), + promise_rejects_exactly(t, error1, writer.ready, 'writer.ready must reject with the error from abort'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals(events, [], 'closePromise, abortPromise and writer.closed must not be fulfilled/rejected yet'); + + controller.error(error2); + + return Promise.all([ + promise_rejects_js(t, TypeError, writer.close(), + 'writer.close() must reject with an error indicating already closing'), + promise_rejects_exactly(t, error1, writer.ready, + 'writer.ready must be still rejected with the error indicating abort'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals( + events, [], + 'closePromise, abortPromise and writer.closed must not be fulfilled/rejected yet even after ' + + 'controller.error() call'); + + resolveClose(); + + return Promise.all([ + closePromise, + abortPromise, + writer.closed, + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals(events, ['closePromise', 'abortPromise', 'closed'], + 'closedPromise, abortPromise and writer.closed must fulfill'); + + return Promise.all([ + promise_rejects_js(t, TypeError, writer.close(), + 'writer.close() must reject with an error indicating already closing'), + promise_rejects_exactly(t, error1, writer.ready, + 'writer.ready must be still rejected with the error indicating abort') + ]); + }).then(() => { + writer.releaseLock(); + + return Promise.all([ + promise_rejects_js(t, TypeError, writer.close(), + 'writer.close() must reject with an error indicating release'), + promise_rejects_js(t, TypeError, writer.ready, + 'writer.ready must be rejected with an error indicating release'), + promise_rejects_js(t, TypeError, writer.closed, + 'writer.closed must be rejected with an error indicating release') + ]); + }); +}, 'writer.abort(), controller.error() while there is an in-flight close, and then finish the close'); + +promise_test(t => { + let resolveWrite; + let controller; + const ws = recordingWritableStream({ + write(chunk, c) { + controller = c; + return new Promise(resolve => { + resolveWrite = resolve; + }); + } + }); + + let writePromise; + let abortPromise; + + const events = []; + + const writer = ws.getWriter(); + + writer.closed.catch(() => { + events.push('closed'); + }); + + // Wait for ws to start + return flushAsyncEvents().then(() => { + writePromise = writer.write('a'); + writePromise.then(() => { + events.push('writePromise'); + }); + + controller.error(error2); + + const writePromise2 = writer.write('a'); + + return Promise.all([ + promise_rejects_exactly(t, error2, writePromise2, + 'writePromise2 must reject with the error passed to the controller\'s error method'), + promise_rejects_exactly(t, error2, writer.ready, + 'writer.ready must reject with the error passed to the controller\'s error method'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals(events, [], 'writePromise and writer.closed must not be fulfilled/rejected yet'); + + abortPromise = writer.abort(error1); + abortPromise.catch(() => { + events.push('abortPromise'); + }); + + const writePromise3 = writer.write('a'); + + return Promise.all([ + promise_rejects_exactly(t, error2, writePromise3, + 'writePromise3 must reject with the error passed to the controller\'s error method'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals( + events, [], + 'writePromise and writer.closed must not be fulfilled/rejected yet even after writer.abort()'); + + resolveWrite(); + + return Promise.all([ + promise_rejects_exactly(t, error2, abortPromise, + 'abort() must reject with the error passed to the controller\'s error method'), + promise_rejects_exactly(t, error2, writer.closed, + 'writer.closed must reject with the error passed to the controller\'s error method'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals(events, ['writePromise', 'abortPromise', 'closed'], + 'writePromise, abortPromise and writer.closed must fulfill/reject'); + assert_array_equals(ws.events, ['write', 'a'], 'sink abort() should not be called'); + + const writePromise4 = writer.write('a'); + + return Promise.all([ + writePromise, + promise_rejects_exactly(t, error2, writePromise4, + 'writePromise4 must reject with the error passed to the controller\'s error method'), + promise_rejects_exactly(t, error2, writer.ready, + 'writer.ready must be still rejected with the error passed to the controller\'s error method') + ]); + }).then(() => { + writer.releaseLock(); + + return Promise.all([ + promise_rejects_js(t, TypeError, writer.ready, + 'writer.ready must be rejected with an error indicating release'), + promise_rejects_js(t, TypeError, writer.closed, + 'writer.closed must be rejected with an error indicating release') + ]); + }); +}, 'controller.error(), writer.abort() while there is an in-flight write, and then finish the write'); + +promise_test(t => { + let resolveClose; + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + }, + close() { + return new Promise(resolve => { + resolveClose = resolve; + }); + } + }); + + let closePromise; + let abortPromise; + + const events = []; + + const writer = ws.getWriter(); + + writer.closed.then(() => { + events.push('closed'); + }); + + // Wait for ws to start + return flushAsyncEvents().then(() => { + closePromise = writer.close(); + closePromise.then(() => { + events.push('closePromise'); + }); + + controller.error(error2); + + return flushAsyncEvents(); + }).then(() => { + assert_array_equals(events, [], 'closePromise must not be fulfilled/rejected yet'); + + abortPromise = writer.abort(error1); + abortPromise.then(() => { + events.push('abortPromise'); + }); + + return Promise.all([ + promise_rejects_exactly(t, error2, writer.ready, + 'writer.ready must reject with the error passed to the controller\'s error method'), + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals( + events, [], + 'closePromise and writer.closed must not be fulfilled/rejected yet even after writer.abort()'); + + resolveClose(); + + return Promise.all([ + closePromise, + promise_rejects_exactly(t, error2, writer.ready, + 'writer.ready must be still rejected with the error passed to the controller\'s error method'), + writer.closed, + flushAsyncEvents() + ]); + }).then(() => { + assert_array_equals(events, ['closePromise', 'abortPromise', 'closed'], + 'abortPromise, closePromise and writer.closed must fulfill/reject'); + }).then(() => { + writer.releaseLock(); + + return Promise.all([ + promise_rejects_js(t, TypeError, writer.ready, + 'writer.ready must be rejected with an error indicating release'), + promise_rejects_js(t, TypeError, writer.closed, + 'writer.closed must be rejected with an error indicating release') + ]); + }); +}, 'controller.error(), writer.abort() while there is an in-flight close, and then finish the close'); + +promise_test(t => { + let resolveWrite; + const ws = new WritableStream({ + write() { + return new Promise(resolve => { + resolveWrite = resolve; + }); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const writePromise = writer.write('a'); + const closed = writer.closed; + const abortPromise = writer.abort(); + writer.releaseLock(); + resolveWrite(); + return Promise.all([ + writePromise, + abortPromise, + promise_rejects_js(t, TypeError, closed, 'closed should reject')]); + }); +}, 'releaseLock() while aborting should reject the original closed promise'); + +// TODO(ricea): Consider removing this test if it is no longer useful. +promise_test(t => { + let resolveWrite; + let resolveAbort; + let resolveAbortStarted; + const abortStarted = new Promise(resolve => { + resolveAbortStarted = resolve; + }); + const ws = new WritableStream({ + write() { + return new Promise(resolve => { + resolveWrite = resolve; + }); + }, + abort() { + resolveAbortStarted(); + return new Promise(resolve => { + resolveAbort = resolve; + }); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const writePromise = writer.write('a'); + const closed = writer.closed; + const abortPromise = writer.abort(); + resolveWrite(); + return abortStarted.then(() => { + writer.releaseLock(); + assert_equals(writer.closed, closed, 'closed promise should not have changed'); + resolveAbort(); + return Promise.all([ + writePromise, + abortPromise, + promise_rejects_js(t, TypeError, closed, 'closed should reject')]); + }); + }); +}, 'releaseLock() during delayed async abort() should reject the writer.closed promise'); + +promise_test(() => { + let resolveStart; + const ws = recordingWritableStream({ + start() { + return new Promise(resolve => { + resolveStart = resolve; + }); + } + }); + const abortPromise = ws.abort('done'); + return flushAsyncEvents().then(() => { + assert_array_equals(ws.events, [], 'abort() should not be called during start()'); + resolveStart(); + return abortPromise.then(() => { + assert_array_equals(ws.events, ['abort', 'done'], 'abort() should be called after start() is done'); + }); + }); +}, 'sink abort() should not be called until sink start() is done'); + +promise_test(() => { + let resolveStart; + let controller; + const ws = recordingWritableStream({ + start(c) { + controller = c; + return new Promise(resolve => { + resolveStart = resolve; + }); + } + }); + const abortPromise = ws.abort('done'); + controller.error(error1); + resolveStart(); + return abortPromise.then(() => + assert_array_equals(ws.events, ['abort', 'done'], + 'abort() should still be called if start() errors the controller')); +}, 'if start attempts to error the controller after abort() has been called, then it should lose'); + +promise_test(() => { + const ws = recordingWritableStream({ + start() { + return Promise.reject(error1); + } + }); + return ws.abort('done').then(() => + assert_array_equals(ws.events, ['abort', 'done'], 'abort() should still be called if start() rejects')); +}, 'stream abort() promise should still resolve if sink start() rejects'); + +promise_test(t => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + const writerReady1 = writer.ready; + writer.abort(error1); + const writerReady2 = writer.ready; + assert_not_equals(writerReady1, writerReady2, 'abort() should replace the ready promise with a rejected one'); + return Promise.all([writerReady1, + promise_rejects_exactly(t, error1, writerReady2, 'writerReady2 should reject')]); +}, 'writer abort() during sink start() should replace the writer.ready promise synchronously'); + +promise_test(t => { + const events = []; + const ws = recordingWritableStream(); + const writer = ws.getWriter(); + const writePromise1 = writer.write(1); + const abortPromise = writer.abort(error1); + const writePromise2 = writer.write(2); + const closePromise = writer.close(); + writePromise1.catch(() => events.push('write1')); + abortPromise.then(() => events.push('abort')); + writePromise2.catch(() => events.push('write2')); + closePromise.catch(() => events.push('close')); + return Promise.all([ + promise_rejects_exactly(t, error1, writePromise1, 'first write() should reject'), + abortPromise, + promise_rejects_exactly(t, error1, writePromise2, 'second write() should reject'), + promise_rejects_exactly(t, error1, closePromise, 'close() should reject') + ]) + .then(() => { + assert_array_equals(events, ['write2', 'write1', 'abort', 'close'], + 'promises should resolve in the standard order'); + assert_array_equals(ws.events, ['abort', error1], 'underlying sink write() should not be called'); + }); +}, 'promises returned from other writer methods should be rejected when writer abort() happens during sink start()'); + +promise_test(t => { + let writeReject; + let controller; + const ws = new WritableStream({ + write(chunk, c) { + controller = c; + return new Promise((resolve, reject) => { + writeReject = reject; + }); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const writePromise = writer.write('a'); + const abortPromise = writer.abort(); + controller.error(error1); + writeReject(error2); + return Promise.all([ + promise_rejects_exactly(t, error2, writePromise, 'write() should reject with error2'), + abortPromise + ]); + }); +}, 'abort() should succeed despite rejection from write'); + +promise_test(t => { + let closeReject; + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + }, + close() { + return new Promise((resolve, reject) => { + closeReject = reject; + }); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const closePromise = writer.close(); + const abortPromise = writer.abort(); + controller.error(error1); + closeReject(error2); + return Promise.all([ + promise_rejects_exactly(t, error2, closePromise, 'close() should reject with error2'), + promise_rejects_exactly(t, error2, abortPromise, 'abort() should reject with error2') + ]); + }); +}, 'abort() should be rejected with the rejection returned from close()'); + +promise_test(t => { + let rejectWrite; + const ws = recordingWritableStream({ + write() { + return new Promise((resolve, reject) => { + rejectWrite = reject; + }); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const writePromise = writer.write('1'); + const abortPromise = writer.abort(error2); + rejectWrite(error1); + return Promise.all([ + promise_rejects_exactly(t, error1, writePromise, 'write should reject'), + abortPromise, + promise_rejects_exactly(t, error2, writer.closed, 'closed should reject with error2') + ]); + }).then(() => { + assert_array_equals(ws.events, ['write', '1', 'abort', error2], 'abort sink method should be called'); + }); +}, 'a rejecting sink.write() should not prevent sink.abort() from being called'); + +promise_test(() => { + const ws = recordingWritableStream({ + start() { + return Promise.reject(error1); + } + }); + return ws.abort(error2) + .then(() => { + assert_array_equals(ws.events, ['abort', error2]); + }); +}, 'when start errors after stream abort(), underlying sink abort() should be called anyway'); + +promise_test(() => { + const ws = new WritableStream(); + const abortPromise1 = ws.abort(); + const abortPromise2 = ws.abort(); + assert_equals(abortPromise1, abortPromise2, 'the promises must be the same'); + + return abortPromise1.then( + v => assert_equals(v, undefined, 'abort() should fulfill with undefined')); +}, 'when calling abort() twice on the same stream, both should give the same promise that fulfills with undefined'); + +promise_test(() => { + const ws = new WritableStream(); + const abortPromise1 = ws.abort(); + + return abortPromise1.then(v1 => { + assert_equals(v1, undefined, 'first abort() should fulfill with undefined'); + + const abortPromise2 = ws.abort(); + assert_not_equals(abortPromise2, abortPromise1, 'because we waited, the second promise should be a new promise'); + + return abortPromise2.then(v2 => { + assert_equals(v2, undefined, 'second abort() should fulfill with undefined'); + }); + }); +}, 'when calling abort() twice on the same stream, but sequentially so so there\'s no pending abort the second time, ' + + 'both should fulfill with undefined'); + +promise_test(t => { + const ws = new WritableStream({ + start(c) { + c.error(error1); + } + }); + + const writer = ws.getWriter(); + + return promise_rejects_exactly(t, error1, writer.closed, 'writer.closed should reject').then(() => { + return writer.abort().then( + v => assert_equals(v, undefined, 'abort() should fulfill with undefined')); + }); +}, 'calling abort() on an errored stream should fulfill with undefined'); + +promise_test(t => { + let controller; + let resolveWrite; + const ws = recordingWritableStream({ + start(c) { + controller = c; + }, + write() { + return new Promise(resolve => { + resolveWrite = resolve; + }); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const writePromise = writer.write('chunk'); + controller.error(error1); + const abortPromise = writer.abort(error2); + resolveWrite(); + return Promise.all([ + writePromise, + promise_rejects_exactly(t, error1, abortPromise, 'abort() should reject') + ]).then(() => { + assert_array_equals(ws.events, ['write', 'chunk'], 'sink abort() should not be called'); + }); + }); +}, 'sink abort() should not be called if stream was erroring due to controller.error() before abort() was called'); + +promise_test(t => { + let resolveWrite; + let size = 1; + const ws = recordingWritableStream({ + write() { + return new Promise(resolve => { + resolveWrite = resolve; + }); + } + }, { + size() { + return size; + }, + highWaterMark: 1 + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const writePromise1 = writer.write('chunk1'); + size = NaN; + const writePromise2 = writer.write('chunk2'); + const abortPromise = writer.abort(error2); + resolveWrite(); + return Promise.all([ + writePromise1, + promise_rejects_js(t, RangeError, writePromise2, 'second write() should reject'), + promise_rejects_js(t, RangeError, abortPromise, 'abort() should reject') + ]).then(() => { + assert_array_equals(ws.events, ['write', 'chunk1'], 'sink abort() should not be called'); + }); + }); +}, 'sink abort() should not be called if stream was erroring due to bad strategy before abort() was called'); + +promise_test(t => { + const ws = new WritableStream(); + return ws.abort().then(() => { + const writer = ws.getWriter(); + return writer.closed.then(t.unreached_func('closed promise should not fulfill'), + e => assert_equals(e, undefined, 'e should be undefined')); + }); +}, 'abort with no arguments should set the stored error to undefined'); + +promise_test(t => { + const ws = new WritableStream(); + return ws.abort(undefined).then(() => { + const writer = ws.getWriter(); + return writer.closed.then(t.unreached_func('closed promise should not fulfill'), + e => assert_equals(e, undefined, 'e should be undefined')); + }); +}, 'abort with an undefined argument should set the stored error to undefined'); + +promise_test(t => { + const ws = new WritableStream(); + return ws.abort('string argument').then(() => { + const writer = ws.getWriter(); + return writer.closed.then(t.unreached_func('closed promise should not fulfill'), + e => assert_equals(e, 'string argument', 'e should be \'string argument\'')); + }); +}, 'abort with a string argument should set the stored error to that argument'); + +promise_test(t => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + return promise_rejects_js(t, TypeError, ws.abort(), 'abort should reject') + .then(() => writer.ready); +}, 'abort on a locked stream should reject'); diff --git a/test/fixtures/wpt/streams/writable-streams/bad-strategies.any.js b/test/fixtures/wpt/streams/writable-streams/bad-strategies.any.js new file mode 100644 index 0000000..b180bae --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/bad-strategies.any.js @@ -0,0 +1,95 @@ +// META: global=window,worker,jsshell +'use strict'; + +const error1 = new Error('a unique string'); +error1.name = 'error1'; + +test(() => { + assert_throws_exactly(error1, () => { + new WritableStream({}, { + get size() { + throw error1; + }, + highWaterMark: 5 + }); + }, 'construction should re-throw the error'); +}, 'Writable stream: throwing strategy.size getter'); + +test(() => { + assert_throws_js(TypeError, () => { + new WritableStream({}, { size: 'a string' }); + }); +}, 'reject any non-function value for strategy.size'); + +test(() => { + assert_throws_exactly(error1, () => { + new WritableStream({}, { + size() { + return 1; + }, + get highWaterMark() { + throw error1; + } + }); + }, 'construction should re-throw the error'); +}, 'Writable stream: throwing strategy.highWaterMark getter'); + +test(() => { + + for (const highWaterMark of [-1, -Infinity, NaN, 'foo', {}]) { + assert_throws_js(RangeError, () => { + new WritableStream({}, { + size() { + return 1; + }, + highWaterMark + }); + }, `construction should throw a RangeError for ${highWaterMark}`); + } +}, 'Writable stream: invalid strategy.highWaterMark'); + +promise_test(t => { + const ws = new WritableStream({}, { + size() { + throw error1; + }, + highWaterMark: 5 + }); + + const writer = ws.getWriter(); + + const p1 = promise_rejects_exactly(t, error1, writer.write('a'), 'write should reject with the thrown error'); + + const p2 = promise_rejects_exactly(t, error1, writer.closed, 'closed should reject with the thrown error'); + + return Promise.all([p1, p2]); +}, 'Writable stream: throwing strategy.size method'); + +promise_test(() => { + const sizes = [NaN, -Infinity, Infinity, -1]; + return Promise.all(sizes.map(size => { + const ws = new WritableStream({}, { + size() { + return size; + }, + highWaterMark: 5 + }); + + const writer = ws.getWriter(); + + return writer.write('a').then(() => assert_unreached('write must reject'), writeE => { + assert_equals(writeE.name, 'RangeError', `write must reject with a RangeError for ${size}`); + + return writer.closed.then(() => assert_unreached('write must reject'), closedE => { + assert_equals(closedE, writeE, `closed should reject with the same error as write`); + }); + }); + })); +}, 'Writable stream: invalid strategy.size return value'); + +test(() => { + assert_throws_js(TypeError, () => new WritableStream(undefined, { + size: 'not a function', + highWaterMark: NaN + }), 'WritableStream constructor should throw a TypeError'); +}, 'Writable stream: invalid size beats invalid highWaterMark'); diff --git a/test/fixtures/wpt/streams/writable-streams/bad-underlying-sinks.any.js b/test/fixtures/wpt/streams/writable-streams/bad-underlying-sinks.any.js new file mode 100644 index 0000000..0bfc036 --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/bad-underlying-sinks.any.js @@ -0,0 +1,204 @@ +// META: global=window,worker,jsshell +// META: script=../resources/test-utils.js +// META: script=../resources/recording-streams.js +'use strict'; + +const error1 = new Error('error1'); +error1.name = 'error1'; + +test(() => { + assert_throws_exactly(error1, () => { + new WritableStream({ + get start() { + throw error1; + } + }); + }, 'constructor should throw same error as throwing start getter'); + + assert_throws_exactly(error1, () => { + new WritableStream({ + start() { + throw error1; + } + }); + }, 'constructor should throw same error as throwing start method'); + + assert_throws_js(TypeError, () => { + new WritableStream({ + start: 'not a function or undefined' + }); + }, 'constructor should throw TypeError when passed a non-function start property'); + + assert_throws_js(TypeError, () => { + new WritableStream({ + start: { apply() {} } + }); + }, 'constructor should throw TypeError when passed a non-function start property with an .apply method'); +}, 'start: errors in start cause WritableStream constructor to throw'); + +promise_test(t => { + + const ws = recordingWritableStream({ + close() { + throw error1; + } + }); + + const writer = ws.getWriter(); + + return promise_rejects_exactly(t, error1, writer.close(), 'close() promise must reject with the thrown error') + .then(() => promise_rejects_exactly(t, error1, writer.ready, 'ready promise must reject with the thrown error')) + .then(() => promise_rejects_exactly(t, error1, writer.closed, 'closed promise must reject with the thrown error')) + .then(() => { + assert_array_equals(ws.events, ['close']); + }); + +}, 'close: throwing method should cause writer close() and ready to reject'); + +promise_test(t => { + + const ws = recordingWritableStream({ + close() { + return Promise.reject(error1); + } + }); + + const writer = ws.getWriter(); + + return promise_rejects_exactly(t, error1, writer.close(), 'close() promise must reject with the same error') + .then(() => promise_rejects_exactly(t, error1, writer.ready, 'ready promise must reject with the same error')) + .then(() => assert_array_equals(ws.events, ['close'])); + +}, 'close: returning a rejected promise should cause writer close() and ready to reject'); + +test(() => { + assert_throws_exactly(error1, () => new WritableStream({ + get close() { + throw error1; + } + }), 'constructor should throw'); +}, 'close: throwing getter should cause constructor to throw'); + +test(() => { + assert_throws_exactly(error1, () => new WritableStream({ + get write() { + throw error1; + } + }), 'constructor should throw'); +}, 'write: throwing getter should cause write() and closed to reject'); + +promise_test(t => { + const ws = new WritableStream({ + write() { + throw error1; + } + }); + + const writer = ws.getWriter(); + + return promise_rejects_exactly(t, error1, writer.write('a'), 'write should reject with the thrown error') + .then(() => promise_rejects_exactly(t, error1, writer.closed, 'closed should reject with the thrown error')); +}, 'write: throwing method should cause write() and closed to reject'); + +promise_test(t => { + + let rejectSinkWritePromise; + const ws = recordingWritableStream({ + write() { + return new Promise((r, reject) => { + rejectSinkWritePromise = reject; + }); + } + }); + + return flushAsyncEvents().then(() => { + const writer = ws.getWriter(); + const writePromise = writer.write('a'); + rejectSinkWritePromise(error1); + + return Promise.all([ + promise_rejects_exactly(t, error1, writePromise, 'writer write must reject with the same error'), + promise_rejects_exactly(t, error1, writer.ready, 'ready promise must reject with the same error') + ]); + }) + .then(() => { + assert_array_equals(ws.events, ['write', 'a']); + }); + +}, 'write: returning a promise that becomes rejected after the writer write() should cause writer write() and ready ' + + 'to reject'); + +promise_test(t => { + + const ws = recordingWritableStream({ + write() { + if (ws.events.length === 2) { + return delay(0); + } + + return Promise.reject(error1); + } + }); + + const writer = ws.getWriter(); + + // Do not wait for this; we want to test the ready promise when the stream is "full" (desiredSize = 0), but if we wait + // then the stream will transition back to "empty" (desiredSize = 1) + writer.write('a'); + const readyPromise = writer.ready; + + return promise_rejects_exactly(t, error1, writer.write('b'), 'second write must reject with the same error').then(() => { + assert_equals(writer.ready, readyPromise, + 'the ready promise must not change, since the queue was full after the first write, so the pending one simply ' + + 'transitioned'); + return promise_rejects_exactly(t, error1, writer.ready, 'ready promise must reject with the same error'); + }) + .then(() => assert_array_equals(ws.events, ['write', 'a', 'write', 'b'])); + +}, 'write: returning a rejected promise (second write) should cause writer write() and ready to reject'); + +test(() => { + assert_throws_js(TypeError, () => new WritableStream({ + start: 'test' + }), 'constructor should throw'); +}, 'start: non-function start method'); + +test(() => { + assert_throws_js(TypeError, () => new WritableStream({ + write: 'test' + }), 'constructor should throw'); +}, 'write: non-function write method'); + +test(() => { + assert_throws_js(TypeError, () => new WritableStream({ + close: 'test' + }), 'constructor should throw'); +}, 'close: non-function close method'); + +test(() => { + assert_throws_js(TypeError, () => new WritableStream({ + abort: { apply() {} } + }), 'constructor should throw'); +}, 'abort: non-function abort method with .apply'); + +test(() => { + assert_throws_exactly(error1, () => new WritableStream({ + get abort() { + throw error1; + } + }), 'constructor should throw'); +}, 'abort: throwing getter should cause abort() and closed to reject'); + +promise_test(t => { + const abortReason = new Error('different string'); + const ws = new WritableStream({ + abort() { + throw error1; + } + }); + + const writer = ws.getWriter(); + + return promise_rejects_exactly(t, error1, writer.abort(abortReason), 'abort should reject with the thrown error') + .then(() => promise_rejects_exactly(t, abortReason, writer.closed, 'closed should reject with abortReason')); +}, 'abort: throwing method should cause abort() and closed to reject'); diff --git a/test/fixtures/wpt/streams/writable-streams/byte-length-queuing-strategy.any.js b/test/fixtures/wpt/streams/writable-streams/byte-length-queuing-strategy.any.js new file mode 100644 index 0000000..9a61dd7 --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/byte-length-queuing-strategy.any.js @@ -0,0 +1,28 @@ +// META: global=window,worker,jsshell +'use strict'; + +promise_test(t => { + let isDone = false; + const ws = new WritableStream( + { + write() { + return new Promise(resolve => { + t.step_timeout(() => { + isDone = true; + resolve(); + }, 200); + }); + }, + + close() { + assert_true(isDone, 'close is only called once the promise has been resolved'); + } + }, + new ByteLengthQueuingStrategy({ highWaterMark: 1024 * 16 }) + ); + + const writer = ws.getWriter(); + writer.write({ byteLength: 1024 }); + + return writer.close(); +}, 'Closing a writable stream with in-flight writes below the high water mark delays the close call properly'); diff --git a/test/fixtures/wpt/streams/writable-streams/close.any.js b/test/fixtures/wpt/streams/writable-streams/close.any.js new file mode 100644 index 0000000..cf997ed --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/close.any.js @@ -0,0 +1,470 @@ +// META: global=window,worker,jsshell +// META: script=../resources/test-utils.js +// META: script=../resources/recording-streams.js +'use strict'; + +const error1 = new Error('error1'); +error1.name = 'error1'; + +const error2 = new Error('error2'); +error2.name = 'error2'; + +promise_test(() => { + const ws = new WritableStream({ + close() { + return 'Hello'; + } + }); + + const writer = ws.getWriter(); + + const closePromise = writer.close(); + return closePromise.then(value => assert_equals(value, undefined, 'fulfillment value must be undefined')); +}, 'fulfillment value of writer.close() call must be undefined even if the underlying sink returns a non-undefined ' + + 'value'); + +promise_test(() => { + let controller; + let resolveClose; + const ws = new WritableStream({ + start(c) { + controller = c; + }, + close() { + return new Promise(resolve => { + resolveClose = resolve; + }); + } + }); + + const writer = ws.getWriter(); + + const closePromise = writer.close(); + return flushAsyncEvents().then(() => { + controller.error(error1); + return flushAsyncEvents(); + }).then(() => { + resolveClose(); + return Promise.all([ + closePromise, + writer.closed, + flushAsyncEvents().then(() => writer.closed)]); + }); +}, 'when sink calls error asynchronously while sink close is in-flight, the stream should not become errored'); + +promise_test(() => { + let controller; + const passedError = new Error('error me'); + const ws = new WritableStream({ + start(c) { + controller = c; + }, + close() { + controller.error(passedError); + } + }); + + const writer = ws.getWriter(); + + return writer.close().then(() => writer.closed); +}, 'when sink calls error synchronously while closing, the stream should not become errored'); + +promise_test(t => { + const ws = new WritableStream({ + close() { + throw error1; + } + }); + + const writer = ws.getWriter(); + + return Promise.all([ + writer.write('y'), + promise_rejects_exactly(t, error1, writer.close(), 'close() must reject with the error'), + promise_rejects_exactly(t, error1, writer.closed, 'closed must reject with the error') + ]); +}, 'when the sink throws during close, and the close is requested while a write is still in-flight, the stream should ' + + 'become errored during the close'); + +promise_test(() => { + const ws = new WritableStream({ + write(chunk, controller) { + controller.error(error1); + return new Promise(() => {}); + } + }); + + const writer = ws.getWriter(); + writer.write('a'); + + return delay(0).then(() => { + writer.releaseLock(); + }); +}, 'releaseLock on a stream with a pending write in which the stream has been errored'); + +promise_test(() => { + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + }, + close() { + controller.error(error1); + return new Promise(() => {}); + } + }); + + const writer = ws.getWriter(); + writer.close(); + + return delay(0).then(() => { + writer.releaseLock(); + }); +}, 'releaseLock on a stream with a pending close in which controller.error() was called'); + +promise_test(() => { + const ws = recordingWritableStream(); + + const writer = ws.getWriter(); + + return writer.ready.then(() => { + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1'); + + writer.close(); + assert_equals(writer.desiredSize, 1, 'desiredSize should be still 1'); + + return writer.ready.then(v => { + assert_equals(v, undefined, 'ready promise should be fulfilled with undefined'); + assert_array_equals(ws.events, ['close'], 'write and abort should not be called'); + }); + }); +}, 'when close is called on a WritableStream in writable state, ready should return a fulfilled promise'); + +promise_test(() => { + const ws = recordingWritableStream({ + write() { + return new Promise(() => {}); + } + }); + + const writer = ws.getWriter(); + + return writer.ready.then(() => { + writer.write('a'); + + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0'); + + let calledClose = false; + return Promise.all([ + writer.ready.then(v => { + assert_equals(v, undefined, 'ready promise should be fulfilled with undefined'); + assert_true(calledClose, 'ready should not be fulfilled before writer.close() is called'); + assert_array_equals(ws.events, ['write', 'a'], 'sink abort() should not be called'); + }), + flushAsyncEvents().then(() => { + writer.close(); + calledClose = true; + }) + ]); + }); +}, 'when close is called on a WritableStream in waiting state, ready promise should be fulfilled'); + +promise_test(() => { + let asyncCloseFinished = false; + const ws = recordingWritableStream({ + close() { + return flushAsyncEvents().then(() => { + asyncCloseFinished = true; + }); + } + }); + + const writer = ws.getWriter(); + return writer.ready.then(() => { + writer.write('a'); + + writer.close(); + + return writer.ready.then(v => { + assert_false(asyncCloseFinished, 'ready promise should be fulfilled before async close completes'); + assert_equals(v, undefined, 'ready promise should be fulfilled with undefined'); + assert_array_equals(ws.events, ['write', 'a', 'close'], 'sink abort() should not be called'); + }); + }); +}, 'when close is called on a WritableStream in waiting state, ready should be fulfilled immediately even if close ' + + 'takes a long time'); + +promise_test(t => { + const rejection = { name: 'letter' }; + const ws = new WritableStream({ + close() { + return { + then(onFulfilled, onRejected) { onRejected(rejection); } + }; + } + }); + return promise_rejects_exactly(t, rejection, ws.getWriter().close(), 'close() should return a rejection'); +}, 'returning a thenable from close() should work'); + +promise_test(t => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const closePromise = writer.close(); + const closedPromise = writer.closed; + writer.releaseLock(); + return Promise.all([ + closePromise, + promise_rejects_js(t, TypeError, closedPromise, '.closed promise should be rejected') + ]); + }); +}, 'releaseLock() should not change the result of sync close()'); + +promise_test(t => { + const ws = new WritableStream({ + close() { + return flushAsyncEvents(); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const closePromise = writer.close(); + const closedPromise = writer.closed; + writer.releaseLock(); + return Promise.all([ + closePromise, + promise_rejects_js(t, TypeError, closedPromise, '.closed promise should be rejected') + ]); + }); +}, 'releaseLock() should not change the result of async close()'); + +promise_test(() => { + let resolveClose; + const ws = new WritableStream({ + close() { + const promise = new Promise(resolve => { + resolveClose = resolve; + }); + return promise; + } + }); + const writer = ws.getWriter(); + const closePromise = writer.close(); + writer.releaseLock(); + return delay(0).then(() => { + resolveClose(); + return closePromise.then(() => { + assert_equals(ws.getWriter().desiredSize, 0, 'desiredSize should be 0'); + }); + }); +}, 'close() should set state to CLOSED even if writer has detached'); + +promise_test(() => { + let resolveClose; + const ws = new WritableStream({ + close() { + const promise = new Promise(resolve => { + resolveClose = resolve; + }); + return promise; + } + }); + const writer = ws.getWriter(); + writer.close(); + writer.releaseLock(); + return delay(0).then(() => { + const abortingWriter = ws.getWriter(); + const abortPromise = abortingWriter.abort(); + abortingWriter.releaseLock(); + resolveClose(); + return abortPromise; + }); +}, 'the promise returned by async abort during close should resolve'); + +// Though the order in which the promises are fulfilled or rejected is arbitrary, we're checking it for +// interoperability. We can change the order as long as we file bugs on all implementers to update to the latest tests +// to keep them interoperable. + +promise_test(() => { + const ws = new WritableStream({}); + + const writer = ws.getWriter(); + + const closePromise = writer.close(); + + const events = []; + return Promise.all([ + closePromise.then(() => { + events.push('closePromise'); + }), + writer.closed.then(() => { + events.push('closed'); + }) + ]).then(() => { + assert_array_equals(events, ['closePromise', 'closed'], + 'promises must fulfill/reject in the expected order'); + }); +}, 'promises must fulfill/reject in the expected order on closure'); + +promise_test(() => { + const ws = new WritableStream({}); + + // Wait until the WritableStream starts so that the close() call gets processed. Otherwise, abort() will be + // processed without waiting for completion of the close(). + return delay(0).then(() => { + const writer = ws.getWriter(); + + const closePromise = writer.close(); + const abortPromise = writer.abort(error1); + + const events = []; + return Promise.all([ + closePromise.then(() => { + events.push('closePromise'); + }), + abortPromise.then(() => { + events.push('abortPromise'); + }), + writer.closed.then(() => { + events.push('closed'); + }) + ]).then(() => { + assert_array_equals(events, ['closePromise', 'abortPromise', 'closed'], + 'promises must fulfill/reject in the expected order'); + }); + }); +}, 'promises must fulfill/reject in the expected order on aborted closure'); + +promise_test(t => { + const ws = new WritableStream({ + close() { + return Promise.reject(error1); + } + }); + + // Wait until the WritableStream starts so that the close() call gets processed. + return delay(0).then(() => { + const writer = ws.getWriter(); + + const closePromise = writer.close(); + const abortPromise = writer.abort(error2); + + const events = []; + closePromise.catch(() => events.push('closePromise')); + abortPromise.catch(() => events.push('abortPromise')); + writer.closed.catch(() => events.push('closed')); + return Promise.all([ + promise_rejects_exactly(t, error1, closePromise, + 'closePromise must reject with the error returned from the sink\'s close method'), + promise_rejects_exactly(t, error1, abortPromise, + 'abortPromise must reject with the error returned from the sink\'s close method'), + promise_rejects_exactly(t, error2, writer.closed, + 'writer.closed must reject with error2') + ]).then(() => { + assert_array_equals(events, ['closePromise', 'abortPromise', 'closed'], + 'promises must fulfill/reject in the expected order'); + }); + }); +}, 'promises must fulfill/reject in the expected order on aborted and errored closure'); + +promise_test(t => { + let resolveWrite; + let controller; + const ws = new WritableStream({ + write(chunk, c) { + controller = c; + return new Promise(resolve => { + resolveWrite = resolve; + }); + } + }); + const writer = ws.getWriter(); + return writer.ready.then(() => { + const writePromise = writer.write('c'); + controller.error(error1); + const closePromise = writer.close(); + let closeRejected = false; + closePromise.catch(() => { + closeRejected = true; + }); + return flushAsyncEvents().then(() => { + assert_false(closeRejected); + resolveWrite(); + return Promise.all([ + writePromise, + promise_rejects_exactly(t, error1, closePromise, 'close() should reject') + ]).then(() => { + assert_true(closeRejected); + }); + }); + }); +}, 'close() should not reject until no sink methods are in flight'); + +promise_test(() => { + const ws = new WritableStream(); + const writer1 = ws.getWriter(); + return writer1.close().then(() => { + writer1.releaseLock(); + const writer2 = ws.getWriter(); + const ready = writer2.ready; + assert_equals(ready.constructor, Promise); + return ready; + }); +}, 'ready promise should be initialised as fulfilled for a writer on a closed stream'); + +promise_test(() => { + const ws = new WritableStream(); + ws.close(); + const writer = ws.getWriter(); + return writer.closed; +}, 'close() on a writable stream should work'); + +promise_test(t => { + const ws = new WritableStream(); + ws.getWriter(); + return promise_rejects_js(t, TypeError, ws.close(), 'close should reject'); +}, 'close() on a locked stream should reject'); + +promise_test(t => { + const ws = new WritableStream({ + start(controller) { + controller.error(error1); + } + }); + return promise_rejects_exactly(t, error1, ws.close(), 'close should reject with error1'); +}, 'close() on an erroring stream should reject'); + +promise_test(t => { + const ws = new WritableStream({ + start(controller) { + controller.error(error1); + } + }); + const writer = ws.getWriter(); + return promise_rejects_exactly(t, error1, writer.closed, 'closed should reject with the error').then(() => { + writer.releaseLock(); + return promise_rejects_js(t, TypeError, ws.close(), 'close should reject'); + }); +}, 'close() on an errored stream should reject'); + +promise_test(t => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + return writer.close().then(() => { + return promise_rejects_js(t, TypeError, ws.close(), 'close should reject'); + }); +}, 'close() on an closed stream should reject'); + +promise_test(t => { + const ws = new WritableStream({ + close() { + return new Promise(() => {}); + } + }); + + const writer = ws.getWriter(); + writer.close(); + writer.releaseLock(); + + return promise_rejects_js(t, TypeError, ws.close(), 'close should reject'); +}, 'close() on a stream with a pending close should reject'); diff --git a/test/fixtures/wpt/streams/writable-streams/constructor.any.js b/test/fixtures/wpt/streams/writable-streams/constructor.any.js new file mode 100644 index 0000000..75eed2a --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/constructor.any.js @@ -0,0 +1,155 @@ +// META: global=window,worker,jsshell +'use strict'; + +const error1 = new Error('error1'); +error1.name = 'error1'; + +const error2 = new Error('error2'); +error2.name = 'error2'; + +promise_test(() => { + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + } + }); + + // Now error the stream after its construction. + controller.error(error1); + + const writer = ws.getWriter(); + + assert_equals(writer.desiredSize, null, 'desiredSize should be null'); + return writer.closed.catch(r => { + assert_equals(r, error1, 'ws should be errored by the passed error'); + }); +}, 'controller argument should be passed to start method'); + +promise_test(t => { + const ws = new WritableStream({ + write(chunk, controller) { + controller.error(error1); + } + }); + + const writer = ws.getWriter(); + + return Promise.all([ + writer.write('a'), + promise_rejects_exactly(t, error1, writer.closed, 'controller.error() in write() should error the stream') + ]); +}, 'controller argument should be passed to write method'); + +// Older versions of the standard had the controller argument passed to close(). It wasn't useful, and so has been +// removed. This test remains to identify implementations that haven't been updated. +promise_test(t => { + const ws = new WritableStream({ + close(...args) { + t.step(() => { + assert_array_equals(args, [], 'no arguments should be passed to close'); + }); + } + }); + + return ws.getWriter().close(); +}, 'controller argument should not be passed to close method'); + +promise_test(() => { + const ws = new WritableStream({}, { + highWaterMark: 1000, + size() { return 1; } + }); + + const writer = ws.getWriter(); + + assert_equals(writer.desiredSize, 1000, 'desiredSize should be 1000'); + return writer.ready.then(v => { + assert_equals(v, undefined, 'ready promise should fulfill with undefined'); + }); +}, 'highWaterMark should be reflected to desiredSize'); + +promise_test(() => { + const ws = new WritableStream({}, { + highWaterMark: Infinity, + size() { return 0; } + }); + + const writer = ws.getWriter(); + + assert_equals(writer.desiredSize, Infinity, 'desiredSize should be Infinity'); + + return writer.ready; +}, 'WritableStream should be writable and ready should fulfill immediately if the strategy does not apply ' + + 'backpressure'); + +test(() => { + new WritableStream(); +}, 'WritableStream should be constructible with no arguments'); + +test(() => { + const underlyingSink = { get start() { throw error1; } }; + const queuingStrategy = { highWaterMark: 0, get size() { throw error2; } }; + + // underlyingSink is converted in prose in the method body, whereas queuingStrategy is done at the IDL layer. + // So the queuingStrategy exception should be encountered first. + assert_throws_exactly(error2, () => new WritableStream(underlyingSink, queuingStrategy)); +}, 'underlyingSink argument should be converted after queuingStrategy argument'); + +test(() => { + const ws = new WritableStream({}); + + const writer = ws.getWriter(); + + assert_equals(typeof writer.write, 'function', 'writer should have a write method'); + assert_equals(typeof writer.abort, 'function', 'writer should have an abort method'); + assert_equals(typeof writer.close, 'function', 'writer should have a close method'); + + assert_equals(writer.desiredSize, 1, 'desiredSize should start at 1'); + + assert_not_equals(typeof writer.ready, 'undefined', 'writer should have a ready property'); + assert_equals(typeof writer.ready.then, 'function', 'ready property should be thenable'); + assert_not_equals(typeof writer.closed, 'undefined', 'writer should have a closed property'); + assert_equals(typeof writer.closed.then, 'function', 'closed property should be thenable'); +}, 'WritableStream instances should have standard methods and properties'); + +test(() => { + let WritableStreamDefaultController; + new WritableStream({ + start(c) { + WritableStreamDefaultController = c.constructor; + } + }); + + assert_throws_js(TypeError, () => new WritableStreamDefaultController({}), + 'constructor should throw a TypeError exception'); +}, 'WritableStreamDefaultController constructor should throw'); + +test(() => { + let WritableStreamDefaultController; + const stream = new WritableStream({ + start(c) { + WritableStreamDefaultController = c.constructor; + } + }); + + assert_throws_js(TypeError, () => new WritableStreamDefaultController(stream), + 'constructor should throw a TypeError exception'); +}, 'WritableStreamDefaultController constructor should throw when passed an initialised WritableStream'); + +test(() => { + const stream = new WritableStream(); + const writer = stream.getWriter(); + const WritableStreamDefaultWriter = writer.constructor; + writer.releaseLock(); + assert_throws_js(TypeError, () => new WritableStreamDefaultWriter({}), + 'constructor should throw a TypeError exception'); +}, 'WritableStreamDefaultWriter should throw unless passed a WritableStream'); + +test(() => { + const stream = new WritableStream(); + const writer = stream.getWriter(); + const WritableStreamDefaultWriter = writer.constructor; + assert_throws_js(TypeError, () => new WritableStreamDefaultWriter(stream), + 'constructor should throw a TypeError exception'); +}, 'WritableStreamDefaultWriter constructor should throw when stream argument is locked'); diff --git a/test/fixtures/wpt/streams/writable-streams/count-queuing-strategy.any.js b/test/fixtures/wpt/streams/writable-streams/count-queuing-strategy.any.js new file mode 100644 index 0000000..30edb3e --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/count-queuing-strategy.any.js @@ -0,0 +1,124 @@ +// META: global=window,worker,jsshell +'use strict'; + +test(() => { + new WritableStream({}, new CountQueuingStrategy({ highWaterMark: 4 })); +}, 'Can construct a writable stream with a valid CountQueuingStrategy'); + +promise_test(() => { + const dones = Object.create(null); + + const ws = new WritableStream( + { + write(chunk) { + return new Promise(resolve => { + dones[chunk] = resolve; + }); + } + }, + new CountQueuingStrategy({ highWaterMark: 0 }) + ); + + const writer = ws.getWriter(); + let writePromiseB; + let writePromiseC; + + return Promise.resolve().then(() => { + assert_equals(writer.desiredSize, 0, 'desiredSize should be initially 0'); + + const writePromiseA = writer.write('a'); + assert_equals(writer.desiredSize, -1, 'desiredSize should be -1 after 1st write()'); + + writePromiseB = writer.write('b'); + assert_equals(writer.desiredSize, -2, 'desiredSize should be -2 after 2nd write()'); + + dones.a(); + return writePromiseA; + }).then(() => { + assert_equals(writer.desiredSize, -1, 'desiredSize should be -1 after completing 1st write()'); + + dones.b(); + return writePromiseB; + }).then(() => { + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0 after completing 2nd write()'); + + writePromiseC = writer.write('c'); + assert_equals(writer.desiredSize, -1, 'desiredSize should be -1 after 3rd write()'); + + dones.c(); + return writePromiseC; + }).then(() => { + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0 after completing 3rd write()'); + }); +}, 'Correctly governs the value of a WritableStream\'s state property (HWM = 0)'); + +promise_test(() => { + const dones = Object.create(null); + + const ws = new WritableStream( + { + write(chunk) { + return new Promise(resolve => { + dones[chunk] = resolve; + }); + } + }, + new CountQueuingStrategy({ highWaterMark: 4 }) + ); + + const writer = ws.getWriter(); + let writePromiseB; + let writePromiseC; + let writePromiseD; + + return Promise.resolve().then(() => { + assert_equals(writer.desiredSize, 4, 'desiredSize should be initially 4'); + + const writePromiseA = writer.write('a'); + assert_equals(writer.desiredSize, 3, 'desiredSize should be 3 after 1st write()'); + + writePromiseB = writer.write('b'); + assert_equals(writer.desiredSize, 2, 'desiredSize should be 2 after 2nd write()'); + + writePromiseC = writer.write('c'); + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1 after 3rd write()'); + + writePromiseD = writer.write('d'); + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0 after 4th write()'); + + writer.write('e'); + assert_equals(writer.desiredSize, -1, 'desiredSize should be -1 after 5th write()'); + + writer.write('f'); + assert_equals(writer.desiredSize, -2, 'desiredSize should be -2 after 6th write()'); + + writer.write('g'); + assert_equals(writer.desiredSize, -3, 'desiredSize should be -3 after 7th write()'); + + dones.a(); + return writePromiseA; + }).then(() => { + assert_equals(writer.desiredSize, -2, 'desiredSize should be -2 after completing 1st write()'); + + dones.b(); + return writePromiseB; + }).then(() => { + assert_equals(writer.desiredSize, -1, 'desiredSize should be -1 after completing 2nd write()'); + + dones.c(); + return writePromiseC; + }).then(() => { + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0 after completing 3rd write()'); + + writer.write('h'); + assert_equals(writer.desiredSize, -1, 'desiredSize should be -1 after 8th write()'); + + dones.d(); + return writePromiseD; + }).then(() => { + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0 after completing 4th write()'); + + writer.write('i'); + assert_equals(writer.desiredSize, -1, 'desiredSize should be -1 after 9th write()'); + }); +}, 'Correctly governs the value of a WritableStream\'s state property (HWM = 4)'); diff --git a/test/fixtures/wpt/streams/writable-streams/error.any.js b/test/fixtures/wpt/streams/writable-streams/error.any.js new file mode 100644 index 0000000..be986fc --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/error.any.js @@ -0,0 +1,64 @@ +// META: global=window,worker,jsshell +'use strict'; + +const error1 = new Error('error1'); +error1.name = 'error1'; + +const error2 = new Error('error2'); +error2.name = 'error2'; + +promise_test(t => { + const ws = new WritableStream({ + start(controller) { + controller.error(error1); + } + }); + return promise_rejects_exactly(t, error1, ws.getWriter().closed, 'stream should be errored'); +}, 'controller.error() should error the stream'); + +test(() => { + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + } + }); + ws.abort(); + controller.error(error1); +}, 'controller.error() on erroring stream should not throw'); + +promise_test(t => { + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + } + }); + controller.error(error1); + controller.error(error2); + return promise_rejects_exactly(t, error1, ws.getWriter().closed, 'first controller.error() should win'); +}, 'surplus calls to controller.error() should be a no-op'); + +promise_test(() => { + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + } + }); + return ws.abort().then(() => { + controller.error(error1); + }); +}, 'controller.error() on errored stream should not throw'); + +promise_test(() => { + let controller; + const ws = new WritableStream({ + start(c) { + controller = c; + } + }); + return ws.getWriter().close().then(() => { + controller.error(error1); + }); +}, 'controller.error() on closed stream should not throw'); diff --git a/test/fixtures/wpt/streams/writable-streams/floating-point-total-queue-size.any.js b/test/fixtures/wpt/streams/writable-streams/floating-point-total-queue-size.any.js new file mode 100644 index 0000000..8e77ba0 --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/floating-point-total-queue-size.any.js @@ -0,0 +1,87 @@ +// META: global=window,worker,jsshell +'use strict'; + +// Due to the limitations of floating-point precision, the calculation of desiredSize sometimes gives different answers +// than adding up the items in the queue would. It is important that implementations give the same result in these edge +// cases so that developers do not come to depend on non-standard behaviour. See +// https://github.com/whatwg/streams/issues/582 and linked issues for further discussion. + +promise_test(() => { + const writer = setupTestStream(); + + const writePromises = [ + writer.write(2), + writer.write(Number.MAX_SAFE_INTEGER) + ]; + + assert_equals(writer.desiredSize, 0 - 2 - Number.MAX_SAFE_INTEGER, + 'desiredSize must be calculated using double-precision floating-point arithmetic (after writing two chunks)'); + + return Promise.all(writePromises).then(() => { + assert_equals(writer.desiredSize, 0, '[[queueTotalSize]] must clamp to 0 if it becomes negative'); + }); +}, 'Floating point arithmetic must manifest near NUMBER.MAX_SAFE_INTEGER (total ends up positive)'); + +promise_test(() => { + const writer = setupTestStream(); + + const writePromises = [ + writer.write(1e-16), + writer.write(1) + ]; + + assert_equals(writer.desiredSize, 0 - 1e-16 - 1, + 'desiredSize must be calculated using double-precision floating-point arithmetic (after writing two chunks)'); + + return Promise.all(writePromises).then(() => { + assert_equals(writer.desiredSize, 0, '[[queueTotalSize]] must clamp to 0 if it becomes negative'); + }); +}, 'Floating point arithmetic must manifest near 0 (total ends up positive, but clamped)'); + +promise_test(() => { + const writer = setupTestStream(); + + const writePromises = [ + writer.write(1e-16), + writer.write(1), + writer.write(2e-16) + ]; + + assert_equals(writer.desiredSize, 0 - 1e-16 - 1 - 2e-16, + 'desiredSize must be calculated using double-precision floating-point arithmetic (after writing three chunks)'); + + return Promise.all(writePromises).then(() => { + assert_equals(writer.desiredSize, 0 - 1e-16 - 1 - 2e-16 + 1e-16 + 1 + 2e-16, + 'desiredSize must be calculated using floating-point arithmetic (after the three chunks have finished writing)'); + }); +}, 'Floating point arithmetic must manifest near 0 (total ends up positive, and not clamped)'); + +promise_test(() => { + const writer = setupTestStream(); + + const writePromises = [ + writer.write(2e-16), + writer.write(1) + ]; + + assert_equals(writer.desiredSize, 0 - 2e-16 - 1, + 'desiredSize must be calculated using double-precision floating-point arithmetic (after writing two chunks)'); + + return Promise.all(writePromises).then(() => { + assert_equals(writer.desiredSize, 0 - 2e-16 - 1 + 2e-16 + 1, + 'desiredSize must be calculated using floating-point arithmetic (after the two chunks have finished writing)'); + }); +}, 'Floating point arithmetic must manifest near 0 (total ends up zero)'); + +function setupTestStream() { + const strategy = { + size(x) { + return x; + }, + highWaterMark: 0 + }; + + const ws = new WritableStream({}, strategy); + + return ws.getWriter(); +} diff --git a/test/fixtures/wpt/streams/writable-streams/general.any.js b/test/fixtures/wpt/streams/writable-streams/general.any.js new file mode 100644 index 0000000..fdd10b2 --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/general.any.js @@ -0,0 +1,277 @@ +// META: global=window,worker,jsshell +'use strict'; + +test(() => { + const ws = new WritableStream({}); + const writer = ws.getWriter(); + writer.releaseLock(); + + assert_throws_js(TypeError, () => writer.desiredSize, 'desiredSize should throw a TypeError'); +}, 'desiredSize on a released writer'); + +test(() => { + const ws = new WritableStream({}); + + const writer = ws.getWriter(); + + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1'); +}, 'desiredSize initial value'); + +promise_test(() => { + const ws = new WritableStream({}); + + const writer = ws.getWriter(); + + writer.close(); + + return writer.closed.then(() => { + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0'); + }); +}, 'desiredSize on a writer for a closed stream'); + +test(() => { + const ws = new WritableStream({ + start(c) { + c.error(); + } + }); + + const writer = ws.getWriter(); + assert_equals(writer.desiredSize, null, 'desiredSize should be null'); +}, 'desiredSize on a writer for an errored stream'); + +test(() => { + const ws = new WritableStream({}); + + const writer = ws.getWriter(); + writer.close(); + writer.releaseLock(); + + ws.getWriter(); +}, 'ws.getWriter() on a closing WritableStream'); + +promise_test(() => { + const ws = new WritableStream({}); + + const writer = ws.getWriter(); + return writer.close().then(() => { + writer.releaseLock(); + + ws.getWriter(); + }); +}, 'ws.getWriter() on a closed WritableStream'); + +test(() => { + const ws = new WritableStream({}); + + const writer = ws.getWriter(); + writer.abort(); + writer.releaseLock(); + + ws.getWriter(); +}, 'ws.getWriter() on an aborted WritableStream'); + +promise_test(() => { + const ws = new WritableStream({ + start(c) { + c.error(); + } + }); + + const writer = ws.getWriter(); + return writer.closed.then( + v => assert_unreached('writer.closed fulfilled unexpectedly with: ' + v), + () => { + writer.releaseLock(); + + ws.getWriter(); + } + ); +}, 'ws.getWriter() on an errored WritableStream'); + +promise_test(() => { + const ws = new WritableStream({}); + + const writer = ws.getWriter(); + writer.releaseLock(); + + return writer.closed.then( + v => assert_unreached('writer.closed fulfilled unexpectedly with: ' + v), + closedRejection => { + assert_equals(closedRejection.name, 'TypeError', 'closed promise should reject with a TypeError'); + return writer.ready.then( + v => assert_unreached('writer.ready fulfilled unexpectedly with: ' + v), + readyRejection => assert_equals(readyRejection, closedRejection, + 'ready promise should reject with the same error') + ); + } + ); +}, 'closed and ready on a released writer'); + +promise_test(t => { + let thisObject = null; + // Calls to Sink methods after the first are implicitly ignored. Only the first value that is passed to the resolver + // is used. + class Sink { + start() { + // Called twice + t.step(() => { + assert_equals(this, thisObject, 'start should be called as a method'); + }); + } + + write() { + t.step(() => { + assert_equals(this, thisObject, 'write should be called as a method'); + }); + } + + close() { + t.step(() => { + assert_equals(this, thisObject, 'close should be called as a method'); + }); + } + + abort() { + t.step(() => { + assert_equals(this, thisObject, 'abort should be called as a method'); + }); + } + } + + const theSink = new Sink(); + thisObject = theSink; + const ws = new WritableStream(theSink); + + const writer = ws.getWriter(); + + writer.write('a'); + const closePromise = writer.close(); + + const ws2 = new WritableStream(theSink); + const writer2 = ws2.getWriter(); + const abortPromise = writer2.abort(); + + return Promise.all([ + closePromise, + abortPromise + ]); +}, 'WritableStream should call underlying sink methods as methods'); + +promise_test(t => { + function functionWithOverloads() {} + functionWithOverloads.apply = t.unreached_func('apply() should not be called'); + functionWithOverloads.call = t.unreached_func('call() should not be called'); + const underlyingSink = { + start: functionWithOverloads, + write: functionWithOverloads, + close: functionWithOverloads, + abort: functionWithOverloads + }; + // Test start(), write(), close(). + const ws1 = new WritableStream(underlyingSink); + const writer1 = ws1.getWriter(); + writer1.write('a'); + writer1.close(); + + // Test abort(). + const abortError = new Error(); + abortError.name = 'abort error'; + + const ws2 = new WritableStream(underlyingSink); + const writer2 = ws2.getWriter(); + writer2.abort(abortError); + + // Test abort() with a close underlying sink method present. (Historical; see + // https://github.com/whatwg/streams/issues/620#issuecomment-263483953 for what used to be + // tested here. But more coverage can't hurt.) + const ws3 = new WritableStream({ + start: functionWithOverloads, + write: functionWithOverloads, + close: functionWithOverloads + }); + const writer3 = ws3.getWriter(); + writer3.abort(abortError); + + return writer1.closed + .then(() => promise_rejects_exactly(t, abortError, writer2.closed, 'writer2.closed should be rejected')) + .then(() => promise_rejects_exactly(t, abortError, writer3.closed, 'writer3.closed should be rejected')); +}, 'methods should not not have .apply() or .call() called'); + +promise_test(() => { + const strategy = { + size() { + if (this !== undefined) { + throw new Error('size called as a method'); + } + return 1; + } + }; + + const ws = new WritableStream({}, strategy); + const writer = ws.getWriter(); + return writer.write('a'); +}, 'WritableStream\'s strategy.size should not be called as a method'); + +promise_test(() => { + const ws = new WritableStream(); + const writer1 = ws.getWriter(); + assert_equals(undefined, writer1.releaseLock(), 'releaseLock() should return undefined'); + const writer2 = ws.getWriter(); + assert_equals(undefined, writer1.releaseLock(), 'no-op releaseLock() should return undefined'); + // Calling releaseLock() on writer1 should not interfere with writer2. If it did, then the ready promise would be + // rejected. + return writer2.ready; +}, 'redundant releaseLock() is no-op'); + +promise_test(() => { + const events = []; + const ws = new WritableStream(); + const writer = ws.getWriter(); + return writer.ready.then(() => { + // Force the ready promise back to a pending state. + const writerPromise = writer.write('dummy'); + const readyPromise = writer.ready.catch(() => events.push('ready')); + const closedPromise = writer.closed.catch(() => events.push('closed')); + writer.releaseLock(); + return Promise.all([readyPromise, closedPromise]).then(() => { + assert_array_equals(events, ['ready', 'closed'], 'ready promise should fire before closed promise'); + // Stop the writer promise hanging around after the test has finished. + return Promise.all([ + writerPromise, + ws.abort() + ]); + }); + }); +}, 'ready promise should fire before closed on releaseLock'); + +test(() => { + class Subclass extends WritableStream { + extraFunction() { + return true; + } + } + assert_equals( + Object.getPrototypeOf(Subclass.prototype), WritableStream.prototype, + 'Subclass.prototype\'s prototype should be WritableStream.prototype'); + assert_equals(Object.getPrototypeOf(Subclass), WritableStream, + 'Subclass\'s prototype should be WritableStream'); + const sub = new Subclass(); + assert_true(sub instanceof WritableStream, + 'Subclass object should be an instance of WritableStream'); + assert_true(sub instanceof Subclass, + 'Subclass object should be an instance of Subclass'); + const lockedGetter = Object.getOwnPropertyDescriptor( + WritableStream.prototype, 'locked').get; + assert_equals(lockedGetter.call(sub), sub.locked, + 'Subclass object should pass brand check'); + assert_true(sub.extraFunction(), + 'extraFunction() should be present on Subclass object'); +}, 'Subclassing WritableStream should work'); + +test(() => { + const ws = new WritableStream(); + assert_false(ws.locked, 'stream should not be locked'); + ws.getWriter(); + assert_true(ws.locked, 'stream should be locked'); +}, 'the locked getter should return true if the stream has a writer'); diff --git a/test/fixtures/wpt/streams/writable-streams/properties.any.js b/test/fixtures/wpt/streams/writable-streams/properties.any.js new file mode 100644 index 0000000..0f7f876 --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/properties.any.js @@ -0,0 +1,53 @@ +// META: global=window,worker,jsshell +'use strict'; + +const sinkMethods = { + start: { + length: 1, + trigger: () => Promise.resolve() + }, + write: { + length: 2, + trigger: writer => writer.write() + }, + close: { + length: 0, + trigger: writer => writer.close() + }, + abort: { + length: 1, + trigger: writer => writer.abort() + } +}; + +for (const method in sinkMethods) { + const { length, trigger } = sinkMethods[method]; + + // Some semantic tests of how sink methods are called can be found in general.js, as well as in the test files + // specific to each method. + promise_test(() => { + let argCount; + const ws = new WritableStream({ + [method](...args) { + argCount = args.length; + } + }); + return Promise.resolve(trigger(ws.getWriter())).then(() => { + assert_equals(argCount, length, `${method} should be called with ${length} arguments`); + }); + }, `sink method ${method} should be called with the right number of arguments`); + + promise_test(() => { + let methodWasCalled = false; + function Sink() {} + Sink.prototype = { + [method]() { + methodWasCalled = true; + } + }; + const ws = new WritableStream(new Sink()); + return Promise.resolve(trigger(ws.getWriter())).then(() => { + assert_true(methodWasCalled, `${method} should be called`); + }); + }, `sink method ${method} should be called even when it's located on the prototype chain`); +} diff --git a/test/fixtures/wpt/streams/writable-streams/reentrant-strategy.any.js b/test/fixtures/wpt/streams/writable-streams/reentrant-strategy.any.js new file mode 100644 index 0000000..afde413 --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/reentrant-strategy.any.js @@ -0,0 +1,174 @@ +// META: global=window,worker,jsshell +// META: script=../resources/test-utils.js +// META: script=../resources/recording-streams.js +'use strict'; + +// These tests exercise the pathological case of calling WritableStream* methods from within the strategy.size() +// callback. This is not something any real code should ever do. Failures here indicate subtle deviations from the +// standard that may affect real, non-pathological code. + +const error1 = { name: 'error1' }; + +promise_test(() => { + let writer; + const strategy = { + size(chunk) { + if (chunk > 0) { + writer.write(chunk - 1); + } + return chunk; + } + }; + + const ws = recordingWritableStream({}, strategy); + writer = ws.getWriter(); + return writer.write(2) + .then(() => { + assert_array_equals(ws.events, ['write', 0, 'write', 1, 'write', 2], 'writes should appear in order'); + }); +}, 'writes should be written in the standard order'); + +promise_test(() => { + let writer; + const events = []; + const strategy = { + size(chunk) { + events.push('size', chunk); + if (chunk > 0) { + writer.write(chunk - 1) + .then(() => events.push('writer.write done', chunk - 1)); + } + return chunk; + } + }; + const ws = new WritableStream({ + write(chunk) { + events.push('sink.write', chunk); + } + }, strategy); + writer = ws.getWriter(); + return writer.write(2) + .then(() => events.push('writer.write done', 2)) + .then(() => flushAsyncEvents()) + .then(() => { + assert_array_equals(events, ['size', 2, 'size', 1, 'size', 0, + 'sink.write', 0, 'sink.write', 1, 'writer.write done', 0, + 'sink.write', 2, 'writer.write done', 1, + 'writer.write done', 2], + 'events should happen in standard order'); + }); +}, 'writer.write() promises should resolve in the standard order'); + +promise_test(t => { + let controller; + const strategy = { + size() { + controller.error(error1); + return 1; + } + }; + const ws = recordingWritableStream({ + start(c) { + controller = c; + } + }, strategy); + const resolved = []; + const writer = ws.getWriter(); + const readyPromise1 = writer.ready.then(() => resolved.push('ready1')); + const writePromise = promise_rejects_exactly(t, error1, writer.write(), + 'write() should reject with the error') + .then(() => resolved.push('write')); + const readyPromise2 = promise_rejects_exactly(t, error1, writer.ready, 'ready should reject with error1') + .then(() => resolved.push('ready2')); + const closedPromise = promise_rejects_exactly(t, error1, writer.closed, 'closed should reject with error1') + .then(() => resolved.push('closed')); + return Promise.all([readyPromise1, writePromise, readyPromise2, closedPromise]) + .then(() => { + assert_array_equals(resolved, ['ready1', 'write', 'ready2', 'closed'], + 'promises should resolve in standard order'); + assert_array_equals(ws.events, [], 'underlying sink write should not be called'); + }); +}, 'controller.error() should work when called from within strategy.size()'); + +promise_test(t => { + let writer; + const strategy = { + size() { + writer.close(); + return 1; + } + }; + + const ws = recordingWritableStream({}, strategy); + writer = ws.getWriter(); + return promise_rejects_js(t, TypeError, writer.write('a'), 'write() promise should reject') + .then(() => { + assert_array_equals(ws.events, ['close'], 'sink.write() should not be called'); + }); +}, 'close() should work when called from within strategy.size()'); + +promise_test(t => { + let writer; + const strategy = { + size() { + writer.abort(error1); + return 1; + } + }; + + const ws = recordingWritableStream({}, strategy); + writer = ws.getWriter(); + return promise_rejects_exactly(t, error1, writer.write('a'), 'write() promise should reject') + .then(() => { + assert_array_equals(ws.events, ['abort', error1], 'sink.write() should not be called'); + }); +}, 'abort() should work when called from within strategy.size()'); + +promise_test(t => { + let writer; + const strategy = { + size() { + writer.releaseLock(); + return 1; + } + }; + + const ws = recordingWritableStream({}, strategy); + writer = ws.getWriter(); + const writePromise = promise_rejects_js(t, TypeError, writer.write('a'), 'write() promise should reject'); + const readyPromise = promise_rejects_js(t, TypeError, writer.ready, 'ready promise should reject'); + const closedPromise = promise_rejects_js(t, TypeError, writer.closed, 'closed promise should reject'); + return Promise.all([writePromise, readyPromise, closedPromise]) + .then(() => { + assert_array_equals(ws.events, [], 'sink.write() should not be called'); + }); +}, 'releaseLock() should abort the write() when called within strategy.size()'); + +promise_test(t => { + let writer1; + let ws; + let writePromise2; + let closePromise; + let closedPromise2; + const strategy = { + size(chunk) { + if (chunk > 0) { + writer1.releaseLock(); + const writer2 = ws.getWriter(); + writePromise2 = writer2.write(0); + closePromise = writer2.close(); + closedPromise2 = writer2.closed; + } + return 1; + } + }; + ws = recordingWritableStream({}, strategy); + writer1 = ws.getWriter(); + const writePromise1 = promise_rejects_js(t, TypeError, writer1.write(1), 'write() promise should reject'); + const readyPromise = promise_rejects_js(t, TypeError, writer1.ready, 'ready promise should reject'); + const closedPromise1 = promise_rejects_js(t, TypeError, writer1.closed, 'closed promise should reject'); + return Promise.all([writePromise1, readyPromise, closedPromise1, writePromise2, closePromise, closedPromise2]) + .then(() => { + assert_array_equals(ws.events, ['write', 0, 'close'], 'sink.write() should only be called once'); + }); +}, 'original reader should error when new reader is created within strategy.size()'); diff --git a/test/fixtures/wpt/streams/writable-streams/start.any.js b/test/fixtures/wpt/streams/writable-streams/start.any.js new file mode 100644 index 0000000..02b5f2a --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/start.any.js @@ -0,0 +1,163 @@ +// META: global=window,worker,jsshell +// META: script=../resources/test-utils.js +// META: script=../resources/recording-streams.js +'use strict'; + +const error1 = { name: 'error1' }; + +promise_test(() => { + let resolveStartPromise; + const ws = recordingWritableStream({ + start() { + return new Promise(resolve => { + resolveStartPromise = resolve; + }); + } + }); + + const writer = ws.getWriter(); + + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1'); + writer.write('a'); + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0 after writer.write()'); + + // Wait and verify that write isn't called. + return flushAsyncEvents() + .then(() => { + assert_array_equals(ws.events, [], 'write should not be called until start promise resolves'); + resolveStartPromise(); + return writer.ready; + }) + .then(() => assert_array_equals(ws.events, ['write', 'a'], + 'write should not be called until start promise resolves')); +}, 'underlying sink\'s write should not be called until start finishes'); + +promise_test(() => { + let resolveStartPromise; + const ws = recordingWritableStream({ + start() { + return new Promise(resolve => { + resolveStartPromise = resolve; + }); + } + }); + + const writer = ws.getWriter(); + + writer.close(); + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1'); + + // Wait and verify that write isn't called. + return flushAsyncEvents().then(() => { + assert_array_equals(ws.events, [], 'close should not be called until start promise resolves'); + resolveStartPromise(); + return writer.closed; + }); +}, 'underlying sink\'s close should not be called until start finishes'); + +test(() => { + const passedError = new Error('horrible things'); + + let writeCalled = false; + let closeCalled = false; + assert_throws_exactly(passedError, () => { + // recordingWritableStream cannot be used here because the exception in the + // constructor prevents assigning the object to a variable. + new WritableStream({ + start() { + throw passedError; + }, + write() { + writeCalled = true; + }, + close() { + closeCalled = true; + } + }); + }, 'constructor should throw passedError'); + assert_false(writeCalled, 'write should not be called'); + assert_false(closeCalled, 'close should not be called'); +}, 'underlying sink\'s write or close should not be called if start throws'); + +promise_test(() => { + const ws = recordingWritableStream({ + start() { + return Promise.reject(); + } + }); + + // Wait and verify that write or close aren't called. + return flushAsyncEvents() + .then(() => assert_array_equals(ws.events, [], 'write and close should not be called')); +}, 'underlying sink\'s write or close should not be invoked if the promise returned by start is rejected'); + +promise_test(t => { + const ws = new WritableStream({ + start() { + return { + then(onFulfilled, onRejected) { onRejected(error1); } + }; + } + }); + return promise_rejects_exactly(t, error1, ws.getWriter().closed, 'closed promise should be rejected'); +}, 'returning a thenable from start() should work'); + +promise_test(t => { + const ws = recordingWritableStream({ + start(controller) { + controller.error(error1); + } + }); + return promise_rejects_exactly(t, error1, ws.getWriter().write('a'), 'write() should reject with the error') + .then(() => { + assert_array_equals(ws.events, [], 'sink write() should not have been called'); + }); +}, 'controller.error() during start should cause writes to fail'); + +promise_test(t => { + let controller; + let resolveStart; + const ws = recordingWritableStream({ + start(c) { + controller = c; + return new Promise(resolve => { + resolveStart = resolve; + }); + } + }); + const writer = ws.getWriter(); + const writePromise = writer.write('a'); + const closePromise = writer.close(); + controller.error(error1); + resolveStart(); + return Promise.all([ + promise_rejects_exactly(t, error1, writePromise, 'write() should fail'), + promise_rejects_exactly(t, error1, closePromise, 'close() should fail') + ]).then(() => { + assert_array_equals(ws.events, [], 'sink write() and close() should not have been called'); + }); +}, 'controller.error() during async start should cause existing writes to fail'); + +promise_test(t => { + const events = []; + const promises = []; + function catchAndRecord(promise, name) { + promises.push(promise.then(t.unreached_func(`promise ${name} should not resolve`), + () => { + events.push(name); + })); + } + const ws = new WritableStream({ + start() { + return Promise.reject(); + } + }, { highWaterMark: 0 }); + const writer = ws.getWriter(); + catchAndRecord(writer.ready, 'ready'); + catchAndRecord(writer.closed, 'closed'); + catchAndRecord(writer.write(), 'write'); + return Promise.all(promises) + .then(() => { + assert_array_equals(events, ['ready', 'write', 'closed'], 'promises should reject in standard order'); + }); +}, 'when start() rejects, writer promises should reject in standard order'); diff --git a/test/fixtures/wpt/streams/writable-streams/write.any.js b/test/fixtures/wpt/streams/writable-streams/write.any.js new file mode 100644 index 0000000..e3defa8 --- /dev/null +++ b/test/fixtures/wpt/streams/writable-streams/write.any.js @@ -0,0 +1,284 @@ +// META: global=window,worker,jsshell +// META: script=../resources/test-utils.js +// META: script=../resources/recording-streams.js +'use strict'; + +const error1 = new Error('error1'); +error1.name = 'error1'; + +const error2 = new Error('error2'); +error2.name = 'error2'; + +function writeArrayToStream(array, writableStreamWriter) { + array.forEach(chunk => writableStreamWriter.write(chunk)); + return writableStreamWriter.close(); +} + +promise_test(() => { + let storage; + const ws = new WritableStream({ + start() { + storage = []; + }, + + write(chunk) { + return delay(0).then(() => storage.push(chunk)); + }, + + close() { + return delay(0); + } + }); + + const writer = ws.getWriter(); + + const input = [1, 2, 3, 4, 5]; + return writeArrayToStream(input, writer) + .then(() => assert_array_equals(storage, input, 'correct data should be relayed to underlying sink')); +}, 'WritableStream should complete asynchronous writes before close resolves'); + +promise_test(() => { + const ws = recordingWritableStream(); + + const writer = ws.getWriter(); + + const input = [1, 2, 3, 4, 5]; + return writeArrayToStream(input, writer) + .then(() => assert_array_equals(ws.events, ['write', 1, 'write', 2, 'write', 3, 'write', 4, 'write', 5, 'close'], + 'correct data should be relayed to underlying sink')); +}, 'WritableStream should complete synchronous writes before close resolves'); + +promise_test(() => { + const ws = new WritableStream({ + write() { + return 'Hello'; + } + }); + + const writer = ws.getWriter(); + + const writePromise = writer.write('a'); + return writePromise + .then(value => assert_equals(value, undefined, 'fulfillment value must be undefined')); +}, 'fulfillment value of ws.write() call should be undefined even if the underlying sink returns a non-undefined ' + + 'value'); + +promise_test(() => { + let resolveSinkWritePromise; + const ws = new WritableStream({ + write() { + return new Promise(resolve => { + resolveSinkWritePromise = resolve; + }); + } + }); + + const writer = ws.getWriter(); + + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1'); + + return writer.ready.then(() => { + const writePromise = writer.write('a'); + let writePromiseResolved = false; + assert_not_equals(resolveSinkWritePromise, undefined, 'resolveSinkWritePromise should not be undefined'); + + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0 after writer.write()'); + + return Promise.all([ + writePromise.then(value => { + writePromiseResolved = true; + assert_equals(resolveSinkWritePromise, undefined, 'sinkWritePromise should be fulfilled before writePromise'); + + assert_equals(value, undefined, 'writePromise should be fulfilled with undefined'); + }), + writer.ready.then(value => { + assert_equals(resolveSinkWritePromise, undefined, 'sinkWritePromise should be fulfilled before writer.ready'); + assert_true(writePromiseResolved, 'writePromise should be fulfilled before writer.ready'); + + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1 again'); + + assert_equals(value, undefined, 'writePromise should be fulfilled with undefined'); + }), + flushAsyncEvents().then(() => { + resolveSinkWritePromise(); + resolveSinkWritePromise = undefined; + }) + ]); + }); +}, 'WritableStream should transition to waiting until write is acknowledged'); + +promise_test(t => { + let sinkWritePromiseRejectors = []; + const ws = new WritableStream({ + write() { + const sinkWritePromise = new Promise((r, reject) => sinkWritePromiseRejectors.push(reject)); + return sinkWritePromise; + } + }); + + const writer = ws.getWriter(); + + assert_equals(writer.desiredSize, 1, 'desiredSize should be 1'); + + return writer.ready.then(() => { + const writePromise = writer.write('a'); + assert_equals(sinkWritePromiseRejectors.length, 1, 'there should be 1 rejector'); + assert_equals(writer.desiredSize, 0, 'desiredSize should be 0'); + + const writePromise2 = writer.write('b'); + assert_equals(sinkWritePromiseRejectors.length, 1, 'there should be still 1 rejector'); + assert_equals(writer.desiredSize, -1, 'desiredSize should be -1'); + + const closedPromise = writer.close(); + + assert_equals(writer.desiredSize, -1, 'desiredSize should still be -1'); + + return Promise.all([ + promise_rejects_exactly(t, error1, closedPromise, + 'closedPromise should reject with the error returned from the sink\'s write method') + .then(() => assert_equals(sinkWritePromiseRejectors.length, 0, + 'sinkWritePromise should reject before closedPromise')), + promise_rejects_exactly(t, error1, writePromise, + 'writePromise should reject with the error returned from the sink\'s write method') + .then(() => assert_equals(sinkWritePromiseRejectors.length, 0, + 'sinkWritePromise should reject before writePromise')), + promise_rejects_exactly(t, error1, writePromise2, + 'writePromise2 should reject with the error returned from the sink\'s write method') + .then(() => assert_equals(sinkWritePromiseRejectors.length, 0, + 'sinkWritePromise should reject before writePromise2')), + flushAsyncEvents().then(() => { + sinkWritePromiseRejectors[0](error1); + sinkWritePromiseRejectors = []; + }) + ]); + }); +}, 'when write returns a rejected promise, queued writes and close should be cleared'); + +promise_test(t => { + const ws = new WritableStream({ + write() { + throw error1; + } + }); + + const writer = ws.getWriter(); + + return promise_rejects_exactly(t, error1, writer.write('a'), + 'write() should reject with the error returned from the sink\'s write method') + .then(() => promise_rejects_js(t, TypeError, writer.close(), 'close() should be rejected')); +}, 'when sink\'s write throws an error, the stream should become errored and the promise should reject'); + +promise_test(t => { + const ws = new WritableStream({ + write(chunk, controller) { + controller.error(error1); + throw error2; + } + }); + + const writer = ws.getWriter(); + + return promise_rejects_exactly(t, error2, writer.write('a'), + 'write() should reject with the error returned from the sink\'s write method ') + .then(() => { + return Promise.all([ + promise_rejects_exactly(t, error1, writer.ready, + 'writer.ready must reject with the error passed to the controller'), + promise_rejects_exactly(t, error1, writer.closed, + 'writer.closed must reject with the error passed to the controller') + ]); + }); +}, 'writer.write(), ready and closed reject with the error passed to controller.error() made before sink.write' + + ' rejection'); + +promise_test(() => { + const numberOfWrites = 1000; + + let resolveFirstWritePromise; + let writeCount = 0; + const ws = new WritableStream({ + write() { + ++writeCount; + if (!resolveFirstWritePromise) { + return new Promise(resolve => { + resolveFirstWritePromise = resolve; + }); + } + return Promise.resolve(); + } + }); + + const writer = ws.getWriter(); + return writer.ready.then(() => { + for (let i = 1; i < numberOfWrites; ++i) { + writer.write('a'); + } + const writePromise = writer.write('a'); + + assert_equals(writeCount, 1, 'should have called sink\'s write once'); + + resolveFirstWritePromise(); + + return writePromise + .then(() => + assert_equals(writeCount, numberOfWrites, `should have called sink's write ${numberOfWrites} times`)); + }); +}, 'a large queue of writes should be processed completely'); + +promise_test(() => { + const stream = recordingWritableStream(); + const w = stream.getWriter(); + const WritableStreamDefaultWriter = w.constructor; + w.releaseLock(); + const writer = new WritableStreamDefaultWriter(stream); + return writer.ready.then(() => { + writer.write('a'); + assert_array_equals(stream.events, ['write', 'a'], 'write() should be passed to sink'); + }); +}, 'WritableStreamDefaultWriter should work when manually constructed'); + +promise_test(() => { + let thenCalled = false; + const ws = new WritableStream({ + write() { + return { + then(onFulfilled) { + thenCalled = true; + onFulfilled(); + } + }; + } + }); + return ws.getWriter().write('a').then(() => assert_true(thenCalled, 'thenCalled should be true')); +}, 'returning a thenable from write() should work'); + +promise_test(() => { + const stream = new WritableStream(); + const writer = stream.getWriter(); + const WritableStreamDefaultWriter = writer.constructor; + assert_throws_js(TypeError, () => new WritableStreamDefaultWriter(stream), + 'should not be able to construct on locked stream'); + // If stream.[[writer]] no longer points to |writer| then the closed Promise + // won't work properly. + return Promise.all([writer.close(), writer.closed]); +}, 'failing DefaultWriter constructor should not release an existing writer'); + +promise_test(t => { + const ws = new WritableStream({ + start() { + return Promise.reject(error1); + } + }, { highWaterMark: 0 }); + const writer = ws.getWriter(); + return Promise.all([ + promise_rejects_exactly(t, error1, writer.ready, 'ready should be rejected'), + promise_rejects_exactly(t, error1, writer.write(), 'write() should be rejected') + ]); +}, 'write() on a stream with HWM 0 should not cause the ready Promise to resolve'); + +promise_test(t => { + const ws = new WritableStream(); + const writer = ws.getWriter(); + writer.releaseLock(); + return promise_rejects_js(t, TypeError, writer.write(), 'write should reject'); +}, 'writing to a released writer should reject the returned promise'); diff --git a/test/fixtures/wpt/versions.json b/test/fixtures/wpt/versions.json new file mode 100644 index 0000000..8321737 --- /dev/null +++ b/test/fixtures/wpt/versions.json @@ -0,0 +1,18 @@ +{ + "common": { + "commit": "d758aedae2d5a96604a07e915795ba00d2b4fe57", + "path": "common" + }, + "interfaces": { + "commit": "4c7a0a83813006cad4d15f28e65ce66cd499db30", + "path": "interfaces" + }, + "resources": { + "commit": "4235130a746794f27e70aa25ba24195d96aacd95", + "path": "resources" + }, + "streams": { + "commit": "7e94a4bcb5bd6808e08ed8db46fa63751543db52", + "path": "streams" + } +} \ No newline at end of file diff --git a/test/wpt/status/streams.json b/test/wpt/status/streams.json new file mode 100644 index 0000000..b2419b3 --- /dev/null +++ b/test/wpt/status/streams.json @@ -0,0 +1,14 @@ +{ + "piping/pipe-through.any.js": { + "fail": "Node does not perform a brand check for AbortSignal.prototype.aborted" + }, + "queuing-strategies-size-function-per-global.window.js": { + "skip": "test requires an