diff --git a/apps/cli/__tests__/preview-expo-web.test.ts b/apps/cli/__tests__/preview-expo-web.test.ts new file mode 100644 index 00000000..5a497c5f --- /dev/null +++ b/apps/cli/__tests__/preview-expo-web.test.ts @@ -0,0 +1,50 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + expoWebDepsInstall, + expoWebInstallSpecs, + missingExpoWebPackages, +} from '../src/services/preview/expo'; + +function project(installed: string[], pins?: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'codeam-expo-web-')); + for (const name of ['expo', ...installed]) { + mkdirSync(join(dir, 'node_modules', name), { recursive: true }); + writeFileSync(join(dir, 'node_modules', name, 'package.json'), '{}'); + } + if (pins) writeFileSync(join(dir, 'node_modules', 'expo', 'bundledNativeModules.json'), JSON.stringify(pins)); + return dir; +} + +describe('the Expo WEB preview installs what Metro needs to serve a browser (owner 2026-10-31)', () => { + it('a project without the web packages gets them at the SDK’s versions, --no-save', () => { + const dir = project([], { 'react-native-web': '~0.21.0', 'react-dom': '19.1.0', '@expo/metro-runtime': '~6.1.2' }); + expect(missingExpoWebPackages(dir)).toEqual(['react-native-web', 'react-dom', '@expo/metro-runtime']); + expect(expoWebDepsInstall(dir)).toEqual({ + cmd: 'npm', + args: [ + 'install', + '--no-save', + '--legacy-peer-deps', + '--no-audit', + '--no-fund', + 'react-native-web@~0.21.0', + 'react-dom@19.1.0', + '@expo/metro-runtime@~6.1.2', + ], + }); + }); + + it('only what is missing; an SDK without pins gives bare names', () => { + const dir = project(['react-dom']); + expect(missingExpoWebPackages(dir)).toEqual(['react-native-web', '@expo/metro-runtime']); + expect(expoWebInstallSpecs(dir, ['react-native-web'])).toEqual(['react-native-web']); + }); + + it('everything present: no install', () => { + const dir = project(['react-native-web', 'react-dom', '@expo/metro-runtime']); + expect(expoWebDepsInstall(dir)).toBeNull(); + }); +}); diff --git a/apps/cli/__tests__/preview/expo-own-tunnel.test.ts b/apps/cli/__tests__/preview/expo-own-tunnel.test.ts index 96ee3383..e8eb639a 100644 --- a/apps/cli/__tests__/preview/expo-own-tunnel.test.ts +++ b/apps/cli/__tests__/preview/expo-own-tunnel.test.ts @@ -97,6 +97,8 @@ beforeEach(() => { process.env.CODEAM_PREVIEW_INSPECTOR = '0'; vi.spyOn(process, 'cwd').mockReturnValue('/tmp/fake-expo'); vi.spyOn(preview, 'detectMissingNodeDeps').mockReturnValue(null); + // The fake project has the web packages: the bring-up installs nothing (see the web tests below). + vi.spyOn(preview, 'expoWebDepsInstall').mockReturnValue(null); vi.spyOn(preview, 'isPortListening').mockResolvedValue(false); vi.spyOn(preview, 'resolveCloudflared').mockResolvedValue('/fake/cloudflared'); vi.spyOn(preview, 'awaitTunnelRegistered').mockResolvedValue({ @@ -120,7 +122,7 @@ afterEach(() => { }); describe('Expo preview over our own cloudflared tunnel', () => { - it('brings the tunnel up FIRST, spawns Expo without --tunnel + with the proxy URL, and publishes exps://', async () => { + it('brings the tunnel up FIRST, spawns Expo without --tunnel + with the proxy URL, and publishes the web URL + exps://', async () => { const { events, emit } = collectEvents(); await runPreviewStart({ sessionId: 'sess-expo', @@ -148,13 +150,20 @@ describe('Expo preview over our own cloudflared tunnel', () => { expect(expo.env.DEBUG ?? '').not.toContain('expo:start:server:ngrok'); const ready = events.find((e) => e.type.endsWith('preview_ready')); - expect(ready?.payload).toEqual({ url: 'exps://host.example', framework: 'Expo', port: 8081 }); + // The web build in the frame, Expo Go beside it (owner 2026-10-31). + expect(ready?.payload).toEqual({ + url: 'https://host.example', + framework: 'Expo', + port: 8081, + expoGoUrl: 'exps://host.example', + }); // The tunnel is registered on the preview so `killPreview` tears it down. const active = preview.activePreviews.get('sess-expo'); expect(active?.tunnel).toBe(tunnel.child); expect(active?.devServer).toBe(expo.child); - expect(active?.url).toBe('exps://host.example'); + expect(active?.url).toBe('https://host.example'); + expect(active?.expoGoUrl).toBe('exps://host.example'); }, 30_000); it('goes READY on the local manifest probe when Expo prints nothing recognisable', async () => { @@ -176,7 +185,63 @@ describe('Expo preview over our own cloudflared tunnel', () => { expect.objectContaining({ timeoutMs: expect.any(Number) }), ); const ready = events.find((e) => e.type.endsWith('preview_ready')); - expect(ready?.payload.url).toBe('exps://host.example'); + expect(ready?.payload.url).toBe('https://host.example'); + }, 30_000); + + it('without the web packages: the install runs first (--no-save) and the web URL is the preview', async () => { + devServerScript = ` + process.stdout.write('Starting Metro Bundler\\n'); + setInterval(() => {}, 1000);`; + vi.spyOn(preview, 'waitForExpoManifest').mockResolvedValue(true); + vi.mocked(preview.expoWebDepsInstall).mockReturnValue({ + cmd: 'npm', + args: ['install', '--no-save', 'react-native-web@~0.21.0'], + }); + const install = vi.spyOn(preview, 'runSetupCommand').mockResolvedValue({ status: 'ok', code: 0 }); + + const { events, emit } = collectEvents(); + await runPreviewStart({ + sessionId: 'sess-expo-web', + detection: expoDetection, + cwd: '/tmp/fake-expo', + emit: emit as never, + }); + + expect(install.mock.calls[0]?.slice(0, 3)).toEqual([ + 'npm', + ['install', '--no-save', 'react-native-web@~0.21.0'], + '/tmp/fake-expo', + ]); + const ready = events.find((e) => e.type.endsWith('preview_ready')); + expect(ready?.payload).toEqual({ + url: 'https://host.example', + framework: 'Expo', + port: 8081, + expoGoUrl: 'exps://host.example', + }); + }, 30_000); + + it('without the web packages: a failed install keeps the Expo Go link alone (the QR surface, as before)', async () => { + devServerScript = ` + process.stdout.write('Starting Metro Bundler\\n'); + setInterval(() => {}, 1000);`; + vi.spyOn(preview, 'waitForExpoManifest').mockResolvedValue(true); + vi.mocked(preview.expoWebDepsInstall).mockReturnValue({ + cmd: 'npm', + args: ['install', '--no-save', 'react-native-web'], + }); + vi.spyOn(preview, 'runSetupCommand').mockResolvedValue({ status: 'failed', code: 1 }); + + const { events, emit } = collectEvents(); + await runPreviewStart({ + sessionId: 'sess-expo-noweb', + detection: expoDetection, + cwd: '/tmp/fake-expo', + emit: emit as never, + }); + + const ready = events.find((e) => e.type.endsWith('preview_ready')); + expect(ready?.payload).toEqual({ url: 'exps://host.example', framework: 'Expo', port: 8081 }); }, 30_000); it('tears the tunnel down when Expo dies before it is ready', async () => { diff --git a/apps/cli/src/commands/start/handlers.ts b/apps/cli/src/commands/start/handlers.ts index 340c0c13..20fbc6bc 100644 --- a/apps/cli/src/commands/start/handlers.ts +++ b/apps/cli/src/commands/start/handlers.ts @@ -2215,6 +2215,7 @@ export function makePreviewReaffirm(args: { url: active.url, framework: active.framework, port: active.detection.port, + ...(active.expoGoUrl ? { expoGoUrl: active.expoGoUrl } : {}), }; }, emitReady: (payload) => { diff --git a/apps/cli/src/services/preview/expo.ts b/apps/cli/src/services/preview/expo.ts index 22863cdb..3dc73832 100644 --- a/apps/cli/src/services/preview/expo.ts +++ b/apps/cli/src/services/preview/expo.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import * as http from 'http'; import * as path from 'path'; @@ -116,3 +117,55 @@ export async function waitForExpoManifest( await new Promise((r) => setTimeout(r, interval)); } } + +/** + * The packages `expo start` needs to serve the app on the WEB: `@expo/config` turns the web + * platform on when `react-dom` resolves from the project, and Metro then answers a plain + * browser `GET /` with the web build (owner 2026-10-31: the console Preview shows the app, not + * a QR; the QR stays as "Open in Expo Go"). + */ +export const EXPO_WEB_PACKAGES = ['react-native-web', 'react-dom', '@expo/metro-runtime'] as const; + +export function missingExpoWebPackages(cwd: string): string[] { + return EXPO_WEB_PACKAGES.filter( + (p) => !fs.existsSync(path.join(cwd, 'node_modules', p, 'package.json')), + ); +} + +/** + * `pkg@version` from the installed SDK's `bundledNativeModules.json` — the versions + * `expo install` would pick, so react-dom matches the project's react. A missing or unreadable + * file (an SDK we don't know) gives bare names. + */ +export function expoWebInstallSpecs(cwd: string, missing: readonly string[]): string[] { + let pinned: Record = {}; + try { + const raw = JSON.parse( + fs.readFileSync(path.join(cwd, 'node_modules', 'expo', 'bundledNativeModules.json'), 'utf8'), + ) as unknown; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) pinned = raw as Record; + } catch { + /* no SDK pin: bare names */ + } + return missing.map((p) => (typeof pinned[p] === 'string' ? `${p}@${String(pinned[p])}` : p)); +} + +/** + * The install that turns the web preview on, or `null` when nothing is missing. `--no-save`: + * the project's package.json and lockfile are the user's — the preview never edits them. + */ +export function expoWebDepsInstall(cwd: string): { cmd: string; args: string[] } | null { + const missing = missingExpoWebPackages(cwd); + if (missing.length === 0) return null; + return { + cmd: 'npm', + args: [ + 'install', + '--no-save', + '--legacy-peer-deps', + '--no-audit', + '--no-fund', + ...expoWebInstallSpecs(cwd, missing), + ], + }; +} diff --git a/apps/cli/src/services/preview/index.ts b/apps/cli/src/services/preview/index.ts index 8ee94937..1316d562 100644 --- a/apps/cli/src/services/preview/index.ts +++ b/apps/cli/src/services/preview/index.ts @@ -42,6 +42,8 @@ export interface ActivePreview { * where nothing of ours fronts the port (codespace port-forwarding). */ tunnel: ChildProcess | null; url: string; + /** Expo: the `exps://` link Expo Go opens, beside the web preview in `url`. */ + expoGoUrl?: string; framework: string; /** The detection this preview was started from, so a restart can * re-spawn it without re-reading `.codeam/preview.json`. */ diff --git a/apps/cli/src/services/preview/reaffirm.ts b/apps/cli/src/services/preview/reaffirm.ts index fba58e00..c6fc653e 100644 --- a/apps/cli/src/services/preview/reaffirm.ts +++ b/apps/cli/src/services/preview/reaffirm.ts @@ -40,10 +40,10 @@ export interface PreviewReaffirmDeps { * ¿Hay un preview REALMENTE sirviendo ahora mismo? Devuelve sus datos, o * `null` si no hay ninguno o su proceso ya murió. */ - serving: () => { url: string; framework: string; port: number } | null; + serving: () => { url: string; framework: string; port: number; expoGoUrl?: string } | null; /** Re-emite `preview_ready`. Debe ir por el MISMO emisor serializado que las * transiciones, para que una re-afirmación no adelante a un evento real. */ - emitReady: (payload: { url: string; framework: string; port: number }) => void; + emitReady: (payload: { url: string; framework: string; port: number; expoGoUrl?: string }) => void; /** * Publishes `preview_stopped` once, on the FIRST beat of this process, when * nothing is serving. The backend keeps `preview:` for an hour, so diff --git a/apps/cli/src/services/preview/start-orchestrator.ts b/apps/cli/src/services/preview/start-orchestrator.ts index 6f231fdd..f6d3e6eb 100644 --- a/apps/cli/src/services/preview/start-orchestrator.ts +++ b/apps/cli/src/services/preview/start-orchestrator.ts @@ -337,6 +337,7 @@ export async function runPreviewStart(args: PreviewStartArgs): Promise { url: existing.url, framework: existing.framework, port: detection.port, + ...(existing.expoGoUrl ? { expoGoUrl: existing.expoGoUrl } : {}), }); return; } @@ -389,6 +390,21 @@ export async function runPreviewStart(args: PreviewStartArgs): Promise { async function runExpoBringUp(ctx: StageCtx): Promise { const { detection, emit, emitProgress } = ctx; + // The WEB preview (owner 2026-10-31): Metro serves the app to a browser once `react-dom` + // resolves from the project, so the web packages are installed first (`--no-save`, the + // project's package.json is the user's). Best-effort: without them the preview is the Expo Go + // QR alone, as before. + const webInstall = previewSvc.expoWebDepsInstall(ctx.cwd); + let web = webInstall === null; + if (webInstall) { + emitProgress('SETUP_RUN', `${webInstall.cmd} ${webInstall.args.join(' ')} (Expo web preview)`); + const r = await previewSvc.runSetupCommand(webInstall.cmd, webInstall.args, ctx.cwd, detection.env, { + timeoutMs: INSTALL_TIMEOUT_MS, + }); + web = r.status === 'ok'; + if (!web) emitProgress('SETUP_RUN', `web packages not installed (${r.status}) — Expo Go only`); + } + emitProgress('TUNNEL_STARTING', `cloudflared quick tunnel → Expo :${detection.port}`); const pub = await bringUpPublicUrl(ctx, detection.port); if (!pub.ok) { @@ -404,8 +420,11 @@ async function runExpoBringUp(ctx: StageCtx): Promise { return; } + const expoGoUrl = previewSvc.expoGoDeepLink(pub.url); announceReady(ctx, dev, { - url: previewSvc.expoGoDeepLink(pub.url), + // The web build in the frame + Expo Go beside it; an SDK without web deps keeps the QR alone. + url: web ? pub.url : expoGoUrl, + expoGoUrl: web ? expoGoUrl : undefined, tunnel: pub.tunnel, // No inspector: a DOM inspector means nothing against a native app. inspector: null, @@ -422,6 +441,7 @@ function announceReady(ctx: StageCtx, dev: DevServerUp, tun: TunnelUp): void { tunnel: tun.tunnel, inspector: tun.inspector, url: tun.url, + expoGoUrl: tun.expoGoUrl, framework: detection.framework, detection, cwd: ctx.cwd, @@ -443,6 +463,7 @@ function announceReady(ctx: StageCtx, dev: DevServerUp, tun: TunnelUp): void { url: tun.url, framework: detection.framework, port: detection.port, + ...(tun.expoGoUrl ? { expoGoUrl: tun.expoGoUrl } : {}), }); } @@ -696,6 +717,7 @@ async function startDevServer( url: raceExisting.url, framework: raceExisting.framework, port: detection.port, + ...(raceExisting.expoGoUrl ? { expoGoUrl: raceExisting.expoGoUrl } : {}), }); return null; } @@ -918,6 +940,8 @@ async function startDevServer( /** What stage 3 hands back for registration. */ interface TunnelUp { url: string; + /** Expo: the `exps://` link Expo Go opens, beside the web preview in `url`. */ + expoGoUrl?: string; /** The cloudflared child fronting the dev server (or the inspector proxy). */ tunnel: ReturnType; /** diff --git a/packages/shared/src/types/preview.ts b/packages/shared/src/types/preview.ts index 0bca93a6..9c38d8f0 100644 --- a/packages/shared/src/types/preview.ts +++ b/packages/shared/src/types/preview.ts @@ -143,6 +143,12 @@ export interface PreviewStatus { /** Who started it — lets a reconnecting client keep the agent-launch UI. */ origin?: PreviewOrigin; url?: string; + /** + * Expo only (codeam-cli ≥ 2.76.42): `url` is the WEB preview served by Metro (react-native-web) + * and this is the `exps://` link Expo Go opens for the same server. An older CLI sends the + * `exps://` link AS `url` and no `expoGoUrl` — the apps keep showing the QR alone for it. + */ + expoGoUrl?: string; framework?: string; detection?: PreviewDetection; error?: { stage: PreviewErrorStage; message: string; code?: PreviewErrorCode };