From 96b9d99e61616802048e1a4ba2a75a6dc02b13cc Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Fri, 15 May 2020 08:31:15 +0800 Subject: [PATCH 01/31] chore: update git ignore for test artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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__ From 348a2f10c5829422229387bbab6c738ac4aa223b Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Fri, 15 May 2020 08:31:29 +0800 Subject: [PATCH 02/31] chore: update eslint to include jest for test files --- .eslintrc.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index 2f9dbef0..3400efc4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,7 +5,9 @@ }, "env": { "browser": true, + "commonjs": true, "es6": true, "node": true - } + }, + "overrides": [{ "files": ["test/**/*.test.js"], "env": { "jest": true } }] } From 9e4174d4a28dc32cb33b283e456dd1e07c111a10 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Fri, 15 May 2020 08:31:55 +0800 Subject: [PATCH 03/31] chore(deps-dev): install necessary packages for testing --- package.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/package.json b/package.json index 45c26726..a314794c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "types" ], "scripts": { + "test": "jest --forceExit", "lint": "eslint --report-unused-disable-directives --ext .js .", "lint:fix": "yarn lint --fix", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"", @@ -48,17 +49,27 @@ "schema-utils": "^2.6.5" }, "devDependencies": { + "@babel/core": "^7.9.6", "@types/json-schema": "^7.0.4", "@types/node": "^13.11.1", "@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-watch-typeahead": "^0.6.0", + "nanoid": "^3.1.7", "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" From d9500143f1d880db063276a4486bcb7f91182658 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Fri, 15 May 2020 08:32:07 +0800 Subject: [PATCH 04/31] test: add jest config --- jest.config.js | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..fde7a60c --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + modulePaths: [], + testEnvironment: 'node', + testMatch: ['/test/**/*.test.js'], + watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], +}; From 61224cda6a0afa0ea4aa124612d21da04ff2a9e7 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Fri, 15 May 2020 08:37:15 +0800 Subject: [PATCH 05/31] test: implement helper to get a browser page --- test/sandbox/getBrowserPage.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 test/sandbox/getBrowserPage.js diff --git a/test/sandbox/getBrowserPage.js b/test/sandbox/getBrowserPage.js new file mode 100644 index 00000000..5a95eb90 --- /dev/null +++ b/test/sandbox/getBrowserPage.js @@ -0,0 +1,24 @@ +const puppeteer = require('puppeteer'); + +async function getBrowserPage(port, path) { + const browser = await puppeteer.launch({ + // TODO: Make this configurable + headless: false, + }); + const page = await browser.newPage(); + + const url = `http://localhost:${port}${path}`; + await page.goto(url); + + await page.evaluateOnNewDocument(() => { + window.__REACT_REFRESH_RELOADED = true; + + if (window.__REACT_REFRESH_RELOAD_CB) { + window.__REACT_REFRESH_RELOAD_CB(); + } + }); + + return page; +} + +module.exports = getBrowserPage; From f7d5ab6282b55389e5b8913f0c7ee7d778659212 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Fri, 15 May 2020 08:37:55 +0800 Subject: [PATCH 06/31] test: implement process spawning for WDS --- test/sandbox/spawn.js | 100 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 test/sandbox/spawn.js diff --git a/test/sandbox/spawn.js b/test/sandbox/spawn.js new file mode 100644 index 00000000..3979eff3 --- /dev/null +++ b/test/sandbox/spawn.js @@ -0,0 +1,100 @@ +const path = require('path'); +const spawn = require('cross-spawn'); + +/** + * @param {string} processPath + * @param {*[]} argv + * @param {object} [options] + * @property {string} [options.cwd] + * @property {*} [options.env] + * @property {string | RegExp} [options.successMessage] + * @return {Promise} + */ +function spawnTestProcess(processPath, argv, options = {}) { + const cwd = options.cwd || path.resolve(__dirname, '../..'); + const env = { + ...process.env, + NODE_ENV: 'test', + ...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; + + function handleStdout(data) { + const message = data.toString(); + if (successRegex.test(message)) { + if (!didResolve) { + didResolve = true; + resolve(instance); + } + } + + process.stdout.write(message); + } + + 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); + }); + }); +} + +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 + ); +} + +function killInstance(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 = { + killInstance, + spawnWDS, +}; From 2da59fddd78dd4c9d9eaf12c77851c03fb493fd2 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Fri, 15 May 2020 08:38:31 +0800 Subject: [PATCH 07/31] test: implement testing sandbox --- test/sandbox/hot-notifier.js | 10 ++ test/sandbox/index.js | 198 +++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 test/sandbox/hot-notifier.js create mode 100644 test/sandbox/index.js diff --git a/test/sandbox/hot-notifier.js b/test/sandbox/hot-notifier.js new file mode 100644 index 00000000..2d88138b --- /dev/null +++ b/test/sandbox/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/index.js b/test/sandbox/index.js new file mode 100644 index 00000000..7ceac89d --- /dev/null +++ b/test/sandbox/index.js @@ -0,0 +1,198 @@ +const path = require('path'); +const fse = require('fs-extra'); +const getPort = require('get-port'); +const { nanoid } = require('nanoid'); +const getBrowserPage = require('./getBrowserPage'); +const { killInstance, spawnWDS } = require('./spawn'); + +const rootSandboxDirectory = path.join(__dirname, '..', '__tmp__'); + +const sleep = (ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +async function sandbox({ id = nanoid(), initialFiles = new Map() } = {}) { + const port = await getPort(); + + const sandboxDirectory = path.join(rootSandboxDirectory, id); + const srcDirectory = path.join(sandboxDirectory, 'src'); + + await fse.remove(sandboxDirectory); + await fse.mkdirp(srcDirectory); + + await fse.writeFile( + path.join(sandboxDirectory, 'webpack.config.js'), + ` +const ReactRefreshPlugin = require('../../../src'); + +module.exports = { + mode: 'development', + context: '${srcDirectory}', + entry: { + main: ['${path.join(__dirname, 'hot-notifier.js')}', './index.js'], + }, + module: { + rules: [ + { + test: /\\.jsx?$/, + include: '${srcDirectory}', + use: [ + { + loader: 'babel-loader', + options: { + plugins: [ + ['react-refresh/babel', { skipEnvCheck: true }], + ], + } + } + ], + }, + ], + }, + plugins: [new ReactRefreshPlugin()], + resolve: { + extensions: ['.js', '.jsx'], + }, +}; +` + ); + + await fse.writeFile( + path.join(sandboxDirectory, 'index.html'), + ` + + + + + + Sandbox React App + + +
+ + + +` + ); + + await fse.writeFile( + path.join(srcDirectory, 'index.js'), + `export default function Sandbox() { return 'new sandbox'; }` + ); + + for (const [filePath, fileContent] of initialFiles.entries()) { + await fse.writeFile(filePath.join(srcDirectory, filePath), fileContent); + } + + // TODO: Add handling for webpack-hot-middleware and webpack-plugin-serve + const app = await spawnWDS(port, sandboxDirectory); + const page = await getBrowserPage(port, '/'); + + return [ + { + async write(fileName, content) { + // Update the file on filesystem + const fullFileName = path.join(srcDirectory, fileName); + const directory = path.dirname(fullFileName); + await fse.mkdirp(directory); + await fse.writeFile(fullFileName, content); + }, + 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 indicates a navigation + if (!error.message.includes('Execution context was destroyed')) { + console.error(error); + } + } + + if (!status) { + await sleep(1000); + + // Wait for application to re-hydrate: + 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(); + }; + } + }); + }); + + console.log('Application re-loaded.'); + + // Slow down tests a bit + await sleep(1000); + return false; + } + + if (status === 'success') { + console.log('Hot update complete.'); + break; + } + + if (status !== 'pending') { + throw new Error(`Application is in inconsistent state: ${status}.`); + } + + await sleep(30); + } + + // Slow down tests a bit (we don't know how long re-rendering takes): + await sleep(1000); + return true; + }, + async remove(fileName) { + const fullFileName = path.join(srcDirectory, fileName); + await fse.remove(fullFileName); + }, + 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.'); + } + }, + }, + function cleanup() { + async function _cleanup() { + await page.browser().close(); + await killInstance(app); + + // TODO: Make this optional for easier debugging + await fse.remove(sandboxDirectory); + } + _cleanup().catch(() => {}); + }, + ]; +} + +module.exports = sandbox; From 2ba264660a1f251cd8966de13deb6cbfa9342fbd Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Fri, 15 May 2020 08:39:09 +0800 Subject: [PATCH 08/31] test(require): adopt require tests from Next.js --- test/ReactRefreshRequire.test.js | 429 +++++++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 test/ReactRefreshRequire.test.js diff --git a/test/ReactRefreshRequire.test.js b/test/ReactRefreshRequire.test.js new file mode 100644 index 00000000..65be562d --- /dev/null +++ b/test/ReactRefreshRequire.test.js @@ -0,0 +1,429 @@ +const sandbox = require('./sandbox'); + +jest.setTimeout(1000 * 60 * 5); + +// 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, cleanup] = await sandbox(); + + await session.patch('./index.js', `export default function Noop() { return null; };`); + + await session.write('./foo.js', `window.log.push('init FooV1'); require('./bar');`); + await session.write( + './bar.js', + `window.log.push('init BarV1'); export default function Bar() { return null; };` + ); + + await session.evaluate(() => (window.log = [])); + await session.patch( + 'index.js', + `require('./foo'); export default function Noop() { return null; };` + ); + expect(await session.evaluate(() => window.log)).toEqual(['init FooV1', 'init BarV1']); + + // We only edited Bar, and it accepted. + // So we expect it to re-run alone. + await session.evaluate(() => (window.log = [])); + await session.patch( + './bar.js', + `window.log.push('init BarV2'); export default function Bar() { return null; };` + ); + expect(await session.evaluate(() => window.log)).toEqual(['init BarV2']); + + // We only edited Bar, and it accepted. + // So we expect it to re-run alone. + await session.evaluate(() => (window.log = [])); + await session.patch( + './bar.js', + `window.log.push('init BarV3'); export default function Bar() { return null; };` + ); + expect(await session.evaluate(() => window.log)).toEqual(['init BarV3']); + + // TODO: + // expect(Refresh.performReactRefresh).toHaveBeenCalled(); + // expect(Refresh.performFullRefresh).not.toHaveBeenCalled(); + + await cleanup(); +}); + +// 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, cleanup] = await sandbox(); + + await session.patch('index.js', `export default function Noop() { return null; };`); + + await session.write( + './foo.js', + ` + window.log.push('init FooV1'); + require('./bar'); + + // Exporting a component marks it as auto-accepting. + export default function Foo() {}; + ` + ); + + await session.write('./bar.js', `window.log.push('init BarV1');`); + + await session.evaluate(() => (window.log = [])); + await session.patch( + 'index.js', + `require('./foo'); export default function Noop() { return null; };` + ); + + expect(await session.evaluate(() => window.log)).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.evaluate(() => (window.log = [])); + await session.patch('./bar.js', `window.log.push('init BarV2');`); + expect(await session.evaluate(() => window.log)).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.evaluate(() => (window.log = [])); + await session.patch('./bar.js', `window.log.push('init BarV3');`); + expect(await session.evaluate(() => window.log)).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.evaluate(() => (window.log = [])); + await session.patch( + './bar.js', + ` + window.log.push('init BarV3'); + // Exporting a component marks it as auto-accepting. + export default function Bar() {}; + ` + ); + expect(await session.evaluate(() => window.log)).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.log = [])); + await session.patch( + './bar.js', + ` + window.log.push('init BarV4'); + export default function Bar() {}; + ` + ); + expect(await session.evaluate(() => window.log)).toEqual(['init BarV4']); + + // TODO: + // expect(Refresh.performReactRefresh).toHaveBeenCalled(); + // expect(Refresh.performFullRefresh).not.toHaveBeenCalled(); + + await cleanup(); +}); +// 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, cleanup] = await sandbox(); + + 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.log.push('init RootV1'); + + import './middleA'; + import './middleB'; + import './middleC'; + + export default function Root() {}; + ` + ); + await session.write( + 'middleA.js', + ` + log.push('init MiddleAV1'); + + import './leaf'; + + export default function MiddleA() {}; + ` + ); + await session.write( + 'middleB.js', + ` + log.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 updates). + await session.write('middleC.js', `log.push('init MiddleCV1'); export default {};`); + + // Doesn't accept its own updates; they will propagate. + await session.write('leaf.js', `log.push('init LeafV1'); export default {};`); + + // Bootstrap: + await session.evaluate(() => (window.log = [])); + await session.patch( + 'index.js', + `require('./root'); export default function Noop() { return null; };` + ); + + expect(await session.evaluate(() => window.log)).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.evaluate(() => (window.log = [])); + await session.patch('leaf.js', `log.push('init LeafV2'); export default {};`); + expect(await session.evaluate(() => window.log)).toEqual([ + 'init LeafV2', + 'init MiddleAV1', + 'init MiddleBV1', + ]); + + // Let's try the same one more time. + await session.evaluate(() => (window.log = [])); + await session.patch('leaf.js', `log.push('init LeafV3'); export default {};`); + expect(await session.evaluate(() => window.log)).toEqual([ + 'init LeafV3', + 'init MiddleAV1', + 'init MiddleBV1', + ]); + + // Now edit MiddleB. It should accept and re-run alone. + await session.evaluate(() => (window.log = [])); + await session.patch( + 'middleB.js', + ` + log.push('init MiddleBV2'); + + import './leaf'; + + export default function MiddleB() {}; + ` + ); + expect(await session.evaluate(() => window.log)).toEqual(['init MiddleBV2']); + + // Finally, edit MiddleC. It didn't accept so it should bubble to Root. + await session.evaluate(() => (window.log = [])); + + await session.patch('middleC.js', `log.push('init MiddleCV2'); export default {};`); + expect(await session.evaluate(() => window.log)).toEqual(['init MiddleCV2', 'init RootV1']); + + // TODO: + // expect(Refresh.performReactRefresh).toHaveBeenCalled() + // expect(Refresh.performFullRefresh).not.toHaveBeenCalled() + + await cleanup(); +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1309-L1406 +test('runs dependencies before dependents', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1408-L1498 +test('provides fresh value for module.exports in parents', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1500-L1590 +test('provides fresh value for exports.* in parents', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1592-L1688 +test('provides fresh value for ES6 named import in parents', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1690-L1786 +test('provides fresh value for ES6 default import in parents', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1788-L1899 +test('stops update propagation after module-level errors', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1901-L2010 +test('can continue hot updates after module-level errors with module.exports', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2012-L2123 +test('can continue hot updates after module-level errors with ES6 exports', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2125-L2233 +test('does not accumulate stale exports over time', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2235-L2279 +test('bails out if update bubbles to the root via the only path', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2281-L2371 +test('bails out if the update bubbles to the root via one of the paths', async () => { + // TODO: +}); + +// https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2373-L2472 +test('propagates a module that stops accepting in next version', async () => { + const [session, cleanup] = await sandbox(); + + // Accept in parent + await session.write( + './foo.js', + `;(typeof global !== 'undefined' ? global : window).log.push('init FooV1'); import './bar'; export default function Foo() {};` + ); + // Accept in child + await session.write( + './bar.js', + `;(typeof global !== 'undefined' ? global : window).log.push('init BarV1'); export default function Bar() {};` + ); + + // Bootstrap: + await session.patch( + 'index.js', + `;(typeof global !== 'undefined' ? global : window).log = []; require('./foo'); export default () => null;` + ); + expect(await session.evaluate(() => window.log)).toEqual(['init BarV1', 'init FooV1']); + + let didFullRefresh = false; + // Verify the child can accept itself: + await session.evaluate(() => (window.log = [])); + didFullRefresh = + didFullRefresh || + !(await session.patch( + './bar.js', + `window.log.push('init BarV1.1'); export default function Bar() {};` + )); + expect(await session.evaluate(() => window.log)).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.evaluate(() => (window.log = [])); + 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 that webpack interrupts the current update phase when + // `module.hot.invalidate()` is called. + `window.log.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. + expect(await session.evaluate(() => window.log)).toEqual([ + // Bar evaluates 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.evaluate(() => (window.log = [])); + didFullRefresh = + didFullRefresh || + !(await session.patch( + './bar.js', + `window.log.push('init BarV2'); export default function Bar() {};` + )); + // Since the export list changed, we have to re-run both the parent + // and the child. + expect(await session.evaluate(() => window.log)).toEqual(['init BarV2', 'init FooV1']); + + // TODO: + // expect(Refresh.performReactRefresh).toHaveBeenCalled(); + + // expect(Refresh.performFullRefresh).not.toHaveBeenCalled(); + expect(didFullRefresh).toBe(false); + + // But editing the child alone now doesn't reevaluate the parent. + await session.evaluate(() => (window.log = [])); + didFullRefresh = + didFullRefresh || + !(await session.patch( + './bar.js', + `window.log.push('init BarV3'); export default function Bar() {};` + )); + expect(await session.evaluate(() => window.log)).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. + expect(await session.evaluate(() => window.localStorage.getItem('init'))).toEqual('init FooV2'); + + // expect(Refresh.performFullRefresh).toHaveBeenCalled(); + expect(didFullRefresh).toBe(true); + + await cleanup(); +}); From eb35a414a8c29842993fd631359e28a688e05b17 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Fri, 15 May 2020 08:39:21 +0800 Subject: [PATCH 09/31] chore: OCD cleanup --- examples/webpack-hot-middleware/server.js | 1 - 1 file changed, 1 deletion(-) 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); From 6b10cb40bf8f14d39b431e4cedcec8ddbd8430dc Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:24:57 +0800 Subject: [PATCH 10/31] test: add global setup and teardown to re-use browser instance --- test/jest-global-setup.js | 13 +++++++++++++ test/jest-global-teardown.js | 7 +++++++ 2 files changed, 20 insertions(+) create mode 100644 test/jest-global-setup.js create mode 100644 test/jest-global-teardown.js diff --git a/test/jest-global-setup.js b/test/jest-global-setup.js new file mode 100644 index 00000000..a1db5e27 --- /dev/null +++ b/test/jest-global-setup.js @@ -0,0 +1,13 @@ +const puppeteer = require('puppeteer'); + +async function setup() { + const browser = await puppeteer.launch({ + headless: 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; From c7a4ee06ea1b4cf0706223acfe4ff9a273f23726 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:26:07 +0800 Subject: [PATCH 11/31] test: use custom environment to allow sharing of browser instance --- jest.config.js | 7 +++++-- package.json | 1 + test/jest-environment.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 test/jest-environment.js diff --git a/jest.config.js b/jest.config.js index fde7a60c..93eeb9af 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,9 @@ module.exports = { + globalSetup: '/jest-global-setup.js', + globalTeardown: '/jest-global-teardown.js', modulePaths: [], - testEnvironment: 'node', - testMatch: ['/test/**/*.test.js'], + 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 a314794c..560da512 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "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", "prettier": "^2.0.4", diff --git a/test/jest-environment.js b/test/jest-environment.js new file mode 100644 index 00000000..c39f116c --- /dev/null +++ b/test/jest-environment.js @@ -0,0 +1,29 @@ +const NodeEnvironment = require('jest-environment-node'); +const puppeteer = require('puppeteer'); + +class SandboxEnvironment extends NodeEnvironment { + async setup() { + await super.setup(); + + this.global.__DEBUG__ = process.env.DEBUG === 'true'; + + 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; From c0699c9351b96b5b8b0c2f60144a66e9beb566bd Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:28:08 +0800 Subject: [PATCH 12/31] chore: setup testing globals for ESLint --- .eslintrc.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index 3400efc4..3661f869 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,5 +9,16 @@ "es6": true, "node": true }, - "overrides": [{ "files": ["test/**/*.test.js"], "env": { "jest": true } }] + "overrides": [ + { + "files": ["test/sandbox/*.js", "test/**/*.test.js"], + "env": { + "jest": true + }, + "globals": { + "__DEBUG__": true, + "browser": true + } + } + ] } From a7d91c509e27866a30ac05c04c77b655b16cffe2 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:29:07 +0800 Subject: [PATCH 13/31] chore: symlink self to prevent usage of relative paths --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 560da512..3d803fbf 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "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": { @@ -63,6 +64,7 @@ "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", From fd58d53aa9317dddfb764716d8da38eaf246614c Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:31:21 +0800 Subject: [PATCH 14/31] refactor: group browser/config-related sandbox utils --- test/sandbox/browser.js | 22 ++++++++++++++ test/sandbox/configs.js | 55 ++++++++++++++++++++++++++++++++++ test/sandbox/getBrowserPage.js | 24 --------------- 3 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 test/sandbox/browser.js create mode 100644 test/sandbox/configs.js delete mode 100644 test/sandbox/getBrowserPage.js diff --git a/test/sandbox/browser.js b/test/sandbox/browser.js new file mode 100644 index 00000000..ea329615 --- /dev/null +++ b/test/sandbox/browser.js @@ -0,0 +1,22 @@ +async function getPage(port, path) { + const page = await browser.newPage(); + + const url = `http://localhost:${port}${path}`; + await page.goto(url); + + // 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.__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..bab6f69c --- /dev/null +++ b/test/sandbox/configs.js @@ -0,0 +1,55 @@ +const path = require('path'); + +function getIndexHTML(port) { + return ` + + + + + Sandbox React App + + +
+ + + +`; +} + +function getWDSConfig(srcDir) { + return ` +const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); + +module.exports = { + mode: 'development', + context: '${srcDir}', + entry: { + main: ['${path.join(__dirname, './runtime/hot-notifier.js')}', './index.js'], + }, + module: { + rules: [ + { + test: /\\.jsx?$/, + include: '${srcDir}', + use: [ + { + loader: 'babel-loader', + options: { + plugins: [ + ['react-refresh/babel', { skipEnvCheck: true }], + ], + } + } + ], + }, + ], + }, + plugins: [new ReactRefreshPlugin()], + resolve: { + extensions: ['.js', '.jsx'], + }, +}; +`; +} + +module.exports = { getIndexHTML, getWDSConfig }; diff --git a/test/sandbox/getBrowserPage.js b/test/sandbox/getBrowserPage.js deleted file mode 100644 index 5a95eb90..00000000 --- a/test/sandbox/getBrowserPage.js +++ /dev/null @@ -1,24 +0,0 @@ -const puppeteer = require('puppeteer'); - -async function getBrowserPage(port, path) { - const browser = await puppeteer.launch({ - // TODO: Make this configurable - headless: false, - }); - const page = await browser.newPage(); - - const url = `http://localhost:${port}${path}`; - await page.goto(url); - - await page.evaluateOnNewDocument(() => { - window.__REACT_REFRESH_RELOADED = true; - - if (window.__REACT_REFRESH_RELOAD_CB) { - window.__REACT_REFRESH_RELOAD_CB(); - } - }); - - return page; -} - -module.exports = getBrowserPage; From ba071598792b6c30e8007d5b140c4d6120246bd7 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:32:52 +0800 Subject: [PATCH 15/31] refactor: use const as configured bundle filename --- test/sandbox/configs.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/sandbox/configs.js b/test/sandbox/configs.js index bab6f69c..bb848016 100644 --- a/test/sandbox/configs.js +++ b/test/sandbox/configs.js @@ -1,5 +1,7 @@ const path = require('path'); +const BUNDLE_FILENAME = 'main'; + function getIndexHTML(port) { return ` @@ -10,7 +12,7 @@ function getIndexHTML(port) {
- + `; @@ -24,7 +26,10 @@ module.exports = { mode: 'development', context: '${srcDir}', entry: { - main: ['${path.join(__dirname, './runtime/hot-notifier.js')}', './index.js'], + '${BUNDLE_FILENAME}': [ + '${path.join(__dirname, './runtime/hot-notifier.js')}', + './index.js', + ], }, module: { rules: [ From 246c5784e6a204aa851e045dc4a923e244e8e235 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:35:20 +0800 Subject: [PATCH 16/31] test: add auto-cleanup and reduce logs when not debugging --- test/sandbox/index.js | 158 ++++++++++++++++++------------------------ test/sandbox/spawn.js | 8 ++- 2 files changed, 74 insertions(+), 92 deletions(-) diff --git a/test/sandbox/index.js b/test/sandbox/index.js index 7ceac89d..dfb9ddd7 100644 --- a/test/sandbox/index.js +++ b/test/sandbox/index.js @@ -2,10 +2,23 @@ const path = require('path'); const fse = require('fs-extra'); const getPort = require('get-port'); const { nanoid } = require('nanoid'); -const getBrowserPage = require('./getBrowserPage'); -const { killInstance, spawnWDS } = require('./spawn'); - -const rootSandboxDirectory = path.join(__dirname, '..', '__tmp__'); +const { getPage } = require('./browser'); +const { getIndexHTML, getWDSConfig } = require('./configs'); +const { killTestProcess, spawnWDS } = require('./spawn'); + +// We 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())); +}); + +const log = (...args) => { + if (__DEBUG__) { + console.log(...args); + } +}; const sleep = (ms) => { return new Promise((resolve) => { @@ -13,88 +26,63 @@ const sleep = (ms) => { }); }; +const rootSandboxDir = path.join(__dirname, '..', '__tmp__'); + async function sandbox({ id = nanoid(), initialFiles = new Map() } = {}) { const port = await getPort(); - const sandboxDirectory = path.join(rootSandboxDirectory, id); - const srcDirectory = path.join(sandboxDirectory, 'src'); - - await fse.remove(sandboxDirectory); - await fse.mkdirp(srcDirectory); - - await fse.writeFile( - path.join(sandboxDirectory, 'webpack.config.js'), - ` -const ReactRefreshPlugin = require('../../../src'); - -module.exports = { - mode: 'development', - context: '${srcDirectory}', - entry: { - main: ['${path.join(__dirname, 'hot-notifier.js')}', './index.js'], - }, - module: { - rules: [ - { - test: /\\.jsx?$/, - include: '${srcDirectory}', - use: [ - { - loader: 'babel-loader', - options: { - plugins: [ - ['react-refresh/babel', { skipEnvCheck: true }], - ], - } - } - ], - }, - ], - }, - plugins: [new ReactRefreshPlugin()], - resolve: { - extensions: ['.js', '.jsx'], - }, -}; -` - ); - + // 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(sandboxDirectory, 'index.html'), - ` - - - - - - Sandbox React App - - -
- - - -` - ); - - await fse.writeFile( - path.join(srcDirectory, 'index.js'), + 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(filePath.join(srcDirectory, filePath), fileContent); + await fse.writeFile(filePath.join(srcDir, filePath), fileContent); } // TODO: Add handling for webpack-hot-middleware and webpack-plugin-serve - const app = await spawnWDS(port, sandboxDirectory); - const page = await getBrowserPage(port, '/'); + 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 [ { async write(fileName, content) { // Update the file on filesystem - const fullFileName = path.join(srcDirectory, fileName); + const fullFileName = path.join(srcDir, fileName); const directory = path.dirname(fullFileName); await fse.mkdirp(directory); await fse.writeFile(fullFileName, content); @@ -121,16 +109,17 @@ module.exports = { try { status = await page.evaluate(() => window.__HMR_STATE); } catch (error) { - // This indicates a navigation + // 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')) { - console.error(error); + throw error; } } if (!status) { await sleep(1000); - // Wait for application to re-hydrate: + // Wait for application to reload await page.evaluate(() => { return new Promise((resolve) => { if (window.__REACT_REFRESH_RELOADED) { @@ -145,15 +134,15 @@ module.exports = { }); }); - console.log('Application re-loaded.'); + log('Application re-loaded.'); - // Slow down tests a bit + // Slow down tests to wait for re-rendering await sleep(1000); return false; } if (status === 'success') { - console.log('Hot update complete.'); + log('Hot update complete.'); break; } @@ -164,12 +153,12 @@ module.exports = { await sleep(30); } - // Slow down tests a bit (we don't know how long re-rendering takes): + // Slow down tests to wait for re-rendering await sleep(1000); return true; }, async remove(fileName) { - const fullFileName = path.join(srcDirectory, fileName); + const fullFileName = path.join(srcDir, fileName); await fse.remove(fullFileName); }, async evaluate(fn) { @@ -178,20 +167,11 @@ module.exports = { await sleep(30); return result; } else { - throw new Error('You must pass a function to be evaluated in the browser.'); + throw new Error('You must pass a function to be evaluated in the browser!'); } }, }, - function cleanup() { - async function _cleanup() { - await page.browser().close(); - await killInstance(app); - - // TODO: Make this optional for easier debugging - await fse.remove(sandboxDirectory); - } - _cleanup().catch(() => {}); - }, + cleanupSandbox, ]; } diff --git a/test/sandbox/spawn.js b/test/sandbox/spawn.js index 3979eff3..5cabf28c 100644 --- a/test/sandbox/spawn.js +++ b/test/sandbox/spawn.js @@ -32,7 +32,9 @@ function spawnTestProcess(processPath, argv, options = {}) { } } - process.stdout.write(message); + if (__DEBUG__) { + process.stdout.write(message); + } } function handleStderr(data) { @@ -76,7 +78,7 @@ function spawnWDS(port, directory, options) { ); } -function killInstance(instance) { +function killTestProcess(instance) { try { process.kill(instance.pid); } catch (error) { @@ -95,6 +97,6 @@ function killInstance(instance) { } module.exports = { - killInstance, + killTestProcess, spawnWDS, }; From 618a8aed2a6b33c40381c930a589ca415fa2967f Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:35:37 +0800 Subject: [PATCH 17/31] refactor: move runtime test files to sub directory --- test/sandbox/{ => runtime}/hot-notifier.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/sandbox/{ => runtime}/hot-notifier.js (100%) diff --git a/test/sandbox/hot-notifier.js b/test/sandbox/runtime/hot-notifier.js similarity index 100% rename from test/sandbox/hot-notifier.js rename to test/sandbox/runtime/hot-notifier.js From c57d385d8363268822198454d731da793ff16221 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:35:49 +0800 Subject: [PATCH 18/31] chore: remove forceExit for jest --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3d803fbf..8e55d4d6 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "types" ], "scripts": { - "test": "jest --forceExit", + "test": "jest", "lint": "eslint --report-unused-disable-directives --ext .js .", "lint:fix": "yarn lint --fix", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"", From d189db72de2533e58373ea55c40fa5b56dfae1ff Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 00:36:09 +0800 Subject: [PATCH 19/31] chore: move test files into specific folders --- .../ReactRefreshRequire.test.js | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) rename test/{ => conformance}/ReactRefreshRequire.test.js (98%) diff --git a/test/ReactRefreshRequire.test.js b/test/conformance/ReactRefreshRequire.test.js similarity index 98% rename from test/ReactRefreshRequire.test.js rename to test/conformance/ReactRefreshRequire.test.js index 65be562d..8a39bc17 100644 --- a/test/ReactRefreshRequire.test.js +++ b/test/conformance/ReactRefreshRequire.test.js @@ -1,10 +1,10 @@ -const sandbox = require('./sandbox'); +const createSandbox = require('../sandbox'); jest.setTimeout(1000 * 60 * 5); // 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, cleanup] = await sandbox(); + const [session] = await createSandbox(); await session.patch('./index.js', `export default function Noop() { return null; };`); @@ -42,13 +42,11 @@ test('re-runs accepted modules', async () => { // TODO: // expect(Refresh.performReactRefresh).toHaveBeenCalled(); // expect(Refresh.performFullRefresh).not.toHaveBeenCalled(); - - await cleanup(); }); // 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, cleanup] = await sandbox(); + const [session] = await createSandbox(); await session.patch('index.js', `export default function Noop() { return null; };`); @@ -139,12 +137,10 @@ test('propagates a hot update to closest accepted module', async () => { // TODO: // expect(Refresh.performReactRefresh).toHaveBeenCalled(); // expect(Refresh.performFullRefresh).not.toHaveBeenCalled(); - - await cleanup(); }); // 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, cleanup] = await sandbox(); + const [session] = await createSandbox(); await session.patch('index.js', `export default function Noop() { return null; };`); @@ -256,8 +252,6 @@ test('propagates hot update to all inverse dependencies', async () => { // TODO: // expect(Refresh.performReactRefresh).toHaveBeenCalled() // expect(Refresh.performFullRefresh).not.toHaveBeenCalled() - - await cleanup(); }); // https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1309-L1406 @@ -317,7 +311,7 @@ test('bails out if the update bubbles to the root via one of the paths', async ( // https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2373-L2472 test('propagates a module that stops accepting in next version', async () => { - const [session, cleanup] = await sandbox(); + const [session] = await createSandbox(); // Accept in parent await session.write( @@ -424,6 +418,4 @@ test('propagates a module that stops accepting in next version', async () => { // expect(Refresh.performFullRefresh).toHaveBeenCalled(); expect(didFullRefresh).toBe(true); - - await cleanup(); }); From 336c90c7fb28eb1a8f12b4627fe7ab6576d516f0 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 02:32:56 +0800 Subject: [PATCH 20/31] refactor: move session log stack logic into sandbox --- test/sandbox/browser.js | 7 +++++++ test/sandbox/index.js | 13 ++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/test/sandbox/browser.js b/test/sandbox/browser.js index ea329615..6c05dd9a 100644 --- a/test/sandbox/browser.js +++ b/test/sandbox/browser.js @@ -4,11 +4,18 @@ async function getPage(port, path) { 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') { diff --git a/test/sandbox/index.js b/test/sandbox/index.js index dfb9ddd7..c6226d19 100644 --- a/test/sandbox/index.js +++ b/test/sandbox/index.js @@ -6,7 +6,10 @@ const { getPage } = require('./browser'); const { getIndexHTML, getWDSConfig } = require('./configs'); const { killTestProcess, spawnWDS } = require('./spawn'); -// We setup a global "queue" of cleanup handlers to allow auto-teardown of tests, +// 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(); @@ -80,6 +83,14 @@ async function sandbox({ id = nanoid(), initialFiles = new Map() } = {}) { return [ { + get logs() { + return page.evaluate(() => window.logs); + }, + async resetLogs() { + await page.evaluate(() => { + window.logs = []; + }); + }, async write(fileName, content) { // Update the file on filesystem const fullFileName = path.join(srcDir, fileName); From 7e99ba7d7d8cd4ba8d02d47065c4940e25746eea Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 02:33:13 +0800 Subject: [PATCH 21/31] refactor: unify assertion for session log stack --- test/conformance/ReactRefreshRequire.test.js | 182 ++++++++----------- 1 file changed, 78 insertions(+), 104 deletions(-) diff --git a/test/conformance/ReactRefreshRequire.test.js b/test/conformance/ReactRefreshRequire.test.js index 8a39bc17..dfb288c6 100644 --- a/test/conformance/ReactRefreshRequire.test.js +++ b/test/conformance/ReactRefreshRequire.test.js @@ -1,43 +1,41 @@ const createSandbox = require('../sandbox'); -jest.setTimeout(1000 * 60 * 5); - // 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.log.push('init FooV1'); require('./bar');`); + await session.write('./foo.js', `window.logs.push('init FooV1'); require('./bar');`); await session.write( './bar.js', - `window.log.push('init BarV1'); export default function Bar() { return null; };` + `window.logs.push('init BarV1'); export default function Bar() { return null; };` ); - await session.evaluate(() => (window.log = [])); + await session.resetLogs(); await session.patch( 'index.js', `require('./foo'); export default function Noop() { return null; };` ); - expect(await session.evaluate(() => window.log)).toEqual(['init FooV1', 'init BarV1']); + 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.evaluate(() => (window.log = [])); + await session.resetLogs(); await session.patch( './bar.js', - `window.log.push('init BarV2'); export default function Bar() { return null; };` + `window.logs.push('init BarV2'); export default function Bar() { return null; };` ); - expect(await session.evaluate(() => window.log)).toEqual(['init BarV2']); + 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.evaluate(() => (window.log = [])); + await session.resetLogs(); await session.patch( './bar.js', - `window.log.push('init BarV3'); export default function Bar() { return null; };` + `window.logs.push('init BarV3'); export default function Bar() { return null; };` ); - expect(await session.evaluate(() => window.log)).toEqual(['init BarV3']); + await expect(session.logs).resolves.toEqual(['init BarV3']); // TODO: // expect(Refresh.performReactRefresh).toHaveBeenCalled(); @@ -52,30 +50,23 @@ test('propagates a hot update to closest accepted module', async () => { await session.write( './foo.js', - ` - window.log.push('init FooV1'); - require('./bar'); - // Exporting a component marks it as auto-accepting. - export default function Foo() {}; - ` + `window.logs.push('init FooV1'); require('./bar'); export default function Foo() {};` ); + await session.write('./bar.js', `window.logs.push('init BarV1');`); - await session.write('./bar.js', `window.log.push('init BarV1');`); - - await session.evaluate(() => (window.log = [])); + await session.resetLogs(); await session.patch( 'index.js', `require('./foo'); export default function Noop() { return null; };` ); - - expect(await session.evaluate(() => window.log)).toEqual(['init FooV1', 'init BarV1']); + 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.evaluate(() => (window.log = [])); - await session.patch('./bar.js', `window.log.push('init BarV2');`); - expect(await session.evaluate(() => window.log)).toEqual([ + 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', @@ -88,9 +79,9 @@ test('propagates a hot update to closest accepted module', async () => { // We edited Bar, but it doesn't accept. // So we expect it to re-run together with Foo which does. - await session.evaluate(() => (window.log = [])); - await session.patch('./bar.js', `window.log.push('init BarV3');`); - expect(await session.evaluate(() => window.log)).toEqual([ + 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', @@ -103,16 +94,13 @@ test('propagates a hot update to closest accepted module', async () => { // We edited Bar so that it accepts itself. // We still re-run Foo because the exports of Bar changed. - await session.evaluate(() => (window.log = [])); + await session.resetLogs(); await session.patch( './bar.js', - ` - window.log.push('init BarV3'); // Exporting a component marks it as auto-accepting. - export default function Bar() {}; - ` + `window.logs.push('init BarV3'); export default function Bar() {};` ); - expect(await session.evaluate(() => window.log)).toEqual([ + expect(await session.evaluate(() => window.logs)).toEqual([ // // FIXME: Metro order: // 'init BarV3', // 'init FooV1', @@ -124,20 +112,21 @@ test('propagates a hot update to closest accepted module', async () => { ]); // Further edits to Bar don't re-run Foo. - await session.evaluate(() => (window.log = [])); + await session.evaluate(() => (window.logs = [])); await session.patch( './bar.js', ` - window.log.push('init BarV4'); + window.logs.push('init BarV4'); export default function Bar() {}; ` ); - expect(await session.evaluate(() => window.log)).toEqual(['init BarV4']); + 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(); @@ -145,9 +134,9 @@ test('propagates hot update to all inverse dependencies', async () => { await session.patch('index.js', `export default function Noop() { return null; };`); // This is the module graph: - // MiddleA* - // / \ - // Root* - MiddleB* - Leaf + // MiddleA* + // / \ + // Root* - MiddleB* - Leaf // \ // MiddleC // @@ -159,7 +148,7 @@ test('propagates hot update to all inverse dependencies', async () => { await session.write( 'root.js', ` - window.log.push('init RootV1'); + window.logs.push('init RootV1'); import './middleA'; import './middleB'; @@ -171,7 +160,7 @@ test('propagates hot update to all inverse dependencies', async () => { await session.write( 'middleA.js', ` - log.push('init MiddleAV1'); + window.logs.push('init MiddleAV1'); import './leaf'; @@ -181,28 +170,25 @@ test('propagates hot update to all inverse dependencies', async () => { await session.write( 'middleB.js', ` - log.push('init MiddleBV1'); + 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 updates). - await session.write('middleC.js', `log.push('init MiddleCV1'); export default {};`); - + // 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', `log.push('init LeafV1'); export default {};`); + await session.write('leaf.js', `window.logs.push('init LeafV1'); export default {};`); - // Bootstrap: - await session.evaluate(() => (window.log = [])); await session.patch( 'index.js', `require('./root'); export default function Noop() { return null; };` ); - expect(await session.evaluate(() => window.log)).toEqual([ + await expect(session.logs).resolves.toEqual([ 'init LeafV1', 'init MiddleAV1', 'init MiddleBV1', @@ -212,42 +198,33 @@ test('propagates hot update to all inverse dependencies', async () => { // We edited Leaf, but it doesn't accept. // So we expect it to re-run together with MiddleA and MiddleB which do. - await session.evaluate(() => (window.log = [])); - await session.patch('leaf.js', `log.push('init LeafV2'); export default {};`); - expect(await session.evaluate(() => window.log)).toEqual([ - 'init LeafV2', - 'init MiddleAV1', - 'init MiddleBV1', - ]); + 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.evaluate(() => (window.log = [])); - await session.patch('leaf.js', `log.push('init LeafV3'); export default {};`); - expect(await session.evaluate(() => window.log)).toEqual([ - 'init LeafV3', - 'init MiddleAV1', - 'init MiddleBV1', - ]); + 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.evaluate(() => (window.log = [])); + await session.resetLogs(); await session.patch( 'middleB.js', ` - log.push('init MiddleBV2'); + window.logs.push('init MiddleBV2'); import './leaf'; export default function MiddleB() {}; ` ); - expect(await session.evaluate(() => window.log)).toEqual(['init MiddleBV2']); + await expect(session.logs).resolves.toEqual(['init MiddleBV2']); // Finally, edit MiddleC. It didn't accept so it should bubble to Root. - await session.evaluate(() => (window.log = [])); - - await session.patch('middleC.js', `log.push('init MiddleCV2'); export default {};`); - expect(await session.evaluate(() => window.log)).toEqual(['init MiddleCV2', 'init RootV1']); + 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() @@ -316,35 +293,31 @@ test('propagates a module that stops accepting in next version', async () => { // Accept in parent await session.write( './foo.js', - `;(typeof global !== 'undefined' ? global : window).log.push('init FooV1'); import './bar'; export default function Foo() {};` + `window.logs.push('init FooV1'); import './bar'; export default function Foo() {};` ); // Accept in child await session.write( './bar.js', - `;(typeof global !== 'undefined' ? global : window).log.push('init BarV1'); export default function Bar() {};` + `window.logs.push('init BarV1'); export default function Bar() {};` ); - // Bootstrap: - await session.patch( - 'index.js', - `;(typeof global !== 'undefined' ? global : window).log = []; require('./foo'); export default () => null;` - ); - expect(await session.evaluate(() => window.log)).toEqual(['init BarV1', 'init FooV1']); + 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; - // Verify the child can accept itself: - await session.evaluate(() => (window.log = [])); + await session.resetLogs(); didFullRefresh = didFullRefresh || !(await session.patch( './bar.js', - `window.log.push('init BarV1.1'); export default function Bar() {};` + `window.logs.push('init BarV1.1'); export default function Bar() {};` )); - expect(await session.evaluate(() => window.log)).toEqual(['init BarV1.1']); + 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.evaluate(() => (window.log = [])); + await session.resetLogs(); didFullRefresh = didFullRefresh || !(await session.patch( @@ -353,15 +326,14 @@ test('propagates a module that stops accepting in next version', async () => { // 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 that webpack interrupts the current update phase when - // `module.hot.invalidate()` is called. - `window.log.push('init BarV2'); export {};` + // 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. - expect(await session.evaluate(() => window.log)).toEqual([ - // Bar evaluates twice: + 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', @@ -370,36 +342,34 @@ test('propagates a module that stops accepting in next version', async () => { ]); // Change it back so that the child accepts itself. - await session.evaluate(() => (window.log = [])); + await session.resetLogs(); didFullRefresh = didFullRefresh || !(await session.patch( './bar.js', - `window.log.push('init BarV2'); export default function Bar() {};` + `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. - expect(await session.evaluate(() => window.log)).toEqual(['init BarV2', 'init FooV1']); + // 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); - // But editing the child alone now doesn't reevaluate the parent. - await session.evaluate(() => (window.log = [])); + // Editing the child alone now doesn't reevaluate the parent. + await session.resetLogs(); didFullRefresh = didFullRefresh || !(await session.patch( './bar.js', - `window.log.push('init BarV3'); export default function Bar() {};` + `window.logs.push('init BarV3'); export default function Bar() {};` )); - expect(await session.evaluate(() => window.log)).toEqual(['init BarV3']); + 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. + // 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 || @@ -414,8 +384,12 @@ test('propagates a module that stops accepting in next version', async () => { )); // Check that we attempted to evaluate, but had to fall back to full refresh. - expect(await session.evaluate(() => window.localStorage.getItem('init'))).toEqual('init FooV2'); + 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); }); From dd58dbf16d6d2ed34ce5dd6d2d0fc9a5aa483344 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 03:10:03 +0800 Subject: [PATCH 22/31] test: add test runner script --- package.json | 5 +++-- scripts/test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 scripts/test.js diff --git a/package.json b/package.json index 8e55d4d6..9d2f4686 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "types" ], "scripts": { - "test": "jest", + "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}\"", @@ -75,7 +75,8 @@ "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..562a5ebc --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,28 @@ +// 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'; + + // 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); From 56ee061cdc468d82871020dd06f1284852c1e4f2 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 03:10:32 +0800 Subject: [PATCH 23/31] test: allow lax usage of DEBUG/HEADLESS values --- test/jest-environment.js | 3 ++- test/jest-global-setup.js | 3 ++- test/sandbox/configs.js | 4 +--- test/sandbox/spawn.js | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/jest-environment.js b/test/jest-environment.js index c39f116c..7bc68b45 100644 --- a/test/jest-environment.js +++ b/test/jest-environment.js @@ -1,11 +1,12 @@ 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__ = process.env.DEBUG === 'true'; + this.global.__DEBUG__ = yn(process.env.DEBUG); const wsEndpoint = process.env.PUPPETEER_WS_ENDPOINT; if (!wsEndpoint) { diff --git a/test/jest-global-setup.js b/test/jest-global-setup.js index a1db5e27..15c074cb 100644 --- a/test/jest-global-setup.js +++ b/test/jest-global-setup.js @@ -1,8 +1,9 @@ const puppeteer = require('puppeteer'); +const yn = require('yn'); async function setup() { const browser = await puppeteer.launch({ - headless: false, + headless: yn(process.env.HEADLESS, { default: false }), }); global.__BROWSER_INSTANCE__ = browser; diff --git a/test/sandbox/configs.js b/test/sandbox/configs.js index bb848016..75dbde63 100644 --- a/test/sandbox/configs.js +++ b/test/sandbox/configs.js @@ -40,9 +40,7 @@ module.exports = { { loader: 'babel-loader', options: { - plugins: [ - ['react-refresh/babel', { skipEnvCheck: true }], - ], + plugins: ['react-refresh/babel'], } } ], diff --git a/test/sandbox/spawn.js b/test/sandbox/spawn.js index 5cabf28c..67d7dd53 100644 --- a/test/sandbox/spawn.js +++ b/test/sandbox/spawn.js @@ -14,7 +14,7 @@ function spawnTestProcess(processPath, argv, options = {}) { const cwd = options.cwd || path.resolve(__dirname, '../..'); const env = { ...process.env, - NODE_ENV: 'test', + NODE_ENV: 'development', ...options.env, }; const successRegex = new RegExp(options.successMessage || 'compiled successfully', 'i'); From 5a0e02177a20b198cafa2374c7f915b36fe85042 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 03:13:35 +0800 Subject: [PATCH 24/31] chore: use CI mode for testing on CI --- scripts/test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/test.js b/scripts/test.js index 562a5ebc..cbd3bcc2 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -16,6 +16,8 @@ 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'); From 2b3cfcb9926fdec9c9cc5fc4798109a22fc6d576 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 03:29:38 +0800 Subject: [PATCH 25/31] ci: add CircleCI configuration --- .circleci/config.yml | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..79733794 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,46 @@ +version: 2.1 + +orbs: + node: circleci/node@2.1.1 + +jobs: + test: + executor: + name: node/default + tag: << parameters.node-version >> + parameters: + app-dir: + default: ~/project + type: string + node-version: + default: '13' + type: string + setup: + default: [] + type: steps + steps: + - checkout + - steps: << parameters.setup >> + - node/install-packages: + app-dir: << parameters.app-dir >> + pkg-manager: yarn + - run: + name: Run Tests + command: yarn test + working_directory: << parameters.app-dir >> + +workflows: + martix-tests: + jobs: + - test: + name: test/node:10 + node-version: '10' + - test: + name: test/node:12 + node-version: '12' + - test: + name: test/node:13 + node-version: '13' + - test: + name: test/node:14 + node-version: '14' From a4a3ae40294e0bcb1cd2016a95735d110241d136 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 03:57:42 +0800 Subject: [PATCH 26/31] ci: use exact node versions and setup headless chrome deps --- .circleci/config.yml | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 79733794..c3fb1f15 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,6 +3,20 @@ version: 2.1 orbs: node: circleci/node@2.1.1 +commands: + setup-headless-chrome: + steps: + - run: + name: Install dependencies for Headless Chrome + 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 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 + jobs: test: executor: @@ -13,7 +27,7 @@ jobs: default: ~/project type: string node-version: - default: '13' + default: '13.14' type: string setup: default: [] @@ -23,6 +37,7 @@ jobs: - steps: << parameters.setup >> - node/install-packages: app-dir: << parameters.app-dir >> + cache-key: yarn.lock pkg-manager: yarn - run: name: Run Tests @@ -30,17 +45,25 @@ jobs: working_directory: << parameters.app-dir >> workflows: - martix-tests: + test-matrix: jobs: - test: name: test/node:10 - node-version: '10' + node-version: '10.20' + setup: + - setup-headless-chrome - test: name: test/node:12 - node-version: '12' + node-version: '12.16' + setup: + - setup-headless-chrome - test: name: test/node:13 - node-version: '13' + node-version: '13.14' + setup: + - setup-headless-chrome - test: name: test/node:14 - node-version: '14' + node-version: '14.2' + setup: + - setup-headless-chrome From f6e0cc747ca430214be9460c0b89d861a3d01123 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 04:23:34 +0800 Subject: [PATCH 27/31] ci: setup sandboxing for chromium --- .circleci/config.yml | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c3fb1f15..406558dd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,18 +4,21 @@ orbs: node: circleci/node@2.1.1 commands: - setup-headless-chrome: + setup-headless-chromium: steps: - run: - name: Install dependencies for Headless Chrome + 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 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 + 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: @@ -23,9 +26,6 @@ jobs: name: node/default tag: << parameters.node-version >> parameters: - app-dir: - default: ~/project - type: string node-version: default: '13.14' type: string @@ -34,15 +34,16 @@ jobs: type: steps steps: - checkout + - setup-headless-chromium - steps: << parameters.setup >> - node/install-packages: - app-dir: << parameters.app-dir >> + app-dir: ~/project cache-key: yarn.lock pkg-manager: yarn - run: name: Run Tests command: yarn test - working_directory: << parameters.app-dir >> + working_directory: ~/project workflows: test-matrix: @@ -50,20 +51,12 @@ workflows: - test: name: test/node:10 node-version: '10.20' - setup: - - setup-headless-chrome - test: name: test/node:12 node-version: '12.16' - setup: - - setup-headless-chrome - test: name: test/node:13 node-version: '13.14' - setup: - - setup-headless-chrome - test: name: test/node:14 node-version: '14.2' - setup: - - setup-headless-chrome From edb12f58926c009169b2eee268cda5c471424449 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 04:40:34 +0800 Subject: [PATCH 28/31] ci: ignore caching for yarn.lock --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 406558dd..66aa5637 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -38,8 +38,8 @@ jobs: - steps: << parameters.setup >> - node/install-packages: app-dir: ~/project - cache-key: yarn.lock pkg-manager: yarn + with-cache: false - run: name: Run Tests command: yarn test From 517b0e65ec9d82eb448df18aa1ed100f933d8442 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 10:33:36 +0800 Subject: [PATCH 29/31] test: skip invalidate test and mark todo tests --- test/conformance/ReactRefreshRequire.test.js | 47 ++++++-------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/test/conformance/ReactRefreshRequire.test.js b/test/conformance/ReactRefreshRequire.test.js index dfb288c6..88dc284b 100644 --- a/test/conformance/ReactRefreshRequire.test.js +++ b/test/conformance/ReactRefreshRequire.test.js @@ -232,62 +232,41 @@ test('propagates hot update to all inverse dependencies', async () => { }); // https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L1309-L1406 -test('runs dependencies before dependents', async () => { - // TODO: -}); +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('provides fresh value for module.exports in parents', async () => { - // TODO: -}); +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('provides fresh value for exports.* in parents', async () => { - // TODO: -}); +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('provides fresh value for ES6 named import in parents', async () => { - // TODO: -}); +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('provides fresh value for ES6 default import in parents', async () => { - // TODO: -}); +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('stops update propagation after module-level errors', async () => { - // TODO: -}); +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('can continue hot updates after module-level errors with module.exports', async () => { - // TODO: -}); +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('can continue hot updates after module-level errors with ES6 exports', async () => { - // TODO: -}); +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('does not accumulate stale exports over time', async () => { - // TODO: -}); +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('bails out if update bubbles to the root via the only path', async () => { - // TODO: -}); +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('bails out if the update bubbles to the root via one of the paths', async () => { - // TODO: -}); +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 -test('propagates a module that stops accepting in next version', async () => { +// 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 From a627740be336bd2ddb4de50e286091292bfa0192 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 10:34:14 +0800 Subject: [PATCH 30/31] test: fix typo in sandbox initialization --- test/sandbox/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/index.js b/test/sandbox/index.js index c6226d19..e19445d9 100644 --- a/test/sandbox/index.js +++ b/test/sandbox/index.js @@ -52,7 +52,7 @@ async function sandbox({ id = nanoid(), initialFiles = new Map() } = {}) { // Write initial files to sandbox for (const [filePath, fileContent] of initialFiles.entries()) { - await fse.writeFile(filePath.join(srcDir, filePath), fileContent); + await fse.writeFile(path.join(srcDir, filePath), fileContent); } // TODO: Add handling for webpack-hot-middleware and webpack-plugin-serve From f7697f8bbf534b61057e393622103c8eb75c83d7 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 10:34:40 +0800 Subject: [PATCH 31/31] docs: add minimal JSDoc to sandbox code --- package.json | 1 + test/sandbox/browser.js | 7 ++++++ test/sandbox/configs.js | 8 +++++++ test/sandbox/index.js | 49 ++++++++++++++++++++++++++++++++++++++++- test/sandbox/spawn.js | 27 ++++++++++++++++++----- 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9d2f4686..c866b48f 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@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", diff --git a/test/sandbox/browser.js b/test/sandbox/browser.js index 6c05dd9a..e3925369 100644 --- a/test/sandbox/browser.js +++ b/test/sandbox/browser.js @@ -1,3 +1,10 @@ +/** + * 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(); diff --git a/test/sandbox/configs.js b/test/sandbox/configs.js index 75dbde63..38535b06 100644 --- a/test/sandbox/configs.js +++ b/test/sandbox/configs.js @@ -2,6 +2,10 @@ const path = require('path'); const BUNDLE_FILENAME = 'main'; +/** + * @param {number} port + * @return {string} + */ function getIndexHTML(port) { return ` @@ -18,6 +22,10 @@ function getIndexHTML(port) { `; } +/** + * @param {string} srcDir + * @return {string} + */ function getWDSConfig(srcDir) { return ` const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); diff --git a/test/sandbox/index.js b/test/sandbox/index.js index e19445d9..4a24dcc7 100644 --- a/test/sandbox/index.js +++ b/test/sandbox/index.js @@ -11,26 +11,53 @@ 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>} */ +/** @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(); @@ -83,14 +110,21 @@ async function sandbox({ id = nanoid(), initialFiles = new Map() } = {}) { 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); @@ -98,6 +132,11 @@ async function sandbox({ id = nanoid(), initialFiles = new Map() } = {}) { 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(() => { @@ -168,10 +207,18 @@ async function sandbox({ id = nanoid(), initialFiles = new Map() } = {}) { 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); diff --git a/test/sandbox/spawn.js b/test/sandbox/spawn.js index 67d7dd53..fc55cdb1 100644 --- a/test/sandbox/spawn.js +++ b/test/sandbox/spawn.js @@ -4,11 +4,11 @@ const spawn = require('cross-spawn'); /** * @param {string} processPath * @param {*[]} argv - * @param {object} [options] - * @property {string} [options.cwd] - * @property {*} [options.env] - * @property {string | RegExp} [options.successMessage] - * @return {Promise} + * @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, '../..'); @@ -23,6 +23,10 @@ function spawnTestProcess(processPath, argv, options = {}) { 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)) { @@ -37,6 +41,10 @@ function spawnTestProcess(processPath, argv, options = {}) { } } + /** + * @param {Buffer} data + * @returns {void} + */ function handleStderr(data) { const message = data.toString(); process.stderr.write(message); @@ -61,6 +69,12 @@ function spawnTestProcess(processPath, argv, options = {}) { }); } +/** + * @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( @@ -78,6 +92,9 @@ function spawnWDS(port, directory, options) { ); } +/** + * @param {import('child_process').ChildProcess} instance + */ function killTestProcess(instance) { try { process.kill(instance.pid);