From 1b6962da4ffd84368a00e39ccac12777fcc31d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Wed, 1 Jul 2026 06:21:54 -0700 Subject: [PATCH] Add an interactive REPL for Fantom (yarn fantom-cli) (#57387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Adds `yarn fantom-cli`, an interactive REPL that evaluates JavaScript against the same native tester binary and Hermes runtime that Fantom tests run in. State persists across lines, input goes through Metro/Babel (so `import`, JSX and Flow all work), and the environment is set up the same way tests set it up — `React`, `ReactNative` and the `Fantom` API are available globally, so you can render surfaces and drive them interactively from the prompt. The native tester gains an `--interactive` mode that loads a warm-up bundle without running tests and then evaluates length-prefixed snippets read from stdin, reporting results, console output and errors back as newline-delimited JSON. A Node driver hosts a Metro server, builds the warm-up bundle, spawns the binary, and bridges each line of input into the live runtime (top-level declarations persist across evaluations). Features: - Console-style output: results are printed with an inspector similar to the Chrome DevTools / Node.js consoles (nested objects/arrays up to a depth limit, quoted strings, functions/classes, `Map`/`Set`/`RegExp`/`Date`/`Error`, class instances, circular references, multi-line wrapping), colorized by type when stdout is a terminal. Inspecting a property never aborts the result or leaks into later evaluations: a property whose getter fails renders as `[Thrown: ]`, including getters that fail asynchronously through the runtime's global error handler (e.g. accessing a `react-native` export backed by a TurboModule that isn't registered). - Autocompletion: pressing Tab completes global identifiers, in-scope bindings and object properties (property names are listed without invoking getters). - Node-like CLI: with no arguments it starts the REPL; `-e ` evaluates a snippet and exits; a filename runs that script and exits. In the non-interactive modes the value of a trailing expression is not printed (use `console.log`) and a thrown error exits with a non-zero status code. Also documents the REPL in the Fantom README. Changelog: [Internal] Differential Revision: D110187712 --- package.json | 1 + .../react-native-fantom/__docs__/README.md | 78 ++- private/react-native-fantom/repl/index.js | 18 + .../react-native-fantom/repl/replBundling.js | 161 +++++ private/react-native-fantom/repl/replMetro.js | 71 +++ .../react-native-fantom/repl/replTransform.js | 156 +++++ private/react-native-fantom/repl/runRepl.js | 550 ++++++++++++++++++ .../runner/entrypoint-template.js | 23 +- .../runner/getHostPlatform.js | 35 ++ .../runtime/ReplEntryPoint.js | 21 + .../react-native-fantom/runtime/repl-setup.js | 446 ++++++++++++++ .../tester/src/AppSettings.cpp | 8 + .../tester/src/AppSettings.h | 5 + .../tester/src/TesterAppDelegate.cpp | 99 +++- .../tester/src/TesterAppDelegate.h | 19 + .../react-native-fantom/tester/src/main.cpp | 8 +- scripts/fantom-cli.sh | 21 + 17 files changed, 1687 insertions(+), 33 deletions(-) create mode 100644 private/react-native-fantom/repl/index.js create mode 100644 private/react-native-fantom/repl/replBundling.js create mode 100644 private/react-native-fantom/repl/replMetro.js create mode 100644 private/react-native-fantom/repl/replTransform.js create mode 100644 private/react-native-fantom/repl/runRepl.js create mode 100644 private/react-native-fantom/runner/getHostPlatform.js create mode 100644 private/react-native-fantom/runtime/ReplEntryPoint.js create mode 100644 private/react-native-fantom/runtime/repl-setup.js create mode 100755 scripts/fantom-cli.sh diff --git a/package.json b/package.json index d759fc915f9..0350c2c9563 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "test-generated-typescript": "tsc -p packages/react-native/types_generated/tsconfig.test.json", "test": "jest", "fantom": "./scripts/fantom.sh", + "fantom-cli": "./scripts/fantom-cli.sh", "trigger-react-native-release": "node ./scripts/releases-local/trigger-react-native-release.js", "update-lock": "npx yarn-deduplicate" }, diff --git a/private/react-native-fantom/__docs__/README.md b/private/react-native-fantom/__docs__/README.md index 22031337963..e54b1918075 100644 --- a/private/react-native-fantom/__docs__/README.md +++ b/private/react-native-fantom/__docs__/README.md @@ -112,6 +112,72 @@ Similar to Jest, you can also run Fantom in watch mode using `--watch`: yarn fantom --watch ``` +### Interactive REPL + +> [!WARNING] +> +> The REPL is experimental — its behavior and APIs may change. + +You can start an interactive REPL that evaluates JavaScript against the same +Hermes runtime and React Native environment that Fantom tests use: + +```shell +yarn fantom-cli +``` + +Each line is evaluated in a persistent session (bindings declared on one line +are available on the next) and goes through Metro, so `import`, JSX and Flow +syntax all work. `React`, `ReactNative` and the `Fantom` API are exposed as +globals, so you can explore them — and render surfaces — without importing +anything. Results are printed with a console-style inspector (similar to Chrome +DevTools / Node.js), and pressing Tab autocompletes global +identifiers, in-scope bindings and object properties. + +```text +👻 Fantom REPL — evaluating against Hermes inside the Fantom runtime. + ⚠️ Experimental: behavior and APIs may change. +fantom> const x = 21 +fantom> x * 2 +42 +fantom> ({items: [1, 2, 3], nested: {ok: true}}) +{ items: [ 1, 2, 3 ], nested: { ok: true } } +``` + +Press Ctrl+D to exit. + +Like Node, you can also evaluate a snippet and exit with `-e`, or run a script +file: + +```shell +yarn fantom-cli -e "console.log(Fantom.getHostPlatform())" +yarn fantom-cli path/to/script.js +``` + +In both cases the value of a trailing expression is not printed (use +`console.log`), and a thrown error exits with a non-zero status code. + +#### Timers and asynchronous code in the REPL + +The REPL runs in the Fantom environment, which uses a **deterministic virtual +clock** — real wall-clock time never passes on its own. A zero-delay +`setTimeout(fn, 0)` runs right after the current line, but timers with a delay +(and `setInterval`) stay pending until the clock is advanced, so this does +**not** print on its own: + +```text +fantom> setTimeout(() => console.log('hi'), 1000) +``` + +Drive timers by installing the timer mock and advancing the clock, exactly like +in a test (see the timers FAQ below): + +```text +fantom> const timers = Fantom.installTimerMock() +fantom> setTimeout(() => console.log('hi'), 1000) +fantom> timers.advanceTimersByTime(1000) +hi +``` + ### Conventions - Place test files in `__tests__` directories alongside the code being tested. @@ -465,9 +531,15 @@ expect(scrollViewElement.scrollTop).toBe(1); #### How can I test logic that relies on timers (`setTimeout`/`setInterval`)? -Install a deterministic timer mock with `Fantom.installTimerMock()`. While -installed, `setTimeout`/`setInterval` callbacks do not fire on their own; you -advance a virtual clock to fire them, similar to `jest.useFakeTimers()`: +Fantom runs on a **deterministic virtual clock**: wall-clock time never advances +on its own, and Fantom decides when the event loop and timers run. In the +default environment a zero-delay `setTimeout(fn, 0)` is dispatched on the next +work-loop tick, but timers with a positive delay (and any `setInterval`) stay +pending and never fire unless the virtual clock is advanced. + +To advance the clock and run those callbacks, install a deterministic timer mock +with `Fantom.installTimerMock()`. While installed, you advance a virtual clock +to fire them, similar to `jest.useFakeTimers()`: ```javascript const timers = Fantom.installTimerMock(); diff --git a/private/react-native-fantom/repl/index.js b/private/react-native-fantom/repl/index.js new file mode 100644 index 00000000000..b8c1f4f6ec1 --- /dev/null +++ b/private/react-native-fantom/repl/index.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +require('../../../scripts/shared/babelRegister').registerForMonorepo(); + +const runRepl = require('./runRepl').default; + +runRepl().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/private/react-native-fantom/repl/replBundling.js b/private/react-native-fantom/repl/replBundling.js new file mode 100644 index 00000000000..5bfaad4670e --- /dev/null +++ b/private/react-native-fantom/repl/replBundling.js @@ -0,0 +1,161 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import fs from 'fs'; +import path from 'path'; + +// react-native-github repo root (repl -> ../../.. ). +export const PROJECT_ROOT: string = path.resolve(__dirname, '..', '..', '..'); + +const RELATIVE_WARMUP_ENTRY = path.join( + 'private', + 'react-native-fantom', + 'runtime', + 'ReplEntryPoint.js', +); + +const MAX_BUNDLE_FETCH_ATTEMPTS = 10; +const BUNDLE_FETCH_BASE_BACKOFF_MS = 100; +const BUNDLE_FETCH_MAX_BACKOFF_MS = 2_000; + +function getMetroPort(): number { + const value = process.env.__FANTOM_METRO_PORT__; + if (value == null) { + throw new Error( + 'Could not find Metro server port (process.env.__FANTOM_METRO_PORT__ not set)', + ); + } + const port = Number(value); + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port for Metro server: ${port}`); + } + return port; +} + +function getBundleURL( + relativeEntryPath: string, + extraParams: {[string]: string}, +): URL { + const requestPath = relativeEntryPath.replace(/\.js$/, ''); + const url = new URL( + `http://localhost:${getMetroPort()}/${requestPath}.bundle`, + ); + url.searchParams.append('platform', 'android'); + url.searchParams.append('dev', 'true'); + url.searchParams.append('minify', 'false'); + for (const key of Object.keys(extraParams)) { + url.searchParams.append(key, extraParams[key]); + } + return url; +} + +async function fetchBundleWithRetry(bundleURL: URL): Promise { + let lastErrorMessage = ''; + + for (let attempt = 0; attempt < MAX_BUNDLE_FETCH_ATTEMPTS; attempt++) { + if (attempt > 0) { + const backoff = Math.min( + BUNDLE_FETCH_BASE_BACKOFF_MS * 2 ** (attempt - 1), + BUNDLE_FETCH_MAX_BACKOFF_MS, + ); + await sleep(backoff); + } + + let response; + try { + response = await fetch(bundleURL); + } catch (error: unknown) { + lastErrorMessage = error instanceof Error ? error.message : String(error); + continue; + } + + if (response.ok) { + return response.text(); + } + + const bodyText = await response.text(); + const {message, retryable} = parseMetroErrorBody(response.status, bodyText); + lastErrorMessage = message; + if (!retryable) { + throw new Error(`Failed to request bundle from Metro:\n${message}`); + } + } + + throw new Error( + `Failed to request bundle from Metro after ${MAX_BUNDLE_FETCH_ATTEMPTS} attempts:\n${lastErrorMessage}`, + ); +} + +function parseMetroErrorBody( + status: number, + bodyText: string, +): {message: string, retryable: boolean} { + let message = bodyText; + let errorType: ?string; + try { + const parsed = JSON.parse(bodyText); + if (typeof parsed?.message === 'string') { + message = parsed.message; + } + if (typeof parsed?.type === 'string') { + errorType = parsed.type; + } + } catch { + // Not JSON — keep the raw body as the message. + } + + const retryable = + status === 404 || + (status === 500 && + (errorType === 'UnableToResolveError' || + errorType === 'ResourceNotFoundError')); + + return {message, retryable}; +} + +/** + * Builds the full warm-up bundle (with the Metro runtime + polyfills) and writes + * it to `outPath`. This is loaded once by the tester binary to set up the + * environment before any REPL input is evaluated. + */ +export async function buildWarmupBundle(outPath: string): Promise { + const code = await fetchBundleWithRetry( + getBundleURL(RELATIVE_WARMUP_ENTRY, {}), + ); + await fs.promises.writeFile(outPath, code, 'utf8'); +} + +/** + * Builds a delta bundle for a single REPL entry: only `__d` registrations (the + * Metro runtime is already loaded from the warm-up bundle) plus the `__r` run + * statement for the entry. Already-registered modules are harmlessly re-declared + * (metro-runtime's `__d` is a no-op for existing module ids). + */ +export async function fetchDeltaBundle(entryPath: string): Promise { + const relativeEntryPath = path.relative(PROJECT_ROOT, entryPath); + const bundleURL = getBundleURL(relativeEntryPath, { + modulesOnly: 'true', + runModule: 'true', + }); + const code = await fetchBundleWithRetry(bundleURL); + + // Evict Metro's cached dependency graph for this one-off entry to free memory. + try { + await fetch(bundleURL, {method: 'DELETE'}); + } catch { + // Best-effort cleanup. + } + + return code; +} + +async function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/private/react-native-fantom/repl/replMetro.js b/private/react-native-fantom/repl/replMetro.js new file mode 100644 index 00000000000..00e61c4c0b9 --- /dev/null +++ b/private/react-native-fantom/repl/replMetro.js @@ -0,0 +1,71 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {Server as HttpServer} from 'http'; +import type {Server as HttpsServer} from 'https'; + +import Metro from 'metro'; +import {mergeConfig} from 'metro-config'; +import {Server as NetServer} from 'net'; +import path from 'path'; + +let metroServer: ?(HttpServer | HttpsServer) = null; + +export async function startMetroServer(): Promise { + process.env.__FANTOM_RUN_ID__ ??= `repl-${Date.now()}`; + + if (process.env.__FANTOM_METRO_PORT__ == null) { + process.env.__FANTOM_METRO_PORT__ = String(await findAvailablePort()); + } + + const port = Number(process.env.__FANTOM_METRO_PORT__); + + const baseConfig = await Metro.loadConfig({ + config: path.resolve(__dirname, '..', 'config', 'metro.config.js'), + }); + + // Force the chosen port over the default one baked into the config. + const metroConfig = mergeConfig(baseConfig, {server: {port}}); + + const {httpServer} = await Metro.runServer(metroConfig, { + waitForBundler: true, + watch: true, + }); + metroServer = httpServer; +} + +export async function stopMetroServer(): Promise { + const server = metroServer; + metroServer = null; + if (server != null) { + await new Promise(resolve => { + server.close(() => resolve()); + }); + } +} + +async function findAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = new NetServer(); + server.listen(0, 'localhost', undefined, () => { + const address = server.address(); + const port = + address != null && typeof address === 'object' ? address.port : 0; + server.close(error => { + if (error != null) { + reject(error); + } else { + resolve(port); + } + }); + }); + server.on('error', reject); + }); +} diff --git a/private/react-native-fantom/repl/replTransform.js b/private/react-native-fantom/repl/replTransform.js new file mode 100644 index 00000000000..be848fec0bc --- /dev/null +++ b/private/react-native-fantom/repl/replTransform.js @@ -0,0 +1,156 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {ESNode, Program} from 'hermes-estree'; + +import {parse} from 'hermes-parser'; + +export type TransformResult = { + code: string, + hasResult: boolean, + newNames: Set, +}; + +function collectPatternNames(node: ESNode, out: Set): void { + switch (node.type) { + case 'Identifier': + out.add(node.name); + break; + case 'ObjectPattern': + for (const property of node.properties) { + if (property.type === 'RestElement') { + collectPatternNames(property.argument, out); + } else { + collectPatternNames(property.value, out); + } + } + break; + case 'ArrayPattern': + for (const element of node.elements) { + if (element != null) { + collectPatternNames(element, out); + } + } + break; + case 'AssignmentPattern': + collectPatternNames(node.left, out); + break; + case 'RestElement': + collectPatternNames(node.argument, out); + break; + default: + break; + } +} + +function collectDeclaredNames(program: Program): Set { + const names = new Set(); + for (const statement of program.body) { + switch (statement.type) { + case 'VariableDeclaration': + for (const declaration of statement.declarations) { + collectPatternNames(declaration.id, names); + } + break; + case 'FunctionDeclaration': + case 'ClassDeclaration': + if (statement.id != null) { + names.add(statement.id.name); + } + break; + case 'ImportDeclaration': + for (const specifier of statement.specifiers) { + names.add(specifier.local.name); + } + break; + default: + break; + } + } + return names; +} + +function getRange(node: ESNode): [number, number] { + return [node.range[0], node.range[1]]; +} + +/** + * Parses a line of REPL input and rewrites it into a Metro entry module that: + * + * 1. Brings previously declared names into scope from `$$REPL_SCOPE$$`. + * 2. Runs the user's code verbatim (preserving Flow/JSX), capturing the value + * of a trailing expression into `$$REPL_RESULT$$`. + * 3. Writes any top-level bindings back to `$$REPL_SCOPE$$` so they persist. + * + * The user's source is never regenerated from the AST (only sliced), so exotic + * syntax in the body round-trips unchanged. + * + * Throws the underlying parse error if the input cannot be parsed. + */ +export function transform( + input: string, + priorNames: Set, +): TransformResult { + const ast = parse(input, {babel: false}); + const declared = collectDeclaredNames(ast); + + let body = input; + let hasResult = false; + const last = ast.body[ast.body.length - 1]; + if (last != null && last.type === 'ExpressionStatement') { + // Replace the entire last statement with a result-capturing assignment, + // using the inner expression's text. We slice the expression (not the + // statement) so any surrounding parentheses/semicolon are dropped, then + // re-wrap in our own parentheses — this keeps parenthesized expressions + // like `({a: 1})` valid. + const [statementStart] = getRange(last); + const [exprStart, exprEnd] = getRange(last.expression); + body = + input.slice(0, statementStart) + + 'globalThis.$$REPL_RESULT$$ = (' + + input.slice(exprStart, exprEnd) + + '); globalThis.$$REPL_HAS_RESULT$$ = true;'; + hasResult = true; + } + + const prepend = [...priorNames].filter(name => !declared.has(name)); + const writeBack = new Set([...priorNames, ...declared]); + + let code = ''; + if (prepend.length > 0) { + code += `let {${prepend.join(', ')}} = globalThis.$$REPL_SCOPE$$;\n`; + } + code += body + '\n'; + for (const name of writeBack) { + code += `globalThis.$$REPL_SCOPE$$.${name} = ${name};\n`; + } + + return {code, hasResult, newNames: writeBack}; +} + +const RECOVERABLE_ERROR_PATTERNS = [ + /Unexpected end of input/i, + /Unexpected EOF/i, + /Unexpected token `?end/i, + /but found end of/i, + // Unterminated string / template / comment / regular-expression literals — + // the user is likely still typing a multi-line construct. + /Unterminated/i, +]; + +/** + * Heuristic to decide whether a parse error is because the user hasn't finished + * typing (e.g. an open brace or unterminated template), in which case the REPL + * should keep reading more lines. + */ +export function isRecoverableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ''); + return RECOVERABLE_ERROR_PATTERNS.some(pattern => pattern.test(message)); +} diff --git a/private/react-native-fantom/repl/runRepl.js b/private/react-native-fantom/repl/runRepl.js new file mode 100644 index 00000000000..499434b2dd3 --- /dev/null +++ b/private/react-native-fantom/repl/runRepl.js @@ -0,0 +1,550 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {AsyncCommandResult} from '../runner/utils'; + +import {isCI, isOSS} from '../runner/EnvironmentOptions'; +import {run as runTester} from '../runner/executables/tester'; +import getHostPlatform from '../runner/getHostPlatform'; +import {getTestBuildOutputPath} from '../runner/paths'; +import {HermesVariant, runCommand} from '../runner/utils'; +import { + PROJECT_ROOT, + buildWarmupBundle, + fetchDeltaBundle, +} from './replBundling'; +import {startMetroServer, stopMetroServer} from './replMetro'; +import {isRecoverableError, transform} from './replTransform'; +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; +import repl from 'repl'; +import tty from 'tty'; + +type ReplMessage = + | {type: 'console-log', level: 'info' | 'warn' | 'error', message: string} + | {type: 'repl-result', text: string} + | {type: 'repl-error', message: string, stack: string} + | {type: 'repl-completions', names: Array} + | {type: 'repl-eval-complete', id: number}; + +type PendingEval = { + result: ?string, + error: ?{message: string, stack: string}, + completions: ?Array, + resolve: () => void, +}; + +type FrameResult = { + result: ?string, + error: ?{message: string, stack: string}, + completions: ?Array, +}; + +type CliOptions = + | {mode: 'repl'} + | {mode: 'eval', code: string} + | {mode: 'file', filePath: string}; + +// Mirrors Node: no args -> REPL, `-e ` -> evaluate and exit, a filename +// -> run that script and exit. +function parseArgs(argv: ReadonlyArray): CliOptions { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '-e' || arg === '--eval') { + const code = argv[i + 1]; + if (code == null) { + throw new Error(`${arg} requires an argument`); + } + return {mode: 'eval', code}; + } + if (arg.startsWith('-')) { + throw new Error(`Unknown option: ${arg}`); + } + return {mode: 'file', filePath: path.resolve(process.cwd(), arg)}; + } + return {mode: 'repl'}; +} + +const SUPPORTS_COLOR: boolean = tty.isatty(1); +const SUPPORTS_COLOR_STDERR: boolean = tty.isatty(2); + +function paint(code: string, text: string, enabled: boolean): string { + return enabled ? `\x1b[${code}m${text}\x1b[0m` : text; +} + +function buildBanner(): string { + return ( + paint( + '36', + '👻 Fantom REPL — evaluating against Hermes inside the Fantom runtime.', + SUPPORTS_COLOR, + ) + + '\n' + + paint( + '33', + ' ⚠️ Experimental: behavior and APIs may change.', + SUPPORTS_COLOR, + ) + + '\n' + + paint( + '90', + ' `React`, `ReactNative` and `Fantom` are available globally. Ctrl-D to exit.', + SUPPORTS_COLOR, + ) + + '\n' + ); +} + +function spawnTester(args: ReadonlyArray): AsyncCommandResult { + if (isOSS) { + const ossPath = path.resolve( + __dirname, + '..', + 'build', + 'tester', + 'fantom_tester', + ); + return runCommand(ossPath, args, {}); + } + + return runTester( + args, + { + hermesVariant: HermesVariant.Hermes, + enableOptimized: false, + enableCoverage: false, + }, + {}, + ); +} + +export default async function runRepl(): Promise { + const options = parseArgs(process.argv.slice(2)); + + await startMetroServer(); + + const outputDir = getTestBuildOutputPath(); + fs.mkdirSync(outputDir, {recursive: true}); + + const warmupBundlePath = path.join(outputDir, 'repl-warmup.bundle.js'); + await buildWarmupBundle(warmupBundlePath); + + const result = spawnTester([ + '--interactive', + '--bundlePath', + warmupBundlePath, + '--featureFlags', + '{}', + '--minLogLevel', + 'error', + ]); + const child = result.childProcess; + + const stdout = child.stdout; + const stdin = child.stdin; + if (stdout == null || stdin == null) { + throw new Error('Fantom tester process did not expose stdio pipes'); + } + + if (child.stderr != null) { + child.stderr.pipe(process.stderr); + } + + let pending: ?PendingEval = null; + + const lineReader = readline.createInterface({input: stdout}); + lineReader.on('line', (rawLine: string) => { + const line = rawLine.trim(); + if (line === '') { + return; + } + + let message: ?ReplMessage; + try { + message = JSON.parse(line); + } catch { + // Not a structured message — surface it directly. + process.stdout.write(rawLine + '\n'); + return; + } + + switch (message?.type) { + case 'console-log': + if (message.level === 'error') { + process.stderr.write(message.message + '\n'); + } else { + process.stdout.write(message.message + '\n'); + } + break; + case 'repl-result': + if (pending != null) { + pending.result = message.text; + } + break; + case 'repl-error': + if (pending != null) { + pending.error = {message: message.message, stack: message.stack}; + } + break; + case 'repl-completions': + if (pending != null) { + pending.completions = message.names; + } + break; + case 'repl-eval-complete': { + const current = pending; + pending = null; + if (current != null) { + current.resolve(); + } + break; + } + default: + break; + } + }); + + function evaluateFrame(payload: string): Promise { + return new Promise(resolve => { + const state: PendingEval = { + result: null, + error: null, + completions: null, + resolve: () => + resolve({ + result: state.result, + error: state.error, + completions: state.completions, + }), + }; + pending = state; + + const buffer = Buffer.from(payload, 'utf8'); + stdin.write(String(buffer.length) + '\n'); + stdin.write(buffer); + }); + } + + let childExited = false; + child.on('exit', () => { + childExited = true; + if (pending != null) { + const current = pending; + pending = null; + current.resolve(); + } + }); + + // Wait for the warm-up to finish (the binary only reads stdin after the + // warm-up bundle has been evaluated) and configure the runtime once, on + // startup, with the constants computed here and whether to colorize output. + const colorsEnabled = options.mode === 'repl' && SUPPORTS_COLOR; + const constants = { + isOSS, + isRunningFromCI: isCI, + runBenchmarks: false, + fantomConfigSummary: '', + jsHeapSnapshotOutputPathTemplate: '', + jsHeapSnapshotOutputPathTemplateToken: '', + jsTraceOutputPath: null, + hostPlatform: getHostPlatform(), + }; + await evaluateFrame( + `globalThis.$$ReplConfigure$$(${JSON.stringify({ + colors: colorsEnabled, + constants, + })});\n`, + ); + + if (childExited) { + throw new Error( + 'Fantom tester exited before the REPL was ready. See logs above.', + ); + } + + const priorNames = new Set(); + let evalCounter = 0; + let evalChain: Promise = Promise.resolve(); + + async function shutdownAndExit(code: number): Promise { + try { + stdin.end(); + } catch {} + try { + await stopMetroServer(); + } catch {} + // Make sure buffered stdout (e.g. console output from a script) is flushed + // before exiting. + await new Promise(resolve => { + if (process.stdout.write('')) { + resolve(); + } else { + process.stdout.once('drain', () => resolve()); + } + }); + process.exit(code); + } + + function replEval( + input: string, + context: unknown, + filename: string, + callback: (error: ?Error, result: unknown) => void, + ): void { + // Node's REPL does not wait for our async callback before reading the next + // line when stdin is not a TTY (e.g. piped input or scripts). Serialize + // evaluations so they run one at a time and in order. + evalChain = evalChain.then(() => evalInput(input, callback)); + } + + async function evalInput( + rawInput: string, + callback: (error: ?Error, result: unknown) => void, + ): Promise { + try { + await evalInputUnsafe(rawInput, callback); + } catch (error: unknown) { + callback(null, formatError(error)); + } + } + + async function evalInputUnsafe( + rawInput: string, + callback: (error: ?Error, result: unknown) => void, + ): Promise { + const input = rawInput.trim(); + if (input === '') { + callback(null, undefined); + return; + } + + let transformed; + try { + transformed = transform(input, priorNames); + } catch (parseError: unknown) { + if (isRecoverableError(parseError)) { + callback(new repl.Recoverable(toError(parseError)), undefined); + } else { + callback(null, formatError(parseError)); + } + return; + } + + const id = evalCounter++; + const entryPath = path.join(outputDir, `repl-${process.pid}-${id}.js`); + fs.writeFileSync(entryPath, transformed.code, 'utf8'); + + let deltaText; + try { + deltaText = await fetchDeltaBundle(entryPath); + } catch (bundleError: unknown) { + callback(null, formatError(bundleError)); + return; + } finally { + try { + fs.unlinkSync(entryPath); + } catch {} + } + + const payload = deltaText + '\n;globalThis.$$ReplReport$$();\n'; + const {result: evalResult, error: evalError} = await evaluateFrame(payload); + + if (childExited) { + callback(new Error('Fantom tester process exited.'), undefined); + return; + } + + if (evalError != null) { + callback( + null, + paint( + '31', + evalError.stack !== '' ? evalError.stack : evalError.message, + SUPPORTS_COLOR, + ), + ); + return; + } + + // Only commit the new bindings to our view of the scope once the evaluation + // succeeded (the runtime only writes them back to $$REPL_SCOPE$$ on success). + priorNames.clear(); + for (const name of transformed.newNames) { + priorNames.add(name); + } + + callback(null, transformed.hasResult ? evalResult : undefined); + } + + // Queries the runtime for completion candidates: either the global + // identifiers + REPL scope bindings (objectExpr == null), or the property + // names of `objectExpr` (a simple member chain evaluated with REPL scope + // bridged in). Property names only — values/getters are never read. + async function queryCompletionNames( + objectExpr: ?string, + ): Promise> { + let payload; + if (objectExpr == null) { + payload = 'globalThis.$$ReplComplete$$(null);\n'; + } else { + const bridge = + priorNames.size > 0 + ? `let {${[...priorNames].join(', ')}} = globalThis.$$REPL_SCOPE$$;\n` + : ''; + payload = `(() => { ${bridge}globalThis.$$ReplComplete$$(() => (${objectExpr})); })();\n`; + } + const {completions} = await evaluateFrame(payload); + return completions ?? []; + } + + async function computeCompletions( + line: string, + ): Promise<[Array, string]> { + const partialMatch = line.match(/[A-Za-z_$][\w$]*$/); + const partial = partialMatch != null ? partialMatch[0] : ''; + const beforePartial = line.slice(0, line.length - partial.length); + + let names: Array; + if (beforePartial.endsWith('.')) { + const objectSource = beforePartial.slice(0, -1); + // Only complete members of a simple dotted identifier chain to avoid + // evaluating calls/indexing with side effects. + const chainMatch = objectSource.match( + /[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/, + ); + if (chainMatch == null) { + return [[], partial]; + } + names = await queryCompletionNames(chainMatch[0]); + } else { + names = await queryCompletionNames(null); + } + + const hits = names.filter(name => name.startsWith(partial)).sort(); + return [hits, partial]; + } + + function completer( + line: string, + callback: (error: ?Error, result: [Array, string]) => void, + ): void { + computeCompletions(line).then( + completions => callback(null, completions), + () => callback(null, [[], line]), + ); + } + + // One-shot evaluation for `-e ` and for running a script file. Unlike + // the REPL, the value of a trailing expression is not printed (matching + // `node -e` / running a script); use `console.log` to print. + async function runOnce(entryPath: string, cleanup: boolean): Promise { + let deltaText; + try { + deltaText = await fetchDeltaBundle(entryPath); + } catch (bundleError: unknown) { + process.stderr.write( + paint('31', formatError(bundleError), SUPPORTS_COLOR_STDERR) + '\n', + ); + await shutdownAndExit(1); + return; + } finally { + if (cleanup) { + try { + fs.unlinkSync(entryPath); + } catch {} + } + } + + // `$$ReplReport$$` surfaces any error captured during evaluation. The raw + // code does not set a result, so (unlike the REPL) nothing is auto-printed + // on success — matching `node -e` / running a script. + const {error} = await evaluateFrame( + deltaText + '\n;globalThis.$$ReplReport$$();\n', + ); + + if (childExited) { + process.stderr.write('Fantom tester process exited.\n'); + await shutdownAndExit(1); + return; + } + if (error != null) { + process.stderr.write( + paint( + '31', + error.stack !== '' ? error.stack : error.message, + SUPPORTS_COLOR_STDERR, + ) + '\n', + ); + await shutdownAndExit(1); + return; + } + await shutdownAndExit(0); + } + + if (options.mode === 'eval') { + const id = evalCounter++; + const entryPath = path.join(outputDir, `repl-eval-${process.pid}-${id}.js`); + fs.writeFileSync(entryPath, options.code, 'utf8'); + await runOnce(entryPath, true); + return; + } + + if (options.mode === 'file') { + if (!fs.existsSync(options.filePath)) { + process.stderr.write(`Cannot find script '${options.filePath}'\n`); + await shutdownAndExit(1); + return; + } + // Metro can only bundle files inside the repo (the bundle URL is the path + // relative to PROJECT_ROOT), so reject scripts outside it with a clear error. + const relativeToRoot = path.relative(PROJECT_ROOT, options.filePath); + if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { + process.stderr.write( + `Script must be inside the React Native repository (${PROJECT_ROOT}): ${options.filePath}\n`, + ); + await shutdownAndExit(1); + return; + } + await runOnce(options.filePath, false); + return; + } + + process.stdout.write(buildBanner()); + + // Node's readline accepts an async completer with a `(line, callback)` + // signature (required here because computing completions needs an async + // round-trip to the runtime), and we verified it works via a pseudo-terminal. + // The Node Flow libdef, however, only types the synchronous + // `(line) => [completions, line]` form, so it rejects the async signature. + // $FlowFixMe[incompatible-type] + const replServer = repl.start({ + prompt: 'fantom> ', + eval: replEval, + completer, + writer: (output: unknown) => (output == null ? '' : String(output)), + ignoreUndefined: true, + }); + + replServer.on('exit', () => { + void shutdownAndExit(0); + }); +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? error.message; + } + return String(error); +} diff --git a/private/react-native-fantom/runner/entrypoint-template.js b/private/react-native-fantom/runner/entrypoint-template.js index 0c3c3becb04..bd93b94daa8 100644 --- a/private/react-native-fantom/runner/entrypoint-template.js +++ b/private/react-native-fantom/runner/entrypoint-template.js @@ -9,31 +9,12 @@ */ import type {SnapshotConfig} from '../runtime/snapshotContext'; -import type {FantomRuntimeConstants, HostPlatform} from '../src/Constants'; +import type {FantomRuntimeConstants} from '../src/Constants'; import type {FantomTestConfig} from './getFantomTestConfigs'; import * as EnvironmentOptions from './EnvironmentOptions'; import formatFantomConfig from './formatFantomConfig'; - -function getHostPlatform(): HostPlatform { - match (process.platform) { - 'darwin' => { - return 'macos'; - } - 'win32' => { - return 'windows'; - } - 'linux' => { - return 'linux'; - } - 'android' => { - return 'android'; - } - _ => { - throw new Error(`Unsupported platform: ${process.platform}`); - } - } -} +import getHostPlatform from './getHostPlatform'; module.exports = function entrypointTemplate({ testPath, diff --git a/private/react-native-fantom/runner/getHostPlatform.js b/private/react-native-fantom/runner/getHostPlatform.js new file mode 100644 index 00000000000..ad69c956e06 --- /dev/null +++ b/private/react-native-fantom/runner/getHostPlatform.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {HostPlatform} from '../src/Constants'; + +/** + * Returns the host OS Fantom is running on (the Node process platform), mapped + * to the `HostPlatform` values used by the Fantom runtime constants. + */ +export default function getHostPlatform(): HostPlatform { + match (process.platform) { + 'darwin' => { + return 'macos'; + } + 'win32' => { + return 'windows'; + } + 'linux' => { + return 'linux'; + } + 'android' => { + return 'android'; + } + _ => { + throw new Error(`Unsupported platform: ${process.platform}`); + } + } +} diff --git a/private/react-native-fantom/runtime/ReplEntryPoint.js b/private/react-native-fantom/runtime/ReplEntryPoint.js new file mode 100644 index 00000000000..b168ebb502a --- /dev/null +++ b/private/react-native-fantom/runtime/ReplEntryPoint.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/** + * Entrypoint for the Fantom REPL (`fantom-cli`). + * + * This is loaded once by the native tester binary in interactive mode to set up + * the React Native environment and the REPL runtime helpers before any user + * input is evaluated. Unlike test entrypoints, it does NOT register or run any + * tests. + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; +import './repl-setup'; diff --git a/private/react-native-fantom/runtime/repl-setup.js b/private/react-native-fantom/runtime/repl-setup.js new file mode 100644 index 00000000000..8520e4f0c75 --- /dev/null +++ b/private/react-native-fantom/runtime/repl-setup.js @@ -0,0 +1,446 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/** + * Runtime helpers for the Fantom REPL (`fantom-cli`). This module runs inside + * the Hermes runtime hosted by the native tester binary. It: + * + * - exposes commonly used modules (`React`, `ReactNative`, `Fantom`) as globals + * so they can be used at the prompt without an explicit import, + * - maintains a persistent scope shared across evaluations + * (`global.$$REPL_SCOPE$$`), + * - captures uncaught errors (Metro routes module-factory errors to + * `ErrorUtils` rather than re-throwing), + * - is configured by the host once on startup via `global.$$ReplConfigure$$` + * (runtime constants and whether to colorize output), and + * - defines `global.$$ReplReport$$`, which the host appends to each evaluation + * to report the result (or error) of the last statement back over stdout. + */ + +import type {FantomRuntimeConstants} from '@react-native/fantom/src/Constants'; + +import * as Fantom from '@react-native/fantom'; +import {setConstants} from '@react-native/fantom/src/Constants'; +import * as React from 'react'; +import * as ReactNative from 'react-native'; + +// Persistent scope shared across REPL evaluations. Top-level bindings declared +// at the prompt are bridged in and out of this object by the host so that they +// survive across evaluations (see repl/replTransform.js). +global.$$REPL_SCOPE$$ = global.$$REPL_SCOPE$$ ?? {}; + +// The value of the last evaluated expression (if any) and whether there was one. +global.$$REPL_RESULT$$ = undefined; +global.$$REPL_HAS_RESULT$$ = false; + +// Whether to colorize output with ANSI escape codes. Set by the host based on +// whether its stdout is a TTY. +global.$$REPL_COLORS$$ = false; + +// Convenience globals available at the prompt without importing. +global.React = React; +global.ReactNative = ReactNative; +global.Fantom = Fantom; + +// Configured by the host once, on startup, with values it computes in Node +// (e.g. `isOSS`, the host platform). This is how the runtime constants are set +// dynamically at startup rather than hardcoded. +global.$$ReplConfigure$$ = (config: { + colors: boolean, + constants: FantomRuntimeConstants, +}): void => { + global.$$REPL_COLORS$$ = config.colors; + setConstants(config.constants); +}; + +// Metro's `__r` runs each entry module inside `ErrorUtils` when it is installed, +// which reports factory errors via the global handler instead of re-throwing. +// Capture those so `$$ReplReport$$` can surface them to the host. +let pendingError: unknown = null; +const errorUtils = global.ErrorUtils; +if (errorUtils != null) { + errorUtils.setGlobalHandler((error: unknown) => { + pendingError = error; + }); +} + +function describeError(error: unknown): string { + if (error instanceof Error) { + const message = error.message != null ? error.message : ''; + return `${error.name}: ${message.split('\n')[0]}`; + } + return String(error); +} + +// Inspector that produces output similar to the Chrome DevTools / Node.js +// console: nested objects and arrays are expanded (up to a depth limit), +// strings are quoted, functions/classes/Map/Set get readable tags, circular +// references are detected, and long collections wrap onto multiple lines. + +const INSPECT_DEPTH = 2; +const INLINE_BREAK_LENGTH = 72; +const MAX_ENTRIES = 100; + +// ANSI colors roughly matching the Node.js / Chrome DevTools console. +const COLORS = { + string: '32', + number: '33', + boolean: '33', + nullish: '90', + symbol: '32', + fn: '36', + special: '36', + date: '35', + regexp: '31', + error: '31', +}; + +function color(code: string, text: string): string { + return global.$$REPL_COLORS$$ === true ? `\x1b[${code}m${text}\x1b[0m` : text; +} + +function quoteString(value: string): string { + return ( + "'" + + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n') + + "'" + ); +} + +function formatKey(key: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : quoteString(key); +} + +// Reads an own property value. Uses property descriptors (rather than `[]` +// indexing, which Flow can't type on an opaque object, or `Reflect`, which +// isn't available in Hermes) so a single getter is invoked at a time, keeping +// the per-property error isolation below working. +function readProperty(target: interface {}, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(target, key); + if (descriptor == null) { + return undefined; + } + const getter = descriptor.get; + if (getter != null) { + return getter.call(target); + } + return descriptor.value; +} + +function getConstructorName(value: interface {}): ?string { + const proto = Object.getPrototypeOf(value); + if (proto == null) { + return null; + } + const ctor = proto.constructor; + return ctor != null && typeof ctor.name === 'string' && ctor.name !== '' + ? ctor.name + : null; +} + +function isClassFunction(value: unknown): boolean { + try { + return /^class[\s{]/.test(String(value)); + } catch { + return false; + } +} + +// Renders a list of already-formatted entries inline if they are short, or one +// per line (indented) otherwise. +function wrapEntries( + open: string, + close: string, + prefix: string, + entries: Array, + indent: string, +): string { + if (entries.length === 0) { + return prefix + open + close; + } + const oneLine = prefix + open + ' ' + entries.join(', ') + ' ' + close; + if (oneLine.length <= INLINE_BREAK_LENGTH && !oneLine.includes('\n')) { + return oneLine; + } + const inner = indent + ' '; + return ( + prefix + + open + + '\n' + + entries.map(entry => inner + entry).join(',\n') + + '\n' + + indent + + close + ); +} + +function inspectObject( + value: interface {}, + depth: number, + seen: Set, + indent: string, +): string { + if (value instanceof Error) { + return color( + COLORS.error, + value.stack != null && value.stack !== '' + ? value.stack + : `${value.name}: ${value.message}`, + ); + } + if (value instanceof RegExp) { + return color(COLORS.regexp, value.toString()); + } + if (value instanceof Date) { + return color( + COLORS.date, + Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString(), + ); + } + if (seen.has(value)) { + return color(COLORS.special, '[Circular]'); + } + if (depth < 0) { + if (Array.isArray(value)) { + return color(COLORS.special, '[Array]'); + } + const name = getConstructorName(value); + return color( + COLORS.special, + name != null && name !== 'Object' ? `[${name}]` : '[Object]', + ); + } + + seen.add(value); + const childDepth = depth - 1; + const childIndent = indent + ' '; + let result; + + if (Array.isArray(value)) { + const entries = []; + const limit = Math.min(value.length, MAX_ENTRIES); + for (let i = 0; i < limit; i++) { + entries.push(inspect(value[i], childDepth, seen, childIndent)); + } + if (value.length > MAX_ENTRIES) { + entries.push(`... ${value.length - MAX_ENTRIES} more item(s)`); + } + result = wrapEntries('[', ']', '', entries, indent); + } else if (value instanceof Map) { + const entries = []; + let count = 0; + for (const [k, v] of value) { + if (count++ >= MAX_ENTRIES) { + entries.push(`... ${value.size - MAX_ENTRIES} more item(s)`); + break; + } + entries.push( + inspect(k, childDepth, seen, childIndent) + + ' => ' + + inspect(v, childDepth, seen, childIndent), + ); + } + result = wrapEntries('{', '}', `Map(${value.size}) `, entries, indent); + } else if (value instanceof Set) { + const entries = []; + let count = 0; + for (const v of value) { + if (count++ >= MAX_ENTRIES) { + entries.push(`... ${value.size - MAX_ENTRIES} more item(s)`); + break; + } + entries.push(inspect(v, childDepth, seen, childIndent)); + } + result = wrapEntries('{', '}', `Set(${value.size}) `, entries, indent); + } else { + const name = getConstructorName(value); + const prefix = name != null && name !== 'Object' ? name + ' ' : ''; + const keys = Object.keys(value); + const entries = []; + const limit = Math.min(keys.length, MAX_ENTRIES); + for (let i = 0; i < limit; i++) { + const key = keys[i]; + // Inspecting a property must never leak an error into the next + // evaluation. Some getters (e.g. TurboModule-backed APIs that aren't + // registered in the Fantom binary) don't throw synchronously: Metro + // routes the error through `ErrorUtils` (into `pendingError`) and the + // getter returns `undefined`. Detect both cases, surface the error in + // the property's slot, and always restore the captured-error state. + const errorBefore = pendingError; + let rendered; + try { + const propertyValue = readProperty(value, key); + if (pendingError !== errorBefore) { + rendered = color( + COLORS.error, + `[Thrown: ${describeError(pendingError)}]`, + ); + } else { + rendered = inspect(propertyValue, childDepth, seen, childIndent); + } + } catch (error) { + rendered = color(COLORS.error, `[Thrown: ${describeError(error)}]`); + } + pendingError = errorBefore; + entries.push(formatKey(key) + ': ' + rendered); + } + if (keys.length > MAX_ENTRIES) { + entries.push(`... ${keys.length - MAX_ENTRIES} more item(s)`); + } + result = wrapEntries('{', '}', prefix, entries, indent); + } + + seen.delete(value); + return result; +} + +function inspect( + value: unknown, + depth: number, + seen: Set, + indent: string, +): string { + switch (typeof value) { + case 'string': + return color(COLORS.string, quoteString(value)); + case 'number': + return color(COLORS.number, Object.is(value, -0) ? '-0' : String(value)); + case 'boolean': + return color(COLORS.boolean, String(value)); + case 'bigint': + return color(COLORS.number, String(value) + 'n'); + case 'undefined': + return color(COLORS.nullish, 'undefined'); + case 'symbol': + return color(COLORS.symbol, value.toString()); + case 'function': { + const name = typeof value.name === 'string' ? value.name : ''; + if (isClassFunction(value)) { + return color( + COLORS.fn, + name !== '' ? `[class ${name}]` : '[class (anonymous)]', + ); + } + return color( + COLORS.fn, + name !== '' ? `[Function: ${name}]` : '[Function (anonymous)]', + ); + } + case 'object': { + if (value === null) { + return color(COLORS.nullish, 'null'); + } + return inspectObject(value, depth, seen, indent); + } + default: + return String(value); + } +} + +function formatValue(value: unknown): string { + // Inspecting must never change the captured-error state used to report + // genuine evaluation errors (getters probed here may set it as a side effect). + const errorBefore = pendingError; + try { + return inspect(value, INSPECT_DEPTH, new Set(), ''); + } catch { + return String(value); + } finally { + pendingError = errorBefore; + } +} + +function reportJSON(message: interface {}): void { + const json = JSON.stringify(message); + if (json == null) { + return; + } + // Force the import of the native module to be lazy. + const NativeFantom = + require('react-native/src/private/testing/fantom/specs/NativeFantom').default; + NativeFantom.reportTestSuiteResultsJSON(json); +} + +// Walks the prototype chain collecting own property names (own + inherited), +// without reading any values (so getters are never invoked). +function collectPropertyNames(target: unknown): Set { + const names = new Set(); + let current: unknown = target; + let depth = 0; + while (current != null && typeof current === 'object' && depth < 50) { + for (const name of Object.getOwnPropertyNames(current)) { + names.add(name); + } + current = Object.getPrototypeOf(current); + depth++; + } + return names; +} + +// Called by the host to compute autocompletion candidates. `thunk` is either +// null (complete global identifiers + REPL scope bindings) or a function that +// returns the object whose properties should be completed. +global.$$ReplComplete$$ = (thunk: ?() => unknown): void => { + // Completion must never affect the error state of real evaluations. + const errorBefore = pendingError; + const names = new Set(); + try { + if (thunk == null) { + for (const name of collectPropertyNames(globalThis)) { + names.add(name); + } + for (const key of Object.keys(global.$$REPL_SCOPE$$)) { + names.add(key); + } + } else { + const target = thunk(); + if (target != null) { + for (const name of collectPropertyNames(Object(target))) { + names.add(name); + } + } + } + } catch { + // No completions available (e.g. the expression threw). + } + pendingError = errorBefore; + + const candidates = []; + for (const name of names) { + if (!name.startsWith('$$Repl') && !name.startsWith('$$REPL')) { + candidates.push(name); + } + } + reportJSON({type: 'repl-completions', names: candidates}); +}; + +global.$$ReplReport$$ = (): void => { + const error = pendingError; + if (error != null) { + pendingError = null; + global.$$REPL_HAS_RESULT$$ = false; + global.$$REPL_RESULT$$ = undefined; + reportJSON({ + type: 'repl-error', + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error && error.stack != null ? error.stack : '', + }); + return; + } + + if (global.$$REPL_HAS_RESULT$$ !== true) { + return; + } + + const text = formatValue(global.$$REPL_RESULT$$); + global.$$REPL_HAS_RESULT$$ = false; + global.$$REPL_RESULT$$ = undefined; + reportJSON({type: 'repl-result', text}); +}; diff --git a/private/react-native-fantom/tester/src/AppSettings.cpp b/private/react-native-fantom/tester/src/AppSettings.cpp index 58c19298a05..e588442d341 100644 --- a/private/react-native-fantom/tester/src/AppSettings.cpp +++ b/private/react-native-fantom/tester/src/AppSettings.cpp @@ -17,6 +17,11 @@ DEFINE_uint32(windowWidth, DEFAULT_WINDOW_WIDTH, "Application window width"); DEFINE_uint32(windowHeight, DEFAULT_WINDOW_HEIGHT, "Application window height"); DEFINE_string(bundlePath, "", "Default path to the application's bundle"); DEFINE_uint32(inspectorPort, 0, "React Native inspector port"); +DEFINE_bool( + interactive, + false, + "Load the bundle as a warm-up environment (without running tests) and then " + "read JS snippets to evaluate from stdin (REPL mode)"); DEFINE_string( featureFlags, "", @@ -32,6 +37,7 @@ unsigned int AppSettings::windowWidth{DEFAULT_WINDOW_WIDTH}; unsigned int AppSettings::windowHeight{DEFAULT_WINDOW_HEIGHT}; std::string AppSettings::defaultBundlePath{}; std::optional AppSettings::inspectorPort{}; +bool AppSettings::interactive{false}; std::optional AppSettings::dynamicFeatureFlags; int AppSettings::minLogLevel{google::GLOG_INFO}; @@ -61,6 +67,8 @@ void AppSettings::initInternal() { inspectorPort = FLAGS_inspectorPort; } + interactive = FLAGS_interactive; + if (!FLAGS_minLogLevel.empty()) { if (FLAGS_minLogLevel == "info") { minLogLevel = google::GLOG_INFO; diff --git a/private/react-native-fantom/tester/src/AppSettings.h b/private/react-native-fantom/tester/src/AppSettings.h index ea2279ae8b4..0b5c7e2fb43 100644 --- a/private/react-native-fantom/tester/src/AppSettings.h +++ b/private/react-native-fantom/tester/src/AppSettings.h @@ -23,6 +23,11 @@ class AppSettings { static std::optional inspectorPort; + // When true, the tester loads the bundle as a warm-up environment (without + // running tests) and then reads JS snippets to evaluate from stdin, acting + // as an interactive REPL. See `fantom-cli`. + static bool interactive; + static std::optional dynamicFeatureFlags; static void init(int argc, char *argv[]); diff --git a/private/react-native-fantom/tester/src/TesterAppDelegate.cpp b/private/react-native-fantom/tester/src/TesterAppDelegate.cpp index a35394dbe50..544deeebf4f 100644 --- a/private/react-native-fantom/tester/src/TesterAppDelegate.cpp +++ b/private/react-native-fantom/tester/src/TesterAppDelegate.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,7 @@ #include #include #include +#include #include namespace facebook::react { @@ -60,6 +62,14 @@ void reportConsoleLog(const std::string& message, unsigned int logLevel) { log["message"] = message; std::cout << folly::toJson(log) << std::endl; } + +void reportReplError(const std::string& message, const std::string& stack) { + folly::dynamic error = folly::dynamic::object(); + error["type"] = "repl-error"; + error["message"] = message; + error["stack"] = stack; + std::cout << folly::toJson(error) << std::endl; +} } // namespace TesterAppDelegate::TesterAppDelegate( @@ -161,20 +171,93 @@ void TesterAppDelegate::loadScript( LOG(INFO) << "Loading script: " << bundlePath << " source " << sourcePath; reactHost_->loadScript(bundlePath, sourcePath); - jsi::Runtime* runtimePtr = nullptr; reactHost_->runOnRuntimeScheduler( - [&runtimePtr](jsi::Runtime& runtime) { runtimePtr = &runtime; }); + [this](jsi::Runtime& runtime) { runtime_ = &runtime; }); - // Run JS code to copy out pointer to the runtime to `runtimePtr`. + // Run JS code to copy out pointer to the runtime to `runtime_`. flushMessageQueue(); +} + +void TesterAppDelegate::loadScriptAndRunTests( + const std::string& bundlePath, + const std::string& sourcePath) { + loadScript(bundlePath, sourcePath); // Invoke the test function directly, so it happens outside of the runloop - auto func = runtimePtr->global() - .getProperty(*runtimePtr, "$$RunTests$$") - .asObject(*runtimePtr) - .asFunction(*runtimePtr); + auto func = runtime_->global() + .getProperty(*runtime_, "$$RunTests$$") + .asObject(*runtime_) + .asFunction(*runtime_); + + func.call(*runtime_); +} - func.call(*runtimePtr); +void TesterAppDelegate::evaluateInteractiveChunk( + const std::string& source, + const std::string& sourceURL) { + if (runtime_ == nullptr) { + reportReplError("Runtime is not initialized", ""); + return; + } + + try { + runtime_->evaluateJavaScript( + std::make_shared(source), sourceURL); + } catch (jsi::JSError& error) { + reportReplError(error.getMessage(), error.getStack()); + } catch (std::exception& error) { + reportReplError(error.what(), ""); + } + + flushMessageQueue(); +} + +void TesterAppDelegate::runInteractiveLoop() { + std::string countLine; + int evalId = 0; + + while (std::getline(std::cin, countLine)) { + if (countLine.empty()) { + continue; + } + + size_t byteCount = 0; + try { + byteCount = static_cast(std::stoul(countLine)); + } catch (const std::exception&) { + reportReplError("Invalid REPL frame header: " + countLine, ""); + continue; + } + + // Guard against a corrupted/desynced stream requesting a huge allocation. + constexpr size_t kMaxFrameSize = 64ULL * 1024 * 1024; // 64 MiB + if (byteCount > kMaxFrameSize) { + reportReplError("REPL frame too large: " + countLine + " bytes", ""); + break; + } + + std::string source(byteCount, '\0'); + size_t totalRead = 0; + while (totalRead < byteCount && std::cin) { + std::cin.read( + &source[totalRead], + static_cast(byteCount - totalRead)); + totalRead += static_cast(std::cin.gcount()); + } + + if (totalRead < byteCount) { + // stdin closed in the middle of a frame. + break; + } + + int id = evalId++; + evaluateInteractiveChunk(source, ""); + + folly::dynamic done = folly::dynamic::object(); + done["type"] = "repl-eval-complete"; + done["id"] = id; + std::cout << folly::toJson(done) << std::endl; + } } void TesterAppDelegate::openDebugger() const { diff --git a/private/react-native-fantom/tester/src/TesterAppDelegate.h b/private/react-native-fantom/tester/src/TesterAppDelegate.h index 283f8cd79e2..3f5fc28babf 100644 --- a/private/react-native-fantom/tester/src/TesterAppDelegate.h +++ b/private/react-native-fantom/tester/src/TesterAppDelegate.h @@ -39,8 +39,23 @@ class TesterAppDelegate { TesterAppDelegate(TesterAppDelegate &&) = delete; TesterAppDelegate &operator=(TesterAppDelegate &&) = delete; + // Loads the bundle, registering the Metro runtime and warm-up modules. + // Does not run any tests. void loadScript(const std::string &bundlePath, const std::string &sourcePath); + // Loads the bundle and then invokes `$$RunTests$$` to run the tests. + void loadScriptAndRunTests(const std::string &bundlePath, const std::string &sourcePath); + + // Evaluates a single JS snippet in the already-loaded runtime, in global + // scope, and flushes the message queue. Used by interactive (REPL) mode. + void evaluateInteractiveChunk(const std::string &source, const std::string &sourceURL); + + // Reads length-prefixed JS snippets from stdin and evaluates each one until + // stdin is closed. Each frame is `\n` followed by exactly + // `byteCount` bytes of UTF-8 source. Emits a `repl-eval-complete` JSON line + // on stdout after each evaluation. + void runInteractiveLoop(); + void openDebugger() const; void startSurface( @@ -88,6 +103,10 @@ class TesterAppDelegate { std::function onAnimationRender_{nullptr}; + // Non-owning pointer to the JS runtime, captured after the script is loaded. + // Used to evaluate snippets directly (outside the run loop) in REPL mode. + jsi::Runtime *runtime_{nullptr}; + // Owned by the TimerManager (inside the ReactInstance); this is a non-owning // pointer used to drive the deterministic timer mock from JS. FantomTimerRegistry *timerRegistry_{nullptr}; diff --git a/private/react-native-fantom/tester/src/main.cpp b/private/react-native-fantom/tester/src/main.cpp index eacc70cd73b..62cebf694ab 100644 --- a/private/react-native-fantom/tester/src/main.cpp +++ b/private/react-native-fantom/tester/src/main.cpp @@ -70,7 +70,13 @@ int main(int argc, char* argv[]) { std::this_thread::sleep_for(std::chrono::seconds(2)); } - appDelegate.loadScript(AppSettings::defaultBundlePath, ""); + if (AppSettings::interactive) { + appDelegate.loadScript(AppSettings::defaultBundlePath, ""); + appDelegate.runInteractiveLoop(); + return 0; + } + + appDelegate.loadScriptAndRunTests(AppSettings::defaultBundlePath, ""); return 0; } diff --git a/scripts/fantom-cli.sh b/scripts/fantom-cli.sh new file mode 100755 index 00000000000..c5422262024 --- /dev/null +++ b/scripts/fantom-cli.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +set -e + +if [[ -f "BUCK" && -z "$FANTOM_FORCE_OSS_BUILD" ]]; then + export JS_DIR='..' +else + if [[ ! -f "private/react-native-fantom/build/tester/fantom_tester" ]]; then + yarn workspace @react-native/fantom build + fi + export FANTOM_FORCE_OSS_BUILD=1 +fi + +# Match the heap headroom used by `fantom.sh` for the in-process Metro server. +export NODE_OPTIONS='--max-old-space-size=16384' + +node private/react-native-fantom/repl/index.js "$@"