test: added a react application test (in the browser)

This commit is contained in:
Miel Truyen 2024-01-07 00:23:20 +01:00
parent e96c2248ee
commit 3e46055845
22 changed files with 1669 additions and 220 deletions

View File

@ -63,12 +63,18 @@
"@babel/core": "^7.23.3", "@babel/core": "^7.23.3",
"@babel/plugin-syntax-import-assertions": "^7.23.3", "@babel/plugin-syntax-import-assertions": "^7.23.3",
"@babel/preset-typescript": "^7.23.3", "@babel/preset-typescript": "^7.23.3",
"@babel/preset-env": "^7.23.6",
"@babel/preset-react": "^7.23.3",
"@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.5", "@rollup/plugin-typescript": "^11.1.5",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-url": "^8.0.2", "@rollup/plugin-url": "^8.0.2",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-replace": "^5.0.5",
"@types/node": "^18.18.12", "@types/node": "^18.18.12",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"ava": "^5.3.1", "ava": "^5.3.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"del-cli": "^5.1.0", "del-cli": "^5.1.0",

1226
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -77,25 +77,25 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
let filter = createFilter(include, exclude, {}); let filter = createFilter(include, exclude, {});
// TODO, we need to clear all these properly at sme point to avoid odd bugs in watch mode // TODO, we need to clear all these properly at sme point to avoid odd bugs in watch mode
// let htmlModules = new Map<string, HtmlModule>();// todo clean this per new build?
let virtualSources = new Map<string, string>(); let virtualSources = new Map<string, string>();
let addedEntries = new Map<string, string>(); let addedEntries = new Map<string, string>();
let entryNames = new Map<string,string>(); let entryNames = new Map<string,string>();
const pluginName = 'html2'; // TODO: Need a better name const pluginName = 'html2'; // TODO: Need a better name, and work to strip everything noted below except the short summary
/** /**
* Short summary: * Short summary:
* Intercepts the loading of the html files and parses it with parse5. * Intercepts the loading of the html files and parses it with parse5.
* The parsed result is iterated to check for external references that need to be including in the rollup build (via for example @rollup/plugin-url). * The parsed result is iterated to check for external references that need to be including in the rollup build (via for example @rollup/plugin-url).
* To satisfy rollup a JS version of the html file is returned, optionally including a few imports left for rollup to resolve * A .js version of the html file is returned to rollup, optionally including a few imports left for rollup to resolve
* When the result is generated the rollup result for the html file and any of its inlined assets are stripped from the output. * When the result is generated the rollup result for the html file and any of its inlined assets are stripped from the output.
* and replaced with the html file. * and replaced with a html file.
* *
* Caveats: * Caveats:
* - to get the resulting html content file we're parsing the evaluating the result JS module and take its default export * - to get the resulting html content file we're evaluating the resulting JS module and take its default export
* This evaluation step is done in the host NodeJS context, which might screw up things that expect a browser context
* [warn] other plugins such as CJS transformer and hot-reload can severely screw this up. * [warn] other plugins such as CJS transformer and hot-reload can severely screw this up.
* - sourcemaps won't work yet (to add support we'd need to rewrite using magic-string?)
* - to fix the naming of resulting html files, and behave properly when files are entryPoints or not... we're fighting rollup alot * - to fix the naming of resulting html files, and behave properly when files are entryPoints or not... we're fighting rollup alot
* issues are likely...
* *
* *
* Rework by testing a stripped down version with JS imports? * Rework by testing a stripped down version with JS imports?
@ -182,6 +182,10 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
// Parse document and store it // Parse document and store it
const document = htmlModule.document = parseHtml(htmlSrc); const document = htmlModule.document = parseHtml(htmlSrc);
// TODO working on this: to preserve sourcemaps as much as possible we're starting the magic string on the raw html source
// question is if we need to though. sourcemaps only make sense for inlined bits of code
//let htmlJS = new MagicString(htmlSrc);// This is where we want to go!
// Figure out which references to load from this HTML by iterating all nodes (looking for src or href attributes) // Figure out which references to load from this HTML by iterating all nodes (looking for src or href attributes)
let htmlImports: HtmlImport[] = htmlModule.imports = []; let htmlImports: HtmlImport[] = htmlModule.imports = [];
if (document.childNodes) { if (document.childNodes) {
@ -261,8 +265,9 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
} while (nodeQueue.length > 0); } while (nodeQueue.length > 0);
} }
let htmlJS = new MagicString(serializeHtml(htmlModule.document));// TODO this is still a leak of AST, we're taking parse5 edited result and then transforming sourcemaps, this will only work when no edits were made let htmlJS = new MagicString(serializeHtml(htmlModule.document));
htmlJS.replaceAll(/`/g,'\\\`').replaceAll(/\$\{/g,'\\${'); htmlJS.replaceAll(/`/g,'\\\`').replaceAll(/\$\{/g,'\\${');
const moduleImports = []; const moduleImports = [];
for(const htmlImport of htmlImports){ for(const htmlImport of htmlImports){
if(htmlImport.type === 'default') { if(htmlImport.type === 'default') {
@ -275,6 +280,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
} }
} }
// Import all dependencies and wrap the HTML in a `...`, assign to a var and export (escaping any ` characters in the HTML)
htmlJS.prepend([ htmlJS.prepend([
...moduleImports, ...moduleImports,
`export const html = \`` `export const html = \``

View File

@ -0,0 +1,13 @@
export async function app({root}){
const states = ['started', 'tick', 'ended'];
for(let state of states){
const text = `App ${state}`;
console.log(`Test my sourcemap: ${text}`);
root.innerHTML = `<div style="align-self: center"><b>${text}</b></div>`;
await new Promise((resolve,reject)=>
setTimeout(()=>resolve(), 10)
);
}
}

View File

@ -1 +0,0 @@
export const batman = 'bum badum badum baaaaa dum!';

View File

@ -13,6 +13,6 @@
</head> </head>
<body> <body>
<div id="root">Here the app should load!</div> <div id="root">Here the app should load!</div>
<script src="./index.js" type="module"></script> <script src="./index.mjs" type="module"></script>
</body> </body>
</html> </html>

View File

@ -9,7 +9,7 @@
const [ const [
appModule, appModule,
] = await Promise.all([ ] = await Promise.all([
import("./batman.js"), import("./app.mjs"),
]); ]);
console.log("Bootstrapped, ready to go!"); console.log("Bootstrapped, ready to go!");
@ -20,7 +20,7 @@
} }
// Start the app! // Start the app!
root.innerHTML = `<div style="align-self: center"><b>${appModule.batman}</b></div>`; await appModule.app({root});
}catch(err){ }catch(err){
console.error(err); console.error(err);
} }

View File

@ -11,6 +11,9 @@ Generated by [AVA](https://avajs.dev).
{ {
console: [ console: [
'[log] Bootstrapped, ready to go!', '[log] Bootstrapped, ready to go!',
'[log] Test my sourcemap: App started',
'[log] Test my sourcemap: App tick',
'[log] Test my sourcemap: App ended',
], ],
errors: [], errors: [],
html: `<html lang="en"><head> html: `<html lang="en"><head>
@ -25,7 +28,7 @@ Generated by [AVA](https://avajs.dev).
<title>I'm cool!</title> <title>I'm cool!</title>
</head> </head>
<body> <body>
<div id="root"><div style="align-self: center"><b>bum badum badum baaaaa dum!</b></div></div> <div id="root"><div style="align-self: center"><b>App ended</b></div></div>
<script src="index.js" type="module"></script> <script src="index.js" type="module"></script>
@ -34,40 +37,6 @@ Generated by [AVA](https://avajs.dev).
responses: [ responses: [
'200 http://localhost/index.html', '200 http://localhost/index.html',
'200 http://localhost/index.js', '200 http://localhost/index.js',
'200 http://localhost/batman.js', '200 http://localhost/app.js',
],
}
## copied-assets
> Snapshot 1
{
console: [
'[log] Bootstrapped, ready to go!',
],
errors: [],
html: `<html lang="en"><head>
<meta charset="UTF-8">
<title>
Test bundle!␊
</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%3E%20%20%20%20%3Ctitle%3EHTML5%20Logo%3C%2Ftitle%3E%20%20%20%20%3Cpath%20d%3D%22M108.4%200h23v22.8h21.2V0h23v69h-23V46h-21v23h-23.2M206%2023h-20.3V0h63.7v23H229v46h-23M259.5%200h24.1l14.8%2024.3L313.2%200h24.1v69h-23V34.8l-16.1%2024.8l-16.1-24.8v34.2h-22.6M348.7%200h23v46.2h32.6V69h-55.6%22%2F%3E%20%20%20%20%3Cpath%20fill%3D%22%23e44d26%22%20d%3D%22M107.6%20471l-33-370.4h362.8l-33%20370.2L255.7%20512%22%2F%3E%20%20%20%20%3Cpath%20fill%3D%22%23f16529%22%20d%3D%22M256%20480.5V131H404.3L376%20447%22%2F%3E%20%20%20%20%3Cpath%20fill%3D%22%23ebebeb%22%20d%3D%22M142%20176.3h114v45.4h-64.2l4.2%2046.5h60v45.3H154.4M156.4%20336.3H202l3.2%2036.3%2050.8%2013.6v47.4l-93.2-26%22%2F%3E%20%20%20%20%3Cpath%20fill%3D%22%23fff%22%20d%3D%22M369.6%20176.3H255.8v45.4h109.6M361.3%20268.2H255.8v45.4h56l-5.3%2059-50.7%2013.6v47.2l93-25.8%22%2F%3E%3C%2Fsvg%3E">
<title>I'm cool!</title>
</head>
<body>
<div id="root"><div style="align-self: center"><b>bum badum badum baaaaa dum!</b></div></div>
<script src="index.js" type="module"></script>
</body></html>`,
requestsFailed: [],
responses: [
'200 http://localhost/index.html',
'200 http://localhost/index.js',
'200 http://localhost/batman.js',
], ],
} }

View File

@ -1,9 +1,11 @@
<html> <html>
<head> <head>
<link rel="icon" href="./icon.svg"> <link rel="icon" href="./icon.svg">
<!-- TODO: support for css imports are yet to be added (as simple assets or through a preprocessor-->
<!-- <link rel="stylesheet" href="./joker.css">--> <!-- <link rel="stylesheet" href="./joker.css">-->
</head> </head>
<body> <body>
<!-- TODO: this shouldn't have been commented out, but our plugin fails if it is included (which shoudn't happen!!) -->
<!--<script src="./batman.js" type="module"></script>--> <!--<script src="./batman.js" type="module"></script>-->
</body> </body>
</html> </html>

View File

@ -14,9 +14,11 @@ Generated by [AVA](https://avajs.dev).
const html = \`<html><head> const html = \`<html><head>
<link rel="icon" href="${asset0}"> <link rel="icon" href="${asset0}">
<!-- TODO: support for css imports are yet to be added (as simple assets or through a preprocessor-->
<!-- <link rel="stylesheet" href="./joker.css">--> <!-- <link rel="stylesheet" href="./joker.css">-->
</head> </head>
<body> <body>
<!-- TODO: this shouldn't have been commented out, but our plugin fails if it is included (which shoudn't happen!!) -->
<!--<script src="./batman.js" type="module"></script>--> <!--<script src="./batman.js" type="module"></script>-->
@ -27,26 +29,28 @@ Generated by [AVA](https://avajs.dev).
}␊ }␊
export { render };␊ export { render };␊
//# sourceMappingURL=index-f75fa1e5.js.map␊ //# sourceMappingURL=index-3d1ca61b.js.map␊
`, `,
fileName: 'index-f75fa1e5.js', fileName: 'index-3d1ca61b.js',
map: SourceMap { map: SourceMap {
file: 'index-f75fa1e5.js', file: 'index-3d1ca61b.js',
mappings: 'AAAA,aAAe;;ACAf,MAAA,IAAA,GAAA,CAAA;AACA,+BAA+B,EAAwD,MAAA,CAAA;AACvF;AACA;AACA;AACA;AACA;AACA;AACA,cAAa,CAAA;;ACNN,SAAS,MAAM,EAAE;AACxB,IAAI,OAAO,IAAI,CAAC;AAChB;;;;', mappings: 'AAAA,aAAe;;ACAf,MAAA,IAAA,GAAA,CAAA;AACA,+BAA+B,EAAwD,MAAA,CAAA;AACvF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,cAAa,CAAA;;ACRN,SAAS,MAAM,EAAE;AACxB,IAAI,OAAO,IAAI,CAAC;AAChB;;;;',
names: [], names: [],
sources: [ sources: [
'../icon.svg', '../icon.svg',
'../index.html', '../index.html',
'../index.ts', '../index.js',
], ],
sourcesContent: [ sourcesContent: [
'export default "data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2032%2032%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%20%20%3Cpath%20style%3D%22fill%3Anone%3Bstroke%3A%2300ff0d%3Bstroke-width%3A5%3Bstroke-linecap%3Asquare%3Bstroke-linejoin%3Amiter%3Bstroke-dasharray%3Anone%3Bstroke-opacity%3A1%22%20d%3D%22M4.1%2014.72%2016%2026.31%2028.38%205.09%22%2F%3E%3C%2Fsvg%3E"', 'export default "data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2032%2032%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%20%20%3Cpath%20style%3D%22fill%3Anone%3Bstroke%3A%2300ff0d%3Bstroke-width%3A5%3Bstroke-linecap%3Asquare%3Bstroke-linejoin%3Amiter%3Bstroke-dasharray%3Anone%3Bstroke-opacity%3A1%22%20d%3D%22M4.1%2014.72%2016%2026.31%2028.38%205.09%22%2F%3E%3C%2Fsvg%3E"',
`<html> `<html>
<head> <head>
<link rel="icon" href="./icon.svg"> <link rel="icon" href="./icon.svg">
<!-- TODO: support for css imports are yet to be added (as simple assets or through a preprocessor-->
<!-- <link rel="stylesheet" href="./joker.css">--> <!-- <link rel="stylesheet" href="./joker.css">-->
</head> </head>
<body> <body>
<!-- TODO: this shouldn't have been commented out, but our plugin fails if it is included (which shoudn't happen!!) -->
<!--<script src="./batman.js" type="module"></script>--> <!--<script src="./batman.js" type="module"></script>-->
</body> </body>
</html> </html>
@ -64,8 +68,8 @@ Generated by [AVA](https://avajs.dev).
}, },
{ {
code: undefined, code: undefined,
fileName: 'index-f75fa1e5.js.map', fileName: 'index-3d1ca61b.js.map',
map: undefined, map: undefined,
source: '{"version":3,"file":"index-f75fa1e5.js","sources":["../icon.svg","../index.html","../index.ts"],"sourcesContent":["export default \\"data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2032%2032%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%20%20%3Cpath%20style%3D%22fill%3Anone%3Bstroke%3A%2300ff0d%3Bstroke-width%3A5%3Bstroke-linecap%3Asquare%3Bstroke-linejoin%3Amiter%3Bstroke-dasharray%3Anone%3Bstroke-opacity%3A1%22%20d%3D%22M4.1%2014.72%2016%2026.31%2028.38%205.09%22%2F%3E%3C%2Fsvg%3E\\"","<html>\\n <head>\\n <link rel=\\"icon\\" href=\\"./icon.svg\\">\\n<!-- <link rel=\\"stylesheet\\" href=\\"./joker.css\\">-->\\n </head>\\n <body>\\n <!--<script src=\\"./batman.js\\" type=\\"module\\"></script>-->\\n </body>\\n</html>\\n","import html from \\"./index.html\\"\\n\\nexport function render(){\\n return html;\\n}\\n"],"names":[],"mappings":"AAAA,aAAe;;ACAf,MAAA,IAAA,GAAA,CAAA;AACA,+BAA+B,EAAwD,MAAA,CAAA;AACvF;AACA;AACA;AACA;AACA;AACA;AACA,cAAa,CAAA;;ACNN,SAAS,MAAM,EAAE;AACxB,IAAI,OAAO,IAAI,CAAC;AAChB;;;;"}', source: '{"version":3,"file":"index-3d1ca61b.js","sources":["../icon.svg","../index.html","../index.js"],"sourcesContent":["export default \\"data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2032%2032%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%20%20%3Cpath%20style%3D%22fill%3Anone%3Bstroke%3A%2300ff0d%3Bstroke-width%3A5%3Bstroke-linecap%3Asquare%3Bstroke-linejoin%3Amiter%3Bstroke-dasharray%3Anone%3Bstroke-opacity%3A1%22%20d%3D%22M4.1%2014.72%2016%2026.31%2028.38%205.09%22%2F%3E%3C%2Fsvg%3E\\"","<html>\\n <head>\\n <link rel=\\"icon\\" href=\\"./icon.svg\\">\\n <!-- TODO: support for css imports are yet to be added (as simple assets or through a preprocessor-->\\n<!-- <link rel=\\"stylesheet\\" href=\\"./joker.css\\">-->\\n </head>\\n <body>\\n <!-- TODO: this shouldn\'t have been commented out, but our plugin fails if it is included (which shoudn\'t happen!!) -->\\n <!--<script src=\\"./batman.js\\" type=\\"module\\"></script>-->\\n </body>\\n</html>\\n","import html from \\"./index.html\\"\\n\\nexport function render(){\\n return html;\\n}\\n"],"names":[],"mappings":"AAAA,aAAe;;ACAf,MAAA,IAAA,GAAA,CAAA;AACA,+BAA+B,EAAwD,MAAA,CAAA;AACvF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,cAAa,CAAA;;ACRN,SAAS,MAAM,EAAE;AACxB,IAAI,OAAO,IAAI,CAAC;AAChB;;;;"}',
}, },
] ]

View File

@ -0,0 +1,36 @@
import {createRoot} from "react-dom/client";
import {StrictMode, useEffect, useState} from "react";
const states = ['started', 'tick', 'ended'];
export function App(){
const [state, setState] = useState(states[0])
useEffect(()=>{
let timeout: any;
let nextState = states[states.indexOf(state)+1];
if(nextState) {
timeout = setTimeout(() => {
console.log(`Test my sourcemap: ${nextState}`);
setState(nextState)
}, 10);
}
return ()=>{
if(timeout) {
clearTimeout(timeout);
}
}
}, [state])
return (<div style={{alignSelf: "center"}}>
<b>{state}</b>
</div>);
}
export async function start({root: rootContainer}: {root: HTMLElement}){
if(!rootContainer) throw new Error("Missing root element");
const root = createRoot(rootContainer);
root.render(<StrictMode>
<App />
</StrictMode>);
}

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<title>HTML5 Logo</title>
<path d="M108.4 0h23v22.8h21.2V0h23v69h-23V46h-21v23h-23.2M206 23h-20.3V0h63.7v23H229v46h-23M259.5 0h24.1l14.8 24.3L313.2 0h24.1v69h-23V34.8l-16.1 24.8l-16.1-24.8v34.2h-22.6M348.7 0h23v46.2h32.6V69h-55.6"/>
<path fill="#e44d26" d="M107.6 471l-33-370.4h362.8l-33 370.2L255.7 512"/>
<path fill="#f16529" d="M256 480.5V131H404.3L376 447"/>
<path fill="#ebebeb" d="M142 176.3h114v45.4h-64.2l4.2 46.5h60v45.3H154.4M156.4 336.3H202l3.2 36.3 50.8 13.6v47.4l-93.2-26"/>
<path fill="#fff" d="M369.6 176.3H255.8v45.4h109.6M361.3 268.2H255.8v45.4h56l-5.3 59-50.7 13.6v47.2l93-25.8"/>
</svg>

After

Width:  |  Height:  |  Size: 693 B

View File

@ -0,0 +1,14 @@
export default {
presets: [
["@babel/preset-env", {
shippedProposals: true,
}],
["@babel/preset-typescript", {
}],
["@babel/preset-react", {
development: process.env.BABEL_ENV === "development",
runtime: "automatic"
}]
],
}

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>
Test bundle!
</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="./assets/logo-sq.svg">
{{{ head }}}
</head>
<body>
<div id="root">Here the app should load!</div>
<script src="./index.mjs" type="module"></script>
</body>
</html>

View File

@ -0,0 +1,27 @@
// Dynamically loads libraries and bootstraps the application
(async ()=>{
// Add a loader here if any
const root = document.getElementById('root')
if(root) root.innerHTML= `<div style="align-self: center">My app has loaded!!</div>`;
try {
// Load app
const [
appModule,
] = await Promise.all([
import("./app.tsx"),
]);
console.log("Bootstrapped, ready to go!");
// Wait for DOM to be ready
if(document.readyState === 'loading') {
await new Promise((resolve)=>document.addEventListener('DOMContentLoaded', resolve));
}
// Start the app!
await appModule.start({root});
}catch(err){
console.error(err);
}
})()

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ESNext",
"moduleResolution": "Bundler",
"module": "ESNext",
"strict": true,
"paths":{
"react": ["./node_modules/@types/react"]
},
"allowSyntheticDefaultImports": true,
"allowUnreachableCode": true,
"allowUnusedLabels": true,
"noUnusedLocals": false
},
}

90
test/jsx-web-app/test.js Normal file
View File

@ -0,0 +1,90 @@
import {join, dirname} from "node:path";
import test from "ava";
// Rollup * plugins
import { rollup } from "rollup";
import urlPlugin from "@rollup/plugin-url";
import nodeResolve from "@rollup/plugin-node-resolve";
import babelPlugin from "@rollup/plugin-babel";
import commonJsPlugin from "@rollup/plugin-commonjs";
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 {fileURLToPath} from "node:url";
import handlebars from "handlebars";
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(join(__dirname, 'fixtures'));
const defaultAssetInclude = [
'**/*.(png|jpg|jpeg|gif|ico|svg)',// images, svg
'**/*.(woff|woff2|eot|ttf|otf)',// fonts
'**/*.(webm|mp4)',// video
];
test.serial('web-bundle', async (t) => {
const bundle = await rollup({
input: 'index.hbs',
treeshake: 'smallest',
plugins: [
html({
transform(src) {
return handlebars.compile(src)({
head: `<title>I'm cool!</title>`
});
}
}),
nodeResolve({
extensions: ['.js', '.mjs', '.jsx', '.ts', '.tsx'],
browser: true,
}),
commonJsPlugin({
}),
typescriptPlugin({
sourceMap: true,
// exclude: 'node_modules/**',
noEmitOnError: true,
outputToFilesystem: false,
noForceEmit: true,
jsx: "preserve",
}),
babelPlugin({
extensions: ['.js', '.mjs', '.jsx', '.ts', '.tsx'],
babelHelpers: "bundled",
}),
replacePlugin({
preventAssignment: false,
'process.env.NODE_ENV': process.env.NODE_ENV==='production'?`'${process.env.NODE_ENV}'` : '"development"'
}),
urlPlugin({
include: defaultAssetInclude,
}),
serveTest({
path: 'index.html',
t,
})
],
});
const generated = await bundle.generate(output);
});

