841 lines
30 KiB
TypeScript
841 lines
30 KiB
TypeScript
/// <reference types="node" />
|
|
// tslint:disable:no-null-keyword
|
|
|
|
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();
|
|
|
|
let browser = "IE";
|
|
let grep: string | undefined;
|
|
let verbose = false;
|
|
|
|
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;
|
|
}
|
|
|
|
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 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: string[] = [];
|
|
visit(serverPath, content, files);
|
|
return sendJson(res, /*statusCode*/ 200, files);
|
|
function visit(dirname: string, relative: string, results: string[]) {
|
|
const { files, directories } = getAccessibleFileSystemEntries(dirname);
|
|
for (const file of files) {
|
|
results.push(path.join(relative, file));
|
|
}
|
|
for (const directory of directories) {
|
|
visit(path.join(dirname, directory), path.join(relative, directory), results);
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
return sendInternalServerError(res, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
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/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);
|
|
}
|
|
|
|
function startClient(server: http.Server) {
|
|
let browserPath: string;
|
|
if (browser === "none") {
|
|
return;
|
|
}
|
|
|
|
if (browser === "chrome") {
|
|
let defaultChromePath = "";
|
|
switch (os.platform()) {
|
|
case "win32":
|
|
defaultChromePath = "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe";
|
|
break;
|
|
case "darwin":
|
|
defaultChromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
break;
|
|
case "linux":
|
|
defaultChromePath = "/opt/google/chrome/chrome";
|
|
break;
|
|
default:
|
|
console.log(`default Chrome location is unknown for platform '${os.platform()}'`);
|
|
break;
|
|
}
|
|
if (fs.existsSync(defaultChromePath)) {
|
|
browserPath = defaultChromePath;
|
|
}
|
|
else {
|
|
browserPath = browser;
|
|
}
|
|
}
|
|
else {
|
|
const defaultIEPath = "C:/Program Files/Internet Explorer/iexplore.exe";
|
|
if (fs.existsSync(defaultIEPath)) {
|
|
browserPath = defaultIEPath;
|
|
}
|
|
else {
|
|
browserPath = browser;
|
|
}
|
|
}
|
|
|
|
console.log(`Using browser: ${browserPath}`);
|
|
|
|
const queryString = grep ? `?grep=${grep}` : "";
|
|
const child = child_process.spawn(browserPath, [`http://localhost:${port}/tests/webTestResults.html${queryString}`], {
|
|
stdio: "inherit"
|
|
});
|
|
}
|
|
|
|
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 'IE', 'chrome', or 'none' (default 'IE').");
|
|
console.log(" <tests> A regular expression to pass to Mocha.");
|
|
console.log(" --verbose Enables verbose logging.");
|
|
}
|
|
|
|
function parseCommandLine(args: string[]) {
|
|
let offset = 0;
|
|
for (const arg of args) {
|
|
const argLower = arg.toLowerCase();
|
|
if (argLower === "--help") {
|
|
printHelp();
|
|
return false;
|
|
}
|
|
else if (argLower === "--verbose") {
|
|
verbose = true;
|
|
}
|
|
else {
|
|
if (offset === 0) {
|
|
browser = arg;
|
|
}
|
|
else if (offset === 1) {
|
|
grep = arg;
|
|
}
|
|
else {
|
|
console.log(`Unrecognized argument: ${arg}\n`);
|
|
return false;
|
|
}
|
|
offset++;
|
|
}
|
|
}
|
|
|
|
if (browser !== "IE" && browser !== "chrome" && browser !== "none") {
|
|
console.log(`Unrecognized browser '${browser}', expected 'IE' or 'chrome'.`);
|
|
return false;
|
|
}
|
|
|
|
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
|