diff --git a/CHANGELOG.md b/CHANGELOG.md index 497e1145a06d..b1e78df194b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ - `[jest-resolve]` Add global paths to `require.resolve.paths` ([#13633](https://github.com/facebook/jest/pull/13633)) - `[jest-runtime]` Support WASM files that import JS resources ([#13608](https://github.com/facebook/jest/pull/13608)) - `[jest-runtime]` Use the `scriptTransformer` cache in `jest-runner` ([#13735](https://github.com/facebook/jest/pull/13735)) -- `[jest-runtime]` Enforce import assertions when importing JSON in ESM ([#12755](https://github.com/facebook/jest/pull/12755)) +- `[jest-runtime]` Enforce import assertions when importing JSON in ESM ([#12755](https://github.com/facebook/jest/pull/12755) & [#13805](https://github.com/facebook/jest/pull/13805)) - `[jest-snapshot]` Make sure to import `babel` outside of the sandbox ([#13694](https://github.com/facebook/jest/pull/13694)) - `[jest-transform]` Ensure the correct configuration is passed to preprocessors specified multiple times in the `transform` option ([#13770](https://github.com/facebook/jest/pull/13770)) diff --git a/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js b/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js new file mode 100644 index 000000000000..40118befbce2 --- /dev/null +++ b/packages/jest-runtime/src/__tests__/runtime_import_assertions.test.js @@ -0,0 +1,78 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {pathToFileURL} from 'url'; +import {onNodeVersions} from '@jest/test-utils'; + +let runtime; + +// version where `vm` API gets `import assertions` +onNodeVersions('>=16.12.0', () => { + beforeAll(async () => { + const createRuntime = require('createRuntime'); + + runtime = await createRuntime(__filename); + }); + + describe('import assertions', () => { + const fileUrl = pathToFileURL(__filename).href; + const jsonFileName = `${__filename}on`; + const jsonFileUrl = pathToFileURL(jsonFileName).href; + + it('works if passed correct import assertion', () => { + expect(() => + runtime.validateImportAssertions(jsonFileName, '', {type: 'json'}), + ).not.toThrow(); + }); + + it('does nothing if no assertions passed for js file', () => { + expect(() => + runtime.validateImportAssertions(__filename, '', undefined), + ).not.toThrow(); + expect(() => + runtime.validateImportAssertions(__filename, '', {}), + ).not.toThrow(); + }); + + it('throws if invalid assertions are passed', () => { + expect(() => + runtime.validateImportAssertions(jsonFileName, '', {type: null}), + ).toThrow('Import assertion value must be a string'); + expect(() => + runtime.validateImportAssertions(jsonFileName, '', {type: 42}), + ).toThrow('Import assertion value must be a string'); + expect(() => + runtime.validateImportAssertions(jsonFileName, '', { + type: 'javascript', + }), + ).toThrow('Import assertion type "javascript" is unsupported'); + }); + + it('throws if missing json assertions', () => { + const errorMessage = `Module "${jsonFileUrl}" needs an import assertion of type "json"`; + + expect(() => + runtime.validateImportAssertions(jsonFileName, '', {}), + ).toThrow(errorMessage); + expect(() => + runtime.validateImportAssertions(jsonFileName, '', { + somethingElse: 'json', + }), + ).toThrow(errorMessage); + expect(() => runtime.validateImportAssertions(jsonFileName, '')).toThrow( + errorMessage, + ); + }); + + it('throws if json assertion passed on wrong file', () => { + expect(() => + runtime.validateImportAssertions(__filename, '', {type: 'json'}), + ).toThrow(`Module "${fileUrl}" is not of type "json"`); + }); + }); +}); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 237040006c05..5b57dc3546ca 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -159,6 +159,23 @@ const supportsNodeColonModulePrefixInRequire = (() => { } })(); +const kImplicitAssertType = Symbol('kImplicitAssertType'); + +// copied from https://github.com/nodejs/node/blob/7dd458382580f68cf7d718d96c8f4d2d3fe8b9db/lib/internal/modules/esm/assert.js#L20-L32 +const formatTypeMap: {[type: string]: string | typeof kImplicitAssertType} = { + // @ts-expect-error - copied + __proto__: null, + builtin: kImplicitAssertType, + commonjs: kImplicitAssertType, + json: 'json', + module: kImplicitAssertType, + wasm: kImplicitAssertType, +}; + +const supportedAssertionTypes = new Set( + Object.values(formatTypeMap).filter(type => type !== kImplicitAssertType), +); + export default class Runtime { private readonly _cacheFS: Map; private readonly _cacheFSBuffer = new Map(); @@ -418,21 +435,10 @@ export default class Runtime { private async loadEsmModule( modulePath: string, query = '', - importAssertions: ImportAssertions = {}, + importAssertions?: ImportAssertions, ): Promise { - if ( - runtimeSupportsImportAssertions && - modulePath.endsWith('.json') && - importAssertions.type !== 'json' - ) { - const error: NodeJS.ErrnoException = new Error( - `Module "${ - modulePath + (query ? `?${query}` : '') - }" needs an import assertion of type "json"`, - ); - error.code = 'ERR_IMPORT_ASSERTION_TYPE_MISSING'; - - throw error; + if (runtimeSupportsImportAssertions) { + this.validateImportAssertions(modulePath, query, importAssertions); } const cacheKey = modulePath + query; @@ -572,6 +578,83 @@ export default class Runtime { return module; } + private validateImportAssertions( + modulePath: string, + query: string, + importAssertions: ImportAssertions = { + // @ts-expect-error - copy https://github.com/nodejs/node/blob/7dd458382580f68cf7d718d96c8f4d2d3fe8b9db/lib/internal/modules/esm/assert.js#LL55C50-L55C65 + __proto__: null, + }, + ) { + const format = this.getModuleFormat(modulePath); + const validType = formatTypeMap[format]; + const url = pathToFileURL(modulePath); + + if (query) { + url.search = query; + } + + const urlString = url.href; + + const assertionType = importAssertions.type; + + switch (validType) { + case undefined: + // Ignore assertions for module formats we don't recognize, to allow new + // formats in the future. + return; + + case kImplicitAssertType: + // This format doesn't allow an import assertion type, so the property + // must not be set on the import assertions object. + if (Object.prototype.hasOwnProperty.call(importAssertions, 'type')) { + handleInvalidAssertionType(urlString, assertionType); + } + return; + + case assertionType: + // The asserted type is the valid type for this format. + return; + + default: + // There is an expected type for this format, but the value of + // `importAssertions.type` might not have been it. + if (!Object.prototype.hasOwnProperty.call(importAssertions, 'type')) { + // `type` wasn't specified at all. + const error: NodeJS.ErrnoException = new Error( + `Module "${urlString}" needs an import assertion of type "json"`, + ); + error.code = 'ERR_IMPORT_ASSERTION_TYPE_MISSING'; + + throw error; + } + handleInvalidAssertionType(urlString, assertionType); + } + } + + private getModuleFormat(modulePath: string) { + if (this._resolver.isCoreModule(modulePath)) { + return 'builtin'; + } + + if (isWasm(modulePath)) { + return 'wasm'; + } + + const fileExtension = path.extname(modulePath); + + if (fileExtension === '.json') { + return 'json'; + } + + if (this.unstable_shouldLoadAsEsm(modulePath)) { + return 'module'; + } + + // any unknown format should be treated as JS + return 'commonjs'; + } + private async resolveModule( specifier: string, referencingIdentifier: string, @@ -2513,3 +2596,29 @@ async function evaluateSyntheticModule(module: SyntheticModule) { return module; } + +function handleInvalidAssertionType(url: string, type: unknown) { + if (typeof type !== 'string') { + throw new TypeError('Import assertion value must be a string'); + } + + // `type` might not have been one of the types we understand. + if (!supportedAssertionTypes.has(type)) { + const error: NodeJS.ErrnoException = new Error( + `Import assertion type "${type}" is unsupported`, + ); + + error.code = 'ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED'; + + throw error; + } + + // `type` was the wrong value for this format. + const error: NodeJS.ErrnoException = new Error( + `Module "${url}" is not of type "${type}"`, + ); + + error.code = 'ERR_IMPORT_ASSERTION_TYPE_FAILED'; + + throw error; +}