Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/expo-device-hub-website-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---

Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { BuildPhase, BuildPhaseResult, LogMarker } from '@expo/eas-build-job';
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import * as ngrok from '@ngrok/ngrok';
import { access, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
import { mkdirSync, writeFileSync } from 'node:fs';
import { access, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import type { CustomBuildContext } from '../../../customBuildContext';
Expand All @@ -17,7 +19,7 @@ import {
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
import { EXPO_DEVICE_HUB_STATE_DIR, startDeviceSessionHostAsync } from '../deviceSessionHost';
import {
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
Expand Down Expand Up @@ -74,8 +76,27 @@ const logger = {
const stopServer = jest.fn();
const closeTunnel = jest.fn();
const directories: string[] = [];
const hubStateFiles: string[] = [];
const baseDomain = 'preview.example.test';

// expo-device-hub records its session token in `server-<port>.json` before it answers /readyz,
// in the state directory it resolves from its env.
function writeHubState({
args,
env: hubEnv,
}: {
args: readonly string[];
env: BuildStepEnv;
}): void {
const port = args[args.indexOf('--port') + 1];
const stateDir =
hubEnv.EXPO_DEVICE_HUB_STATE_DIR || path.join(hubEnv.TMPDIR ?? os.tmpdir(), 'expo-device-hub');
const file = path.join(stateDir, `server-${port}.json`);
mkdirSync(stateDir, { recursive: true });
writeFileSync(file, JSON.stringify({ pid: 1, port: Number(port), token: 'hub-preview-token' }));
hubStateFiles.push(file);
}

async function startHostAsync(separateLogPhase = true) {
return await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.LINUX,
Expand Down Expand Up @@ -103,6 +124,9 @@ beforeEach(() => {
if (flag >= 0) {
directories.push(options.args[flag + 1]);
}
if (options.args.includes('--platform')) {
writeHubState(options);
}
return {
pid: undefined,
getOutput: () => '',
Expand All @@ -126,7 +150,9 @@ beforeEach(() => {
afterEach(async () => {
jest.useRealTimers();
await Promise.all(
directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))
[...directories.splice(0), ...hubStateFiles.splice(0)].map(entry =>
rm(entry, { recursive: true, force: true })
)
);
});

Expand Down Expand Up @@ -243,6 +269,7 @@ it('drains an in-flight tunnel when finishing and rejects new opens', async () =
it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
writeHubState(options);
return {
pid: undefined,
getOutput: () => '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255',
Expand Down Expand Up @@ -284,6 +311,7 @@ it('keeps cleanup and upload best-effort when tunnel close and finalization fail
it('logs the reason and skips upload and output dump when the Hub never recorded', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
writeHubState(options);
return {
pid: undefined,
getOutput: () => '[serve-emu] Android recording skipped',
Expand Down Expand Up @@ -474,6 +502,55 @@ it('closes a listener that arrives after finish has already timed out waiting fo
expect(closeTunnel).toHaveBeenCalledTimes(1);
});

// The same session token and website origins as the serve-sim host on iOS.
it('starts expo-device-hub with a session token and the website origins', async () => {
const host = await startHostAsync();
const { args } = jest.mocked(spawnDetached).mock.calls[0][0];
expect(args).toContain('--require-token');
expect(args.join(' ')).toContain(
'--cors-origin https://expo.dev --frame-ancestor https://expo.dev'
);
const preview = await host.openPreviewAsync({ baseDomain });
expect(preview.previewToken).toBe('hub-preview-token');
await host.finishAsync();
});

it.each([
['its own state directory', 'EXPO_DEVICE_HUB_STATE_DIR'],
['its own TMPDIR', 'TMPDIR'],
])('reads the Hub token when the step env sets %s', async (_description, variable) => {
const override = await mkdtemp(path.join(os.tmpdir(), 'hub-state-override-'));
directories.push(override);
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.LINUX,
env: { ...env, [variable]: override },
logger,
separateLogPhase: true,
timeoutMs: 10_000,
});
const options = jest.mocked(spawnDetached).mock.calls[0][0];
expect(options.env.EXPO_DEVICE_HUB_STATE_DIR).toBe(EXPO_DEVICE_HUB_STATE_DIR);
expect((await host.openPreviewAsync({ baseDomain })).previewToken).toBe('hub-preview-token');
await host.finishAsync();
});

it('stops when expo-device-hub records no session token', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
const flag = options.args.indexOf('--android-recording-directory');
directories.push(options.args[flag + 1]);
return {
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
await expect(startHostAsync()).rejects.toThrow(
'expo-device-hub became ready but wrote no session token'
);
expect(stopServer).toHaveBeenCalledTimes(1);
});

it('leaves iOS recording to its existing build steps', async () => {
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
Expand All @@ -500,13 +577,21 @@ const turnArgs = [
'turn-secret',
];

it('passes the TURN credential and the Android control token to the host as secrets', async () => {
it('passes the TURN credential and the Android control token to the host as secrets, and adds the session token once the Hub is ready', async () => {
jest.mocked(fetchWebPreviewTurnArgsAsync).mockResolvedValueOnce(turnArgs);
const spawn = jest.mocked(spawnDetached).getMockImplementation()!;
let secretsAtSpawn: string[] | undefined;
jest.mocked(spawnDetached).mockImplementationOnce(options => {
secretsAtSpawn = [...(options.secrets ?? [])];
return spawn(options);
});
const host = await startHostAsync();
const options = jest.mocked(spawnDetached).mock.calls[0][0];
const controlToken = options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN;
expect(controlToken).toMatch(/^[a-f0-9]{64}$/);
expect(options.secrets).toEqual(['turn-secret', controlToken]);
expect(secretsAtSpawn).toEqual(['turn-secret', controlToken]);
// Host output is redacted against this array, so the token must land in the one passed at spawn.
expect(options.secrets).toEqual(['turn-secret', controlToken, 'hub-preview-token']);
await host.finishAsync();
});

Expand Down Expand Up @@ -642,6 +727,7 @@ it.each([
let exitError: Error | undefined;
jest.mocked(spawnDetached).mockImplementationOnce(options => {
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
writeHubState(options);
return {
pid: undefined,
getOutput: () => '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { EventEmitter } from 'node:events';
import { mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
Expand Down Expand Up @@ -38,13 +39,14 @@ import {
} from '../remoteDeviceRunSession';

import {
EXPO_DEVICE_HUB_STATE_DIR,
createExpoDeviceHubArgs,
createServeSimArgs,
simulatorPreviewPageUrl,
startDeviceSessionHostAsync,
waitForWebPreviewReadyAsync,
websiteOrigin,
websiteOriginServeSimArgs,
websiteOriginArgs,
} from '../deviceSessionHost';
import { parseNetworkCaptureFieldsInput, parseNetworkCaptureInputs } from '../networkCaptureFields';

Expand Down Expand Up @@ -561,6 +563,7 @@ describe(createExpoDeviceHubArgs, () => {
'4321',
'--host',
'127.0.0.1',
'--require-token',
'--platform',
'android',
'--transport',
Expand All @@ -582,16 +585,33 @@ describe(createExpoDeviceHubArgs, () => {
]);
});

// The same website flags as serve-sim, after the TURN args.
it('appends the website args after the TURN args when provided', () => {
const args = createExpoDeviceHubArgs({
port: 4321,
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'],
});
expect(args.slice(-6)).toEqual([
'--turn-url',
'turns:turn.example.test:443',
'--cors-origin',
'https://expo.dev',
'--frame-ancestor',
'https://expo.dev',
]);
});

it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
'expo-device-hub@0.7.0'
);
});
});

describe(websiteOriginServeSimArgs, () => {
describe(websiteOriginArgs, () => {
it('names only the production website by default', () => {
expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([
expect(websiteOriginArgs({} as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.dev',
'--frame-ancestor',
Expand All @@ -600,7 +620,7 @@ describe(websiteOriginServeSimArgs, () => {
});

it('names staging, its deploy previews and local website subdomains on staging', () => {
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
const args = websiteOriginArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
expect(args).toEqual([
'--cors-origin',
'https://staging.expo.dev',
Expand All @@ -623,14 +643,14 @@ describe(websiteOriginServeSimArgs, () => {

it('names only https origins', () => {
for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) {
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
const args = websiteOriginArgs(env as BuildStepEnv);
expect(args.filter(value => value.startsWith('http://'))).toEqual([]);
}
});

it('names local website subdomains without the deploy-preview wildcard on local', () => {
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
expect(websiteOriginServeSimArgs(env as BuildStepEnv)).toEqual([
expect(websiteOriginArgs(env as BuildStepEnv)).toEqual([
'--cors-origin',
'https://expo.test',
'--frame-ancestor',
Expand Down Expand Up @@ -831,6 +851,7 @@ describe(startDeviceSessionHostAsync, () => {
DEVICE_RUN_SESSION_ID: 'drs-id',
NGROK_AUTHTOKEN: 'ngrok-token',
} as unknown as BuildStepEnv;
const hubStateFiles: string[] = [];

beforeEach(() => {
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
Expand All @@ -852,7 +873,17 @@ describe(startDeviceSessionHostAsync, () => {
}),
},
});
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
// expo-device-hub records its session token in `server-<port>.json` before it is ready.
jest.mocked(spawn).mockImplementation(((_command: string, args: string[] = []) => {
if (args.includes('--platform')) {
const port = args[args.indexOf('--port') + 1];
const file = path.join(EXPO_DEVICE_HUB_STATE_DIR, `server-${port}.json`);
mkdirSync(EXPO_DEVICE_HUB_STATE_DIR, { recursive: true });
writeFileSync(file, JSON.stringify({ pid: 1, port: Number(port), token: 'hub-token' }));
hubStateFiles.push(file);
}
return spawnPromise;
}) as never);

jest.mocked(turtleFetch).mockImplementation(async url => {
if (url.endsWith('/turn-ice-servers')) {
Expand All @@ -876,8 +907,9 @@ describe(startDeviceSessionHostAsync, () => {
});
});

afterEach(() => {
afterEach(async () => {
jest.restoreAllMocks();
await Promise.all(hubStateFiles.splice(0).map(file => fs.rm(file, { force: true })));
});

it('gives serve-sim its recording grace period on shutdown', async () => {
Expand Down Expand Up @@ -947,7 +979,13 @@ describe(startDeviceSessionHostAsync, () => {
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
expect(args).toEqual([
'--yes',
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
...createExpoDeviceHubArgs({
port,
turnArgs,
websiteArgs,
packageVersion,
recordingDirectory,
}),
]);
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
expect(preview.apiUrl).toBe('https://android-preview.example.test');
Expand Down Expand Up @@ -1026,8 +1064,8 @@ describe(startDeviceSessionHostAsync, () => {
).rejects.toThrow(/wrote no session token/);
});

// expo-device-hub mints no token, so the Android preview must not require one.
it('starts for Linux without a token, and does not gate expo-device-hub', async () => {
// expo-device-hub records its token in a state file, as serve-sim does.
it('carries the expo-device-hub session token for Linux', async () => {
jest.mocked(readServeSimServersAsync).mockResolvedValue([]);
jest.mocked(ngrok.forward).mockResolvedValue({
url: () => 'https://android-preview.example.test',
Expand All @@ -1042,9 +1080,10 @@ describe(startDeviceSessionHostAsync, () => {
});
const preview = await host.openPreviewAsync({ baseDomain });

expect(preview.previewToken).toBeUndefined();
const [, args] = jest.mocked(spawn).mock.calls[0];
expect(args).not.toContain('--require-token');
expect(preview.previewToken).toBe('hub-token');
const [, args] = jest.mocked(spawn).mock.calls.find(([command]) => command === 'npx')!;
expect(args).toContain('--require-token');
await host.finishAsync();
});

it('starts serve-sim for Darwin with its metrics policy and cleans up the preview resources', async () => {
Expand Down
Loading
Loading