diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..66aa5637 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,62 @@ +version: 2.1 + +orbs: + node: circleci/node@2.1.1 + +commands: + setup-headless-chromium: + steps: + - run: + name: Install dependencies for headless Chromium + command: | + sudo apt-get update + sudo apt-get install -yq \ + gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \ + libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 \ + libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \ + libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \ + ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget + - run: + name: Setup sandboxing for Chromium + command: sudo sysctl -w kernel.unprivileged_userns_clone=1 + +jobs: + test: + executor: + name: node/default + tag: << parameters.node-version >> + parameters: + node-version: + default: '13.14' + type: string + setup: + default: [] + type: steps + steps: + - checkout + - setup-headless-chromium + - steps: << parameters.setup >> + - node/install-packages: + app-dir: ~/project + pkg-manager: yarn + with-cache: false + - run: + name: Run Tests + command: yarn test + working_directory: ~/project + +workflows: + test-matrix: + jobs: + - test: + name: test/node:10 + node-version: '10.20' + - test: + name: test/node:12 + node-version: '12.16' + - test: + name: test/node:13 + node-version: '13.14' + - test: + name: test/node:14 + node-version: '14.2' diff --git a/.eslintrc.json b/.eslintrc.json index 2f9dbef0..3661f869 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,7 +5,20 @@ }, "env": { "browser": true, + "commonjs": true, "es6": true, "node": true - } + }, + "overrides": [ + { + "files": ["test/sandbox/*.js", "test/**/*.test.js"], + "env": { + "jest": true + }, + "globals": { + "__DEBUG__": true, + "browser": true + } + } + ] } diff --git a/.gitignore b/.gitignore index 83a557f0..6d435d43 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ yarn-error.log* # lockfiles package-lock.json /yarn.lock + +# test artifacts +*__tmp__ diff --git a/examples/webpack-hot-middleware/server.js b/examples/webpack-hot-middleware/server.js index 57c4c4eb..14d91aac 100644 --- a/examples/webpack-hot-middleware/server.js +++ b/examples/webpack-hot-middleware/server.js @@ -1,6 +1,5 @@ const express = require('express'); const webpack = require('webpack'); -const path = require('path'); const config = require('./webpack.config.js'); const compiler = webpack(config); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..93eeb9af --- /dev/null +++ b/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + globalSetup: '/jest-global-setup.js', + globalTeardown: '/jest-global-teardown.js', + modulePaths: [], + rootDir: 'test', + testEnvironment: '/jest-environment.js', + testMatch: ['/**/*.test.js'], + watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], +}; diff --git a/package.json b/package.json index 45c26726..c866b48f 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,13 @@ "types" ], "scripts": { + "test": "node scripts/test.js", "lint": "eslint --report-unused-disable-directives --ext .js .", "lint:fix": "yarn lint --fix", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"", "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,md}\"", "generate-types": "tsc -p tsconfig.json && rimraf \"types/{helpers,runtime}\" && yarn format", + "postinstall": "yarn link && yarn link \"@pmmmwh/react-refresh-webpack-plugin\"", "prepublishOnly": "rimraf types && yarn generate-types" }, "dependencies": { @@ -48,20 +50,34 @@ "schema-utils": "^2.6.5" }, "devDependencies": { + "@babel/core": "^7.9.6", "@types/json-schema": "^7.0.4", "@types/node": "^13.11.1", + "@types/puppeteer": "^2.1.0", "@types/webpack": "^4.41.11", + "babel-loader": "^8.1.0", + "cross-spawn": "^7.0.2", "eslint": "^6.8.0", "eslint-config-prettier": "^6.10.1", + "fs-extra": "^9.0.0", + "get-port": "^5.1.1", + "jest": "^26.0.1", + "jest-environment-node": "^26.0.1", + "jest-watch-typeahead": "^0.6.0", + "nanoid": "^3.1.7", + "postinstall-postinstall": "^2.1.0", "prettier": "^2.0.4", + "puppeteer": "^3.0.4", "react-refresh": "^0.8.1", "rimraf": "^3.0.2", "type-fest": "^0.13.1", "typescript": "^3.8.3", "webpack": "^4.42.1", + "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.11.0", "webpack-hot-middleware": "^2.25.0", - "webpack-plugin-serve": "^1.0.0" + "webpack-plugin-serve": "^1.0.0", + "yn": "^4.0.0" }, "peerDependencies": { "@types/webpack": "^4.41.12", diff --git a/scripts/test.js b/scripts/test.js new file mode 100644 index 00000000..cbd3bcc2 --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,30 @@ +// Setup environment before any code - +// this makes sure everything coming after will run in the correct env. +process.env.NODE_ENV = 'test'; + +// Crash on unhandled rejections instead of failing silently. +process.on('unhandledRejection', (error) => { + throw error; +}); + +const jest = require('jest'); +const yn = require('yn'); + +let argv = process.argv.slice(2); + +if (yn(process.env.CI)) { + // Force headless mode in CI environments + process.env.HEADLESS = 'true'; + + // Use CI mode + argv.push('--ci'); + // Parallelized puppeteer tests have high memory overhead in CI environments. + // Fall back to run in series so tests will run faster. + argv.push('--runInBand'); +} + +if (yn(process.env.DEBUG)) { + argv.push('--verbose'); +} + +void jest.run(argv); diff --git a/test/conformance/ReactRefreshRequire.test.js b/test/conformance/ReactRefreshRequire.test.js new file mode 100644 index 00000000..88dc284b --- /dev/null +++ b/test/conformance/ReactRefreshRequire.test.js @@ -0,0 +1,374 @@ +const createSandbox = require('../sandbox'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L989-L1048 +test('re-runs accepted modules', async () => { + const [session] = await createSandbox(); + + await session.patch('./index.js', `export default function Noop() { return null; };`); + + await session.write('./foo.js', `window.logs.push('init FooV1'); require('./bar');`); + await session.write( + './bar.js', + `window.logs.push('init BarV1'); export default function Bar() { return null; };` + ); + + await session.resetLogs(); + await session.patch( + 'index.js', + `require('./foo'); export default function Noop() { return null; };` + ); + await expect(session.logs).resolves.toEqual(['init FooV1', 'init BarV1']); + + // We only edited Bar, and it accepted. + // So we expect it to re-run alone. + await session.resetLogs(); + await session.patch( + './bar.js', + `window.logs.push('init BarV2'); export default function Bar() { return null; };` + ); + await expect(session.logs).resolves.toEqual(['init BarV2']); + + // We only edited Bar, and it accepted. + // So we expect it to re-run alone. + await session.resetLogs(); + await session.patch( + './bar.js', + `window.logs.push('init BarV3'); export default function Bar() { return null; };` + ); + await expect(session.logs).resolves.toEqual(['init BarV3']); + + // TODO: + // expect(Refresh.performReactRefresh).toHaveBeenCalled(); + // expect(Refresh.performFullRefresh).not.toHaveBeenCalled(); +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1050-L1137 +test('propagates a hot update to closest accepted module', async () => { + const [session] = await createSandbox(); + + await session.patch('index.js', `export default function Noop() { return null; };`); + + await session.write( + './foo.js', + // Exporting a component marks it as auto-accepting. + `window.logs.push('init FooV1'); require('./bar'); export default function Foo() {};` + ); + await session.write('./bar.js', `window.logs.push('init BarV1');`); + + await session.resetLogs(); + await session.patch( + 'index.js', + `require('./foo'); export default function Noop() { return null; };` + ); + await expect(session.logs).resolves.toEqual(['init FooV1', 'init BarV1']); + + // We edited Bar, but it doesn't accept. + // So we expect it to re-run together with Foo which does. + await session.resetLogs(); + await session.patch('./bar.js', `window.logs.push('init BarV2');`); + await expect(session.logs).resolves.toEqual([ + // // FIXME: Metro order: + // 'init BarV2', + // 'init FooV1', + 'init FooV1', + 'init BarV2', + // Webpack runs in this order because it evaluates modules parent down, not + // child up. Parents will re-run child modules in the order that they're + // imported from the parent. + ]); + + // We edited Bar, but it doesn't accept. + // So we expect it to re-run together with Foo which does. + await session.resetLogs(); + await session.patch('./bar.js', `window.logs.push('init BarV3');`); + await expect(session.logs).resolves.toEqual([ + // // FIXME: Metro order: + // 'init BarV3', + // 'init FooV1', + 'init FooV1', + 'init BarV3', + // Webpack runs in this order because it evaluates modules parent down, not + // child up. Parents will re-run child modules in the order that they're + // imported from the parent. + ]); + + // We edited Bar so that it accepts itself. + // We still re-run Foo because the exports of Bar changed. + await session.resetLogs(); + await session.patch( + './bar.js', + // Exporting a component marks it as auto-accepting. + `window.logs.push('init BarV3'); export default function Bar() {};` + ); + expect(await session.evaluate(() => window.logs)).toEqual([ + // // FIXME: Metro order: + // 'init BarV3', + // 'init FooV1', + 'init FooV1', + 'init BarV3', + // Webpack runs in this order because it evaluates modules parent down, not + // child up. Parents will re-run child modules in the order that they're + // imported from the parent. + ]); + + // Further edits to Bar don't re-run Foo. + await session.evaluate(() => (window.logs = [])); + await session.patch( + './bar.js', + ` + window.logs.push('init BarV4'); + export default function Bar() {}; + ` + ); + await expect(session.logs).resolves.toEqual(['init BarV4']); + + // TODO: + // expect(Refresh.performReactRefresh).toHaveBeenCalled(); + // expect(Refresh.performFullRefresh).not.toHaveBeenCalled(); +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1139-L1307 +test('propagates hot update to all inverse dependencies', async () => { + const [session] = await createSandbox(); + + await session.patch('index.js', `export default function Noop() { return null; };`); + + // This is the module graph: + // MiddleA* + // / \ + // Root* - MiddleB* - Leaf + // \ + // MiddleC + // + // * - accepts update + // + // We expect that editing Leaf will propagate to + // MiddleA and MiddleB both of which can handle updates. + + await session.write( + 'root.js', + ` + window.logs.push('init RootV1'); + + import './middleA'; + import './middleB'; + import './middleC'; + + export default function Root() {}; + ` + ); + await session.write( + 'middleA.js', + ` + window.logs.push('init MiddleAV1'); + + import './leaf'; + + export default function MiddleA() {}; + ` + ); + await session.write( + 'middleB.js', + ` + window.logs.push('init MiddleBV1'); + + import './leaf'; + + export default function MiddleB() {}; + ` + ); + // This one doesn't import leaf and also doesn't export a component, + // so, it doesn't accept its own updates. + await session.write('middleC.js', `window.logs.push('init MiddleCV1'); export default {};`); + // Doesn't accept its own updates; they will propagate. + await session.write('leaf.js', `window.logs.push('init LeafV1'); export default {};`); + + await session.patch( + 'index.js', + `require('./root'); export default function Noop() { return null; };` + ); + + await expect(session.logs).resolves.toEqual([ + 'init LeafV1', + 'init MiddleAV1', + 'init MiddleBV1', + 'init MiddleCV1', + 'init RootV1', + ]); + + // We edited Leaf, but it doesn't accept. + // So we expect it to re-run together with MiddleA and MiddleB which do. + await session.resetLogs(); + await session.patch('leaf.js', `window.logs.push('init LeafV2'); export default {};`); + await expect(session.logs).resolves.toEqual(['init LeafV2', 'init MiddleAV1', 'init MiddleBV1']); + + // Let's try the same one more time. + await session.resetLogs(); + await session.patch('leaf.js', `window.logs.push('init LeafV3'); export default {};`); + await expect(session.logs).resolves.toEqual(['init LeafV3', 'init MiddleAV1', 'init MiddleBV1']); + + // Now edit MiddleB. It should accept and re-run alone. + await session.resetLogs(); + await session.patch( + 'middleB.js', + ` + window.logs.push('init MiddleBV2'); + + import './leaf'; + + export default function MiddleB() {}; + ` + ); + await expect(session.logs).resolves.toEqual(['init MiddleBV2']); + + // Finally, edit MiddleC. It didn't accept so it should bubble to Root. + await session.resetLogs(); + await session.patch('middleC.js', `window.logs.push('init MiddleCV2'); export default {};`); + await expect(session.logs).resolves.toEqual(['init MiddleCV2', 'init RootV1']); + + // TODO: + // expect(Refresh.performReactRefresh).toHaveBeenCalled() + // expect(Refresh.performFullRefresh).not.toHaveBeenCalled() +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1309-L1406 +test.todo('runs dependencies before dependents'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1408-L1498 +test.todo('provides fresh value for module.exports in parents'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1500-L1590 +test.todo('provides fresh value for exports.* in parents'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1592-L1688 +test.todo('provides fresh value for ES6 named import in parents'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1690-L1786 +test.todo('provides fresh value for ES6 default import in parents'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1788-L1899 +test.todo('stops update propagation after module-level errors'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1901-L2010 +test.todo('can continue hot updates after module-level errors with module.exports'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2012-L2123 +test.todo('can continue hot updates after module-level errors with ES6 exports'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2125-L2233 +test.todo('does not accumulate stale exports over time'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2235-L2279 +test.todo('bails out if update bubbles to the root via the only path'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2281-L2371 +test.todo('bails out if the update bubbles to the root via one of the paths'); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2373-L2472 +// FIXME: Enable this test in #89 +test.skip('propagates a module that stops accepting in next version', async () => { + const [session] = await createSandbox(); + + // Accept in parent + await session.write( + './foo.js', + `window.logs.push('init FooV1'); import './bar'; export default function Foo() {};` + ); + // Accept in child + await session.write( + './bar.js', + `window.logs.push('init BarV1'); export default function Bar() {};` + ); + + await session.patch('index.js', `require('./foo'); export default () => null;`); + await expect(session.logs).resolves.toEqual(['init BarV1', 'init FooV1']); + + // Verify the child can accept itself + let didFullRefresh = false; + await session.resetLogs(); + didFullRefresh = + didFullRefresh || + !(await session.patch( + './bar.js', + `window.logs.push('init BarV1.1'); export default function Bar() {};` + )); + await expect(session.logs).resolves.toEqual(['init BarV1.1']); + + // Now let's change the child to *not* accept itself. + // We'll expect that now the parent will handle the evaluation. + await session.resetLogs(); + didFullRefresh = + didFullRefresh || + !(await session.patch( + './bar.js', + // It's important we still export _something_, otherwise webpack will + // also emit an extra update to the parent module. This happens because + // webpack converts the module from ESM to CJS, which means the parent + // module must update how it "imports" the module (drops interop code). + // TODO: propose Webpack to interrupt the current update phase when `module.hot.invalidate()` is called. + `window.logs.push('init BarV2'); export {};` + )); + // We re-run Bar and expect to stop there. However, + // it didn't export a component, so we go higher. + // We stop at Foo which currently _does_ export a component. + await expect(session.logs).resolves.toEqual([ + // Bar is evaluated twice: + // 1. To invalidate itself once it realizes it's no longer acceptable. + // 2. As a child of Foo re-evaluating. + 'init BarV2', + 'init BarV2', + 'init FooV1', + ]); + + // Change it back so that the child accepts itself. + await session.resetLogs(); + didFullRefresh = + didFullRefresh || + !(await session.patch( + './bar.js', + `window.logs.push('init BarV2'); export default function Bar() {};` + )); + // Since the export list changed, we have to re-run both the parent and the child. + await expect(session.logs).resolves.toEqual(['init BarV2', 'init FooV1']); + + // TODO: + // expect(Refresh.performReactRefresh).toHaveBeenCalled(); + // expect(Refresh.performFullRefresh).not.toHaveBeenCalled(); + expect(didFullRefresh).toBe(false); + + // Editing the child alone now doesn't reevaluate the parent. + await session.resetLogs(); + didFullRefresh = + didFullRefresh || + !(await session.patch( + './bar.js', + `window.logs.push('init BarV3'); export default function Bar() {};` + )); + await expect(session.logs).resolves.toEqual(['init BarV3']); + + // Finally, edit the parent in a way that changes the export. + // It would still be accepted on its own - + // but it's incompatible with the past version which didn't have two exports. + await session.evaluate(() => window.localStorage.setItem('init', '')); + didFullRefresh = + didFullRefresh || + !(await session.patch( + './foo.js', + ` + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem('init', 'init FooV2') + } + export function Foo() {}; + export function FooFoo() {};` + )); + + // Check that we attempted to evaluate, but had to fall back to full refresh. + await expect(session.evaluate(() => window.localStorage.getItem('init'))).resolves.toEqual( + 'init FooV2' + ); + + // TODO: + // expect(Refresh.performFullRefresh).toHaveBeenCalled(); + // expect(Refresh.performReactRefresh).not.toHaveBeenCalled(); + expect(didFullRefresh).toBe(true); +}); diff --git a/test/jest-environment.js b/test/jest-environment.js new file mode 100644 index 00000000..7bc68b45 --- /dev/null +++ b/test/jest-environment.js @@ -0,0 +1,30 @@ +const NodeEnvironment = require('jest-environment-node'); +const puppeteer = require('puppeteer'); +const yn = require('yn'); + +class SandboxEnvironment extends NodeEnvironment { + async setup() { + await super.setup(); + + this.global.__DEBUG__ = yn(process.env.DEBUG); + + const wsEndpoint = process.env.PUPPETEER_WS_ENDPOINT; + if (!wsEndpoint) { + throw new Error('Puppeteer wsEndpoint not found!'); + } + + this.global.browser = await puppeteer.connect({ + browserWSEndpoint: wsEndpoint, + }); + } + + async teardown() { + await super.teardown(); + + if (this.global.browser) { + await this.global.browser.disconnect(); + } + } +} + +module.exports = SandboxEnvironment; diff --git a/test/jest-global-setup.js b/test/jest-global-setup.js new file mode 100644 index 00000000..15c074cb --- /dev/null +++ b/test/jest-global-setup.js @@ -0,0 +1,14 @@ +const puppeteer = require('puppeteer'); +const yn = require('yn'); + +async function setup() { + const browser = await puppeteer.launch({ + headless: yn(process.env.HEADLESS, { default: false }), + }); + + global.__BROWSER_INSTANCE__ = browser; + + process.env.PUPPETEER_WS_ENDPOINT = browser.wsEndpoint(); +} + +module.exports = setup; diff --git a/test/jest-global-teardown.js b/test/jest-global-teardown.js new file mode 100644 index 00000000..db24bc5a --- /dev/null +++ b/test/jest-global-teardown.js @@ -0,0 +1,7 @@ +async function teardown() { + if (global.__BROWSER_INSTANCE__) { + await global.__BROWSER_INSTANCE__.close(); + } +} + +module.exports = teardown; diff --git a/test/sandbox/browser.js b/test/sandbox/browser.js new file mode 100644 index 00000000..e3925369 --- /dev/null +++ b/test/sandbox/browser.js @@ -0,0 +1,36 @@ +/** + * Gets a new page from the current browser instance, + * and initializes up testing-related lifecycles. + * @param {number} port + * @param {string} path + * @return {Promise} + */ +async function getPage(port, path) { + const page = await browser.newPage(); + + const url = `http://localhost:${port}${path}`; + await page.goto(url); + + // Initialize page session logging + await page.evaluate(() => { + window.logs = []; + }); + + // This is evaluated when the current page have a new document, + // which indicates a navigation or reload. + // We'll have to signal the test runner that this has occurred, + // whether it is expected or not. + await page.evaluateOnNewDocument(() => { + window.logs = []; + + window.__REACT_REFRESH_RELOADED = true; + + if (typeof window.__REACT_REFRESH_RELOAD_CB === 'function') { + window.__REACT_REFRESH_RELOAD_CB(); + } + }); + + return page; +} + +module.exports = { getPage }; diff --git a/test/sandbox/configs.js b/test/sandbox/configs.js new file mode 100644 index 00000000..38535b06 --- /dev/null +++ b/test/sandbox/configs.js @@ -0,0 +1,66 @@ +const path = require('path'); + +const BUNDLE_FILENAME = 'main'; + +/** + * @param {number} port + * @return {string} + */ +function getIndexHTML(port) { + return ` + + + + + Sandbox React App + + +
+ + + +`; +} + +/** + * @param {string} srcDir + * @return {string} + */ +function getWDSConfig(srcDir) { + return ` +const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); + +module.exports = { + mode: 'development', + context: '${srcDir}', + entry: { + '${BUNDLE_FILENAME}': [ + '${path.join(__dirname, './runtime/hot-notifier.js')}', + './index.js', + ], + }, + module: { + rules: [ + { + test: /\\.jsx?$/, + include: '${srcDir}', + use: [ + { + loader: 'babel-loader', + options: { + plugins: ['react-refresh/babel'], + } + } + ], + }, + ], + }, + plugins: [new ReactRefreshPlugin()], + resolve: { + extensions: ['.js', '.jsx'], + }, +}; +`; +} + +module.exports = { getIndexHTML, getWDSConfig }; diff --git a/test/sandbox/index.js b/test/sandbox/index.js new file mode 100644 index 00000000..4a24dcc7 --- /dev/null +++ b/test/sandbox/index.js @@ -0,0 +1,236 @@ +const path = require('path'); +const fse = require('fs-extra'); +const getPort = require('get-port'); +const { nanoid } = require('nanoid'); +const { getPage } = require('./browser'); +const { getIndexHTML, getWDSConfig } = require('./configs'); +const { killTestProcess, spawnWDS } = require('./spawn'); + +// Extends the timeout for tests using the sandbox +jest.setTimeout(1000 * 60 * 5); + +// Setup a global "queue" of cleanup handlers to allow auto-teardown of tests, +// even when they did not run the cleanup function. +/** @type {Set>} */ +const cleanupHandlers = new Set(); +afterEach(async () => { + await Promise.all([...cleanupHandlers].map((c) => c())); +}); + +/** + * Logs output to the console (only in debug mode). + * @param {...*} args + * @returns {void} + */ +const log = (...args) => { + if (__DEBUG__) { + console.log(...args); + } +}; + +/** + * Pause current asynchronous execution for provided milliseconds. + * @param {number} ms + * @return {Promise} + */ +const sleep = (ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +/** + * @typedef {Object} SandboxSession + * @property {Promise<*[]>} logs + * @property {function(): Promise} resetLogs + * @property {function(string, string): Promise} write + * @property {function(string, string): Promise} patch + * @property {function(string): Promise} remove + * @property {function(*): Promise<*>} evaluate + */ + +const rootSandboxDir = path.join(__dirname, '..', '__tmp__'); + +/** + * Creates a Webpack and Puppeteer backed sandbox to execute HMR operations on. + * @param {Object} [options] + * @param {string} [options.id] + * @param {Map} [options.initialFiles] + * @returns {Promise<[SandboxSession, function(): Promise]>} + */ +async function sandbox({ id = nanoid(), initialFiles = new Map() } = {}) { + const port = await getPort(); + + // Get sandbox directory paths + const sandboxDir = path.join(rootSandboxDir, id); + const srcDir = path.join(sandboxDir, 'src'); + // In case of an ID clash, remove the existing sandbox directory + await fse.remove(sandboxDir); + // Create the sandbox source directory + await fse.mkdirp(srcDir); + + // Write necessary files to sandbox + await fse.writeFile(path.join(sandboxDir, 'webpack.config.js'), getWDSConfig(srcDir)); + await fse.writeFile(path.join(sandboxDir, 'index.html'), getIndexHTML(port)); + await fse.writeFile( + path.join(srcDir, 'index.js'), + `export default function Sandbox() { return 'new sandbox'; }` + ); + + // Write initial files to sandbox + for (const [filePath, fileContent] of initialFiles.entries()) { + await fse.writeFile(path.join(srcDir, filePath), fileContent); + } + + // TODO: Add handling for webpack-hot-middleware and webpack-plugin-serve + const app = await spawnWDS(port, sandboxDir); + const page = await getPage(port, '/'); + + async function cleanupSandbox() { + async function _cleanup() { + await page.close(); + await killTestProcess(app); + + if (!__DEBUG__) { + await fse.remove(sandboxDir); + } + } + + try { + await _cleanup(); + + // Remove current cleanup handler from the global queue since it has been called + cleanupHandlers.delete(cleanupSandbox); + } catch (e) {} + } + + // Cache the cleanup handler for global cleanup + // This is done in case tests fail and async handlers are kept alive + cleanupHandlers.add(cleanupSandbox); + + return [ + { + /** @returns {Promise<*[]>} */ + get logs() { + return page.evaluate(() => window.logs); + }, + /** @returns {Promise} */ + async resetLogs() { + await page.evaluate(() => { + window.logs = []; + }); + }, + /** + * @param {string} fileName + * @param {string} content + * @return {Promise} + */ + async write(fileName, content) { + // Update the file on filesystem + const fullFileName = path.join(srcDir, fileName); + const directory = path.dirname(fullFileName); + await fse.mkdirp(directory); + await fse.writeFile(fullFileName, content); + }, + /** + * @param {string} fileName + * @param {string} content + * @return {Promise} + */ + async patch(fileName, content) { + // Register an event for HMR completion + await page.evaluate(() => { + window.__HMR_STATE = 'pending'; + + const timeout = setTimeout(function () { + window.__HMR_STATE = 'timeout'; + }, 30 * 1000); + + window.__HMR_CALLBACK = function () { + clearTimeout(timeout); + window.__HMR_STATE = 'success'; + }; + }); + + await this.write(fileName, content); + + for (;;) { + let status; + try { + status = await page.evaluate(() => window.__HMR_STATE); + } catch (error) { + // This message indicates a navigation, so it can be safely ignored. + // Else, we re-throw the error to indicate a failure. + if (!error.message.includes('Execution context was destroyed')) { + throw error; + } + } + + if (!status) { + await sleep(1000); + + // Wait for application to reload + await page.evaluate(() => { + return new Promise((resolve) => { + if (window.__REACT_REFRESH_RELOADED) { + resolve(); + } else { + const timeout = setTimeout(resolve, 30 * 1000); + window.__REACT_REFRESH_RELOADED_CB = function () { + clearTimeout(timeout); + resolve(); + }; + } + }); + }); + + log('Application re-loaded.'); + + // Slow down tests to wait for re-rendering + await sleep(1000); + return false; + } + + if (status === 'success') { + log('Hot update complete.'); + break; + } + + if (status !== 'pending') { + throw new Error(`Application is in inconsistent state: ${status}.`); + } + + await sleep(30); + } + + // Slow down tests to wait for re-rendering + await sleep(1000); + return true; + }, + /** + * @param {string} fileName + * @returns {Promise} + */ + async remove(fileName) { + const fullFileName = path.join(srcDir, fileName); + await fse.remove(fullFileName); + }, + /** + * @param {*} fn + * @returns {Promise<*>} + */ + async evaluate(fn) { + if (typeof fn === 'function') { + const result = await page.evaluate(fn); + await sleep(30); + return result; + } else { + throw new Error('You must pass a function to be evaluated in the browser!'); + } + }, + }, + cleanupSandbox, + ]; +} + +module.exports = sandbox; diff --git a/test/sandbox/runtime/hot-notifier.js b/test/sandbox/runtime/hot-notifier.js new file mode 100644 index 00000000..2d88138b --- /dev/null +++ b/test/sandbox/runtime/hot-notifier.js @@ -0,0 +1,10 @@ +if (module.hot) { + module.hot.addStatusHandler(function (status) { + if (status === 'idle') { + if (window.__HMR_CALLBACK) { + window.__HMR_CALLBACK(); + window.__HMR_CALLBACK = null; + } + } + }); +} diff --git a/test/sandbox/spawn.js b/test/sandbox/spawn.js new file mode 100644 index 00000000..fc55cdb1 --- /dev/null +++ b/test/sandbox/spawn.js @@ -0,0 +1,119 @@ +const path = require('path'); +const spawn = require('cross-spawn'); + +/** + * @param {string} processPath + * @param {*[]} argv + * @param {Object} [options] + * @param {string} [options.cwd] + * @param {*} [options.env] + * @param {string | RegExp} [options.successMessage] + * @return {Promise} + */ +function spawnTestProcess(processPath, argv, options = {}) { + const cwd = options.cwd || path.resolve(__dirname, '../..'); + const env = { + ...process.env, + NODE_ENV: 'development', + ...options.env, + }; + const successRegex = new RegExp(options.successMessage || 'compiled successfully', 'i'); + + return new Promise((resolve, reject) => { + const instance = spawn(processPath, argv, { cwd, env }); + let didResolve = false; + + /** + * @param {Buffer} data + * @returns {void} + */ + function handleStdout(data) { + const message = data.toString(); + if (successRegex.test(message)) { + if (!didResolve) { + didResolve = true; + resolve(instance); + } + } + + if (__DEBUG__) { + process.stdout.write(message); + } + } + + /** + * @param {Buffer} data + * @returns {void} + */ + function handleStderr(data) { + const message = data.toString(); + process.stderr.write(message); + } + + instance.stdout.on('data', handleStdout); + instance.stderr.on('data', handleStderr); + + instance.on('close', () => { + instance.stdout.removeListener('data', handleStdout); + instance.stderr.removeListener('data', handleStderr); + + if (!didResolve) { + didResolve = true; + resolve(); + } + }); + + instance.on('error', (error) => { + reject(error); + }); + }); +} + +/** + * @param {number} port + * @param {string} directory + * @param {*} options + * @return {Promise} + */ +function spawnWDS(port, directory, options) { + const wdsBin = path.resolve('node_modules/.bin/webpack-dev-server'); + return spawnTestProcess( + wdsBin, + [ + '--config', + path.resolve(directory, 'webpack.config.js'), + '--content-base', + directory, + '--hot-only', + '--port', + port, + ], + options + ); +} + +/** + * @param {import('child_process').ChildProcess} instance + */ +function killTestProcess(instance) { + try { + process.kill(instance.pid); + } catch (error) { + if ( + process.platform === 'win32' && + typeof error.message === 'string' && + (error.message.includes(`no running instance of the task`) || + error.message.includes(`not found`)) + ) { + // Windows throws an error if the process is already dead + return; + } + + throw error; + } +} + +module.exports = { + killTestProcess, + spawnWDS, +};