diff --git a/.changeset/rspack-2-support.md b/.changeset/rspack-2-support.md new file mode 100644 index 000000000..c93aeee23 --- /dev/null +++ b/.changeset/rspack-2-support.md @@ -0,0 +1,11 @@ +--- +"@callstack/repack": minor +--- + +Add Rspack 2 support. Re.Pack now works with both Rspack 1.x and 2.x from a single release - it detects the installed `@rspack/core` major and adjusts its behavior automatically. Rspack 1 and webpack setups are unaffected: nothing changes unless you upgrade. + +To move a project to Rspack 2: + +- Use Node.js `^20.19.0 || >=22.12.0` - required by Rspack 2 itself; Re.Pack raises a clear error on older versions instead of failing with `ERR_REQUIRE_ESM` +- Install `@rspack/plugin-react-refresh@^2` and `react-refresh` - under Rspack 2, development/HMR support is wired through the official React Refresh plugin +- Move any `experiments.cache` configuration to the top-level `cache` option - Rspack 2 ignores the legacy location, and Re.Pack warns when it detects it diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1777c63d..4fa3ef466 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,6 +72,24 @@ jobs: - name: Run tests against Rspack 1 run: pnpm turbo run test:rspack1 + test_pr_rspack_compat: + name: Tests [Rspack compat smoke] + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2 + - name: Setup workspace + uses: ./.github/actions/setup-pnpm-node + with: + node-version: '24' + + # built-dist smoke tests: pack repack into a tarball, install it into + # standalone fixtures pinning each @rspack/core major, and assert the + # dual-major behavior matrix against the real dist + - name: Run dual-major smoke suite + run: pnpm --filter rspack-compat-test test:smoke + test_pr_windows: name: Tests [Windows] if: github.event_name == 'pull_request' diff --git a/AGENTS.md b/AGENTS.md index 93227d931..e786be8d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,4 @@ Re.Pack is a toolkit for building and developing React Native applications with - `tests/integration/`: Integration-level automated coverage. - `tests/metro-compat/`: Metro compatibility behavior coverage. - `tests/resolver-cases/`: Resolver behavior and edge-case coverage. +- `tests/rspack-compat/`: Built-dist smoke coverage across both supported `@rspack/core` majors (standalone, tarball-installed fixtures). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12fdbb848..5d76d9e59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -831,6 +831,8 @@ importers: specifier: 'catalog:' version: 4.1.0(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.28.2)(terser@5.31.3)(yaml@2.8.2) + tests/rspack-compat: {} + website: dependencies: '@callstack/rspress-preset': diff --git a/tests/rspack-compat/.gitignore b/tests/rspack-compat/.gitignore new file mode 100644 index 000000000..8072a70e2 --- /dev/null +++ b/tests/rspack-compat/.gitignore @@ -0,0 +1,7 @@ +# per-run artifacts - the fixtures are standalone projects that get a fresh +# repack tarball installed on every run +.pack/ +fixtures/*/node_modules/ +fixtures/*/pnpm-lock.yaml +fixtures/*/callstack-repack.tgz +fixtures/*/dist/ diff --git a/tests/rspack-compat/README.md b/tests/rspack-compat/README.md new file mode 100644 index 000000000..c2f315256 --- /dev/null +++ b/tests/rspack-compat/README.md @@ -0,0 +1,73 @@ +# rspack-compat + +Built-dist smoke tests for `@callstack/repack` against both supported +`@rspack/core` majors (1.x and 2.x). + +Unit tests exercise sources through the Jest/Babel pipeline; the failure +modes this suite covers — `require(esm)` interop, loader resolution, React +Refresh runtime source selection — only reproduce against the **built dist** +installed in a real project layout. The runner therefore always builds +`packages/repack`, packs it into a tarball, installs the tarball into each +fixture, and runs the assertions there. + +## Running + +```sh +pnpm --filter rspack-compat-test test:smoke # both fixtures +pnpm --filter rspack-compat-test test:smoke rspack-2 # a single fixture +``` + +The suite performs network installs and full repack builds, so it is +deliberately **not** part of the default `test` pipeline — CI runs it in a +dedicated lane. + +## What is asserted (per major) + +1. `getRepackConfig` routing — `experiments.parallelLoader` kept under + Rspack 1 / dropped under Rspack 2; `exportsPresence: 'auto'` parser + override under Rspack 2 only. +2. Legacy `experiments.cache` handling, composed with the `isRspack2` gate + the commands use — the gate reads the fixture's installed major, Rspack 1 + sees no warning, Rspack 2 warns exactly once under the warn-only policy, + the config is never mutated, and the cache accessor reads the legacy + location. +3. `ensureNodeSupportsRspack` passes on a supported Node version. +4. The compiled command modules (`dist/commands/rspack/{bundle,start,Compiler}.js`) + load from the tarball-installed package — the eager + `require('@rspack/core')` they compile to (`require(esm)` under + Rspack 2), the `rspack` named export they call, and the lazy + `@callstack/repack/commands/rspack` entry. +5. A full dev build through `DevelopmentPlugin` — HMR client and React + Refresh wiring present in the bundle, with the refresh runtime coming + from the correct source per major: the vendored client files + (`packages/repack/vendor/react-refresh/`) under Rspack 1 with zero + official-plugin references, the official `@rspack/plugin-react-refresh` + under Rspack 2 with zero vendored references. + +## Why the fixtures are standalone (tarball install) + +Inside the pnpm workspace, repack is linked as a symlink, so its own +`@rspack/core@^2` devDependency shadows any fixture's v1 pin — bare +`import '@rspack/core'` from `packages/repack/dist` resolves repack's copy +before the project's. An in-workspace v1 fixture would therefore silently +test v2 twice. Published installs don't have this problem (`@rspack/core` +is a peer dependency there), and shipped code deliberately carries no +monorepo-aware resolution workarounds, so the fixtures replicate the +published layout instead: each one is its own pnpm workspace root +(`pnpm-workspace.yaml` with `packages: []`) with its own `node_modules`, +installing repack from a packed tarball — the same pattern as +`apps/tester-app-rspack1`. + +`smoke.cjs` enforces this as a **lane guard** before running any behavior +assertions: the fixture's installed `@rspack/core` major must match the +major declared in its manifest, the `@rspack/core` that *repack itself* +resolves must be that same copy, and the resolved repack package must live +inside the fixture's `node_modules` (never a workspace link back to +`packages/repack`). Any violation aborts the run with a non-zero exit. + +`react-native` is stubbed (`fixtures/shared/rn-stub`) with just the modules +Re.Pack's development runtime imports — real RN sources need the full +flow/babel loader chain, which is out of scope for wiring-level smoke tests. + +See `agent_context/rspackv2-jul2026/design.md` for the overall dual-major +support design. diff --git a/tests/rspack-compat/fixtures/rspack-1/package.json b/tests/rspack-compat/fixtures/rspack-1/package.json new file mode 100644 index 000000000..a61ec4c82 --- /dev/null +++ b/tests/rspack-compat/fixtures/rspack-1/package.json @@ -0,0 +1,11 @@ +{ + "name": "rspack-compat-fixture-rspack-1", + "version": "0.0.1", + "description": "Rspack 1 smoke fixture - installs @callstack/repack from a packed tarball like a real user project", + "private": true, + "devDependencies": { + "@callstack/repack": "file:./callstack-repack.tgz", + "@rspack/core": "^1.7.12", + "react-native": "file:../shared/rn-stub" + } +} diff --git a/tests/rspack-compat/fixtures/rspack-1/pnpm-workspace.yaml b/tests/rspack-compat/fixtures/rspack-1/pnpm-workspace.yaml new file mode 100644 index 000000000..81803e726 --- /dev/null +++ b/tests/rspack-compat/fixtures/rspack-1/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +# Marks this directory as its own workspace root, so pnpm treats it as a +# standalone project instead of a member of the repository workspace. +# See ../../README.md for why the fixtures must stay outside the workspace. +packages: [] diff --git a/tests/rspack-compat/fixtures/rspack-1/src/App.jsx b/tests/rspack-compat/fixtures/rspack-1/src/App.jsx new file mode 100644 index 000000000..bf974672d --- /dev/null +++ b/tests/rspack-compat/fixtures/rspack-1/src/App.jsx @@ -0,0 +1,3 @@ +export function App() { + return null; +} diff --git a/tests/rspack-compat/fixtures/rspack-2/package.json b/tests/rspack-compat/fixtures/rspack-2/package.json new file mode 100644 index 000000000..a1ea84c35 --- /dev/null +++ b/tests/rspack-compat/fixtures/rspack-2/package.json @@ -0,0 +1,14 @@ +{ + "name": "rspack-compat-fixture-rspack-2", + "version": "0.0.1", + "description": "Rspack 2 smoke fixture - installs @callstack/repack from a packed tarball like a real user project", + "private": true, + "devDependencies": { + "@callstack/repack": "file:./callstack-repack.tgz", + "@rspack/core": "^2.1.2", + "@rspack/plugin-react-refresh": "^2.0.2", + "@swc/helpers": "^0.5.23", + "react-native": "file:../shared/rn-stub", + "react-refresh": "^0.18.0" + } +} diff --git a/tests/rspack-compat/fixtures/rspack-2/pnpm-workspace.yaml b/tests/rspack-compat/fixtures/rspack-2/pnpm-workspace.yaml new file mode 100644 index 000000000..81803e726 --- /dev/null +++ b/tests/rspack-compat/fixtures/rspack-2/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +# Marks this directory as its own workspace root, so pnpm treats it as a +# standalone project instead of a member of the repository workspace. +# See ../../README.md for why the fixtures must stay outside the workspace. +packages: [] diff --git a/tests/rspack-compat/fixtures/rspack-2/src/App.jsx b/tests/rspack-compat/fixtures/rspack-2/src/App.jsx new file mode 100644 index 000000000..bf974672d --- /dev/null +++ b/tests/rspack-compat/fixtures/rspack-2/src/App.jsx @@ -0,0 +1,3 @@ +export function App() { + return null; +} diff --git a/tests/rspack-compat/fixtures/shared/rn-stub/Libraries/Core/NativeExceptionsManager.js b/tests/rspack-compat/fixtures/shared/rn-stub/Libraries/Core/NativeExceptionsManager.js new file mode 100644 index 000000000..0f1d5dd40 --- /dev/null +++ b/tests/rspack-compat/fixtures/shared/rn-stub/Libraries/Core/NativeExceptionsManager.js @@ -0,0 +1,6 @@ +export default { + hide() {}, + showMessage() {}, + dismissRedbox() {}, + reportException() {}, +}; diff --git a/tests/rspack-compat/fixtures/shared/rn-stub/Libraries/NativeModules/specs/NativeRedBox.js b/tests/rspack-compat/fixtures/shared/rn-stub/Libraries/NativeModules/specs/NativeRedBox.js new file mode 100644 index 000000000..0f1d5dd40 --- /dev/null +++ b/tests/rspack-compat/fixtures/shared/rn-stub/Libraries/NativeModules/specs/NativeRedBox.js @@ -0,0 +1,6 @@ +export default { + hide() {}, + showMessage() {}, + dismissRedbox() {}, + reportException() {}, +}; diff --git a/tests/rspack-compat/fixtures/shared/rn-stub/Libraries/Utilities/DevLoadingView.js b/tests/rspack-compat/fixtures/shared/rn-stub/Libraries/Utilities/DevLoadingView.js new file mode 100644 index 000000000..0f1d5dd40 --- /dev/null +++ b/tests/rspack-compat/fixtures/shared/rn-stub/Libraries/Utilities/DevLoadingView.js @@ -0,0 +1,6 @@ +export default { + hide() {}, + showMessage() {}, + dismissRedbox() {}, + reportException() {}, +}; diff --git a/tests/rspack-compat/fixtures/shared/rn-stub/index.js b/tests/rspack-compat/fixtures/shared/rn-stub/index.js new file mode 100644 index 000000000..a78c3eeb5 --- /dev/null +++ b/tests/rspack-compat/fixtures/shared/rn-stub/index.js @@ -0,0 +1,2 @@ +export const DevSettings = { reload() {} }; +export const LogBox = { clearAllLogs() {} }; diff --git a/tests/rspack-compat/fixtures/shared/rn-stub/package.json b/tests/rspack-compat/fixtures/shared/rn-stub/package.json new file mode 100644 index 000000000..f29453033 --- /dev/null +++ b/tests/rspack-compat/fixtures/shared/rn-stub/package.json @@ -0,0 +1,7 @@ +{ + "name": "react-native", + "version": "0.84.1", + "description": "Stub of react-native for the rspack-compat smoke fixtures - covers the modules imported by Re.Pack's development runtime (real RN sources need the full flow/babel loader chain, which is out of scope for wiring-level smoke tests)", + "private": true, + "main": "index.js" +} diff --git a/tests/rspack-compat/package.json b/tests/rspack-compat/package.json new file mode 100644 index 000000000..8ae1af919 --- /dev/null +++ b/tests/rspack-compat/package.json @@ -0,0 +1,13 @@ +{ + "name": "rspack-compat-test", + "version": "0.0.1", + "description": "Built-dist smoke tests for @callstack/repack against both @rspack/core majors", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "test:smoke": "node run.mjs" + } +} diff --git a/tests/rspack-compat/run.mjs b/tests/rspack-compat/run.mjs new file mode 100644 index 000000000..9faa2f5d4 --- /dev/null +++ b/tests/rspack-compat/run.mjs @@ -0,0 +1,101 @@ +// Drives the dual-major smoke fixtures: builds @callstack/repack, packs it +// into a tarball, installs that tarball into each standalone fixture and +// runs smoke.cjs against the result. +// +// Usage: node run.mjs [fixture...] (default: all fixtures) +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const suiteDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(suiteDir, '../..'); +const repackDir = path.join(repoRoot, 'packages/repack'); +const packDir = path.join(suiteDir, '.pack'); + +const allFixtures = ['rspack-1', 'rspack-2']; +const requested = process.argv.slice(2); +const fixtures = requested.length > 0 ? requested : allFixtures; + +for (const fixture of fixtures) { + if (!allFixtures.includes(fixture)) { + console.error( + `Unknown fixture '${fixture}' - expected one of: ${allFixtures.join(', ')}` + ); + process.exit(1); + } +} + +function run(command, args, options = {}) { + console.log(`\n$ ${command} ${args.join(' ')}`); + execFileSync(command, args, { stdio: 'inherit', ...options }); +} + +// The lane guard in smoke.cjs verifies the *installed* major against the +// fixture's declared intent - derive that intent from the fixture manifest +// so the two can never drift apart silently. +function expectedMajorOf(fixtureDir) { + const manifest = JSON.parse( + fs.readFileSync(path.join(fixtureDir, 'package.json'), 'utf8') + ); + const range = manifest.devDependencies['@rspack/core']; + const major = Number.parseInt(range.replace(/^\D*/, ''), 10); + if (!Number.isInteger(major)) { + throw new Error( + `Cannot derive the expected @rspack/core major from range '${range}'` + ); + } + return major; +} + +// 1. Build and pack repack. The suite exists to test the *built dist* - the +// failure modes it covers (require(esm) interop, loader resolution, refresh +// runtime source selection) don't reproduce through the Jest/Babel pipeline, +// so it must never run against stale sources. Built through turbo so that +// workspace dependencies (repack-dev-server) are built first. +run('pnpm', ['turbo', 'run', 'build', '--filter', '@callstack/repack'], { + cwd: repoRoot, +}); +fs.rmSync(packDir, { recursive: true, force: true }); +fs.mkdirSync(packDir, { recursive: true }); +run('pnpm', ['pack', '--pack-destination', packDir], { cwd: repackDir }); + +const tarball = fs.readdirSync(packDir).find((file) => file.endsWith('.tgz')); +if (!tarball) { + throw new Error('pnpm pack produced no tarball'); +} + +// 2. Install the tarball into each fixture and run the assertions. +const failed = []; + +for (const fixture of fixtures) { + const fixtureDir = path.join(suiteDir, 'fixtures', fixture); + console.log(`\n=== fixture: ${fixture} ===`); + + fs.copyFileSync( + path.join(packDir, tarball), + path.join(fixtureDir, 'callstack-repack.tgz') + ); + + try { + run('pnpm', ['install', '--no-frozen-lockfile'], { cwd: fixtureDir }); + run('node', [path.join(suiteDir, 'smoke.cjs')], { + cwd: fixtureDir, + env: { + ...process.env, + RSPACK_COMPAT_EXPECTED_MAJOR: String(expectedMajorOf(fixtureDir)), + }, + }); + console.log(`=== fixture ${fixture}: PASS ===`); + } catch { + failed.push(fixture); + console.error(`=== fixture ${fixture}: FAIL ===`); + } +} + +if (failed.length > 0) { + console.error(`\nSmoke suite failed for: ${failed.join(', ')}`); + process.exit(1); +} + +console.log(`\nSmoke suite passed for: ${fixtures.join(', ')}`); diff --git a/tests/rspack-compat/smoke.cjs b/tests/rspack-compat/smoke.cjs new file mode 100644 index 000000000..690be3198 --- /dev/null +++ b/tests/rspack-compat/smoke.cjs @@ -0,0 +1,346 @@ +// Smoke test of the built @callstack/repack package against the +// @rspack/core major installed in the surrounding fixture. +// +// Run by run.mjs with a fixture directory as cwd and +// RSPACK_COMPAT_EXPECTED_MAJOR set to the major the fixture pins. +// The script asserts the dual-major behavior matrix: config routing, +// legacy-cache warning (gated the way the commands gate it), Node +// compatibility guard, the compiled command modules' require('@rspack/core') +// path, and a full dev build with HMR + React Refresh wired from the +// correct source per major. +const fs = require('node:fs'); +const { createRequire } = require('node:module'); +const path = require('node:path'); + +const FIXTURE = process.cwd(); +const fixtureRequire = createRequire(path.join(FIXTURE, 'package.json')); + +const failures = []; + +function report(name, ok, detail) { + const status = ok ? 'PASS' : detail ? `FAIL (${detail})` : 'FAIL'; + console.log(`${name.padEnd(44)}: ${status}`); + if (!ok) failures.push(name); +} + +function majorOf(version) { + return Number(version.split('.')[0]); +} + +// --- 0. lane guard --------------------------------------------------------- +// The fixtures install repack from a packed tarball, like a real user +// project, precisely because in-workspace repack's own @rspack/core@^2 +// devDependency shadows any v1 pin (bare imports resolve from +// packages/repack before the project's copy) - a workspace-linked fixture +// would silently test v2 twice. Verify all of it holds before asserting +// anything else, and abort loudly if it doesn't. + +const expectedMajor = Number(process.env.RSPACK_COMPAT_EXPECTED_MAJOR); +if (!Number.isInteger(expectedMajor)) { + console.error( + 'RSPACK_COMPAT_EXPECTED_MAJOR must be set - run this script via run.mjs' + ); + process.exit(1); +} + +const installedVersion = fixtureRequire('@rspack/core/package.json').version; +const repackPkgPath = fixtureRequire.resolve('@callstack/repack/package.json'); +const REPACK = path.dirname(repackPkgPath); +const repackRequire = createRequire(repackPkgPath); +const versionSeenByRepack = repackRequire('@rspack/core/package.json').version; +const realRepackDir = fs.realpathSync(REPACK); +const fixtureModulesDir = fs.realpathSync(path.join(FIXTURE, 'node_modules')); +const tarballInstalled = realRepackDir.startsWith(fixtureModulesDir + path.sep); + +console.log( + `\nsmoke: @rspack/core ${installedVersion} (expected major ${expectedMajor})` +); + +report( + '0a. fixture installs the intended major', + majorOf(installedVersion) === expectedMajor, + `installed ${installedVersion}` +); +report( + '0b. repack resolves the fixture rspack', + majorOf(versionSeenByRepack) === expectedMajor, + `repack sees ${versionSeenByRepack}` +); +report( + '0c. repack is the tarball-installed copy', + tarballInstalled, + realRepackDir +); + +if (failures.length > 0) { + console.error( + '\nLane guard failed - the fixture does not test what it claims to ' + + 'test. Aborting before running any behavior assertions.' + ); + process.exit(1); +} + +const major = expectedMajor; + +(async () => { + // --- 1. getRepackConfig routing ------------------------------------------ + const { getRepackConfig } = repackRequire( + path.join(REPACK, 'dist/commands/common/config/getRepackConfig.js') + ); + const repackConfig = await getRepackConfig('rspack', FIXTURE); + const hasParallel = repackConfig.experiments?.parallelLoader === true; + const exportsPresence = + repackConfig.module?.parser?.javascript?.exportsPresence; + if (major >= 2) { + report('1a. no experiments.parallelLoader', !hasParallel); + report( + '1b. exportsPresence auto', + exportsPresence === 'auto', + `got ${exportsPresence}` + ); + } else { + report('1a. experiments.parallelLoader kept', hasParallel); + report('1b. no module parser override', repackConfig.module === undefined); + } + + // --- 2. cache accessor + legacy-cache warning (warn-only) ----------------- + const { getRspackCacheConfigs } = repackRequire( + path.join(REPACK, 'dist/commands/common/resetPersistentCache.js') + ); + const { warnLegacyRspackCacheConfig } = repackRequire( + path.join(REPACK, 'dist/commands/common/warnLegacyRspackCacheConfig.js') + ); + const { isRspack2 } = repackRequire( + path.join(REPACK, 'dist/helpers/index.js') + ); + const legacyCfg = { + experiments: { + cache: { type: 'persistent', storage: { directory: '/custom' } }, + }, + }; + report( + '2a. accessor collects legacy location', + getRspackCacheConfigs(legacyCfg)[0]?.storage?.directory === '/custom' + ); + // regression guard: with both keys set, both locations must be collected + // (previously the legacy key shadowed the top-level one) + const bothCfg = { + cache: { type: 'persistent', storage: { directory: '/top' } }, + experiments: { + cache: { type: 'persistent', storage: { directory: '/custom' } }, + }, + }; + const bothDirs = getRspackCacheConfigs(bothCfg).map( + (cacheConfig) => cacheConfig?.storage?.directory + ); + report( + '2b. accessor collects both cache locations', + bothDirs.includes('/custom') && bothDirs.includes('/top'), + `dirs=${bothDirs.join(',')}` + ); + // The warning is a Rspack 2 migration aid: bundle.ts/start.ts only call + // the helper behind an isRspack2(root) gate, so what a user sees per + // major is the gate composed with the helper. Assert the gate reads the + // fixture's actual install, then mirror the command wiring: Rspack 1 + // users must see no warning, Rspack 2 users exactly one - and the config + // is never mutated. + const gated = isRspack2(FIXTURE); + report( + '2c. isRspack2 gate matches fixture major', + gated === major >= 2, + `isRspack2=${gated}` + ); + const warns = []; + const origWarn = console.warn; + console.warn = (msg) => warns.push(String(msg)); + if (gated) { + warnLegacyRspackCacheConfig([legacyCfg]); + warnLegacyRspackCacheConfig([legacyCfg]); // must warn only once + } + console.warn = origWarn; + const untouched = + legacyCfg.cache === undefined && + legacyCfg.experiments.cache?.storage?.directory === '/custom'; + const expectedWarns = major >= 2 ? 1 : 0; + report( + major >= 2 + ? '2d. warns once, mutates nothing' + : '2d. no rspack-2 warning under rspack 1', + warns.length === expectedWarns && untouched, + `warns=${warns.length} untouched=${untouched}` + ); + report( + '2e. accessor still collects legacy location', + getRspackCacheConfigs(legacyCfg)[0]?.storage?.directory === '/custom' + ); + + // --- 3. node compatibility guard ------------------------------------------- + const { ensureNodeSupportsRspack } = repackRequire( + path.join(REPACK, 'dist/commands/rspack/ensureNodeCompat.js') + ); + try { + ensureNodeSupportsRspack(); + report(`3. node guard passes on ${process.versions.node}`, true); + } catch (error) { + report('3. node guard passes', false, error.message); + } + + // --- 4. command modules: the compiled require('@rspack/core') path -------- + // Re.Pack's built command modules require('@rspack/core') eagerly at load + // time and call its `rspack` named export - under Rspack 2 that is a CJS + // require() of an ESM-only package (require(esm) interop). Load them from + // the tarball-installed package so a regression in that interop, the + // export shape, or the lazy `@callstack/repack/commands/rspack` entry + // can't slip past the DevelopmentPlugin build below, which never imports + // the commands. + const rspackCommands = fixtureRequire('@callstack/repack/commands/rspack'); + report( + '4a. lazy command entry exposes bundle/start', + Array.isArray(rspackCommands) && + ['bundle', 'start'].every((name) => + rspackCommands.some( + (command) => + command.name === name && typeof command.func === 'function' + ) + ) + ); + let commandModulesOk = false; + let commandModulesDetail; + try { + const { bundle } = repackRequire( + path.join(REPACK, 'dist/commands/rspack/bundle.js') + ); + const { start } = repackRequire( + path.join(REPACK, 'dist/commands/rspack/start.js') + ); + const { Compiler } = repackRequire( + path.join(REPACK, 'dist/commands/rspack/Compiler.js') + ); + commandModulesOk = + typeof bundle === 'function' && + typeof start === 'function' && + typeof Compiler === 'function'; + } catch (error) { + commandModulesDetail = error.code ?? error.message; + } + report( + '4b. command modules load against this major', + commandModulesOk, + commandModulesDetail + ); + const rspack = repackRequire('@rspack/core'); + report( + '4c. rspack named export shape', + typeof rspack.rspack === 'function', + `typeof rspack.rspack === '${typeof rspack.rspack}'` + ); + + // --- 5. dev build through DevelopmentPlugin (HMR + React Refresh) --------- + // Create the compiler exactly the way the compiled commands do: through + // the `rspack` named export of the copy repack itself resolves - no + // fallback that could mask a missing export. + const rspackFn = rspack.rspack; + const { DevelopmentPlugin } = repackRequire( + path.join(REPACK, 'dist/plugins/DevelopmentPlugin.js') + ); + const OUT = path.join(FIXTURE, 'dist', 'smoke'); + + const config = { + context: FIXTURE, + mode: 'development', + devtool: false, + entry: { main: './src/App.jsx' }, + devServer: { host: 'localhost', port: 8081, hot: true }, + output: { + path: OUT, + filename: 'index.bundle', + uniqueName: 'smokeapp', + clean: true, + }, + module: { + rules: [ + { + // mirror Re.Pack's getJsTransformRules: 'javascript/auto' makes + // ESM/CJS detection syntax-based instead of package-scope based + type: 'javascript/auto', + test: /\.[cm]?jsx?$/, + use: { + loader: 'builtin:swc-loader', + options: { jsc: { parser: { syntax: 'ecmascript', jsx: true } } }, + }, + }, + ], + }, + plugins: [new DevelopmentPlugin({ platform: 'ios' })], + }; + + const compiler = rspackFn(config); + const stats = await new Promise((resolve, reject) => { + compiler.run((error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); + + const statsJson = stats.toJson({ all: false, errors: true }); + if (statsJson.errors?.length) { + for (const error of statsJson.errors.slice(0, 3)) { + console.error( + 'build error:', + error.message.split('\n').slice(0, 2).join(' | ').slice(0, 200) + ); + } + report('5a. dev build with refresh + HMR', false, 'compilation errors'); + } else { + await new Promise((resolve) => compiler.close(resolve)); + const out = fs.readFileSync(path.join(OUT, 'index.bundle'), 'utf8'); + const contentChecks = { + 'refresh entry bundled (__reactRefreshInjected)': out.includes( + '__reactRefreshInjected' + ), + 'repack loader footer applied (setImmediate)': + out.includes('setImmediate'), + 'HMR client bundled': + out.includes('[Re.Pack] HMR') || + out.includes('WebpackHMRClient') || + out.includes('__hmr'), + 'refresh utils wired ($RefreshReg$)': out.includes('$RefreshReg$'), + }; + let bundleOk = true; + for (const [name, ok] of Object.entries(contentChecks)) { + if (!ok) { + bundleOk = false; + console.error('missing from bundle:', name); + } + } + report('5a. dev build with refresh + HMR', bundleOk); + + // the refresh runtime must come from the correct source per major: + // vendored client files (packages/repack/vendor/react-refresh) under + // Rspack 1, the official @rspack/plugin-react-refresh under Rspack 2 - + // with zero references to the other source + const usedVendored = + out.includes('vendor/react-refresh/reactRefresh.js') || + out.includes('vendor_react-refresh'); + const usedOfficial = out.includes('plugin-react-refresh/client/'); + const sourceOk = + major >= 2 + ? usedOfficial && !usedVendored + : usedVendored && !usedOfficial; + report( + '5b. refresh runtime source correct', + sourceOk, + `vendored=${usedVendored} official=${usedOfficial}, ` + + `expected ${major >= 2 ? 'official plugin' : 'vendored files'}` + ); + } + + if (failures.length > 0) { + console.error(`\n${failures.length} assertion(s) failed`); + process.exit(1); + } + console.log('\nall assertions passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +});