diff --git a/packages/targets/mobile-expo/src/index.test.ts b/packages/targets/mobile-expo/src/index.test.ts index 78bf41bd..82784237 100644 --- a/packages/targets/mobile-expo/src/index.test.ts +++ b/packages/targets/mobile-expo/src/index.test.ts @@ -1,4 +1,167 @@ -import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { fakeBuildContext, fakeShipContext, makeVault, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execMock } = vi.hoisted(() => ({ + execMock: vi.fn(), +})); + +vi.mock('@profullstack/sh1pt-core', async () => ({ + ...await vi.importActual('@profullstack/sh1pt-core'), + exec: execMock, +})); + import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'mobile', requireKind: true }); + +const tempDirs: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('mobile-expo target adapter', () => { + it('writes an EAS package plan without invoking the CLI in dry-run builds', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-expo-out-')); + const projectDir = await mkdtemp(join(tmpdir(), 'sh1pt-expo-project-')); + tempDirs.push(outDir, projectDir); + + const result = await adapter.build(fakeBuildContext({ + outDir, + projectDir, + version: '1.2.3', + channel: 'beta', + dryRun: true, + }) as any, { + appId: '@acme/mobile-app', + platform: 'ios', + profile: 'internal', + }); + + expect(execMock).not.toHaveBeenCalled(); + expect(result.artifact).toBe(join(outDir, 'acme-mobile-app-1.2.3.eas-plan.json')); + const plan = JSON.parse(await readFile(result.artifact, 'utf-8')); + expect(plan).toEqual({ + provider: 'expo-eas', + appId: '@acme/mobile-app', + version: '1.2.3', + channel: 'beta', + platform: 'ios', + profile: 'internal', + projectDir, + build: { + command: 'eas', + args: ['build', '--platform', 'ios', '--profile', 'internal', '--non-interactive', '--json'], + cwd: projectDir, + }, + metadataArtifact: join(outDir, 'acme-mobile-app-1.2.3.eas-build.json'), + }); + }); + + it('runs EAS with argv args and writes build metadata for real builds', async () => { + execMock.mockResolvedValue({ exitCode: 0, stdout: '{"buildId":"abc"}\n', stderr: '' }); + + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-expo-out-')); + const projectDir = await mkdtemp(join(tmpdir(), 'sh1pt-expo-project-')); + tempDirs.push(outDir, projectDir); + + const ctx = fakeBuildContext({ + outDir, + projectDir, + version: '1.2.3', + channel: 'stable', + dryRun: false, + secret: makeVault({ EXPO_TOKEN: 'expo-token' }), + }); + const result = await adapter.build(ctx as any, { + appId: 'acme-mobile', + platform: 'android', + }); + + const artifact = join(outDir, 'acme-mobile-1.2.3.eas-build.json'); + expect(execMock).toHaveBeenCalledWith('eas', [ + 'build', + '--platform', + 'android', + '--profile', + 'production', + '--non-interactive', + '--json', + ], { + cwd: projectDir, + env: { EXPO_TOKEN: 'expo-token' }, + log: ctx.log, + throwOnNonZero: true, + }); + expect(result).toEqual({ artifact }); + expect(await readFile(artifact, 'utf-8')).toBe('{"buildId":"abc"}\n'); + }); + + it('returns the planned EAS update command for dry-run ships', async () => { + const ctx = fakeShipContext({ + projectDir: '/tmp/expo-app', + version: '1.2.3', + channel: 'canary', + dryRun: true, + }); + + const result = await adapter.ship(ctx as any, { + appId: 'acme-mobile', + }); + + expect(execMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + id: 'dry-run', + meta: { + command: { + command: 'eas', + args: ['update', '--channel', 'canary', '--non-interactive'], + cwd: '/tmp/expo-app', + }, + }, + }); + }); + + it('runs EAS submit with argv args for real submit ships', async () => { + execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); + const ctx = fakeShipContext({ + projectDir: '/tmp/expo-app', + version: '1.2.3', + channel: 'stable', + dryRun: false, + secret: makeVault({ EXPO_TOKEN: 'expo-token' }), + }); + + const result = await adapter.ship(ctx as any, { + appId: 'acme-mobile', + platform: 'ios', + profile: 'production', + submit: true, + }); + + expect(execMock).toHaveBeenCalledWith('eas', [ + 'submit', + '--platform', + 'ios', + '--profile', + 'production', + '--non-interactive', + ], { + cwd: '/tmp/expo-app', + env: { EXPO_TOKEN: 'expo-token' }, + log: ctx.log, + throwOnNonZero: true, + }); + expect(result).toEqual({ + id: 'acme-mobile@1.2.3', + url: 'https://expo.dev/accounts/acme-mobile', + }); + }); +}); diff --git a/packages/targets/mobile-expo/src/index.ts b/packages/targets/mobile-expo/src/index.ts index 700d292d..74b67db2 100644 --- a/packages/targets/mobile-expo/src/index.ts +++ b/packages/targets/mobile-expo/src/index.ts @@ -1,4 +1,6 @@ -import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; +import { defineTarget, exec, manualSetup } from '@profullstack/sh1pt-core'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; interface Config { appId: string; @@ -7,21 +9,141 @@ interface Config { submit?: boolean; } +interface EasCommand { + command: 'eas'; + args: string[]; + cwd: string; +} + +interface ExpoBuildPlan { + provider: 'expo-eas'; + appId: string; + version: string; + channel: string; + platform: 'ios' | 'android' | 'all'; + profile: string; + projectDir: string; + build: EasCommand; + metadataArtifact: string; +} + +interface ExpoShipPlan { + provider: 'expo-eas'; + appId: string; + version: string; + channel: string; + platform: 'ios' | 'android' | 'all'; + profile: string; + action: 'submit' | 'update'; + projectDir: string; + command: EasCommand; +} + +function platform(config: Config): 'ios' | 'android' | 'all' { + return config.platform ?? 'all'; +} + +function profile(ctx: { channel: string }, config: Config): string { + return config.profile ?? (ctx.channel === 'stable' ? 'production' : 'preview'); +} + +function safeFileStem(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-|-$/g, '') || 'expo-app'; +} + +function buildMetadataPath(ctx: { outDir: string; version: string }, config: Config): string { + return join(ctx.outDir, `${safeFileStem(config.appId)}-${safeFileStem(ctx.version)}.eas-build.json`); +} + +function buildPlan( + ctx: { projectDir: string; outDir: string; version: string; channel: string }, + config: Config, +): ExpoBuildPlan { + const selectedPlatform = platform(config); + const selectedProfile = profile(ctx, config); + return { + provider: 'expo-eas', + appId: config.appId, + version: ctx.version, + channel: ctx.channel, + platform: selectedPlatform, + profile: selectedProfile, + projectDir: ctx.projectDir, + build: { + command: 'eas', + args: ['build', '--platform', selectedPlatform, '--profile', selectedProfile, '--non-interactive', '--json'], + cwd: ctx.projectDir, + }, + metadataArtifact: buildMetadataPath(ctx, config), + }; +} + +function shipPlan( + ctx: { projectDir: string; version: string; channel: string }, + config: Config, +): ExpoShipPlan { + const selectedPlatform = platform(config); + const selectedProfile = profile(ctx, config); + const action = config.submit ? 'submit' : 'update'; + const args = config.submit + ? ['submit', '--platform', selectedPlatform, '--profile', selectedProfile, '--non-interactive'] + : ['update', '--channel', ctx.channel, '--non-interactive']; + + return { + provider: 'expo-eas', + appId: config.appId, + version: ctx.version, + channel: ctx.channel, + platform: selectedPlatform, + profile: selectedProfile, + action, + projectDir: ctx.projectDir, + command: { + command: 'eas', + args, + cwd: ctx.projectDir, + }, + }; +} + +function expoEnv(ctx: { secret(key: string): string | undefined }): Record { + return { EXPO_TOKEN: ctx.secret('EXPO_TOKEN') }; +} + export default defineTarget({ id: 'mobile-expo', kind: 'mobile', label: 'Expo / EAS', async build(ctx, config) { - const platform = config.platform ?? 'all'; - const profile = config.profile ?? (ctx.channel === 'stable' ? 'production' : 'preview'); - ctx.log(`eas build --platform ${platform} --profile ${profile}`); - return { artifact: `${ctx.outDir}/expo-eas-build` }; + const plan = buildPlan(ctx, config); + ctx.log(`eas build --platform ${plan.platform} --profile ${plan.profile}`); + await mkdir(ctx.outDir, { recursive: true }); + + if (ctx.dryRun) { + const planPath = join(ctx.outDir, `${safeFileStem(config.appId)}-${safeFileStem(ctx.version)}.eas-plan.json`); + await writeFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, 'utf-8'); + return { artifact: planPath, meta: { command: plan.build, metadataArtifact: plan.metadataArtifact } }; + } + + const result = await exec(plan.build.command, plan.build.args, { + cwd: plan.build.cwd, + env: expoEnv(ctx), + log: ctx.log, + throwOnNonZero: true, + }); + await writeFile(plan.metadataArtifact, result.stdout || '{}\n', 'utf-8'); + return { artifact: plan.metadataArtifact }; }, async ship(ctx, config) { - const platform = config.platform ?? 'all'; - const profile = config.profile ?? (ctx.channel === 'stable' ? 'production' : 'preview'); - ctx.log(config.submit ? `eas submit --platform ${platform} --profile ${profile}` : `eas update --channel ${ctx.channel}`); - if (ctx.dryRun) return { id: 'dry-run' }; + const plan = shipPlan(ctx, config); + ctx.log(config.submit ? `eas submit --platform ${plan.platform} --profile ${plan.profile}` : `eas update --channel ${ctx.channel}`); + if (ctx.dryRun) return { id: 'dry-run', meta: { command: plan.command } }; + await exec(plan.command.command, plan.command.args, { + cwd: plan.command.cwd, + env: expoEnv(ctx), + log: ctx.log, + throwOnNonZero: true, + }); return { id: `${config.appId}@${ctx.version}`, url: `https://expo.dev/accounts/${config.appId}` }; }, setup: manualSetup({