diff --git a/incubator/polyfills/package.json b/incubator/polyfills/package.json index 1cf82a275..ac2ff107d 100644 --- a/incubator/polyfills/package.json +++ b/incubator/polyfills/package.json @@ -25,7 +25,8 @@ "scripts": { "build": "rnx-kit-scripts build", "format": "rnx-kit-scripts format", - "lint": "rnx-kit-scripts lint" + "lint": "rnx-kit-scripts lint", + "test": "rnx-kit-scripts test" }, "dependencies": { "@babel/core": "^7.0.0", diff --git a/incubator/polyfills/src/dependency.ts b/incubator/polyfills/src/dependency.ts index d99f110ec..322f2f0fa 100644 --- a/incubator/polyfills/src/dependency.ts +++ b/incubator/polyfills/src/dependency.ts @@ -1,5 +1,6 @@ import { error } from "@rnx-kit/console"; import { readPackage } from "@rnx-kit/tools-node"; +import * as fs from "fs"; import * as path from "path"; import type { Context } from "./types"; @@ -17,13 +18,13 @@ function getDependencies({ projectRoot }: Context): string[] { return Array.from(dependencies); } -function isValidPath(p: string): boolean { - return ( - Boolean(p) && - !p.startsWith("..") && - !p.startsWith("/") && - !/^[A-Za-z]:/.test(p) - ); +export function resolvePath(fromDir: string, p: unknown): string | null { + if (typeof p !== "string" || !p) { + return null; + } + + const resolved = path.resolve(fromDir, p); + return resolved.startsWith(fromDir) ? resolved : null; } export function getDependencyPolyfills(context: Context): string[] { @@ -36,14 +37,17 @@ export function getDependencyPolyfills(context: Context): string[] { try { const config = require.resolve(`${name}/react-native.config.js`, options); const polyfill = require(config).dependency?.api?.polyfill; - if (typeof polyfill === "string") { - if (!isValidPath(polyfill)) { - error(`${name}: invalid polyfill path: ${polyfill}`); - continue; - } - - polyfills.push(path.resolve(path.dirname(config), polyfill)); + const absolutePath = resolvePath(path.dirname(config), polyfill); + if (!absolutePath) { + error(`${name}: invalid polyfill path: ${polyfill}`); + continue; } + if (!fs.existsSync(absolutePath)) { + error(`${name}: no such polyfill: ${polyfill}`); + continue; + } + + polyfills.push(absolutePath); } catch (_) { // ignore } diff --git a/incubator/polyfills/src/index.ts b/incubator/polyfills/src/index.ts index 395e8db1f..dee7ca15b 100644 --- a/incubator/polyfills/src/index.ts +++ b/incubator/polyfills/src/index.ts @@ -23,7 +23,9 @@ module.exports = declare((api: ConfigAPI) => { const polyfills = getDependencyPolyfills({ projectRoot: context.cwd }); const importPolyfill = babelTemplate(`import %%source%%;`); - for (const polyfill of polyfills) { + // Add polyfills in reverse order because we're unshifting + for (let i = polyfills.length - 1; i >= 0; --i) { + const polyfill = polyfills[i]; path.unshiftContainer( "body", importPolyfill({ source: t.stringLiteral(polyfill) }) diff --git a/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/battery-status/package.json b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/battery-status/package.json new file mode 100644 index 000000000..1c7f7e80c --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/battery-status/package.json @@ -0,0 +1,4 @@ +{ + "name": "@react-native-webapis/battery-status", + "version": "1.0.0" +} diff --git a/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/battery-status/polyfill.js b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/battery-status/polyfill.js new file mode 100644 index 000000000..e69de29bb diff --git a/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/battery-status/react-native.config.js b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/battery-status/react-native.config.js new file mode 100644 index 000000000..e3a86a9f4 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/battery-status/react-native.config.js @@ -0,0 +1,7 @@ +module.exports = { + dependency: { + api: { + polyfill: "polyfill.js", + }, + }, +}; diff --git a/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/gamepad/package.json b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/gamepad/package.json new file mode 100644 index 000000000..9421abe91 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/gamepad/package.json @@ -0,0 +1,4 @@ +{ + "name": "@react-native-webapis/gamepad", + "version": "1.0.0" +} diff --git a/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/gamepad/polyfill.js b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/gamepad/polyfill.js new file mode 100644 index 000000000..e69de29bb diff --git a/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/gamepad/react-native.config.js b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/gamepad/react-native.config.js new file mode 100644 index 000000000..e3a86a9f4 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/@react-native-webapis/gamepad/react-native.config.js @@ -0,0 +1,7 @@ +module.exports = { + dependency: { + api: { + polyfill: "polyfill.js", + }, + }, +}; diff --git a/incubator/polyfills/test/__fixtures__/node_modules/excluded-package/package.json b/incubator/polyfills/test/__fixtures__/node_modules/excluded-package/package.json new file mode 100644 index 000000000..d87206dc4 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/excluded-package/package.json @@ -0,0 +1,4 @@ +{ + "name": "excluded-package", + "version": "1.0.0" +} diff --git a/incubator/polyfills/test/__fixtures__/node_modules/excluded-package/react-native.config.js b/incubator/polyfills/test/__fixtures__/node_modules/excluded-package/react-native.config.js new file mode 100644 index 000000000..e3a86a9f4 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/excluded-package/react-native.config.js @@ -0,0 +1,7 @@ +module.exports = { + dependency: { + api: { + polyfill: "polyfill.js", + }, + }, +}; diff --git a/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boolean/package.json b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boolean/package.json new file mode 100644 index 000000000..d0194b6a2 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boolean/package.json @@ -0,0 +1,4 @@ +{ + "name": "invalid-polyfill-boolean", + "version": "1.0.0" +} diff --git a/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boolean/react-native.config.js b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boolean/react-native.config.js new file mode 100644 index 000000000..d6605ef17 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boolean/react-native.config.js @@ -0,0 +1,7 @@ +module.exports = { + dependency: { + api: { + polyfill: true, + }, + }, +}; diff --git a/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boundary/package.json b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boundary/package.json new file mode 100644 index 000000000..fac7702e1 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boundary/package.json @@ -0,0 +1,4 @@ +{ + "name": "invalid-polyfill-boundary", + "version": "1.0.0" +} diff --git a/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boundary/react-native.config.js b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boundary/react-native.config.js new file mode 100644 index 000000000..3157130c2 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-boundary/react-native.config.js @@ -0,0 +1,7 @@ +module.exports = { + dependency: { + api: { + polyfill: "../@react-native-webapis/gamepad/polyfill.js", + }, + }, +}; diff --git a/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-missing/package.json b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-missing/package.json new file mode 100644 index 000000000..bc10d18c2 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-missing/package.json @@ -0,0 +1,4 @@ +{ + "name": "invalid-polyfill-missing", + "version": "1.0.0" +} diff --git a/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-missing/react-native.config.js b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-missing/react-native.config.js new file mode 100644 index 000000000..e3a86a9f4 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/invalid-polyfill-missing/react-native.config.js @@ -0,0 +1,7 @@ +module.exports = { + dependency: { + api: { + polyfill: "polyfill.js", + }, + }, +}; diff --git a/incubator/polyfills/test/__fixtures__/node_modules/react-native/package.json b/incubator/polyfills/test/__fixtures__/node_modules/react-native/package.json new file mode 100644 index 000000000..8dacfe743 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/react-native/package.json @@ -0,0 +1,4 @@ +{ + "name": "react-native", + "version": "1.0.0" +} diff --git a/incubator/polyfills/test/__fixtures__/node_modules/react-native/react-native.config.js b/incubator/polyfills/test/__fixtures__/node_modules/react-native/react-native.config.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/react-native/react-native.config.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/incubator/polyfills/test/__fixtures__/node_modules/react/package.json b/incubator/polyfills/test/__fixtures__/node_modules/react/package.json new file mode 100644 index 000000000..a1069cc8a --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/node_modules/react/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "1.0.0" +} diff --git a/incubator/polyfills/test/__fixtures__/package.json b/incubator/polyfills/test/__fixtures__/package.json new file mode 100644 index 000000000..77ce251cf --- /dev/null +++ b/incubator/polyfills/test/__fixtures__/package.json @@ -0,0 +1,16 @@ +{ + "name": "AwesomeGame", + "version": "1.0.0", + "dependencies": { + "@react-native-webapis/battery-status": "*" + }, + "peerDependencies": { + "this-should-be-ignored": "*" + }, + "devDependencies": { + "@react-native-webapis/gamepad": "*", + "invalid-polyfill-boolean": "*", + "invalid-polyfill-boundary": "*", + "invalid-polyfill-missing": "*" + } +} diff --git a/incubator/polyfills/test/__mocks__/chalk.js b/incubator/polyfills/test/__mocks__/chalk.js new file mode 100644 index 000000000..c4fb2363b --- /dev/null +++ b/incubator/polyfills/test/__mocks__/chalk.js @@ -0,0 +1,14 @@ +const chalk = jest.createMockFromModule("chalk"); + +function passthrough(s) { + return s; +} + +chalk.cyan = passthrough; +chalk.cyan.bold = passthrough; +chalk.red = passthrough; +chalk.red.bold = passthrough; +chalk.yellow = passthrough; +chalk.yellow.bold = passthrough; + +module.exports = chalk; diff --git a/incubator/polyfills/test/dependency.test.ts b/incubator/polyfills/test/dependency.test.ts new file mode 100644 index 000000000..82e30b23f --- /dev/null +++ b/incubator/polyfills/test/dependency.test.ts @@ -0,0 +1,85 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { getDependencyPolyfills, resolvePath } from "../src/dependency"; + +describe("getDependencyPolyfills", () => { + const consoleErrorSpy = jest.spyOn(global.console, "error"); + + beforeEach(() => { + consoleErrorSpy.mockReset(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test("collects polyfills from included valid packages", () => { + const context = { + projectRoot: path.join(__dirname, "__fixtures__"), + }; + + expect(getDependencyPolyfills(context).sort()).toEqual([ + expect.stringMatching( + /[/\\]node_modules[/\\]@react-native-webapis[/\\]battery-status[/\\]polyfill.js$/ + ), + expect.stringMatching( + /[/\\]node_modules[/\\]@react-native-webapis[/\\]gamepad[/\\]polyfill.js$/ + ), + ]); + + expect(consoleErrorSpy).toBeCalledTimes(3); + expect(consoleErrorSpy).toBeCalledWith( + "error", + expect.stringMatching(/^invalid-polyfill-boolean: invalid polyfill path/) + ); + expect(consoleErrorSpy).toBeCalledWith( + "error", + expect.stringMatching(/^invalid-polyfill-boundary: invalid polyfill path/) + ); + expect(consoleErrorSpy).toBeCalledWith( + "error", + expect.stringMatching(/^invalid-polyfill-missing: no such polyfill/) + ); + }); +}); + +describe("resolvePath", () => { + test("rejects invalid paths", () => { + expect(resolvePath(__dirname, "")).toBe(null); + expect(resolvePath(__dirname, [])).toBe(null); + expect(resolvePath(__dirname, false)).toBe(null); + expect(resolvePath(__dirname, null)).toBe(null); + expect(resolvePath(__dirname, undefined)).toBe(null); + }); + + test("rejects paths outside the package boundary", () => { + expect(resolvePath(__dirname, "/bin/sh")).toBe(null); + + expect(resolvePath(__dirname, "../../bin/sh")).toBe(null); + expect(resolvePath(__dirname, "../bin/sh")).toBe(null); + expect(resolvePath(__dirname, "./../bin/sh")).toBe(null); + + if (os.platform() === "win32") { + const p = "C:\\Windows\\System32\\cmd.exe"; + expect(resolvePath(__dirname, p)).toBe(null); + expect(resolvePath(__dirname, p.toLowerCase())).toBe(null); + + expect(resolvePath(__dirname, "..\\..\\Windows\\System32\\cmd.exe")).toBe( + null + ); + expect(resolvePath(__dirname, "..\\Windows\\System32\\cmd.exe")).toBe( + null + ); + expect(resolvePath(__dirname, ".\\..\\Windows\\System32\\cmd.exe")).toBe( + null + ); + } + }); + + test("accepts paths inside the package boundary", () => { + expect(resolvePath(__dirname, "./index.js")).not.toBe(null); + expect(resolvePath(__dirname, "./lib/index.js")).not.toBe(null); + expect(resolvePath(__dirname, "index.js")).not.toBe(null); + expect(resolvePath(__dirname, "lib/index.js")).not.toBe(null); + }); +}); diff --git a/incubator/polyfills/test/index.test.ts b/incubator/polyfills/test/index.test.ts new file mode 100644 index 000000000..29f46d3fe --- /dev/null +++ b/incubator/polyfills/test/index.test.ts @@ -0,0 +1,52 @@ +import * as babel from "@babel/core"; +import * as path from "node:path"; + +describe("polyfills", () => { + const currentWorkingDir = process.cwd(); + const transformOptions = { + plugins: [require(path.join(__dirname, "..", "lib", "index.js"))], + }; + + function setFixture(): void { + process.chdir(path.join(__dirname, "__fixtures__")); + } + + function transform(code: string): string | null | undefined { + const result = babel.transformSync(code, transformOptions); + return result?.code; + } + + afterEach(() => { + process.chdir(currentWorkingDir); + }); + + test("is noop without trigger word", () => { + const code = "console.log(0);"; + expect(transform(code)).toBe(code); + + setFixture(); + expect(transform(code)).toBe(code); + }); + + test("is noop without polyfills", () => { + const code = "// @react-native-webapis\nconsole.log(0);"; + expect(transform(code)).toBe(code); + }); + + test("injects polyfills at the top", () => { + setFixture(); + + const code = ["// @react-native-webapis", "console.log(0);"]; + const result = transform(code.join("\n")); + + expect(result?.split("\n")).toEqual([ + expect.stringMatching( + /^import ".*?[/\\]{1,2}@react-native-webapis[/\\]{1,2}battery-status[/\\]{1,2}polyfill.js";$/ + ), + expect.stringMatching( + /^import ".*?[/\\]{1,2}@react-native-webapis[/\\]{1,2}gamepad[/\\]{1,2}polyfill.js";$/ + ), + ...code, + ]); + }); +});