Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 馃帀 New features

- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))

### 馃悰 Bug fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
controlPort,
authfilePath,
env,
logger,
signal: startupSignal,
});
server = started.process;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import {
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
import { ensureFfmpegInstalledOnceAsync, spawnDetached } from '../remoteDeviceRunSession';
import {
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
spawnDetached,
} from '../remoteDeviceRunSession';

jest.mock('@ngrok/ngrok');
jest.mock('../deviceRunSessionArtifacts');
Expand Down Expand Up @@ -574,3 +578,47 @@ it('keeps the session running when encoder setup for the preview fails', async (
);
await host.finishAsync();
});

it('adds the iOS preview token to the host secrets once serve-sim is ready', async () => {
jest
.mocked(fetchWebPreviewTurnArgsAsync)
.mockResolvedValueOnce(['--turn-credential', 'turn-secret']);
let secretsAtSpawn: string[] | undefined;
jest.mocked(spawnDetached).mockImplementationOnce(options => {
secretsAtSpawn = [...(options.secrets ?? [])];
return {
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopServer,
};
});
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.DARWIN,
env,
logger,
timeoutMs: 10_000,
});
expect(secretsAtSpawn).toEqual(['turn-secret']);
// Host output is redacted against this array, so the token must land in the one passed at spawn.
expect(jest.mocked(spawnDetached).mock.calls[0][0].secrets).toEqual([
'turn-secret',
'preview-token',
]);
expect((await host.openPreviewAsync({ baseDomain })).previewToken).toBe('preview-token');
await host.finishAsync();
});

it('uses the existing step logger for startup and shutdown output', async () => {
const host = await startHostAsync();
const processLogger = jest.mocked(spawnDetached).mock.calls[0][0].logger;
expect(processLogger).toBe(logger);
stopServer.mockImplementationOnce(async () => processLogger?.info('last host output'));
await host.finishAsync();
expect(logger.info).toHaveBeenCalledWith('last host output');
expect(
jest
.mocked(logger.info)
.mock.calls.some(([record]) => typeof record === 'object' && 'marker' in record)
).toBe(false);
});
23 changes: 19 additions & 4 deletions packages/build-tools/src/steps/utils/__tests__/localEgress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,11 +437,13 @@ describe(configureSimulatorProxyEnvironmentAsync, () => {
});