5
test/util/debug-mode.ts Normal file
View File

@ -0,0 +1,5 @@
import inspector from 'node:inspector';
export function isInDebugMode() {
return (inspector.url() !== undefined) || process.env.DEBUG;
}

View File

@ -0,0 +1,155 @@
/**
* 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 puppeteer, {Page} from "puppeteer";
import {fileURLToPath, URL} from "node:url";
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
cb: PageTestCallback
filterOutput: TestFilterOptions
replaceHost: boolean
replaceHostWith?: string
}
const defaultOptions: Partial<TestOptions> = {
page: '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[],
}
/**
* 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
* When DEBUG mode is detected, puppeteer headless mode will be disabled allowing you to inspect the page if you set a breakpoint
*
* @param opts
* @param hostUrl
*/
export async function runTest(opts: Partial<TestOptions>, hostUrl: string){
const options : TestOptions = (<TestOptions>{
...defaultOptions,
...opts,
filterOutput: {
...defaultOptions.filterOutput,
...(opts?.filterOutput),
},
});
const {
page: path,
cb,
replaceHost,
replaceHostWith,
filterOutput
} = options;
const browser = await puppeteer.launch({
headless: isInDebugMode()? false : 'new',
});
const page = await browser.newPage();
let output : TestOutput = {
console: [],
errors: [],
responses: [],
requestsFailed: []
};
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}`)
}
});
const url = new URL(`${hostUrl}/${path??''}`);
await page.goto(url.href);
if(!cb) {
await page.waitForNetworkIdle({});
}else{
await cb(page);
}
const htmlHandle = await page.$('html');
const html = await page.evaluate(html => html?.outerHTML??html?.innerHTML, htmlHandle);
// Add the final html
output.html = html;
}finally{
if(isInDebugMode()){
console.log(`DEBUG MODE ENABLED, Close the puppeteer browsertab to continue!\n${import.meta.url}:144`);
await new Promise((resolve)=>{
page.on('close', ()=>{
console.log("Page closed");
resolve(null);
})
});
}else{
await page.close();
}
await browser.close();
}
return output;
}

View File

@ -4,21 +4,20 @@
*/ */
import puppeteer, {Page} from "puppeteer"; import {runTest, TestFilterOptions, PageTestCallback} from "./run-browser-test.ts";
import http from 'http'; import {isInDebugMode} from "./debug-mode.ts";
import {resolve, posix} from "node:path"; import {resolve, posix} from "node:path";
import {URL} from "node:url";
import { readFile } from 'fs'
import { createServer as createHttpsServer } from 'https' import { createServer as createHttpsServer } from 'https'
import { createServer} from 'http' import { createServer} from 'http'
import { Mime } from 'mime/lite' import { Mime } from 'mime/lite'
import standardTypes from 'mime/types/standard.js' import standardTypes from 'mime/types/standard.js'
import otherTypes from 'mime/types/other.js' import otherTypes from 'mime/types/other.js'
import type {NormalizedOutputOptions, OutputAsset, OutputBundle, OutputChunk, Plugin} from 'rollup' import type {NormalizedOutputOptions, OutputAsset, OutputBundle, OutputChunk, Plugin} from 'rollup';
import type { import type {
IncomingHttpHeaders, OutgoingHttpHeaders, IncomingHttpHeaders, OutgoingHttpHeaders,
@ -26,21 +25,34 @@ import type {
Server Server
} from 'http' } from 'http'
import type { ServerOptions } from 'https' import type { ServerOptions } from 'https'
type TypeMap = { type TypeMap = {
[key: string]: string[]; [key: string]: string[];
}; };
type ErrorCodeException = Error & {code: string}; type ErrorCodeException = Error & {code: string};
export interface RollupServeOptions { export interface RollupServeTestOptions {
/** /**
* Change the path to be opened when the test is started * Change the path to be opened when the test is started
* Remember to start with a slash, e.g. `'/different/page'` * Remember to start with a slash, e.g. `'/different/page'`
*/ */
path?: string path?: string
/**
* Optionally specify what to filter from the output
*/
filterOutput?: TestFilterOptions;
/**
* A callback to manually take control of the page and simulate user interactions
*/
cb?: PageTestCallback cb?: PageTestCallback
t?: any /**
* The AVA context used to test (ie t.snapshot(..) )
*/
t: any
/** /**
* Set to `true` to return index.html (200) instead of error page (404) * Set to `true` to return index.html (200) instead of error page (404)
@ -89,7 +101,7 @@ export interface RollupServeOptions {
* Serve your rolled up bundle like webpack-dev-server * Serve your rolled up bundle like webpack-dev-server
* @param {import('..').RollupServeOptions} options * @param {import('..').RollupServeOptions} options
*/ */
export default function serveTest (options: RollupServeOptions ): Plugin { export default function serveTest (options: RollupServeTestOptions ): Plugin {
const mime = new Mime(standardTypes, otherTypes) const mime = new Mime(standardTypes, otherTypes)
const testOptions = { const testOptions = {
port: 0, port: 0,
@ -135,36 +147,6 @@ export default function serveTest (options: RollupServeOptions ): Plugin {
const content = (<OutputChunk>file).code || (<OutputAsset>file).source; // Todo might need to read a source file; const content = (<OutputChunk>file).code || (<OutputAsset>file).source; // Todo might need to read a source file;
return found(response, mime.getType(filePath!), content); return found(response, mime.getType(filePath!), content);
} }
//
// if(bundle[urlPath]){
// const fallbackPath = typeof testOptions.historyApiFallback === 'string' ? testOptions.historyApiFallback : '/index.html'
// }
//
// readFileFromContentBase(contentBase, urlPath, function (error, content, filePath) {
// if (!error) {
// return found(response, mime.getType(filePath!), content)
// }
// if ((<ErrorCodeException>error).code !== 'ENOENT') {
// response.writeHead(500)
// response.end('500 Internal Server Error' +
// '\n\n' + filePath +
// '\n\n' + Object.values(error).join('\n') +
// '\n\n(rollup-plugin-serve)', 'utf-8')
// return
// }
// if (testOptions.historyApiFallback) {
// const fallbackPath = typeof testOptions.historyApiFallback === 'string' ? testOptions.historyApiFallback : '/index.html'
// readFileFromContentBase(contentBase, fallbackPath, function (error, content, filePath) {
// if (error) {
// notFound(response, filePath)
// } else {
// found(response, mime.getType(filePath), content)
// }
// })
// } else {
// notFound(response, filePath)
// }
// })
} }
@ -180,7 +162,6 @@ export default function serveTest (options: RollupServeOptions ): Plugin {
}) })
} }
// release previous server instance if rollup is reloading configuration in watch mode // release previous server instance if rollup is reloading configuration in watch mode
// @ts-ignore // @ts-ignore
if (server) { if (server) {
@ -229,6 +210,7 @@ export default function serveTest (options: RollupServeOptions ): Plugin {
const testOutput = await runTest({ const testOutput = await runTest({
page: testOptions.path!, page: testOptions.path!,
cb: testOptions.cb, cb: testOptions.cb,
filterOutput: testOptions.filterOutput,
}, url) }, url)
testOptions.t?.snapshot?.(testOutput); testOptions.t?.snapshot?.(testOutput);
} }
@ -257,128 +239,3 @@ function found (response: ServerResponse, mimeType: string|null, content: any) {
function green (text: string) { function green (text: string) {
return '\u001b[1m\u001b[32m' + text + '\u001b[39m\u001b[22m' return '\u001b[1m\u001b[32m' + text + '\u001b[39m\u001b[22m'
} }
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
cb: PageTestCallback
filterOutput: TestFilterOptions
replaceHost: boolean
replaceHostWith?: string
}
const defaultOptions: Partial<TestOptions> = {
page: '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[],
}
export async function runTest(opts: Partial<TestOptions>, hostUrl: string){
const options : TestOptions = (<TestOptions>{
...defaultOptions,
...opts,
filterOutput: {
...defaultOptions.filterOutput,
...(opts?.filterOutput),
},
});
const {
page: path,
cb,
replaceHost,
replaceHostWith,
filterOutput
} = options;
const browser = await puppeteer.launch({
headless: 'new',
});
const page = await browser.newPage();
let output : TestOutput = {
console: [],
errors: [],
responses: [],
requestsFailed: []
};
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}`)
}
});
const url = new URL(`${hostUrl}/${path??''}`);
await page.goto(url.href);
if(!cb) {
await page.waitForNetworkIdle({});
}else{
await cb(page);
}
const htmlHandle = await page.$('html');
const html = await page.evaluate(html => html?.outerHTML??html?.innerHTML, htmlHandle);
// Add the final html
output.html = html;
}finally{
await page.close();
await browser.close();
}
return output;
}