Skip to content
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

feat: ESM data uri import and mock #12392

Merged
merged 12 commits into from Feb 16, 2022
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -10,6 +10,7 @@
- `[jest-environment-node]` [**BREAKING**] Add default `node` and `node-addon` conditions to `exportConditions` for `node` environment ([#11924](https://github.com/facebook/jest/pull/11924))
- `[@jest/expect-utils]` New module exporting utils for `expect` ([#12323](https://github.com/facebook/jest/pull/12323))
- `[jest-resolver]` [**BREAKING**] Add support for `package.json` `exports` ([11961](https://github.com/facebook/jest/pull/11961))
- `[jest-resolve, jest-runtime]` Add support for `data:` URI import and mock ([#12392](https://github.com/facebook/jest/pull/12392))
- `[@jes/schemas]` New module for JSON schemas for Jest's config ([#12384](https://github.com/facebook/jest/pull/12384))
- `[jest-worker]` [**BREAKING**] Allow only absolute `workerPath` ([#12343](https://github.com/facebook/jest/pull/12343))

Expand Down
4 changes: 2 additions & 2 deletions e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap
Expand Up @@ -41,7 +41,7 @@ exports[`moduleNameMapper wrong array configuration 1`] = `
12 | module.exports = () => 'test';
13 |

at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:568:17)
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:572:17)
at Object.require (index.js:10:1)"
`;

Expand Down Expand Up @@ -70,6 +70,6 @@ exports[`moduleNameMapper wrong configuration 1`] = `
12 | module.exports = () => 'test';
13 |

at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:568:17)
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:572:17)
at Object.require (index.js:10:1)"
`;
2 changes: 1 addition & 1 deletion e2e/__tests__/__snapshots__/nativeEsm.test.ts.snap
Expand Up @@ -2,7 +2,7 @@

exports[`on node >=12.16.0 runs test with native ESM 1`] = `
"Test Suites: 1 passed, 1 total
Tests: 21 passed, 21 total
Tests: 32 passed, 32 total
Snapshots: 0 total
Time: <<REPLACED>>
Ran all test suites matching /native-esm.test.js/i."
Expand Down
95 changes: 95 additions & 0 deletions e2e/native-esm/__tests__/native-esm.test.js
Expand Up @@ -195,3 +195,98 @@ test('can mock module', async () => {
test('supports imports using "node:" prefix', () => {
expect(dns).toBe(prefixDns);
});

test('supports imports from "data:text/javascript" URI with charset=utf-8 encoding', async () => {
const code = 'export const something = "some value"';
const importedEncoded = await import(
`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`
);
expect(importedEncoded.something).toBe('some value');
});

test('supports imports from "data:text/javascript" URI with base64 encoding', async () => {
const code = 'export const something = "some value"';
const importedBase64 = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
expect(importedBase64.something).toBe('some value');
});

test('supports imports from "data:text/javascript" URI without explicit encoding', async () => {
const code = 'export const something = "some value"';
const importedEncoded = await import(
`data:text/javascript,${encodeURIComponent(code)}`
);
expect(importedEncoded.something).toBe('some value');
});

test('imports from "data:text/javascript" URI with invalid encoding fail', async () => {
const code = 'export const something = "some value"';
await expect(
async () =>
await import(
`data:text/javascript;charset=badEncoding,${encodeURIComponent(code)}`
),
).rejects.toThrow('Invalid data URI');
});

test('imports from "data:" URI with invalid mime type fail', async () => {
const code = 'export const something = "some value"';
await expect(
async () => await import(`data:something/else,${encodeURIComponent(code)}`),
).rejects.toThrow('Invalid data URI');
});

test('imports from "data:text/javascript" URI with invalid data fail', async () => {
await expect(
async () =>
await import('data:text/javascript;charset=utf-8,so(me)+.-gibberish'),
).rejects.toThrow("Unexpected token '.'");
});

test('imports from "data:application/wasm" URI not supported', async () => {
await expect(
async () => await import('data:application/wasm,96cafe00babe'),
).rejects.toThrow('WASM is currently not supported');
});

test('supports imports from "data:application/json" URI', async () => {
const data = await import('data:application/json,{"foo": "bar"}');
expect(data.default).toEqual({foo: 'bar'});
});

test('supports static "data:" URI import', async () => {
const module = await import('../staticDataImport.js');
expect(module.value()).toEqual({bar: {obj: 456}, foo: '123'});
});

test('imports from "data:" URI is properly cached', async () => {
const code =
'export const wrapper = {value: 123}\nexport const set = (value) => wrapper.value = value';
const data1 = await import(
`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`
);
expect(data1.wrapper.value).toBe(123);
data1.set(234);
expect(data1.wrapper.value).toBe(234);
const data2 = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
expect(data2.wrapper.value).toBe(123);
const data3 = await import(
`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`
);
expect(data3.wrapper.value).toBe(234);
});

test('can mock "data:" URI module', async () => {
const code = 'export const something = "some value"';
const dataModule = `data:text/javascript;base64,${Buffer.from(code).toString(
'base64',
)}`;
jestObject.unstable_mockModule(dataModule, () => ({foo: 'bar'}), {
virtual: true,
});
const mocked = await import(dataModule);
expect(mocked.foo).toBe('bar');
});
13 changes: 13 additions & 0 deletions e2e/native-esm/staticDataImport.js
@@ -0,0 +1,13 @@
/**
SimenB marked this conversation as resolved.
Show resolved Hide resolved
* 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 bar from 'data:application/json,{"obj": 456}';
import {foo} from 'data:text/javascript,export const foo = "123"';

export function value() {
return {bar, foo};
}
3 changes: 3 additions & 0 deletions packages/jest-resolve/src/resolver.ts
Expand Up @@ -370,6 +370,9 @@ export default class Resolver {
if (this.isCoreModule(moduleName)) {
return moduleName;
}
if (moduleName.startsWith('data:')) {
return moduleName;
}
return this._isModuleResolved(from, moduleName)
? this.getModule(moduleName)
: this._getVirtualMockPath(virtualMocks, from, moduleName, options);
Expand Down
83 changes: 83 additions & 0 deletions packages/jest-runtime/src/index.ts
Expand Up @@ -63,6 +63,9 @@ export type {Context} from './types';

const esmIsAvailable = typeof SourceTextModule === 'function';

const dataURIregex =
/^data:(?<mime>text\/javascript|application\/json|application\/wasm)(?:;(?<encoding>charset=utf-8|base64))?,(?<code>.*)$/;

interface JestGlobals extends Global.TestFrameworkGlobals {
expect: typeof JestGlobals.expect;
}
Expand Down Expand Up @@ -553,6 +556,86 @@ export default class Runtime {
return globals;
}

if (specifier.startsWith('data:')) {
if (
this._shouldMock(
referencingIdentifier,
specifier,
this._explicitShouldMockModule,
{conditions: this.esmConditions},
)
) {
return this.importMock(referencingIdentifier, specifier, context);
}

const fromCache = this._esmoduleRegistry.get(specifier);

if (fromCache) {
return fromCache;
}

const match = specifier.match(dataURIregex);

if (!match || !match.groups) {
throw new Error('Invalid data URI');
}

const mime = match.groups.mime;
if (mime === 'application/wasm') {
throw new Error('WASM is currently not supported');
}

const encoding = match.groups.encoding;
let code = match.groups.code;
if (!encoding || encoding === 'charset=utf-8') {
code = decodeURIComponent(code);
} else if (encoding === 'base64') {
code = Buffer.from(code, 'base64').toString();
} else {
throw new Error(`Invalid data URI encoding: ${encoding}`);
}

let module;
if (mime === 'application/json') {
module = new SyntheticModule(
['default'],
function () {
const obj = JSON.parse(code);
// @ts-expect-error: TS doesn't know what `this` is
this.setExport('default', obj);
},
{context, identifier: specifier},
);
} else {
module = new SourceTextModule(code, {
context,
identifier: specifier,
importModuleDynamically: async (
specifier: string,
referencingModule: VMModule,
) => {
invariant(
runtimeSupportsVmModules,
'You need to run with a version of node that supports ES Modules in the VM API. See https://jestjs.io/docs/ecmascript-modules',
);
const module = await this.resolveModule(
specifier,
referencingModule.identifier,
referencingModule.context,
);

return this.linkAndEvaluateModule(module);
},
initializeImportMeta(meta: ImportMeta) {
meta.url = specifier;
},
});
}

this._esmoduleRegistry.set(specifier, module);
return module;
}

if (specifier.startsWith('file://')) {
specifier = fileURLToPath(specifier);
}
Expand Down