mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 13:08:11 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29200b85a | ||
|
|
f61bbf3c24 | ||
|
|
63712a252a | ||
|
|
8b58764384 |
No files matched your search
@@ -125,6 +125,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
});
|
||||
|
||||
const publicToolsUrl = await startNgrokTunnelAsync({
|
||||
|
||||
@@ -30,19 +30,18 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Preview URL: ${previewUrl}`);
|
||||
logger.info(`Stream URL: ${streamUrl}`);
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { previewUrl, streamUrl },
|
||||
remoteConfig: { previewUrl },
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
@@ -10,6 +11,12 @@ jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
|
||||
for await (const chunk of stream as Readable) {
|
||||
void chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
@@ -70,16 +77,21 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
it('downloads an Argent artifact and uploads it as a device run session artifact', async () => {
|
||||
const data = Buffer.from('artifact-data');
|
||||
const reportedSize = 1024;
|
||||
const ctx = {
|
||||
logger: createLoggerMock(),
|
||||
} as unknown as CustomBuildContext;
|
||||
const logger = createLoggerMock();
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(data));
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
toolsUrl: 'http://127.0.0.1:1234',
|
||||
toolsAuthToken: 'tools-token',
|
||||
logger,
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
filename: 'report.json',
|
||||
@@ -96,7 +108,7 @@ describe(uploadArgentArtifactAsync, () => {
|
||||
artifactId: 'artifact-id',
|
||||
name: 'report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
size: reportedSize,
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
createServeSimTunnelArgs,
|
||||
fetchServeSimTurnArgsAsync,
|
||||
turnIceServersToServeSimArgs,
|
||||
} from '../remoteDeviceRunSession';
|
||||
@@ -36,6 +37,51 @@ function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(createServeSimTunnelArgs, () => {
|
||||
it('builds ngrok/H264 serve-sim args and passes through TURN flags', () => {
|
||||
expect(
|
||||
createServeSimTunnelArgs({
|
||||
baseDomain: 'expo-simulator.ngrok.dev',
|
||||
turnArgs: [
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
'--turn-url',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'u',
|
||||
'--turn-credential',
|
||||
'c',
|
||||
],
|
||||
})
|
||||
).toEqual([
|
||||
'serve-sim-sjchmiela@latest',
|
||||
'--tunnel',
|
||||
'--tunnel-provider',
|
||||
'ngrok',
|
||||
'--tunnel-domain',
|
||||
'expo-simulator.ngrok.dev',
|
||||
'--stream-max-dimension',
|
||||
'1280',
|
||||
'--stream-quality',
|
||||
'0.55',
|
||||
'--stream-fps',
|
||||
'10',
|
||||
'--h264-bitrate',
|
||||
'3000000',
|
||||
'--h264-max-fps',
|
||||
'30',
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
'--turn-url',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'u',
|
||||
'--turn-credential',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToServeSimArgs, () => {
|
||||
it('returns no args for an empty ICE server list', () => {
|
||||
expect(turnIceServersToServeSimArgs([])).toEqual([]);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises';
|
||||
import fetch from 'node-fetch';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -29,13 +35,14 @@ export async function pollArgentArtifactsForUploadAsync(
|
||||
deviceRunSessionId,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
logger,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<never> {
|
||||
const { logger } = ctx;
|
||||
logger.info('Started polling Argent tool-server for artifacts.');
|
||||
const seenArtifactIds = new Set<string>();
|
||||
let listArtifactsErrorCount = 0;
|
||||
@@ -54,6 +61,7 @@ export async function pollArgentArtifactsForUploadAsync(
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
artifact,
|
||||
logger,
|
||||
}).catch(err => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload Argent remote session artifact', error);
|
||||
@@ -104,39 +112,52 @@ export async function uploadArgentArtifactAsync(
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
artifact,
|
||||
logger,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
artifact: ArgentArtifact;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const filename = artifact.isDirectory ? `${artifact.filename}.tar.gz` : artifact.filename;
|
||||
ctx.logger.info(`Uploading artifact ${filename} (${formatBytes(artifact.size)}).`);
|
||||
const stream = await createArgentArtifactDownloadStreamAsync({
|
||||
artifact,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
});
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: artifact.id,
|
||||
name: `${filename} (${artifact.id})`,
|
||||
filename,
|
||||
size: artifact.size,
|
||||
stream,
|
||||
});
|
||||
logger.info(`Downloading artifact ${filename}.`);
|
||||
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'argent-artifact-'));
|
||||
try {
|
||||
const temporaryArtifactPath = path.join(temporaryDirectory, path.basename(filename));
|
||||
await downloadArgentArtifactToFileAsync({
|
||||
artifact,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
destinationPath: temporaryArtifactPath,
|
||||
});
|
||||
const { size } = await stat(temporaryArtifactPath);
|
||||
logger.info(`Uploading artifact ${filename} (${formatBytes(size)}).`);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: artifact.id,
|
||||
name: `${filename} (${artifact.id})`,
|
||||
filename,
|
||||
size,
|
||||
stream: createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function createArgentArtifactDownloadStreamAsync({
|
||||
async function downloadArgentArtifactToFileAsync({
|
||||
artifact,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
destinationPath,
|
||||
}: {
|
||||
artifact: ArgentArtifact;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
}): Promise<NodeJS.ReadableStream> {
|
||||
destinationPath: string;
|
||||
}): Promise<void> {
|
||||
const response = await fetch(new URL(`/artifacts/${artifact.id}`, toolsUrl).toString(), {
|
||||
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
|
||||
});
|
||||
@@ -150,5 +171,5 @@ async function createArgentArtifactDownloadStreamAsync({
|
||||
`Argent artifact ${artifact.id} response did not include a readable body.`
|
||||
);
|
||||
}
|
||||
return response.body;
|
||||
await pipeline(response.body, createWriteStream(destinationPath));
|
||||
}
|
||||
@@ -14,13 +14,17 @@ import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
const SERVE_SIM_PACKAGE_SPEC = 'serve-sim-sjchmiela@latest';
|
||||
const SERVE_SIM_MJPEG_FALLBACK_MAX_DIMENSION = '1280';
|
||||
const SERVE_SIM_MJPEG_FALLBACK_QUALITY = '0.55';
|
||||
const SERVE_SIM_MJPEG_FALLBACK_FPS = '10';
|
||||
const SERVE_SIM_H264_BITRATE = '3000000';
|
||||
const SERVE_SIM_H264_MAX_FPS = '30';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
deviceRunSession {
|
||||
startDeviceRunSession(
|
||||
deviceRunSessionId: $deviceRunSessionId
|
||||
remoteConfig: $remoteConfig
|
||||
) {
|
||||
startDeviceRunSession(deviceRunSessionId: $deviceRunSessionId, remoteConfig: $remoteConfig) {
|
||||
id
|
||||
status
|
||||
}
|
||||
@@ -236,45 +240,60 @@ export async function startServeSimWithTunnelAsync(
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
): Promise<{ previewUrl: string }> {
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'serve-sim-szdziedzic@latest',
|
||||
'--tunnel',
|
||||
'--tunnel-provider',
|
||||
'ngrok',
|
||||
'--tunnel-domain',
|
||||
baseDomain,
|
||||
'--stream-max-dimension',
|
||||
'1280',
|
||||
'--stream-quality',
|
||||
'0.55',
|
||||
'--codec',
|
||||
'webrtc',
|
||||
...turnArgs,
|
||||
],
|
||||
args: createServeSimTunnelArgs({ baseDomain, turnArgs }),
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
|
||||
logger.info('Waiting for serve-sim to report tunnel URL.');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const output = serveSim.getOutput();
|
||||
const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
|
||||
const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
|
||||
if (previewUrl && streamUrl) {
|
||||
return { previewUrl, streamUrl };
|
||||
if (previewUrl) {
|
||||
return { previewUrl };
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
`Timed out waiting for serve-sim to report Tunnel URL. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export function createServeSimTunnelArgs({
|
||||
baseDomain,
|
||||
turnArgs = [],
|
||||
}: {
|
||||
baseDomain: string;
|
||||
turnArgs?: string[];
|
||||
}): string[] {
|
||||
return [
|
||||
SERVE_SIM_PACKAGE_SPEC,
|
||||
'--tunnel',
|
||||
'--tunnel-provider',
|
||||
'ngrok',
|
||||
'--tunnel-domain',
|
||||
baseDomain,
|
||||
// MJPEG remains the browser fallback when AVCC/H.264 is unavailable.
|
||||
'--stream-max-dimension',
|
||||
SERVE_SIM_MJPEG_FALLBACK_MAX_DIMENSION,
|
||||
'--stream-quality',
|
||||
SERVE_SIM_MJPEG_FALLBACK_QUALITY,
|
||||
'--stream-fps',
|
||||
SERVE_SIM_MJPEG_FALLBACK_FPS,
|
||||
// The tunneled helper advertises /stream.avcc, so keep H.264 network usage bounded.
|
||||
'--h264-bitrate',
|
||||
SERVE_SIM_H264_BITRATE,
|
||||
'--h264-max-fps',
|
||||
SERVE_SIM_H264_MAX_FPS,
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
function matchLabeledUrl({
|
||||
output,
|
||||
label,
|
||||
|
||||
Reference in new issue
Block a user