diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8328a5751..e04c4df84 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -98,6 +98,7 @@ jobs: arch: x86_64 profile: pixel_7 avd-name: e2e + disable-animations: false working-directory: apps/tester-federation-v2 script: | adb shell settings put global hide_error_dialogs 1 diff --git a/agent_context/README.md b/agent_context/README.md index 35109711a..73f7e5bee 100644 --- a/agent_context/README.md +++ b/agent_context/README.md @@ -9,3 +9,4 @@ works, and is kept in sync with the implementation as it evolves. | Folder | Topic | | --- | --- | | [rspackv2-jul2026](./rspackv2-jul2026/design.md) | Dual Rspack 1.x/2.x support | +| [e2e-oct2026](./e2e-oct2026/design.md) | e2e tests on an emulator, on CI | diff --git a/agent_context/e2e-oct2026/design.md b/agent_context/e2e-oct2026/design.md new file mode 100644 index 000000000..13c56777a --- /dev/null +++ b/agent_context/e2e-oct2026/design.md @@ -0,0 +1,254 @@ +# e2e tests on an emulator + +End-to-end tests that run the MF2 tester app (`apps/tester-federation-v2`) on a +real emulator or simulator, so a Re.Pack bundling or runtime bug is caught before +merge instead of inside a consumer app. What the current form optimizes for: a +red run means a real problem rather than a flake. That goal shows up below as +concrete rules, not intentions. + +Tooling: [`tester-army/e2e`](https://e2e.tester.army/docs) with +`@e2e-dev/mobile`, built on +[agent-device](https://github.com/callstack/agent-device). Chosen because +deterministic tests need no model credential, which is what lets a fork PR run +the suite at all. Both packages are pinned exact — pre-1.0, APIs move between +minors. Both are pinned without a caret in the `testers` catalog of +`pnpm-workspace.yaml`, so the app manifest alone does not show the pin. + +## Where things live + +| Path | Role | +| --- | --- | +| `apps/tester-federation-v2/e2e/*.e2e.ts` | One file per feature | +| `apps/tester-federation-v2/e2e.config.ts` | Targets, `retries: 0`, `workers: 1` | +| `apps/tester-federation-v2/src/host/FEATURES.ts` | Registry Home renders; one entry = one button. Routes stay hand-listed in `MainNavigator.tsx`; `screen` is typed against them | +| `apps/tester-federation-v2/src/host/ui/` | `Report.tsx` (the one widget screens use to report a value), `ActionButton.tsx` (the button screens share) | +| `apps/tester-federation-v2/src/host/setup.ts` | The chunk resolver; registers the event counters | +| `apps/tester-federation-v2/src/host/features/runtime/events.ts` | The event counters the tests read | +| `.github/workflows/e2e.yml` | Android job (PRs) + iOS job (main, dispatch) | + +`e2e`'s default glob is `tests/**/*.e2e.ts`; this app keeps specs under `e2e/`, +so the config sets `tests` explicitly. Output goes to `.e2e/`, gitignored. + +### Running it locally + +No orchestration script by design — the framework and the developer each already +own booting devices and building apps. Three terminals: + +```bash +# 1. boot an emulator/simulator first — webpack-start only reverses ports for +# devices attached at its start +pnpm start:hostapp # 8081 +pnpm start:miniapp # 8082 +E2E_DEVICE= pnpm exec e2e run --target ios +``` + +`e2e run` without `--target` runs both targets; a file path narrows to one test. +Pin `E2E_DEVICE` — unset, the engine takes every booted device of the platform. +Needs Node `^22.22.3 || >=24.8.0` (CI pins 24.22.1). The app must be installed +(`pnpm android` / `pnpm ios`). + +**Warm the platform bundle before the first run**, on a fresh server: +`curl 'localhost:8081/index.bundle?platform=android&dev=true&lazy=true&…'` (the +iOS job's shape is in the workflow). A cold first launch compiles before it can +boot, that compile eats the `home-screen` anchor's budget, and the failure looks +like a broken app. This bites locally and in CI equally. + +## How the app gets under test + +The app is a Debug build that loads its JavaScript from the two dev servers — +host on 8081, MiniApp remote on 8082 — not from an embedded bundle. `app.json` +points `resources.android` at a directory this app never emits, so there is no +fallback bundle. Three consequences are load-bearing: + +- **Both servers must answer before the app starts.** react-native-test-app + chooses its bundle source once at startup and never retries. If 8081 is down, + Android raises a modal "Unable to find JavaScript bundle" that blocks every + later tap; iOS silently falls back and keeps going. Both read as a test failure + that is really an environment failure — the uploaded `.e2e/` artifacts tell + them apart. +- **Workspace packages build first.** `pnpm --filter tester-federation-v2... + build` is how a PR's Repack change reaches the servers under test. +- **8082 must be reachable as `localhost` from the device.** The remote URL is + baked into the bundle as `http://localhost:8082/…` and Repack's + `__PUBLIC_HOST__` rewrite covers asset public paths and HMR only, never Module + Federation remote URLs. The device needs `adb reverse tcp:8081 tcp:8082`; locally + the servers do this at startup, CI does it explicitly. + +Re.Pack builds a platform lazily, on that platform's first asset request, so +"`/status` answers" has never meant "bundle is built". That is why the workflow +has an explicit warm step: without it the emulator waits on a cold compile after +a tap. + +## What is covered + +One file per feature; every test starts from a fresh app process (`app.open()` +restarts the app), so nothing accumulates between tests. + +| File | What a red run means | Re.Pack surface | +| --- | --- | --- | +| `gallery` | the MiniApp remote fails to install/load/render | MF2 container load over `localhost:8082` | +| `runtime-events` | the resolve/load chain or the hook chain is broken; one test drives it via Gallery, then reads counters + a computed `pipeline: settled` row | all 6 `ScriptManager` events + the 4 non-owning hooks, counted in `features/runtime/events.ts`; each of the 8 driving rows asserts `≥ 1`, `error: 0` is asserted, and `prefetching`/`error` feed the pipeline row | +| `async-chunk` | a host-owned async chunk no longer loads through `ScriptManager` | `import()` + the allowlist resolver. Not `extraChunks`: `OutputPlugin` is off under a dev server and the resolver's `__DEV__` branch sends every chunk to it, so the `local` rule never runs in this Debug lane | +| `remote-chunk` | the prefetch → load cycle broke, or `invalidateScripts` threw | `ScriptManager.prefetchScript` / `invalidateScripts` on host remote chunks. The asserts after invalidate read UI state the screen sets itself, so an invalidate that resolves but clears nothing stays green | +| `miniapp-install` | install / show / hide / uninstall of a host mini-app chunk broke | same APIs, second lifecycle; same invalidate gap as `remote-chunk` | +| `host-assets` | asset rules no longer produce what they claim: inline gate (pic-1 87 KB in, pic-2 119 KB out), folder rules (`localAssets` emitted despite being small, `inlineAssets` inlined), the emitted URI is servable (fetch → 200, port and query string taken from the reported URI, never hardcoded). The `.svg` row asserts nothing, and that is honest: svgr compiles at build time, so a broken rule reddens the whole app at boot, before any assert could run | `assets-loader` (inline gate, per-folder rules), served asset route, `@svgr/webpack` rule | +| `nativewind` | CSS stopped computing: `padding-applied: 16` and `calc-width: 248` are layout numbers, not text checks. The dark-mode round trip reads NativeWind's `useColorScheme()` state, not a resolved `dark:` style, and assumes the device starts in light appearance | `NativeWindPlugin` + the host-scoped JSX retag | +| `reanimated` | the worklet pipeline broke — the width row (`100 → 150 → 200 → 100`) is a shared value driven through `useAnimatedStyle` + `useAnimatedReaction` + `scheduleOnRN` (from `react-native-worklets` — reanimated's `runOnJS` re-export is deprecated), so it only moves if user worklets execute; an untransformed worklet throws at boot | the babel `react-native-worklets/plugin` transform; `ReanimatedPlugin`'s `unstable_disableTransform` only suppresses its own rules, which the new babel-swc-loader makes obsolete | + +Every row on a screen must own a distinct way to fail. That rule deleted the +third host bitmap fixture (`pic_3.jpg`, 58 KB): under the same 90 KB gate as +`pic_1`, so it could only ever fail where `pic_1` already fails. The same rule is +the filter for any future row. + +Navigation is not a subject of its own. The navigator is react-native-screens — +upstream code with upstream tests — and a broken one reddens every test at its +prologue tap anyway. The back-navigation round trip still gets exercised, implied +where a test needs it: `runtime-events` calls `app.back()` and its next step can +only succeed if Home came back. + +## Test conventions + +- **Screens report values, tests hold the expectation.** `` renders `mode: Inlined` on one `Text` that carries the + testID; the test compares it against what the build should have produced. + Never print `ok`, and never hardcode a label the feature could contradict. +- **No global-counter asserts.** Any background load bumps a global counter, so + `≥ 1` passes whatever the feature under test does. Counters live only on the + Runtime Events screen, where they are the subject; elsewhere the proof of a + load is the loaded content appearing. +- **No branching on app state inside tests.** A loop over a fixed list of named + rows (the event reports in `runtime-events`) is fine; an `if` on what the app + currently shows is not — it hides which branch actually ran. +- **Timeouts: defaults first.** Test 120 s, assertion 5 s, `scrollUntilVisible` + 30 s. An extension needs a named physical cause and belongs at the assert — a + test-level number hides which step stalled. The overrides: + **30 s on the boot anchor (`home-screen` after `app.open()`) in every file** — + `app.open()` returns when the window draws, not when the first frame renders; + after that the app still downloads and evaluates the whole dev bundle, which + on a cold process takes over 10 s on the CI Android emulator (an emulated + device sharing a small runner with the dev servers) and only a few seconds on + the iOS simulator, which runs natively on the host CPU. A test that follows a + warm run can pass at 5 s, so the default hides the problem until a run starts + cold; 30 s covers the cold boot at twice the measured worst case. 45 s on + `miniapp-gallery` in `gallery` and `runtime-events` (each file pays the + manifest + container + chunk chain itself); 30 s on `pipeline: settled` (the + load chain's own ceiling is `Script.DEFAULT_TIMEOUT`). Note rspack compiles + the whole graph in one run: lazy chunks are memory files the moment the main + bundle is served, so chunk asserts are network + execute, never "compile + waits". +- **testIDs:** `home--button`, `-screen`, + `--report`, `--button`. +- **Assert app state, never the absence of a warning.** +- **Break each test once and watch it go red.** Say so in the PR. A test nobody + saw fail proves nothing. + +### Adding a test + +1. `FEATURES.ts`: one entry. `screen` is typed against the navigator, so a + missing route is a typecheck error. +2. `MainNavigator.tsx`: param-list key + ``. +3. The screen: root `testID="-screen"`, and one `` row per + value, `testID="--report"`, `name` the bare noun. +4. `e2e/.e2e.ts`: copy the prologue verbatim — `app.open()`, assert + `home-screen`, `scrollUntilVisible(entry)` (Home is a ScrollView; a bare tap + on an off-screen row silently misses), `tap`, assert the screen — then only + your asserts. No helpers, deliberately: each file reads standalone. +5. Break it once: invert the feature in the config or the screen, watch exactly + your assert go red, revert. + +## The CI lane + +Android gates `pull_request`; iOS runs on `push` to `main` and +`workflow_dispatch`. Plain `pull_request` rather than `pull_request_target`: the +job reads no secrets, needs only `contents: read`, and the default merge-ref +checkout means a PR tests its own `e2e.yml`. The gate is GitHub's own fork +approval, which is per push. Path filters keep docs-only PRs off emulators. +Fork runs get a read-only cache token from the platform, so they can restore +`main`'s caches but not poison them. + +Steps: setup → build packages → start servers + wait `/status` → warm both +compilers → check manifest shared versions are real semver → build the app → +device { reverse ×2, install, run } → collect logs → artifacts. Lines worth +knowing about: + +| Line | Defends against | +| --- | --- | +| `timeout 180 … until /status` | app launching mid-server-boot → the never-retry bundle alert | +| the two warm `curl`s | a cold compile landing on the device's critical path | +| the semver manifest check | `catalog:` specifiers leaking into manifest shared versions, at the source | +| `adb reverse` ×2, asserted | CI starts servers before the emulator exists, and the server's own reverse does not self-heal | +| `nohup` + `disown` | the runner kills the step's process group at step end | +| install + run inside the emulator action's `script` | the action kills the emulator when the step ends | +| ANR-dialog clear loop before the run | a launcher "isn't responding" dialog swallows every tap; `hide_error_dialogs` does not suppress it | +| `disable-animations: false` | the flag writes `transition_animation_scale` 0.0, which RN and Reanimated both read as "reduce motion on"; the dev warning's LogBox container then eats taps on Home's last row | +| `include-hidden-files: true` on artifacts | `upload-artifact` matching zero files for the dot-dir `.e2e` and still reporting success | +| `if: ${{ !cancelled() }}` on artifacts | losing evidence from a test that failed then passed | +| always tail the server logs | they live outside `.e2e` and are the only evidence that survives a broken upload | + +## Debugging a red run + +Evidence, in order of usefulness: `.e2e/results//trace.md` (step-by-step, +with the full node dump at failure), `attempt-*/screenshots/`, +`screen-at-failure.txt`, `logcat.txt` / `ios-syslog.txt`, both dev-server logs — +all uploaded as the `e2e-android` / `e2e-ios` artifact even when the run dies +before tests start. + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `home-screen` never visible, first test of the day | cold dev-server compile eating the boot anchor's budget on first launch | warm the platform bundle (curl, above) | +| `home-screen` never visible, screenshot is the status bar / launcher | something owns the screen: ANR dialog, toast | the workflow's ANR clear; locally dismiss and rerun | +| taps do nothing, screen dump says "unchanged" | a toast covers the control — CI screens are ~15 % smaller than devices, so it may not reproduce locally | screenshot proves it; suppress the warning at its source, never with `ignoreAllLogs` | +| everything passes on iOS, fails on Android only | the local emulator is `arm64-v8a`, docs habit is `-PreactNativeArchitectures=x86_64` → `INSTALL_FAILED_NO_MATCHING_ABIS`; or a restored AVD snapshot rolled the APK back | build for `arm64-v8a`; compare `dumpsys package ` `lastUpdateTime` against the APK on disk | +| Gallery hangs, nothing loads | 8082 never compiled, or its reverse is missing | the warm step / `adb reverse --list` | +| `404 to be 200` on the asset fetch | the served-asset route needs the query string (`?platform=…&hash=…`) — path alone 404s | the test keeps `pathname + search`; if the URL shape changed, that is the regression | +| `Property '__X__' doesn't exist` redbox | `DefinePlugin` only applies at server start | restart the server after config edits | +| the engine reads an empty/stale screen tree while `adb shell uiautomator dump` sees everything | the engine's snapshot helper is wedged | restart the emulator; reinstall `com.callstack.agentdevice.*` helpers if it persists | + +## Traps worth remembering + +- **Tapping `ScriptManager.hooks.resolve`/`hooks.load` replaces the built-in + loop**, not decorates it — a counter tap that returns `undefined` makes the + manager resolve nothing. Counters only tap the 4 non-owning hooks; the events + cover what `resolve`/`load` would have. +- **`extraChunks.include` is matched against chunk names and the numeric chunk + id** (`OutputPlugin` flattens both). An unanchored pattern can catch other + chunks whose names contain it, and a near-miss like `/.+local.+/` does not + match `host-async-local` at all, so the chunk silently falls to the catch-all + remote rule. The rule is `/^host-async-local$/`. It only applies to builds + without a dev server; the e2e lane does not exercise it. +- **The ignored LogBox string is load-bearing**: NativeWind's runtime reads RN's + deprecated `ImageBackground` getter at boot + (`react-native-css-interop/components.js`) though no screen uses it, and the + toast covers Home's last row and steals taps. A NativeWind upgrade can change + the wording; the toast reappearing is the symptom. +- **The shared `babel.config.js` serves all three compilations** (host, mini, + expo widget — all set `env.context` to the app root). A host-only JSX retag + goes in `overrides` with a regex (string `test` resolves against the CLI cwd); + adding it at top level retags `src/mini` too and breaks the remote that + `gallery` and `runtime-events` load (`miniapp-install` is a host-owned chunk, + not that remote). The worklets transform must stay top level: + `react-native-worklets` ships compiled code that still carries `'worklet'` + directives, and host-scoping it leaves the boot red. +- **`NativeWindPlugin`'s JSX half is a silent no-op** with + `@callstack/repack/babel-swc-loader` (it only matches rules named + `builtin:swc-loader`); the operative path is babel detecting + `transform-react-jsx` and feeding `importSource` to swc. +- **iOS layout numbers are device-dependent** (393 pt vs 402 pt). Assert a + difference, not a width: `padding-applied: 16` holds everywhere, a raw box + width does not. Exact pixel quality is out of scope entirely — no assertion + worth maintaining. +- **iOS CI pods need Ruby ≥ 3.4** via `ruby/setup-ruby` with `working-directory` + (`gemfile:` is not an input — the action warns, then silently caches nothing). + The floor exists because of a CocoaPods null-byte crash on pnpm-symlinked + `node_modules`; CocoaPods remains intermittent there, `bundle update + cocoapods` is the candidate fix. +- **Native module changes need a rebuild on both platforms**, and iOS may need + `pod install` twice — the first pass can symlink headers before codegen emits + `rnworklets/rnworklets.h`. Same trap as the emulator snapshot: the JS bundle + looks correct while the native side is old. +- **`app.open()` restarts the app** (terminate + relaunch), so module-scope + counters reset per test — event assertions avoid exact totals only because + background loads inside one test also bump them. +- Actions are pinned to SHAs verified against the GitHub API (note + `android-emulator-runner`'s tag is annotated — dereference it). Node matches + `release.yml`. `.github/actions/setup-pnpm-node` is reused, not re-derived. diff --git a/apps/tester-federation-v2/babel.config.js b/apps/tester-federation-v2/babel.config.js index d4461aec7..dabc63302 100644 --- a/apps/tester-federation-v2/babel.config.js +++ b/apps/tester-federation-v2/babel.config.js @@ -1,4 +1,18 @@ module.exports = { presets: ['module:@react-native/babel-preset'], + plugins: ['react-native-worklets/plugin'], comments: true, + overrides: [ + { + // Regex instead of a relative string: babel resolves strings against the + // launch cwd, and the rspack CLI launches from the repo root. + test: /[\\/]src[\\/]host[\\/]/, + plugins: [ + [ + '@babel/plugin-transform-react-jsx', + { runtime: 'automatic', importSource: 'nativewind' }, + ], + ], + }, + ], }; diff --git a/apps/tester-federation-v2/configs/hostAssetRules.mts b/apps/tester-federation-v2/configs/hostAssetRules.mts new file mode 100644 index 000000000..b53edc3d8 --- /dev/null +++ b/apps/tester-federation-v2/configs/hostAssetRules.mts @@ -0,0 +1,44 @@ +import path from 'node:path'; +import * as Repack from '@callstack/repack'; + +const BITMAP_EXTENSIONS = Repack.getAssetExtensionsRegExp( + Repack.ASSET_EXTENSIONS.filter((ext) => ext !== 'svg') +); + +export function buildHostAssetRules(context: string) { + const localAssets = path.join(context, 'src/host/assets/localAssets'); + const inlineAssets = path.join(context, 'src/host/assets/inlineAssets'); + + return [ + { + test: BITMAP_EXTENSIONS, + exclude: [localAssets, inlineAssets], + use: { + loader: '@callstack/repack/assets-loader', + options: { inline: true, maxInlineSize: 90 * 1024 }, + }, + }, + { + test: /\.svg$/, + use: [ + { + loader: '@svgr/webpack', + options: { native: true, dimensions: false }, + }, + ], + }, + { + test: BITMAP_EXTENSIONS, + include: [localAssets], + use: '@callstack/repack/assets-loader', + }, + { + test: BITMAP_EXTENSIONS, + include: [inlineAssets], + use: { + loader: '@callstack/repack/assets-loader', + options: { inline: true }, + }, + }, + ]; +} diff --git a/apps/tester-federation-v2/configs/rspack.host-app.mts b/apps/tester-federation-v2/configs/rspack.host-app.mts index 138dbf24c..0fcd4313f 100644 --- a/apps/tester-federation-v2/configs/rspack.host-app.mts +++ b/apps/tester-federation-v2/configs/rspack.host-app.mts @@ -1,6 +1,9 @@ import * as Repack from '@callstack/repack'; +import { NativeWindPlugin } from '@callstack/repack-plugin-nativewind'; +import { ReanimatedPlugin } from '@callstack/repack-plugin-reanimated'; import rspack from '@rspack/core'; +import { buildHostAssetRules } from './hostAssetRules.mts'; import { buildSharedSingletons } from './sharedSingletons.mts'; export default Repack.defineRspackConfig((env) => { @@ -28,15 +31,19 @@ export default Repack.defineRspackConfig((env) => { }, type: 'javascript/auto', }, - ...Repack.getAssetTransformRules({ - inline: true, - maxInlineSize: 90 * 1024, - }), + ...buildHostAssetRules(context!), ], }, plugins: [ + new NativeWindPlugin({ + cssInteropOptions: { inlineRem: 16 }, + }), new Repack.RepackPlugin({ extraChunks: [ + { + include: /^host-async-local$/, + type: 'local', + }, { include: /.*/, type: 'remote', @@ -62,6 +69,7 @@ export default Repack.defineRspackConfig((env) => { process.env.WITH_PRELOAD === 'true' || process.env.WITH_PRELOAD === '1', }), + new ReanimatedPlugin({ unstable_disableTransform: true }), ], }; }); diff --git a/apps/tester-federation-v2/configs/webpack.host-app.mts b/apps/tester-federation-v2/configs/webpack.host-app.mts index 45ac79aed..437a0afea 100644 --- a/apps/tester-federation-v2/configs/webpack.host-app.mts +++ b/apps/tester-federation-v2/configs/webpack.host-app.mts @@ -1,7 +1,10 @@ // @ts-check import * as Repack from '@callstack/repack'; +import { NativeWindPlugin } from '@callstack/repack-plugin-nativewind'; +import { ReanimatedPlugin } from '@callstack/repack-plugin-reanimated'; import webpack from 'webpack'; +import { buildHostAssetRules } from './hostAssetRules.mts'; import { buildSharedSingletons } from './sharedSingletons.mts'; export default Repack.defineWebpackConfig((env) => { @@ -25,13 +28,19 @@ export default Repack.defineWebpackConfig((env) => { use: '@callstack/repack/babel-swc-loader', type: 'javascript/auto', }, - ...Repack.getAssetTransformRules(), + ...buildHostAssetRules(context!), ], }, plugins: [ - // @ts-expect-error + new NativeWindPlugin({ + cssInteropOptions: { inlineRem: 16 }, + }), new Repack.RepackPlugin({ extraChunks: [ + { + include: /^host-async-local$/, + type: 'local', + }, { include: /.*/, type: 'remote', @@ -39,7 +48,6 @@ export default Repack.defineWebpackConfig((env) => { }, ], }), - // @ts-expect-error new Repack.plugins.ModuleFederationPluginV2({ name: 'HostApp', filename: 'HostApp.container.js.bundle', @@ -58,6 +66,7 @@ export default Repack.defineWebpackConfig((env) => { process.env.WITH_PRELOAD === 'true' || process.env.WITH_PRELOAD === '1', }), + new ReanimatedPlugin({ unstable_disableTransform: true }), ], }; }); diff --git a/apps/tester-federation-v2/configs/webpack.mini-app.mts b/apps/tester-federation-v2/configs/webpack.mini-app.mts index 59e14fde9..05a9378c8 100644 --- a/apps/tester-federation-v2/configs/webpack.mini-app.mts +++ b/apps/tester-federation-v2/configs/webpack.mini-app.mts @@ -28,7 +28,6 @@ export default Repack.defineWebpackConfig((env) => { ], }, plugins: [ - // @ts-expect-error new Repack.RepackPlugin({ extraChunks: [ { @@ -38,7 +37,6 @@ export default Repack.defineWebpackConfig((env) => { }, ], }), - // @ts-expect-error new Repack.plugins.ModuleFederationPluginV2({ name: 'MiniApp', filename: 'MiniApp.container.js.bundle', diff --git a/apps/tester-federation-v2/e2e/async-chunk.e2e.ts b/apps/tester-federation-v2/e2e/async-chunk.e2e.ts new file mode 100644 index 000000000..7f268f751 --- /dev/null +++ b/apps/tester-federation-v2/e2e/async-chunk.e2e.ts @@ -0,0 +1,19 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +test('Async chunk loads through ScriptManager and renders', async ({ + app, + screen, +}) => { + await app.open(); + await expect(screen.getByTestId('home-screen')).toBeVisible({ + timeout: 30_000, + }); + + const entry = screen.getByTestId('home-async-chunk-button'); + await screen.scrollUntilVisible(entry); + await entry.tap(); + await expect(screen.getByTestId('async-chunk-screen')).toBeVisible(); + + await expect(screen.getByTestId('async-chunk-content')).toBeVisible(); +}); diff --git a/apps/tester-federation-v2/e2e/gallery.e2e.ts b/apps/tester-federation-v2/e2e/gallery.e2e.ts new file mode 100644 index 000000000..b5e0b1ee3 --- /dev/null +++ b/apps/tester-federation-v2/e2e/gallery.e2e.ts @@ -0,0 +1,21 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +test('Gallery MiniApp loads and renders its own navigator', async ({ + app, + screen, +}) => { + await app.open(); + await expect(screen.getByTestId('home-screen')).toBeVisible({ + timeout: 30_000, + }); + + const entry = screen.getByTestId('home-gallery-button'); + await screen.scrollUntilVisible(entry); + await entry.tap(); + + await expect(screen.getByTestId('miniapp-gallery')).toBeVisible({ + timeout: 45_000, + }); + await expect(screen.getByText('Picture 1')).toBeVisible(); +}); diff --git a/apps/tester-federation-v2/e2e/host-assets.e2e.ts b/apps/tester-federation-v2/e2e/host-assets.e2e.ts new file mode 100644 index 000000000..27e6ff7b6 --- /dev/null +++ b/apps/tester-federation-v2/e2e/host-assets.e2e.ts @@ -0,0 +1,44 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +test('Host Assets screen renders', async ({ app, screen }) => { + await app.open(); + await expect(screen.getByTestId('home-screen')).toBeVisible({ + timeout: 30_000, + }); + + const entry = screen.getByTestId('home-host-assets-button'); + await screen.scrollUntilVisible(entry); + await entry.tap(); + await expect(screen.getByTestId('host-assets-screen')).toBeVisible(); + + const pic1Mode = screen.getByTestId('host-asset-pic-1-mode-report'); + const pic2Mode = screen.getByTestId('host-asset-pic-2-mode-report'); + const localMode = screen.getByTestId('host-asset-local-mode-report'); + const inlineMode = screen.getByTestId('host-asset-inline-mode-report'); + const localUri = screen.getByTestId('host-asset-local-uri-report'); + + await screen.scrollUntilVisible(pic1Mode); + await expect(pic1Mode).toHaveText('mode: Inlined'); + await screen.scrollUntilVisible(pic2Mode); + await expect(pic2Mode).toHaveText('mode: Emitted'); + await screen.scrollUntilVisible(localMode); + await expect(localMode).toHaveText('mode: Emitted'); + await screen.scrollUntilVisible(inlineMode); + await expect(inlineMode).toHaveText('mode: Inlined'); + + await screen.scrollUntilVisible(localUri); + const uriText = (await localUri.textContent()) ?? ''; + const assetUrl = new URL(uriText.slice('uri: '.length)); + expect(assetUrl.protocol).toBe('http:'); + expect(assetUrl.port).toBe('8081'); + // Android resolves the dev server as 10.0.2.2 and iOS as localhost + expect(['localhost', '10.0.2.2']).toContain(assetUrl.hostname); + + const probeHost = + assetUrl.hostname === '10.0.2.2' ? 'localhost' : assetUrl.hostname; + const response = await fetch( + `${assetUrl.protocol}//${probeHost}:${assetUrl.port}${assetUrl.pathname}${assetUrl.search}` + ); + expect(response.status).toBe(200); +}); diff --git a/apps/tester-federation-v2/e2e/miniapp-install.e2e.ts b/apps/tester-federation-v2/e2e/miniapp-install.e2e.ts new file mode 100644 index 000000000..670eb5a1a --- /dev/null +++ b/apps/tester-federation-v2/e2e/miniapp-install.e2e.ts @@ -0,0 +1,34 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +test('install, show, and remove the host mini-app chunk', async ({ + app, + screen, +}) => { + await app.open(); + await expect(screen.getByTestId('home-screen')).toBeVisible({ + timeout: 30_000, + }); + + const entry = screen.getByTestId('home-miniapp-install-button'); + await screen.scrollUntilVisible(entry); + await entry.tap(); + await expect(screen.getByTestId('miniapp-install-screen')).toBeVisible(); + + await screen.getByTestId('miniapp-install-button').tap(); + await expect(screen.getByTestId('miniapp-install-button')).toBeDisabled(); + + await screen.getByTestId('miniapp-toggle-button').tap(); + await expect(screen.getByTestId('miniapp-install-content')).toBeVisible(); + + await screen.getByTestId('miniapp-toggle-button').tap(); + await expect(screen.getByTestId('miniapp-install-content')).toBeHidden(); + + await screen.getByTestId('miniapp-toggle-button').tap(); + await expect(screen.getByTestId('miniapp-install-content')).toBeVisible(); + + await screen.getByTestId('miniapp-uninstall-button').tap(); + await expect(screen.getByTestId('miniapp-install-content')).toBeHidden(); + await expect(screen.getByTestId('miniapp-install-button')).toBeEnabled(); + await expect(screen.getByTestId('miniapp-toggle-button')).toBeDisabled(); +}); diff --git a/apps/tester-federation-v2/e2e/nativewind.e2e.ts b/apps/tester-federation-v2/e2e/nativewind.e2e.ts new file mode 100644 index 000000000..8e7a6decc --- /dev/null +++ b/apps/tester-federation-v2/e2e/nativewind.e2e.ts @@ -0,0 +1,30 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +test('NativeWind styles render, respond and switch to dark', async ({ + app, + screen, +}) => { + await app.open(); + await expect(screen.getByTestId('home-screen')).toBeVisible({ + timeout: 30_000, + }); + + const entry = screen.getByTestId('home-nativewind-button'); + await screen.scrollUntilVisible(entry); + await entry.tap(); + await expect(screen.getByTestId('nativewind-screen')).toBeVisible(); + + const padding = screen.getByTestId('nativewind-padding-report'); + await expect(padding).toHaveText('padding-applied: 16'); + + const calcWidth = screen.getByTestId('nativewind-calc-width-report'); + await expect(calcWidth).toHaveText('calc-width: 248'); + + const colorScheme = screen.getByTestId('nativewind-color-scheme-report'); + await expect(colorScheme).toHaveText('color-scheme: light'); + await screen.getByTestId('nativewind-dark-mode-button').tap(); + await expect(colorScheme).toHaveText('color-scheme: dark'); + await screen.getByTestId('nativewind-dark-mode-button').tap(); + await expect(colorScheme).toHaveText('color-scheme: light'); +}); diff --git a/apps/tester-federation-v2/e2e/reanimated.e2e.ts b/apps/tester-federation-v2/e2e/reanimated.e2e.ts new file mode 100644 index 000000000..9f705c5af --- /dev/null +++ b/apps/tester-federation-v2/e2e/reanimated.e2e.ts @@ -0,0 +1,26 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +test('Reanimated spring tracks taps and reset', async ({ app, screen }) => { + await app.open(); + await expect(screen.getByTestId('home-screen')).toBeVisible({ + timeout: 30_000, + }); + + const entry = screen.getByTestId('home-reanimated-button'); + await screen.scrollUntilVisible(entry); + await entry.tap(); + await expect(screen.getByTestId('reanimated-screen')).toBeVisible(); + + const width = screen.getByTestId('reanimated-width-report'); + await expect(width).toHaveText('width: 100'); + + await screen.getByTestId('reanimated-click-button').tap(); + await expect(width).toHaveText('width: 150'); + + await screen.getByTestId('reanimated-click-button').tap(); + await expect(width).toHaveText('width: 200'); + + await screen.getByTestId('reanimated-reset-button').tap(); + await expect(width).toHaveText('width: 100'); +}); diff --git a/apps/tester-federation-v2/e2e/remote-chunk.e2e.ts b/apps/tester-federation-v2/e2e/remote-chunk.e2e.ts new file mode 100644 index 000000000..9e10f609d --- /dev/null +++ b/apps/tester-federation-v2/e2e/remote-chunk.e2e.ts @@ -0,0 +1,32 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +test('prefetch, load, and invalidate the host remote chunk', async ({ + app, + screen, +}) => { + await app.open(); + await expect(screen.getByTestId('home-screen')).toBeVisible({ + timeout: 30_000, + }); + + const entry = screen.getByTestId('home-remote-chunk-button'); + await screen.scrollUntilVisible(entry); + await entry.tap(); + await expect(screen.getByTestId('remote-chunk-screen')).toBeVisible(); + + await screen.getByTestId('remote-chunk-prefetch-button').tap(); + await expect( + screen.getByTestId('remote-chunk-prefetch-button') + ).toBeDisabled(); + + await screen.getByTestId('remote-chunk-load-button').tap(); + await expect(screen.getByTestId('remote-chunk-content')).toBeVisible(); + + await screen.getByTestId('remote-chunk-invalidate-button').tap(); + await expect(screen.getByTestId('remote-chunk-content')).toBeHidden(); + await expect(screen.getByTestId('remote-chunk-load-button')).toBeVisible(); + await expect( + screen.getByTestId('remote-chunk-prefetch-button') + ).toBeEnabled(); +}); diff --git a/apps/tester-federation-v2/e2e/runtime-events.e2e.ts b/apps/tester-federation-v2/e2e/runtime-events.e2e.ts new file mode 100644 index 000000000..2584ca3d9 --- /dev/null +++ b/apps/tester-federation-v2/e2e/runtime-events.e2e.ts @@ -0,0 +1,51 @@ +import { test } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; + +const EVENTS = [ + 'resolving', + 'resolved', + 'loading', + 'loaded', + 'beforeResolve', + 'afterResolve', + 'beforeLoad', + 'afterLoad', +]; + +test('opening the mini app drives every event and hook', async ({ + app, + screen, +}) => { + await app.open(); + await expect(screen.getByTestId('home-screen')).toBeVisible({ + timeout: 30_000, + }); + + const gallery = screen.getByTestId('home-gallery-button'); + await screen.scrollUntilVisible(gallery); + await gallery.tap(); + await expect(screen.getByTestId('miniapp-gallery')).toBeVisible({ + timeout: 45_000, + }); + + await app.back(); + const runtime = screen.getByTestId('home-runtime-events-button'); + await screen.scrollUntilVisible(runtime); + await runtime.tap(); + await expect(screen.getByTestId('runtime-events-screen')).toBeVisible(); + + for (const event of EVENTS) { + await expect( + screen.getByTestId(`runtime-event-${event}-report`) + ).toHaveText(new RegExp(`^${event}: [1-9]\\d*$`)); + } + + await expect(screen.getByTestId('runtime-event-error-report')).toHaveText( + 'error: 0' + ); + + await expect(screen.getByTestId('runtime-events-pipeline-report')).toHaveText( + 'pipeline: settled', + { timeout: 30_000 } + ); +}); diff --git a/apps/tester-federation-v2/e2e/smoke.e2e.ts b/apps/tester-federation-v2/e2e/smoke.e2e.ts deleted file mode 100644 index 2097ed870..000000000 --- a/apps/tester-federation-v2/e2e/smoke.e2e.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test } from '@e2e-dev/mobile'; -import { expect } from 'e2e'; - -test('the host app boots to Home', async ({ app, screen }) => { - await app.open(); - - await expect(screen.getByTestId('home-details-button')).toBeVisible({ - timeout: 60_000, - }); - await expect(screen.getByTestId('home-gallery-button')).toBeVisible(); -}); - -test('navigates from Home to Detail and back', async ({ app, screen }) => { - await app.open(); - await expect(screen.getByTestId('home-details-button')).toBeVisible({ - timeout: 60_000, - }); - - await screen.getByTestId('home-details-button').tap(); - await expect(screen.getByTestId('detail-screen')).toBeVisible(); - - await app.back(); - await expect(screen.getByTestId('home-details-button')).toBeVisible(); - await expect(screen.getByTestId('detail-screen')).toBeHidden(); -}); - -test('Home opens the Gallery MiniApp', { - timeout: 120_000, -}, async ({ app, screen }) => { - await app.open(); - await expect(screen.getByTestId('home-gallery-button')).toBeVisible({ - timeout: 60_000, - }); - - await screen.getByTestId('home-gallery-button').tap(); - await expect(screen.getByTestId('miniapp-gallery')).toBeVisible({ - timeout: 45_000, - }); - await expect(screen.getByText('Picture 1')).toBeVisible(); -}); diff --git a/apps/tester-federation-v2/global.css b/apps/tester-federation-v2/global.css new file mode 100644 index 000000000..14df9aa59 --- /dev/null +++ b/apps/tester-federation-v2/global.css @@ -0,0 +1,26 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.custom-style-test { + color: mediumpurple; +} + +.calc-element { + width: calc(var(--my-variable) - (20px + 2rem)); +} + +:root { + --H: 200; + --S: 50%; + --L: 40%; + --my-variable: 300px; +} + +.color-element { + background-color: hsl( + calc(var(--H) + 20), + calc(var(--S) - 10%), + calc(var(--L) + 30%) + ); +} diff --git a/apps/tester-federation-v2/ios/Podfile.lock b/apps/tester-federation-v2/ios/Podfile.lock index cf9ca48e6..b471e7911 100644 --- a/apps/tester-federation-v2/ios/Podfile.lock +++ b/apps/tester-federation-v2/ios/Podfile.lock @@ -1891,6 +1891,109 @@ PODS: - React-Core - React-jsi - ReactTestApp-Resources (1.0.0-dev) + - RNReanimated (4.7.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNReanimated/apple (= 4.7.1) + - RNReanimated/common (= 4.7.1) + - RNReanimated/view (= 4.7.1) + - RNWorklets + - Yoga + - RNReanimated/apple (4.7.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - RNReanimated/common (4.7.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - RNReanimated/view (4.7.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga - RNScreens (4.30.0): - hermes-engine - RCTRequired @@ -1940,6 +2043,127 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - RNSVG (15.15.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNSVG/common (= 15.15.5) + - Yoga + - RNSVG/common (15.15.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNWorklets (0.13.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets/apple (= 0.13.0) + - RNWorklets/common (= 0.13.0) + - Yoga + - RNWorklets/apple (0.13.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNWorklets/common (0.13.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - SocketRocket (0.7.1): - ReactNativeDependencies - SwiftyRSA (1.7.0): @@ -2040,7 +2264,10 @@ DEPENDENCIES: - "ReactNativeHost (from `../../../node_modules/.pnpm/react-native-test-app@5.4.14_@expo+config-plugins@58.0.9_typescript@7.0.2__@react-nativ_454696e0707abccdd63dcea849d4f6b7/node_modules/@rnx-kit/react-native-host`)" - ReactTestApp-DevSupport (from `../node_modules/react-native-test-app`) - ReactTestApp-Resources (from `..`) + - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) + - RNSVG (from `../node_modules/react-native-svg`) + - RNWorklets (from `../node_modules/react-native-worklets`) - SocketRocket (from `build/rndeps-facades/SocketRocket`) - Yoga (from `build/rncore-facades/Yoga`) @@ -2231,8 +2458,14 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-test-app" ReactTestApp-Resources: :path: ".." + RNReanimated: + :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" + RNSVG: + :path: "../node_modules/react-native-svg" + RNWorklets: + :path: "../node_modules/react-native-worklets" SocketRocket: :path: build/rndeps-facades/SocketRocket Yoga: @@ -2330,7 +2563,10 @@ SPEC CHECKSUMS: ReactNativeHost: 12775fd18bc0260080b2a4196dc0522476dde0ce ReactTestApp-DevSupport: 92d3bc3acb9ddf9f90f96976add4577957be1d90 ReactTestApp-Resources: 88392c1cd85eed4e50cc2798de87b790a9791c7f + RNReanimated: b38c0bcd07f37ce6a7b85baf7ad29fbe7eef5f0b RNScreens: 6d0d3b7819284dbf9f222899eb819f069fce4b8b + RNSVG: 8b5f2fdb9f0245a863cdd6e9c48a7689821a08b0 + RNWorklets: ac481cd68c74e369c2835d7cb070cf7c70918c95 SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871 SwiftyRSA: 8c6dd1ea7db1b8dc4fb517a202f88bb1354bc2c6 Yoga: d1c536142c5ff8ec8cd856ab2a7c227a1d875c8e diff --git a/apps/tester-federation-v2/nativewind-env.d.ts b/apps/tester-federation-v2/nativewind-env.d.ts new file mode 100644 index 000000000..958346287 --- /dev/null +++ b/apps/tester-federation-v2/nativewind-env.d.ts @@ -0,0 +1,3 @@ +/// + +// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind. diff --git a/apps/tester-federation-v2/package.json b/apps/tester-federation-v2/package.json index ed90c3d62..22de2dfdb 100644 --- a/apps/tester-federation-v2/package.json +++ b/apps/tester-federation-v2/package.json @@ -20,13 +20,25 @@ "@react-native-async-storage/async-storage": "catalog:testers", "@react-navigation/native": "7.3.14", "@react-navigation/native-stack": "7.18.6", + "@rn-primitives/slot": "^1.2.0", + "@rn-primitives/types": "^1.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "nativewind": "^4.2.6", "react": "catalog:", "react-native": "catalog:", + "react-native-css-interop": "0.2.6", + "react-native-reanimated": "catalog:testers", "react-native-safe-area-context": "catalog:testers", - "react-native-screens": "catalog:testers" + "react-native-screens": "catalog:testers", + "react-native-svg": "15.15.5", + "react-native-worklets": "catalog:testers", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@babel/core": "^7.29.7", + "@callstack/repack-plugin-nativewind": "workspace:*", + "@callstack/repack-plugin-reanimated": "workspace:*", "@e2e-dev/mobile": "catalog:testers", "@react-native-community/cli": "catalog:testers", "@react-native-community/cli-platform-android": "catalog:testers", @@ -37,12 +49,17 @@ "@react-native/typescript-config": "catalog:testers", "@rsdoctor/rspack-plugin": "1.6.4", "@rspack/core": "catalog:", + "@svgr/webpack": "^8.1.0", "@swc/helpers": "catalog:", "@types/jest": "^29.5.13", "@types/node": "catalog:", "@types/react": "catalog:testers", + "autoprefixer": "^10.4.20", "e2e": "catalog:testers", + "postcss": "^8.5.23", + "postcss-loader": "^8.1.1", "react-native-test-app": "catalog:testers", + "tailwindcss": "^3.4.17", "terser-webpack-plugin": "catalog:", "typescript": "catalog:", "webpack": "catalog:" diff --git a/apps/tester-federation-v2/src/globals.d.ts b/apps/tester-federation-v2/src/globals.d.ts index f51151309..8e43034ec 100644 --- a/apps/tester-federation-v2/src/globals.d.ts +++ b/apps/tester-federation-v2/src/globals.d.ts @@ -1,5 +1,16 @@ declare const __WITH_PRELOAD__: boolean; +declare module '*.svg' { + import type { ComponentType } from 'react'; + + const SvgComponent: ComponentType<{ + width?: number | string; + height?: number | string; + color?: string; + }>; + export default SvgComponent; +} + declare module 'MiniApp/MiniAppNavigator' { import type { ComponentType } from 'react'; diff --git a/apps/tester-federation-v2/src/host/FEATURES.ts b/apps/tester-federation-v2/src/host/FEATURES.ts new file mode 100644 index 000000000..9cf08456d --- /dev/null +++ b/apps/tester-federation-v2/src/host/FEATURES.ts @@ -0,0 +1,61 @@ +export const FEATURE_GROUPS = [ + 'Runtime', + 'Mini Apps', + 'Chunks', + 'Plugins', +] as const; + +export type FeatureGroup = (typeof FEATURE_GROUPS)[number]; + +export type Feature = { + key: string; + name: string; + group: FeatureGroup; + screen: keyof import('./navigation/MainNavigator').MainStackParamList; +}; + +export const FEATURES: Feature[] = [ + { key: 'gallery', name: 'Gallery', group: 'Mini Apps', screen: 'MiniApp' }, + { + key: 'host-assets', + name: 'Host assets', + group: 'Runtime', + screen: 'HostAssets', + }, + { + key: 'runtime-events', + name: 'Runtime events', + group: 'Runtime', + screen: 'RuntimeEvents', + }, + { + key: 'async-chunk', + name: 'Async chunk', + group: 'Chunks', + screen: 'AsyncChunk', + }, + { + key: 'remote-chunk', + name: 'Remote chunk', + group: 'Chunks', + screen: 'RemoteChunk', + }, + { + key: 'miniapp-install', + name: 'Mini-app install', + group: 'Chunks', + screen: 'MiniAppInstall', + }, + { + key: 'nativewind', + name: 'NativeWind', + group: 'Plugins', + screen: 'NativeWind', + }, + { + key: 'reanimated', + name: 'Reanimated', + group: 'Plugins', + screen: 'Reanimated', + }, +]; diff --git a/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack.png b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack.png new file mode 100644 index 000000000..352e4631e Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack.png differ diff --git a/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@2x.png b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@2x.png new file mode 100644 index 000000000..23b99fc52 Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@2x.png differ diff --git a/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@3x.png b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@3x.png new file mode 100644 index 000000000..3719056cc Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@3x.png differ diff --git a/apps/tester-federation-v2/src/host/assets/localAssets/webpack.png b/apps/tester-federation-v2/src/host/assets/localAssets/webpack.png new file mode 100644 index 000000000..352e4631e Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/localAssets/webpack.png differ diff --git a/apps/tester-federation-v2/src/host/assets/localAssets/webpack@2x.png b/apps/tester-federation-v2/src/host/assets/localAssets/webpack@2x.png new file mode 100644 index 000000000..23b99fc52 Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/localAssets/webpack@2x.png differ diff --git a/apps/tester-federation-v2/src/host/assets/localAssets/webpack@3x.png b/apps/tester-federation-v2/src/host/assets/localAssets/webpack@3x.png new file mode 100644 index 000000000..3719056cc Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/localAssets/webpack@3x.png differ diff --git a/apps/tester-federation-v2/src/host/assets/pic_3.jpg b/apps/tester-federation-v2/src/host/assets/pic_3.jpg deleted file mode 100644 index 29f6093e8..000000000 Binary files a/apps/tester-federation-v2/src/host/assets/pic_3.jpg and /dev/null differ diff --git a/apps/tester-federation-v2/src/host/assets/repack-logo.svg b/apps/tester-federation-v2/src/host/assets/repack-logo.svg new file mode 100644 index 000000000..f6b2cd3de --- /dev/null +++ b/apps/tester-federation-v2/src/host/assets/repack-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/tester-federation-v2/src/host/features/async/Async.local.tsx b/apps/tester-federation-v2/src/host/features/async/Async.local.tsx new file mode 100644 index 000000000..32e54d35d --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/async/Async.local.tsx @@ -0,0 +1,21 @@ +import { StyleSheet, Text, View } from 'react-native'; + +export default function AsyncLocal() { + return ( + + + Async: this text comes from async chunk + + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + }, + text: { + fontSize: 16, + color: '#200a12', + }, +}); diff --git a/apps/tester-federation-v2/src/host/features/async/AsyncChunkSection.tsx b/apps/tester-federation-v2/src/host/features/async/AsyncChunkSection.tsx new file mode 100644 index 000000000..1ddfa9cea --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/async/AsyncChunkSection.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +const AsyncLocal = React.lazy( + () => import(/* webpackChunkName: "host-async-local" */ './Async.local') +); + +const Fallback = () => ( + + + +); + +const AsyncChunkSection = () => { + return ( + + }> + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignSelf: 'stretch', + paddingVertical: 12, + }, + fallback: { + paddingVertical: 24, + }, +}); + +export default AsyncChunkSection; diff --git a/apps/tester-federation-v2/src/host/features/miniapp/MiniApp.tsx b/apps/tester-federation-v2/src/host/features/miniapp/MiniApp.tsx new file mode 100644 index 000000000..c72bf92eb --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/miniapp/MiniApp.tsx @@ -0,0 +1,21 @@ +import { StyleSheet, Text, View } from 'react-native'; + +export default function MiniAppLocal() { + return ( + + + MiniApp: this text comes from MiniApp chunk + + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + }, + text: { + fontSize: 16, + color: '#200a12', + }, +}); diff --git a/apps/tester-federation-v2/src/host/features/miniapp/MiniAppSection.tsx b/apps/tester-federation-v2/src/host/features/miniapp/MiniAppSection.tsx new file mode 100644 index 000000000..62f6d5825 --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/miniapp/MiniAppSection.tsx @@ -0,0 +1,66 @@ +import { ScriptManager } from '@callstack/repack/client'; +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import ActionButton from '../../ui/ActionButton'; + +const MiniAppChunkId = 'miniapp'; + +const MiniAppSection = () => { + const [isInstalled, setIsInstalled] = React.useState(false); + const [isVisible, setIsVisible] = React.useState(false); + const [MiniAppComponent, setMiniAppComponent] = React.useState< + React.ComponentType | undefined + >(undefined); + + const install = async () => { + const bundle = await import(/* webpackChunkName: "miniapp" */ './MiniApp'); + setMiniAppComponent(() => bundle.default); + setIsInstalled(true); + }; + + const uninstall = async () => { + await ScriptManager.shared.invalidateScripts([MiniAppChunkId]); + setMiniAppComponent(undefined); + setIsInstalled(false); + setIsVisible(false); + }; + + return ( + + + + setIsVisible((visible) => !visible)} + /> + {isInstalled && isVisible && MiniAppComponent ? ( + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignSelf: 'stretch', + paddingVertical: 12, + gap: 8, + }, +}); + +export default MiniAppSection; diff --git a/apps/tester-federation-v2/src/host/features/nativewind/Basic.tsx b/apps/tester-federation-v2/src/host/features/nativewind/Basic.tsx new file mode 100644 index 000000000..788c0ceb1 --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/Basic.tsx @@ -0,0 +1,14 @@ +import { Text, View } from 'react-native'; + +export default function Basic() { + return ( + + + Colored through CSS! + + + ); +} diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ComponentWithVariants.tsx b/apps/tester-federation-v2/src/host/features/nativewind/ComponentWithVariants.tsx new file mode 100644 index 000000000..e47dfbfff --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/ComponentWithVariants.tsx @@ -0,0 +1,25 @@ +import type React from 'react'; +import { Text } from 'react-native'; + +const variantStyles = { + default: 'rounded', + primary: 'bg-blue-500 text-white', +}; + +type Props = { + variant: keyof typeof variantStyles; + className?: string; +} & React.ComponentProps; + +export default function ComponentWithVariants({ + variant, + className, + ...props +}: Props) { + const merged = [variantStyles.default, variantStyles[variant], className] + .filter(Boolean) + .join(' '); + return ( + + ); +} diff --git a/apps/tester-federation-v2/src/host/features/nativewind/CustomComponent.tsx b/apps/tester-federation-v2/src/host/features/nativewind/CustomComponent.tsx new file mode 100644 index 000000000..4b9411362 --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/CustomComponent.tsx @@ -0,0 +1,14 @@ +import { Text } from 'react-native'; + +import { cn } from './ui/utils'; + +export default function CustomComponent({ className }: { className: string }) { + return ( + + I am a custom component + + ); +} diff --git a/apps/tester-federation-v2/src/host/features/nativewind/DarkMode.tsx b/apps/tester-federation-v2/src/host/features/nativewind/DarkMode.tsx new file mode 100644 index 000000000..ddec886d5 --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/DarkMode.tsx @@ -0,0 +1,27 @@ +import { colorScheme } from 'nativewind'; +import { Button, Text, View } from 'react-native'; + +export default function DarkMode() { + return ( + + + ); +} diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ui/Button.tsx b/apps/tester-federation-v2/src/host/features/nativewind/ui/Button.tsx new file mode 100644 index 000000000..19693965a --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/ui/Button.tsx @@ -0,0 +1,81 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { Pressable } from 'react-native'; +import { TextClassContext } from './Text'; +import { cn } from './utils'; + +const buttonVariants = cva( + 'group flex items-center justify-center rounded-md', + { + variants: { + variant: { + default: 'bg-primary active:opacity-90', + primary: 'bg-primary active:opacity-90', + }, + size: { + default: 'h-10 px-4 py-2 native:h-12 native:px-5 native:py-3', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8 native:h-14', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +const buttonTextVariants = cva( + 'web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors', + { + variants: { + variant: { + default: 'text-primary-foreground', + primary: 'text-primary-foreground', + }, + size: { + default: '', + sm: '', + lg: 'native:text-lg', + icon: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +type ButtonProps = React.ComponentPropsWithoutRef & + VariantProps; + +const Button = React.forwardRef< + React.ElementRef, + ButtonProps +>(({ className, variant, size, ...props }, ref) => { + return ( + + + + ); +}); +Button.displayName = 'Button'; + +export type { ButtonProps }; +export { Button, buttonTextVariants, buttonVariants }; diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ui/Text.tsx b/apps/tester-federation-v2/src/host/features/nativewind/ui/Text.tsx new file mode 100644 index 000000000..a933f0829 --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/ui/Text.tsx @@ -0,0 +1,28 @@ +import * as Slot from '@rn-primitives/slot'; +import type { SlottableTextProps, TextRef } from '@rn-primitives/types'; +import * as React from 'react'; +import { Text as RNText } from 'react-native'; +import { cn } from './utils'; + +const TextClassContext = React.createContext(undefined); + +const Text = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const textClass = React.useContext(TextClassContext); + const Component = asChild ? Slot.Text : RNText; + return ( + + ); + } +); +Text.displayName = 'Text'; + +export { Text, TextClassContext }; diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ui/utils.ts b/apps/tester-federation-v2/src/host/features/nativewind/ui/utils.ts new file mode 100644 index 000000000..9ad0df426 --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/nativewind/ui/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/tester-federation-v2/src/host/features/reanimated/ReanimatedBox.tsx b/apps/tester-federation-v2/src/host/features/reanimated/ReanimatedBox.tsx new file mode 100644 index 000000000..e8653e05a --- /dev/null +++ b/apps/tester-federation-v2/src/host/features/reanimated/ReanimatedBox.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { Button, StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; + +const BASE_WIDTH = 100; + +export default function ReanimatedBox({ + onWidthChange, +}: { + onWidthChange: (width: number) => void; +}) { + const width = useSharedValue(BASE_WIDTH); + const target = React.useRef(BASE_WIDTH); + + const boxStyle = useAnimatedStyle(() => { + 'worklet'; + return { width: width.value }; + }); + + useAnimatedReaction( + () => width.value, + (_previous, next) => { + 'worklet'; + if (next !== null) { + scheduleOnRN(onWidthChange, Math.round(next)); + } + } + ); + + const handlePress = () => { + target.current += 50; + width.value = withSpring(target.current); + }; + + const handleReset = () => { + target.current = BASE_WIDTH; + width.value = withSpring(target.current); + }; + + return ( + + + +