Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 164 additions & 1 deletion packages/targets/mobile-expo/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@profullstack/sh1pt-core')>('@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',
});
});
});
140 changes: 131 additions & 9 deletions packages/targets/mobile-expo/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string, string | undefined> {
return { EXPO_TOKEN: ctx.secret('EXPO_TOKEN') };
}

export default defineTarget<Config>({
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({
Expand Down
Loading