From 1d18a28316305f6272fc2294c12f4931e847c834 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 21 Nov 2018 01:29:37 +0100 Subject: [PATCH] Support .any.js and .window.js tests This adds support for running tests with auto-generated test boilerplate (https://web-platform-tests.org/writing-tests/testharness.html#auto-generated-test-boilerplate), i.e. .any.js and .window.js tests. (Worker tests are ignored since jsdom doesn't support workers yet.) This keeps the JavaScript implementation as close as possible to the original Python implementation from web-platform-tests (https://github.com/web-platform-tests/wpt/blob/926d722bfc83f3135aab36fddc977de82ed7e63e/tools/serve/serve.py). This should (hopefully) make maintaining our implementation a bit easier, in the (rare) case the test format changes in the future. As part of this, we started using Node's new URL parser, bringing up our minimum version requirement. Closes #11. --- .travis.yml | 2 +- lib/internal/handlers.js | 31 ++++ lib/internal/serve.js | 300 +++++++++++++++++++++++++++++++++++++ lib/internal/sourcefile.js | 218 +++++++++++++++++++++++++++ lib/wpt-runner.js | 43 ++++-- package.json | 3 + 6 files changed, 587 insertions(+), 10 deletions(-) create mode 100644 lib/internal/handlers.js create mode 100644 lib/internal/serve.js create mode 100644 lib/internal/sourcefile.js diff --git a/.travis.yml b/.travis.yml index 31200cd..808711d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: node_js node_js: - - 4 + - 8 script: npm run lint && npm test diff --git a/lib/internal/handlers.js b/lib/internal/handlers.js new file mode 100644 index 0000000..36c1010 --- /dev/null +++ b/lib/internal/handlers.js @@ -0,0 +1,31 @@ +"use strict"; + +// Adapted from wpt tools +// https://github.com/web-platform-tests/wpt/blob/master/tools/wptserve/wptserve/handlers.py + +const path = require("path"); +const { URL } = require("url"); + +function filesystemPath(basePath, request, urlBase = "/") { + const { pathname } = new URL(request.url, `http://${request.headers.host}`); + let p = decodeURIComponent(pathname); + + if (p.startsWith(urlBase)) { + p = p.slice(urlBase.length); + } + + if (p.includes("..")) { + throw new Error("invalid path"); + } + + p = path.join(basePath, p); + + // Otherwise setting path to / allows access outside the root directory + if (!p.startsWith(basePath)) { + throw new Error("invalid path"); + } + + return p; +} + +exports.filesystemPath = filesystemPath; diff --git a/lib/internal/serve.js b/lib/internal/serve.js new file mode 100644 index 0000000..e8b4bf3 --- /dev/null +++ b/lib/internal/serve.js @@ -0,0 +1,300 @@ +"use strict"; + +// Adapted from wpt tools +// https://github.com/web-platform-tests/wpt/blob/926d722bfc83f3135aab36fddc977de82ed7e63e/tools/serve/serve.py + +const assert = require("assert"); +const fs = require("fs"); +const { URL } = require("url"); +const { filesystemPath } = require("./handlers.js"); +const { jsMetaRegexp, parseVariants, readScriptMetadata, replaceEnd } = require("./sourcefile.js"); + +/** + * @abstract + */ +class WrapperHandler { + constructor(basePath, urlBase = "/") { + this.basePath = basePath; + this.urlBase = urlBase; + } + + handleRequest(request, response) { + for (const [headerName, headerValue] of this.headers()) { + response.setHeader(headerName, headerValue); + } + + try { + this.checkExposure(request); + } catch (e) { + response.statusCode = 404; + response.end(e.message); + return; + } + + try { + const { pathname, search } = new URL(request.url, `http://${request.headers.host}`); + const path = this.getPath(pathname, true); + const query = search; + const meta = [...this.getMeta(request)].join("\n"); + const script = [...this.getScript(request)].join("\n"); + const content = this.wrapper(meta, script, path, query); + response.end(content); + // TODO wrap_pipeline? + } catch (e) { + response.statusCode = 500; + response.end(e.message); + } + } + + /** + * Convert the path from an incoming request into a path corresponding to an "unwrapped" + * resource e.g. the file on disk that will be loaded in the wrapper. + * + * @param path Path from the HTTP request + * @param resourcePath Boolean used to control whether to get the path for the resource that + * this wrapper will load or the associated file on disk. + * Typically these are the same but may differ when there are multiple + * layers of wrapping e.g. for a .any.worker.html input the underlying disk file is + * .any.js but the top level html file loads a resource with a + * .any.worker.js extension, which itself loads the .any.js file. + * If true return the path to the resource that the wrapper will load, + * otherwise return the path to the underlying file on disk. + */ + getPath(path, resourcePath) { + for (const item of this.pathReplace()) { + let src; + let dest; + if (item.length === 2) { + [src, dest] = item; + } else { + assert.equal(item.length, 3); + src = item[0]; + dest = item[resourcePath ? 2 : 1]; + } + if (path.endsWith(src)) { + path = replaceEnd(path, src, dest); + } + } + return path; + } + + /** + * Get an iterator over script metadata based on // META comments in the + * associated js file. + * + * @param request The Request being processed. + * @returns {Iterable<[string, string]>} + */ + * getMetadata(request) { + const path = this.getPath(filesystemPath(this.basePath, request, this.urlBase), false); + const f = fs.readFileSync(path, { encoding: "utf8" }); + yield* readScriptMetadata(f, jsMetaRegexp); + } + + /** + * Get an iterator over strings to inject into the wrapper document + * based on // META comments in the associated js file. + * + * @param request The Request being processed. + * @returns {Iterable} + */ + * getMeta(request) { + for (const [key, value] of this.getMetadata(request)) { + const replacement = this.metaReplacement(key, value); + if (replacement) { + yield replacement; + } + } + } + + /** + * Get an iterator over strings to inject into the wrapper document + * based on // META comments in the associated js file. + * + * @param request The Request being processed. + * @returns {Iterable} + */ + * getScript(request) { + for (const [key, value] of this.getMetadata(request)) { + const replacement = this.scriptReplacement(key, value); + if (replacement) { + yield replacement; + } + } + } + + /** + * @abstract + * @returns {Array<[string, string]>} + */ + headers() { + return []; + } + + /** + * A list containing a mix of 2 item tuples with (input suffix, output suffix) + * and 3-item tuples with (input suffix, filesystem suffix, resource suffix) + * for the case where we want a different path in the generated resource to + * the actual path on the filesystem (e.g. when there is another handler + * that will wrap the file). + * + * @abstract + * @returns {Array<[string, string] | [string, string, string]>} + */ + pathReplace() { + throw new Error("Not implemented"); + } + + /** + * String template with variables path and meta for wrapper document + * + * @abstract + * @param {string} meta + * @param {string} script + * @param {string} path + * @param {string} query + * @returns {string} + */ + wrapper(meta, script, path, query) { // eslint-disable-line no-unused-vars + throw new Error("Not implemented"); + } + + /** + * Get the string to insert into the wrapper document, given + * a specific metadata key: value pair. + * + * @abstract + * @param {string} key + * @param {string} value + * @returns {string} + */ + metaReplacement(key, value) { // eslint-disable-line no-unused-vars + return ""; + } + + /** + * Get the string to insert into the wrapper document, given + * a specific metadata key: value pair. + * + * @abstract + * @param {string} key + * @param {string} value + * @returns {string} + */ + scriptReplacement(key, value) { // eslint-disable-line no-unused-vars + return ""; + } + + /** + * Raise an exception if this handler shouldn't be exposed after all. + * + * @abstract + * @param request + * @returns {void} + */ + checkExposure(request) { // eslint-disable-line no-unused-vars + // do nothing + } +} + +/** + * @abstract + */ +class HtmlWrapperHandler extends WrapperHandler { + /** + * @abstract + * @returns {string} + */ + globalType() { + return ""; + } + + checkExposure(request) { + const globalType = this.globalType(); + if (globalType) { + let globals = ""; + for (const [key, value] of this.getMetadata(request)) { + if (key === "global") { + globals = value; + } + } + if (!parseVariants(globals).has(globalType)) { + throw new Error(`This test cannot be loaded in ${globalType} mode`); + } + } + } + + headers() { + return [["Content-Type", "text/html"]]; + } + + metaReplacement(key, value) { + if (key === "timeout") { + if (value === "long") { + return ``; + } + } + if (key === "title") { + value = value.replace("&", "&").replace("<", "<"); + return `${value}`; + } + return ""; + } + + scriptReplacement(key, value) { + if (key === "script") { + const attribute = value.replace("&", "&").replace("\"", """); + return ``; + } + return ""; + } +} + +class WindowHandler extends HtmlWrapperHandler { + pathReplace() { + return [ + [".window.html", ".window.js"] + ]; + } + + wrapper(meta, script, path) { + return ` + +${meta} + + +${script} +
+ +`; + } +} + +class AnyHtmlHandler extends HtmlWrapperHandler { + pathReplace() { + return [ + [".any.html", ".any.js"] + ]; + } + + wrapper(meta, script, path) { + return ` + +${meta} + + + +${script} +
+ +`; + } +} + +exports.WindowHandler = WindowHandler; +exports.AnyHtmlHandler = AnyHtmlHandler; diff --git a/lib/internal/sourcefile.js b/lib/internal/sourcefile.js new file mode 100644 index 0000000..bd15ccf --- /dev/null +++ b/lib/internal/sourcefile.js @@ -0,0 +1,218 @@ +"use strict"; + +// Adapted from wpt tools +// https://github.com/web-platform-tests/wpt/blob/926d722bfc83f3135aab36fddc977de82ed7e63e/tools/manifest/sourcefile.py + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +/** + * Given a string `s` that ends with `oldSuffix`, replace that occurrence of `oldSuffix` + * with `newSuffix`. + */ +function replaceEnd(s, oldSuffix, newSuffix) { + assert.ok(s.endsWith(oldSuffix)); + return s.slice(0, s.length - oldSuffix.length) + newSuffix; +} + +const jsMetaRegexp = /\/\/\s*META:\s*(\w*)=(.*)$/; + +/** + * Yields any metadata (pairs of strings) from the multi-line string `s`, + * as specified according to a supplied regexp. + * + * @param s + * @param regexp Regexp containing two groups containing the metadata name and value. + * @returns {Iterable<[string, string]>} + */ +function* readScriptMetadata(s, regexp) { + for (const line of s.split("\n")) { + const m = line.match(regexp); + if (!m) { + break; + } + yield [m[1], m[2]]; + } +} + +const anyVariants = { + // Worker tests are not supported yet, so we remove all worker variants + default: { + longhand: ["window"] + }, + window: { + suffix: ".any.html" + }, + jsshell: { + suffix: ".any.js" + } +}; + +/** + * Returns a set of variants (strings) defined by the given keyword. + * + * @returns {Set} + */ +function getAnyVariants(item) { + assert.equal(item.startsWith("!"), false); + + const variant = anyVariants[item]; + if (!variant) { + return new Set([]); + } + return new Set(variant.longhand || [item]); +} + +/** + * Returns a set of variants (strings) that will be used by default. + * + * @returns {Set} + */ +function getDefaultAnyVariants() { + return new Set(anyVariants.default.longhand); +} + +/** + * Returns a set of variants (strings) defined by a comma-separated value. + * + * @returns {Set} + */ +function parseVariants(value) { + const globals = getDefaultAnyVariants(); + for (let item of value.split(",")) { + item = item.trim(); + if (item.startsWith("!")) { + for (const variant of getAnyVariants(item.slice(1))) { + globals.delete(variant); + } + } else { + for (const variant of getAnyVariants(item)) { + globals.add(variant); + } + } + } + return globals; +} + +/** + * Yields tuples of the relevant filename suffix (a string) and whether the + * variant is intended to run in a JS shell, for the variants defined by the + * given comma-separated value. + * + * @param {string} value + * @returns {Array<[string, boolean]>} + */ +function globalSuffixes(value) { + const rv = []; + + const globalTypes = parseVariants(value); + for (const globalType of globalTypes) { + const variant = anyVariants[globalType]; + const suffix = variant.suffix || `.any.${globalType}.html`; + rv.push([suffix, globalType === "jsshell"]); + } + + return rv; +} + +/** + * Returns a url created from the given url and suffix. + * + * @param {string} url + * @param {string} suffix + * @returns {string} + */ +function globalVariantUrl(url, suffix) { + url = url.replace(".any.", "."); + // If the url must be loaded over https, ensure that it will have + // the form .https.any.js + if (url.includes(".https.") && suffix.startsWith(".https.")) { + url = url.replace(".https.", "."); + } + return replaceEnd(url, ".js", suffix); +} + +class SourceFile { + constructor(testsRoot, relPath) { + this.testsRoot = testsRoot; + this.relPath = relPath.replace(/\\/g, "/"); + this.contents = undefined; + + this.filename = path.basename(this.relPath); + this.ext = path.extname(this.filename); + this.name = this.filename.slice(0, this.filename.length - this.ext.length); + + this.metaFlags = this.name.split(".").slice(1); + } + + open() { + if (this.contents === undefined) { + this.contents = fs.readFileSync(this.path(), { encoding: "utf8" }); + } + return this.contents; + } + + path() { + return path.join(this.testsRoot, this.relPath); + } + + nameIsMultiGlobal() { + return this.metaFlags.includes("any") && this.ext === ".js"; + } + + nameIsWorker() { + return this.metaFlags.includes("worker") && this.ext === ".js"; + } + + nameIsWindow() { + return this.metaFlags.includes("window") && this.ext === ".js"; + } + + contentIsTestharness() { + // TODO Parse the HTML and look for