Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 34.3k
esm: refactor mocking test#49465
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
nodejs-github-bot merged 6 commits into nodejs:main from GeoffreyBooth:refactor-mocking-testSep 5, 2023
Uh oh!
There was an error while loading. Please reload this page.
Merged
esm: refactor mocking test #49465
Changes from all commits
Commits
Show all changes
6 commits Select commit Hold shift + click to select a range
3e846c4 esm: refactor mocking test
GeoffreyBooth a12d520 Move setup code into fixture; avoid global; add JSON.stringify, __pro…
GeoffreyBooth 844fe45 Review notes
GeoffreyBooth d1bedb7 do not mutate `import.meta`, trailing commas, add `doDrainPort` back
aduh95 34cc279 Update test/fixtures/es-module-loaders/mock-loader.mjs
aduh95 7a58719 Update test/fixtures/es-module-loaders/mock-loader.mjs
aduh95 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Jump to file
Failed to load files.
Loading
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -2,7 +2,7 @@ import{receiveMessageOnPort } from 'node:worker_threads' | ||
| const mockedModuleExports = new Map(); | ||
| let currentMockVersion = 0; | ||
| // This loader causes a new module `node:mock` to become available as a way to | ||
| // These hooks enable code running on the application thread to | ||
| // swap module resolution results for mocking purposes. It uses this instead | ||
| // of import.meta so that CommonJS can still use the functionality. | ||
| // | ||
| @@ -22,7 +22,7 @@ let currentMockVersion = 0; | ||
| // it cannot be changed. So things like the following DO NOT WORK: | ||
| // | ||
| // ```mjs | ||
| // import mock from 'node:mock' | ||
| // import mock from 'test-esm-loader-mock' // See test-esm-loader-mock.mjs | ||
| // mock('file:///app.js',{x:1}); | ||
| // const namespace1 = await import('file:///app.js'); | ||
| // namespace1.x; // 1 | ||
| @@ -34,17 +34,6 @@ let currentMockVersion = 0; | ||
| // assert(namespace1 === namespace2); | ||
| // ``` | ||
| /** | ||
| * FIXME: this is a hack to workaround loaders being | ||
| * single threaded for now, just ensures that the MessagePort drains | ||
| */ | ||
| function doDrainPort(){ | ||
| let msg; | ||
| while (msg = receiveMessageOnPort(preloadPort)){ | ||
| onPreloadPortMessage(msg.message); | ||
| } | ||
| } | ||
| /** | ||
| * @param param0 message from the application context | ||
| */ | ||
| @@ -54,127 +43,31 @@ function onPreloadPortMessage({ | ||
| currentMockVersion = mockVersion; | ||
| mockedModuleExports.set(resolved, exports); | ||
| } | ||
| let preloadPort; | ||
| export function globalPreload({port}){ | ||
| // Save the communication port to the application context to send messages | ||
| // to it later | ||
| preloadPort = port; | ||
| // Every time the application context sends a message over the port | ||
| port.on('message', onPreloadPortMessage); | ||
| // This prevents the port that the Loader/application talk over | ||
| // from keeping the process alive, without this, an application would be kept | ||
| // alive just because a loader is waiting for messages | ||
| port.unref(); | ||
| const insideAppContext = (getBuiltin, port, setImportMetaCallback) =>{ | ||
| /** | ||
| * This is the Map that saves *all* the mocked URL -> replacement Module | ||
| * mappings | ||
| * @type{Map<string,{namespace, listeners}>} | ||
| */ | ||
| let mockedModules = new Map(); | ||
| let mockVersion = 0; | ||
| /** | ||
| * This is the value that is placed into the `node:mock` default export | ||
| * | ||
| * @example | ||
| * ```mjs | ||
| * import mock from 'node:mock' | ||
| * const mutator = mock('file:///app.js',{x:1}); | ||
| * const namespace = await import('file:///app.js'); | ||
| * namespace.x; // 1; | ||
| * mutator.x = 2; | ||
| * namespace.x; // 2; | ||
| * ``` | ||
| * | ||
| * @param{string} resolved an absolute URL HREF string | ||
| * @param{object} replacementProperties an object to pick properties from | ||
| * to act as a module namespace | ||
| * @returns{object} a mutator object that can update the module namespace | ||
| * since we can't do something like old Object.observe | ||
| */ | ||
| const doMock = (resolved, replacementProperties) =>{ | ||
| let exportNames = Object.keys(replacementProperties); | ||
| let namespace = Object.create(null); | ||
| /** | ||
| * @type{Array<(name: string)=>void>} functions to call whenever an | ||
| * export name is updated | ||
| */ | ||
| let listeners = []; | ||
| for (const name of exportNames){ | ||
| let currentValueForPropertyName = replacementProperties[name]; | ||
| Object.defineProperty(namespace, name,{ | ||
| enumerable: true, | ||
| get(){ | ||
| return currentValueForPropertyName; | ||
| }, | ||
| set(v){ | ||
| currentValueForPropertyName = v; | ||
| for (let fn of listeners){ | ||
| try{ | ||
| fn(name); | ||
| } catch{ | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| mockedModules.set(resolved,{ | ||
| namespace, | ||
| listeners | ||
| }); | ||
| mockVersion++; | ||
| // Inform the loader that the `resolved` URL should now use the specific | ||
| // `mockVersion` and has export names of `exportNames` | ||
| // | ||
| // This allows the loader to generate a fake module for that version | ||
| // and names the next time it resolves a specifier to equal `resolved` | ||
| port.postMessage({mockVersion, resolved, exports: exportNames }); | ||
| return namespace; | ||
| } | ||
| // Sets the import.meta properties up | ||
| // has the normal chaining workflow with `defaultImportMetaInitializer` | ||
| setImportMetaCallback((meta, context, defaultImportMetaInitializer) =>{ | ||
| /** | ||
| * 'node:mock' creates its default export by plucking off of import.meta | ||
| * and must do so in order to get the communications channel from inside | ||
| * preloadCode | ||
| */ | ||
| if (context.url === 'node:mock'){ | ||
| meta.doMock = doMock; | ||
| return; | ||
| } | ||
| /** | ||
| * Fake modules created by `node:mock` get their meta.mock utility set | ||
| * to the corresponding value keyed off `mockedModules` and use this | ||
| * to setup their exports/listeners properly | ||
| */ | ||
| if (context.url.startsWith('mock-facade:')){ | ||
| let [proto, version, encodedTargetURL] = context.url.split(':'); | ||
| let decodedTargetURL = decodeURIComponent(encodedTargetURL); | ||
| if (mockedModules.has(decodedTargetURL)){ | ||
| meta.mock = mockedModules.get(decodedTargetURL); | ||
| return; | ||
| } | ||
| } | ||
| /** | ||
| * Ensure we still get things like `import.meta.url` | ||
| */ | ||
| defaultImportMetaInitializer(meta, context); | ||
| }); | ||
| }; | ||
| return `(${insideAppContext})(getBuiltin, port, setImportMetaCallback)` | ||
| /** @type{URL['href']} */ | ||
| let mainImportURL; | ||
| /** @type{MessagePort} */ | ||
| let preloadPort; | ||
| export async function initialize(data){ | ||
| ({mainImportURL, port: preloadPort } = data); | ||
| data.port.on('message', onPreloadPortMessage); | ||
| } | ||
| /** | ||
| * Because Node.js internals use a separate MessagePort for cross-thread | ||
| * communication, there could be some messages pending that we should handle | ||
| * before continuing. | ||
| */ | ||
| function doDrainPort(){ | ||
| let msg; | ||
| while (msg = receiveMessageOnPort(preloadPort)){ | ||
| onPreloadPortMessage(msg.message); | ||
| } | ||
| } | ||
| // Rewrites node: loading to mock-facade: so that it can be intercepted | ||
| export async function resolve(specifier, context, defaultResolve){ | ||
| if (specifier === 'node:mock'){ | ||
| return{ | ||
| shortCircuit: true, | ||
| url: specifier | ||
| }; | ||
| } | ||
| doDrainPort(); | ||
| const def = await defaultResolve(specifier, context); | ||
| if (context.parentURL?.startsWith('mock-facade:')){ | ||
| @@ -193,55 +86,46 @@ export async function resolve(specifier, context, defaultResolve){ | ||
| export async function load(url, context, defaultLoad){ | ||
| doDrainPort(); | ||
aduh95 marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading. Please reload this page. | ||
| if (url === 'node:mock'){ | ||
| /** | ||
| * Simply grab the import.meta.doMock to establish the communication | ||
| * channel with preloadCode | ||
| */ | ||
| return{ | ||
| shortCircuit: true, | ||
| source: 'export default import.meta.doMock', | ||
| format: 'module' | ||
| }; | ||
| } | ||
| /** | ||
| * Mocked fake module, not going to be handled in default way so it | ||
| * generates the source text, then short circuits | ||
| */ | ||
| if (url.startsWith('mock-facade:')){ | ||
| let [proto, version, encodedTargetURL] = url.split(':'); | ||
| let ret = generateModule(mockedModuleExports.get( | ||
| decodeURIComponent(encodedTargetURL) | ||
| )); | ||
| const encodedTargetURL = url.slice(url.lastIndexOf(':') + 1); | ||
| return{ | ||
| shortCircuit: true, | ||
| source: ret, | ||
| format: 'module' | ||
| source: generateModule(encodedTargetURL), | ||
| format: 'module', | ||
| }; | ||
| } | ||
| return defaultLoad(url, context); | ||
| } | ||
| /** | ||
| * | ||
| * @param{Array<string>} exports name of the exports of the module | ||
| * Generate the source code for a mocked module. | ||
| * @param{string} encodedTargetURL the module being mocked | ||
| * @returns{string} | ||
| */ | ||
| function generateModule(exports){ | ||
| function generateModule(encodedTargetURL){ | ||
| const exports = mockedModuleExports.get( | ||
| decodeURIComponent(encodedTargetURL) | ||
| ); | ||
| let body = [ | ||
| `import{mockedModules } from ${JSON.stringify(mainImportURL)};`, | ||
| 'export{};', | ||
| 'let mapping ={__proto__: null};' | ||
| 'let mapping ={__proto__: null};', | ||
| `const mock = mockedModules.get(${JSON.stringify(encodedTargetURL)});`, | ||
| ]; | ||
| for (const [i, name] of Object.entries(exports)){ | ||
| let key = JSON.stringify(name); | ||
| body.push(`var _${i} = import.meta.mock.namespace[${key}];`); | ||
| body.push(`var _${i} = mock.namespace[${key}];`); | ||
| body.push(`Object.defineProperty(mapping, ${key},{enumerable: true, set(v){_${i} = v}, get(){return _${i}} });`); | ||
| body.push(`export{_${i} as ${name}};`); | ||
| } | ||
| body.push(`import.meta.mock.listeners.push(${ | ||
| body.push(`mock.listeners.push(${ | ||
| () =>{ | ||
| for (var k in mapping){ | ||
| mapping[k] = import.meta.mock.namespace[k]; | ||
| mapping[k] = mock.namespace[k]; | ||
| } | ||
| } | ||
| });`); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import{register } from 'node:module' | ||
| import{MessageChannel } from 'node:worker_threads' | ||
| const{port1, port2 } = new MessageChannel(); | ||
| register('./mock-loader.mjs', import.meta.url,{ | ||
| data:{ | ||
| port: port2, | ||
| mainImportURL: import.meta.url, | ||
| }, | ||
| transferList: [port2], | ||
| }); | ||
| /** | ||
| * This is the Map that saves *all* the mocked URL -> replacement Module | ||
| * mappings | ||
| * @type{Map<string,{namespace, listeners}>} | ||
| */ | ||
| export const mockedModules = new Map(); | ||
| let mockVersion = 0; | ||
| /** | ||
| * @param{string} resolved an absolute URL HREF string | ||
| * @param{object} replacementProperties an object to pick properties from | ||
| * to act as a module namespace | ||
| * @returns{object} a mutator object that can update the module namespace | ||
| * since we can't do something like old Object.observe | ||
| */ | ||
| export function mock(resolved, replacementProperties){ | ||
| const exportNames = Object.keys(replacementProperties); | ||
| const namespace ={__proto__: null }; | ||
| /** | ||
| * @type{Array<(name: string)=>void>} functions to call whenever an | ||
| * export name is updated | ||
| */ | ||
| const listeners = []; | ||
| for (const name of exportNames){ | ||
| let currentValueForPropertyName = replacementProperties[name]; | ||
| Object.defineProperty(namespace, name,{ | ||
| __proto__: null, | ||
| enumerable: true, | ||
| get(){ | ||
| return currentValueForPropertyName; | ||
| }, | ||
| set(v){ | ||
| currentValueForPropertyName = v; | ||
| for (const fn of listeners){ | ||
| try{ | ||
| fn(name); | ||
| } catch{ | ||
| /* noop */ | ||
| } | ||
| } | ||
| }, | ||
| }); | ||
| } | ||
| mockedModules.set(encodeURIComponent(resolved),{ | ||
| namespace, | ||
| listeners, | ||
| }); | ||
| mockVersion++; | ||
| // Inform the loader that the `resolved` URL should now use the specific | ||
| // `mockVersion` and has export names of `exportNames` | ||
| // | ||
| // This allows the loader to generate a fake module for that version | ||
| // and names the next time it resolves a specifier to equal `resolved` | ||
| port1.postMessage({mockVersion, resolved, exports: exportNames }); | ||
| return namespace; | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.