mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc25602d65 | ||
|
|
261836aaf6 | ||
|
|
55e746a0e7 | ||
|
|
821e7f85f5 | ||
|
|
6f0f90bd99 | ||
|
|
6c545d3460 | ||
|
|
243c8b8023 | ||
|
|
fe6c35ef73 | ||
|
|
a29b1d1d70 |
No files matched your search
@@ -2,6 +2,21 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.3.0](https://github.com/expo/eas-cli/releases/tag/v27.3.0) - 2026-11-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-build-job] Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
|
||||
- [worker] Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [worker][build-tools] Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "27.1.0",
|
||||
"PRODUCTION": "27.1.0"
|
||||
"STAGING": "27.2.0",
|
||||
"PRODUCTION": "27.2.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
|
||||
- Recover unhealthy device-session tunnels without changing their URLs and block further traffic when shutdown starts, even if ngrok cleanup times out. ([#4526](https://github.com/expo/eas-cli/pull/4526) by [@gwdp](https://github.com/gwdp))
|
||||
- Stop sending the SSH host when registering an SSH session. ([#4784](https://github.com/expo/eas-cli/pull/4784) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.2.0",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/downloader": "27.3.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.2.0",
|
||||
"@expo/template-file": "27.2.0",
|
||||
"@expo/turtle-spawn": "27.2.0",
|
||||
"@expo/steps": "27.3.0",
|
||||
"@expo/template-file": "27.3.0",
|
||||
"@expo/turtle-spawn": "27.3.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -167,6 +167,7 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/health' },
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
|
||||
@@ -274,6 +274,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
subdomainPrefix: 'appium',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
healthCheck: { path: '/status' },
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -340,6 +340,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/tools', statuses: [200, 401] },
|
||||
logger,
|
||||
});
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
|
||||
@@ -0,0 +1,873 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { type AddressInfo, createConnection, createServer as createTcpServer } from 'node:net';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startNgrokTunnelAsync } from '../ngrokTunnel';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers', () => ({
|
||||
setTimeout: (...args: Parameters<typeof setTimeout>) => setTimeout(...args),
|
||||
clearTimeout: (...args: Parameters<typeof clearTimeout>) => clearTimeout(...args),
|
||||
}));
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
|
||||
const { Response } = jest.requireActual<typeof import('node-fetch')>('node-fetch');
|
||||
const url = 'https://web-preview-fixed.example.test';
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const options = {
|
||||
port: 4321,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId: 'fixed',
|
||||
baseDomain: 'example.test',
|
||||
authtoken: 'token',
|
||||
rewriteHostHeader: true,
|
||||
healthCheck: { path: '/healthz' },
|
||||
logger,
|
||||
};
|
||||
|
||||
function listener(publicUrl = url) {
|
||||
return { url: () => publicUrl, close: jest.fn().mockResolvedValue(undefined) };
|
||||
}
|
||||
|
||||
let initial: ReturnType<typeof listener>;
|
||||
let replacement: ReturnType<typeof listener>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
|
||||
jest.clearAllMocks();
|
||||
initial = listener();
|
||||
replacement = listener();
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockResolvedValue(replacement as never);
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function failPublicProbes() {
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async target =>
|
||||
new Response(undefined, {
|
||||
status: target.startsWith('https:') ? 503 : 200,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
it('keeps tunnels without a health check unsupervised and closes them on stop', async () => {
|
||||
const tunnel = await startNgrokTunnelAsync({ ...options, healthCheck: undefined });
|
||||
await jest.advanceTimersByTimeAsync(90_000);
|
||||
expect(turtleFetch).not.toHaveBeenCalled();
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
await tunnel.stopAsync();
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('probes the public service and keeps a healthy listener open', async () => {
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
`${url}/healthz`,
|
||||
'GET',
|
||||
expect.objectContaining({
|
||||
retries: 0,
|
||||
redirect: 'manual',
|
||||
headers: expect.objectContaining({ 'ngrok-skip-browser-warning': 'true' }),
|
||||
})
|
||||
);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('reopens after three failed public probes with a healthy local service, preserving the endpoint', async () => {
|
||||
failPublicProbes();
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(jest.mocked(ngrok.forward).mock.calls[1][0]).toEqual({
|
||||
addr: expect.any(Number),
|
||||
authtoken: 'token',
|
||||
domain: 'web-preview-fixed.example.test',
|
||||
request_header_add: ['Host:localhost:4321'],
|
||||
force_new_session: true,
|
||||
});
|
||||
expect(tunnel.url).toBe(url);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('healthy again'));
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reopen when the local service is unhealthy', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 503 }));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(90_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
expect(initial.close).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('resets consecutive failures after a successful public probe', async () => {
|
||||
failPublicProbes();
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries a failed reopen with backoff', async () => {
|
||||
failPublicProbes();
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1_999);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes a listener that finishes opening after shutdown, with idempotent cleanup', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const stopping = tunnel.stopAsync();
|
||||
expect(tunnel.stopAsync()).toBe(stopping);
|
||||
const stopped = jest.fn();
|
||||
void stopping.then(stopped);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(stopped).not.toHaveBeenCalled();
|
||||
resolveOpening(replacement as never);
|
||||
await stopping;
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits for a replacement acquired during shutdown to finish closing', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
let resolveClose!: () => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
replacement.close.mockReturnValue(new Promise<void>(resolve => (resolveClose = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
let stopped = false;
|
||||
const stopping = tunnel.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const stoppedBeforeClose = stopped;
|
||||
resolveClose();
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await stopping;
|
||||
expect(stoppedBeforeClose).toBe(false);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([0, 4, 5, 10])('retires a reopen cancelled after %s promise turns', async turns => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
resolveOpening(replacement as never);
|
||||
for (let turn = 0; turn < turns; turn++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
await tunnel.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close when a reopen finishes after shutdown', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const stopping = tunnel.stopAsync();
|
||||
replacement.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await stopping;
|
||||
expect(replacement.close).toHaveBeenCalledTimes(2);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
});
|
||||
|
||||
it('fails stop if an in-flight reopen still has not settled by the stop deadline', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries an old listener while a reopen outlasts shutdown', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
const oldCloseCalls = initial.close.mock.calls.length;
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(oldCloseCalls).toBe(2);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fails stop if a pending reopen arrives but cannot be retired', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
replacement.close.mockRejectedValue(new Error('unlisten failed'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
|
||||
);
|
||||
resolveOpening(replacement as never);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(replacement.close.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Could not retire late ngrok tunnel web-preview-fixed.example.test.'
|
||||
);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects a changed public URL and closes the replacement', async () => {
|
||||
failPublicProbes();
|
||||
const changed = listener('https://different.example.test');
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockResolvedValue(changed as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(changed.close).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.url).toBe(url);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('Could not reopen')
|
||||
);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats ngrok error responses as failures even if their status is accepted', async () => {
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async target =>
|
||||
new Response(undefined, {
|
||||
status: 200,
|
||||
headers: target.startsWith('https:') ? { 'ngrok-error-code': 'ERR_NGROK_3200' } : {},
|
||||
})
|
||||
);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reopens on a fresh agent session even if closing the old listener fails', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(ngrok.forward).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ force_new_session: true })
|
||||
);
|
||||
expect(ngrok.disconnect).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close before reopening an endpoint that remains bound', async () => {
|
||||
failPublicProbes();
|
||||
let domainOccupied = true;
|
||||
initial.close
|
||||
.mockRejectedValueOnce(new Error('agent disconnected'))
|
||||
.mockImplementation(async () => {
|
||||
domainOccupied = false;
|
||||
});
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockImplementation(async () => {
|
||||
if (domainOccupied) {
|
||||
throw new Error('endpoint is still bound');
|
||||
}
|
||||
return replacement as never;
|
||||
});
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
expect(tunnel.url).toBe(url);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retires an old listener even after its replacement is healthy', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValueOnce(new Error('agent disconnected'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('retries an old listener without repeating its close warning on every probe', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValue(new Error('old session disconnected'));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
expect(warnings).toBeGreaterThan(0);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close during shutdown', async () => {
|
||||
const failure = new Error('agent disconnected');
|
||||
initial.close.mockRejectedValueOnce(failure);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
const stopping = tunnel.stopAsync();
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
await stopping;
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: failure },
|
||||
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the first close rejection once while bounded shutdown retries it', async () => {
|
||||
const failure = new Error('agent disconnected');
|
||||
initial.close.mockRejectedValue(failure);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
const rejected = expect(tunnel.stopAsync()).rejects.toThrow('stop timed out');
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(initial.close.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: failure },
|
||||
'Could not stop ngrok tunnel web-preview-fixed.example.test.'
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds shutdown during recovery without duplicating a pending native close', async () => {
|
||||
failPublicProbes();
|
||||
let finishClose!: () => void;
|
||||
initial.close.mockReturnValueOnce(new Promise<void>(resolve => (finishClose = resolve)));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
await jest.advanceTimersByTimeAsync(45_000);
|
||||
const stopping = tunnel.stopAsync();
|
||||
expect(tunnel.stopAsync()).toBe(stopping);
|
||||
const rejected = expect(stopping).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out after 4000ms'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
const warnings = jest.mocked(logger.warn).mock.calls.length;
|
||||
finishClose();
|
||||
await jest.advanceTimersByTimeAsync(120_000);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(warnings);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds a stalled listener close before reopening', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockImplementationOnce(async () => await new Promise(() => {}));
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(49_999);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
const stopping = tunnel.stopAsync();
|
||||
const rejected = expect(stopping).rejects.toThrow(
|
||||
'Ngrok tunnel web-preview-fixed.example.test stop timed out'
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await rejected;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not queue more native opens while a timed-out open is still pending', async () => {
|
||||
failPublicProbes();
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(replacement as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
const late = listener();
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(180_000);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
resolveOpening(late as never);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
expect(late.close).toHaveBeenCalledTimes(1);
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
expect(replacement.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bounds initial tunnel creation and retires a late listener', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
resolveOpening = resolve;
|
||||
})
|
||||
);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await rejected;
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(initial.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a rejected close after initial tunnel creation timed out', async () => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)));
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('Ngrok tunnel open timed out');
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await rejected;
|
||||
initial.close.mockRejectedValueOnce(new Error('temporary unlisten failure'));
|
||||
resolveOpening(initial as never);
|
||||
await jest.advanceTimersByTimeAsync(250);
|
||||
expect(initial.close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retires only the late listener without blaming it for an older stuck listener', async () => {
|
||||
failPublicProbes();
|
||||
initial.close.mockRejectedValue(new Error('old session disconnected'));
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(initial as never)
|
||||
.mockReturnValueOnce(new Promise(resolve => (resolveOpening = resolve)))
|
||||
.mockResolvedValue(replacement as never);
|
||||
const tunnel = await startNgrokTunnelAsync(options);
|
||||
try {
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
const late = listener();
|
||||
resolveOpening(late as never);
|
||||
jest.mocked(turtleFetch).mockResolvedValue(new Response(undefined, { status: 200 }));
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
expect(late.close).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest
|
||||
.mocked(logger.warn)
|
||||
.mock.calls.some(([, message]) => message?.includes('Could not retire late'))
|
||||
).toBe(false);
|
||||
} finally {
|
||||
initial.close.mockResolvedValue(undefined);
|
||||
await tunnel.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('recovers using actual HTTP health probes with a local ngrok adapter', async () => {
|
||||
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
|
||||
const realSetTimeout = jest.requireActual<typeof import('node:timers')>('node:timers').setTimeout;
|
||||
let broken = true;
|
||||
const local = createServer((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end('healthy');
|
||||
});
|
||||
const publicServer = createServer((_req, res) => {
|
||||
res.writeHead(broken ? 503 : 200);
|
||||
res.end('probe');
|
||||
});
|
||||
await Promise.all(
|
||||
[local, publicServer].map(
|
||||
server => new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
)
|
||||
);
|
||||
const publicUrl = `http://127.0.0.1:${(publicServer.address() as AddressInfo).port}`;
|
||||
let completedRequests = 0;
|
||||
jest.mocked(turtleFetch).mockImplementation(async (target, method, requestOptions) => {
|
||||
const response = await actualFetch(target, { ...requestOptions, method });
|
||||
completedRequests++;
|
||||
return response;
|
||||
});
|
||||
const first = listener(publicUrl);
|
||||
const next = listener(publicUrl);
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(first as never)
|
||||
.mockImplementation(async () => {
|
||||
broken = false;
|
||||
return next as never;
|
||||
});
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: (local.address() as AddressInfo).port,
|
||||
});
|
||||
try {
|
||||
for (let count = 0; count < 3; count++) {
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
for (let turns = 0; turns < 50 && completedRequests < (count + 1) * 2; turns++) {
|
||||
await new Promise(resolve => realSetTimeout(resolve, 10));
|
||||
}
|
||||
expect(completedRequests).toBe((count + 1) * 2);
|
||||
}
|
||||
expect(ngrok.forward).toHaveBeenCalledTimes(2);
|
||||
expect(first.close).toHaveBeenCalledTimes(1);
|
||||
expect((await actualFetch(`${tunnel.url}/healthz`)).status).toBe(200);
|
||||
} finally {
|
||||
await tunnel.stopAsync();
|
||||
await Promise.all(
|
||||
[local, publicServer].map(
|
||||
server =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close(err => (err ? reject(err) : resolve()))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
expect(next.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('blocks HTTP and existing WebSockets on Stop even when ngrok cannot close', async () => {
|
||||
jest.useRealTimers();
|
||||
const actualFetch = jest.requireActual<typeof import('node-fetch')>('node-fetch').default;
|
||||
let requests = 0;
|
||||
let messages = 0;
|
||||
const backend = createServer((_request, response) => {
|
||||
requests++;
|
||||
response.end('owned backend');
|
||||
});
|
||||
const websockets = new WebSocketServer({ server: backend });
|
||||
websockets.on('connection', socket => {
|
||||
socket.on('message', data => {
|
||||
messages++;
|
||||
socket.send(data);
|
||||
});
|
||||
});
|
||||
backend.listen(0, '127.0.0.1');
|
||||
await once(backend, 'listening');
|
||||
const backendPort = (backend.address() as AddressInfo).port;
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: backendPort,
|
||||
healthCheck: undefined,
|
||||
});
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
|
||||
const targetUrl = `http://127.0.0.1:${targetPort}`;
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${targetPort}`);
|
||||
const opened = once(socket, 'open');
|
||||
try {
|
||||
expect(await (await actualFetch(targetUrl)).text()).toBe('owned backend');
|
||||
await opened;
|
||||
const echoed = once(socket, 'message');
|
||||
socket.send('before-stop');
|
||||
expect((await echoed)[0].toString()).toBe('before-stop');
|
||||
const closed = once(socket, 'close');
|
||||
initial.close.mockRejectedValue(new Error('agent disconnected'));
|
||||
const stopping = tunnel.stopAsync();
|
||||
const rejected = expect(stopping).rejects.toThrow('stop timed out after 4000ms');
|
||||
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
|
||||
await closed;
|
||||
expect(requests).toBe(1);
|
||||
expect(messages).toBe(1);
|
||||
expect(await (await actualFetch(`http://127.0.0.1:${backendPort}`)).text()).toBe(
|
||||
'owned backend'
|
||||
);
|
||||
await rejected;
|
||||
await expect(actualFetch(targetUrl, { timeout: 1_000 })).rejects.toThrow();
|
||||
expect(requests).toBe(2);
|
||||
expect(messages).toBe(1);
|
||||
const competing = createTcpServer();
|
||||
const conflict = once(competing, 'error');
|
||||
competing.listen(targetPort as number, '127.0.0.1');
|
||||
expect((await conflict)[0]).toMatchObject({ code: 'EADDRINUSE' });
|
||||
} finally {
|
||||
socket.terminate();
|
||||
for (const client of websockets.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
websockets.close();
|
||||
backend.closeAllConnections();
|
||||
await new Promise<void>(resolve => backend.close(() => resolve()));
|
||||
await tunnel.stopAsync().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('releases its local target when initial tunnel creation fails', async () => {
|
||||
jest.useRealTimers();
|
||||
jest.mocked(ngrok.forward).mockReset().mockRejectedValue(new Error('forward failed'));
|
||||
await expect(startNgrokTunnelAsync(options)).rejects.toThrow('forward failed');
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr;
|
||||
const server = createServer();
|
||||
server.listen(targetPort as number, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('preserves a full response for a half-closed slow reader', async () => {
|
||||
jest.useRealTimers();
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 37);
|
||||
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
|
||||
socket.resume();
|
||||
socket.on('end', () => socket.end(payload));
|
||||
});
|
||||
backend.listen(0, '127.0.0.1');
|
||||
await once(backend, 'listening');
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: (backend.address() as AddressInfo).port,
|
||||
healthCheck: undefined,
|
||||
});
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
socket.on('data', chunk => chunks.push(chunk));
|
||||
const ended = once(socket, 'end');
|
||||
socket.pause();
|
||||
socket.end('request');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
socket.resume();
|
||||
await ended;
|
||||
expect(Buffer.concat(chunks).equals(payload)).toBe(true);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await tunnel.stopAsync();
|
||||
await new Promise<void>(resolve => backend.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['resolve', 'reject'])(
|
||||
'releases the target after timed-out startup later %s',
|
||||
async outcome => {
|
||||
let resolveOpening!: (value: ngrok.Listener) => void;
|
||||
let rejectOpening!: (error: Error) => void;
|
||||
jest
|
||||
.mocked(ngrok.forward)
|
||||
.mockReset()
|
||||
.mockReturnValue(
|
||||
new Promise((resolve, reject) => {
|
||||
resolveOpening = resolve;
|
||||
rejectOpening = reject;
|
||||
})
|
||||
);
|
||||
const opening = startNgrokTunnelAsync(options);
|
||||
const rejected = expect(opening).rejects.toThrow('open timed out');
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await rejected;
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
if (outcome === 'resolve') {
|
||||
resolveOpening(replacement as never);
|
||||
} else {
|
||||
rejectOpening(new Error('forward failed'));
|
||||
}
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const server = createTcpServer();
|
||||
server.listen(targetPort, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
}
|
||||
);
|
||||
|
||||
it('drains a full upload when the backend ends its response early', async () => {
|
||||
jest.useRealTimers();
|
||||
const payload = Buffer.alloc(4 * 1024 * 1024, 51);
|
||||
let received!: Buffer;
|
||||
let receivedRequest!: () => void;
|
||||
const completed = new Promise<void>(resolve => {
|
||||
receivedRequest = resolve;
|
||||
});
|
||||
const backend = createTcpServer({ allowHalfOpen: true }, socket => {
|
||||
const chunks: Buffer[] = [];
|
||||
socket.on('data', chunk => chunks.push(chunk));
|
||||
socket.on('end', () => {
|
||||
received = Buffer.concat(chunks);
|
||||
receivedRequest();
|
||||
});
|
||||
socket.pause();
|
||||
socket.end('early response');
|
||||
setTimeout(() => socket.resume(), 100);
|
||||
});
|
||||
backend.listen(0, '127.0.0.1');
|
||||
await once(backend, 'listening');
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
...options,
|
||||
port: (backend.address() as AddressInfo).port,
|
||||
healthCheck: undefined,
|
||||
});
|
||||
const targetPort = (jest.mocked(ngrok.forward).mock.calls[0][0] as ngrok.Config).addr as number;
|
||||
const socket = createConnection({ host: '127.0.0.1', port: targetPort, allowHalfOpen: true });
|
||||
try {
|
||||
socket.resume();
|
||||
socket.end(payload);
|
||||
await completed;
|
||||
expect(received.equals(payload)).toBe(true);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await tunnel.stopAsync();
|
||||
await new Promise<void>(resolve => backend.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -932,7 +932,7 @@ describe(waitForWebPreviewReadyAsync, () => {
|
||||
|
||||
describe(startNgrokTunnelAsync, () => {
|
||||
it.each(['rejected', 'stalled'] as const)(
|
||||
'retains a %s close failure on repeated stops',
|
||||
'bounds %s listener retirement and reuses the stop promise',
|
||||
async mode => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
@@ -954,14 +954,12 @@ describe(startNgrokTunnelAsync, () => {
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
const stopping = tunnel.stopAsync();
|
||||
const assertion = expect(stopping).rejects.toThrow(
|
||||
mode === 'rejected' ? 'close failed' : 'Ngrok tunnel stop timed out after 4000ms.'
|
||||
);
|
||||
const assertion = expect(stopping).rejects.toThrow(/stop timed out after 4000ms/);
|
||||
expect(tunnel.stopAsync()).toBe(stopping);
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
await assertion;
|
||||
await expect(tunnel.stopAsync()).rejects.toThrow();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
@@ -985,7 +983,7 @@ describe(startNgrokTunnelAsync, () => {
|
||||
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
addr: 4321,
|
||||
addr: expect.any(Number),
|
||||
authtoken: 'token',
|
||||
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
|
||||
})
|
||||
@@ -1302,7 +1300,10 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
recordingDirectory,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addr: expect.any(Number) })
|
||||
);
|
||||
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await host.finishAsync();
|
||||
@@ -1438,7 +1439,10 @@ describe(startDeviceSessionHostAsync, () => {
|
||||
packageVersion,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ addr: expect.any(Number) })
|
||||
);
|
||||
expect(ngrok.forward).not.toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await host.finishAsync();
|
||||
|
||||
@@ -623,6 +623,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
healthCheck: { path: isAndroid ? '/readyz' : '/healthz' },
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { type AddressInfo, type Socket, createConnection, createServer } from 'node:net';
|
||||
import { clearTimeout, setTimeout } from 'node:timers';
|
||||
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
|
||||
const PROBE_INTERVAL_MS = 15_000;
|
||||
const FAILURE_THRESHOLD = 3;
|
||||
const STOP_TIMEOUT_MS = 4_000;
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
subdomainId = randomBytes(16).toString('hex'),
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
healthCheck,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
subdomainId?: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
healthCheck?: { path: string; statuses?: number[] };
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const sockets = new Set<Socket>();
|
||||
const target = createServer({ allowHalfOpen: true }, client => {
|
||||
if (signal.aborted) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
const upstream = createConnection({ host: '127.0.0.1', port, allowHalfOpen: true });
|
||||
for (const socket of [client, upstream]) {
|
||||
sockets.add(socket);
|
||||
socket.on('error', () => {
|
||||
client.destroy();
|
||||
upstream.destroy();
|
||||
});
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
}
|
||||
client.pipe(upstream).pipe(client);
|
||||
});
|
||||
target.listen(0, '127.0.0.1');
|
||||
await once(target, 'listening');
|
||||
target.unref();
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
const config: ngrok.Config = {
|
||||
addr: (target.address() as AddressInfo).port,
|
||||
authtoken,
|
||||
domain,
|
||||
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
|
||||
};
|
||||
|
||||
const ownedListeners = new Set<ngrok.Listener>();
|
||||
const retiredListeners = new Set<ngrok.Listener>();
|
||||
const closingListeners = new Map<ngrok.Listener, Promise<void>>();
|
||||
let pendingOpen: Promise<ngrok.Listener> | undefined;
|
||||
let currentListener: ngrok.Listener | undefined;
|
||||
function releaseTarget(): void {
|
||||
if (signal.aborted && !pendingOpen && ownedListeners.size === 0 && target.listening) {
|
||||
target.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAsync(
|
||||
listener: ngrok.Listener,
|
||||
closeSignal: AbortSignal,
|
||||
logFailure = true
|
||||
): Promise<void> {
|
||||
retiredListeners.add(listener);
|
||||
let closing = closingListeners.get(listener);
|
||||
if (!closing) {
|
||||
closing = Promise.resolve().then(async () => await listener.close());
|
||||
closingListeners.set(listener, closing);
|
||||
void closing.then(
|
||||
() => {
|
||||
closingListeners.delete(listener);
|
||||
retiredListeners.delete(listener);
|
||||
ownedListeners.delete(listener);
|
||||
releaseTarget();
|
||||
},
|
||||
() => closingListeners.delete(listener)
|
||||
);
|
||||
}
|
||||
try {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel close', timeoutMs: 5_000, signal: closeSignal },
|
||||
async () => await closing
|
||||
);
|
||||
} catch (err) {
|
||||
if (logFailure && !closeSignal.aborted) {
|
||||
logger.warn({ err }, `Could not stop ngrok tunnel ${domain}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retireListenersAsync(retirementSignal: AbortSignal): Promise<void> {
|
||||
let logFailure = true;
|
||||
while (retiredListeners.size > 0) {
|
||||
retirementSignal.throwIfAborted();
|
||||
await Promise.all(
|
||||
[...retiredListeners].map(retired => closeAsync(retired, retirementSignal, logFailure))
|
||||
);
|
||||
logFailure = false;
|
||||
if (retiredListeners.size > 0) {
|
||||
await waitAsync(250, retirementSignal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function retireLateListenerAsync(listener: ngrok.Listener): Promise<void> {
|
||||
retiredListeners.add(listener);
|
||||
try {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late ngrok tunnel retirement', timeoutMs: STOP_TIMEOUT_MS },
|
||||
async retirementSignal => {
|
||||
while (retiredListeners.has(listener)) {
|
||||
retirementSignal.throwIfAborted();
|
||||
await closeAsync(listener, retirementSignal, false);
|
||||
if (retiredListeners.has(listener)) {
|
||||
await waitAsync(250, retirementSignal);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not retire late ngrok tunnel ${domain}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function openAsync(forceNewSession = false): Promise<ngrok.Listener> {
|
||||
if (pendingOpen) {
|
||||
throw new SystemError(`A previous ngrok tunnel open for ${domain} is still pending.`);
|
||||
}
|
||||
let acquired: ngrok.Listener | undefined;
|
||||
let retirement: Promise<void> | undefined;
|
||||
const retireAcquired = () => {
|
||||
if (acquired) {
|
||||
retirement ??= retireLateListenerAsync(acquired);
|
||||
}
|
||||
};
|
||||
try {
|
||||
return await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel open', timeoutMs: 15_000, signal },
|
||||
async openSignal => {
|
||||
openSignal.throwIfAborted();
|
||||
const opening = ngrok
|
||||
.forward({
|
||||
...config,
|
||||
...(forceNewSession ? { force_new_session: true } : {}),
|
||||
})
|
||||
.then(listener => {
|
||||
acquired = listener;
|
||||
ownedListeners.add(listener);
|
||||
return listener;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingOpen = undefined;
|
||||
});
|
||||
pendingOpen = opening;
|
||||
void opening
|
||||
.then(() => {
|
||||
if (openSignal.aborted) {
|
||||
retireAcquired();
|
||||
}
|
||||
})
|
||||
.catch(() => releaseTarget());
|
||||
const listener = await opening;
|
||||
openSignal.throwIfAborted();
|
||||
return listener;
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
retireAcquired();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
let publicUrl: string | null;
|
||||
try {
|
||||
currentListener = await openAsync();
|
||||
publicUrl = currentListener.url();
|
||||
if (!publicUrl) {
|
||||
await closeAsync(currentListener, signal);
|
||||
currentListener = undefined;
|
||||
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
|
||||
}
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
releaseTarget();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = publicUrl;
|
||||
|
||||
async function probeAsync(
|
||||
baseUrl: string,
|
||||
{ path, statuses = [200] }: NonNullable<typeof healthCheck>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel health probe', timeoutMs: 5_000, signal },
|
||||
async probeSignal => {
|
||||
const requestController = new AbortController();
|
||||
try {
|
||||
const response = await turtleFetch(new URL(path, baseUrl).href, 'GET', {
|
||||
retries: 0,
|
||||
redirect: 'manual',
|
||||
shouldThrowOnNotOk: false,
|
||||
headers: { 'ngrok-skip-browser-warning': 'true', 'Cache-Control': 'no-cache' },
|
||||
signal: AbortSignal.any([probeSignal, requestController.signal]),
|
||||
});
|
||||
return !response.headers.has('ngrok-error-code') && statuses.includes(response.status);
|
||||
} finally {
|
||||
requestController.abort();
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function superviseAsync(check: NonNullable<typeof healthCheck>): Promise<void> {
|
||||
let failures = 0;
|
||||
let attempts = 0;
|
||||
let localUnhealthy = false;
|
||||
while (
|
||||
await waitAsync(
|
||||
currentListener || localUnhealthy
|
||||
? PROBE_INTERVAL_MS
|
||||
: Math.min(1_000 * 2 ** Math.min(attempts, 5), 30_000),
|
||||
signal
|
||||
)
|
||||
) {
|
||||
await Promise.all([...retiredListeners].map(retired => closeAsync(retired, signal, false)));
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (currentListener && (await probeAsync(url, check))) {
|
||||
if (failures > 0 || attempts > 0 || localUnhealthy) {
|
||||
logger.info(`Ngrok tunnel ${domain} is healthy again.`);
|
||||
}
|
||||
failures = 0;
|
||||
attempts = 0;
|
||||
localUnhealthy = false;
|
||||
continue;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
if (!(await probeAsync(`http://127.0.0.1:${port}`, check))) {
|
||||
if (!localUnhealthy && !signal.aborted) {
|
||||
logger.warn(`Local service on port ${port} is unhealthy; waiting for it to recover.`);
|
||||
}
|
||||
localUnhealthy = true;
|
||||
failures = 0;
|
||||
continue;
|
||||
}
|
||||
localUnhealthy = false;
|
||||
if (currentListener) {
|
||||
failures++;
|
||||
if (failures < FAILURE_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
logger.warn(`Ngrok tunnel ${domain} failed ${failures} health probes; reopening it.`);
|
||||
await closeAsync(currentListener, signal);
|
||||
currentListener = undefined;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
try {
|
||||
const reopened = await openAsync(/* forceNewSession */ true);
|
||||
if (reopened.url() !== url) {
|
||||
await closeAsync(reopened, signal);
|
||||
throw new SystemError(
|
||||
`Reopened ngrok tunnel for ${domain} returned a different public URL.`
|
||||
);
|
||||
}
|
||||
currentListener = reopened;
|
||||
failures = 0;
|
||||
} catch (err) {
|
||||
if (!signal.aborted && (attempts === 1 || attempts % 5 === 0)) {
|
||||
logger.warn(
|
||||
{ err, attempt: attempts },
|
||||
`Could not reopen ngrok tunnel ${domain}; will retry.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const supervision = healthCheck
|
||||
? superviseAsync(healthCheck).catch(err => {
|
||||
if (!signal.aborted) {
|
||||
logger.warn({ err }, `Ngrok tunnel supervision stopped for ${domain}.`);
|
||||
}
|
||||
})
|
||||
: Promise.resolve();
|
||||
let stopTask: Promise<void> | undefined;
|
||||
return {
|
||||
url,
|
||||
subdomainId,
|
||||
stopAsync: () =>
|
||||
(stopTask ??= (async () => {
|
||||
controller.abort();
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: `Ngrok tunnel ${domain} stop`, timeoutMs: STOP_TIMEOUT_MS },
|
||||
async stopSignal => {
|
||||
await supervision;
|
||||
if (currentListener) {
|
||||
retiredListeners.add(currentListener);
|
||||
currentListener = undefined;
|
||||
}
|
||||
await Promise.all([pendingOpen?.catch(() => {}), retireListenersAsync(stopSignal)]);
|
||||
await retireListenersAsync(stopSignal);
|
||||
}
|
||||
);
|
||||
releaseTarget();
|
||||
})()),
|
||||
};
|
||||
}
|
||||
|
||||
function waitAsync(ms: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(true);
|
||||
}, ms);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -9,9 +9,7 @@ import {
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import path from 'node:path';
|
||||
@@ -967,63 +965,7 @@ export async function findAvailablePortAsync(): Promise<number> {
|
||||
return address.port;
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
subdomainId: subdomainIdArg,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
subdomainId?: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const subdomainId = subdomainIdArg ?? randomBytes(16).toString('hex');
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
|
||||
// the process exits, and the step blocks forever to hold it open.
|
||||
const listener = await ngrok.forward({
|
||||
addr: port,
|
||||
authtoken,
|
||||
domain,
|
||||
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
|
||||
});
|
||||
const url = listener.url();
|
||||
if (!url) {
|
||||
await listener.close();
|
||||
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
|
||||
}
|
||||
let stopTask: Promise<void> | undefined;
|
||||
return {
|
||||
url,
|
||||
subdomainId,
|
||||
stopAsync: () =>
|
||||
(stopTask ??= (async () => {
|
||||
try {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Ngrok tunnel stop', timeoutMs: 4_000 },
|
||||
async () => await listener.close()
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, `Could not stop ngrok tunnel ${domain}.`);
|
||||
throw error;
|
||||
}
|
||||
})()),
|
||||
};
|
||||
}
|
||||
export { type NgrokTunnelHandle, startNgrokTunnelAsync } from './ngrokTunnel';
|
||||
|
||||
export async function waitForFileAsync<T>({
|
||||
filePath,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { retryAsync } from './retry';
|
||||
import { waitForWarmSimulatorCleanupAsync } from './warmSimulatorCleanup';
|
||||
|
||||
export type IosSimulatorUuid = string & z.BRAND<'IosSimulatorUuid'>;
|
||||
export type IosSimulatorName = string & z.BRAND<'IosSimulatorName'>;
|
||||
@@ -119,6 +120,7 @@ export namespace IosSimulatorUtils {
|
||||
destinationDeviceName: IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
await spawn('xcrun', ['simctl', 'clone', sourceDeviceIdentifier, destinationDeviceName], {
|
||||
env,
|
||||
});
|
||||
@@ -131,6 +133,7 @@ export namespace IosSimulatorUtils {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
try {
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(
|
||||
@@ -231,6 +234,7 @@ export namespace IosSimulatorUtils {
|
||||
env: NodeJS.ProcessEnv;
|
||||
launchdEnvironment?: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
const bootEnv = { ...env };
|
||||
for (const [name, value] of Object.entries(launchdEnvironment)) {
|
||||
bootEnv[`SIMCTL_CHILD_${name}`] = value;
|
||||
@@ -253,6 +257,7 @@ export namespace IosSimulatorUtils {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<{ udid: IosSimulatorUuid }> {
|
||||
await waitForWarmSimulatorCleanupAsync(env);
|
||||
const bootstatusResult = await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'bootstatus', deviceIdentifier, '-b'],
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
|
||||
import { IosSimulatorUtils } from '../IosSimulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
@@ -14,6 +15,7 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
jest.mock('../retry', () => ({
|
||||
retryAsync: jest.fn(async fn => await fn(0)),
|
||||
}));
|
||||
jest.mock('../warmSimulatorCleanup');
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
@@ -21,6 +23,7 @@ describe('IosSimulatorUtils', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
jest.mocked(waitForWarmSimulatorCleanupAsync).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.getAvailableRuntimesAsync, () => {
|
||||
@@ -151,6 +154,30 @@ describe('IosSimulatorUtils', () => {
|
||||
}) as any);
|
||||
}
|
||||
|
||||
it('waits for warm cleanup before checking device state and writing accessibility prefs', async () => {
|
||||
let finishCleanup: (() => void) | undefined;
|
||||
jest.mocked(waitForWarmSimulatorCleanupAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<void>(resolve => {
|
||||
finishCleanup = resolve;
|
||||
})
|
||||
);
|
||||
mockAvailableDevices([device()]);
|
||||
|
||||
const setup = IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: device().udid as any,
|
||||
env: {},
|
||||
});
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(finishCleanup).toBeDefined();
|
||||
finishCleanup?.();
|
||||
await setup;
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('plutil', expect.anything(), {
|
||||
env: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
|
||||
mockAvailableDevices([
|
||||
device({
|
||||
|
||||
@@ -76,11 +76,7 @@ describe(getSshRelayServerUrl, () => {
|
||||
});
|
||||
|
||||
describe(startSshSessionAsync, () => {
|
||||
const config1: SshConnectionConfig = {
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'secret-1',
|
||||
};
|
||||
const config1: SshConnectionConfig = { type: 'upterm-v1', secret: 'secret-1' };
|
||||
const config2: SshConnectionConfig = { ...config1, secret: 'secret-2' };
|
||||
const target = { type: 'JOB_RUN', id: 'jr-1' } as const;
|
||||
|
||||
@@ -133,7 +129,7 @@ describe(startSshSessionAsync, () => {
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
expect(idleTimeoutSeconds).toBe(900);
|
||||
@@ -155,7 +151,7 @@ describe(startSshSessionAsync, () => {
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
target: { type: 'BUILD', id: 'b-1' },
|
||||
connectionConfig: { ...config1, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-1', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 900 },
|
||||
});
|
||||
});
|
||||
@@ -242,7 +238,7 @@ describe(startSshSessionAsync, () => {
|
||||
);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
@@ -298,7 +294,7 @@ describe(startSshSessionAsync, () => {
|
||||
expect(host.redialAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mutation).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
target: { type: 'JOB_RUN', id: 'jr-1' },
|
||||
connectionConfig: { ...config2, reconnecting: false, type: 'UPTERM_V1' },
|
||||
connectionConfig: { type: 'UPTERM_V1', secret: 'secret-2', reconnecting: false },
|
||||
sessionSettings: { idleTimeoutSeconds: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { sleepAsync } from '../retry';
|
||||
import {
|
||||
connectionConfigFromUptermSession,
|
||||
redactConnectionSecrets,
|
||||
redactSpawnErrorForLog,
|
||||
resolveUptermGcsObjectName,
|
||||
@@ -42,49 +41,6 @@ describe(resolveUptermGcsObjectName, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(connectionConfigFromUptermSession, () => {
|
||||
it('parses sessionId and host from session current fields', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'TOKENabc123',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENabc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a bare hostname', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'relay.expo.dev' })).toEqual(
|
||||
{
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'tok',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non-default port so the CLI can pass it to ssh -p', () => {
|
||||
expect(
|
||||
connectionConfigFromUptermSession({
|
||||
sessionId: 'tok',
|
||||
host: 'ssh://relay.expo.dev:2222',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev:2222',
|
||||
secret: 'tok',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the host is not a parseable url', () => {
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://' })).toBeNull();
|
||||
expect(connectionConfigFromUptermSession({ sessionId: 'tok', host: 'ssh://[' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(redactConnectionSecrets, () => {
|
||||
it('strips the userinfo secret from a connection string', () => {
|
||||
expect(
|
||||
@@ -257,11 +213,7 @@ describe(startUptermHostAsync, () => {
|
||||
relayServerUrl: 'wss://relay.expo.dev',
|
||||
});
|
||||
|
||||
expect(host.connectionConfig).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(host.isAlive()).toBe(true);
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('upterm', ['version'], {
|
||||
@@ -369,6 +321,21 @@ describe(startUptermHostAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps polling until upterm reports a session id', async () => {
|
||||
sessionStdout = JSON.stringify({ host: 'ssh://relay.expo.dev:22', clientCount: 0 });
|
||||
mockedSleep.mockImplementationOnce(async () => {
|
||||
sessionStdout = JSON.stringify({
|
||||
sessionId: 'TOKENy',
|
||||
host: 'ssh://relay.expo.dev:22',
|
||||
clientCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
|
||||
expect(host.connectionConfig).toEqual({ type: 'upterm-v1', secret: 'TOKENy' });
|
||||
});
|
||||
|
||||
it('tears down and rethrows when the host never registers with the relay', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(0);
|
||||
@@ -420,11 +387,7 @@ describe(startUptermHostAsync, () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
const config = await host.redialAsync();
|
||||
|
||||
expect(config).toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
expect(config).toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(host.connectionConfig).toEqual(config);
|
||||
});
|
||||
|
||||
@@ -462,11 +425,7 @@ describe(startUptermHostAsync, () => {
|
||||
const host = await startUptermHostAsync(makeCtx(), { relayServerUrl: 'wss://r' });
|
||||
hostProcess.catch = () => new Promise<void>(() => {});
|
||||
|
||||
await expect(host.redialAsync()).resolves.toEqual({
|
||||
type: 'upterm-v1',
|
||||
host: 'relay.expo.dev',
|
||||
secret: 'TOKENx',
|
||||
});
|
||||
await expect(host.redialAsync()).resolves.toEqual({ type: 'upterm-v1', secret: 'TOKENx' });
|
||||
expect(mockedSleep).toHaveBeenCalledWith(5_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { waitForWarmSimulatorCleanupAsync } from '../warmSimulatorCleanup';
|
||||
|
||||
const STATE_FILE = '/tmp/expo-simulator-startup.json';
|
||||
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
|
||||
async function writeStateAsync(state: object): Promise<void> {
|
||||
await fs.mkdir('/tmp', { recursive: true });
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify({ bootUuid: 'current-boot', ...state }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: 'current-boot\n' } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
});
|
||||
|
||||
it('continues at once on a VM without warmup', async () => {
|
||||
await expect(waitForWarmSimulatorCleanupAsync({})).resolves.toBeUndefined();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a previous boot cleanup record and reads the current boot only once', async () => {
|
||||
await writeStateAsync({ status: 'disabled', bootUuid: 'old-boot' });
|
||||
let finished = false;
|
||||
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'disabled' });
|
||||
await waiting;
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not accept a cleanup record without a boot ID', async () => {
|
||||
await writeStateAsync({ status: 'disabled', bootUuid: undefined });
|
||||
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
|
||||
'Warm Simulator cleanup did not finish'
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['failed', 'empty'])('fails closed when the current boot ID is %s', async mode => {
|
||||
await writeStateAsync({ status: 'disabled' });
|
||||
if (mode === 'failed') {
|
||||
jest.mocked(spawn).mockRejectedValueOnce(new Error('sysctl failed'));
|
||||
} else {
|
||||
jest.mocked(spawn).mockResolvedValueOnce({ stdout: '' } as never);
|
||||
}
|
||||
await expect(waitForWarmSimulatorCleanupAsync({})).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('waits for cleanup when warmup is installed but has not written state yet', async () => {
|
||||
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
|
||||
await fs.writeFile(STARTUP_SCRIPT, '');
|
||||
let finished = false;
|
||||
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'starting' });
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'disabled' });
|
||||
await waiting;
|
||||
});
|
||||
|
||||
it('times out when installed warmup never writes state', async () => {
|
||||
await fs.mkdir('/usr/local/share/vm-tools', { recursive: true });
|
||||
await fs.writeFile(STARTUP_SCRIPT, '');
|
||||
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
|
||||
'Warm Simulator cleanup did not finish; the Simulator cannot be used.'
|
||||
);
|
||||
});
|
||||
|
||||
it('waits until the launcher records that cleanup finished', async () => {
|
||||
await writeStateAsync({ status: 'ready' });
|
||||
let finished = false;
|
||||
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5 }).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'stopping' });
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
expect(finished).toBe(false);
|
||||
await writeStateAsync({ status: 'disabled' });
|
||||
await waiting;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
it('fails with the recorded error when cleanup does not finish in time', async () => {
|
||||
await writeStateAsync({ status: 'stopping', error: 'serve-sim did not exit' });
|
||||
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 20, pollMs: 5 })).rejects.toThrow(
|
||||
'Warm Simulator cleanup did not finish (serve-sim did not exit); the Simulator cannot be used.'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops waiting when the job is cancelled', async () => {
|
||||
await writeStateAsync({ status: 'stopping' });
|
||||
const controller = new AbortController();
|
||||
const waiting = waitForWarmSimulatorCleanupAsync({}, { pollMs: 5, signal: controller.signal });
|
||||
controller.abort(new Error('cancelled'));
|
||||
await expect(waiting).rejects.toThrow('cancelled');
|
||||
});
|
||||
|
||||
it('does not wait for a job that uses the warm Simulator', async () => {
|
||||
await writeStateAsync({ status: 'claimed' });
|
||||
await expect(
|
||||
waitForWarmSimulatorCleanupAsync({ EAS_USE_WARM_SIMULATOR: '1' }, { timeoutMs: 0 })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing outside macOS', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
await writeStateAsync({ status: 'ready' });
|
||||
await expect(waitForWarmSimulatorCleanupAsync({}, { timeoutMs: 0 })).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -37,11 +37,9 @@ const CONNECTION_STARTUP_TIMEOUT_MS = 60_000;
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000;
|
||||
const CLIENT_COUNT_READ_ATTEMPTS = 4;
|
||||
const CLIENT_COUNT_READ_RETRY_MS = 500;
|
||||
const DEFAULT_SSH_PORT = '22';
|
||||
|
||||
export type SshConnectionConfig = {
|
||||
type: 'upterm-v1';
|
||||
host: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
@@ -55,30 +53,10 @@ export type UptermHost = {
|
||||
|
||||
const UptermSessionJsonZ = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
host: z.string().min(1),
|
||||
clientCount: z.number().optional(),
|
||||
});
|
||||
type UptermSessionJson = z.infer<typeof UptermSessionJsonZ>;
|
||||
|
||||
export function connectionConfigFromUptermSession(
|
||||
parsed: Pick<UptermSessionJson, 'sessionId' | 'host'>
|
||||
): SshConnectionConfig | null {
|
||||
let host = parsed.host;
|
||||
if (host.includes('://')) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(host);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
host = url.port && url.port !== DEFAULT_SSH_PORT ? `${url.hostname}:${url.port}` : url.hostname;
|
||||
}
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
return { type: 'upterm-v1', host, secret: parsed.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip upterm session secrets out of text before it reaches a log or an error message. upterm
|
||||
* prints the session id as the userinfo of the connect line it advertises
|
||||
@@ -196,10 +174,7 @@ async function waitForConnectionConfigAsync(
|
||||
if (adminSocketPath) {
|
||||
const session = await readCurrentSessionJsonAsync(uptermPath, adminSocketPath);
|
||||
if (session) {
|
||||
const connectionConfig = connectionConfigFromUptermSession(session);
|
||||
if (connectionConfig) {
|
||||
return connectionConfig;
|
||||
}
|
||||
return { type: 'upterm-v1', secret: session.sessionId };
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { existsSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
|
||||
const LAUNCHER_STATE_FILE = '/tmp/expo-simulator-startup.json';
|
||||
const STARTUP_SCRIPT = '/usr/local/share/vm-tools/startup.sh';
|
||||
|
||||
/**
|
||||
* The worker stops warmup in the background for a job that does not use the warm Simulator. Wait
|
||||
* here, before the job changes a Simulator, until the launcher records that its cleanup finished.
|
||||
*/
|
||||
export async function waitForWarmSimulatorCleanupAsync(
|
||||
env: NodeJS.ProcessEnv,
|
||||
{
|
||||
timeoutMs = 150_000,
|
||||
pollMs = 250,
|
||||
signal,
|
||||
}: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal } = {}
|
||||
): Promise<void> {
|
||||
if (process.platform !== 'darwin' || env.EAS_USE_WARM_SIMULATOR === '1') {
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let bootUuid: string | undefined;
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
let state: { status?: unknown; error?: unknown; bootUuid?: unknown };
|
||||
try {
|
||||
state = JSON.parse(await fs.readFile(LAUNCHER_STATE_FILE, 'utf8'));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT' && !existsSync(STARTUP_SCRIPT)) {
|
||||
return;
|
||||
}
|
||||
// Warmup may still be loading; only disabled confirms cleanup on an enabled VM.
|
||||
state = {};
|
||||
}
|
||||
if (state.status === 'disabled' && typeof state.bootUuid === 'string') {
|
||||
bootUuid ??= (
|
||||
await spawn('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], {
|
||||
signal,
|
||||
timeout: Math.max(1, deadline - Date.now()),
|
||||
})
|
||||
).stdout.trim();
|
||||
if (!bootUuid) {
|
||||
throw new SystemError('Could not read the current VM boot ID.');
|
||||
}
|
||||
if (state.bootUuid === bootUuid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new SystemError(
|
||||
`Warm Simulator cleanup did not finish${typeof state.error === 'string' ? ` (${state.error})` : ''}; the Simulator cannot be used.`
|
||||
);
|
||||
}
|
||||
// The signal is checked at each poll, so a cancelled job stops within one interval.
|
||||
await sleepAsync(pollMs);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "27.2.0"
|
||||
"@expo/steps": "27.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add optional Observe error context to workflow jobs. ([#4787](https://github.com/expo/eas-cli/pull/4787) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.2.0",
|
||||
"@expo/turtle-spawn": "27.3.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ArchiveSourceType, BuildTrigger, StaticWorkflowInterpolationContextZ } from '../common';
|
||||
import { Generic } from '../generic';
|
||||
|
||||
function createContext() {
|
||||
return {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'Investigate error',
|
||||
filename: 'observe.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: { id: '123e4567-e89b-42d3-a456-426614174000', slug: 'example' },
|
||||
account: { id: 'account-id', name: 'example' },
|
||||
observe: {
|
||||
event: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174001',
|
||||
type: 'new_error' as const,
|
||||
occurredAt: '2026-10-29T12:00:00.000Z',
|
||||
appId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
fingerprint: { value: 'error-fingerprint', version: 'v1' },
|
||||
severity: 'fatal' as const,
|
||||
environment: 'production',
|
||||
platform: 'ios' as const,
|
||||
deviceOs: 'iOS',
|
||||
exception: { type: 'Error', message: 'Example error' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Observe workflow context', () => {
|
||||
it('preserves the event when validating a generic job', () => {
|
||||
const context = createContext();
|
||||
const job = {
|
||||
projectArchive: { type: ArchiveSourceType.GIT, gitCommitHash: 'abc123', gitRef: null },
|
||||
steps: [{ run: 'echo "${{ observe.event.exception.message }}"', shell: 'sh' }],
|
||||
secrets: { robotAccessToken: 'token', environmentSecrets: [] },
|
||||
expoDevUrl: context.workflow.url,
|
||||
builderEnvironment: { image: 'ubuntu', env: {} },
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
appId: context.app.id,
|
||||
initiatingUserId: '123e4567-e89b-42d3-a456-426614174002',
|
||||
workflowInterpolationContext: context,
|
||||
};
|
||||
|
||||
const parsed = Generic.JobZ.parse(job);
|
||||
|
||||
expect(parsed.workflowInterpolationContext).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts nullable event metadata and an omitted device OS', () => {
|
||||
const context = createContext();
|
||||
const nullableContext = {
|
||||
...context,
|
||||
observe: {
|
||||
event: {
|
||||
...context.observe.event,
|
||||
fingerprint: { value: 'error-fingerprint', version: null },
|
||||
severity: 'error',
|
||||
environment: null,
|
||||
platform: null,
|
||||
deviceOs: undefined,
|
||||
exception: { type: null, message: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(nullableContext)).toEqual(nullableContext);
|
||||
});
|
||||
|
||||
it('preserves additional event fields from a newer producer', () => {
|
||||
const context = createContext();
|
||||
const extendedContext = {
|
||||
...context,
|
||||
observe: {
|
||||
...context.observe,
|
||||
event: {
|
||||
...context.observe.event,
|
||||
additionalMetadata: 'future value',
|
||||
exception: { ...context.observe.event.exception, stacktrace: 'future stack trace' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(extendedContext)).toEqual(extendedContext);
|
||||
});
|
||||
|
||||
it('rejects an Observe context without an event', () => {
|
||||
expect(
|
||||
StaticWorkflowInterpolationContextZ.safeParse({ ...createContext(), observe: {} }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -344,6 +344,24 @@ const SentryContextZ = z.looseObject({
|
||||
resolution_type: z.string().optional(),
|
||||
});
|
||||
|
||||
const ObserveContextZ = z.looseObject({
|
||||
event: z.looseObject({
|
||||
id: z.uuid(),
|
||||
type: z.literal('new_error'),
|
||||
occurredAt: z.iso.datetime(),
|
||||
appId: z.uuid(),
|
||||
fingerprint: z.looseObject({
|
||||
value: z.string().min(1),
|
||||
version: z.string().min(1).nullable(),
|
||||
}),
|
||||
severity: z.enum(['fatal', 'error']),
|
||||
environment: z.string().nullable(),
|
||||
platform: z.enum(['ios', 'android']).nullable(),
|
||||
deviceOs: z.string().optional(),
|
||||
exception: z.looseObject({ type: z.string().nullable(), message: z.string().nullable() }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
after: z.record(
|
||||
z.string(),
|
||||
@@ -381,6 +399,7 @@ export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
app_store_connect: AppStoreConnectContextZ.optional(),
|
||||
// We need to .optional() to support jobs that are not triggered by a Sentry event.
|
||||
sentry: SentryContextZ.optional(),
|
||||
observe: ObserveContextZ.optional(),
|
||||
});
|
||||
|
||||
export type StaticWorkflowInterpolationContext = z.infer<
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Bump `@expo/apple-utils` to `2.3.0` to fix iOS capability sync when App Store Connect rejects enabling, disabling or changing a capability. ([#4790](https://github.com/expo/eas-cli/pull/4790) by [@douglowder](https://github.com/douglowder))
|
||||
- Keep already linked App Groups, Merchant IDs and iCloud containers when capability sync links another identifier. ([#4584](https://github.com/expo/eas-cli/pull/4584) by [@ceydaakin](https://github.com/ceydaakin))
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+146
-146
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -44,16 +44,16 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.2.1",
|
||||
"@expo/apple-utils": "2.3.0",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/eas-json": "27.2.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/eas-json": "27.3.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.2.0",
|
||||
"@expo/steps": "27.3.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -187,6 +187,69 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`keeps already linked capability identifiers when linking another one`, async () => {
|
||||
const Apple = require('@expo/apple-utils');
|
||||
mockCapabilities(Apple);
|
||||
const bundleId = {
|
||||
context: {},
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
|
||||
'com.apple.security.application-groups': ['group.expo', 'group.bacon'],
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
created: ['group.bacon'],
|
||||
linked: ['group.bacon'],
|
||||
});
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenLastCalledWith([
|
||||
{
|
||||
capabilityType: 'APP_GROUPS',
|
||||
option: 'ON',
|
||||
relationships: {
|
||||
appGroups: ['XXX-group-1', 'XXX-group-2'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it(`keeps already linked capability identifiers when linking an existing unlinked one`, async () => {
|
||||
const Apple = require('@expo/apple-utils');
|
||||
mockCapabilities(Apple);
|
||||
Apple.AppGroup.getAsync.mockResolvedValue([
|
||||
mockItems.appGroup.expo,
|
||||
{ id: 'XXX-group-2', attributes: { identifier: 'group.bacon' } },
|
||||
]);
|
||||
const bundleId = {
|
||||
context: {},
|
||||
updateBundleIdCapabilityAsync: jest.fn(),
|
||||
id: 'XXX',
|
||||
} as any;
|
||||
|
||||
const result = await syncCapabilityIdentifiersForEntitlementsAsync(bundleId, {
|
||||
'com.apple.security.application-groups': ['group.expo', 'group.bacon'],
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
created: [],
|
||||
linked: ['group.bacon'],
|
||||
});
|
||||
expect(Apple.AppGroup.createAsync).not.toHaveBeenCalled();
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenCalledTimes(1);
|
||||
expect(bundleId.updateBundleIdCapabilityAsync).toHaveBeenLastCalledWith([
|
||||
{
|
||||
capabilityType: 'APP_GROUPS',
|
||||
option: 'ON',
|
||||
relationships: {
|
||||
appGroups: ['XXX-group-1', 'XXX-group-2'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it(`doesn't perform duplicate CapabilityModel.getAsync calls`, async () => {
|
||||
const Apple = require('@expo/apple-utils');
|
||||
mockCapabilities(Apple);
|
||||
|
||||
@@ -94,7 +94,8 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
(remoteLinkedIds?.[CapabilityModel.type] ?? []).map(model => model.id)
|
||||
);
|
||||
|
||||
// A list of server IDs for linking.
|
||||
// A list of server IDs for linking. The update replaces the linked identifiers,
|
||||
// so this includes the ones that are already linked.
|
||||
const capabilityIdOpaqueIds: string[] = [];
|
||||
|
||||
// Iterate through all the local IDs and see if they exist on the server.
|
||||
@@ -106,8 +107,8 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
if (!alreadyLinkedOpaqueIds.has(remoteIdModel.id)) {
|
||||
// Link the existing identifier to this bundle.
|
||||
linkedIds.push(remoteIdModel.attributes.identifier);
|
||||
capabilityIdOpaqueIds.push(remoteIdModel.id);
|
||||
}
|
||||
capabilityIdOpaqueIds.push(remoteIdModel.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -133,7 +134,9 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
capabilityIdOpaqueIds.push(remoteIdModel.id);
|
||||
}
|
||||
|
||||
if (capabilityIdOpaqueIds.length) {
|
||||
// The update replaces the linked identifiers, so it is sent only when one of them is not
|
||||
// linked yet. An identifier removed from the entitlements therefore stays linked.
|
||||
if (capabilityIdOpaqueIds.some(id => !alreadyLinkedOpaqueIds.has(id))) {
|
||||
updateRequest.push({
|
||||
capabilityType: classifier.capability,
|
||||
option: CapabilityTypeOption.ON,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "27.2.0",
|
||||
"@expo/build-tools": "27.3.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "27.2.0",
|
||||
"@expo/turtle-spawn": "27.3.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/logger
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/simulator-launcher",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"description": "Launch and claim Simulator sessions on EAS workers",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/steps
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,8 +27,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/vm-tools
|
||||
|
||||
## 27.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/vm-tools",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"description": "VM startup script loader",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @expo/worker
|
||||
|
||||
## 27.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Mask secrets in every record the worker process writes to its stdout: the build log records and the worker's own records. ([#4782](https://github.com/expo/eas-cli/pull/4782) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Stop warmup in the background for jobs that do not use the warm Simulator, so job setup does not wait for it. Before build-tools boots or clones a Simulator, it waits until the launcher's state file records that cleanup finished, and fails if cleanup does not finish in time. ([#4721](https://github.com/expo/eas-cli/pull/4721) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "27.2.0",
|
||||
"version": "27.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "27.2.0",
|
||||
"@expo/build-tools": "27.3.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/eas-build-job": "27.3.0",
|
||||
"@expo/logger": "27.3.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.2.0",
|
||||
"@expo/template-file": "27.2.0",
|
||||
"@expo/turtle-spawn": "27.2.0",
|
||||
"@expo/steps": "27.3.0",
|
||||
"@expo/template-file": "27.3.0",
|
||||
"@expo/turtle-spawn": "27.3.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -4,9 +4,11 @@ import { ManagedArtifactType, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import config from '../config';
|
||||
import { createBuildContext } from '../context';
|
||||
import { getBuildEnv } from '../env';
|
||||
import { maybeStopWarmSimulatorAsync } from '../warmSimulator';
|
||||
import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment';
|
||||
import { uploadSourceMapAsync, uploadWorkflowArtifactAsync } from '../upload';
|
||||
|
||||
jest.mock('../warmSimulator');
|
||||
jest.mock('../env', () => ({
|
||||
getBuildEnv: jest.fn(() => ({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
@@ -44,6 +46,8 @@ describe(createBuildContext.name, () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(maybeStopWarmSimulatorAsync).mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(prepareRuntimeEnvironmentConfigFiles).mockReset().mockResolvedValue();
|
||||
jest.spyOn(RuntimeSettings, 'loadAsync').mockResolvedValue();
|
||||
});
|
||||
|
||||
@@ -51,6 +55,70 @@ describe(createBuildContext.name, () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts shutdown after runtime settings without blocking setup or context creation', async () => {
|
||||
let finishStop!: () => void;
|
||||
let failStop!: (error: Error) => void;
|
||||
const stopping = new Promise<void>((resolve, reject) => {
|
||||
finishStop = resolve;
|
||||
failStop = reject;
|
||||
});
|
||||
jest.mocked(maybeStopWarmSimulatorAsync).mockReturnValue(stopping);
|
||||
const job = { platform: Platform.IOS } as any;
|
||||
let created = false;
|
||||
const creating = createBuildContext({ ...baseOptions, job }).then(ctx => {
|
||||
created = true;
|
||||
return ctx;
|
||||
});
|
||||
try {
|
||||
for (let turn = 0; turn < 20 && !created; turn++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(created).toBe(true);
|
||||
expect(maybeStopWarmSimulatorAsync).toHaveBeenCalledWith(job);
|
||||
expect(jest.mocked(RuntimeSettings.loadAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(maybeStopWarmSimulatorAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(prepareRuntimeEnvironmentConfigFiles).toHaveBeenCalledTimes(1);
|
||||
const error = new Error('Late Simulator shutdown failure');
|
||||
failStop(error);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
'Could not stop warm Simulator services.'
|
||||
);
|
||||
} finally {
|
||||
finishStop();
|
||||
await creating;
|
||||
}
|
||||
});
|
||||
|
||||
it('warns and continues setup when shutdown rejects immediately', async () => {
|
||||
const error = new Error('Simulator stop failed');
|
||||
jest.mocked(maybeStopWarmSimulatorAsync).mockRejectedValue(error);
|
||||
await createBuildContext({ ...baseOptions, job: { platform: Platform.IOS } as any });
|
||||
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||
{ err: error },
|
||||
'Could not stop warm Simulator services.'
|
||||
);
|
||||
expect(prepareRuntimeEnvironmentConfigFiles).toHaveBeenCalledTimes(1);
|
||||
expect(getBuildEnv).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves an unrelated setup failure even if background shutdown also fails', async () => {
|
||||
const stopError = new Error('Simulator stop failed');
|
||||
const setupError = new Error('Could not prepare runtime config');
|
||||
jest.mocked(maybeStopWarmSimulatorAsync).mockRejectedValue(stopError);
|
||||
jest.mocked(prepareRuntimeEnvironmentConfigFiles).mockRejectedValueOnce(setupError);
|
||||
await expect(
|
||||
createBuildContext({ ...baseOptions, job: { platform: Platform.IOS } as any })
|
||||
).rejects.toBe(setupError);
|
||||
expect(childLogger.warn).toHaveBeenCalledWith(
|
||||
{ err: stopError },
|
||||
'Could not stop warm Simulator services.'
|
||||
);
|
||||
expect(getBuildEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads runtime settings with the job environment before creating build env', async () => {
|
||||
const env = { EAS_USE_NPM_CACHE: '1' };
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TransformCallback, Writable } from 'node:stream';
|
||||
import { EnvironmentSecretType } from '@expo/eas-build-job';
|
||||
import { LoggerLevel } from '@expo/logger';
|
||||
import z from 'zod';
|
||||
|
||||
jest.mock('@expo/build-tools', () => {
|
||||
@@ -8,14 +9,12 @@ jest.mock('@expo/build-tools', () => {
|
||||
|
||||
class MockRemoteLoggerStream extends Writable {
|
||||
public static readonly CompressionMethod = actual.RemoteLoggerStream.CompressionMethod;
|
||||
public static readonly instances: MockRemoteLoggerStream[] = [];
|
||||
|
||||
public readonly writes: any[] = [];
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super({ objectMode: true });
|
||||
void args;
|
||||
MockRemoteLoggerStream.instances.push(this);
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {}
|
||||
@@ -62,41 +61,37 @@ jest.mock('@expo/build-tools', () => {
|
||||
};
|
||||
});
|
||||
|
||||
import config from '../config';
|
||||
import { createBuildLoggerWithSecretsFilter } from '../logger';
|
||||
type BuildToolsMock = {
|
||||
HttpLogStream: { instances: Array<{ config: Record<string, unknown>; writes: any[] }> };
|
||||
};
|
||||
|
||||
let config: typeof import('../config').default;
|
||||
let defaultLogger: typeof import('../logger').default;
|
||||
let createBuildLoggerWithSecretsFilter: typeof import('../logger').createBuildLoggerWithSecretsFilter;
|
||||
let HttpLogStream: BuildToolsMock['HttpLogStream'];
|
||||
|
||||
async function waitForStreamFlush(): Promise<void> {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
|
||||
const { HttpLogStream, RemoteLoggerStream } = jest.requireMock('@expo/build-tools') as {
|
||||
HttpLogStream: {
|
||||
instances: Array<{
|
||||
config: Record<string, unknown>;
|
||||
writes: any[];
|
||||
}>;
|
||||
};
|
||||
RemoteLoggerStream: {
|
||||
instances: Array<{
|
||||
writes: any[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
describe('logger', () => {
|
||||
const originalHttpBaseUrl = config.loggers.http.baseUrl;
|
||||
const originalBuildId = config.buildId;
|
||||
const originalGcsSignedUploadUrlForLogs = config.loggers.gcs.signedUploadUrlForLogs;
|
||||
let stdoutRecords: any[];
|
||||
let stdoutWrite: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
HttpLogStream.instances.length = 0;
|
||||
RemoteLoggerStream.instances.length = 0;
|
||||
jest.resetModules();
|
||||
config = require('../config').default;
|
||||
({ default: defaultLogger, createBuildLoggerWithSecretsFilter } = require('../logger'));
|
||||
({ HttpLogStream } = jest.requireMock('@expo/build-tools'));
|
||||
stdoutRecords = [];
|
||||
stdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation(chunk => {
|
||||
stdoutRecords.push(JSON.parse(String(chunk)));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.loggers.http.baseUrl = originalHttpBaseUrl;
|
||||
config.buildId = originalBuildId;
|
||||
config.loggers.gcs.signedUploadUrlForLogs = originalGcsSignedUploadUrlForLogs;
|
||||
stdoutWrite.mockRestore();
|
||||
});
|
||||
|
||||
it('obfuscates secrets in logs', async () => {
|
||||
@@ -183,6 +178,89 @@ describe('logger', () => {
|
||||
expect(logs.map(log => log.msg)).toEqual(['token: ***************, prefix: ***']);
|
||||
});
|
||||
|
||||
it('masks command output before writing it to process stdout', async () => {
|
||||
const { logger, addBuildLogMask } = await createBuildLoggerWithSecretsFilter({});
|
||||
addBuildLogMask('leased-token');
|
||||
|
||||
logger.child({ source: 'stdout' }).info('token: leased-token');
|
||||
await waitForStreamFlush();
|
||||
|
||||
expect(stdoutRecords).toMatchObject([{ source: 'stdout', msg: 'token: ************' }]);
|
||||
});
|
||||
|
||||
it('keeps the other streams flowing while stdout is not drained', async () => {
|
||||
stdoutWrite.mockImplementation(() => false);
|
||||
const { logger, outputStream } = await createBuildLoggerWithSecretsFilter({});
|
||||
const logs: any[] = [];
|
||||
outputStream.pipe(
|
||||
new Writable({
|
||||
objectMode: true,
|
||||
write(chunk: any, _encoding: BufferEncoding, callback: TransformCallback) {
|
||||
logs.push(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const lineCount = 2 * outputStream.readableHighWaterMark;
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
logger.info(`line ${i}`);
|
||||
}
|
||||
await waitForStreamFlush();
|
||||
|
||||
expect(logs).toHaveLength(lineCount);
|
||||
});
|
||||
|
||||
it("masks the worker's own records on stdout", async () => {
|
||||
const { addBuildLogMask } = await createBuildLoggerWithSecretsFilter({});
|
||||
addBuildLogMask('leased-token');
|
||||
|
||||
defaultLogger.error({ err: new Error('request with leased-token failed') }, 'Job failed');
|
||||
await waitForStreamFlush();
|
||||
|
||||
expect(stdoutRecords).toMatchObject([
|
||||
{ msg: 'Job failed', err: { message: 'request with ************ failed' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes a circular field as unserializable when no secret is registered and keeps going', async () => {
|
||||
const { logger } = await createBuildLoggerWithSecretsFilter({});
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
|
||||
logger.info({ circular }, 'first');
|
||||
logger.info('second');
|
||||
await waitForStreamFlush();
|
||||
|
||||
expect(stdoutRecords).toMatchObject([
|
||||
{ msg: 'first', circular: '[Unserializable]' },
|
||||
{ msg: 'second' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a deeply nested value when no secret is registered', async () => {
|
||||
const { logger } = await createBuildLoggerWithSecretsFilter({});
|
||||
let nested: any = { leaf: 'kept' };
|
||||
for (let index = 0; index < 30; index++) {
|
||||
nested = { nested };
|
||||
}
|
||||
|
||||
logger.info({ nested }, 'deep');
|
||||
await waitForStreamFlush();
|
||||
|
||||
expect(JSON.stringify(stdoutRecords[0].nested)).toContain('"leaf":"kept"');
|
||||
});
|
||||
|
||||
it("copies the default logger's level when the build logger is created", async () => {
|
||||
defaultLogger.level(LoggerLevel.DEBUG);
|
||||
const { logger } = await createBuildLoggerWithSecretsFilter({});
|
||||
|
||||
logger.debug('debug line');
|
||||
await waitForStreamFlush();
|
||||
|
||||
expect(stdoutRecords).toMatchObject([{ msg: 'debug line' }]);
|
||||
});
|
||||
|
||||
describe('structured fields', () => {
|
||||
async function logWithSecretAsync(
|
||||
log: (
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { type Job } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { maybeStopWarmSimulatorAsync } from '../warmSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node:fs/promises', () => ({ access: jest.fn(), readFile: jest.fn() }));
|
||||
|
||||
const baseJob = {} as Job;
|
||||
const originalPlatform = process.platform;
|
||||
const startup = '/usr/local/share/vm-tools/startup.sh';
|
||||
const state = '/tmp/expo-simulator-startup.json';
|
||||
let environment: NodeJS.ProcessEnv;
|
||||
beforeEach(() => {
|
||||
environment = { ...process.env };
|
||||
delete process.env.EAS_OVERRIDE_PACKAGE_MANAGER;
|
||||
delete process.env.EAS_FALLBACK_PACKAGE_MANAGER;
|
||||
jest.mocked(fs.access).mockReset().mockResolvedValue();
|
||||
jest
|
||||
.mocked(fs.readFile)
|
||||
.mockReset()
|
||||
.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
bootUuid: 'current-boot',
|
||||
status: 'disabled',
|
||||
launcherVersion: '25.3.0',
|
||||
})
|
||||
);
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ stdout: '' } as Awaited<ReturnType<typeof spawn>>);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env = environment;
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('delegates state and boot handling to one launcher stop command', async () => {
|
||||
await maybeStopWarmSimulatorAsync({} as Job);
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith('bun', ['x', '@expo/simulator-launcher@latest', 'stop'], {
|
||||
env: expect.objectContaining({ PATH: process.env.PATH }),
|
||||
stdio: 'pipe',
|
||||
timeout: 150_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for the launcher to confirm cleanup', async () => {
|
||||
let finish!: (value: Awaited<ReturnType<typeof spawn>>) => void;
|
||||
jest.mocked(spawn).mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
finish = resolve;
|
||||
}) as ReturnType<typeof spawn>
|
||||
);
|
||||
let finished = false;
|
||||
const stopping = maybeStopWarmSimulatorAsync({} as Job).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
for (let turn = 0; turn < 20 && !finish; turn++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(finish).toBeDefined();
|
||||
expect(finished).toBe(false);
|
||||
finish({ stdout: '' } as Awaited<ReturnType<typeof spawn>>);
|
||||
await stopping;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['cleanup failed', "Could not find an existing 'simulator-launcher' binary --no-install"])(
|
||||
'propagates stop failure without retrying: %s',
|
||||
async stderr => {
|
||||
const error = Object.assign(new Error(stderr), { status: 1, stdout: '', stderr });
|
||||
jest.mocked(spawn).mockRejectedValueOnce(error);
|
||||
await expect(maybeStopWarmSimulatorAsync({} as Job)).rejects.toBe(error);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('does nothing on non-Darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
await maybeStopWarmSimulatorAsync({} as Job);
|
||||
expect(fs.access).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps warmup only for the job opt-in, independently of the host flag', async () => {
|
||||
await maybeStopWarmSimulatorAsync({
|
||||
...baseJob,
|
||||
builderEnvironment: { image: 'test-image', env: { EAS_USE_WARM_SIMULATOR: '1' } },
|
||||
});
|
||||
expect(fs.access).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
process.env.EAS_USE_WARM_SIMULATOR = '1';
|
||||
await maybeStopWarmSimulatorAsync({} as Job);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([startup, state])('stops when the image has only %s', async present => {
|
||||
jest.mocked(fs.access).mockImplementation(async file => {
|
||||
if (file !== present) {
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
|
||||
}
|
||||
});
|
||||
await maybeStopWarmSimulatorAsync({} as Job);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('makes no package-manager call on an image with neither warmup file', async () => {
|
||||
jest.mocked(fs.access).mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
|
||||
await maybeStopWarmSimulatorAsync({} as Job);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([startup, state])('fails closed if checking %s fails', async inaccessible => {
|
||||
const error = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
jest.mocked(fs.access).mockImplementation(async file => {
|
||||
if (file === inaccessible) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
await expect(maybeStopWarmSimulatorAsync({} as Job)).rejects.toBe(error);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
it('preserves the job package-manager override and environment', async () => {
|
||||
process.env.EAS_OVERRIDE_PACKAGE_MANAGER = 'bun';
|
||||
await maybeStopWarmSimulatorAsync({
|
||||
...baseJob,
|
||||
builderEnvironment: { image: 'test-image', env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' } },
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith('npx', ['--yes', '@expo/simulator-launcher@latest', 'stop'], {
|
||||
env: expect.objectContaining({ EAS_OVERRIDE_PACKAGE_MANAGER: 'npm', PATH: process.env.PATH }),
|
||||
stdio: 'pipe',
|
||||
timeout: 150_000,
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import config from './config';
|
||||
import { getBuildEnv } from './env';
|
||||
import { Analytics } from './external/analytics';
|
||||
import { uploadXcodeBuildLogs } from './ios/xcodeLogs';
|
||||
import { maybeStopWarmSimulatorAsync } from './warmSimulator';
|
||||
import { prepareRuntimeEnvironmentConfigFiles } from './runtimeEnvironment';
|
||||
import sentry from './sentry';
|
||||
import {
|
||||
@@ -42,6 +43,10 @@ export async function createBuildContext<TJob extends Job>({
|
||||
environment: config.env,
|
||||
env: job.builderEnvironment?.env,
|
||||
});
|
||||
// Build-tools waits for the launcher's own cleanup record before it changes a Simulator.
|
||||
void maybeStopWarmSimulatorAsync(job).catch(err => {
|
||||
childLogger.warn({ err }, 'Could not stop warm Simulator services.');
|
||||
});
|
||||
await prepareRuntimeEnvironmentConfigFiles();
|
||||
const env = getBuildEnv({ job, projectId, metadata, buildId });
|
||||
|
||||
|
||||
@@ -7,11 +7,6 @@ import config from './config';
|
||||
import { maybeStringBase64Decode, simpleSecretsWhitelist } from './secrets';
|
||||
import { uuidv7 } from 'uuidv7';
|
||||
|
||||
const defaultLogger = createLogger({
|
||||
name: config.loggers.base.name,
|
||||
level: LoggerLevel.INFO,
|
||||
});
|
||||
|
||||
const MAX_LINES_IN_BUFFER = 100;
|
||||
|
||||
interface CachedLog {
|
||||
@@ -90,10 +85,7 @@ function createTransformStream(secretPatterns: ReadonlySet<string>): Transform {
|
||||
const chunk: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
// Secrets can be in any field, such as a serialized error's message and stack.
|
||||
chunk[key] =
|
||||
secretPatterns.size === 0 || UNMASKED_FIELDS.has(key)
|
||||
? value
|
||||
: maskSecretsInValue(value, secretPatterns);
|
||||
chunk[key] = UNMASKED_FIELDS.has(key) ? value : maskSecretsInValue(value, secretPatterns);
|
||||
}
|
||||
// Add logId to each log.
|
||||
chunk.logId = uuidv7();
|
||||
@@ -127,7 +119,7 @@ function maskSecretsInValue(value: unknown, secrets: ReadonlySet<string>): unkno
|
||||
} catch {
|
||||
return '[Unserializable]';
|
||||
}
|
||||
return maskSecretsInJsonValue(json, secrets, 0);
|
||||
return secrets.size === 0 ? json : maskSecretsInJsonValue(json, secrets, 0);
|
||||
}
|
||||
|
||||
function maskSecretsInJsonValue(
|
||||
@@ -158,15 +150,38 @@ function maskSecretsInJsonValue(
|
||||
);
|
||||
}
|
||||
|
||||
function createDiscardStream(): Writable {
|
||||
function createStdoutStream(): Writable {
|
||||
return new Writable({
|
||||
objectMode: true,
|
||||
write(_chunk: any, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
|
||||
write(chunk: any, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
|
||||
process.stdout.write(`${JSON.stringify(chunk)}\n`);
|
||||
callback(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const secretPatterns = new Set<string>();
|
||||
|
||||
function addBuildLogMask(secret: string): void {
|
||||
for (const pattern of [secret, maybeStringBase64Decode(secret)]) {
|
||||
if (pattern && pattern.length > 1 && !simpleSecretsWhitelist.includes(pattern)) {
|
||||
secretPatterns.add(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createStdoutMaskingStream(): Transform {
|
||||
const transformStream = createTransformStream(secretPatterns);
|
||||
transformStream.pipe(createStdoutStream());
|
||||
return transformStream;
|
||||
}
|
||||
|
||||
const defaultLogger = createLogger({
|
||||
name: config.loggers.base.name,
|
||||
level: LoggerLevel.INFO,
|
||||
streams: [{ type: 'raw', stream: createStdoutMaskingStream(), reemitErrorEvents: true }],
|
||||
});
|
||||
|
||||
export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']): Promise<{
|
||||
logger: bunyan;
|
||||
addBuildLogMask: (secret: string) => void;
|
||||
@@ -174,29 +189,16 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
|
||||
logBuffer: WorkerLogBuffer;
|
||||
outputStream: Readable;
|
||||
}> {
|
||||
const buildLogger = defaultLogger.child({ phase: BuildPhase.UNKNOWN });
|
||||
|
||||
const secretPatterns = new Set<string>();
|
||||
const addBuildLogMask = (secret: string): void => {
|
||||
for (const pattern of [secret, maybeStringBase64Decode(secret)]) {
|
||||
if (pattern && pattern.length > 1 && !simpleSecretsWhitelist.includes(pattern)) {
|
||||
secretPatterns.add(pattern);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const { value } of secrets?.environmentSecrets ?? []) {
|
||||
addBuildLogMask(value);
|
||||
}
|
||||
|
||||
const transformStream = createTransformStream(secretPatterns);
|
||||
const discardStream = createDiscardStream();
|
||||
transformStream.pipe(discardStream);
|
||||
|
||||
buildLogger.addStream({
|
||||
type: 'raw',
|
||||
stream: transformStream,
|
||||
reemitErrorEvents: true,
|
||||
level: buildLogger.level(),
|
||||
const transformStream = createStdoutMaskingStream();
|
||||
const buildLogger = createLogger({
|
||||
name: config.loggers.base.name,
|
||||
level: defaultLogger.level(),
|
||||
phase: BuildPhase.UNKNOWN,
|
||||
streams: [{ type: 'raw', stream: transformStream, reemitErrorEvents: true }],
|
||||
});
|
||||
|
||||
let remoteLoggerStream: RemoteLoggerStream | null = null;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '@expo/build-tools/dist/utils/packageManager';
|
||||
import { type Job } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
/** Request warmup shutdown unless this job opts into warm Simulator adoption. */
|
||||
export async function maybeStopWarmSimulatorAsync(job: Job): Promise<void> {
|
||||
if (
|
||||
process.platform !== 'darwin' ||
|
||||
job.builderEnvironment?.env?.EAS_USE_WARM_SIMULATOR === '1'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const warmupFiles = await Promise.all(
|
||||
['/usr/local/share/vm-tools/startup.sh', '/tmp/expo-simulator-startup.json'].map(file =>
|
||||
fs.access(file).then(
|
||||
() => true,
|
||||
error => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
if (!warmupFiles.some(Boolean)) {
|
||||
return;
|
||||
}
|
||||
const env = { ...process.env, ...job.builderEnvironment?.env };
|
||||
const { command, args } = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.BUN),
|
||||
['@expo/simulator-launcher@latest', 'stop']
|
||||
);
|
||||
await spawn(command, args, { env, stdio: 'pipe', timeout: 150_000 });
|
||||
}
|
||||
@@ -47,7 +47,6 @@ function handleConnection(socket: WebSocket, service: BuildService): void {
|
||||
|
||||
wsServer.onMessage((message: LauncherMessage.Message) => {
|
||||
try {
|
||||
logger.debug({ message }, 'WebSocket message received');
|
||||
const { handled } = handleMessage(service, message) || { handled: false };
|
||||
if (!handled) {
|
||||
throw new Error(
|
||||
|
||||
@@ -2506,33 +2506,33 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/apple-utils@npm:2.2.1":
|
||||
version: 2.2.1
|
||||
resolution: "@expo/apple-utils@npm:2.2.1"
|
||||
"@expo/apple-utils@npm:2.3.0":
|
||||
version: 2.3.0
|
||||
resolution: "@expo/apple-utils@npm:2.3.0"
|
||||
bin:
|
||||
apple-utils: bin.js
|
||||
checksum: 10c0/c23cac8b1b96d42dfc5f7c63a1fe1c0cb19d097b9357fd2f545c14e6cfa98725f0fca1334446b3112e66b6822ffd45ad52cb8eb4bfd29ab9f8e462abf1593cc2
|
||||
checksum: 10c0/150edaa141c220c22e14791bd16f97dd7906f9f6441e3e0475eb6327a89df7e57e67beb911b0edfed72e0daedc6000c1f1e40141d485e284ce7b9a62f5060638
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:27.2.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:27.3.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:27.2.0"
|
||||
"@expo/eas-build-job": "npm:27.2.0"
|
||||
"@expo/downloader": "npm:27.3.0"
|
||||
"@expo/eas-build-job": "npm:27.3.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:27.2.0"
|
||||
"@expo/logger": "npm:27.3.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.3.5"
|
||||
"@expo/repack-app": "npm:~0.9.0"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.2.0"
|
||||
"@expo/template-file": "npm:27.2.0"
|
||||
"@expo/turtle-spawn": "npm:27.2.0"
|
||||
"@expo/steps": "npm:27.3.0"
|
||||
"@expo/template-file": "npm:27.3.0"
|
||||
"@expo/turtle-spawn": "npm:27.3.0"
|
||||
"@expo/xcpretty": "npm:^4.4.7"
|
||||
"@ngrok/ngrok": "npm:1.7.0"
|
||||
"@sentry/node": "npm:7.77.0"
|
||||
@@ -2768,7 +2768,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/downloader@npm:27.2.0, @expo/downloader@workspace:packages/downloader":
|
||||
"@expo/downloader@npm:27.3.0, @expo/downloader@workspace:packages/downloader":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/downloader@workspace:packages/downloader"
|
||||
dependencies:
|
||||
@@ -2784,13 +2784,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:27.2.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:27.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.2.0"
|
||||
"@expo/logger": "npm:27.3.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:27.2.0"
|
||||
"@expo/turtle-spawn": "npm:27.3.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
jest: "npm:^29.7.0"
|
||||
@@ -2803,12 +2803,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:27.2.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:27.3.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:27.2.0"
|
||||
"@expo/eas-build-job": "npm:27.3.0"
|
||||
"@tsconfig/node22": "npm:22.0.6"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2931,7 +2931,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/logger@npm:27.2.0, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:27.3.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
dependencies:
|
||||
@@ -3147,12 +3147,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:27.2.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:27.3.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:27.2.0"
|
||||
"@expo/logger": "npm:27.2.0"
|
||||
"@expo/eas-build-job": "npm:27.3.0"
|
||||
"@expo/logger": "npm:27.3.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
@@ -3178,7 +3178,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/template-file@npm:27.2.0, @expo/template-file@workspace:packages/template-file":
|
||||
"@expo/template-file@npm:27.3.0, @expo/template-file@workspace:packages/template-file":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/template-file@workspace:packages/template-file"
|
||||
dependencies:
|
||||
@@ -3201,11 +3201,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/turtle-spawn@npm:27.2.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
"@expo/turtle-spawn@npm:27.3.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
|
||||
dependencies:
|
||||
"@expo/logger": "npm:27.2.0"
|
||||
"@expo/logger": "npm:27.3.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -3236,17 +3236,17 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:27.2.0"
|
||||
"@expo/build-tools": "npm:27.3.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:27.2.0"
|
||||
"@expo/logger": "npm:27.2.0"
|
||||
"@expo/eas-build-job": "npm:27.3.0"
|
||||
"@expo/logger": "npm:27.3.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.2.0"
|
||||
"@expo/template-file": "npm:27.2.0"
|
||||
"@expo/turtle-spawn": "npm:27.2.0"
|
||||
"@expo/steps": "npm:27.3.0"
|
||||
"@expo/template-file": "npm:27.3.0"
|
||||
"@expo/turtle-spawn": "npm:27.3.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
"@types/babel__core": "npm:7.20.5"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -10130,7 +10130,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:27.2.0"
|
||||
"@expo/steps": "npm:27.3.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:22.20.5"
|
||||
@@ -10714,12 +10714,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:27.2.0"
|
||||
"@expo/build-tools": "npm:27.3.0"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:27.2.0"
|
||||
"@expo/logger": "npm:27.2.0"
|
||||
"@expo/eas-build-job": "npm:27.3.0"
|
||||
"@expo/logger": "npm:27.3.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:27.2.0"
|
||||
"@expo/turtle-spawn": "npm:27.3.0"
|
||||
"@types/fs-extra": "npm:^11.0.4"
|
||||
"@types/hapi__joi": "npm:^17.1.14"
|
||||
"@types/lodash": "npm:^4.17.4"
|
||||
@@ -10769,16 +10769,16 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli@workspace:packages/eas-cli"
|
||||
dependencies:
|
||||
"@expo/apple-utils": "npm:2.2.1"
|
||||
"@expo/apple-utils": "npm:2.3.0"
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:27.2.0"
|
||||
"@expo/eas-json": "npm:27.2.0"
|
||||
"@expo/eas-build-job": "npm:27.3.0"
|
||||
"@expo/eas-json": "npm:27.3.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/experimentation": "npm:0.0.5"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:27.2.0"
|
||||
"@expo/logger": "npm:27.3.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -10790,7 +10790,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:27.2.0"
|
||||
"@expo/steps": "npm:27.3.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user