describe(startChiselServerAsync, () => {
const logger = { warn: jest.fn() } as unknown as bunyan;
const options = {
chiselPath: '/tmp/chisel',
controlPort: 52001,
authfilePath: '/tmp/authfile.json',
env: { PATH: '/bin', AUTH: 'unexpected:password' },
logger,
};
let output: string;
let stopAsync: jest.Mock;
Expand Down Expand Up @@ -483,7 +485,9 @@ describe(startChiselServerAsync, () => {
expect(stopAsync).not.toHaveBeenCalled();
});

it('rejects and stops a child that prints its fingerprint then fails to bind', async () => {
it('keeps the bind error and warns when a child that failed to bind cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const started = startChiselServerAsync(options);
const rejected = expect(started).rejects.toThrow('address already in use');
output += 'listen tcp 127.0.0.1:52001: bind: address already in use\n';
Expand All @@ -493,16 +497,27 @@ describe(startChiselServerAsync, () => {
await jest.advanceTimersByTimeAsync(250);
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the reverse tunnel server after it failed to start.'
);
});

it('stops the child when startup is cancelled', async () => {
it('keeps the cancellation and warns when the cancelled child cannot be stopped', async () => {
const stopError = new Error('drain timed out');
stopAsync.mockRejectedValueOnce(stopError);
const controller = new AbortController();
const cancelled = new Error('cancelled');
const started = startChiselServerAsync({ ...options, signal: controller.signal });
const rejected = expect(started).rejects.toThrow('cancelled');
controller.abort(new Error('cancelled'));
const rejected = expect(started).rejects.toBe(cancelled);
controller.abort(cancelled);
await jest.advanceTimersByTimeAsync(250);
await rejected;
expect(stopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: stopError },
'Could not stop the reverse tunnel server after it failed to start.'
);
});
});

Expand Down
139 changes: 139 additions & 0 deletions packages/build-tools/src/steps/utils/__tests__/processOutput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { type bunyan } from '@expo/logger';

import { createProcessOutput } from '../processOutput';

const logger = { info: jest.fn() } as unknown as bunyan;

beforeEach(() => jest.clearAllMocks());

it('redacts split credentials before publishing complete lines', () => {
const output = createProcessOutput(logger, ['turn secret']);
output.stdout.append('https://preview.test/?tok');
output.stdout.append('en=preview-secret&ok=true\n');
output.stderr.append('Authorization: Bea');
output.stderr.append('rer tool-secret\n{"credential":"turn secret"}\n');
expect(logger.info).toHaveBeenNthCalledWith(
1,
{ source: 'stdout' },
'https://preview.test/?token=[REDACTED]&ok=true'
);
expect(logger.info).toHaveBeenNthCalledWith(
2,
{ source: 'stderr' },
'Authorization: Bearer [REDACTED]'
);
expect(output.getOutput()).not.toMatch(/preview-secret|tool-secret|turn secret/);
});

it.each([
[
'argent link argent://startup-secret@127.0.0.1:5678',
'argent link argent://[REDACTED]@127.0.0.1:5678',
],
[
'argent link argent://encoded%2Fsecret@127.0.0.1:5678',
'argent link argent://[REDACTED]@127.0.0.1:5678',
],
[
'argent link --host 127.0.0.1 --port 5678 --token startup-secret',
'argent link --host 127.0.0.1 --port 5678 --token [REDACTED]',
],
['(or: argent link --token startup-secret)', '(or: argent link --token [REDACTED])'],
[
'argent link --token "quoted \\"secret\\"" --port 5678',
'argent link --token [REDACTED] --port 5678',
],
["argent link --token='quoted secret' --port 5678", 'argent link --token=[REDACTED] --port 5678'],
])('redacts startup credentials at every chunk boundary: %s', (line, expected) => {
for (let split = 0; split <= line.length; split++) {
jest.mocked(logger.info).mockClear();
const output = createProcessOutput(logger);
output.stdout.append(line.slice(0, split));
expect(logger.info).not.toHaveBeenCalled();
output.stdout.append(line.slice(split) + '\n');
expect(logger.info).toHaveBeenCalledWith({ source: 'stdout' }, expected);
expect(output.getOutput()).toBe(expected + '\n');
}
});

it('keeps stdout and stderr fragments separate and decodes split UTF-8', () => {
const output = createProcessOutput(logger);
const bytes = Buffer.from('hello 馃實\n');
output.stdout.append(bytes.subarray(0, 8));
output.stderr.append('error\n');
output.stdout.append(bytes.subarray(8));
expect(logger.info).toHaveBeenNthCalledWith(1, { source: 'stderr' }, 'error');
expect(logger.info).toHaveBeenNthCalledWith(2, { source: 'stdout' }, 'hello 馃實');
});

it('omits overlong lines completely and keeps logging the following diagnostics', () => {
const output = createProcessOutput(logger);
output.stdout.append('https://preview.test/?token=');
output.stdout.append('secret'.repeat(20_000));
expect(logger.info).not.toHaveBeenCalled();
output.stdout.append('\nfatal diagnostic\n');
expect(logger.info).toHaveBeenNthCalledWith(
1,
{ source: 'stdout' },
'[Overlong output line omitted.]'
);
expect(logger.info).toHaveBeenNthCalledWith(2, { source: 'stdout' }, 'fatal diagnostic');
expect(output.getOutput()).not.toContain('secret');
});

it('splits multi-line and CRLF chunks and keeps a line exactly at the length limit', () => {
const output = createProcessOutput(logger);
const limit = 16 * 1024;
output.stdout.append('first\r\nsecond\nthi');
output.stdout.append('rd\n' + 'x'.repeat(limit - 1));
output.stdout.append('x\n' + 'y'.repeat(limit) + 'y\nafter');
output.finish();
expect(jest.mocked(logger.info).mock.calls.map(([, line]) => line)).toEqual([
'first',
'second',
'third',
'x'.repeat(limit),
'[Overlong output line omitted.]',
'after',
]);
});

it('keeps unfinished stdout and stderr lines apart in diagnostics', () => {
const output = createProcessOutput(logger);
output.stdout.append('done\nDownloading 45%');
output.stderr.append('fatal: port in use');
expect(output.getOutput()).toBe('done\nDownloading 45%\nfatal: port in use');
output.stdout.append('\n');
expect(output.getOutput()).toBe('done\nDownloading 45%\nfatal: port in use');
});

it('redacts final partial lines and bounds diagnostics without capping live output', () => {
const output = createProcessOutput(logger);
for (let index = 0; index < 100; index++) {
output.stdout.append('x'.repeat(1023) + '\n');
}
output.stderr.append('token=final-secret');
output.finish();
expect(logger.info).toHaveBeenCalledTimes(101);
expect(logger.info).toHaveBeenLastCalledWith({ source: 'stderr' }, 'token=[REDACTED]');
expect(output.getOutput()).toHaveLength(64 * 1024);
expect(output.getOutput()).toContain('token=[REDACTED]');
});

it('applies newly learned secrets to pending output and retained diagnostics', () => {
const secrets: string[] = [];
const output = createProcessOutput(undefined, secrets);
output.stdout.append('opaque-secret\n');
output.stderr.append('opaque-secret');
secrets.push('opaque-secret');
expect(output.getOutput()).not.toContain('opaque-secret');
});

it('redacts JSON token fields with escaped quotes before the token is known', () => {
const output = createProcessOutput(logger);
output.stdout.append(JSON.stringify({ authToken: 'a"b', controlToken: 'android-secret' }) + '\n');
expect(logger.info).toHaveBeenCalledWith(
{ source: 'stdout' },
'{"authToken":"[REDACTED]","controlToken":"[REDACTED]"}'
);
});
Loading
Loading