plugin-html/test/util/serve-test.ts

287 lines
8.4 KiB
TypeScript

/**
* Puppeteer + from-memory devServer rollup plugin to open the result in a webpage en output the result
* (after an optional series of commands to the puppeteer Page)
*/
import {puppeteerRunTest, PageTestCallback, TestOutput} from "./puppeteer-run-test.ts";
import {isInDebugMode} from "./debug-mode.ts";
import {resolve, posix} from "node:path";
import fs from "node:fs/promises";
import type {Stats} from "node:fs";
import { createServer as createHttpsServer } from 'https'
import { createServer} from 'http'
import { Mime } from 'mime/lite'
import standardTypes from 'mime/types/standard.js'
import otherTypes from 'mime/types/other.js'
import type {NormalizedOutputOptions, OutputAsset, OutputBundle, OutputChunk, Plugin} from 'rollup';
import type {
IncomingHttpHeaders, OutgoingHttpHeaders,
IncomingMessage, ServerResponse,
Server
} from 'http'
import type { ServerOptions } from 'https'
type TypeMap = {
[key: string]: string[];
};
type ErrorCodeException = Error & {code: string};
export type TestResultCallback = (output: TestOutput)=>void;
export type LogCallback = (...args: string[])=>void;
export interface ServeTestOptions {
/**
* Change the path to be opened when the test is started
* Remember to start with a slash, e.g. `'/different/page'`
*/
path?: string
/**
* Fallback to serving from a specified srcDir, this allows setting breakpoints on sourcecode and test the sourcemaps
*/
srcDir?: string|boolean;
/**
* A callback to manually take control of the page and simulate user interactions
*/
cb?: PageTestCallback;
/**
* Set to `true` to return index.html (200) instead of error page (404)
* or path to fallback page
*/
historyApiFallback?: boolean | string
/**
* Change the host of the server (default: `'localhost'`)
*/
host?: string
/**
* Change the port that the server will listen on (default: `10001`)
*/
port?: number | string
/**
* By default server will be served over HTTP (https: `false`). It can optionally be served over HTTPS.
*/
https?: ServerOptions | false
/**
* Set custom response headers
*/
headers?:
| IncomingHttpHeaders
| OutgoingHttpHeaders
| {
// i.e. Parameters<OutgoingMessage["setHeader"]>
[name: string]: number | string | ReadonlyArray<string>
}
/**
* Set custom mime types, usage https://github.com/broofa/mime#mimedefinetypemap-force--false
*/
mimeTypes?: TypeMap
/**
* Execute function after server has begun listening
*/
onListening?: (server: Server) => void
}
export interface RollupServeTestOptions extends ServeTestOptions{
/**
* A callback to run when a test has been run
*/
onResult?: TestResultCallback;
/**
* Callback to log messages
*/
log?: LogCallback;
}
/**
* Serve your rolled up bundle like webpack-dev-server
* @param {import('..').RollupServeOptions} options
*/
export default function serveTest (options: RollupServeTestOptions ): Plugin {
const mime = new Mime(standardTypes, otherTypes)
const testOptions = {
port: 0,
headers: {},
historyApiFallback: true,
srcDir: '', // Serve source dir as fallback (for sourcemaps / debugging)
onListening: function noop (){},
...options||{},
https: options.https??false,
mimeTypes: options.mimeTypes? mime.define(options.mimeTypes, true): false
}
let server : Server;
let bundle : OutputBundle = {};
const logTest = (msg: string, mode: 'info'|'warn' = 'info')=>{
if(isInDebugMode()){
console.log(msg);
}
const modeColor = {
green: 32,
info: 34,
warn: 33,
}[mode];
testOptions.log?.(`\u001b[${modeColor}m${msg}\u001b[0m`);
}
const requestListener = async (request: IncomingMessage, response: ServerResponse) => {
// Remove querystring
const unsafePath = decodeURI(request.url!.split('?')[0])
// Don't allow path traversal
const urlPath = posix.normalize(unsafePath)
for(const [key, value] of Object.entries((<OutgoingHttpHeaders>testOptions.headers))){
response.setHeader(key, value!);
}
function urlToFilePath(url:string){
return url[0]==='/'?url.slice(1):url;
}
let filePath = urlToFilePath(urlPath);
let absPath: string | undefined = undefined;
let stats: Stats | undefined = undefined;
if(!bundle[filePath]){
if(testOptions.srcDir || testOptions.srcDir===''){
try{
absPath = resolve(<string>testOptions.srcDir||'',filePath);
stats = await fs.stat(absPath);
}catch(err){
// File not found
}
}
if(!(stats?.isFile()) && testOptions.historyApiFallback) {
const fallbackPath = typeof testOptions.historyApiFallback === 'string'
? testOptions.historyApiFallback
: '/index.html';
if(bundle[urlToFilePath(fallbackPath)]){
filePath = urlToFilePath(fallbackPath);
}
}
}
const mimeType = mime.getType(filePath!);
if(bundle[filePath]) {
let file: OutputChunk | OutputAsset = bundle[filePath];
const content = (<OutputChunk>file).code || (<OutputAsset>file).source; // Todo might need to read a source file;
response.writeHead(200, {'Content-Type': mimeType || 'text/plain'});
response.end(content, 'utf-8');
logTest(`[200] ${request.url}`);
return;
}else if(stats?.isFile()){
response.writeHead(200, {
'Content-Type': mimeType || 'text/plain',
'Content-Length': stats.size,
'Last-Modified': stats.mtime.toUTCString()
});
const content = await fs.readFile(absPath!);
response.end(content);
response.end();
logTest(`[200] ${request.url} (src)`);
}else{
response.writeHead(404)
response.end(
'404 Not Found' + '\n\n' + filePath,
'utf-8'
)
logTest(`[404] ${request.url}`, "warn");
return;
}
}
function closeServerOnTermination () {
const terminationSignals = ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP']
terminationSignals.forEach(signal => {
process.on(signal, () => {
if (server) {
server.close()
process.exit()
}
})
})
}
// release previous server instance if rollup is reloading configuration in watch mode
// @ts-ignore
if (server) {
server.close()
} else {
closeServerOnTermination()
}
// If HTTPS options are available, create an HTTPS server
server = testOptions.https
? createHttpsServer(testOptions.https, requestListener)
: createServer(requestListener)
server.listen(
typeof(testOptions.port)==='string'? Number.parseInt(testOptions.port):testOptions.port,
testOptions.host,
undefined,
() => testOptions.onListening?.(server)
)
testOptions.port = (<any>server.address())?.port ?? testOptions.port;
// Assemble url for error and info messages
const url = (testOptions.https ? 'https' : 'http') + '://' + (testOptions.host || 'localhost') + ':' + testOptions.port
// Handle common server errors
server.on('error', e => {
if ((<ErrorCodeException>e).code === 'EADDRINUSE') {
console.error(url + ' is in use, either stop the other server or use a different port.')
process.exit()
} else {
throw e
}
})
let first = true
return {
name: 'serve',
generateBundle: {
order: 'post',
async handler(options, output){
bundle = output;
if (first) {
first = false
const testOutput = await puppeteerRunTest({
...testOptions
}, url);
testOptions.onResult?.(testOutput);
}
}
},
closeBundle (){
// Done with the bundle
}
}
}