Compare commits

...

2 Commits

9 changed files with 330 additions and 160 deletions

View File

@ -265,6 +265,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
} while (nodeQueue.length > 0);
}
// Beware leak of AST (we're starting MagicString on a parsed and modified version of the HTML file, sourcemappings in the HTML file will be off. (can't add a sourcemap for a html file anyway, unless it is outputted as JS module)
let htmlJS = new MagicString(serializeHtml(htmlModule.document));
htmlJS.replaceAll(/`/g,'\\\`').replaceAll(/\$\{/g,'\\${');

View File

@ -5,22 +5,11 @@ import { rollup } from "rollup";
import urlPlugin from "@rollup/plugin-url";
import html from "../../src/index.ts";
import serveTest from "../util/test-server.ts";
/**
* @type {OutputOptions}
*/
const output= {
dir: 'output', // Output all files
format: 'es', // iifi and cjs should be added to tests
sourcemap: true,// Test if #sourcemapUrl is not accidentally included in the html-output
chunkFileNames: '[name].js',
entryFileNames: '[name].[extname]',
assetFileNames: '[name].[extname]',
};
import {runBrowserTest} from "../util/browser-test.ts";
import {fileURLToPath} from "node:url";
import handlebars from "handlebars";
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(join(__dirname, 'fixtures'));
@ -32,7 +21,7 @@ const defaultAssetInclude = [
];
test.serial('web-bundle', async (t) => {
const bundle = await rollup({
const out = await runBrowserTest({
input: 'index.hbs',
treeshake: 'smallest',
plugins: [
@ -46,13 +35,19 @@ test.serial('web-bundle', async (t) => {
urlPlugin({
include: defaultAssetInclude,
}),
serveTest({
path: 'index.html',
t,
})
],
}, {
path: 'index.html',
log: t.log,
},{
dir: 'output', // Output all files
format: 'es', // iifi and cjs should be added to tests
sourcemap: true,// Test if #sourcemapUrl is not accidentally included in the html-output
chunkFileNames: '[name].js',
entryFileNames: '[name].[extname]',
assetFileNames: '[name].[extname]',
});
await bundle.generate(output);
t.snapshot(out);
// await bundle.generate(output);
});

View File

@ -12,24 +12,11 @@ import typescriptPlugin from "@rollup/plugin-typescript";
import replacePlugin from "@rollup/plugin-replace";
import html from "../../src/index.ts";
import serveTest from "../util/test-server.ts";
/**
* @type {OutputOptions}
*/
const output= {
dir: 'output', // Output all files
format: 'es', // iifi and cjs should be added to tests
sourcemap: true,// Test if #sourcemapUrl is not accidentally included in the html-output
chunkFileNames: '[name].js',
entryFileNames: '[name].[extname]',
assetFileNames: '[name].[extname]',
};
import {runBrowserTest} from "../util/browser-test.ts";
import {fileURLToPath} from "node:url";
import handlebars from "handlebars";
// import {debugPrintOutput, getCode, runBrowserTest} from "../util/index.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(join(__dirname, 'fixtures'));
@ -41,7 +28,7 @@ const defaultAssetInclude = [
];
test.serial('web-bundle', async (t) => {
const bundle = await rollup({
const out = await runBrowserTest({
input: 'index.hbs',
treeshake: 'smallest',
plugins: [
@ -78,13 +65,21 @@ test.serial('web-bundle', async (t) => {
urlPlugin({
include: defaultAssetInclude,
}),
serveTest({
path: 'index.html',
t,
})
],
}, {
path: 'index.html',
log: t.log,
},{
dir: 'output', // Output all files
format: 'es', // iifi and cjs should be added to tests
sourcemap: true,// Test if #sourcemapUrl is not accidentally included in the html-output
chunkFileNames: '[name].js',
entryFileNames: '[name].[extname]',
assetFileNames: '[name].[extname]',
});
const generated = await bundle.generate(output);
t.snapshot(out);
// const code = await getCode(bundle, output);
// debugPrintOutput('jsx-web-app',code);
});

View File

@ -1,24 +1,17 @@
import {resolve, join, dirname} from "node:path";
import * as path from "node:path";
import test from "ava";
import { rollup } from "rollup";
import {debugPrintOutput, getCode} from "../util/index.ts";
import {runBrowserTest} from "../util/index.ts";
import html from "../../src/index.ts";
const output = {
dir: 'output', // Output all files
format: 'es', // iifi and cjs should be added to tests
sourcemap: true,// Test if #sourcemapUrl is not accidentally included in the html-output
};
import {fileURLToPath} from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(join(__dirname, 'fixtures'));
test.serial('rewrite-url', async (t) => {
const bundle = await rollup({
const out = await runBrowserTest({
input: {
['index']: 'index.html',
['admin/index']: resolve(__dirname,'fixtures','admin/index.html'),
@ -30,11 +23,38 @@ test.serial('rewrite-url', async (t) => {
return `/${rootPath}`;
}
}),
]
],
},{
log: t.log,
filterOutput:{
// TODO: Currently only need the "await getCode(bundle, output);" as output
},
path: '/admin'
}, {
dir: 'output', // Output all files
format: 'es', // iifi and cjs should be added to tests
sourcemap: true,// Test if #sourcemapUrl is not accidentally included in the html-output
});
const code = await getCode(bundle, output);
debugPrintOutput('rewrite-url',code);
t.snapshot(code);
t.snapshot(out.code); // Snapshot the result code
// const bundle = await rollup({
// input: {
// ['index']: 'index.html',
// ['admin/index']: resolve(__dirname,'fixtures','admin/index.html'),
// ['admin/app']: resolve(__dirname,'fixtures','admin/app.js'),
// },
// plugins: [
// html({
// rewriteUrl(relative, {rootPath, from}){
// return `/${rootPath}`;
// }
// }),
// ]
// });
// const code = await getCode(bundle, output);
// debugPrintOutput('rewrite-url',code);
// t.snapshot(code);
});
// TODO various parameters

129
test/util/browser-test.ts Normal file
View File

@ -0,0 +1,129 @@
import {Plugin, InputPluginOption, RollupOptions, OutputOptions, RollupOutput} from "rollup";
import {TestOptions as BrowserTestOptions, TestOutput as PuppeteerTestOutput} from "./puppeteer-run-test.js";
import { rollup } from "rollup";
import serveTest, {LogCallback} from "./serve-test.js";
import type {ExecutionContext} from "ava";
import {getCode, TestOutput} from "./code-output.ts";
// /**
// * The AVA context used to test (ie t.snapshot(..) )
// */
// t: ExecutionContext
//
//
// filterOutput:{
// html: true,
// console: ['log','error','warn'],// TODO: or warning? need to check what possible values are
// errors: true, // again don't know possible values
// responses: true, // interesting to see what other values were requested
// requestsFailed: true, // will probably also be replicated into console errors, but helpful to have if imports werent found
// }
// try{
// // Track requests, errors and console
// page.on('console', message => {
// let [type, text] = [message.type(), message.text()];
// if(replaceHost){
// text = text.replaceAll(hostUrl, replaceHostWith!);
// }
// if((<any>filterOutput.console)?.includes?.(<any>type) ?? (filterOutput.console === true)){// TODO: add callback option
// output.console?.push(`[${type}] ${text}`);
// }
// }).on('pageerror', ({ message }) => {
// let text = message;
// if(replaceHost){
// text = text.replaceAll(hostUrl, replaceHostWith!);
// }
// if(filterOutput.errors === true) {// TODO add callback option
// output.errors?.push(text)
// }
// }).on('response', response => {
// let [status, url] = [response.status(), response.url()]
// if(replaceHost){
// url = url.replaceAll(hostUrl, replaceHostWith!);
// }
// if(filterOutput.responses === true) {// TODO add callback option
// output.responses?.push(`${status} ${url}`)
// }
// }).on('requestfailed', request => {
// let [failure, url] = [request.failure()?.errorText, request.url()];
// if(replaceHost){
// failure = failure?.replaceAll(hostUrl, replaceHostWith!);
// url = url.replaceAll(hostUrl, replaceHostWith!);
// }
// if(filterOutput.requestsFailed === true) {// TODO add callback option
// output.requestsFailed?.push(`${failure} ${url}`)
// }
// });
// testOptions.t?.snapshot?.(testOutput);
export interface OutputFilterOptions {
html?: boolean
console?: ('log'|'error'|'warn')[] | true
errors?: boolean, // again don't know possible values
responses?: boolean, // interesting to see what other values were requested
requestsFailed?: boolean, // will probably also be replicated into console errors, but helpful to have if imports werent found
}
export interface BrowserTestInput extends BrowserTestOptions{
log?: LogCallback;
/**
* Optionally specify what to filter from the output
*/
filterOutput?: OutputFilterOptions;
}
export interface BrowserTestOutput extends PuppeteerTestOutput{
code: TestOutput[];
}
export async function runBrowserTest(
build: RollupOptions,
test?: BrowserTestInput | false,
output?: OutputOptions
) : Promise<Partial<BrowserTestOutput>>{
const resolvedPlugins = await Promise.resolve(build.plugins||null);
let pluginsArray : InputPluginOption[] = [];
if(resolvedPlugins && resolvedPlugins instanceof Array){
pluginsArray = resolvedPlugins
}else if(resolvedPlugins){
pluginsArray = [resolvedPlugins];
}
let testOutput: Partial<BrowserTestOutput> = {};
const bundle = await rollup({
...build,
plugins: [
...pluginsArray,
// TODO check if browser output is requested (either for snapshot or for testing)
...(test? [serveTest({
// TODO: intercept output from the serveTest? (and include as one bit in output options below, for snapshotting)
...test,
log: test.log ?? console.log,
onResult: (output)=>{
testOutput = {...testOutput, ...output};
}
})]: [])
]
});
// TODO make configurable?
const generated = await bundle.generate({
dir: 'output', // Output all files
format: 'es', // iifi and cjs should be added to tests
sourcemap: true,// Test if #sourcemapUrl is not accidentally included in the html-output
chunkFileNames: '[name].js',
entryFileNames: '[name].mjs',
assetFileNames: '[name].[extname]',
});
if(output){
testOutput.code = await getCode(bundle, output);
}
return testOutput
}

View File

@ -1,6 +1,8 @@
// TODO: this should be the main module used, other should be imported manually if exceptions are needed?
export * from "./browser-test.ts";
export * from "./code-output.ts";
export * from "./print-code-output.ts";
export * from "./test-server.ts";
export * from "./serve-test.ts";
export * from './misc.js';
// export * from './misc.js';

View File

@ -1,3 +1,5 @@
// This is still from the old rollup plugin we forked from. For now not used.
import path from "node:path";
import process from "node:process";

View File

@ -10,44 +10,29 @@ import {isInDebugMode} from "./debug-mode.ts";
export type PageTestCallback = (page: Page)=>Promise<void>;
export interface TestFilterOptions{
html?: boolean
console?: ('log'|'error'|'warn')[] | true
errors?: boolean, // again don't know possible values
responses?: boolean, // interesting to see what other values were requested
requestsFailed?: boolean, // will probably also be replicated into console errors, but helpful to have if imports werent found
}
export interface TestOptions {
page: string
path: string
cb: PageTestCallback
filterOutput: TestFilterOptions
replaceHost: boolean
replaceHostWith?: string
}
const defaultOptions: Partial<TestOptions> = {
page: 'index.html',
path: 'index.html',
cb: async (page: Page)=>{
await page.waitForNetworkIdle({});
},
replaceHost: true,
replaceHostWith: `http://localhost`,
filterOutput:{
html: true,
console: ['log','error','warn'],// TODO: or warning? need to check what possible values are
errors: true, // again don't know possible values
responses: true, // interesting to see what other values were requested
requestsFailed: true, // will probably also be replicated into console errors, but helpful to have if imports werent found
}
}
export interface TestOutput{
html?: string,
console?: string[],
errors?: string[],
responses?: string[],
requestsFailed?: string[],
html: string,
console: string[],
errors: string[],
responses: string[],
requestsFailed: string[],
}
/**
* Opens a page in a puppeteer browser and return the resulting HTML and logmessages produced.
* Optionally a callback can be provided to simulate user interactions on the page before returning the HTML
@ -56,21 +41,16 @@ export interface TestOutput{
* @param opts
* @param hostUrl
*/
export async function runTest(opts: Partial<TestOptions>, hostUrl: string){
export async function puppeteerRunTest(opts: Partial<TestOptions>, hostUrl: string){
const options : TestOptions = (<TestOptions>{
...defaultOptions,
...opts,
filterOutput: {
...defaultOptions.filterOutput,
...(opts?.filterOutput),
},
});
const {
page: path,
path,
cb,
replaceHost,
replaceHostWith,
filterOutput
} = options;
const browser = await puppeteer.launch({
@ -79,64 +59,64 @@ export async function runTest(opts: Partial<TestOptions>, hostUrl: string){
const page = await browser.newPage();
let output : TestOutput = {
html: '',
console: [],
errors: [],
responses: [],
requestsFailed: []
};
try{
let errored = false;
try {
// Track requests, errors and console
page.on('console', message => {
let [type, text] = [message.type(), message.text()];
if(replaceHost){
if (replaceHost) {
text = text.replaceAll(hostUrl, replaceHostWith!);
}
if((<any>filterOutput.console)?.includes?.(<any>type) ?? (filterOutput.console === true)){// TODO: add callback option
output.console?.push(`[${type}] ${text}`);
}
}).on('pageerror', ({ message }) => {
}).on('pageerror', ({message}) => {
let text = message;
if(replaceHost){
if (replaceHost) {
text = text.replaceAll(hostUrl, replaceHostWith!);
}
if(filterOutput.errors === true) {// TODO add callback option
output.errors?.push(text)
}
output.errors?.push(text);
}).on('response', response => {
let [status, url] = [response.status(), response.url()]
if(replaceHost){
if (replaceHost) {
url = url.replaceAll(hostUrl, replaceHostWith!);
}
if(filterOutput.responses === true) {// TODO add callback option
output.responses?.push(`${status} ${url}`)
}
output.responses?.push(`${status} ${url}`);
}).on('requestfailed', request => {
let [failure, url] = [request.failure()?.errorText, request.url()];
if(replaceHost){
if (replaceHost) {
failure = failure?.replaceAll(hostUrl, replaceHostWith!);
url = url.replaceAll(hostUrl, replaceHostWith!);
}
if(filterOutput.requestsFailed === true) {// TODO add callback option
output.requestsFailed?.push(`${failure} ${url}`)
}
output.requestsFailed?.push(`${failure} ${url}`);
});
const url = new URL(`${hostUrl}/${path??''}`);
const url = new URL(path??'', hostUrl);
await page.goto(url.href);
if(!cb) {
if (!cb) {
await page.waitForNetworkIdle({});
}else{
} else {
await cb(page);
}
const htmlHandle = await page.$('html');
const html = await page.evaluate(html => html?.outerHTML??html?.innerHTML, htmlHandle);
const html = await page.evaluate(html => html?.outerHTML ?? html?.innerHTML, htmlHandle);
// Add the final html
output.html = html;
output.html = html || '';
return output;
}catch(err){
errored = true;
throw err;
}finally{
if(isInDebugMode()){
if(isInDebugMode() && !errored){
console.log(`DEBUG MODE ENABLED, Close the puppeteer browsertab to continue!\n${import.meta.url}:144`);
await new Promise((resolve)=>{
page.on('close', ()=>{
@ -147,9 +127,7 @@ export async function runTest(opts: Partial<TestOptions>, hostUrl: string){
}else{
await page.close();
}
await browser.close();
}
return output;
}

View File

@ -4,10 +4,12 @@
*/
import {runTest, TestFilterOptions, PageTestCallback} from "./run-browser-test.ts";
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'
@ -26,33 +28,36 @@ import type {
} from 'http'
import type { ServerOptions } from 'https'
import test, {ExecutionContext} from "ava";
import {createReadStream} from "fs";
type TypeMap = {
[key: string]: string[];
};
type ErrorCodeException = Error & {code: string};
export type TestResultCallback = (output: TestOutput)=>void;
export type LogCallback = (...args: string[])=>void;
export interface RollupServeTestOptions {
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
/**
* Optionally specify what to filter from the output
* Fallback to serving from a specified srcDir, this allows setting breakpoints on sourcecode and test the sourcemaps
*/
filterOutput?: TestFilterOptions;
srcDir?: string|boolean;
/**
* A callback to manually take control of the page and simulate user interactions
*/
cb?: PageTestCallback
/**
* The AVA context used to test (ie t.snapshot(..) )
*/
t: any
cb?: PageTestCallback;
/**
* Set to `true` to return index.html (200) instead of error page (404)
@ -95,8 +100,23 @@ export interface RollupServeTestOptions {
* 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
@ -107,6 +127,7 @@ export default function serveTest (options: RollupServeTestOptions ): Plugin {
port: 0,
headers: {},
historyApiFallback: true,
srcDir: '', // Serve source dir as fallback (for sourcemaps / debugging)
onListening: function noop (){},
...options||{},
https: options.https??false,
@ -116,7 +137,19 @@ export default function serveTest (options: RollupServeTestOptions ): Plugin {
let server : Server;
let bundle : OutputBundle = {};
const requestListener = (request: IncomingMessage, response: ServerResponse) => {
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])
@ -130,9 +163,20 @@ export default function serveTest (options: RollupServeTestOptions ): Plugin {
function urlToFilePath(url:string){
return url[0]==='/'?url.slice(1):url;
}
let filePath = urlToFilePath(urlPath); // Todo check if we need to strip '/'
let file: OutputChunk|OutputAsset;
if(!bundle[filePath] && testOptions.historyApiFallback) {
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';
@ -140,12 +184,35 @@ export default function serveTest (options: RollupServeTestOptions ): Plugin {
filePath = urlToFilePath(fallbackPath);
}
}
file = bundle[filePath];
if(!file){
return notFound(response, filePath);
}else{
}
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;
return found(response, mime.getType(filePath!), content);
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;
}
}
@ -207,12 +274,11 @@ export default function serveTest (options: RollupServeTestOptions ): Plugin {
if (first) {
first = false
const testOutput = await runTest({
page: testOptions.path!,
cb: testOptions.cb,
filterOutput: testOptions.filterOutput,
}, url)
testOptions.t?.snapshot?.(testOutput);
const testOutput = await puppeteerRunTest({
...testOptions
}, url);
testOptions.onResult?.(testOutput);
}
}
},
@ -221,21 +287,3 @@ export default function serveTest (options: RollupServeTestOptions ): Plugin {
}
}
}
function notFound (response: ServerResponse, filePath: string) {
response.writeHead(404)
response.end(
'404 Not Found' + '\n\n' + filePath,
'utf-8'
)
}
function found (response: ServerResponse, mimeType: string|null, content: any) {
response.writeHead(200, { 'Content-Type': mimeType || 'text/plain' })
response.end(content, 'utf-8')
}
function green (text: string) {
return '\u001b[1m\u001b[32m' + text + '\u001b[39m\u001b[22m'
}