diff --git a/__tests__/server/utils/watchLocalModules.spec.js b/__tests__/server/utils/watchLocalModules.spec.js index c60eae823..47206ce52 100644 --- a/__tests__/server/utils/watchLocalModules.spec.js +++ b/__tests__/server/utils/watchLocalModules.spec.js @@ -1,3 +1,7 @@ +/** + * @jest-environment node + */ + /* * Copyright 2019 American Express Travel Related Services Company, Inc. * @@ -17,26 +21,345 @@ import fs from 'node:fs'; import path from 'node:path'; import { fromJS } from 'immutable'; -import chokidar from 'chokidar'; import loadModule from 'holocron/loadModule.node'; -import { - getModules, - resetModuleRegistry, -} from 'holocron/moduleRegistry'; +import { getModules, resetModuleRegistry } from 'holocron/moduleRegistry'; import watchLocalModules from '../../../src/server/utils/watchLocalModules'; -import { getIp } from '../../../src/server/utils/getIP'; -const ip = getIp(); +jest.mock('fs', () => { + const fsActual = jest.requireActual('fs'); + const setImmediateNative = global.setImmediate; + + let mockedFilesystem; + /* Map { + indicator: 'd', + stat: {...}, + entries: Map { + 'home': Map { + indicator: 'd', + stat: {...}, + entries: Map { + 'username': Map { + indicator: 'd', + stat: {...}, + entries: Map { + 'hello.txt': Map { + indicator: 'f', + stat: {...}, + } + } + } + } + } + } + } */ + + function getEntry(parts) { + return parts.reduce((parentEntry, entryName) => { + if (!parentEntry || !parentEntry.has('entries')) { + return null; + } + return parentEntry.get('entries').get(entryName); + }, mockedFilesystem); + } + + let inodeCount = 0; + function getNewInode() { + inodeCount += 1; + return inodeCount; + } + const mock = { + clear() { + const createdMillis = Date.now() + Math.floor(Math.random() * 1e4) / 1e4; + mockedFilesystem = new Map([ + ['indicator', 'd'], + [ + 'stat', + { + dev: 1234, + mode: 16877, + nlink: 1, + uid: 512, + gid: 512, + rdev: 0, + blksize: 4096, + ino: getNewInode(), + size: 128, + blocks: 0, + atimeMs: Date.now() + Math.floor(Math.random() * 1e4) / 1e4, + mtimeMs: createdMillis, + ctimeMs: createdMillis, + birthtimeMs: createdMillis, + }, + ], + ['entries', new Map()], + ]); + }, + delete(fsPath) { + const parts = fsPath.split('/').filter(Boolean); + const final = parts.pop(); + const parent = getEntry(parts); + if (!parent || !parent.has('entries')) { + throw new Error(`not in mock fs ${fsPath} (delete)`); + } + parent.get('entries').delete(final); + return mock; + }, + mkdir(fsPath, { parents: createParents } = { parents: false }) { + let parent = mockedFilesystem; + const parts = fsPath.split('/').filter(Boolean); + + for (let i = 0; i < parts.length; i += 1) { + const nextEntry = parts[i]; + if ( + parent.get('entries').has(nextEntry) + && parent.get('entries').get(nextEntry).get('indicator') !== 'd' + ) { + throw new Error(`parent is not a directory ${fsPath} (mkdir)`); + } + if (!parent.get('entries').has(nextEntry)) { + if (i !== (parts.length - 1) && !createParents) { + throw new Error(`parent directory does not exist for ${fsPath}`); + } + parent.get('entries').set( + nextEntry, + new Map([ + ['indicator', 'd'], + [ + 'stat', + { + dev: 1234, + mode: 16877, + nlink: 1, + uid: 512, + gid: 512, + rdev: 0, + blksize: 4096, + ino: getNewInode(), + size: 128, + blocks: 0, + atimeMs: Date.now() + 0.3254, + mtimeMs: Date.now() + 0.2454, + ctimeMs: Date.now() + 0.2454, + birthtimeMs: Date.now() + 0.0117, + }, + ], + ['entries', new Map()], + ]) + ); + } + parent = parent.get('entries').get(nextEntry); + } + return mock; + }, + writeFile(fsPath, contents) { + const parts = fsPath.split('/').filter(Boolean); + const final = parts.pop(); + const parent = getEntry(parts); + if (!parent || !parent.get('entries')) { + throw new Error(`not in mock fs ${fsPath} (write)`); + } -jest.mock('chokidar', () => { - const listeners = {}; - const watcher = () => null; - watcher.on = (event, listener) => { - listeners[event] = listener; + if (parent.get('entries').has(final)) { + const fileEntry = parent.get('entries').get(final); + Object.assign( + fileEntry.get('stat'), + { mtimeMs: Date.now() + Math.floor(Math.random() * 1e4) / 1e4 } + ); + fileEntry.set('contents', contents); + } else { + const createdMillis = Date.now() + Math.floor(Math.random() * 1e4) / 1e4; + parent.get('entries').set( + final, + new Map([ + ['indicator', 'f'], + [ + 'stat', + { + dev: 1234, + mode: 33188, + nlink: 1, + uid: 512, + gid: 512, + rdev: 0, + blksize: 4096, + ino: getNewInode(), + size: contents.length, + blocks: contents.length / 512, + atimeMs: Date.now() + Math.floor(Math.random() * 1e4) / 1e4, + mtimeMs: createdMillis, + ctimeMs: createdMillis, + birthtimeMs: createdMillis, + }, + ], + ['contents', contents], + ]) + ); + } + + return mock; + }, + print() { + function traverser(parentPath, entry) { + let printout = ''; + [...entry.get('entries').entries()].forEach(([childName, childNode]) => { + const childPath = `${parentPath}/${childName}`; + if (!childNode) { + throw new Error(`no child for ${childName}??`); + } + const indicator = childNode.get('indicator'); + printout += `${indicator} ${childPath}\n`; + if (indicator === 'd') { + printout += traverser(childPath, childNode); + } + }); + return printout; + } + + const printout = traverser('', mockedFilesystem); + console.info(printout); + return mock; + }, }; - const watch = jest.fn(() => watcher); - const getListeners = () => listeners; - return { watch, getListeners }; + + mock.clear(); + + // https://github.com/isaacs/path-scurry/blob/main/src/index.ts#L88-L100 + // https://github.com/isaacs/path-scurry/blob/v1.10.2/src/index.ts#L268-L270 + function indicatorToDirentType(indicator) { + switch (indicator) { + case 'd': + return fsActual.constants.UV_DIRENT_DIR; + case 'f': + return fsActual.constants.UV_DIRENT_FILE; + default: + return fsActual.constants.UV_DIRENT_UNKNOWN; + } + } + + jest.spyOn(fsActual, 'readdir').mockImplementation((dirPath, options, callback) => { + if (!callback) { + /* eslint-disable no-param-reassign -- fs.readdir sets `options` as an optional argument */ + callback = options; + options = {}; + /* eslint-enable no-param-reassign */ + } + const parts = dirPath.split('/').filter(Boolean); + const dir = getEntry(parts); + if (!dir) { + setImmediateNative(callback, new Error(`not in mock fs ${dirPath} (readdir)`)); + return; + } + if (dir.get('indicator') !== 'd') { + setImmediateNative(callback, new Error(`not a mocked directory ${dirPath} (readdir)`)); + return; + } + + let directoryEntries = dir.get('entries'); + if (options.withFileTypes) { + directoryEntries = [...directoryEntries.entries()].map(([name, attributes]) => new fsActual.Dirent(name, indicatorToDirentType(attributes.get('indicator')), [dirPath, name].join('/'))); + } else { + directoryEntries = [...directoryEntries.keys()]; + } + setImmediateNative(callback, null, directoryEntries); + }); + + jest.spyOn(fsActual.promises, 'readdir').mockImplementation((dirPath, options = {}) => new Promise((resolve, reject) => { + const parts = dirPath.split('/').filter(Boolean); + const dir = getEntry(parts); + if (!dir) { + reject(new Error(`not in mock fs ${dirPath} (readdir)`)); + return; + } + if (dir.get('indicator') !== 'd') { + reject(new Error(`not a mocked directory ${dirPath} (readdir)`)); + return; + } + + let directoryEntries = dir.get('entries'); + if (options.withFileTypes) { + directoryEntries = [...directoryEntries.entries()].map(([name, attributes]) => new fsActual.Dirent(name, indicatorToDirentType(attributes.get('indicator')), [dirPath, name].join('/'))); + } else { + directoryEntries = [...directoryEntries.keys()]; + } + resolve(directoryEntries); + })); + + jest.spyOn(fsActual, 'readdirSync').mockImplementation((dirPath, options = {}) => { + const parts = dirPath.split('/').filter(Boolean); + const dir = getEntry(parts); + if (!dir) { + throw new Error(`not in mock fs ${dirPath} (readdir)`); + } + if (dir.get('indicator') !== 'd') { + throw new Error(`not a mocked directory ${dirPath} (readdir)`); + } + + let directoryEntries = dir.get('entries'); + if (options.withFileTypes) { + directoryEntries = [...directoryEntries.entries()].map(([name, attributes]) => new fsActual.Dirent(name, indicatorToDirentType(attributes.get('indicator')), [dirPath, name].join('/'))); + } else { + directoryEntries = [...directoryEntries.keys()]; + } + return directoryEntries; + }); + + function statKeyArgsToPositional({ + dev, + mode, + nlink, + uid, + gid, + rdev, + blksize, + ino, + size, + blocks, + atimeMs, + mtimeMs, + ctimeMs, + birthtimeMs, + }) { + return [ + dev, + mode, + nlink, + uid, + gid, + rdev, + blksize, + ino, + size, + blocks, + atimeMs, + mtimeMs, + ctimeMs, + birthtimeMs, + ]; + } + + jest.spyOn(fsActual.promises, 'stat').mockImplementation( + (fsPath) => new Promise((resolve, reject) => { + const entry = getEntry(fsPath.split('/').filter(Boolean)); + if (!entry) { + reject(new Error(`no entry for ${fsPath} (stat)`)); + } else { + resolve(new fsActual.Stats(...statKeyArgsToPositional(entry.get('stat')))); + } + }) + ); + + jest.spyOn(fsActual.promises, 'lstat').mockImplementation((fsPath) => new Promise((resolve, reject) => { + const entry = getEntry(fsPath.split('/').filter(Boolean)); + if (!entry) { + reject(new Error(`no entry for ${fsPath} (stat)`)); + } else { + resolve(new fsActual.Stats(...statKeyArgsToPositional(entry.get('stat')))); + } + })); + + fsActual.mock = mock; + + return fsActual; }); jest.mock('holocron/moduleRegistry', () => { @@ -59,184 +382,1099 @@ jest.mock('holocron/moduleRegistry', () => { }); jest.mock('holocron/loadModule.node', () => jest.fn(() => Promise.resolve(() => null))); -jest.spyOn(fs, 'readFileSync'); -jest.spyOn(console, 'error').mockImplementation(() => { }); + +jest.spyOn(console, 'error').mockImplementation(() => {}); +jest.spyOn(console, 'warn').mockImplementation(() => {}); +jest.spyOn(console, 'info').mockImplementation(() => {}); describe('watchLocalModules', () => { - beforeEach(() => jest.clearAllMocks()); let origOneAppDevCDNPort; beforeAll(() => { origOneAppDevCDNPort = process.env.HTTP_ONE_APP_DEV_CDN_PORT; }); + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.HTTP_ONE_APP_DEV_CDN_PORT; + fs.mock.clear(); + jest.spyOn(global, 'setTimeout').mockImplementation(() => ({ unref: jest.fn() })); + jest.spyOn(global, 'setImmediate').mockImplementation(() => ({ unref: jest.fn() })); + }); + afterEach(() => { + setImmediate.mockClear(); + setTimeout.mockClear(); + }); afterAll(() => { process.env.HTTP_ONE_APP_DEV_CDN_PORT = origOneAppDevCDNPort; }); - it('should watch the modules directory', () => { + it('should tell the user when a module build was updated', async () => { + expect.assertions(22); + const moduleName = 'some-module'; + const moduleVersion = '1.0.1'; + const moduleMapSample = { + modules: { + [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, + node: { + integrity: '133', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, + }, + browser: { + integrity: '234', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, + }, + legacyBrowser: { + integrity: '134633', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, + }, + }, + }, + }; + const originalModule = () => null; + const updatedModule = () => null; + const modules = fromJS({ [moduleName]: originalModule }); + const moduleMap = fromJS(moduleMapSample); + resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello");'); + + // initiate watching + watchLocalModules(); + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + + expect(getModules().get(moduleName)).toBe(originalModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock + .delete(path.resolve('static/modules', moduleName)) + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello again");'); + loadModule.mockReturnValueOnce(Promise.resolve(updatedModule)); + + // run the third change poll, which should see the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(4); + expect(console.info).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll + await setTimeout.mock.calls[3][0](); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(console.info).not.toHaveBeenCalled(); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(loadModule).toHaveBeenCalledTimes(1); + expect(getModules().get(moduleName)).toBe(updatedModule); + expect(console.info).toHaveBeenCalledTimes(2); + expect(console.info.mock.calls[0]).toMatchInlineSnapshot(` + [ + "the Node.js bundle for %s finished saving, attempting to load", + "some-module", + ] + `); + }); + + it('should tell the user when the updated module is loaded', async () => { + expect.assertions(22); + const moduleName = 'some-module'; + const moduleVersion = '1.0.1'; + const moduleMapSample = { + modules: { + [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, + node: { + integrity: '133', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, + }, + browser: { + integrity: '234', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, + }, + legacyBrowser: { + integrity: '134633', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, + }, + }, + }, + }; + const originalModule = () => null; + const updatedModule = () => null; + const modules = fromJS({ [moduleName]: originalModule }); + const moduleMap = fromJS(moduleMapSample); + resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello");'); + + // initiate watching watchLocalModules(); - expect(chokidar.watch).toHaveBeenCalledWith(path.resolve(__dirname, '../../../static/modules'), { awaitWriteFinish: true }); + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + + expect(getModules().get(moduleName)).toBe(originalModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock + .delete(path.resolve('static/modules', moduleName)) + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello again");'); + loadModule.mockReturnValueOnce(Promise.resolve(updatedModule)); + + // run the third change poll, which should see the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(4); + expect(console.info).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll + await setTimeout.mock.calls[3][0](); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(console.info).not.toHaveBeenCalled(); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(loadModule).toHaveBeenCalledTimes(1); + expect(getModules().get(moduleName)).toBe(updatedModule); + expect(console.info).toHaveBeenCalledTimes(2); + expect(console.info.mock.calls[1]).toMatchInlineSnapshot(` + [ + "finished reloading %s", + "some-module", + ] + `); }); it('should update the module registry when a server bundle changes', async () => { + expect.assertions(23); const moduleName = 'some-module'; const moduleVersion = '1.0.1'; const moduleMapSample = { modules: { [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, node: { integrity: '133', - url: `https://example.com/cdn/${moduleName}/${moduleVersion}/${moduleName}-node.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, }, browser: { integrity: '234', - url: `https://example.com/cdn/${moduleName}/${moduleVersion}/${moduleName}-browser.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, }, legacyBrowser: { integrity: '134633', - url: `https://example.com/cdn/${moduleName}/${moduleVersion}/${moduleName}-legacy.browser.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, }, }, }, }; - fs.readFileSync.mockImplementationOnce(() => JSON.stringify(moduleMapSample)); - const modulePath = path.resolve(__dirname, `../../../static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`); const originalModule = () => null; const updatedModule = () => null; const modules = fromJS({ [moduleName]: originalModule }); const moduleMap = fromJS(moduleMapSample); resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello");'); + + // initiate watching watchLocalModules(); - const changeListener = chokidar.getListeners().change; + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + expect(getModules().get(moduleName)).toBe(originalModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock + .delete(path.resolve('static/modules', moduleName)) + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello again");'); loadModule.mockReturnValueOnce(Promise.resolve(updatedModule)); - await changeListener(modulePath); - expect(loadModule).toHaveBeenCalledWith( - moduleName, - moduleMapSample.modules[moduleName], + + // run the third change poll, which should see the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(4); + expect(console.info).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll + await setTimeout.mock.calls[3][0](); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(console.info).not.toHaveBeenCalled(); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(loadModule).toHaveBeenCalledTimes(1); + expect(loadModule.mock.calls[0][0]).toBe(moduleName); + expect(loadModule.mock.calls[0][1]).toMatchInlineSnapshot(` + { + "baseUrl": "http://localhost:3001/static/modules/some-module/1.0.1/", + "browser": { + "integrity": "234", + "url": "http://localhost:3001/static/modules/some-module/1.0.1/some-module.browser.js", + }, + "legacyBrowser": { + "integrity": "134633", + "url": "http://localhost:3001/static/modules/some-module/1.0.1/some-module.legacy.browser.js", + }, + "node": { + "integrity": "133", + "url": "http://localhost:3001/static/modules/some-module/1.0.1/some-module.node.js", + }, + } + `); + expect(loadModule.mock.calls[0][2]).toBe( require('../../../src/server/utils/onModuleLoad').default ); expect(getModules().get(moduleName)).toBe(updatedModule); }); - it('handles a rejection properly', async () => { + it('should not change the module registry when a new module fails to load', async () => { + expect.assertions(20); + const moduleName = 'some-module'; + const moduleVersion = '1.0.1'; + const moduleMapSample = { + modules: { + [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, + node: { + integrity: '133', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, + }, + browser: { + integrity: '234', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, + }, + legacyBrowser: { + integrity: '134633', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, + }, + }, + }, + }; + const originalModule = () => null; + const modules = fromJS({ [moduleName]: originalModule }); + const moduleMap = fromJS(moduleMapSample); + resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello");'); + + // initiate watching + watchLocalModules(); + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + + expect(getModules().get(moduleName)).toBe(originalModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock + .delete(path.resolve('static/modules', moduleName)) + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello again");'); + loadModule.mockImplementationOnce(() => Promise.reject(new Error('sample-module startup error'))); + + // run the third change poll, which should see the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(4); + expect(console.info).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll + await setTimeout.mock.calls[3][0](); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(console.info).not.toHaveBeenCalled(); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(loadModule).toHaveBeenCalledTimes(1); + expect(getModules().get(moduleName)).toBe(originalModule); + }); + + it('should wait if the file was written to since it was detected to have changed', async () => { + expect.assertions(20); const moduleName = 'some-module'; const moduleVersion = '1.0.1'; const moduleMapSample = { modules: { [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, node: { integrity: '133', - url: `https://example.com/cdn/${moduleName}/${moduleVersion}/${moduleName}-node.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, }, browser: { integrity: '234', - url: `https://example.com/cdn/${moduleName}/${moduleVersion}/${moduleName}-browser.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, }, legacyBrowser: { integrity: '134633', - url: `https://example.com/cdn/${moduleName}/${moduleVersion}/${moduleName}-legacy.browser.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, }, }, }, }; - fs.readFileSync.mockImplementationOnce(() => JSON.stringify(moduleMapSample)); - const modulePath = path.resolve(__dirname, `../../../static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`); const originalModule = () => null; const updatedModule = () => null; const modules = fromJS({ [moduleName]: originalModule }); const moduleMap = fromJS(moduleMapSample); resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello");'); + + // initiate watching watchLocalModules(); - const changeListener = chokidar.getListeners().change; + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + expect(getModules().get(moduleName)).toBe(originalModule); - loadModule.mockReturnValueOnce(Promise.reject(updatedModule)); - await changeListener(modulePath); - expect(loadModule).toHaveBeenCalledWith( - moduleName, - moduleMapSample.modules[moduleName], - require('../../../src/server/utils/onModuleLoad').default - ); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock + .delete(path.resolve('static/modules', moduleName)) + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("he'); + loadModule.mockImplementation(() => Promise.reject(new Error('sample-module startup error'))); + + // run the third change poll, which should see the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(4); + + fs.mock.writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello again");'); + loadModule.mockClear().mockReturnValue(Promise.resolve(updatedModule)); + + // run the writesFinishWatcher poll after the writing finishes + await setTimeout.mock.calls[3][0](); + + // writesFinishWatcher should need to run again + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(5); + + expect(loadModule).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll again, checks for further writes + await setTimeout.mock.calls[4][0](); + + // writesFinishWatcher should NOT need to run again + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(5); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(5); + + expect(loadModule).toHaveBeenCalled(); + }); + + it('should load a module that has since been built but was not on disk when the process started', async () => { + expect.assertions(22); + const moduleName = 'some-module'; + const moduleVersion = '1.0.1'; + const moduleMapSample = { + modules: { + [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, + node: { + integrity: '133', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, + }, + browser: { + integrity: '234', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, + }, + legacyBrowser: { + integrity: '134633', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, + }, + }, + }, + }; + const originalModule = () => null; + const updatedModule = () => null; + const modules = fromJS({ [moduleName]: originalModule }); + const moduleMap = fromJS(moduleMapSample); + resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }); + + // initiate watching + watchLocalModules(); + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + expect(getModules().get(moduleName)).toBe(originalModule); - expect(console.error).toHaveBeenCalledTimes(1); - expect(console.error).toHaveBeenCalledWith(updatedModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock + .delete(path.resolve('static/modules', moduleName)) + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello again");'); + loadModule.mockReturnValueOnce(Promise.resolve(updatedModule)); + + // run the third change poll, which should see the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(4); + expect(console.info).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll + await setTimeout.mock.calls[3][0](); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(console.info).not.toHaveBeenCalled(); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + expect(loadModule).toHaveBeenCalledTimes(1); + expect(getModules().get(moduleName)).toBe(updatedModule); + expect(console.info).toHaveBeenCalledTimes(2); + expect(console.info.mock.calls[0]).toMatchInlineSnapshot(` + [ + "the Node.js bundle for %s finished saving, attempting to load", + "some-module", + ] + `); }); - it('should ignore when the regex doesn\'t match', async () => { - const changedPath = path.resolve(__dirname, '../../../static/modules/dont-match-me-bro.node.js'); + it('should ignore modules that are not in the module map', async () => { + expect.assertions(25); + const moduleName = 'some-module'; + const moduleVersion = '1.0.1'; + const moduleMapSample = { + modules: { + [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, + node: { + integrity: '133', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, + }, + browser: { + integrity: '234', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, + }, + legacyBrowser: { + integrity: '134633', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, + }, + }, + }, + }; + const originalModule = () => null; + const updatedModule = () => null; + const modules = fromJS({ [moduleName]: originalModule }); + const moduleMap = fromJS(moduleMapSample); + resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', 'other-module', '1.2.3'), { parents: true }) + .writeFile(path.resolve('static/modules', 'other-module', '1.2.3', `${'other-module'}.node.js`), 'console.info("hello");'); + + loadModule.mockReturnValue(Promise.resolve(updatedModule)); + + // initiate watching watchLocalModules(); - const changeListener = chokidar.getListeners().change; - await changeListener(changedPath); + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + + expect(getModules().get(moduleName)).toBe(originalModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock + .delete(path.resolve('static/modules', moduleName)) + .mkdir(path.resolve('static/modules', 'other-module', '1.2.3'), { parents: true }) + .writeFile(path.resolve('static/modules', 'other-module', '1.2.3', `${'other-module'}.node.js`), 'console.info("hello");'); + + // run the third change poll, which should see the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(4); + + // run the writesFinishWatcher poll after the writing finishes + await setTimeout.mock.calls[3][0](); + + // writesFinishWatcher should need to run again + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(4); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + expect(loadModule).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn.mock.calls[0]).toMatchInlineSnapshot(` + [ + "module "%s" not in the module map, make sure to serve-module first", + "other-module", + ] + `); + + // make sure this doesn't block modules in the module map from being reloaded + fs.mock + .delete(path.resolve('static/modules', moduleName)) + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello again");'); + + // run the changeWatcher poll + await setTimeout.mock.calls[2][0](); + + expect(setImmediate).toHaveBeenCalledTimes(2); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(6); + + expect(loadModule).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll + await setTimeout.mock.calls[5][0](); + + // writesFinishWatcher should NOT need to run again + expect(setImmediate).toHaveBeenCalledTimes(3); + expect(setTimeout).toHaveBeenCalledTimes(6); + + // run the change handler + await setImmediate.mock.calls[2][0](setImmediate.mock.calls[2][1]); + + expect(setImmediate).toHaveBeenCalledTimes(3); + expect(setTimeout).toHaveBeenCalledTimes(6); + + expect(loadModule).toHaveBeenCalled(); + }); + + // instance when the CHANGE_WATCHER_INTERVAL and WRITING_FINISH_WATCHER_TIMEOUT lined up + // we need to avoid scheduling the write watcher like a tree (many branches, eventually eating + // all CPU and memory) + it('should schedule watching for writes only once when both watchers have run', async () => { + expect.assertions(13); + const moduleName = 'some-module'; + const moduleVersion = '1.0.1'; + const moduleMapSample = { + modules: { + [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, + node: { + integrity: '133', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, + }, + browser: { + integrity: '234', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, + }, + legacyBrowser: { + integrity: '134633', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, + }, + }, + }, + }; + const originalModule = () => null; + const modules = fromJS({ [moduleName]: originalModule }); + const moduleMap = fromJS(moduleMapSample); + resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello");'); + + // initiate watching + watchLocalModules(); + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + + expect(getModules().get(moduleName)).toBe(originalModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock + .delete(path.resolve('static/modules', moduleName)) + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello again");'); + loadModule.mockImplementation(() => Promise.reject(new Error('sample-module startup error'))); + + // run the third change poll, which should see the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(4); + + // there may be times that their intervals coincide + // we don't want writesFinishWatcher to be scheduled twice + await Promise.all([ + setTimeout.mock.calls[2][0](), + setTimeout.mock.calls[3][0](), + ]); + + expect(setTimeout).toHaveBeenCalledTimes(5); }); - it('should ignore changes to all files but the server bundle', async () => { + it('should ignore changes to all bundles but the server bundle', async () => { + expect.assertions(22); const moduleName = 'some-module'; const moduleVersion = '1.0.0'; - const changedPath = path.resolve(__dirname, `../../../static/modules/${moduleName}/${moduleVersion}/assets/image.png`); + const moduleMapSample = { + modules: { + [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, + node: { + integrity: '133', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, + }, + browser: { + integrity: '234', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, + }, + legacyBrowser: { + integrity: '134633', + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, + }, + }, + }, + }; + const originalModule = () => null; + const updatedModule = () => null; + const modules = fromJS({ [moduleName]: originalModule }); + const moduleMap = fromJS(moduleMapSample); + resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello Node.js");') + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.browser.js`), 'console.info("hello Browser");') + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.legacy.browser.js`), 'console.info("hello previous spec Browser");'); + + // initiate watching watchLocalModules(); - const changeListener = chokidar.getListeners().change; - await changeListener(changedPath); + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + + expect(getModules().get(moduleName)).toBe(originalModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.browser.js`), 'console.info("hello again Browser");') + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.legacy.browser.js`), 'console.info("hello again previous spec Browser");'); + loadModule.mockImplementation(() => Promise.reject(new Error('sample-module startup error'))); + + // run the third change poll, which should see but ignore the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(3); + expect(loadModule).not.toHaveBeenCalled(); + + fs.mock.writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello Node.js");'); + loadModule.mockClear().mockReturnValue(Promise.resolve(updatedModule)); + + // run the fourth change poll, which should see the filesystem changes + await setTimeout.mock.calls[2][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(5); + expect(console.info).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll + await setTimeout.mock.calls[4][0](); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(5); + + expect(console.info).not.toHaveBeenCalled(); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(5); + + expect(loadModule).toHaveBeenCalled(); }); - it('should replace [one-app-dev-cdn-url] with correct ip and port', async () => { + it('should ignore changes to server bundles that are not the module entrypoint', async () => { + expect.assertions(22); const moduleName = 'some-module'; - const moduleVersion = '1.0.1'; - process.env.HTTP_ONE_APP_DEV_CDN_PORT = 3002; + const moduleVersion = '1.0.0'; const moduleMapSample = { modules: { [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, node: { integrity: '133', - url: `[one-app-dev-cdn-url]/cdn/${moduleName}/${moduleVersion}/${moduleName}-node.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, }, browser: { integrity: '234', - url: `[one-app-dev-cdn-url]/cdn/${moduleName}/${moduleVersion}/${moduleName}-browser.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, }, legacyBrowser: { integrity: '134633', - url: `[one-app-dev-cdn-url]/cdn/${moduleName}/${moduleVersion}/${moduleName}-legacy.browser.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, }, }, }, }; - const oneAppDevCdnAddress = `http://${ip}:${process.env.HTTP_ONE_APP_DEV_CDN_PORT || 3001}`; - const updatedModuleMapSample = { + const originalModule = () => null; + const updatedModule = () => null; + const modules = fromJS({ [moduleName]: originalModule }); + const moduleMap = fromJS(moduleMapSample); + resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello");') + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, 'vendors.node.js'), 'console.info("hi");'); + + // initiate watching + watchLocalModules(); + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + + expect(getModules().get(moduleName)).toBe(originalModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock.writeFile(path.resolve('static/modules', moduleName, moduleVersion, 'vendors.node.js'), 'console.info("hi there");'); + loadModule.mockImplementation(() => Promise.reject(new Error('sample-module startup error'))); + + // run the third change poll, which should see but ignore the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(3); + + expect(loadModule).not.toHaveBeenCalled(); + + fs.mock.writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello Node.js");'); + loadModule.mockClear().mockReturnValue(Promise.resolve(updatedModule)); + + // run the fourth change poll, which should see the filesystem changes + await setTimeout.mock.calls[2][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(5); + expect(console.info).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll + await setTimeout.mock.calls[4][0](); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(5); + + expect(console.info).not.toHaveBeenCalled(); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(5); + + expect(loadModule).toHaveBeenCalled(); + }); + + it('should ignore changes to files that are not JavaScript', async () => { + expect.assertions(22); + const moduleName = 'some-module'; + const moduleVersion = '1.0.0'; + const moduleMapSample = { modules: { [moduleName]: { + baseUrl: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/`, node: { integrity: '133', - url: `${oneAppDevCdnAddress}/cdn/${moduleName}/${moduleVersion}/${moduleName}-node.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`, }, browser: { integrity: '234', - url: `${oneAppDevCdnAddress}/cdn/${moduleName}/${moduleVersion}/${moduleName}-browser.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.browser.js`, }, legacyBrowser: { integrity: '134633', - url: `${oneAppDevCdnAddress}/cdn/${moduleName}/${moduleVersion}/${moduleName}-legacy.browser.js`, + url: `http://localhost:3001/static/modules/${moduleName}/${moduleVersion}/${moduleName}.legacy.browser.js`, }, }, }, }; - fs.readFileSync.mockImplementationOnce(() => JSON.stringify(moduleMapSample)); - const modulePath = path.resolve(__dirname, `../../../static/modules/${moduleName}/${moduleVersion}/${moduleName}.node.js`); const originalModule = () => null; const updatedModule = () => null; const modules = fromJS({ [moduleName]: originalModule }); const moduleMap = fromJS(moduleMapSample); resetModuleRegistry(modules, moduleMap); + fs.mock + .mkdir(path.resolve('static/modules', moduleName, moduleVersion), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello");') + .mkdir(path.resolve('static/modules', moduleName, moduleVersion, 'assets'), { parents: true }) + .writeFile(path.resolve('static/modules', moduleName, moduleVersion, 'assets', 'image.png'), 'binary stuff'); + + // initiate watching watchLocalModules(); - const changeListener = chokidar.getListeners().change; + + expect(setTimeout).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(console.info).not.toHaveBeenCalled(); + + // run the first change poll + await setImmediate.mock.calls[0][0](); + + expect(console.info).not.toHaveBeenCalled(); + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(1); + + // run the second change poll, which should not see any filesystem changes + await setTimeout.mock.calls[0][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(2); + expect(getModules().get(moduleName)).toBe(originalModule); - loadModule.mockReturnValueOnce(Promise.resolve(updatedModule)); - await changeListener(modulePath); - expect(loadModule).toHaveBeenCalledWith( - moduleName, - updatedModuleMapSample.modules[moduleName], - require('../../../src/server/utils/onModuleLoad').default - ); - expect(getModules().get(moduleName)).toBe(updatedModule); + expect(console.info).not.toHaveBeenCalled(); + + fs.mock.writeFile(path.resolve('static/modules', moduleName, moduleVersion, 'assets', 'image.png'), 'other binary stuff'); + loadModule.mockImplementation(() => Promise.reject(new Error('sample-module startup error'))); + + // run the third change poll, which should see but ignore the filesystem changes + await setTimeout.mock.calls[1][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledTimes(3); + + expect(loadModule).not.toHaveBeenCalled(); + + fs.mock.writeFile(path.resolve('static/modules', moduleName, moduleVersion, `${moduleName}.node.js`), 'console.info("hello Node.js");'); + loadModule.mockClear().mockReturnValue(Promise.resolve(updatedModule)); + + // run the fourth change poll, which should see the filesystem changes + await setTimeout.mock.calls[2][0](); + + expect(setImmediate).toHaveBeenCalledTimes(1); + // first the changeWatcher is queued to run again + // then the writesFinishWatcher is queued + expect(setTimeout).toHaveBeenCalledTimes(5); + expect(console.info).not.toHaveBeenCalled(); + + // run the writesFinishWatcher poll + await setTimeout.mock.calls[4][0](); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(5); + + expect(console.info).not.toHaveBeenCalled(); + + // run the change handler + await setImmediate.mock.calls[1][0](setImmediate.mock.calls[1][1]); + + expect(setImmediate).toHaveBeenCalledTimes(2); + expect(setTimeout).toHaveBeenCalledTimes(5); + + expect(loadModule).toHaveBeenCalled(); }); }); diff --git a/package-lock.json b/package-lock.json index 7e64948f5..5b0ea8b71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,6 +108,7 @@ "eslint-plugin-jest": "^27.9.0", "eslint-plugin-jest-dom": "^4.0.3", "expect": "^29.7.0", + "glob": "^10.3.12", "husky": "^9.0.11", "jest": "^29.7.0", "jest-circus": "^29.7.0", @@ -460,6 +461,26 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/cli/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", @@ -2909,49 +2930,6 @@ "glob": "^10.3.4" } }, - "node_modules/@fastify/static/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@fastify/static/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@fastify/static/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -3513,6 +3491,26 @@ } } }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/reporters/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3784,6 +3782,25 @@ "node": ">=10" } }, + "node_modules/@npmcli/move-file/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@npmcli/move-file/node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -5935,6 +5952,26 @@ "node": ">= 6" } }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/archiver/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -7043,6 +7080,25 @@ "node": ">= 10" } }, + "node_modules/cacache/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/cacache/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -9602,6 +9658,26 @@ "integrity": "sha512-5fGyt9xmMqUl2VI7+rnUkKCiAQIpLns8sfQtTENy5L70ktbNw0Z3TFJ1JoFNYdx/jffz4YXU45VF75wKZD7sZQ==", "dev": true }, + "node_modules/devtools/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/devtools/node_modules/https-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", @@ -11687,6 +11763,26 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flat-cache/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/flat-cache/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -12225,19 +12321,21 @@ } }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "version": "10.3.12", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", + "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.6", + "minimatch": "^9.0.1", + "minipass": "^7.0.4", + "path-scurry": "^1.10.2" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -12255,6 +12353,28 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -14173,6 +14293,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest-config/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -14871,6 +15011,26 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest-runtime/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -17619,11 +17779,11 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", + "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==", "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { @@ -18366,6 +18526,26 @@ "integrity": "sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==", "dev": true }, + "node_modules/puppeteer-core/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/puppeteer-core/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -19376,6 +19556,25 @@ "rimraf": "bin.js" } }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ripemd160": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", @@ -21258,6 +21457,26 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-extensions": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", @@ -22619,6 +22838,25 @@ "node": ">=6" } }, + "node_modules/webpack/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/webpack/node_modules/is-descriptor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", @@ -23299,6 +23537,26 @@ "node": ">= 10" } }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/zip-stream/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", diff --git a/package.json b/package.json index 97c6f7891..f0cf26a0b 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "eslint-plugin-jest": "^27.9.0", "eslint-plugin-jest-dom": "^4.0.3", "expect": "^29.7.0", + "glob": "^10.3.12", "husky": "^9.0.11", "jest": "^29.7.0", "jest-circus": "^29.7.0", diff --git a/src/server/utils/watchLocalModules.js b/src/server/utils/watchLocalModules.js index 449ea79e4..49260b2b7 100644 --- a/src/server/utils/watchLocalModules.js +++ b/src/server/utils/watchLocalModules.js @@ -14,9 +14,9 @@ * permissions and limitations under the License. */ -import fs from 'node:fs'; import path from 'node:path'; -import chokidar from 'chokidar'; +import fs from 'node:fs/promises'; +import { glob } from 'glob'; import loadModule from 'holocron/loadModule.node'; import { getModules, @@ -24,42 +24,135 @@ import { resetModuleRegistry, addHigherOrderComponent, } from 'holocron/moduleRegistry'; -import { getIp } from './getIP'; import onModuleLoad from './onModuleLoad'; +const CHANGE_WATCHER_INTERVAL = 1000; +const WRITING_FINISH_WATCHER_TIMEOUT = 400; + +// why our own code instead of something like https://www.npmjs.com/package/chokidar? +// we did, but saw misses, especially when using @americanexpress/one-app-runner +// * occasional misses even on the native filesystem (e.g. macOS) +// * https://forums.docker.com/t/file-system-watch-does-not-work-with-mounted-volumes/12038 +// * runner on macOS chokidar would see the first change but not subsequent changes, presumably due +// to inode tracking and the way the build directories are destroyed and recreated +// * using runner with --envVars.CHOKIDAR_USEPOLLING=true had no effect +// for us the pattern of the files we want to check have a very consistent pattern and we don't need +// to know the changes in the sometimes very many other files, so we can skip a lot of work from a +// general solution + +async function changeHandler(changedPath) { + // I had apprehension of this manual manipulation + // but from what I can tell a file or directory with '/' in the name is not valid + // Linux: https://stackoverflow.com/q/9847288 + // macOS: '/' from Finder is translated to ':' + // Windows: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions + const [moduleName] = changedPath.split(path.sep); + const moduleMap = getModuleMap(); + const moduleMapEntry = moduleMap.getIn(['modules', moduleName]); + + if (!moduleMapEntry) { + console.warn('module "%s" not in the module map, make sure to serve-module first', moduleName); + return; + } + + console.info('the Node.js bundle for %s finished saving, attempting to load', moduleName); + + let newModule; + try { + // FIXME: this leads to a race condition later + // (two modules changed at the same time, both editing different copies of the module map) + newModule = addHigherOrderComponent(await loadModule( + moduleName, + moduleMapEntry.toJS(), + onModuleLoad + )); + } catch (error) { + // loadModule already logs the error and then re-throws + // logging again just adds noise for the developer + return; + } + + const newModules = getModules().set(moduleName, newModule); + resetModuleRegistry(newModules, moduleMap); + console.info('finished reloading %s', moduleName); +} + export default function watchLocalModules() { const staticsDirectoryPath = path.resolve(__dirname, '../../../static'); - const moduleDirectory = path.resolve(staticsDirectoryPath, 'modules'); - const moduleMapPath = path.resolve(staticsDirectoryPath, 'module-map.json'); - const watcher = chokidar.watch(moduleDirectory, { awaitWriteFinish: true }); + const moduleDirectory = path.join(staticsDirectoryPath, 'modules'); - watcher.on('change', async (changedPath) => { - try { - if (!changedPath.endsWith('.node.js')) return; + const checkForNoWrites = new Map(); + let nextWriteCheck = null; + async function writesFinishWatcher() { + nextWriteCheck = null; - const match = changedPath.slice(moduleDirectory.length).match(/\/([^/]+)\/([^/]+)/); - if (!match) return; - const [, moduleNameChangeDetectedIn] = match; + await Promise.allSettled( + [...checkForNoWrites.entries()].map(async ([holocronEntrypoint, previousStat]) => { + const currentStat = await fs.stat(path.join(moduleDirectory, holocronEntrypoint)); + if ( + currentStat.mtimeMs !== previousStat.mtimeMs + || currentStat.size !== previousStat.size + ) { + // need to check again later + checkForNoWrites.set(holocronEntrypoint, currentStat); + return; + } - const moduleMap = JSON.parse(fs.readFileSync(moduleMapPath, 'utf8')); + setImmediate(changeHandler, holocronEntrypoint); + checkForNoWrites.delete(holocronEntrypoint); + }) + ); - const moduleData = moduleMap.modules[moduleNameChangeDetectedIn]; - const oneAppDevCdnAddress = `http://${getIp()}:${process.env.HTTP_ONE_APP_DEV_CDN_PORT || 3001}`; + if (!nextWriteCheck && checkForNoWrites.size > 0) { + nextWriteCheck = setTimeout(writesFinishWatcher, WRITING_FINISH_WATCHER_TIMEOUT).unref(); + } + } - moduleData.browser.url = moduleData.browser.url.replace('[one-app-dev-cdn-url]', oneAppDevCdnAddress); - moduleData.legacyBrowser.url = moduleData.legacyBrowser.url.replace('[one-app-dev-cdn-url]', oneAppDevCdnAddress); - moduleData.node.url = moduleData.node.url.replace('[one-app-dev-cdn-url]', oneAppDevCdnAddress); + let previousStats; + async function changeWatcher() { + const holocronEntrypoints = (await glob('*/*/*.node.js', { cwd: moduleDirectory })) + .filter((p) => { + const parts = p.split('/'); + return parts[0] === path.basename(parts[2], '.node.js'); + }) + .sort(); - const module = addHigherOrderComponent(await loadModule( - moduleNameChangeDetectedIn, - moduleData, - onModuleLoad - )); + const currentStats = new Map(); + const statsToWait = []; + holocronEntrypoints.forEach((holocronEntrypoint) => { + statsToWait.push( + fs.stat(path.join(moduleDirectory, holocronEntrypoint)) + .then((stat) => currentStats.set(holocronEntrypoint, stat)) + ); + }); + await Promise.allSettled(statsToWait); - const modules = getModules().set(moduleNameChangeDetectedIn, module); - resetModuleRegistry(modules, getModuleMap()); - } catch (error) { - console.error(error); + if (!previousStats) { + previousStats = currentStats; + setTimeout(changeWatcher, CHANGE_WATCHER_INTERVAL).unref(); + return; } - }); + + [...currentStats.entries()].forEach(([holocronEntrypoint, currentStat]) => { + if (!previousStats.has(holocronEntrypoint)) { + checkForNoWrites.set(holocronEntrypoint, currentStat); + return; + } + + const previousStat = previousStats.get(holocronEntrypoint); + if (currentStat.mtimeMs !== previousStat.mtimeMs || currentStat.size !== previousStat.size) { + checkForNoWrites.set(holocronEntrypoint, currentStat); + } + }); + + previousStats = currentStats; + setTimeout(changeWatcher, CHANGE_WATCHER_INTERVAL).unref(); + + // wait for writes to the file to stop + if (!nextWriteCheck && checkForNoWrites.size > 0) { + nextWriteCheck = setTimeout(writesFinishWatcher, WRITING_FINISH_WATCHER_TIMEOUT).unref(); + } + } + + setImmediate(changeWatcher); }