Skip to content

Commit

Permalink
chore(babel): add hermes tests for language features (#27900)
Browse files Browse the repository at this point in the history
# Why

- Inspired by #27885
- Even though we have tests which catch a wide range of missing language
features, it would be nice to have the tests fail faster and in
isolation from a native build. Right now, they only run after a full
native build has completed, which could lead to instances where the
native build fails and we think it's fine to land anyways.


<!--
Please describe the motivation for this PR, and link to relevant GitHub
issues, forums posts, or feature requests.
-->

# How

- Add Hermes compilation to the babel jest tests and a number of
language.

<!--
How did you build this feature or fix this bug and why?
-->

# Test Plan

- There's a chance this PR doesn't work in CI.


<!--
Please describe how you tested this change and how a reviewer could
reproduce your test, especially if this PR does not include automated
tests! If possible, please also provide terminal output and/or
screenshots demonstrating your test/reproduction.
-->

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
This is required for changes to Expo modules.
-->

- [ ] Documentation is up to date to reflect these changes (eg:
https://docs.expo.dev and README.md).
- [ ] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).

---------

Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com>
  • Loading branch information
EvanBacon and expo-bot committed Mar 28, 2024
1 parent 6dec133 commit ae0e0ef
Show file tree
Hide file tree
Showing 3 changed files with 383 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/babel-preset-expo/CHANGELOG.md
Expand Up @@ -17,6 +17,7 @@

### 💡 Others

- Add Hermes language support tests. ([#27900](https://github.com/expo/expo/pull/27900) by [@EvanBacon](https://github.com/EvanBacon))
- Remove unused peer dependency on `@babel/preset-env`. ([#27705](https://github.com/expo/expo/pull/27705) by [@EvanBacon](https://github.com/EvanBacon))
- Disable color in snapshot tests in CI. ([#27301](https://github.com/expo/expo/pull/27301) by [@EvanBacon](https://github.com/EvanBacon))
- Add additional tests for undefined platform minification behavior. ([#27515](https://github.com/expo/expo/pull/27515) by [@EvanBacon](https://github.com/EvanBacon))
Expand Down
296 changes: 296 additions & 0 deletions packages/babel-preset-expo/src/__tests__/hermes-bytecode.test.ts
@@ -0,0 +1,296 @@
import * as babel from '@babel/core';

import { compileToHermesBytecodeAsync } from './hermes-util';
import preset from '..';

function getCaller(props: Record<string, string | boolean>): babel.TransformCaller {
return props as unknown as babel.TransformCaller;
}

jest.mock('../common.ts', () => ({
...jest.requireActual('../common.ts'),
hasModule: jest.fn((moduleId) => {
if (['react-native-reanimated', 'expo-router', '@expo/vector-icons'].includes(moduleId)) {
return true;
}
return false;
}),
}));

const SAMPLE_CODE = `
try {
} catch ({ message }) {
}
`;

const LANGUAGE_SAMPLES: {
name: string;
code: string;
getCompiledCode: () => string;
hermesError?: RegExp;
}[] = [
// Unsupported features
{
name: `destructuring in catch statement (ES10)`,
code: SAMPLE_CODE,
getCompiledCode() {
return 'try{}catch(_ref){var message=_ref.message;}';
},
hermesError: /Destructuring in catch parameters is currently unsupported/,
},
{
name: `classes`,
code: `class Test {
constructor(name) {
this.name = name;
}
logger() {
console.log("Hello", this.name);
}
}`,
getCompiledCode() {
return 'var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _classCallCheck2=_interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));var _createClass2=_interopRequireDefault(require("@babel/runtime/helpers/createClass"));var Test=function(){function Test(name){(0,_classCallCheck2.default)(this,Test);this.name=name;}(0,_createClass2.default)(Test,[{key:"logger",value:function logger(){console.log("Hello",this.name);}}]);return Test;}();';
},
hermesError: /invalid statement encountered\./,
},
{
// https://babeljs.io/docs/babel-plugin-transform-async-generator-functions
// Hermes docs say this is supported, but I can't get it to work (March 27, 2024). https://hermesengine.dev/docs/language-features#supported
name: `async-generator-functions`,
code: `async function* agf() {
await 1;
yield 2;
}`,
getCompiledCode() {
return 'var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _awaitAsyncGenerator2=_interopRequireDefault(require("@babel/runtime/helpers/awaitAsyncGenerator"));var _wrapAsyncGenerator2=_interopRequireDefault(require("@babel/runtime/helpers/wrapAsyncGenerator"));function agf(){return _agf.apply(this,arguments);}function _agf(){_agf=(0,_wrapAsyncGenerator2.default)(function*(){yield(0,_awaitAsyncGenerator2.default)(1);yield 2;});return _agf.apply(this,arguments);}';
},
hermesError: /async generators are unsupported/,
},
{
// https://babeljs.io/docs/babel-plugin-transform-private-methods
name: `private-methods`,
code: `class Counter {
#xValue = 0;
}`,
getCompiledCode() {
return 'var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _createClass2=_interopRequireDefault(require("@babel/runtime/helpers/createClass"));var _classCallCheck2=_interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));var _classPrivateFieldLooseKey2=_interopRequireDefault(require("@babel/runtime/helpers/classPrivateFieldLooseKey"));var _xValue=(0,_classPrivateFieldLooseKey2.default)("xValue");var Counter=(0,_createClass2.default)(function Counter(){(0,_classCallCheck2.default)(this,Counter);Object.defineProperty(this,_xValue,{writable:true,value:0});});';
},
hermesError: /private properties are not supported/,
},
{
// https://babeljs.io/docs/babel-plugin-transform-private-property-in-object
name: `private-property-in-object`,
code: `class Foo {
#bar = "bar";
test(obj) {
return #bar in obj;
}
}`,
getCompiledCode() {
return `var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _classCallCheck2=_interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));var _createClass2=_interopRequireDefault(require("@babel/runtime/helpers/createClass"));var _classPrivateFieldLooseKey2=_interopRequireDefault(require("@babel/runtime/helpers/classPrivateFieldLooseKey"));function _checkInRHS(e){if(Object(e)!==e)throw TypeError("right-hand side of 'in' should be an object, got "+(null!==e?typeof e:"null"));return e;}var _bar=(0,_classPrivateFieldLooseKey2.default)("bar");var Foo=function(){function Foo(){(0,_classCallCheck2.default)(this,Foo);Object.defineProperty(this,_bar,{writable:true,value:"bar"});}(0,_createClass2.default)(Foo,[{key:"test",value:function test(obj){return Object.prototype.hasOwnProperty.call(_checkInRHS(obj),_bar);}}]);return Foo;}();`;
},
hermesError: /private properties are not supported/,
},
{
// Sanity check. It appears Hermes can parse JSX optionally.
name: `JSX`,
code: `const value = (<div />)`,
getCompiledCode() {
return `var _jsxRuntime=require("react/jsx-runtime");var value=(0,_jsxRuntime.jsx)("div",{});`;
},
hermesError: /possible JSX: pass -parse-jsx to parse/,
},
{
// https://babeljs.io/docs/babel-plugin-transform-export-namespace-from
// babel-preset-expo adds support for this.
name: `export-namespace-from`,
code: `export * as ns from "mod";`,
getCompiledCode() {
return `Object.defineProperty(exports,"__esModule",{value:true});exports.ns=void 0;var _ns=_interopRequireWildcard(require("mod"));exports.ns=_ns;function _getRequireWildcardCache(e){if("function"!=typeof WeakMap)return null;var r=new WeakMap(),t=new WeakMap();return(_getRequireWildcardCache=function(e){return e?t:r;})(e);}function _interopRequireWildcard(e,r){if(!r&&e&&e.__esModule)return e;if(null===e||"object"!=typeof e&&"function"!=typeof e)return{default:e};var t=_getRequireWildcardCache(r);if(t&&t.has(e))return t.get(e);var n={__proto__:null},a=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var u in e)if("default"!==u&&Object.prototype.hasOwnProperty.call(e,u)){var i=a?Object.getOwnPropertyDescriptor(e,u):null;i&&(i.get||i.set)?Object.defineProperty(n,u,i):n[u]=e[u];}return n.default=e,t&&t.set(e,n),n;}`;
},
hermesError: /error: 'export' statement requires module mode/,
},

// Supported natively
{
name: 'computed-properties',
code: `const obj = {
["x" + foo]: "heh",
["y" + bar]: "noo",
foo: "foo",
bar: "bar"
};`,
getCompiledCode() {
return `var obj={["x"+foo]:"heh",["y"+bar]:"noo",foo:"foo",bar:"bar"};`;
},
},
{
// No babel transform is run on this but we did observe runtime issues with Reflect when adopting bridgeless mode and the new architecture.
// These tests will likely not be capable of replicating the failure but it's a public reference to the issue in case anything changes in the future.
name: 'Reflect',
code: `const obj = Reflect.get({ foo: 'bar' }, 'foo');`,
getCompiledCode() {
return `var obj=Reflect.get({foo:'bar'},'foo');`;
},
},
{
name: 'shorthand-properties',
code: `var a1 = 0;
var c = { a1 };`,
getCompiledCode() {
return `var a1=0;var c={a1};`;
},
},
{
name: 'optional-catch-binding',
code: `try {
throw 0;
} catch {
}`,
getCompiledCode() {
return `try{throw 0;}catch{}`;
},
},
{
name: 'literals',
code: `const d = 0b11; // binary integer literal
const e = 0o7; // octal integer literal
const f = "Hello\\u{000A}\\u{0009}!"; // unicode string literals, newline and tab`,
getCompiledCode() {
return `var d=0b11;var e=0o7;var f="Hello\\u{000A}\\u{0009}!";`;
},
},
{
name: 'sticky-regex',
code: `var regex = /foo+/y;`,
getCompiledCode() {
return `var regex=/foo+/y;`;
},
},
{
name: 'spread',
code: `var h = ["a", "b", "c"];
var i = [...h, "foo"];
var j = foo(...h);`,
getCompiledCode() {
return `var h=["a","b","c"];var i=[...h,"foo"];var j=foo(...h);`;
},
},
{
name: 'object-rest-spread',
code: `var y = {};
var x = 1;
var k = { x, ...y };`,
getCompiledCode() {
return `var y={};var x=1;var k={x,...y};`;
},
},
{
name: 'optional-chaining',
code: `var m = {}?.x;`,
getCompiledCode() {
return `var m={}?.x;`;
},
},
{
name: 'nullish-coalescing-operator',
code: `var obj2 = {};
var foo = obj2.foo ?? "default";`,
getCompiledCode() {
return `var obj2={};var foo=obj2.foo??"default";`;
},
},
{
// https://babeljs.io/docs/babel-plugin-transform-async-to-generator
// Hermes says this isn't supported but it appears to work when compiled.
name: `async/await`,
code: `async function foo() {
await bar();
}`,
getCompiledCode() {
return 'var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _asyncToGenerator2=_interopRequireDefault(require("@babel/runtime/helpers/asyncToGenerator"));function foo(){return _foo.apply(this,arguments);}function _foo(){_foo=(0,_asyncToGenerator2.default)(function*(){yield bar();});return _foo.apply(this,arguments);}';
},
},
{
// https://babeljs.io/docs/babel-plugin-transform-named-capturing-groups-regex
// Hermes says this isn't supported but it appears to work when compiled.
name: `named-capturing-groups-regex`,
code: `var re = /(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})/;
console.log(re.exec("1999-02-29").groups.year);`,
getCompiledCode() {
return 'var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _inherits2=_interopRequireDefault(require("@babel/runtime/helpers/inherits"));var _setPrototypeOf2=_interopRequireDefault(require("@babel/runtime/helpers/setPrototypeOf"));function _wrapRegExp(){_wrapRegExp=function(e,r){return new BabelRegExp(e,void 0,r);};var e=RegExp.prototype,r=new WeakMap();function BabelRegExp(e,t,p){var o=new RegExp(e,t);return r.set(o,p||r.get(e)),(0,_setPrototypeOf2.default)(o,BabelRegExp.prototype);}function buildGroups(e,t){var p=r.get(t);return Object.keys(p).reduce(function(r,t){var o=p[t];if("number"==typeof o)r[t]=e[o];else{for(var i=0;void 0===e[o[i]]&&i+1<o.length;)i++;r[t]=e[o[i]];}return r;},Object.create(null));}return(0,_inherits2.default)(BabelRegExp,RegExp),BabelRegExp.prototype.exec=function(r){var t=e.exec.call(this,r);if(t){t.groups=buildGroups(t,this);var p=t.indices;p&&(p.groups=buildGroups(p,this));}return t;},BabelRegExp.prototype[Symbol.replace]=function(t,p){if("string"==typeof p){var o=r.get(this);return e[Symbol.replace].call(this,t,p.replace(/\\$<([^>]+)>/g,function(e,r){var t=o[r];return"$"+(Array.isArray(t)?t.join("$"):t);}));}if("function"==typeof p){var i=this;return e[Symbol.replace].call(this,t,function(){var e=arguments;return"object"!=typeof e[e.length-1]&&(e=[].slice.call(e)).push(buildGroups(e,i)),p.apply(this,e);});}return e[Symbol.replace].call(this,t,p);},_wrapRegExp.apply(this,arguments);}var re=_wrapRegExp(/(\\d{4})\\x2D(\\d{2})\\x2D(\\d{2})/,{year:1,month:2,day:3});console.log(re.exec("1999-02-29").groups.year);';
},
},

{
// https://babeljs.io/docs/babel-plugin-transform-unicode-regex
// Hermes doesn't claim that this doesn't work but it is in the upstream transform.
name: `unicode-regex`,
code: `var string = "foo💩bar";
var match = string.match(/foo(.)bar/u);`,
getCompiledCode() {
return 'var string="foo💩bar";var match=string.match(/foo((?:[\\0-\\t\\x0B\\f\\x0E-\\u2027\\u202A-\\uD7FF\\uE000-\\uFFFF]|[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF]))bar/);';
},
},
{
// https://babeljs.io/docs/babel-plugin-transform-arrow-functions
// This works with Hermes but we seem to transpile it out anyways, presumably for fast refresh?
// https://github.com/facebook/react-native/blob/b1047d49ff45f0f795c9e336e18deb9e09c34887/packages/react-native-babel-preset/src/configs/main.js#L89
name: `arrow-functions`,
code: `var a = () => {};
var a = b => b;`,
getCompiledCode() {
return `var a=function(){};var a=function(b){return b;};`;
},
},
{
// Requires module mode to be enabled, which we don't currently do in React Native.
name: `export`,
code: `export {}`,
getCompiledCode() {
return 'Object.defineProperty(exports,"__esModule",{value:true});';
},
hermesError: /'export' statement requires module mode/,
},
];

LANGUAGE_SAMPLES.forEach((sample) => {
describe(sample.name, () => {
['ios', 'web'].forEach((platform) => {
it(`babel-preset-expo ensures Hermes compiles (platform: ${platform})`, async () => {
const options = {
babelrc: false,
presets: [preset],
filename: '/unknown',
sourceMaps: true,
caller: getCaller({ name: 'metro', platform, engine: 'hermes' }),
};

const babelResults = babel.transform(sample.code, options)!;
expect(babelResults.code).toEqual(sample.getCompiledCode());

// Will not throw
await compileToHermesBytecodeAsync({ code: babelResults.code! });
});
});

if (sample.hermesError) {
it(`Hermes does not have native support`, async () => {
await expect(compileToHermesBytecodeAsync({ code: sample.code })).rejects.toThrowError(
sample.hermesError
);
});
} else {
it(`Hermes compiles directly`, async () => {
await compileToHermesBytecodeAsync({ code: sample.code });
});
}
});
});
86 changes: 86 additions & 0 deletions packages/babel-preset-expo/src/__tests__/hermes-util.ts
@@ -0,0 +1,86 @@
import spawnAsync from '@expo/spawn-async';
import fs from 'fs';
import fse from 'fs-extra';
import os from 'os';
import path from 'path';
import process from 'process';
import resolveFrom from 'resolve-from';

const debug = require('debug')('expo:metro:hermes') as typeof console.log;

const reactNativeDir = path.join(require.resolve('react-native/package.json'), '..');

function importHermesCommandFromProject(): string {
const platformExecutable = getHermesCommandPlatform();
const hermescLocations = [
// Override hermesc dir by environment variables
process.env['REACT_NATIVE_OVERRIDE_HERMES_DIR']
? path.join(process.env['REACT_NATIVE_OVERRIDE_HERMES_DIR'], 'build/bin/hermesc')
: '',

// Building hermes from source
'react-native/ReactAndroid/hermes-engine/build/hermes/bin/hermesc',

// Prebuilt hermesc in official react-native 0.69+
`react-native/sdks/hermesc/${platformExecutable}`,

// Legacy hermes-engine package
`hermes-engine/${platformExecutable}`,
];

for (const location of hermescLocations) {
try {
return resolveFrom(reactNativeDir, location);
} catch {}
}
throw new Error('Cannot find the hermesc executable.');
}

function getHermesCommandPlatform(): string {
switch (os.platform()) {
case 'darwin':
return 'osx-bin/hermesc';
case 'linux':
return 'linux64-bin/hermesc';
case 'win32':
return 'win64-bin/hermesc.exe';
default:
throw new Error(`Unsupported host platform for Hermes compiler: ${os.platform()}`);
}
}

type BuildHermesOptions = {
code: string;
minify?: boolean;
};

export async function compileToHermesBytecodeAsync({
code,
minify = false,
}: BuildHermesOptions): Promise<Buffer> {
const tempDir = path.join(os.tmpdir(), `expo-babel-test-${Math.random()}-${Date.now()}`);
await fs.promises.mkdir(tempDir, { recursive: true });
try {
const tempBundleFile = path.join(tempDir, 'index.js');
await fs.promises.writeFile(tempBundleFile, code);

const tempHbcFile = path.join(tempDir, 'index.hbc');
const hermesCommand = importHermesCommandFromProject();
const args = ['-emit-binary', '-strict', '-out', tempHbcFile, tempBundleFile];
if (minify) {
args.push('-O');
}

debug(`Running hermesc: ${hermesCommand} ${args.join(' ')}`);
await spawnAsync(hermesCommand, args);

return await fs.promises.readFile(tempHbcFile);
} catch (error: any) {
if ('status' in error) {
throw new Error(error.output.join('\n'));
}
throw error;
} finally {
await fse.remove(tempDir);
}
}

0 comments on commit ae0e0ef

Please sign in to comment.