1014 lines
No EOL
38 KiB
TypeScript
1014 lines
No EOL
38 KiB
TypeScript
/// <reference types="node" />
|
|
// tslint:disable:no-null-keyword
|
|
|
|
import minimist = require("minimist");
|
|
import http = require("http");
|
|
import fs = require("fs");
|
|
import path = require("path");
|
|
import url = require("url");
|
|
import URL = url.URL;
|
|
import child_process = require("child_process");
|
|
import os = require("os");
|
|
import crypto = require("crypto");
|
|
import { Readable, Writable } from "stream";
|
|
import { isBuffer, isString, isObject } from "util";
|
|
import { install, getErrorSource } from "source-map-support";
|
|
|
|
install();
|
|
|
|
const port = 8888; // harness.ts and webTestResults.html depend on this exact port number.
|
|
const baseUrl = new URL(`http://localhost:${port}/`);
|
|
const rootDir = path.dirname(__dirname);
|
|
const useCaseSensitiveFileNames = isFileSystemCaseSensitive();
|
|
|
|
const defaultBrowser = os.platform() === "win32" ? "edge" : "chrome";
|
|
let browser: "edge" | "chrome" | "none" = defaultBrowser;
|
|
let grep: string | undefined;
|
|
let verbose = false;
|
|
|
|
interface FileBasedTest {
|
|
file: string;
|
|
configurations?: FileBasedTestConfiguration[];
|
|
}
|
|
|
|
interface FileBasedTestConfiguration {
|
|
[setting: string]: string;
|
|
}
|
|
|
|
function isFileSystemCaseSensitive(): boolean {
|
|
// win32\win64 are case insensitive platforms
|
|
const platform = os.platform();
|
|
if (platform === "win32" || <string>platform === "win64") {
|
|
return false;
|
|
}
|
|
// If this file exists under a different case, we must be case-insensitve.
|
|
return !fs.existsSync(swapCase(__filename));
|
|
}
|
|
|
|
function swapCase(s: string): string {
|
|
return s.replace(/\w/g, (ch) => {
|
|
const up = ch.toUpperCase();
|
|
return ch === up ? ch.toLowerCase() : up;
|
|
});
|
|
}
|
|
|
|
function hasLeadingSeparator(pathname: string) {
|
|
const ch = pathname.charAt(0);
|
|
return ch === "/" || ch === "\\";
|
|
}
|
|
|
|
function ensureLeadingSeparator(pathname: string) {
|
|
return hasLeadingSeparator(pathname) ? pathname : "/" + pathname;
|
|
}
|
|
|
|
function trimLeadingSeparator(pathname: string) {
|
|
return hasLeadingSeparator(pathname) ? pathname.slice(1) : pathname;
|
|
}
|
|
|
|
function normalizeSlashes(path: string) {
|
|
return path.replace(/\\+/g, "/");
|
|
}
|
|
|
|
function hasTrailingSeparator(pathname: string) {
|
|
const ch = pathname.charAt(pathname.length - 1);
|
|
return ch === "/" || ch === "\\";
|
|
}
|
|
|
|
function toServerPath(url: url.URL | string) {
|
|
if (typeof url === "string") url = new URL(url, baseUrl);
|
|
const pathname = decodeURIComponent(url.pathname);
|
|
return path.join(rootDir, pathname);
|
|
}
|
|
|
|
function toClientPath(pathname: string) {
|
|
pathname = normalizeSlashes(pathname);
|
|
pathname = trimLeadingSeparator(pathname);
|
|
|
|
const serverPath = path.resolve(rootDir, pathname);
|
|
if (serverPath.slice(0, rootDir.length) !== rootDir) {
|
|
return undefined;
|
|
}
|
|
|
|
let clientPath = serverPath.slice(rootDir.length);
|
|
clientPath = ensureLeadingSeparator(clientPath);
|
|
clientPath = normalizeSlashes(clientPath);
|
|
return clientPath;
|
|
}
|
|
|
|
function flatMap<T, U>(array: T[], selector: (value: T) => U | U[]) {
|
|
let result: U[] = [];
|
|
for (const item of array) {
|
|
const mapped = selector(item);
|
|
if (Array.isArray(mapped)) {
|
|
result = result.concat(mapped);
|
|
}
|
|
else {
|
|
result.push(mapped);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
declare module "http" {
|
|
interface IncomingHttpHeaders {
|
|
"if-match"?: string;
|
|
"if-none-match"?: string;
|
|
"if-modified-since"?: string;
|
|
"if-unmodified-since"?: string;
|
|
"accept-charset"?: string;
|
|
"accept-encoding"?: string;
|
|
"range"?: string;
|
|
}
|
|
}
|
|
|
|
function getQuality<T extends { quality?: number }>(value: T) {
|
|
return value.quality === undefined ? 1 : value.quality;
|
|
}
|
|
|
|
function bestMatch<T, TPattern extends { quality?: number }>(value: T, patterns: TPattern[], isMatch: (value: T, pattern: TPattern) => boolean) {
|
|
let match: TPattern | undefined;
|
|
for (const pattern of patterns) {
|
|
if (!isMatch(value, pattern)) continue;
|
|
if (match === undefined || getQuality(pattern) > getQuality(match)) {
|
|
match = pattern;
|
|
}
|
|
}
|
|
return match;
|
|
}
|
|
|
|
const mediaTypeParser = /^([^\/]+)\/([^\/;]+)(?:;(.*))?$/;
|
|
|
|
interface MediaType {
|
|
type: string;
|
|
subtype: string;
|
|
parameters: Record<string, string>;
|
|
charset?: string;
|
|
quality?: number;
|
|
}
|
|
|
|
function parseMediaType(mediaType: string): MediaType {
|
|
const match = mediaTypeParser.exec(mediaType);
|
|
if (!match) throw new Error("Invalid media type");
|
|
const type = match[1].trim();
|
|
const subtype = match[2].trim();
|
|
if (type === "*" && subtype !== "*") throw new Error("Invalid media type");
|
|
const parameters: Record<string, string> = {};
|
|
let charset: string | undefined;
|
|
let quality: number | undefined;
|
|
if (match[3]) {
|
|
for (const parameter of match[3].split(";")) {
|
|
const pair = parameter.split("=");
|
|
const name = pair[0].trim();
|
|
const value = pair[1].trim();
|
|
parameters[name] = value;
|
|
if (name === "charset") charset = value;
|
|
if (name === "q") quality = +value;
|
|
}
|
|
}
|
|
return { type, subtype, parameters, charset, quality };
|
|
}
|
|
|
|
function parseMediaTypes(value: string) {
|
|
const mediaTypes: MediaType[] = [];
|
|
for (const mediaRange of value.split(",")) {
|
|
mediaTypes.push(parseMediaType(mediaRange));
|
|
}
|
|
return mediaTypes;
|
|
}
|
|
|
|
function matchesMediaType(mediaType: MediaType, mediaTypePattern: MediaType) {
|
|
if (mediaTypePattern.type === "*") return true;
|
|
if (mediaTypePattern.type === mediaType.type) {
|
|
if (mediaTypePattern.subtype === "*") return true;
|
|
if (mediaTypePattern.subtype === mediaType.subtype) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
interface StringWithQuality {
|
|
value: string;
|
|
quality?: number;
|
|
}
|
|
|
|
const stringWithQualityParser = /^([^;]+)(;\s*q\s*=\s*([^\s]+)\s*)?$/;
|
|
|
|
function parseStringWithQuality(value: string) {
|
|
const match = stringWithQualityParser.exec(value);
|
|
if (!match) throw new Error("Invalid header value");
|
|
return { value: match[1].trim(), quality: match[2] ? +match[2] : undefined };
|
|
}
|
|
|
|
function parseStringsWithQuality(value: string) {
|
|
const charsets: StringWithQuality[] = [];
|
|
for (const charset of value.split(",")) {
|
|
charsets.push(parseStringWithQuality(charset));
|
|
}
|
|
return charsets;
|
|
}
|
|
|
|
function matchesCharSet(charset: string, charsetPattern: StringWithQuality) {
|
|
return charsetPattern.value === "*" || charsetPattern.value === charset;
|
|
}
|
|
|
|
function computeETag(stats: fs.Stats) {
|
|
return JSON.stringify(crypto
|
|
.createHash("sha1")
|
|
.update(JSON.stringify({
|
|
dev: stats.dev,
|
|
ino: stats.ino,
|
|
mtime: stats.mtimeMs,
|
|
size: stats.size
|
|
}))
|
|
.digest("base64"));
|
|
}
|
|
|
|
function tryParseETags(value: string | undefined): "*" | string[] | undefined {
|
|
if (!value) return undefined;
|
|
if (value === "*") return value;
|
|
const etags: string[] = [];
|
|
for (const etag of value.split(",")) {
|
|
etags.push(etag.trim());
|
|
}
|
|
return etags;
|
|
}
|
|
|
|
function matchesETag(etag: string | undefined, condition: "*" | string[] | undefined) {
|
|
if (!condition) return true;
|
|
if (!etag) return false;
|
|
return condition === "*" || condition.indexOf(etag) >= 0;
|
|
}
|
|
|
|
function tryParseDate(value: string | undefined) {
|
|
return value ? new Date(value) : undefined;
|
|
}
|
|
|
|
interface ByteRange {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
const byteRangeParser = /^\s*(\d+)\s*-\s*(\d+)\s*$/;
|
|
|
|
function tryParseByteRange(value: string, contentLength: number): ByteRange | undefined {
|
|
const match = byteRangeParser.exec(value);
|
|
const firstBytePos = match && match[1] ? +match[1] : undefined;
|
|
const lastBytePos = match && match[2] ? +match[2] : undefined;
|
|
if (firstBytePos !== undefined && lastBytePos !== undefined) {
|
|
if (lastBytePos < firstBytePos) return undefined;
|
|
return { start: firstBytePos, end: lastBytePos + 1 };
|
|
}
|
|
if (firstBytePos !== undefined) return { start: firstBytePos, end: contentLength };
|
|
if (lastBytePos !== undefined) return { start: contentLength - lastBytePos, end: contentLength };
|
|
return undefined;
|
|
}
|
|
|
|
function tryParseByteRanges(value: string, contentLength: number): ByteRange[] | undefined {
|
|
if (!value.startsWith("bytes=")) return;
|
|
const ranges: ByteRange[] = [];
|
|
for (const range of value.slice(6).split(",")) {
|
|
const byteRange = tryParseByteRange(range, contentLength);
|
|
if (byteRange === undefined) return undefined;
|
|
if (byteRange.start >= contentLength) continue;
|
|
ranges.push(byteRange);
|
|
}
|
|
return ranges;
|
|
}
|
|
|
|
function once<T extends (...args: any[]) => void>(callback: T): T;
|
|
function once(callback: (...args: any[]) => void) {
|
|
let called = false;
|
|
return (...args: any[]) => {
|
|
if (called) return;
|
|
called = true;
|
|
callback(...args);
|
|
};
|
|
}
|
|
|
|
function mkdirp(dirname: string, callback: (err: NodeJS.ErrnoException | null) => void) {
|
|
fs.mkdir(dirname, err => {
|
|
if (err && err.code === "EEXIST") err = null;
|
|
if (err && err.code === "ENOENT") {
|
|
const parentdir = path.dirname(dirname);
|
|
if (!parentdir || parentdir === dirname) return callback(err);
|
|
return mkdirp(parentdir, err => {
|
|
if (err) return callback(err);
|
|
return fs.mkdir(dirname, callback);
|
|
});
|
|
}
|
|
return callback(err);
|
|
});
|
|
}
|
|
|
|
function getAccessibleFileSystemEntries(pathname: string) {
|
|
try {
|
|
const entries = fs.readdirSync(pathname).sort();
|
|
const files: string[] = [];
|
|
const directories: string[] = [];
|
|
for (const entry of entries) {
|
|
// This is necessary because on some file system node fails to exclude
|
|
// "." and "..". See https://github.com/nodejs/node/issues/4002
|
|
if (entry === "." || entry === "..") {
|
|
continue;
|
|
}
|
|
const name = path.join(pathname, entry);
|
|
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.statSync(name);
|
|
}
|
|
catch (e) {
|
|
continue;
|
|
}
|
|
|
|
if (stat.isFile()) {
|
|
files.push(entry);
|
|
}
|
|
else if (stat.isDirectory()) {
|
|
directories.push(entry);
|
|
}
|
|
}
|
|
return { files, directories };
|
|
}
|
|
catch (e) {
|
|
return { files: [], directories: [] };
|
|
}
|
|
}
|
|
|
|
function guessMediaType(pathname: string) {
|
|
switch (path.extname(pathname).toLowerCase()) {
|
|
case ".html": return "text/html; charset=utf-8";
|
|
case ".css": return "text/css; charset=utf-8";
|
|
case ".js": return "application/javascript; charset=utf-8";
|
|
case ".mjs": return "application/javascript; charset=utf-8";
|
|
case ".jsx": return "text/jsx; charset=utf-8";
|
|
case ".ts": return "text/plain; charset=utf-8";
|
|
case ".tsx": return "text/plain; charset=utf-8";
|
|
case ".json": return "text/plain; charset=utf-8";
|
|
case ".map": return "application/json; charset=utf-8";
|
|
default: return "application/octet-stream";
|
|
}
|
|
}
|
|
|
|
function readContent(req: http.ServerRequest, callback: (err: NodeJS.ErrnoException | null, content: string | null) => void) {
|
|
const chunks: Buffer[] = [];
|
|
const done = once((err: NodeJS.ErrnoException | null) => {
|
|
if (err) return callback(err, /*content*/ null);
|
|
let content: string | null = null;
|
|
try {
|
|
content = Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
catch (e) {
|
|
err = e;
|
|
}
|
|
return callback(err, content);
|
|
});
|
|
req.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8")));
|
|
req.on("error", err => done(err));
|
|
req.on("end", () => done(/*err*/ null));
|
|
}
|
|
|
|
function saveToFile(file: string, readable: Readable, callback: (err: NodeJS.ErrnoException | null) => void) {
|
|
callback = once(callback);
|
|
const writable = fs.createWriteStream(file, { autoClose: true });
|
|
writable.on("error", err => callback(err));
|
|
readable.on("end", () => callback(/*err*/ null));
|
|
readable.pipe(writable, { end: true });
|
|
}
|
|
|
|
function sendContent(res: http.ServerResponse, statusCode: number, content: string | Buffer, contentType: string): void;
|
|
function sendContent(res: http.ServerResponse, statusCode: number, content: Readable, contentType: string, contentLength: number): void;
|
|
function sendContent(res: http.ServerResponse, statusCode: number, content: string | Buffer | Readable, contentType: string, contentLength?: number) {
|
|
res.statusCode = statusCode;
|
|
res.setHeader("Content-Type", contentType);
|
|
if (isString(content)) {
|
|
res.setHeader("Content-Length", Buffer.byteLength(content, "utf8"));
|
|
res.end(content, "utf8");
|
|
}
|
|
else if (isBuffer(content)) {
|
|
res.setHeader("Content-Length", content.byteLength);
|
|
res.end(content);
|
|
}
|
|
else {
|
|
if (contentLength !== undefined) res.setHeader("Content-Length", contentLength);
|
|
content.on("error", e => sendInternalServerError(res, e));
|
|
content.pipe(res, { end: true });
|
|
}
|
|
}
|
|
|
|
function sendJson(res: http.ServerResponse, statusCode: number, value: any) {
|
|
try {
|
|
sendContent(res, statusCode, JSON.stringify(value), "application/json; charset=utf-8");
|
|
}
|
|
catch (e) {
|
|
sendInternalServerError(res, e);
|
|
}
|
|
}
|
|
|
|
function sendCreated(res: http.ServerResponse, location?: string, etag?: string) {
|
|
res.statusCode = 201;
|
|
if (location) res.setHeader("Location", location);
|
|
if (etag) res.setHeader("ETag", etag);
|
|
res.end();
|
|
}
|
|
|
|
function sendNoContent(res: http.ServerResponse) {
|
|
res.statusCode = 204;
|
|
res.end();
|
|
}
|
|
|
|
function sendFound(res: http.ServerResponse, location: string) {
|
|
res.statusCode = 302;
|
|
res.setHeader("Location", location);
|
|
res.end();
|
|
}
|
|
|
|
function sendNotModified(res: http.ServerResponse) {
|
|
res.statusCode = 304;
|
|
res.end();
|
|
}
|
|
|
|
function sendBadRequest(res: http.ServerResponse) {
|
|
res.statusCode = 400;
|
|
res.end();
|
|
}
|
|
|
|
function sendNotFound(res: http.ServerResponse) {
|
|
res.statusCode = 404;
|
|
res.end();
|
|
}
|
|
|
|
function sendMethodNotAllowed(res: http.ServerResponse, allowedMethods: string[]) {
|
|
res.statusCode = 405;
|
|
res.setHeader("Allow", allowedMethods);
|
|
res.end();
|
|
}
|
|
|
|
function sendNotAcceptable(res: http.ServerResponse) {
|
|
res.statusCode = 406;
|
|
res.end();
|
|
}
|
|
|
|
function sendPreconditionFailed(res: http.ServerResponse) {
|
|
res.statusCode = 412;
|
|
res.end();
|
|
}
|
|
|
|
function sendUnsupportedMediaType(res: http.ServerResponse) {
|
|
res.statusCode = 415;
|
|
res.end();
|
|
}
|
|
|
|
function sendRangeNotSatisfiable(res: http.ServerResponse) {
|
|
res.statusCode = 416;
|
|
res.end();
|
|
}
|
|
|
|
function sendInternalServerError(res: http.ServerResponse, error: Error) {
|
|
console.error(error);
|
|
return sendContent(res, /*statusCode*/ 500, error.stack, "text/plain; charset=utf8");
|
|
}
|
|
|
|
function sendNotImplemented(res: http.ServerResponse) {
|
|
res.statusCode = 501;
|
|
res.end();
|
|
}
|
|
|
|
function shouldIgnoreCache(url: URL) {
|
|
switch (url.pathname) {
|
|
case "/built/local/bundle.js":
|
|
case "/built/local/bundle.js.map":
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isAcceptable(req: http.ServerRequest, contentType: string) {
|
|
const mediaType = parseMediaType(contentType);
|
|
return isAcceptableMediaType(req, mediaType)
|
|
&& isAcceptableCharSet(req, mediaType)
|
|
&& isAcceptableEncoding(req);
|
|
}
|
|
|
|
function isAcceptableMediaType(req: http.ServerRequest, mediaType: MediaType) {
|
|
if (!req.headers.accept) return true;
|
|
const acceptedMediaType = bestMatch(mediaType, parseMediaTypes(req.headers.accept), matchesMediaType);
|
|
return acceptedMediaType ? getQuality(acceptedMediaType) > 0 : false;
|
|
}
|
|
|
|
function isAcceptableCharSet(req: http.ServerRequest, mediaType: MediaType) {
|
|
if (!req.headers["accept-charset"]) return true;
|
|
const acceptedCharSet = bestMatch(mediaType.charset || "utf-8", parseStringsWithQuality(req.headers["accept-charset"]), matchesCharSet);
|
|
return acceptedCharSet ? getQuality(acceptedCharSet) > 0 : false;
|
|
}
|
|
|
|
function isAcceptableEncoding(req: http.ServerRequest) {
|
|
if (!req.headers["accept-encoding"]) return true;
|
|
const acceptedEncoding = bestMatch(/*value*/ undefined, parseStringsWithQuality(req.headers["accept-encoding"]), (_, pattern) => pattern.value === "*" || pattern.value === "identity");
|
|
return acceptedEncoding ? getQuality(acceptedEncoding) > 0 : true;
|
|
}
|
|
|
|
function shouldSendNotModified(req: http.ServerRequest, stats: fs.Stats, etag: string) {
|
|
const ifNoneMatch = tryParseETags(req.headers["if-none-match"]);
|
|
if (ifNoneMatch) return matchesETag(etag, ifNoneMatch);
|
|
|
|
const ifModifiedSince = tryParseDate(req.headers["if-modified-since"]);
|
|
if (ifModifiedSince) return stats.mtime.getTime() <= ifModifiedSince.getTime();
|
|
|
|
return false;
|
|
}
|
|
|
|
function shouldSendPreconditionFailed(req: http.ServerRequest, stats: fs.Stats, etag: string) {
|
|
const ifMatch = tryParseETags(req.headers["if-match"]);
|
|
if (ifMatch && !matchesETag(etag, ifMatch)) return true;
|
|
|
|
const ifUnmodifiedSince = tryParseDate(req.headers["if-unmodified-since"]);
|
|
if (ifUnmodifiedSince && stats.mtime.getTime() > ifUnmodifiedSince.getTime()) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
function handleGetRequest(req: http.ServerRequest, res: http.ServerResponse) {
|
|
const url = new URL(req.url, baseUrl);
|
|
if (url.pathname === "/") {
|
|
url.pathname = "/tests/webTestResults.html";
|
|
return sendFound(res, url.toString());
|
|
}
|
|
|
|
const file = toServerPath(url);
|
|
fs.stat(file, (err, stats) => {
|
|
try {
|
|
if (err) {
|
|
if (err.code === "ENOENT") return sendNotFound(res);
|
|
return sendInternalServerError(res, err);
|
|
}
|
|
if (stats && stats.isFile()) {
|
|
const contentType = guessMediaType(file);
|
|
if (!isAcceptable(req, contentType)) return sendNotAcceptable(res);
|
|
|
|
const etag = computeETag(stats);
|
|
if (shouldSendNotModified(req, stats, etag)) return sendNotModified(res);
|
|
if (shouldSendPreconditionFailed(req, stats, etag)) return sendPreconditionFailed(res);
|
|
|
|
if (shouldIgnoreCache(url)) res.setHeader("Cache-Control", "no-store");
|
|
res.setHeader("Last-Modified", stats.mtime.toUTCString());
|
|
res.setHeader("ETag", etag);
|
|
res.setHeader("Content-Type", contentType);
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
|
|
const ranges = req.headers.range && tryParseByteRanges(req.headers.range, stats.size);
|
|
if (ranges && ranges.length === 0) return sendRangeNotSatisfiable(res);
|
|
|
|
let start: number | undefined;
|
|
let end: number | undefined;
|
|
if (ranges && ranges.length === 1) {
|
|
start = ranges[0].start;
|
|
end = ranges[0].end;
|
|
if (start >= stats.size || end > stats.size) return sendRangeNotSatisfiable(res);
|
|
res.statusCode = 206;
|
|
res.setHeader("Content-Length", end - start);
|
|
res.setHeader("Content-Range", `bytes ${start}-${end - 1}/${stats.size}`);
|
|
}
|
|
else {
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Length", stats.size);
|
|
}
|
|
if (req.method === "HEAD") return res.end();
|
|
const readable = fs.createReadStream(file, { start, end, autoClose: true });
|
|
readable.on("error", err => sendInternalServerError(res, err));
|
|
readable.pipe(res, { end: true });
|
|
}
|
|
else {
|
|
if (req.headers["if-match"] === "*") return sendPreconditionFailed(res);
|
|
return sendNotFound(res);
|
|
}
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handlePutRequest(req: http.ServerRequest, res: http.ServerResponse) {
|
|
if (req.headers["content-encoding"]) return sendUnsupportedMediaType(res);
|
|
if (req.headers["content-range"]) return sendNotImplemented(res);
|
|
const file = toServerPath(req.url);
|
|
fs.stat(file, (err, stats) => {
|
|
try {
|
|
if (err && err.code !== "ENOENT") return sendInternalServerError(res, err);
|
|
if (stats && !stats.isFile()) return sendMethodNotAllowed(res, []);
|
|
return mkdirp(path.dirname(file), err => {
|
|
if (err) return sendInternalServerError(res, err);
|
|
try {
|
|
const writable = fs.createWriteStream(file, { autoClose: true });
|
|
writable.on("error", err => sendInternalServerError(res, err));
|
|
writable.on("finish", () => {
|
|
if (stats) return sendNoContent(res);
|
|
fs.stat(file, (err, stats) => {
|
|
if (err) return sendInternalServerError(res, err);
|
|
return sendCreated(res, toClientPath(file), computeETag(stats));
|
|
});
|
|
});
|
|
req.pipe(writable, { end: true });
|
|
return;
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleDeleteRequest(req: http.ServerRequest, res: http.ServerResponse) {
|
|
const file = toServerPath(req.url);
|
|
fs.stat(file, (err, stats) => {
|
|
try {
|
|
if (err && err.code !== "ENOENT") return sendInternalServerError(res, err);
|
|
if (!stats) return sendNotFound(res);
|
|
if (stats.isFile()) return fs.unlink(file, handleResult);
|
|
if (stats.isDirectory()) return fs.rmdir(file, handleResult);
|
|
return sendNotFound(res);
|
|
function handleResult(err: NodeJS.ErrnoException) {
|
|
if (err && err.code !== "ENOENT") return sendInternalServerError(res, err);
|
|
if (err) return sendNotFound(res);
|
|
return sendNoContent(res);
|
|
}
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleOptionsRequest(req: http.ServerRequest, res: http.ServerResponse) {
|
|
res.setHeader("X-Case-Sensitivity", useCaseSensitiveFileNames ? "CS" : "CI");
|
|
return sendNoContent(res);
|
|
}
|
|
|
|
function handleApiResolve(req: http.ServerRequest, res: http.ServerResponse) {
|
|
readContent(req, (err, content) => {
|
|
try {
|
|
if (err) return sendInternalServerError(res, err);
|
|
if (!content) return sendBadRequest(res);
|
|
const serverPath = toServerPath(content);
|
|
const clientPath = toClientPath(serverPath);
|
|
if (clientPath === undefined) return sendBadRequest(res);
|
|
return sendContent(res, /*statusCode*/ 200, clientPath, /*contentType*/ "text/plain;charset=utf-8");
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleApiEnumerateTestFiles(req: http.ServerRequest, res: http.ServerResponse) {
|
|
readContent(req, (err, content) => {
|
|
try {
|
|
if (err) return sendInternalServerError(res, err);
|
|
if (!content) return sendBadRequest(res);
|
|
const tests: (string | FileBasedTest)[] = enumerateTestFiles(content);
|
|
return sendJson(res, /*statusCode*/ 200, tests);
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function enumerateTestFiles(runner: string) {
|
|
switch (runner) {
|
|
case "conformance":
|
|
case "compiler":
|
|
return listFiles(`tests/cases/${runner}`, /*serverDirname*/ undefined, /\.tsx?$/, { recursive: true }).map(parseCompilerTestConfigurations);
|
|
case "fourslash":
|
|
return listFiles(`tests/cases/fourslash`, /*serverDirname*/ undefined, /\.ts/i, { recursive: false });
|
|
case "fourslash-shims":
|
|
return listFiles(`tests/cases/fourslash/shims`, /*serverDirname*/ undefined, /\.ts/i, { recursive: false });
|
|
case "fourslash-shims-pp":
|
|
return listFiles(`tests/cases/fourslash/shims-pp`, /*serverDirname*/ undefined, /\.ts/i, { recursive: false });
|
|
case "fourslash-server":
|
|
return listFiles(`tests/cases/fourslash/server`, /*serverDirname*/ undefined, /\.ts/i, { recursive: false });
|
|
default:
|
|
throw new Error(`Runner '${runner}' not supported in browser tests.`);
|
|
}
|
|
}
|
|
|
|
// Regex for parsing options in the format "@Alpha: Value of any sort"
|
|
const optionRegex = /^[\/]{2}\s*@(\w+)\s*:\s*([^\r\n]*)/gm; // multiple matches on multiple lines
|
|
|
|
function extractCompilerSettings(content: string): Record<string, string> {
|
|
const opts: Record<string, string> = {};
|
|
|
|
let match: RegExpExecArray;
|
|
while ((match = optionRegex.exec(content)) !== null) {
|
|
opts[match[1]] = match[2].trim();
|
|
}
|
|
|
|
return opts;
|
|
}
|
|
|
|
function splitVaryBySettingValue(text: string): string[] | undefined {
|
|
if (!text) return undefined;
|
|
const entries = text.split(/,/).map(s => s.trim().toLowerCase()).filter(s => s.length > 0);
|
|
return entries && entries.length > 1 ? entries : undefined;
|
|
}
|
|
|
|
function computeFileBasedTestConfigurationVariations(configurations: FileBasedTestConfiguration[], variationState: FileBasedTestConfiguration, varyByEntries: [string, string[]][], offset: number) {
|
|
if (offset >= varyByEntries.length) {
|
|
// make a copy of the current variation state
|
|
configurations.push({ ...variationState });
|
|
return;
|
|
}
|
|
|
|
const [varyBy, entries] = varyByEntries[offset];
|
|
for (const entry of entries) {
|
|
// set or overwrite the variation
|
|
variationState[varyBy] = entry;
|
|
computeFileBasedTestConfigurationVariations(configurations, variationState, varyByEntries, offset + 1);
|
|
}
|
|
}
|
|
|
|
function getFileBasedTestConfigurations(settings: Record<string, string>, varyBy: string[]): FileBasedTestConfiguration[] | undefined {
|
|
let varyByEntries: [string, string[]][] | undefined;
|
|
for (const varyByKey of varyBy) {
|
|
if (Object.prototype.hasOwnProperty.call(settings, varyByKey)) {
|
|
const entries = splitVaryBySettingValue(settings[varyByKey]);
|
|
if (entries) {
|
|
if (!varyByEntries) varyByEntries = [];
|
|
varyByEntries.push([varyByKey, entries]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!varyByEntries) return undefined;
|
|
|
|
const configurations: FileBasedTestConfiguration[] = [];
|
|
computeFileBasedTestConfigurationVariations(configurations, {}, varyByEntries, 0);
|
|
return configurations;
|
|
}
|
|
|
|
function parseCompilerTestConfigurations(file: string): FileBasedTest {
|
|
const content = fs.readFileSync(path.join(rootDir, file), "utf8");
|
|
const settings = extractCompilerSettings(content);
|
|
const configurations = getFileBasedTestConfigurations(settings, ["module", "target"]);
|
|
return { file, configurations };
|
|
}
|
|
|
|
function handleApiListFiles(req: http.ServerRequest, res: http.ServerResponse) {
|
|
readContent(req, (err, content) => {
|
|
try {
|
|
if (err) return sendInternalServerError(res, err);
|
|
if (!content) return sendBadRequest(res);
|
|
const serverPath = toServerPath(content);
|
|
const files = listFiles(content, serverPath, /*spec*/ undefined, { recursive: true });
|
|
return sendJson(res, /*statusCode*/ 200, files);
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function listFiles(clientDirname: string, serverDirname: string = path.resolve(rootDir, clientDirname), spec?: RegExp, options: { recursive?: boolean } = {}): string[] {
|
|
const files: string[] = [];
|
|
visit(serverDirname, clientDirname, files);
|
|
return files;
|
|
|
|
function visit(dirname: string, relative: string, results: string[]) {
|
|
const { files, directories } = getAccessibleFileSystemEntries(dirname);
|
|
for (const file of files) {
|
|
if (!spec || file.match(spec)) {
|
|
results.push(path.join(relative, file));
|
|
}
|
|
}
|
|
for (const directory of directories) {
|
|
if (options.recursive) {
|
|
visit(path.join(dirname, directory), path.join(relative, directory), results);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleApiDirectoryExists(req: http.ServerRequest, res: http.ServerResponse) {
|
|
readContent(req, (err, content) => {
|
|
try {
|
|
if (err) return sendInternalServerError(res, err);
|
|
if (!content) return sendBadRequest(res);
|
|
const serverPath = toServerPath(content);
|
|
fs.stat(serverPath, (err, stats) => {
|
|
try {
|
|
if (err && err.code !== "ENOENT") return sendInternalServerError(res, err);
|
|
return sendJson(res, /*statusCode*/ 200, !!stats && stats.isDirectory());
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleApiGetAccessibleFileSystemEntries(req: http.ServerRequest, res: http.ServerResponse) {
|
|
readContent(req, (err, content) => {
|
|
try {
|
|
if (err) return sendInternalServerError(res, err);
|
|
if (!content) return sendBadRequest(res);
|
|
const serverPath = toServerPath(content);
|
|
return sendJson(res, /*statusCode*/ 200, getAccessibleFileSystemEntries(serverPath));
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handlePostRequest(req: http.ServerRequest, res: http.ServerResponse) {
|
|
// API responses should not be cached
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
switch (new URL(req.url, baseUrl).pathname) {
|
|
case "/api/resolve": return handleApiResolve(req, res);
|
|
case "/api/listFiles": return handleApiListFiles(req, res);
|
|
case "/api/enumerateTestFiles": return handleApiEnumerateTestFiles(req, res);
|
|
case "/api/directoryExists": return handleApiDirectoryExists(req, res);
|
|
case "/api/getAccessibleFileSystemEntries": return handleApiGetAccessibleFileSystemEntries(req, res);
|
|
default: return sendMethodNotAllowed(res, ["HEAD", "GET", "PUT", "DELETE", "OPTIONS"]);
|
|
}
|
|
}
|
|
|
|
function handleRequest(req: http.ServerRequest, res: http.ServerResponse) {
|
|
try {
|
|
switch (req.method) {
|
|
case "HEAD":
|
|
case "GET": return handleGetRequest(req, res);
|
|
case "PUT": return handlePutRequest(req, res);
|
|
case "POST": return handlePostRequest(req, res);
|
|
case "DELETE": return handleDeleteRequest(req, res);
|
|
case "OPTIONS": return handleOptionsRequest(req, res);
|
|
default: return sendMethodNotAllowed(res, ["HEAD", "GET", "PUT", "POST", "DELETE"]);
|
|
}
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
}
|
|
|
|
function startServer() {
|
|
console.log(`Static file server running at\n => http://localhost:${port}/\nCTRL + C to shutdown`);
|
|
return http.createServer(handleRequest).listen(port);
|
|
}
|
|
|
|
const REG_COLUMN_PADDING = 4;
|
|
|
|
function queryRegistryValue(keyPath: string, callback: (error: Error | null, value: string) => void) {
|
|
const args = ["query", keyPath];
|
|
child_process.execFile("reg", ["query", keyPath, "/ve"], { encoding: "utf8" }, (error, stdout) => {
|
|
if (error) return callback(error, null);
|
|
|
|
const valueLine = stdout.replace(/^\r\n.+?\r\n|\r\n\r\n$/g, "");
|
|
if (!valueLine) {
|
|
return callback(new Error("Unable to retrieve value."), null);
|
|
}
|
|
|
|
const valueNameColumnOffset = REG_COLUMN_PADDING;
|
|
if (valueLine.lastIndexOf("(Default)", valueNameColumnOffset) !== valueNameColumnOffset) {
|
|
return callback(new Error("Unable to retrieve value."), null);
|
|
}
|
|
|
|
const dataTypeColumnOffset = valueNameColumnOffset + "(Default)".length + REG_COLUMN_PADDING;
|
|
if (valueLine.lastIndexOf("REG_SZ", dataTypeColumnOffset) !== dataTypeColumnOffset) {
|
|
return callback(new Error("Unable to retrieve value."), null);
|
|
}
|
|
|
|
const valueColumnOffset = dataTypeColumnOffset + "REG_SZ".length + REG_COLUMN_PADDING;
|
|
const value = valueLine.slice(valueColumnOffset);
|
|
return callback(null, value);
|
|
});
|
|
}
|
|
|
|
interface Browser {
|
|
description: string;
|
|
command: string;
|
|
}
|
|
|
|
function createBrowserFromPath(path: string): Browser {
|
|
return { description: path, command: path };
|
|
}
|
|
|
|
function getChromePath(callback: (error: Error | null, browser: Browser | string | null) => void) {
|
|
switch (os.platform()) {
|
|
case "win32":
|
|
return queryRegistryValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe", (error, value) => {
|
|
if (error) return callback(null, "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe");
|
|
return callback(null, createBrowserFromPath(value));
|
|
});
|
|
case "darwin": return callback(null, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
|
|
case "linux": return callback(null, "/opt/google/chrome/chrome");
|
|
default: return callback(new Error(`Chrome location is unknown for platform '${os.platform()}'`), null);
|
|
}
|
|
}
|
|
|
|
function getEdgePath(callback: (error: Error | null, browser: Browser | null) => void) {
|
|
switch (os.platform()) {
|
|
case "win32": return callback(null, { description: "Microsoft Edge", command: "cmd /c start microsoft-edge:%1" });
|
|
default: return callback(new Error(`Edge location is unknown for platform '${os.platform()}'`), null);
|
|
}
|
|
}
|
|
|
|
function getBrowserPath(callback: (error: Error | null, browser: Browser | null) => void) {
|
|
switch (browser) {
|
|
case "chrome": return getChromePath(afterGetBrowserPath);
|
|
case "edge": return getEdgePath(afterGetBrowserPath);
|
|
default: return callback(new Error(`Browser location is unknown for '${browser}'`), null);
|
|
}
|
|
|
|
function afterGetBrowserPath(error: Error | null, browser: Browser | string | null) {
|
|
if (error) return callback(error, null);
|
|
if (typeof browser === "object") return callback(null, browser);
|
|
return fs.stat(browser, (error, stats) => {
|
|
if (!error && stats.isFile()) {
|
|
return callback(null, createBrowserFromPath(browser));
|
|
}
|
|
if (browser === "chrome") return callback(null, createBrowserFromPath("chrome"));
|
|
return callback(new Error(`Browser location is unknown for '${browser}'`), null);
|
|
});
|
|
}
|
|
}
|
|
|
|
function startClient(server: http.Server) {
|
|
let browserPath: string;
|
|
if (browser === "none") {
|
|
return;
|
|
}
|
|
|
|
getBrowserPath((error, browser) => {
|
|
if (error) return console.error(error);
|
|
console.log(`Using browser: ${browser.description}`);
|
|
const queryString = grep ? `?grep=${grep}` : "";
|
|
const args = [`http://localhost:${port}/tests/webTestResults.html${queryString}`];
|
|
if (browser.command.indexOf("%") === -1) {
|
|
child_process.spawn(browser.command, args);
|
|
}
|
|
else {
|
|
const command = browser.command.replace(/%(\d+)/g, (_, offset) => args[+offset - 1]);
|
|
child_process.exec(command);
|
|
}
|
|
});
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log("Runs an http server on port 8888, looking for tests folder in the current directory\n");
|
|
console.log("Syntax: node webTestServer.js [browser] [tests] [--verbose]\n");
|
|
console.log("Options:");
|
|
console.log(" <browser> The browser to launch. One of 'edge', 'chrome', or 'none' (default 'edge' on Windows, otherwise `chrome`).");
|
|
console.log(" <tests> A regular expression to pass to Mocha.");
|
|
console.log(" --verbose Enables verbose logging.");
|
|
}
|
|
|
|
function parseCommandLine(args: string[]) {
|
|
const parsed = minimist(args, { boolean: ["help", "verbose"] });
|
|
if (parsed.help) {
|
|
printHelp();
|
|
return false;
|
|
}
|
|
|
|
if (parsed.verbose) {
|
|
verbose = true;
|
|
}
|
|
|
|
const [parsedBrowser = defaultBrowser, parsedGrep, ...unrecognized] = parsed._;
|
|
if (parsedBrowser !== "edge" && parsedBrowser !== "chrome" && parsedBrowser !== "none") {
|
|
console.log(`Unrecognized browser '${parsedBrowser}', expected 'edge', 'chrome', or 'none'.`);
|
|
return false;
|
|
}
|
|
|
|
if (unrecognized.length > 0) {
|
|
console.log(`Unrecognized argument: ${unrecognized[0]}`);
|
|
return false;
|
|
}
|
|
|
|
browser = parsedBrowser;
|
|
grep = parsedGrep;
|
|
return true;
|
|
}
|
|
|
|
function log(msg: string) {
|
|
if (verbose) {
|
|
console.log(msg);
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
if (parseCommandLine(process.argv.slice(2))) {
|
|
startClient(startServer());
|
|
}
|
|
}
|
|
|
|
main();
|
|
// tslint:enable:no-null-keyword
|